Dynamische Angular-Konfiguration

Angular-Apps werden traditionell statisch über Environment-Dateien konfiguriert, was separate Builds für jede Umgebung erfordert - in der Praxis ein umständlicher Prozess. In diesem Blogbeitrag beschreiben wir eine Alternative, bei der die Konfiguration zur Laufzeit dynamisch geladen wird, um die Entwicklung zu vereinfachen und die Flexibilität zu erhöhen. Als zusätzlichen Vorteil werden wir die Konfiguration gegen ein Schema validieren und Typsicherheit hinzufügen.

Chamäleon mit Angular-Logo
Originalfoto von Pierre Bamin auf Unsplash

Table Of Contents

Was ist das Problem mit Angular-Environments?

Angular-Environments sind im Prinzip Build-Konfigurationen. Ihr wichtigstes Feature ist die Möglichkeit, Dateien während des Build-Prozesses in Abhängigkeit von der ausgewählten Konfiguration auszutauschen. Das wird fast immer die Datei src/environments/environments.ts sein, siehe Building and serving Angular apps für weitere Informationen.

Dieser Ansatz mag in Ordnung sein, wenn man lediglich zwei Umgebungen hat, typischerweise eine Entwicklungsversion, die lokal läuft, und eine Live-Version, die auf einen Web-Server ausgerollt wird. Mit mehr Stages wie "testing", "integration" und "production" wird die Sache aber schnell unübersichtlich. Wenn die Applikation internationalisiert ist und Kompilierzeit-Übersetzungen verwendet (wie es bei @angular/localize standardmäßig der Fall ist), wird die Situation noch schlechter, weil die Anzahl der erforderlichen Builds sich jetzt aus der Multiplikation der Anzahl der Stages mit der Anzahl der unterstützten Locales errechnet.

Ein weiteres Problem besteht darin, dass die Konfiguration im Quelltext enthalten sein muss. Das ist kein Sicherheitsproblem, weil der Quelltext ohnehin keine vertraulichen Informationen enthalten sollte, denn die kann jeder mit der JavaScript-Konsole auslesen. Aber die Notwendigkeit, die Applikation jedesmal neu zu bauen, wenn sich die Konfiguration ändert, kann das Deployment umständlich machen.

Dynamische Konfiguration

Die Konfiguration zur Laufzeit nachzuladen, ist viel flexibler. Damit das funktioniert, muss der Bootstrapping-Prozess der Angular-Applikation modifiziert werden. Das gestaltet sich in etwa so:

  1. Bestimme den URL der Konfigurationsdatei in Abhängigkeit der lokalen Umgebung, wie Hostname oder IP-Adresse oder Webserver-Konfiguration.
  2. Lade die Konfiguration mit der Fetch-Schnittstelle.
  3. Injiziere die Konfiguration.
  4. Führe das reguläre Angular-Bootstrapping mit bootstrapApplication von der selektierten Plattform aus.

Alternativ könnte man auch die Datei index.html so ändern, dass die Konfiguration mittels eines script-Tags geladen wird. Diesen Ansatz werden wir hier aber nicht weiter betrachten, weil er nicht gut passt, wenn die Applikation in einem Container laufen soll. Für diesen Anwendungsfall würde das Problem lediglich vom Build- zum Deployment-Schritt verschoben werden, weil dann mehrere Images für jede Stage erzeugt werden müssen. Falls das kein Problem darstellt, steht dieser Strategie aber nichts entgegen, und es sollte relativ einfach sein, sie zu implementieren.

Implementierung

Um unseren Ansatz auszuprobieren, erzeugen wir ein neues Angular-Projekt in einem beliebigen Verzeichnis:

$ npx ng new --strict dynamic-angular-configuration

Dies erzeugt eine Standalone-Angular-Applikation.

Alternativ kann man auch einfach das begleitende Git-Repository https://github.com/gflohr/dynamic-angular-configuration klonen. Der Commit-Log folgt mehr oder weniger den Schritten, wie sie hier beschrieben sind.

