W aplikacjach Angular istnieją dwa główne podejścia do obsługi ustawień konfiguracyjnych: używanie plików environment.ts oraz używanie tokenu APP_INITIALIZER. Na pierwszy rzut oka oba sposoby wydają się robić to samo: przechowywać wartości, których aplikacja potrzebuje do działania. W rzeczywistości istnieją między nimi znaczące różnice:
- Używanie plików environment.ts: Wartości konfiguracyjne są bezpośrednio zawarte w pliku wynikowym aplikacji, co oznacza, że są one zakodowane tam na stałe. Takie podejście zapewnia szybki start aplikacji, ale ma swoje ograniczenia. Duże konfiguracje mogą zwiększać rozmiar pakietu, a każda zmiana konfiguracji wymaga ponownego zbudowania i wdrożenia aplikacji.
- Używanie tokenu APP_INITIALIZER: Ten sposób powoduje ładowanie ustawień konfiguracyjnych w czasie uruchamiania aplikacji. Konfiguracja jest pobierana w momencie startu aplikacji, co pozwala na jej aktualizację bez konieczności ponownego budowania lub wdrażania. Takie podejście jest szczególnie przydatne w aplikacjach, które wymagają różnych ustawień dla różnych środowisk lub klientów.
W skrócie, podejście oparte o plik environment.ts sprawia, że konfiguracja staje się częścią kompilacji, co może zwiększyć rozmiar pakietu i utrudnić aktualizację. Z kolei APP_INITIALIZER umożliwia dynamiczną aktualizację konfiguracji, co czyni tę podejście bardziej elastycznym i łatwiejszym w zarządzaniu. W tym artykule opisane zostanie te drugie podejście.
Przypadek użycia
Projekt, który jest brany pod uwagę, musi być wdrożony dla różnych klientów, z których każdy ma inną konfigurację. Aby utrzymać wspólną bazę kodu, aplikacja powinna ładować konfigurację w czasie wykonywania. Rozwiązanie powinno wspierać możliwość aktualizacji konfiguracji bez zmiany kodu aplikacji. Innymi słowy, powinna istnieć możliwość modyfikacji konfiguracji bez konieczności ponownej kompilacji lub wdrażania aplikacji. W podanym przykładzie konfiguracja będzie obejmować następujące elementy:
- nazwa firmy
- adres api – każdy klient hostuje api indywidualnie
- theme – domyślna theme aplikacji
- język domyślny
- dostępne funkcjonalności takie jak czat, płatności czy powiadomienia
- e-mail do supportu
Zastosowanie tokenu APP_INITIALIZER zostanie omówione jako rozwiązanie spełniające przedstawione wymagania. Dodatkowo, podejście zostanie porównane z tradycyjnym sposobem wykorzystującym plik environment.ts. Co więcej, w artykuł zostanie pokazane, jak zweryfikować konfigurację przy użyciu Zod.js,tak aby upewnić się, że spełnia ona wszystkie potrzeby aplikacji.
APP_INITIALIZER Token
Zgodnie z oficjalną dokumentacją Angulara, APP INITIALIZER token to:
“A DI token that you can use to provide one or more initialization functions.
The provided functions are injected at application startup and executed during app initialization. If any of these functions returns a Promise or an Observable, initialization does not complete until the Promise is resolved or the Observable is completed.
You can, for example, create a factory function that loads language data or an external configuration, and provide that function to the APP_INITIALIZER token. The function is executed during the application bootstrap process, and the needed data is available on startup.”
APP_INITIALIZER to specjalny token w Angularze, który pozwala na uruchomienie jednej lub więcej funkcji podczas startu aplikacji. Funkcje te są wywoływane w trakcie inicjalizacji aplikacji. Na przykład, można utworzyć funkcję, która ładuje konfigurację z pliku JSON. Dzięki użyciu APP_INITIALIZER, ta funkcja zostanie automatycznie uruchomiona przy starcie aplikacji, co zapewnia, że konfiguracja będzie gotowa do użycia od samego początku.
Następnym krokiem będzie zademonstrowanie przykładowej implementacji dynamicznego ładowania konfiguracji.
Implementacja dynamicznego ładowania konfiguracji
Przykładowe rozwiązanie może zostać opisane w następujących krokach:
- Na starcie, aplikacja pobiera plik konfiguracyjny z serwera
- Zawartość tego pliku jest walidowana oraz przekazywana do dedykowanego serwisu.
- Od tego momentu, konfiguracja może zostać pobrana z serwisu i wykorzystana w dowolnym miejscu i momencie działania aplikacji.
W pierwszej kolejności warto przeanalizować punkt startowy aplikacji, a mianowicie plik main.ts.
function initializeAppFactory(
httpClient: HttpClient,
configService: ConfigService
) {
const url = './config.json';
return () =>
httpClient.get(url).pipe(
tap((config) => {
const dto = parseDTO(config);
if (dto.success) {
configService.setConfig(dto.data);
} else {
console.error('Invalid config.json', dto.error);
}
})
);
}
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
{
provide: APP_INITIALIZER,
useFactory: initializeAppFactory,
multi: true,
deps: [HttpClient, ConfigService],
},
],
});
W powyższym kodzie została zdefiniowana oraz wykorzystana fabryka (initializeAppFactory), której zadaniem jest stworzenie funkcji ładującej plik z konfiguracją (config.json). Gwarantuje to, że konfiguracja zostanie pobrana przed startem aplikacji.
Funkcja initializeAppFactory jest wywoływana przez Angular podczas fazy inicjalizacji. Wstrzyknięte zostały dwa serwisy: HttpClient i ConfigService. Serwis HttpClient jest używany do wykonania żądania HTTP GET w celu pobrania pliku config.json. Gdy plik zostanie pobrany, jego zawartość jest parsowana za pomocą biblioteki zod.js. Takie podejście zapewnia, że konfiguracja jest zwalidowana, zanim aplikacja zacznie działać. Temat integracji zod.js został szczegółowo opisany w artykule “Parsowanie i mapowanie odpowiedzi z API z wykorzystaniem zod.js”. W nim dowiesz się, jak zoptymalizować kod, aby poprawić komunikację z API. To samo podejście można zastosować do parsowania plików konfiguracyjnych. W skrócie, zod.js pozwala zdefiniować schemat oczekiwanych danych, zweryfikować, czy dane spełniają wszystkie wymagania, oraz wywnioskować typ na podstawie schematu. Implementacja została przedstawiona w kodzie:
import { z } from 'zod';
// Define the schema for the config
const schema = z.object({
companyName: z.string(),
apiUrl: z.string(),
theme: z.string(),
language: z.string(),
features: z.object({
enableChat: z.boolean(),
enablePayments: z.boolean(),
enableNotifications: z.boolean(),
}),
supportEmail: z.string(),
});
// Infer the type from the schema
export type ConfigDTO = z.infer<typeof schema>;
// Parse the config if matches the schema
export function parseDTO(source: unknown) {
return schema.safeParse(source);
}
Wracając do initializeAppFactory, jeśli DTO (Data Transfer Object) zostanie pomyślnie sparsowany, konfiguracja zostanie przekazana do ConfigService. Odpowiedzialność tego serwisu jest prosta – przechowuje on konfigurację i udostępnia ją do użytku w całej aplikacji. Jest to w zasadzie opakowanie wokół tokenu APP_INITIALIZER. W miarę rozwoju aplikacji i konfiguracji serwis może zostać rozszerzony o specyficzne metody pozwalające na pobieranie poszczególnych części konfiguracji.
@Injectable({
providedIn: 'root',
})
export class ConfigService {
#config!: ConfigDTO;
setConfig(config: ConfigDTO) {
this.#config = config;
}
getConfig() {
return this.#config;
}
}
Finalnie, w dowolnego części aplikacji, ConfigService może zostać wstrzyknięty w celu wykorzystania konfiguracji.
config = inject(ConfigService).getConfig();
Config.json
Spójrzmy na przykład pliku config.json. Jest to podstawowy plik JSON, który może przechowywać dowolne dane potrzebne do uruchomienia aplikacji. Ważne jest, aby struktura danych odpowiadała schematowi i mogła zostać poprawnie sparsowana przez zod.js.
{
"companyName": "Customer One",
"apiUrl": "https://api.customer1.com/v1/",
"theme": "dark",
"language": "en-US",
"features": {
"enableChat": true,
"enablePayments": false,
"enableNotifications": true
},
"supportEmail": "support@customer1.com"
}
Kluczową zaletą opisywanego podejścia jest to, że plik nie jest uwzględniany w paczce podczas procesu budowania. Oznacza to, że może być łatwo zaktualizowany nawet po zbudowaniu aplikacji (na przykład bezpośrednio na serwerze, na którym aplikacja jest hostowana). Wszelkie zmiany wprowadzone w tym pliku będą odzwierciedlone natychmiast, bez potrzeby ponownej kompilacji aplikacji, a pliki wynikowe pozostają nienaruszone. Pozwala to także na wdrożenie tej samej paczki z różnymi konfiguracjami.
Poniżej przedstawiono wynik procesu budowania:

