React – build once, deploy many: vlastnosti dynamické konfigurace

Build once, deploy many je jeden ze zásadních principů vývoje softwaru. Hlavní myšlenka spočívá v tom, že se použije stejná dodávka pro všechna prostředí – od testování až po produkci. Tento přístup nabízí řadu výhod například v podobě snadného nastavení a testování a je považován za základní princip continuous delivery. Je rovněž součástí metodiky twelve-factor. Přestože tento přístup nabízí ohromné možnosti, v oblasti vývoje frontendu se netěší výraznější podpoře. To platí i pro React.

Jistě, v rámci create-react-app můžeme specifikovat různé properties REACT_APP v .env souboru. Ty však musí být definovány již během kompilace. Create-react-app očekává, že se pro každé cílové prostředí udělá nový build aplikace, což porušuje výše uvedený princip. Přesto se zdá, že se v současné době jedná o ten nejběžnější přístup. Co však dělat v případě, kdy musíte nebo chcete dodržet princip build once, deploy many?

Říkáte si, kdy něco takového můžete potřebovat? Typickým příkladem je specifikování apiBaseUrl po dokončení kompilace. Samozřejmě můžete namítnout, že celý problém vyřeší správný DevOps přístup. Ten však není natolik vyspělý, aby si poradil se všemi eventualitami. Navíc vytváření samostatného buildu pro každé prostředí může být považováno za ztrátu času a je potenciálním zdrojem chyb ve zdrojovém kódu.

Pokud chcete vytvořit aplikaci v podobě statického balíčku, do kterého se konfigurace doplní po dokončení buildu, mohou se vám hodit následující řešení. Níže představuji dvě možná řešení pro dynamickou konfiguraci v Reactu a u každého uvádím výhody a nevýhody. Obě řešení se vyznačují typovou bezpečností, obě chrání programátory před neočekávanými chybami a ani jedno z nich nevyžaduje další knihovny.

Moment, počkat – k tomuto účelu už přece musí být existovat nějaká npm knihovna, nebo snad ne? Koneckonců tohle je svět Reactu. Abych byl upřímný, já jsem žádnou nenašel. Když jsem na tento problém narazil poprvé, absence možností a existujících řešení mě dost překvapila. Našel jsem jenom jednu nezodpovězenou otázku na StackOverflow (na kterou snad tento článek přinese odpověď) a tuto knihovnu, která však udělá jen polovinu práce – sice stáhne nastavení z URL, ale zajistit jeho injectování do aplikace už musí sám uživatel.

Pak jsem konečně narazil na tento článek, který mi hodně pomohl. Inspiroval mě k řešením, která zde předkládám.

Tak se na ně pojďme podívat.

Inspirace řešením „načti JavaScript“

Článek výše navrhuje dynamicky načíst skript uložený v public/config.js, který obsahuje definice konfiguračních proměnných.

// public/config.js
// provided by the customer as a resource in the public folder, after compilation

const apiUrl = "http://api.myservice.com";
const env = "TST";

<!-- public/index.html -->

<script src="%PUBLIC_URL%/config.js"></script>
<script>
  window.config = { apiUrl, env };
</script>

 

Toto řešení funguje a navíc se snadno implementuje. Jsou zde však dvě úskalí.

  1. Není typově bezpečné (a ani nemůže být, protože prohlížeče nepodporují TypeScript), na což nás TypeScript důrazně upozorní.
  2. Konfigurační soubor je v JavaScriptu a může obsahovat libovolný kód, který se spustí. V dobře nastaveném projektu by součástí konfigurace nikdy neměl být spustitelný kód. Tento kód je zcela mimo naši kontrolu, což může způsobit řadu problémů.

Pokud vám tato úskalí nevadí, dál číst nemusíte. Pokud však nestojíte o to, aby vaši zákazníci mohli vkládat libovolný spustitelný kód JavaScriptu a chcete mít konfiguraci ve strukturovaném .json souboru bez vedlejších efektů, máte několik možností.

První řešení využívá globální konfigurační objekt. Druhé řešení pak využívá React context. Základní myšlenka je v obou případech stejná: před renderováním aplikace načíst soubor config.json a hodnoty v něm uložené. Až po načtení dat pak proběhne renderování aplikace, za použití hodnot z konfigurace.

První krok je u obou řešení stejný: ve složce public vytvořit soubor config.json a přidat ho do .gitignore. Ten poslouží jako injectovatelná konfigurace.

// public/config.json
{
  "apiUrl": "http://api.myservice.com",
  "environment": "TST"
}

 

