Blog

Eigene Standalone APIs für Angular

16 Mrz 2023

Gemeinsam mit Standalone Components hat das Angular-Team die sogenannten Standalone APIs eingeführt. Sie bieten eine geradlinige Lösung zum Einrichten von Bibliotheken und kommen ohne Angular-Module aus. Populäre Beispiele von Bibliotheken, die diese Idee bereits aufgreifen, sind der HttpClient, der Router, aber auch NgRx. Diese Bibliotheken basieren auf einigen Mustern, die auch für eigene Vorhaben nützlich sind. Außerdem bieten sie den Konsument:innen unserer Bibliotheken vertraute Strukturen und Verhaltensweisen. In diesem Artikel stelle ich drei solcher Muster vor, die ich aus den genannten Bibliotheken abgeleitet habe. Der Quellcode mit den dazu verwendeten Beispielen findet sich unter [1].

Beispiel

Um die einzelnen Muster zu veranschaulichen, kommt hier eine einfache Logger-Bibliothek zum Einsatz (Abb. 1). Der LogFormatter formatiert die Nachrichten, bevor sie der Logger veröffentlicht. Es handelt sich dabei um eine abstrakte Klasse, die als DI-Token zum Einsatz kommt. Die Konsument:innen der Logger-Bibliothek können die Formatierung anpassen, indem sie eine eigene Implementierung bereitstellen. Alternativ können sie sich mit einer von der Bibliothek bereitgestellten Standardimplementierung zufriedengeben.

Abb. 1: Struktur einer beispielhaften Logger-BibliothekAbb. 1: Struktur einer beispielhaften Logger-Bibliothek

Der LogAppender ist ein weiteres austauschbares Konzept, das sich um das Anhängen der Nachricht an ein Protokoll kümmert. Die Standardimplementierung schreibt die Nachricht lediglich auf die Konsole.

Während es nur einen einzigen LogFormatter geben kann, unterstützt die Bibliothek mehrere LogAppender. Beispielsweise könnte ein erster LogAppender die Nachricht auf die Konsole schreiben, während ein zweiter sie auch an den Server sendet. Um das zu ermöglichen, werden die einzelnen LogAppender über Multiprovider registriert. Alle registrierten LogAppender gibt der Injector in Form eines Arrays zurück. Da sich ein Array nicht als DI-Token verwenden lässt, nutzt das Beispiel stattdessen ein InjectionToken:

export const LOG_APPENDERS =
  new InjectionToken<LogAppender[]>("LOG_APPENDERS");

Eine abstrakte LoggerConfig, die ebenfalls als DI-Token fungiert, definiert die möglichen Konfigurationsoptionen (Listing 1).

Listing 1

export abstract class LoggerConfig {
  abstract level: LogLevel;
  abstract formatter: Type<LogFormatter>;
  abstract appenders: Type<LogAppender>[];
}
 
export const defaultConfig: LoggerConfig = {
  level: LogLevel.DEBUG,
  formatter: DefaultLogFormatter,
  appenders: [DefaultLogAppender],
};

Die Standardwerte für diese Konfigurationsoptionen befinden sich in der Konstante defaultConfig. Das LogLevel in der Konfiguration versteht sich als Filter für Lognachrichten. Es ist vom Typ enum und weist zur Vereinfachung lediglich die Werte DEBUGINFO und ERROR auf:

export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  ERROR = 2,
}

Der Logger veröffentlicht nur Nachrichten, die den hier angegebenen oder einen höheren LogLevel aufweisen. Der LoggerService selbst erhält die LoggerConfig, den LogFormatter und ein Array mit LogAppender via DI und protokolliert damit die erhaltenen Nachrichten (Listing 2).

Listing 2

@Injectable()
export class LoggerService {
  private config = inject(LoggerConfig);
  private formatter = inject(LogFormatter);
  private appenders = inject(LOG_APPENDERS);
 
  log(level: LogLevel, category: string, msg: string): void {
    if (level < this.config.level) {
      return;
    }
    const formatted = this.formatter.format(level, category, msg);
    for (const a of this.appenders) {
      a.append(level, category, formatted);
    }
  }
 
  error(category: string, msg: string): void {
    this.log(LogLevel.ERROR, category, msg);
  }
 
  info(category: string, msg: string): void {
    this.log(LogLevel.INFO, category, msg);
  }
 
  debug(category: string, msg: string): void {
    this.log(LogLevel.DEBUG, category, msg);
  }
}

Newsletter

Jetzt anmelden & regelmäßig wertvolle Insights in Angular sowie aktuelle Weiterbildungen erhalten!

Die goldene Regel

