Suche
Implementierung von Querschnittsaspekten in Angular 2

Architektur von Webclients: Logging, Caching, Security mit Angular 2 und TypeScript

Karsten Sitterberg

(c) Shutterstock / grmarc

Mit diesem Beitrag startet Karsten Sitterberg in seine neue Kolumne zur Webentwicklung mit Angular 2. Am Anfang zeigt Karsten in einer dreiteiligen Serie, mit welchen unterschiedlichen Ansätzen sich Querschnittsaspekte wie Logging, Caching oder Security-Anforderungen umsetzen lassen – und wie sich diese langfristig auf die Wartbarkeit einer Webanwendung auswirken.

x

x

Update: Seit Angular 4.3.1 existiert durch den neuen HttpClient die Möglichkeit, Interceptoren einzusetzen, um darüber Querschnittsaspekte abzubilden. Hinweise zu diesem Update und der Verwendung von Interceptoren finden sich hier: Abgefangen: So funktionieren Http-Interceptoren in Angular

Querschnittsaspekte in Angular 2 mit TypeScript

Karsten Sitterberg

Karsten Sitterberg

Viele Softwareprojekte beinhalten Querschnittsaspekte wie Logging, Caching oder die Umsetzung von Sicherheitsanforderungen. Es lohnt sich an dieser Stelle, sich Zeit für das Design zu nehmen, da gerade solche Anforderungen schnell zu starker Kopplung oder „duplicate code“ neigen. Vernachlässigt man entsprechendes Vorabdesign, kann im Ergebnis unbeabsichtige Komplexität und eine schwer zu erweiternde oder wartende Codebasis stehen.

In dieser Artikelserie wird die Anforderung, alle HTTP-Anfragen zu protokollieren, als Beispiel für einen Querschnittsaspekt gewählt, um verschiedene Ansätze zu illustrieren. Um die Implementierung einfach zu halten, soll lediglich eine Ausgabe durch das console-Objekt erfolgen. Auch wenn es sich um eine stark vereinfachte Anforderung handelt, ist dies ein gutes Beispiel für häufig in der Praxis anzutreffende Anforderungen bei Webfrontend- und Angular-2-Projekten.

Anhand verschiedener Design- und Implementierungsoptionen betrachten wir die Auswirkungen auf die Anwendung: Ein gutes Design sollte nicht die Prinzipien DRY (Don’t repeat yourself, doppelter Code)  und SRP (Single Responsibility Principle, Prinzip der einen Verantwortlichkeit) verletzen. Dafür gibt es – wie so oft – keine kanonische Lösung oder einen goldenen Hammer. Wir werden im Laufe der Artikelserie sehen, dass jeder Ansatz verschiedene Eigenschaften in Bezug auf Testbarkeit, Erweiterbarkeit und Wartbarkeit hat.

Unser Beispiel ist eine Anwendung, die die Entitäten Customer und Invoice verwaltet und diese durch ein HTTP API bezieht. Bei jedem Aufruf der HTTP API soll die URL protokolliert werden.

Angular 2 TutorialSerie: Querschnittsaspekte mit Angular 2

Beispiel-Szenario

Innerhalb der Anwendung stellen Customers und Invoices die wesentlichen Geschäftsobjekte dar. Wie bei Angular üblich, wird der Zugriff auf Customer durch den CustomerService modelliert, während Zugriff auf die Invoice-Ressourcen durch den InvoiceService bereitgestellt wird. Beide Dienste nutzen HTTP/JSON, um auf das Backend zuzugreifen.

Da hier der Fokus auf den Querschnittsaspekten liegen soll, wird die Darstellung und Integration mit anderen Komponenten auf einer höheren Ebene in diesem Artikel nicht behandelt. Die folgende Liste stellt eine Übersicht der einzelnen Aufgaben beim Zugriff auf Backend-Resourcen dar:

Individuelle Aufgaben jedes Dienstes:

  • Ermittlung der anzufragenden URL
  • Ausführung der HTTP-Anfrage an die URL
  • Mapping der Ergebnisdaten auf den Zieltyp