Řešení v podobě globálního konfiguračního objektu

Pokud vás nezajímají kroky vedoucí k tomuto řešení, přeskočte rovnou na kapitolu Kompletní Codebase.

Začneme tím, že přidáme nový soubor s globálně přístupnou konfigurací src/configuration/config.ts.

// config.ts
export interface DynamicConfig {
  apiUrl: string;
  environment: "DEV" | "TST" | "AKC" | "PROD";
}

export const defaultConfig: DynamicConfig = {
  apiUrl: "http://localhost:8080/undefinedApiUrl",
  environment: "DEV"
};

class GlobalConfig {
  config: DynamicConfig = defaultConfig;
}

export const globalConfig = new GlobalConfig();

export const globalConfigUrl = "config.json";

 

Nyní se přesuneme do index.tsx a načteme konfiguraci z URL, uložíme ji do globálního konfiguračního objektu a až potom zavoláme React.render – to znamená, že jakýkoli kód Reactu spustíme až poté, co naše konfigurace bude kompletně načtena. V případě, že dojde k chybě, předáme do React.render chybovou zprávu.

// index.tsx:
import axios from "axios";
import React, {ReactElement} from "react";
import App from "./App";
import {globalConfig, globalConfigUrl} from "./configuration/config";

axios.get(globalConfigUrl)
  .then((response) => {
    globalConfig.config = response.data;
    return <App />;
  })
  .catch(e => {
      return <p style={{color: "red", textAlign: "center"}}>Error while fetching global config</p>;
  })
  .then((reactElement: ReactElement) => {
    ReactDOM.render(
      reactElement,
      document.getElementById("root")
    );
  });

 

Pozor, definice globalConfig musí být v samostatném souboru! Nesmí být v souboru index.tsx, jinak vznikne cyklická závislost, která vyvolá následující chybu: ReferenceError: can’t access lexical declaration ‚X‘ before initialization.

Pokud vše uděláte správně, budete moci konfiguraci použít naprosto jednoduše.

// BusinessService.ts
import { globalConfig } from "../configuration/config";

export class BusinessService {
  public static getSomeDataFromApi(): void {
    console.log("service method performing an action with the following config: ", globalConfig.config);
  }
}

 

Je zde však jedna nástraha – pokud ke konfiguraci přistoupíte ze statického kontextu, například z rootu souboru se service, použije se výchozí konfigurace, jako v příkladu níže. Důvod je prostý – kód v rootu souboru se spouští ještě před dokončením požadavku o načtení, tedy předtím, než se zpracuje vložená konfigurace.

// BusinessService.ts
import { globalConfig } from "../configuration/config";

const config = globalConfig.config; // does not work as desired
export class BusinessService {
  public static getSomeDataFromApi(): void {
    console.log("service method performing an action with the following config: ", config);
  }
}

 

Proto byste měli ke konfiguraci přistupovat vždy z volání metody, nikdy ne v kontextu rootu souboru. Abychom v podobných situacích předešli tichým chybám, aktualizujeme globální konfiguraci tak, aby používala gettery a settery s kontrolami.

// config.ts
class GlobalConfig {
  config: DynamicConfig = defaultConfig; // assign a value because of TypeScript
  notDefinedYet = true;

  public get(): DynamicConfig {
    if (this.notDefinedYet) {
      throw new Error("Global config has not been defined yet.");
    } else {
      return this.config;
    }
  }

  public set(value: DynamicConfig): void {
    if (this.notDefinedYet) {
      this.config = value;
      this.notDefinedYet = false;
    } else {
      throw new Error("Global config has already been defined");
    }
  }
}

export const globalConfig = new GlobalConfig();

// BusinessService.ts
import { globalConfig } from "../configuration/config";

// const config = globalConfig.get(); // wrong way to obtain the config, throws error
export class BusinessService {
  public static getSomeDataFromApi(): void {
    // console.log("service method", config); // wrong way to obtain the config
    console.log(
      "service method performing an action with following config: ",
      globalConfig.get()
    ); // correct way to obtain the config
  }
}

 

Jestliže chcete kód ještě vylepšit, můžete použít Object.defineProperty z JavaScriptu s vlastními gettery a settery, a vytvořit tak úhlednou syntaxi globalConfig.config pro volání getteru.

A je to! Teď už víte, jak načíst, uložit a použít konfiguraci. V jedné z dalších kapitol níže jsem pro vás připravil plně funkční příklad.

Řešení v kontextu Reactu