Bevor wir einen Blick auf die Muster werfen, möchte ich noch meine goldene Regel für die Registrierung von Services erwähnen: Verwenden Sie @Injectable({providedIn: ‚root‘}) wann immer möglich! Besonders in Anwendungen, aber auch in zahlreichen Situationen in Bibliotheken reicht diese Vorgehensweise vollkommen aus. Sie ist einfach, treeshakable und funktioniert sogar mit Lazy Loading. Letztgenannter Aspekt ist weniger ein Verdienst von Angular als des zugrunde liegenden Bundler. Alles, was nur in einem lazy Bundle zum Einsatz kommt, ist auch dort untergebracht.

Muster: Provider Factory

Eine Provider Factory ist eine Funktion, die sämtliche Services für eine wiederverwendbare Bibliothek zurückliefert. Sie kann auch Konfigurationsobjekte als Services registrieren oder Service-Implementierungen austauschen.

Die zurückgelieferten Services befinden sich in einem Provider-Array, das die Factory mit dem Typ EnvironmentProviders ummantelt. Diese vom Angular-Team konzipierte Vorgehensweise stellt sicher, dass eine Anwendung die Provider nur bei sogenannten Environment Injectors registrieren kann. Dabei handelt es sich primär um den Injector für den Root-Scope sowie um Injectors, die Angular für einzelne Anwendungsbereiche über das Routing einrichtet. Die Provider-Factory in Listing 3 veranschaulicht das. Sie nimmt eine LoggerConfig entgegen und richtet die einzelnen Services für den Logger ein.

Listing 3

export function provideLogger(
  config: Partial<LoggerConfig>
): EnvironmentProviders {
  // using default values for   // missing properties
  const merged = { ...defaultConfig, ...config };
 
  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LogFormatter,
      useClass: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),
  ]);
}

Fehlende Konfigurationswerte entnimmt die Factory der Standardkonfiguration. Die von Angular angebotene Funktion makeEnvironmentProviders verpackt das Provider-Array in eine Instanz von EnvironmentProviders. Diese Factory ermöglicht es den Konsumenten, den Logger ähnlich wie auch den HttpClient oder den Router einzurichten (Listing 4).

Listing 4

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideRouter(APP_ROUTES),
    [...]
    provideLogger(loggerConfig),
  ]
}

BRINGEN SIE LICHT INS ANGULAR-DUNKEL

Die ersten Schritte in Angular geht man am besten im Basic Camp.
→ Nächster Termin: 17. - 19. Juni, online

Muster: Feature

Das Feature-Muster erlaubt das Aktivieren und Konfigurieren optionaler Funktionalität. Kommt diese Funktionalität nicht zum Einsatz, entfernt sie der Build-Prozess mittels Treeshaking. Das optionale Feature wird durch ein Objekt mit einem providers-Array repräsentiert. Außerdem weist das Objekt eine Eigenschaft kind auf, die das Feature einer bestimmten Kategorie untergliedert. Diese Kategorisierung ermöglicht die Validierung der gemeinsam konfigurierten Features. Beispielsweise können sich Features gegenseitig ausschließen. Ein Beispiel dafür finden wir im HttpClient: Er verbietet die Nutzung eines Features zur Konfiguration des XSRF-Handlings, wenn die Konsument:innen gleichzeitig ein Feature zu dessen Deaktivierung aktiviert haben.

Die hier benutzte Logger Library verwendet ein ColorFeature, das es ermöglicht, Meldungen je nach LoggerLevel in verschiedenen Farben auszugeben (Abb. 2).

Abb. 2: Struktur des ColorFeaturesAbb. 2: Struktur des ColorFeatures

Zur Kategorisierung von Merkmalen kommt ein enum zum Einsatz:

export enum LoggerFeatureKind {
  COLOR,
  OTHER_FEATURE,
  ADDITIONAL_FEATURE
}

Objekte des Typs LoggerFeature repräsentieren die einzelnen Features:

export interface LoggerFeature {
  kind: LoggerFeatureKind;
  providers: Provider[];
}

Zur Bereitstellung des ColorFeature kommt eine weitere Factory zum Einsatz (Listing 5).

Listing 5

export function withColor(config?: Partial<ColorConfig>): LoggerFeature {
  const internal = { ...defaultColorConfig, ...config };
 
  return {
    kind: LoggerFeatureKind.COLOR,
    providers: [
      {
        provide: ColorConfig,
        useValue: internal,
      },
      {
        provide: ColorService,
        useClass: DefaultColorService,
      },
    ],
  };
}

Die aktualisierte Provider Factory provideLogger übernimmt mehrere Features über einen optionalen zweiten Parameter, der als Rest-Array definiert ist (Listing 6).

Listing 6

export function provideLogger(
  config: Partial<LoggerConfig>,
  ...features: LoggerFeature[]
): EnvironmentProviders {
  const merged = { ...defaultConfig, ...config };
 
  // Inspecting passed features
  const colorFeatures =
    features?.filter((f) => f.kind === LoggerFeatureKind.COLOR)?.length ?? 0;
 
  // Validating passed features
  if (colorFeatures > 1) {
    throw new Error("Only one color feature allowed for logger!");
  }
 
  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LogFormatter,
      useClass: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),
 
    // Providing services for the     // features
    features?.map((f) => f.providers),
  ]);
}