Querschnittsaspekt:

  • Protokollierung

Wir schauen uns diese Schritte am Beispiel einer Liste von Invoice an, die durch die Invoice-Ressource mit der Basis URL http://api.example.com/ geladen werden soll. In dieser Konstellation kann die vollständige URL dadurch ermittelt werden, dass der Name der InvoiceRessource an den Pfad der Basis-URL angehängt wird. Die URL lautet dann http://api.example.com/invoices/.

Um die Daten der Invoice abzufragen, wird ein HTTP GET Request ausgeführt. Dazu wird der Angular http-Dienst verwendet.

Die Antwort enthält dann die Daten als JSON-Objekt. Da TypeScript zum Einsatz kommt, muss anschließend eine Konvertierung des JavaScript-Objektes in ein TypeScript-Objekt mit dem richtigen Typen erfolgen. Um dies zu erreichen, kann ein neues TypeScript-Objekt instantiiert und anschließend mit den JSON-Daten passend befüllt werden. Um die Beispiele kurz zu halten, verwenden wir stattdessen eine Typ-Zusicherung (Type Assertion). Dabei wird TypeScript angewiesen, den folgenden Ausdruck so zu behandeln, als habe er den zugesicherten Typ. Zur Laufzeit entfallen die Typinformationen sowieso, da reguläres JavaScript die Zielsprache ist.

Das folgende Beispiel zeigt, wie dies für ein Array von Invoice aussehen kann:

.map((response) => <Invoice[]>response.json());

Am Ende muss noch die URL auf der console protokolliert werden. Auch wenn das für den produktiven Einsatz so sicherlich nicht gemacht würde, ist das für unser Beispiel ausreichend:

console.log(`url: ${url}`);

Beim Vergleich der verschiedenen Lösungen betrachten wir, an welcher Stelle die jeweilige Verantwortlichkeit implementiert ist, und wie sich Änderungen an den Anforderungen auf die Implementierung auswirken. Um zu bewerten, wie sich Änderungen auswirken, wird als Szenario eine Änderung des zu verwendenden Logformats zu einem späteren Zeitpunkt gewählt.

Um zu bewerten, wie sich Erweiterungen auswirken, wird als Szenario die Umsetzung der zusätzlichen Anforderung von Caching gewählt.

Als sehr simpler Ansatz mit minimalem Designaufwand werden alle Anforderungen direkt implementiert. Am Anfang ist die gefühlte Entwicklungsgeschwindigkeit gut und es sind keine Probleme mit diesem Vorgehen offensichtlich. Wir werden sehen, wie sich Probleme wie schwer wartbarer Code und Verlust von Produktivität ergeben, wenn die Anwendung wächst.

Simples Vorgehen

Angular2 - Bild 1

Bei diesem sehr einfachen Vorgehen implementiert jeder Dienst, der API Ressourcen bereitstellt, alle Funktionalität selbst. Dies beinhaltet dann sowohl die direkten Verantwortlichkeiten als auch Querschnittsaspekte, zwischen denen nicht differenziert wird. Das folgende Beispiel zeigt, wie dies mit Angular 2 und TypeScript aussehen könnte:

customer.service.ts
import {Injectable} from '@angular/core'
import {Http} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {Customer} from './customer.model';
@Injectable()
export class CustomerService {
 private name: string = 'customers';
 constructor(private http: Http) {}
 protected buildURL(): string {
   return `/data/${this.name}/data.json`;
 }
 public getCustomers(): Observable<Customer[]> {
   let url = this.buildURL();
   console.log(`url: ${url}`);
   return this.http.get(url)
     .map((response) => <Customer[]>response.json());
 }
}


invoice.service.ts
import {Injectable} from '@angular/core'
import {Http} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {Invoice} from './invoice.model';
@Injectable()
export class InvoiceService {
 private name: string = 'invoices';
 constructor(private http: Http) {}
 protected buildURL(): string {
   return `/data/${this.name}/data.json`;
 }
 public getInvoices(): Observable<Invoice[]> {
   let url = this.buildURL();
   console.log(`url: ${url}`);
   return this.http.get(url)
     .map((response) => <Invoice[]>response.json());
 }
}