Pokud vás nezajímají kroky vedoucí k tomuto řešení, přeskočte rovnou na kapitolu Kompletní Codebase.

Začneme tím, že přidáme nový soubor s definicemi typů dynamické konfigurace src/configuration/config.ts.

export interface DynamicConfig {
  apiUrl: string;
  environment: "DEV" | "TST" | "AKC" | "PROD";
}

export const defaultConfig: DynamicConfig = {
  apiUrl: "http://localhost:8080/undefinedApiUrl",
  environment: "DEV"
};

export const dynamicConfigUrl = "config.json";

 

Před renderováním aplikace v souboru App.tsx načteme konfiguraci uloženou v useEffect. Toto je v zásadě standardní přístup u komponent, které potřebují před renderováním načíst data. Budeme muset uložit aktuální stav načítání a na jeho základě pak provést renderování.

// App.tsx:
import axios from "axios";
import React, {useEffect, useState} from "react";
import {dynamicConfigUrl} from "./configuration/config";

const App: React.FC = () => {

  const [configLoadingState, setConfigLoadingState] = useState<"loading" | "ready" | "error">("loading");
  
  useEffect(() => {
    axios.get(dynamicConfigUrl)
      .then((response) => {
        // setConfig(response.data); // we will declare this method in another snippet
        setConfigLoadingState("ready");
      })
      .catch(e => {
          setConfigLoadingState("error");
      })
  }, []);
  
  if (configLoadingState === "loading") {
    return <p>loading...</p>
   }
  if (configLoadingState === "error") {
    return <p style={{color: "red", textAlign: "center"}}>Error while fetching global config </p>;
   }

  return /* whatever you return */;
};

export default App;

 

Tento kód můžete také přesunout do vlastní komponenty a lépe ho tak oddělit. To zde pro jednoduchost neukazujeme.

React context nám umožňuje uložit konfiguraci a kdykoli k ní přistupovat ze stromu komponent Reactu. Jeho funkcionální syntaxe vás může zaskočit, pokud jste se s ní v minulosti ještě nesetkali. V takovém případě vám doporučují pročíst si související dokumentaci k Reactu, abyste lépe pochopili, o co se jedná. Budeme potřebovat provider, který bude uchovávat hodnotu, hook, který tuto hodnotu využije, a také způsob, jak hodnotu po načtení nastavit. Rovněž musíme specifikovat výchozí hodnotu konfigurace (pro TypeScript), přestože ji třeba nikdy nevyužijeme. Celý kód pak bude vypadat následovně.

// configuration/useConfig.tsx:
import React, { useContext, useState } from "react";
import { defaultConfig, DynamicConfig } from "./config";

interface DynamicConfigContext {
  config: DynamicConfig;
  setConfig: (newConfig: DynamicConfig) => void;
}

const configContextObject = React.createContext<DynamicConfigContext>({
  config: defaultConfig,
  setConfig: () => {}
});

export const useConfig = () => useContext(configContextObject);

const ConfigContextProvider: React.FC = ({ children }) => {
  const [configState, setConfigState] = useState(defaultConfig);

  return (
    <configContextObject.Provider
      value={{
        config: configState,
        setConfig: setConfigState
      }}
    >
      {children}
    </configContextObject.Provider>
  );
};

export default ConfigContextProvider;

 

A potom aktualizujeme soubor App.tsx tak, aby využil kontext následujícím způsobem.

// App.tsx
import {useConfig} from "./configuration/useConfig";

// ... in the method: 
  const { setConfig } = useConfig();
  useEffect(() => {
    axios
      .get(dynamicConfigUrl)
      .then((response) => {
        setConfig(response.data);
        log.debug("Global config fetched: ", response.data);
        setConfigLoadingState("ready");
      })
      .catch((e) => {
        setConfigLoadingState("error");
      });
  }, [setConfig]);

 

Abychom hodnotu získali, zavoláme hook useConfig(). Nevýhodou tohoto přístupu je, že nemůžeme delegovat tuto logiku na naši service třídu. V Reactu můžeme hooky volat pouze z komponent (tedy včetně hooku useConfig()). Protože service třída není komponentou, nemůžeme zde volat hook. Ten můžeme zavolat pouze v naší komponentě. Poté potřebujeme této službě hodnotu z konfigurace předat. Instanci služby můžeme efektivně uchovat pomocí React techniky memoization. Viz následující příklad.

// service/BusinessService.ts
import { DynamicConfig } from "../configuration/config";

export class BusinessService {
  constructor(readonly config: DynamicConfig) {}

