Kolumne: Die Angular-Abenteuer

Angular-Anwendungen anpassen: Wie Sie kundenspezifische Modifikationen wartbar integrieren

Manfred Steyer

In der Kolumne Die Angular-Abenteuer stürzt sich Manfred Steyer mitten ins Getümmel der dynamischen Welt der Webentwicklung. Tipps und Tricks für Angular-Entwickler kommen dabei ebenso zur Sprache wie aktuelle Entwicklungen im JavaScript-Ökosystem.

Produkte werden in der Regel gemeinsam mit Dienstleistungen verkauft. Das ist bei Softwareprodukten nicht anders. Häufig möchte der Kunde eine gekaufte Standardsoftware anpassen lassen, damit sie den eigenen Geschäftsprozessen besser entspricht. Doch wie entwickelt man den Kern der Software dergestalt, dass er für unterschiedliche Einsatzzwecke anpassbar wird?

In dieser Ausgabe zeige ich, wie man ein solches Vorhaben mit Angular realisieren kann. Dazu nutze ich ein einfaches Beispiel mit einer Komponente zum Bezahlen eines Einkaufs. Abbildung 1 zeigt ihre Standardimplementierung, die eine Bezahlung mit Kreditkarte erlaubt.

Abb. 1: Standardimplementierung des Zahlungsbereiches

Abb. 1: Standardimplementierung des Zahlungsbereiches

Die gezeigte Komponente besteht aus zwei weiteren, deren Grenzen mit einer gestrichelten Linie gekennzeichnet sind. Bei kundenspezifischen Anpassungen sollen nun all diese Komponenten oder auch nur ein Teil davon austauschbar sein. Das Gleiche gilt für Services, die sich um die Zahlungsabwicklung kümmern. Ein Beispiel für eine solche Anpassung zeigt Abbildung 2.

Abb. 2: Kundenspezifische Variante des Zahlungsbereichs

Abb. 2: Kundenspezifische Variante des Zahlungsbereichs

Die hier vorgestellte Lösung befindet sich wie immer auf GitHub.

Angular-Anpassung zur Laufzeit oder beim Build?

Für die Umsetzung kundenspezifischer Anpassungen hat man im Grunde zwei Möglichkeiten. Entweder man kümmert sich erst zur Laufzeit darum oder aber bereits beim Build, sodass das Kompilat bereits auf den Kunden abgestimmt ist.

Bevorzugt man die erste Variante, lassen sich mit Verzweigungen (if, switch etc.) parallel mehrere Varianten implementieren und zum Beispiel über eine Konfiguration aktivieren. Der Nachteil dieser Möglichkeit ist, dass das den Code aufbläht und die Wartbarkeit verringert. Der Einsatz von Dependency Injection schafft hier Abhilfe: Beispielsweise könnte die Anwendung für jeden Service eine factory-Funktion bei Angular registrieren. Diese entscheidet dann, welche Ausprägung des Services zu nutzen ist.

Geht man noch einen Schritt weiter, lädt man die vom Kunden gewünschten Service- und Komponentenvarianten dynamisch zur Laufzeit. Besonders einfach gestaltet sich das dynamische Nachladen, wenn Web Components zum Einsatz kommen.

Wer hingegen das Maximum an Performance herausholen möchte, integriert die kundenspezifischen Anpassungen bereits im Build. Das minimiert den Aufwand zur Laufzeit und erlaubt Optimierungen wie Tree Shaking. Außerdem erhält der Kunde nur die lizensierten Programmteile. Das Nachladen einzelner Teile kann hier mittels Lazy Loading bewerkstelligt werden. Genau diesen Aspekt werden wir uns hier näher ansehen. Dazu nutze ich den derzeit sehr beliebten Monorepo-Ansatz.

Flexibilität mit Monorepos

Bei einem Monorepo handelt es sich eigentlich nur um einen Ordner mit zusammengehörigen Projekten (Abb. 3).

Abb. 3: Monorepo