Die Startseite ändern

Die Startseite src/app/components/app.component.html ändern wir folgendermaßen:

<h1>Dynamic Angular Configuration</h1>

<router-outlet />

Wird die Applikation jetzt mit npm run watch gestartet, und öffnet die Adresse http://localhost:4200/ im Browser, sieht man mit etwas Glück die Zeile "Dynamic Angular Configuration". Unser Ziel ist es, einen zweiten Absatz mit der Beschreibung der Umgebung zuzufügen.

Die Konfigurationsdateien erzeugen

Als nächstes wollen wir verschiedene Konfigurationen erzeugen. Wir fangen an mit src/assets/config.dev.json:

{
    "production": false,
    "description": "This is the dev environment!",
}

Und dann noch src/assets/config.prod.json:

{
    "production": true,
    "description": "This is the prod environment!",
}

Einen Typ Configuration erzeugen

Es ist eine gute Idee, Typsicherheit für die Konfiguration zu haben. Das hilft gegen Tippfehler und hilft auch bei der Entwicklung, weil es Auto-Vervollständigung erlaubt. Das könnte zum Beispiel so aussehen:

type Configuration = {
    production: boolean,
    description: string,
};

Wir laden die Konfiguration aber mit einem Ajax-Request, und es ist deshalb sinnvoll, sie vor der Verwendung gegen ein Schema zu validieren. Das Problem dabei ist allerdings, das wir dann sowohl das Schema als auch den TypeScript-Typ pflegen müssen, und dafür sorgen, dass sie nicht synchronisiert bleiben.

Es wäre cool, wenn wir das Schema aus der Typ-Definition generieren könnten. Noch cooler wäre es umgekehrt, denn Schemas sind normalerweise strenger als TypeScript-Typen. Glücklicherweise existiert eine solche Option in Form der Software valibot.

Fügen wir also valibot unserem Projekt zu:

$ npm add --save valibot

Typen müssen nicht installiert werden, weil valibot in TypeScript geschrieben ist.

Jetzt erzeugen wir eine Datei src/app/configuration.ts:

import * as v from 'valibot';
import { InjectionToken } from '@angular/core';

export const ConfigurationSchema = v.object({
    production: v.boolean(),
    description: v.string([v.minLength(5)]),
    email: v.optional(v.string([v.email()]), 'info@example.com'),
    answers: v.optional(
        v.object({
            all: v.optional(v.number([v.minValue(1)]), 42),
        }),
        {},
    ),
});

export type Configuration = v.Input<typeof ConfigurationSchema>;

export const CONFIGURATION = new InjectionToken<Configuration>('Configuration');

Der interessante Teil fängt in Zeile 4 an, wo das Konfigurations-Schema definiert wird. Weil unsere Konfiguration ein verschachteltes Objekt ist, brauchen wir auch ein verschachteltes Schema.

Schemas werden mit Hilfe von Schema-Funktionen wie object(), boolean(), string() oder number() definiert. Optional können auch Pipelines wie email(), minLength() oder minValue() verwendet werden. Diese erlauben strengere Überprüfungen.

Zu Illustrationszwecken habe ich zwei optionale Felder email und answers zugefügt. Der Schema-Funktion optional kann ein optionales zweites Argument übergeben werden, um einen Default-Wert anzugeben. Siehe die valibot-Dokumentation für weiterführende Informationen.

In Zeile 16 wird die TypeScript-Typdefinition aus dem Schema abgeleitet.

Und in Zeile 18 wird ein Injection-Token für die Konfiguration erzeugt, so dass sie als Abhängigkeit injiziert werden kann.

Der Angular-Bootstrap-Prozess

Bevor wir fortfahren, sollten wir den Angular-Bootstrap-Prozess näher betrachten, weil wir ihn modifizieren müssen. Der Einstiegspunkt der Applikation ist src/main.ts und sieht standardmäßig so aus:

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err));