(Hinweis: Alle URLs in den Beispielen zeigen auf eine lokale JSON-Datei als Ersatz für ein echtes Backend.)

Bei diesem Ansatz kann die Implementierung sehr schnell und mit minimalem Planungsaufwand entwickelt werden. Bei umfangreicheren Anwendungen, die über eine längere Zeit gewartet werden sollen, fallen die Kosten für doppelte Implementierungen entsprechend hoch aus: Ermittlung der URL, Ausführung des HTTP-Aufrufs, Datenmapping und Logging sind jeweils für jeden Dienst zu implementieren.

Doch dies ist ein offensichtlicher Verstoß gegen die DRY- und SRP-Prinzipien. Das wird noch offensichtlicher, wenn man sich die Auswirkungen auf die zugehörigen Tests anschaut: Jeder einzelne Dienst muss daraufhin getestet werden, ob alle Funktionen korrekt implementiert sind. Damit erhält man auch duplizierten Testcode und entsprechend komplexe Tests.

Soll das Logformat geändert werden, besteht darüber hinaus das Risiko, dass dies nicht konsistent an allen Stellen umgesetzt wird, da die Änderung mehrfach an unterschiedlichen Stellen vorgenommen werden muss. Das Risiko steigt dann entsprechend, wenn mehrere Entwickler parallel an der Codebasis arbeiten, möglicherweise noch auf verschiedenen Branches.

Ein ähnliches Bild zeichnet sich ab, wenn eine Erweiterung vorgenommen werden soll. Soll Caching als neue Anforderung implementiert werden, muss diese in jedem Dienst implementiert werden und auch alle Tests angepasst werden. Auch hier besteht bei mehreren Entwicklern oder Branches ein hohes Risiko, dass es zu einer inkonsistenten Implementierung kommt.

Dieser Ansatz eignet sich also nur für kleine Demos, Proof-Of-Concept oder auch Lernprojekte. In einem produktiven Umfeld für größere Anwendungen, die über einen längeren Zeitraum entwickelt werden, ist davon abzuraten. Die anfängliche Produktivität wandelt sich im Laufe der Zeit zu einer großen, schwer wartbaren Codebasis. Änderungen müssen mehrfach an unterschiedlichen Stellen implementiert werden, was bei Entwicklern schnell zu Copy-and-Paste führt. Typische Symptome sind behoben geglaubte Fehler, die an anderer Stelle erneut auftauchen, veraltete oder nicht zum Code passende Dokumentation und vernachlässigte Tests.

Um unser Projekt frühzeitig vor solchen Auswüchsen zu schützen, lohnt es also, sich explizit mit dem Design und der Implementierung von Querschnittsaspekten zu befassen. Damit ein Projekt davor bewahrt wird, unbeabsichtigt in so eine Situation zu kommen, haben sich regelmäßige Code- und Designreviews bewährt.

Überarbeiteter Ansatz

Wir überarbeiten unser Design aus dem vorherigen Szenario und führen eine Komponente ein, die das gemeinsame Verhalten bündelt, den BackendService.

Angular2 - Bild 2

In dieser Komponente wird sowohl die HTTP-Kommunikation als auch das Logging implementiert. Alle anderen Dienste nutzen Delegation, um den BackendService zu integrieren.

Das folgende Codebeispiel zeigt, wie die Implementierung des BackendService und eines anderen Dienstes, der den BackendService nutzt, aussehen kann.

backend.service.ts
import {Injectable} from '@angular/core'
import {Http} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
@Injectable()
export class BackendService{
 constructor(private http: Http) {
 }
 public get(url:string): Observable<any[]> {
   console.log(`url: ${url}`);
   return this.http.get(url)
     .map((response) => response.json()||[]);
 }
}