Die Provider Factory nutzt die Eigenschaft kind, um die übergebenen Features zu untersuchen und zu validieren. Wenn alles in Ordnung ist, nimmt es die Provider des Features in das EnvironmentProviders-Objekt auf. Der DefaultLogAppender holt sich den ColorService, der vom ColorFeature per Dependency Injection bereitgestellt wird (Listing 7).

Listing 7

export class DefaultLogAppender implements LogAppender {
  colorService = inject(ColorService, { optional: true });
 
  append(level: LogLevel, category: string, msg: string): void {
    if (this.colorService) {
      msg = this.colorService.apply(level, msg);
    }
    console.log(msg);
  }
}

Da Features optional sind, übergibt der DefaultLog-Appender die Option {optional: true} an inject. Das verhindert eine Exception in Fällen, in denen das Feature und somit der ColorService nicht bereitgestellt wurden. Außerdem muss der DefaultLogAppender auf Nullwerte prüfen.

Dieses Muster kommt im Router vor, z. B. um das Preloading zu konfigurieren oder um Tracing zu aktivieren. Der HttpClient nutzt es zum Bereitstellen von Interceptors, zum Konfigurieren von JSONP und zum Konfigurieren/Deaktivieren des XSRF-Token-Handlings [2].

ABTAUCHEN IM DEEP DIVE

Im Fortgeschrittenen Camp tauchen Sie ab unter die Oberfläche einer modernen Angular-Anwendung.
→ Nächster Termin: 13. - 15. Mai, München

Muster: Configuration Factory

Configuration Factories erweitern das Verhalten vorhandener Services. Sie können dazu weitere Konfigurationsoptionen, aber auch zusätzliche Services bereitstellen. Zur Veranschaulichung soll hier eine erweiterte Version unseres LoggerService dienen. Sie erlaubt es, für jede Logkategorie einen zusätzlichen LogAppender zu definieren:

@Injectable()
export class LoggerService {
  readonly categories: Record<string, LogAppender> = {};
  […]
}

Um einen LogAppender für eine Kategorie zu konfigurieren, führen wir eine Configuration Factory mit dem Namen provideCategory ein (Listing 8).

Listing 8

export function provideCategory(
  category: string,
  appender: Type<LogAppender>
): EnvironmentProviders {
  // Internal/ Local token for registering the service
  // and retrieving the resolved service instance
  // immediately after.
  const appenderToken = new InjectionToken<LogAppender>("APPENDER_" + category);
 
  return makeEnvironmentProviders([
    {
      provide: appenderToken,
      useClass: appender,
    },
    {
      provide: ENVIRONMENT_INITIALIZER,
      multi: true,
      useValue: () => {
        const appender = inject(appenderToken);
        const logger = inject(LoggerService);
 
        logger.categories[category] = appender;
      },
    },
  ]);
}

Diese Factory erstellt einen Provider für die Klasse LogAppender. Der Aufruf von inject gibt uns eine Instanz davon und löst ihre Abhängigkeiten auf. Das Token ENVIRONMENT_INITIALIZER verweist auf eine Funktion, die Angular beim Initialisieren des jeweiligen Environment Injector anstößt. Sie registriert den LogAppender beim LoggerService (Listing 9).

Listing 9

export const FLIGHT_BOOKING_ROUTES: Routes = [
 
  {
    path: '',
    component: FlightBookingComponent,
    providers: [
      // Setting up an NgRx      // feature slice
      provideState(bookingFeature),
      provideEffects([BookingEffects]),
 
      // Provide LogAppender for      // logger category
      provideCategory('booking', DefaultLogAppender),
    ],
    children: [
      {
        path: 'flight-search',
        component: FlightSearchComponent,
      },
      [...]
    ],
  },
];

Dieses Muster findet man zum Beispiel in NgRx, um Feature-Slices zu registrieren. Auch das vom Router angebotene Feature withDebugTracing [3] nutzt dieses Muster, um das Observable events im Router Service zu abonnieren.

Fazit

Standalone APIs erlauben das Einrichten von Bibliotheken ohne Angular-Module. Ihre Nutzung ist zunächst einmal einfach: Konsument:innen müssen zunächst lediglich nach einer Provider Factory mit dem Namen provideXYZ Ausschau halten. Zusätzliche Features lassen sich ggf. mit Funktionen aktivieren, die sich am Namensschema withABC orientieren.

Die Implementierung solcher APIs ist jedoch nicht immer ganz trivial. Genau dabei helfen die hier vorgestellten Muster. Da sie aus Bibliotheken des Angular- und NgRx-Teams abgeleitet sind, spiegeln sie Erfahrungswerte und Entwurfsentscheidungen aus erster Hand wider.