Die Funktion bootstrapApplication() erwartet zwei Argumente, nämlich die Wurzelkomponente und ein Konfigurationsobjekt. Letzteres wird aus src/app/app.config.ts importiert:

import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
    providers: [provideRouter(routes)],
};

Wie man sieht, besteht der einzige Zweck darin, Abhängigkeiten für das Dependency-Injection-System bereitzustellen. Das bedeutet, dass wir exakt hier einen Provider für die geladene Konfiguration zufügen müssen. Das Problem dabei ist aber, dass wir an diesem Punkt keine Konfiguration haben, weil sie dynamisch mit einem Ajax-Request nachgeladen wird.

Die Applikations-Konfiguration dynamisieren

Ein konstantes Objekt zu exportieren wird deshalb nicht mehr funktionieren. Wir müssen eine asynchrone Funktion verwenden, welche die Konfiguration über HTTP lädt und dann einen Provider für das Konfigurations-Object zufügt. Wir ändern dazu src/app/app.config.ts folgendermaßen:

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import * as v from 'valibot';

import { routes } from './app.routes';
import {
    CONFIGURATION,
    Configuration,
    ConfigurationSchema,
} from './configuration';

export const createAppConfig = async (): Promise<ApplicationConfig> => {
    const configurationPath = locateConfiguration(location);
    const response = await fetch(configurationPath);
    const json = await response.json();
    const configuration = parseConfiguration(json);

    return {
        providers: [
            { provide: CONFIGURATION, useValue: configuration },
            provideRouter(routes),
        ],
    };
};

const locateConfiguration = (location: Location): string => {
    if (location.hostname === 'localhost') {
        return './assets/config.dev.json';
    } else {
        return './assets/config.prod.json';
    }
};

const parseConfiguration = (configuration: unknown): Configuration => {
    try {
        return v.parse(ConfigurationSchema, configuration);
    } catch (e) {
        const v = e as v.ValiError;
        const issues = v.issues;
        console.error('Configuration error(s):');
        for (const issue of issues) {
            const path =
                issue.path?.map((segment: any) => segment.key).join('.') ??
                '[path not set]';
            console.error(`  error: ${path}: ${issue.message}`);
        }
        throw new Error('Application not started!');
    }
};

In Zeile 13 bestimmen wir zunächst den Pfad zur Konfigurationsdatei. Die in Zeile 26 definierte Funktion locateConfiguration() benutzt einen sehr simplen Ansatz. Wenn der Hostname "localhost" ist, wird die Konfiguration für Development geladen, ansonsten die für Live. Das muss natürlich an die eigenen Anforderungen angepasst werden.

In den Zeilen 14-16 wird die Konfiguration über HTTP geladen und dann durch die Funktion parseConfiguration() gejagt, die in Zeile 34 definiert ist. Diese Funktion verwendet den valibot-Parser, der eine Exception schmeißt, wenn ein Validierungsfehler auftritt. Das kann man ausprobieren, indem man die Konfigurationsdateien in /assets ändert.

In Zeile 20 wird schließlich der Provider für das Konfigurations-Objekt zugefügt und zurückgegeben.

Applikations-Einstieg ändern

Der Einstiegspunkt der Applikation src/main.ts kompiliert jetzt nicht mehr. Er muss wie folgt geändert werden:

