Ein Angular Plugin-System

Thorsten Rintelen

Seit über 20 Jahren Software-Developer, Spezialisiert auf Angular, Teamleiter Entwicklung bei der traperto GmbH

Ausgangssituation

Das Frontend unserer Kundeninstallationen basiert auf einer Angular-Anwendung, in der wir für jeden Kunden eine eigene APP (auch project genannt) erstellen.
Da alle APPs mehr oder weniger gleich sind, basieren diese auf einer Hand voll Bibliotheken (libraries). In diesen Bibliotheken finden sich die einzelnen gekapselten Feature, verpackt und getrennt in Feature-Modulen, wieder.
Welcher Kunde welche Features bekommt, wird in der Kunden-APP durch den Import der entsprechenden Feature-Module gesteuert.
Soweit ein bekanntes und beliebtes Vorgehen in der Angular-Welt.

Jetzt kommt es vor, dass Feature 1 wissen möchte, ob Feature 2 ebenfalls aktiv ist, um z.B. eine weitere Spalte in einer Tabelle darzustellen oder einen Link zu diesem Feature anzubieten. 
Schön wäre es, wenn wir in Angular prüfen könnten, ob ein Modul importiert worden ist und somit das Feature aktiv ist. Diese Möglichkeit bietet uns Angular leider nicht.

Aus diesem Grund haben wir ein Plugin-System entwickelt, welches genau diese Aufgabe übernimmt: Plugins (Feature) können sich in der APP registrieren, die Überprüfung auf vorhandene Plugins wird ermöglicht. 

Schritt 1 – Basis-System

Für die Registrierung von Plugins können wir uns die Dependency Injection (DI) von Angular zu nutzen machen. 
Dazu definieren wir einen eigenen InjectionToken, den wir in die DI von Angular legen.

export const PLUGIN = new InjectionToken<string>('Plugin');

Damit die Erweiterung einer APP möglichst einfach ist, soll es reichen, das Feature-Modul zu importieren. Aus diesem Grund steuert das Feature-Modul die Registrierung des Tokens in der DI.
Dazu registrieren (providen) wir unseren Token und geben als Wert (value) den Namen des Plugins mit. Da wir mehrere Plugins aktivieren können, handelt es sich hierbei um einen multi-Token, also einem Token der mehrere Werte bekommen kann.

@NgModule({
    declarations: [],
    imports: [],
    providers: [{ provide: PLUGIN, useValue: 'feature1', multi: true }],
})
export class FeatureModule {}

Die DI in Angular weiß damit nach dem Import dieses Moduls automatisch, dass es den Token „PLUGIN“ gibt und dieser einen Wert „feature1“ hat.

Als nächstes wollen wir diesen Token aus der DI rausholen, um überprüfen zu können, ob ein Plugin vorhanden ist. Dazu schreiben wir einen „TokenService“ und legen diesen auf die root-Ebene der APP (provideIn: ‚root‘).
Mit @Inject() können wir auf den Token zugreifen – wir wissen, dass er eine Liste von Namen (strings) enthält. 

Über die Methode „hasPlugin“ können wir nun prüfen, ob ein oder mehrere angefragte Plugins registriert worden sind.
Der Aufruf sieht dann wie folgt aus: service.hasPlugin(‚feature1‘) oder service.hasPlugin([‚feture1‘, ‚feature2‘])

@Injectable({
    providedIn: 'root',
})
export class PluginService {
    private pluginIds: string[];
 
    constructor(@Optional() @Inject(PLUGIN) readonly plugins: string[]) {
        this.pluginIds = plugins ?? [];
    }
 
    hasPlugin(pluginId: string | string[]): boolean {
        const plugins = Array.isArray(pluginId) ? pluginId : [pluginId];
        return this.pluginIds.some(plugin => plugins.includes(plugin));
    }
}

Damit sind wir in der Lage, über den Service anzufragen, ob ein bestimmtes Plugin in unserer APP vorhanden ist. Ziel erreicht? Fast.

Schritt 2 – LazyLoaded Module

