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 !