Angular : ajouter une configuration chargée au runtime (post-build) plutôt que pendant la build
Angular propose de base un système de paramétrage de votre application par environnement via (notamment) un fichier environment.ts. Le seul problème de ce fonctionnement est que la configuration est définie au moment de la compilation alors qu'on veut parfois (toujours ?) pouvoir générer un package et pouvoir modifier sa configuration via un fichier "externe" au package. Cet article proposera une solution que je vous laisse améliorer via les commentaires !
TL;DR;
On place un fichier config.json contenant la configuration que l'on rend disponible dans les providers.
Déclarer les configurations
Pour déclarer la configuration, on va mettre un fichier config.json dans notre dossier asset. Celui contiendra notre configuration et sera rendu disponible à l'application. Voici un exemple classique :
{ "name": "local", "apiUrl": "http://localhost:58231/", "isProd": true }
On peut alors créer un dossier "configurations" dans lequel on mettra les différentes configurations disponibles.
Je place ces fichiers volontairement dans le dossier "assets" pour qu'ils soient disponibles même après une compilation angular en mode production avec minification et cie comme décrit dans la documentation angular.
Modéliser la configuration
Pour représenter la configuration, j'ai choisi de définir une classe AppConfiguration (on ne peut pas injecter une interface et c'est ce qu'on veut faire au final) tout ce qu'il y a de plus classique :
import { Injectable } from "@angular/core"; @Injectable() export class AppConfiguration { name: string; apiUrl: string; isProd: boolean; }
Utiliser un APP_INITIALIZER
Les APP_INITIALIZER vous permettent de demander au moteur Angular d'attendre la fin d'une promise avant de lancer réellement l'application. Pour cela, il faut ajouter un provider en utilisant le jeton "APP_INITIALIZER" et fournir le "service" en utilisant une factory. Cette dernière doit retourner une Promise indiquant la fin du traitement.
On va alors utiliser ce mécanisme de cette manière :
- Ajouter AppConfiguration comme provide : de cette manière il n'y en aura qu'une seule instance,
- Déclarer une méthode ensureInit sur la classe chargée de faire l'initialisation de la configuration,
- Créer une factory qui retourne la promise d'initialisation de la configuration,
- Déclarer un APP_INITIALIZER qui utilise cette Factory en déclarant 2 dépendances : AppConfiguration lui-même et HttpClient (utilisé pour lire le fichier de configuration).
Voici la déclaration du module réduite au strict minimum :
@NgModule({ declarations: [ ], imports: [ HttpClientModule], providers: [ AppConfiguration, { provide: APP_INITIALIZER, useFactory: AppConfigurationFactory, deps: [AppConfiguration, HttpClient], multi: true }, ], bootstrap: [AppComponent] }) export function AppConfigurationFactory( appConfig: AppConfiguration) { return () => appConfig.ensureInit(); }
Et bien sûr la méthode de chargement de la configuration est ajoutée dans AppConfiguration :
ensureInit(): Promise<any> { return new Promise((r, e) => { this.httpClient.get("./assets/config.json") .subscribe( (content: AppConfiguration) => { Object.assign(this, content); r(this); }, reason => e(reason)); }); }j
Modifier l'environnement ciblé
Pour modifier l'environnement ciblé, il ne reste plus qu'à écraser le fichier config.json par celui correspondant au bon environnement. Cela reste très simple à intégrer dans une usine de build qui déploierait sur Docker (explication de Vivien ici).
Pourquoi j'aime bien cette solution ?
Je trouve cette solution intéressante car cela permet d'injecter directement la configuration dans les différents composants/modules de l'application.
Initialement, j'avais imaginé charger le fichier via HttpClient et exposer un service de "configuration" mais cela me forçait à exposer des promises ou des Observables ce qui compliquait amèrement le code des consommateurs de ce service.
Pourquoi je ne suis pas complétement satisfait de cette solution ?
Un défaut de cette solution est la non-disponibilité de la configuration avant le bootstrap de l'application Angular. Par exemple, je ne peux pas encore activer ou non le mode "Prod" car j'ai l'information trop tard dans le processus. Je reste donc encore dépendant de l'environnement utilisé pour builder l'application :
if (environment.production) { enableProdMode(); }
Technique qui ne fonctionne pas
Dans un précédent essai, j'ai d'abord tenté une autre solution à base d'import mais William (je vous laisse découvrir sa passion en lisant un de ses articles) m'a démontré avec gentillesse que cela ne fonctionnerait pas après le passage de Webpack en prod car tout serait "inliné". Comme cela reste intéressant techniquement, je vous laisse mon explication originelle :)
Pour pouvoir accéder à ces données depuis un module Angular il va falloir importer ce fichier.
Pour cela il est nécessaire de permettre l'import d'un fichier json en ajoutant la déclaration ci-dessous dans votre fichier de typings. Cela permet d'importer n'importe quel fichier de type JSON comme étant une constante "value" exposée directement lorsque l'on accède au module. Pour en savoir plus, il suffit de scroller un peu sur la documentation de TypeScript jusqu'à la section "Wildcard module declarations".
// permet d'importer les fichiers json declare module "*.json" { const value: any; export default value; }
Une fois fait, vous pouvez importer la configuration en rajouter cette ligne qui place les données dans une variable nommée configuration :
import * as configuration from '../assets/config.json';
L'injection de la configuration est alors faite de manière classique en utilisant la valeur récupérée via l'import réalisé précédemment :
// par soucis de clarté, je ne mets que le tableau de providers ici providers: [ { provide: AppConfiguration, useValue: configuration } ]
Happy coding !
Commentaires