Abb. 3: Monorepo

Jedes dieser Projekte kann entweder eine ausführbare Anwendung oder eine wiederverwendbare Bibliothek sein. Bei flight-app handelt es sich im betrachteten Beispiel um ersteres, bei der payment-lib, die die eingangs gezeigten Komponenten beherbergt, um letzteres. Die für einen bestimmten Kunden angepasste Variante findet sich in der Bibliothek payment-lib-customer-a.

Für alle Projekte existiert ein globaler node_modules-Ordner mit gemeinsamen externen Paketen, wie zum Beispiel Angular oder Bootstrap. Somit kommen überall dieselben Versionen der externen Pakete zum Einsatz, was Versionskonflikte verhindert.

Normalerweise greifen die einzelnen Projekte auch nicht direkt aufeinander zu. Stattdessen bietet jede Bibliothek eine Datei public_api.ts. Diese Datei stellt eine Fassade vor der Bibliothek dar und veröffentlicht diejenigen Teile, die für die Konsumenten bestimmt sind, Implementierungsdetails schirmt sie hingegen ab. Die Fassade der payment-lib ist in Listing 1 zu sehen.

export * from './lib/payment-lib.module';

export * from './lib/payment.service';
export * from './lib/default-payment.service';

// Payment-Card
export * from './lib/container/container.component';

// Not customized component with buyer's name
export * from './lib/non-customized/non-customized.component';

// Customized component with payment details
// (e. g. credit card, bank info etc.)
export * from './lib/payment.component';

Seit Version 6 kann die Angular CLI sogar beim Aufbau einer solchen Projektstruktur unterstützen. Nach dem Anlegen eines neuen Projekts mit ng new erlaubt sie das Hinzufügen von Subprojekten. Die Anweisung ng generate application flight-app fügt beispielsweise eine ausführbare Anwendung hinzu. Analog dazu lässt sich mit ng generate library payment-lib eine Bibliothek erzeugen.

Damit sich die einzelnen Projekte gegenseitig referenzieren können, werden ihre Pfade auf logische Namen gemappt. Das geschieht in der tsconfig.json (Listing 2).

"paths": {
  "@flight-workspace/flight-api": [
    "projects/flight-api/src/public_api"
  ],
  "@flight-workspace/payment-lib": [
    "projects/payment-lib/src/public_api"
  ],
  "@flight-workspace/payment-lib-customer-a": [
    "projects/payment-lib-customer-a/src/public_api"
  ]
},

Diese logischen Namen verweisen auf die Fassade der jeweiligen Bibliothek. Um darauf zu verweisen, nutzt man den logischen Namen in den einzelnen import-Statements:

import { PaymentLibModule } from '@flight-workspace/payment-lib';

Aus Sicht der Anwendung gibt es also keinen Unterschied zwischen dem Referenzieren eines heruntergeladenen npm-Paketes und einer eigenen Bibliothek. Hinzu kommt, dass der Quellcode keine Annahmen über den Ordner der Bibliothek treffen muss. Diese lässt sich somit durch Abändern der gezeigten Mappings einfach austauschen. Genau das ist hier der Schlüssel zur Integration kundenspezifischer Erweiterungen.

Umsetzung

Die kundenspezifische Variante der Bibliothek weist hinter ihrer Fassade natürlich ein Angular-Modul auf. Dieses registriert für Komponenten und Services, die es auszutauschen gilt, entsprechende Gegenstücke. Diejenigen Komponenten und Services, die der Kunde eins zu eins übernehmen möchte, sind hier erneut zu registrieren (Listing 3).

import { NgModule } from '@angular/core';

// Customized Service
import { PaymentCustomerAService } from './payment-customer-a.service';

// Customized Component
import { PaymentCustomerAComponent } from './payment-customer-a.component';

// Non customized stuff (relative path)
import { ContainerComponent } from '../../../payment-lib/src/lib/container/container.component';