Porównanie z podejściem wykorzystującym pliki environment.ts
Jak wspomniano we wstępie, Angular oferuje alternatywne podejście do zarządzania konfiguracją aplikacji za pomocą pliku environment.ts. Jednak czy to podejście zapewnia taką samą elastyczność co użycie tokenu APP_INITIALIZER? Odpowiedź brzmi: nie. Plik environment.ts jest dołączany do paczki podczas procesu budowania, co oznacza, że jego zawartość jest zakodowana na stałe w finalnej paczce aplikacji. To ograniczenie uniemożliwia aktualizowanie lub zmienianie konfiguracji po zbudowaniu i wdrożeniu aplikacji.
W przeciwieństwie do tego, token APP_INITIALIZER pozwala na dynamiczne pobieranie danych konfiguracyjnych w czasie wykonywania aplikacji, co daje możliwość modyfikowania zachowania aplikacji bez potrzeby jej ponownej kompilacji czy wdrażania. Dzięki temu APP_INITIALIZER jest szczególnie przydatny w aplikacjach, które wymagają różnych konfiguracji dla różnych środowisk wdrożeniowych.
Wady związane z wykorzystaniem APP_INITIALIZER_TOKEN
Korzystanie z APP_INITIALIZER_TOKEN może być bardzo przydatne, ale ma pewne wady. Jednym z głównych problemów jest fakt, że w przypadku pobierania danych z zewnętrznego serwisu, wszelkie opóźnienia lub problemy z tym serwisem mogą opóźnić uruchomienie aplikacji. Na przykład, jeśli usługa działa wolno lub zwróci błąd, start aplikacji będzie tymczasowo zablokowany, a w najgorszym wypadku, może nie udać się jej uruchomić.
W przykładzie użytym w tym artykule, ładowany jest lokalny plik JSON, który znajduje się na tym samym serwerze co aplikacja. W takim przypadku opóźnienia zazwyczaj nie stanowią problemu. Ponieważ plik jest lokalny, ładowanie jest szybkie, a opóźnienia sieciowe są minimalne. Dlatego opóźnienie związane z ładowaniem pliku JSON jest mało prawdopodobne i nie powinno stanowić ryzyka.
Podsumowanie
Wykorzystanie tokenu APP_INITIALIZER jest bardzo efektywnym sposobem konfigurowania aplikacji Angular w czasie rzeczywistym. Dzięki ładowaniu konfiguracji zawartej w pliku json można w łatwy sposób zarządzać danymi takimi jak adres API, temat aplikacji, język domyślny czy dostępne funkcje. Dodatkowo, zmiana konfiguracji nie wymaga wprowadzania zmian w kodzie, ani przebudowania plików wynikowych. Takie podejście jest bardziej elastyczne od tradycyjnego podejścia opartego o plik environment.ts, które uniemożliwia zmiany konfiguracji po procesie budowania. Wykorzystanie tokenu APP_INITIALIZER sprawia, że aplikacja może zostać wdrożona dla różnych klientów z różnymi konfiguracjami. Dodatkowo, integracja biblioteki Zod.js zapewnia poprawność konfiguracji z wymaganiami aplikacji. Podsumowując, opisane podejście upraszcza proces zarządzania konfiguracją oraz utrzymania aplikacji.
Source code: https://github.com/maciejkoch/config-example