  public getSomeDataFromApi(): void {
    console.log("service method", this.config);
  }
}

// arbitrary component:
import React, { useEffect, useMemo } from "react";
import { BusinessService } from "./BusinessService";
import { useConfig } from "../configuration/useConfig";

const BusinessComponent: React.FC = () => {
  const { config } = useConfig();
  const service = useMemo(() => new BusinessService(config), [config]);

  useEffect(() => {
    service.getSomeDataFromApi();
  }, [service]);

  return ( /* whatever */ );
};

export default BusinessComponent;

 

A je to! Teď už víte, jak načíst, uložit a použít konfiguraci. V následující kapitole jsem pro vás připravil plně funkční příklad.

Kompletní codebase

Obě řešení poslouží dobře, přesto má tento přístup jednu nevýhodu. Každý nový vývojář, který si stáhne projekt ze source control a spustí ho, dostane chybu. To proto, že soubor config.json není commitnutý a bude chybět. Přestože chyba vypadá přesně tak, jak bychom očekávali při chybějícím souboru config.json v produkci, není pro naše vývojáře zcela intuitivní. V případě místního vývoje se jako lepší řešení jeví varování a použití výchozí konfigurace. Toho můžeme dosáhnout s pomocí vestavěné statické konfigurace create-react-app. Hodnota NODE_ENV se automaticky nastaví podle toho, jestli je aplikace spuštěna pomocí run, bild nebo test.

// ... promise handling
.catch((e) => {
  // In development, treat this case as a warning, render the app and use the default config values.
  // In production (and testing), on the other hand, show the error instead of rendering the app.
  if (process.env.NODE_ENV === "development") {
    log.warn(`Failed to load global configuration from '${dynamicConfigUrl}', using the default configuration instead:`, defaultConfig);
    setConfigLoadingState("ready"); // or globalConfig.set(defaultConfig)
    return <App />;
  } else {
    setConfigLoadingState("error"); // or return <p>error</p>;
  }
}

 

A to je úplně všechno! Zde připojuji plně funkční příklady každého z diskutovaných řešení. Přidal jsem také logování, podrobné chybové zprávy a několik drobných vylepšení.

Výhody a nevýhody

Shrňme si výhody a nevýhody jednotlivých přístupů.

Globální konfigurační objekt

  • + Pokud chybí konfigurace, nikdy se nezavolá žádný kód Reactu. V okamžiku volání Reactu máme jistotu, že konfigurace je k dispozici.
  • + Konfiguraci lze řešit v rámci services a React nemusí vědět vůbec nic o dynamické konfiguraci.
  • – Kompilátor nekontroluje statické použití globální konfigurace (kódu v rootu souboru), ta je detekována až za běhu.
  • – Mírně komplikovanější soubor index.tsx.

Řešení pomocí React context 

  • + Jedná se o standardní způsob vykonávání těchto úloh v Reactu.
  • + Kdykoli přistoupíme ke konfiguraci, máme jistotu, že byla načtena.
  • – Čtení hodnot konfigurace musí být zpracováno komponentami a nelze ho delegovat na services.
  • – Mírně komplikovanější strom renderování (potřebujeme provider a fetcher).

Závěr

Pokud se v případě své aplikace v Reactu potřebujete řídit principem build once, deploy many, znáte nyní dva způsoby (plus jeden bonusový), jak toho dosáhnout. Obecná myšlenka je u obou způsobů podobná. Když se podívám na jejich výhody a nevýhody, nedokážu jednoznačně říct, který z nich je lepší. Vše závisí na okolnostech a vašem stylu kódování, struktuře vašeho kódu a na způsobu implementace služeb. Ukázal jsem vám, co se za oběma řešeními skrývá a představil jejich výhody a nevýhody. Měli byste tedy mít dostatek informací k tomu, abyste si sami zvolili to vhodnější řešení. S pomocí výše uvedených příkladů byste také měli být schopni začlenit vybrané řešení do vaší codebase. Happy coding!

Poznámka:

V příkladech výše používáme knihovny axiosloglevel. Pokud preferujete řešení bez knihoven, jednoduše zaměňte log. za console. a

axios.get(dynamicConfigUrl).then(/* whatever functionality */)

 

za

fetch(dynamicConfigUrl).then(response => response.json()).then(/* whatever functionality */);

 

Když jsme u toho, musím připomenout, že logování přímo do konzole je špatnou praxí, a kromě lokálního ladění se nedoporučuje.

 

Autor: Antonín Teichmann

Consultant