// Non customized stuff (internal mapping)
import { NonCustomizedComponent } from '@internal/payment-lib/non-customized/non-customized.component';
import { PaymentService } from '@internal/payment-lib/payment.service';

@NgModule({
  imports: [
  ],
  providers: [
    {provide: PaymentService, useClass: PaymentCustomerAService }
  ],
  declarations: [
    PaymentCustomerAComponent, 
    ContainerComponent, 
    NonCustomizedComponent
  ],
  exports: [
    ContainerComponent
  ]
})
export class PaymentLibModule { }

Besonders zu beachten ist, dass von der payment-lib übernommene Building Blocks nicht über deren Fassade beziehungsweise ihren logischen Namen importiert werden dürfen. In diesem Fall würde Angular nämlich in der Fassade auch das Modul der payment-lib entdecken. Da dieses teilweise dieselben Komponenten wie die payment-lib-customer-a registriert, reagiert Angular mit einer Fehlermeldung: Eine Komponente muss eben bei genau einem Modul hinterlegt sein.

Zur Umgehung dieses Problems demonstriert das gezeigte Listing zwei Optionen. Im Falle der ContainerComponent kommt ein relativer Verweis zum Einsatz. Im Falle der beiden weiteren Importe greift das Beispiel auf einen zusätzlichen logischen Namen zurück. Dieser verweist auf den Ordner der Bibliothek und nicht auf ihre Fassade:

"@internal/payment-lib/*": [
  "projects/payment-lib/src/lib/*"
]

Um nun die kundenspezifische Anpassung zu aktivieren, ist lediglich das Mapping in der tsconfig.json anzupassen:

"@flight-workspace/payment-lib": [
  // "projects/payment-lib/src/public_api"
  "projects/payment-lib-customer-a/src/public_api"
]

Damit das funktioniert, muss die Fassade von payment-lib-customer-a mit der der payment-lib kompatibel sein. Das Gleiche gilt für das bereitgestellte Angular-Modul. Ersteres kann der TypeScript-Compiler sehr einfach sicherstellen, für Letzteres sind Integrationstests notwendig.

Angular-Anwendungen anpassen – Fazit

Das Anpassen eines Produkts durch den Austausch von Bibliotheken in einem Monorepo ist sehr geradlinig und bietet die beste Laufzeitperformance. Ein Grund dafür ist, dass die für den jeweiligen Kunden bestimmten Sourcen gemeinsam kompiliert werden, was Optimierungen wie Tree Shaking möglich macht. Durch den zusätzlichen Einsatz von Lazy Loading können zur Laufzeit trotzdem bestimmte Teile erst bei Bedarf vom Server geladen werden. Außerdem erhält der Kunde auf diesem Weg auch nur die von ihm lizensierten Programmteile.

Ein wenig störend ist die Tatsache, dass man mittels Integrationstests die Kompatibilität der kundenspezifischen zu den ursprünglichen Bibliotheken sicherstellen muss. Allerdings ist der Einsatz von Integrationstests ohnehin eine Best Practice, die die Qualität der Entwicklung auch anderweitig unterstützt.

Daneben ist das Entwicklungsteam auch gezwungen, viele kleine austauschbare Bibliotheken im Monorepo zu erzeugen. Auch das ist eine Best Practice, zumal es die Komplexität reduziert und die Wartbarkeit verbessert. Unterstützt wird das auch durch den Einsatz einer Fassade pro Bibliothek, die nur die öffentlichen Bestandteile für andere Programmteile veröffentlicht und Implementierungsdetails verbirgt.

Geschrieben von
Manfred Steyer
Manfred Steyer
Manfred Steyer ist selbstständiger Trainer und Berater mit Fokus auf Angular 2. In seinem aktuellen Buch "AngularJS: Moderne Webanwendungen und Single Page Applications mit JavaScript" behandelt er die vielen Seiten des populären JavaScript-Frameworks aus der Feder von Google. Web: www.softwarearchitekt.at
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: