Angular - Créer une application multi-environnements

Présentation

Lors du cycle de vie d'un projet de développement, il est fréquent de devoir installer notre code sur différents environnements disposants de caractéristiques différentes. Cet article détaille la solution retenue pour disposer d'un package unique fonctionnant sur différents environnements dans le cadre d'un projet Frontend Angular

Coder une fois, déployer partout

La problématique de pouvoir exécuter un même code logiciel dans différents environnements (local, plateforme de validation, plateforme de production, ...) a certainement toujours existé et nous disposons de nombreuses solutions pour y parvenir.

Dans le cas de projets web, notamment en technologie Angular, la solution la plus simple à mettre en œuvre est de disposer de fichiers de configuration distincts par environnement et de passer une instruction au moment du build pour indiquer la cible d'exécution. La documentation officielle Angular détaille ce procédé. Cette approche parfaitement fonctionnelle a toutefois quelques inconvénients :

  • L'exécution du build doit être répétée pour chaque environnement
  • Il n'y a pas de garantie que le livrable construit pour un environnement A soit exactement identique à un livrable construit pour l'environnement B, Le build Angular procédant à une optimisation du code (compilation TypeScript, Tree shaking, ...)
  • Peut rendre plus complexe la chaîne d'intégration continue CI/CD

Dans cette article, nous allons décrire la solution mise en œuvre pour avoir un livrable unique exécutable sur plusieurs environnements, build once, run everywhere ;-) !

Principe

Le principe que nous avons retenu est d'embarquer l'ensemble des fichiers de configuration des environnements dans le livrable et d'avoir un moyen de définir à l'exécution quelle configuration utiliser. Cette approche s'apparente à ce qui peut être fait via des profils avec le framework Spring pour un projet Backend. Dans notre cas le fichier à utiliser sera déterminé par le contenu d'un fichier de configuration externe env.json.

 

Structure des fichiers de variables

La structure des fichiers de variables est la suivante :

  • un fichier env.json qui contient une référence au fichier d'environnement à utiliser
  • un fichier par environnement à gérer, nommé env.[nom de l'environnement].json


Ces différents fichiers sont à créer dans le répertoire assets/env/ et doivent reprendre l'ensemble des éléments variables selon l'environnement d'exécution de votre application dans un format JSON.

Exemple :

// fichier env.json
{
"environment": "qa"
}

// fichier env.qa.json
{
  "production": false,
"baseUrl": "https://qa.acme.com",
"backendUrl": "https://api-qa.acme.com",
"loginRedirectUrl": "https://api-qa.acme.com/login",
"logoutRedirectUrl": "https://api-qa.acme.com/logout",
"profileLabel": "Environnement de validation"
}

Nota, une bonne pratique est de ne pas "commiter" le fichier assets/env/env.json, ceci afin de ne pas risquer de déployer un fichier correspondant à votre configuration locale sur un autre environnement. Généralement on "commit" un fichier assets/env/env.json.dist qui donne un modèle de fichier à utiliser.

 

Gestion des variables liées aux environnements

Pour gérer le chargement et la gestion des variables liées aux environnements, nous allons créer un fichier /src/config/AppConfig.ts.

Ce fichier contiendra toutes les fonctions qui permettront de récupérer les différentes variables liées à l'environnement d'exécution.

Dons ce fichier, nous allons également créer les différentes interfaces et énumérations qui permettent de typer les différents objets utilisés en configuration.

Typage des objets utilisés

Notre projet utilisant le langage TypeScript, nous allons mettre à profit son approche en typant au maximum nos variables.

Nous allons en premier lieu définir une énumération ExistingEnvironment contenant toutes les valeurs possiblement utilisées dans le fichier d'entrée env.json pour désigner un environnement.

Si la valeur utilisée sous env.json n'est pas contenue dans cette énumération, la configuration ne sera pas chargée.

enum ExistingEnvironment {
  local = 'local',
  qa = 'qa',
  prod = 'prod', 
}

 

Ensuite, nous allons créer une interface EnvironmentRoot et une énumération EnvironmentRootKey

interface EnvironmentRoot {
[EnvironmentRootKey.environment]: ExistingEnvironment;
}

enum EnvironmentRootKey {
  environment = 'environment'
}

EnvironmentRoot correspond au format utilisé dans le fichier d'entrée env.json et  EnvironmentRootKey contient toutes les clés utilisées dans l'objet  EnvironmentRoot. Cette approche permet de récupérer les éléments du fichier env.json de manière plus sûre.

 

Enfin, selon la même logique, nous allons créer une interface EnvironmentFile et une énumération EnvironmentKey.

export interface EnvironmentFile {
  [EnvironmentKey.production]: boolean,
  [EnvironmentKey.baseUrl]: string,
  [EnvironmentKey.loginRedirectUrl]: string,
  [EnvironmentKey.logoutRedirectUrl]: string,
  [EnvironmentKey.profileLabel]: string,
  [EnvironmentKey.backendUrl]: string,
}

export enum EnvironmentKey {
  production = 'production',
  baseUrl = 'baseUrl',
  loginRedirectUrl = 'loginRedirectUrl',
  logoutRedirectUrl = 'logoutRedirectUrl',
  profileLabel = 'profileLabel',
  backendUrl = 'backendUrl'
}

EnvironmentFile correspond au format utilisé dans les fichiers contenant les variables d'environnement et  EnvironmentKey contient toutes les clés utilisées dans l'objet  EnvironmentFile, ceci permet de récupérer les éléments du fichier JSON de manière plus structurée.


Nota, le cas exposé ici ne contient que des variables primitives, la configuration pourrait contenir des objets complexes. Dans ce cas, il faudrait également définir des interfaces pour ceux-ci afin de mieux structurer notre gestion des variables d'environnement.

Chargement des variables

Maintenant que nous avons déclaré les interfaces et énumérations dans le fichier AppConfig.ts, nous allons pouvoir mettre en place la fonctionalité de chargement des variables depuis les fichiers à proprement parler.

@Injectable()
export class AppConfig {

private configuration: EnvironmentFile = { // configuration par défaut
    production: false,
    baseUrl: "",
    backendUrl: "",
    loginRedirectUrl: "",
    logoutRedirectUrl: "",
    profileLabel: "",
  };

  constructor(private http: HttpClient) {
  }


  /**
 * Cette méthode:
 *   a) Charge "env.json" pour récupérer l'environnement d'éxécution (ex.: 'qa', 'local')
 *   b) Charge "env.[env].json" pour récupérer les variables liées à cet environnement
   */
  public load(): Promise<boolean> {
  return new Promise((resolve, reject) => { // utilisation d'une promesse pour la gestion asynchrone
    this.http.get<EnvironmentRoot>('./assets/env/env.json').subscribe( // récupération du fichier d'entrée
        {
        next: (envContent: EnvironmentRoot) => { // récupération du fichier d'entrée : OK
            if (
            envContent[EnvironmentRootKey.environment] &&
            Object.values(ExistingEnvironment).includes(envContent[EnvironmentRootKey.environment] as ExistingEnvironment)
          ) { // vérification de l'existence et de la valeur de 'environment'
            this.http.get<EnvironmentFile>(`./assets/env/env.${envContent[EnvironmentRootKey.environment]}.json`)
              .subscribe({ // récupération du fichier de variables lié à l'environnement d'éxécution
                next: (responseData: EnvironmentFile) => { // récupération du fichier de variables à utiliser : OK
                  this.configuration = responseData; // configuration récupérée
                  if (this.configuration[EnvironmentKey.production]) { // mise en place du mode production si besoin
                      enableProdMode();
                    }
                    resolve(true);
                  },
                error: () => { // récupération du fichier de variables à utiliser : NOK
                  console.log(`Error reading ${envContent[EnvironmentRootKey.environment]} configuration file`);
                    reject();
                  }
                });
          } else { // vérification de l'existence et de la valeur de 'environment'dans le fichier env.json: NOK
              console.error('Environment file is not set or invalid');
              reject();
            }
          },
        error: () => { // récupération du fichier d'entrée : NOK
            console.log('Configuration file "env.json" could not be read');
            reject();
          }
        }
      )
    })
  }
}

Appel de la fonction de chargement des variables

Maintenant que notre fonction de chargement des variables a été créée, il est nécessaire de l'appeler lors de l'initialisation de l'application.

Pour ce faire, il faut rajouter dans l'instruction des providers dans le fichier app/app.module deux entrées :

providers: [
  AppConfig, // Appel du fichier appConfig
  {
    provide: APP_INITIALIZER, // À l'initialisation,
    useFactory: initConfig, // lancer la fonction,
    deps: [AppConfig], // qui fait appel à AppConfig,
  multi: true // et rajouter cette instruction aux autres instructions du type APP_INITIALIZER
  }
]

ainsi que la fonction initConfig qui fait appel à la fonction load du fichier AppConfig.ts au-dessus de l'instruction des NgModule :

export function initConfig(config: AppConfig) {
  return () => config.load();
}

Lecture de la valeur d'une variable

A ce stade la mise en œuvre est pratiquement terminée, il nous reste à fournir à notre application une fonction permettant de récupérer la valeur d'une variable liée à l'environnement.

Nous allons revenir sur le fichier AppConfig.ts et ajouter une méthode getConfiguration :

@Injectable()
export class AppConfig {

  private configuration: EnvironmentFile = {
  // Commenté, voir le chapitre précédent
  };

  constructor(private http: HttpClient) {
}

  public load(): Promise<boolean> {
   // Commenté, voir le chapitre précédent
  }
  
  public getConfiguration(key: EnvironmentKey): boolean | string {
    return this.configuration[key];
  }
}

Nota, le type retourné par la méthode getConfiguration sera à adapter selon votre cas, ici nous n'avions que des types simples boolean et string.

 

Utilisation des variables dans le code de l'application

Si vous êtes arrivés jusque-là, ce paragraphe vous semblera, nous l'espérons, bien inutile ;-).

La configuration liée à l'environnement est maintenant chargée à l'initialisation et la valeur des variables est accessible n'importe où dans l'application.

Voici deux exemples d'utilisation :

public backendUrl: string;

constructor(private readonly appConfig: AppConfig) {
  this.backendUrl = this.appConfig.getConfiguration(EnvironmentKey.backendUrl) as string
}

ou encore :

public logout() {
  this.user = null;
window.location.href = this.appConfig.getConfiguration(EnvironmentKey.logoutRedirectUrl) as string;
}

 

Et ensuite ?

Cet article est le premier que nous rédigeons sur un sujet de développement, nous espérons qu'il est à la fois digeste et complet. Nous esperons que c'est le premier d'une longue série.

Vous noterez que nous n'avons pas activé la fonctionnalité de commentaires sur notre blog. Toutefois, nous serions ravis d'avoir votre retour ou de répondre à vos questions, il suffit de nous envoyer un message via le formulaire de contact ci-dessous !