Sowohl der Customer- als auch der Invoice-Service beinhalten ähnliche Funktionalität in Bezug auf URL-Ermittlung und Mapping der Ergebnisdaten.

invoice.service.ts
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {BackendService} from '../shared/backend.service';
import {Invoice} from './invoice.model';
@Injectable()
export class InvoiceService {
   
 constructor(private backendService: BackendService) {
 }
   
 protected buildURL(): string {
   return `/data/invoices/data.json`;
 }
   
 public getInvoices(): Observable<Invoice[]> {
   return this.backendService.get(this.buildURL())
     .map(elem => {
       return <Invoice[]>elem;
     });
 }
}


customer.service.ts
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {BackendService} from '../shared/backend.service';
import {Customer} from './customer.model';
@Injectable()
export class CustomerService {
 constructor(private backendService: BackendService) {
 }
 protected buildURL(): string {
   return `/data/customers/data.json`;
 }
 public getCustomers(): Observable<Customer[]> {
   return this.backendService.get(this.buildURL())
     .map(response => {
       return <Customer[]>response;
     });
 }
}

Dieser Ansatz verbessert die Lösung durch Delegation von Querschnittsaspekten an eine Komponente, es verbleibt jedoch noch immer duplizierter Code. Dazu zählen beispielsweise die Ermittlung der Ziel-URL und das Mapping der Antwort. Somit verletzen die Invoice- und Customer-Komponenten erneut das DRY-Prinzip; der BackendService verletzt das SRP-Prinzip, da sämtliche Querschnittsaspekte sich dort befinden.

Unter Berücksichtigung zukünftiger Erweiterungen wird der BackendService immer mehr wachsen und die Komplexität entsprechend ansteigen, ebenso wie der zugehörige Testcode. Andererseits werden Tests für den CustomerService und den InvoiceService einfacher, werden jedoch nicht ganz auf Duplizierung verzichten können.

Bei Änderungen zeichnet sich ein positives Bild ab: Die Änderung des Logformats kann zuverlässig und einfach vorgenommen werden, da lediglich eine einzelne Komponente angepasst werden muss. Ist jedoch schon ein gewisser Umfang an Funktionalität an dieser Stelle zusammen gekommen, kann auch diese Aufgabe langwieriger werden, als man es anhand der Anforderung schätzen würde.

Wird eine neue Querschnittsfunktionalität ergänzt, wie in unserem Fall das Caching, kann dies direkt im BackendService erfolgen. Damit wird allerdings das SRP-Prinzip verletzt, und die zugehörigen Tests werden entsprechend komplex.

Im Ergebnis merkt man, dass dieser Ansatz ein Schritt in die richtige Richtung darstellt: Doppelter Code wird drastisch reduziert, was die Produktivität erhöht, und eine konsistente Implementierung wird erleichtert. Der Nachteil ist die Anhäufung von Verhalten im BackendService. Hier ist abzusehen, dass dieser mit der Zeit immer schwerer zu warten wird.

Zwischenfazit

In Abhängigkeit von Kontext und den langfristigen Zielen einer Webanwendung stehen unterschiedliche Designansätze zur Wahl, um Querschnittsaspekte zu implementieren. In den folgenden Teilen dieser Serie werden wir uns mit weiteren Möglichkeiten beschäftigen. Zur Auswahl stehen in der nächsten Folge die Einführung einer abstrakten Basisklasse sowie die Nutzung des Decorator-Entwurfsmusters. In der dritten Folge schauen wir uns schließlich an, wie sich die Lage mit Elementen der aspektorientierten Programmierung sowie dem sogenannten „Monkey Patching“ verbessern lässt.

 

Verwandte Themen:

Geschrieben von
Karsten Sitterberg
Karsten Sitterberg
Karsten Sitterberg ist als freiberuflicher Entwickler, Trainer und Berater für Java und Webtechnologien tätig. Karsten ist Physiker (MSc) und Oracle-zertifizierter Java Developer. Seit 2012 arbeitet er mit trion zusammen.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.