Logging, Caching, Security mit Angular 2 und TypeScript: Teil 2

Querschnittsaspekte in Angular 2: Abstrakte Basisklasse und das Decorator-Entwurfsmuster

Karsten Sitterberg

(c) Shutterstock / grmarc

Wie lassen sich Querschnittsaspekte wie Logging, Caching oder Security-Anforderungen in Angular-2-Webanwendungen umsetzen? Karsten Sitterberg stellt in dieser Serie verschiedene Ansätze vor und zeigt, wie sich diese langfristig auf die Wartbarkeit auswirken. Wurde im ersten Teil ein trivialer Ansatz vorgestellt, der dann durch die Einführung eines BackendService erweitert wurde, kommen in Teil 2 die Nutzung einer abstrakten Basisklasse sowie das Decorator-Entwurfsmuster zur Sprache.

Serie: Querschnittsaspekte in Angular 2 mit TypeScript

Karsten SitterbergViele Softwareprojekte beinhalten Querschnittsaspekte wie Logging, Caching oder die Umsetzung von Sicherheitsanforderungen. 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.

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 Tutorial

Abstrakte Basisklasse

Um den Anteil an Boilerplate-Code zu reduzieren, werden die Funktionalitäten, die zu doppeltem Code führen, in eine abstrakte Basisklasse, BaseBackendService, ausgelagert. Dies beinhaltet neben den Querschnittsaspekten auch das Datenmapping der Antworten und die URL-Ermittlung.

Angular2 - Bild 3

Damit die URL-Ermittlung ausgelagert werden kann, ist es nötig, eine Konvention für die URL-Struktur des HTTP API festzulegen: Kann die URL für einen Dienst anhand einer Konvention, wie /customers für die Customer-Ressource, ermittelt werden, wird lediglich die URL des Einstiegspunkts benötigt.

base-backend.service.ts
import {Injectable} from '@angular/core'
import {Http} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {Entity} from './entity.model';
@Injectable()
export abstract class BaseBackendService<E extends Entity>{
 constructor(private http: Http, private name: string) {
 }
 protected buildURL(): string {
   return `/data/${this.name}`;
 }
 public get(): Observable<E[]> {
   let url = `${this.buildURL()}/data.json`;
   console.log(`url: ${url}`);
   return this.http.get(url)
     .map((response) => <E[]>response.json());
 }
}

Der InvoiceBackendService und der CustomerBackendService erweitern dann diese Basisklasse und fügen dabei lediglich die Spezifikation des zurückzuliefernden Typs und die URL-Ergänzung für den HTTP-Zugriff hinzu.

invoice.service.ts
import {Injectable} from '@angular/core';
import {Http} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {BaseBackendService} from '../shared/index';
import {Invoice} from './invoice.model';
@Injectable()
export class InvoiceService extends BaseBackendService<Invoice> {
 constructor(http: Http) {
   super(http,'invoices');
 }
}

Der CustomerBackendService sieht vergleichbar aus und enthält somit auch keinen Boilerplate-Code.

customer.service.ts
import {Injectable} from '@angular/core';
import {Http} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
import {BaseBackendService} from '../shared/index';
import {Customer} from './customer.model';
@Injectable()
export class CustomerService extends BaseBackendService<Customer>{
 constructor(http: Http) {
   super(http, 'customers');
 }
}

In diesem Design wird die URL-Ermittlung, der HTTP-Zugriff und das Mapping der Ergebnisdaten an einer Stelle – dem BaseBackendService – zusammen mit Querschnittsaspekten behandelt.

Das reduziert zwar doppelten Code (DRY), auf der anderen Seite verstößt es gegen das Prinzip der einen Verantwortung (SRP), da die Basisklasse verschiedene Verantwortungen hält.