Nicht immer werden die Module direkt in der APP importiert. Aus Performance-Gründen können Feature-Module auch zur Laufzeit nachgeladen (lazy loaded) werden. 
In diesem Fall funktioniert die Variante aus Schritt 1 nicht. Da das Modul nicht importiert wird, wird auch der Token nicht registriert und kann über den Service nicht abgefragt werden.

Um möglichst eine zentrale Stelle für die Konfiguration des Features zu haben, schauen wir uns die Stelle an, an der das Feature Modul nachgeladen wird. In Angular passiert dies an der Route.
Über loadChildren wird gesagt, dass das Modul geladen werden soll, sobald der Nutzer diese Route aktiviert / aufruft. 

Zum Start unsere APP ist also das Modul nicht geladen, aber die Routen-Informationen sind vorhanden. Genau hier können wir ansetzen. 

In der Property „data“ der Route ergänzen wir jetzt eine pluginId und geben dieser wieder einen eindeutigen Namen, hier „feature2“.

{
    path: 'feature2',
    loadChildren: () =>
        import('@lib/feature2/feature2.module').then(m => m.Feature2Module),
    data: {
        pluginId: 'feature2',
    },
},

Wie bekommt jetzt unser Service mit, welche Plugins über die Route definiert sind?

Über den Angular Router gehen wir alle routen durch und prüfen, ob eine pluginId im data Property vorhanden ist. Finden wir eine, so melden wir dies dem Service über eine „registerLazy“ Methode. Diese ergänzt die Liste der Plugin IDs im Service und vermischt alle zur Laufzeit nachgeladenen Plugins mit denen aus der DI.
Den entsprechenden Code haben wir im PluginModule hinterlegt. Dieses sammelt alle relevanten Bausteine (siehe auch Bonus), so dass wir in unserem APP Modul nur noch das PluginModul importieren müssen um die „Magie“ zu starten.

@NgModule({
  declarations: [IfPluginDirective],
  imports: [RouterModule],
  exports: [IfPluginDirective],
  providers: [HasPluginGuard],
})
export class PluginModule {
  constructor(readonly router: Router, readonly pluginService: PluginService) {
    router.config
      .filter((route) => !!route.loadChildren && !!route.data?.['pluginId'])
      .forEach((route) => pluginService.registerLazy(route.data?.['pluginId']));
  }
}
registerLazy(pluginId: string) {
    this.pluginIds = this.pluginIds.concat(pluginId);
}

Damit ist das Plugin-System einsatzbereit.

Schritt 3 – Bonus

Um uns das Leben mit Hilfe des Plugin-System weiter zu vereinfachen, haben wir noch zwei Bonus-Elemente bereitgestellt.

Eine directive, mit der wir direkt im HTML auf die Plugin IDs prüfen können…

@Directive({
    selector: '[ifPlugin]',
})
export class IfPluginDirective {
    constructor(
        private readonly templateRef: TemplateRef<any>,
        private readonly viewContainerRef: ViewContainerRef,
        private readonly pluginService: PluginService,
    ) {}
 
    @Input('ifPlugin') set plugin(pluginId: string | string[]) {
        this.viewContainerRef.clear();
 
        if (!pluginId || this.pluginService.hasPlugin(pluginId)) {
            this.viewContainerRef.createEmbeddedView(this.templateRef);
        }
    }
}

…und einen Guard, der eine Route nur dann für gültig erklärt, wenn das entsprechende Plugin vorhanden ist.

@Injectable()
export class HasPluginGuard implements CanActivate {
    constructor(private readonly pluginService: PluginService) {}
 
    canActivate(route: ActivatedRouteSnapshot): boolean {
        return this.pluginService.hasPlugin(route.data.pluginId);
    }
}

Fazit zum Angular Plugin-System

Mit diesem Plugin-System ist es jederzeit möglich, Querverweise zwischen einzelnen Featuren durchzuführen, falls diese vorhanden sind. Die Nutzung ist denkbar einfach, die Umsetzung kein Hexenwerk – unter anderem Dank der DI von Angular, die uns vieles ermöglicht. 

Die genannten Beispiele würden auch ohne Module, also mit „standalone components“ funktionieren. Das Vorgehen bleibt exakt das Gleiche. 

Den vollständigen Beispiel-Code gibt es auf GitHub unter https://github.com/ThRintelen/angular-pluginsystem