import { bootstrapApplication } from '@angular/platform-browser';
import { createAppConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

createAppConfig()
    .then(appConfig => bootstrapApplication(AppComponent, appConfig))
    .catch(err => console.error(err));

Das sollte klar sein. Anstatt ein statisches ApplicationConfig-Objekt zu verwenden, muss jetzt eine Funktion aufgerufen werden. Es ist übrigens nicht möglich, await zu benutzen, um appConfig zu laden, denn await ist an dieser Stelle in Angular-Applikationen nicht erlaubt. Wir müssen uns also mit konventionellem Promise-Chaining behelfen.

Konfigurations-Variablen rendern

Die Applikation kompiliert jetzt wieder und läuft auch. Aber funktioniert sie?

Wir ändern die Komponent src/app/app.component.ts wie folgt:

import { Component, Inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CONFIGURATION, Configuration } from './configuration';

@Component({
    selector: 'app-root',
    standalone: true,
    imports: [RouterOutlet],
    templateUrl: './app.component.html',
    styleUrl: './app.component.css',
})
export class AppComponent {
    description: string;
    answer: number;

    constructor(
        @Inject(CONFIGURATION)
        private configuration: Configuration,
    ) {
        this.description = this.configuration.description;
        this.answer = this.configuration.answers?.all as number;
    }
}

Die Konfiguration wird injiziert und zwei öffentliche Properties description und answer aus ihr extrahiert. Diese können wir jetzt rendern, indem wir das Komponenten-Template src/app/app.component.html anpassen:

<h1>Dynamic Angular Configuration</h1>
<p id="description">{{ description }}</p>
<p id="answer">
    The answer to the ultimate question of life, the universe, and everything is
    {{ answer }}.
</p>
<router-outlet />

Wenn wir jetzt http://localhost:4200/ im Browser öffnen, sollten wir die Beschreibung sehen, die konstatiert, dass es sich um die Entwicklungs-Umgebung handelt, und auch die Antwort auf die endgültige Frage nach dem Leben, dem Universum und dem ganzen Rest.

Man kann den Server auch stoppen und den Hostnamen mit dem Kommando npx ng serve --host 127.0.0.1 auf 127.0.0.1 ändern. Öffnet man jetzt http://127.0.0.1:4200/, wird man erfahren, dass die Applikation jetzt davon ausgeht in einer Produktionsumgebung zu laufen, weil unser Code explizit prüft, dass der Hostname localhost ist.

Testen

Alles läuft jetzt, und eigentlich könnte man sich jetzt wieder anspruchsvolleren Aufgaben widmen. Aber jetzt, wo der Bootstrapping-Code auch Business-Logik enthält, könnte es auch nicht schaden, ihn zu testen.

Das schauen wir uns allerdings nicht im Detail an, sondern beschreiben lediglich, wie es geht. Einzelheiten kann man dem Quelltext unter https://github.com/gflohr/dynamic-angular-configuration/blob/main/src/app/app.config.spec.ts entnehmen.

Weil src/app/app.config.ts HTTP-Requests macht, müssen wir die globale Methode fetch() mocken. Den Mock definieren wir mit fetchSpy = spyOn(window, 'fetch') als Spy, damit wir prüfen können, ob fetch() mit dem erwarteten Konfigurationspfad aufgerufen wurde.

Wir wollen auch testen, dass die Validierung der Konfiguration funktioniert und ungültige Konfigurationen verworfen werden und die erwartete Anzahl von Fehlern erzeugen. Das erfordert eine kleine Änderung an src/app/app.config.ts, weil die Funktion parseConfiguration() bis jetzt nicht exportiert wurde.

Und schließlich testen wir auch noch, dass die Default-Werte funktionieren.

Fazit

Die Konfiguration einer Angular-Applikation über HTTP zu laden, hat eine Reihe von Vorteilen. Es kann die Anzahl der erforderlichen Builds drastisch reduzieren und macht das Deployment flexibler. Wer diesen Ansatz verfolgen will, kann das begleitende GitHub-Repository dynamic-angular-configuration gerne als Starter-Projekt verwenden.

Feedback ist natürlich willkommen, entweder über die Kommentarfunktion unten oder als Issue auf GitHub.

Leave a comment

Dynamische Angular-Konfiguration

ImageMagick für Perl kompilieren

Angular Tour of heroes als Standalone-App

Zugriff auf privaten Content in AWS CloudFront authentifizieren

Fallstricke beim Testen von NestJS-Modulen mit HttpService

Schacheröffnungen mit Anki trainieren

Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.