Um die abstrakte Klasse zu testen, muss eine konkrete Klasse abgeleitet werden: Solange das Verhalten nicht überschrieben wird, ist somit der Test von CustomerBackendService oder InvoiceBackendService alleine ausreichend. Auch damit zeigt sich also, dass das DRY-Prinzip eingehalten wird. Der Testcode kann jedoch komplex und umfangreich werden, da SRP nicht eingehalten wird.

Betrachten wir nun, wie sich die Änderung des Logformats auswirkt: Auch hier ist lediglich eine Klasse anzupassen. Gleiches gilt für die Erweiterung um Caching: Ebenfalls ist nur eine Klasse anzupassen, um den Preis gesteigerter Komplexität dieser Klasse.

Vergleicht man dieses Vorgehen mit den anderen Ansätzen, so ist eine deutliche Codereduktion zu erzielen. Der Trade-Off ist die zu erwartende hohe Komplexität in der BaseBackendService-Klasse.

Decorator (Interceptor)

Wir betrachten hier nicht die TypeScript-spezifischen @Decorator-Annotationen, sondern das Decorator-Entwurfsmuster. Ein Decorator, oft auch Wrapper genannt, bietet nach außen das selbe Interface wie die zugrundeliegende Klasse, fügt dabei aber zusätzliches Verhalten hinzu oder ändert existierendes Verhalten.

Angular2 - Bild 4

Das zugrundeliegende Objekt muss dafür keinen expliziten Support anbieten. Es muss nicht einmal wissen, dass es mit einem Decorator versehen ist. In Angular 2 lässt sich damit der von AngularJS bekannte http-Interceptor nachbilden, ohne dass dies durch das API explizit angeboten werden muss. Damit lässt sich dieses Verfahren auch außerhalb des HTTP-Service anwenden, auf den es bei AngularJS beschränkt war.

Für das Beispiel wird ein Logging-Decorator durch einen Lambda-Ausdruck implementiert und der dekorierte HTTP-Service durch eine Factory-Funktion erstellt. Die Factory-Funktion muss dabei an der Komponente im Angular-Komponentenbaum spezifiziert werden, um festzulegen, ab wo der dekorierte HTTP-Service statt dem regulären angewendet werden soll. Dabei macht man sich das Dependency-Injection-Verfahren zunutze, welches einen leichten Austausch von Implementierungen ermöglicht.

Im Beispiel ist die Factory-Funktion über das useFactory Property spezifiziert.

In der App-Komponente:

@Component({
 selector: ‘app-cmp’,
 templateUrl: ‘...’
 providers: [HTTP_PROVIDERS,
   {
     provide: Http,
     useFactory: (xhrBackend: XHRBackend, requestOptions: RequestOptions) =>
       new HttpLoggingDecorator(
         new Http(xhrBackend, requestOptions)
       ),
     deps: [XHRBackend, RequestOptions]
   }
 ]
})
export class AppComponent{}

Normalerweise verwendet man Interfaces, um damit das Decorator Muster umzusetzen. Angular 2 bietet leider kein Interface für den Http-Service an. Eine Alternative stellt eine abgeleitete Klasse dar. Um die Implementierung unterschiedlicher Decorator zu erleichtern, erstellen wir die Klasse HttpDecorator mit einem Konstruktor, der die Instanz des Http-Service als Argument erwartet, an die Aufrufe delegiert werden sollen.

Da TypeScript den Aufruf des Oberklassenkonstruktors erzwingt, wir jedoch keine geerbte Funktionalität verwenden, sondern an die dekorierte Instanz delegieren, verwenden wir null als Argument für den Super-Konstruktor. Dies ist zwar nicht sehr elegant, jedoch ein notwendiges Zugeständnis an TypeScript und das Angular 2 API.

Alle Methoden, die nutzbar sein sollen, werden dann analog zur get-Methode im HttpDecorator implementiert.

http.decorator.ts
import {Injectable} from '@angular/core';
import {Http, RequestOptionsArgs, Response} from '@angular/http';
import {Observable} from 'rxjs/Observable';
@Injectable()
export abstract class HttpDecorator extends Http {
 constructor(private delegate: Http) {
   super(null, null);
 }
 get(url: string, options?: RequestOptionsArgs): Observable<Response> {
   return this.delegate.get(url, options);
 }
  //...
}

Das geforderte Logging wird implementiert, indem die getMethode des HttpDecorator überschrieben wird. Im Beispiel ist zu sehen, wie nach der Konsolenausgabe die Ausführung an die HttpDecorator-Klasse weitergegeben wird, die schließlich die getMethode auf der Instanz aufruf, die ursprünglich dekoriert wurde.

http-logging.decorator.ts
import {Injectable} from '@angular/core';
import {Http, RequestOptionsArgs, Response} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import {HttpDecorator} from './http-decorator.service';
@Injectable()
export class HttpLoggingDecorator extends HttpDecorator{
 constructor(http:Http) {
   super(http);
 }
 get(url: string, options?: RequestOptionsArgs): Observable<Response> {
   console.log(url);
   return super.get(url, options);
 }
}

Eine Instanz vom Typ Http wird dem InvoiceService injiziert. Da bereits auf einer übergeordneten Ebene der HttpLoggingDecorator über den Provider als zu nutzende Instanz für den Typ Http konfiguriert wurde, erhält der InvoiceService die entsprechend dekorierte Version.

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 url: string;
   constructor(private http: Http) {
       this.url = '/data/invoices/data.json';
   }
   getInvoices(): Observable<Invoice[]> {
       return this.http.get(this.url)
           .map(
           (response) => <Invoice[]>response.json()
           );
   }
}

Der CustomerService wird ähnlich wie der InvoiceSerivce implementiert. Beide beeinhalten URL-Ermittlung, Aufruf des HTTP-Dienstes und das Mapping der Antwort. Somit kann man argumentieren, dass das DRY-Prinzip verletzt ist. In Bezug auf Querschnittsfunktionalität ist SRP eingehalten, da jeder Querschnittsanteil in einen eigenen Decorator ausgelagert wird. Müssen jedoch die Dienste auf unterschiedliche Weise mit den Antworten umgehen, oder weicht die URL-Ermittlung ab, so handelt es sich dabei auch um separate Verantwortlichkeiten und nicht um doppelten Code.

Der Test eines Decorators ist sehr einfach, vor allem im Vergleich zu den vorherigen Ansätzen. In Bezug auf den InvoiceService und CustomerService ist der Test des mehrfach vorhandenen Codes erforderlich und bringt die bereits diskutierten Nachteile mit sich.

Eine Änderung des Logformats ist bei diesem Ansatz sehr einfach möglich, da nur eine einzelne Klasse geändert werden muss. Entsprechend wenig ist am Testcode zu ändern, um das geänderte Verhalten zu verifizieren.

Die Erweiterung um die zusätzliche Anforderung von Caching wird mit einem eigenen Decorator umgesetzt. Für diese Klasse werden passende Tests erstellt, die sich ebenfalls in einer eigenen Testklasse befinden. Diese Lösung nutzt auf geschickte Weise die Dependency-Injection-Möglichkeiten von Angular 2, um mittels des Decorator-Entwurfsmusters Querschnittsaspekte abzubilden. Auch außerhalb der HTTP-Kommunikation lässt sich dieser Ansatz analog anwenden.

Zwischenfazit

In Abhängigkeit von Kontext und den langfristigen Zielen einer Webanwendung stehen unterschiedliche Designansätze zur Wahl, um Querschnittsaspekte zu implementieren. In der dritten Folge dieser Serie schauen wir uns die Möglichkeiten an, die die aspektorientierte Programmierung uns bieten. Außerdem wird ein Vorgehen vorgestellt, das im Umfeld dynamisch oder nicht typisierter Sprachen wie Ruby und JavaScript sehr beliebt ist: Monkey Patching.

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.