Datenmodellierung in Angular: Die Architektur einer Angular-Applikation richtig gestalten
Wer Angular effizient nutzen will, sollte sich mit den Datenquellen und -flüssen seiner Applikation sowie deren Synchronisierung beschäftigen. Dazu sollten Angular-Entwickler der
Aufbau und die Komponenten ihrer Applikation bekannt sein. Wenn saubere Konventionen zur Datenmodellierung nämlich fehlen, kommt es spätestens dann zu Problemen, wenn ein Team eine App programmiert, bei der die gleichen Informationen an unterschiedlichen Stellen auftauchen oder modelliert werden sollen.
Die Angular-Komponenten
Die wichtigsten Bausteine einer Angular-Applikation sind die Komponenten. Eine Angular-App gleicht einem ganzen Baum von Komponenten. Die so genannte Root-Komponente verzweigt sich dabei in Sub-Komponenten zu einer baumförmigen Programmstruktur. Die Flexibilität dieses Konstrukts können Entwickler zusätzlich erhöhen, wenn sie zur Laufzeit Komponenten ein- oder aushängen. Darüber hinaus kann der Angular-Router den Status einer Applikation in der URL abbilden und basierend darauf Komponentenbäume an bestimmten Stellen der Applikation einhängen.
Doch zurück zur Komponente: Diese besteht aus einem Template, einer Komponentenklasse und optional einem Stylesheet. Das Template ist für die Strukturierung der Benutzeroberfläche und die Präsentation der Informationen zuständig. Die Komponentenklasse stellt die View-Logik zur Verfügung, die zum Betrieb der Oberfläche erforderlich ist. Die eigentliche Business-Logik einer Applikation lagern Entwickler in separaten Services aus. Das sind spezielle Klassen, die sich mittels Dependency Injection in Komponenten einbinden lassen und eine Interaktion über definierte Schnittstellen bieten.
Eine einfache Angular-Komponente:
@Component({ selector: ‘app’, templateUrl: ‘app.component.html’ }) class AppComponent { public title = ‘Hello World’; }
Eine wichtige Anforderung an Komponenten ist, dass sie von anderen Komponenten so unabhängig wie möglich sein sollten. Das reduziert die Kopplung zwischen den Komponenten, erhöht also ihre Wiederverwendbarkeit und erleichtert insgesamt das Testen.
Informationsflüsse
Ein sehr einfaches Beispiel zeigt den Informationsfluss einer Applikation ganz gut: Eine Auflistung aller Benutzer-Accounts. Die erste Komponente, die zur Lösung dieser Aufgabe notwendig ist, ist die Liste selbst. Innerhalb der Komponentenklasse holen Angular-Entwickler durch die Dependency Injection eine Instanz des http-Services und starten eine GET-Anfrage an den Server, mit der die entsprechenden Daten eingeholt werden.
Für die Darstellung der Datensätze sorgt eine Kindkomponente, die die App in einer Schleife mehrfach wiedergibt. Doch: Wie kommen die Informationen in die Kindkomponenten? An dieser Stelle kommt das Property-Binding von Angular ins Spiel. Im Template der Elternkomponente weisen Entwickler dazu einfach der Eigenschaft „account“ der Kindkomponente dem Objekt aus der Elternkomponente zu.
Implementierung der Elternkomponente:
@Component({ selector: 'account-list', template: ` <ul> <account-item let *ngFor="let account of accounts" [account]="account"> </ul> `, styleUrls: ['./list.component.css'] }) export class ListComponent implements OnInit { private accounts: Account[]; constructor(private http: Http) {} ngOnInit() { this.http.get('/accounts') .map(res => res.json()) .subscribe((accounts: Account[]) => { this.accounts = accounts; }); } }
Um in der Kindkomponente einen Zugriff auf das Objekt hinter der account-Eigenschaft zu erhalten, muss man in der Komponentenklasse angeben, dass es sich hierbei um eine Input-Eigenschaft handelt. Dies geschieht über den @Input-Decorator. Dadurch können Entwickler diese Eigenschaft als gewöhnlichen Teil ihrer Komponente behandeln. Allerdings gelangt das Objekt nicht als Kopie, sondern als Referenz in die Kindkomponente. Das bedeutet, dass sich jede Änderung in der Kindkomponente auch auf die Elternkomponente auswirkt.
Diese impliziten Änderungen der Informationen in einer Applikation ist nicht erstrebenswert, da dies die Elternkomponente nicht benachrichtigt. Besser ist es, für den Informationsfluss von der Kindkomponente zur Elternkomponente ein eventbasiertes Output-Binding zu verwenden. So erhält die Elternkomponente bei einer Änderung eine Benachrichtigung und Entwickler können einen entsprechenden Eventlistener erstellen.
Für die Implementierung stellt Angular alles nötige bereit. Die Anwender des Frameworks erzeugen in der Komponentenklasse der Kindkomponente zunächst eine Eigenschaft des Typs „EventEmitter“. Die Objekte dieses Typs stellen die emit-Methode bereit, mit der die Nachricht über Veränderungen an die Elternkomponente verschickt wird. Die Eigenschaft markieren Entwickler mit dem @Output-Decorator. Dadurch können sie im Template der Elternkomponente über das Eventbinding von Angular einen Eventhandler an das Event der Kindkomponente anbinden und eine entsprechende Routine auslösen.
Implementierung der Kindkomponente:
@Component({ selector: 'account-item', template: '<li>{{account.name}}</li>' }) export class ItemComponent { @Input() account: Account; @Output() accountChanged = new EventEmitter(); }
Data Services
Mit dem bisher vorgestellten Property- und Eventbinding lässt sich der Informationsfluss durch einen Komponentenbaum sehr gut abbilden. Häufig werden die Informationen in einer Applikation jedoch nicht in direkt zusammenhängenden Komponenten dargestellt oder – noch schwieriger – die Informationen sollen an mehreren Stellen modifiziert werden können. Hier können die Informationen entweder über eine gemeinsame Elternkomponente fließen oder es kommen Data Services zum Einsatz. Je nach Umfang der Applikation kann die gemeinsame Elternkomponente relativ weit entfernt liegen. Das bedeutet, dass Entwickler die Informationen durch eine große Anzahl von Komponenten leiten müssen. Dieser Ansatz ist also in vielen Fällen keine Option.
Ein Data Service hat eine event-basierte Architektur, sodass Entwickler Komponenten an verschiedenen Stellen einer Applikation bei diesem Service registrieren und über Aktualisierungen informiert werden können. Dabei müssen sie dieses Event-System nicht von Grund auf erfinden, sondern können auf die RxJS-Bibliothek zurückgreifen und sich so einiges an Arbeit sparen.
Ihre Implementierung hat den Vorteil, dass man Informationen nur einmal in einer Applikation vorhalten muss und sich an dieser zentralen Stelle auch gleich um die Server-Kommunikation kümmern kann. Außerdem bleiben die Informationen immer konsistent, da es eine „Single Source of Truth“ gibt.
Der Data Service ist zunächst ein ganz gewöhnlicher Angular Service, den Entwickler in ihrem Applikationsmodul registrieren müssen, um ihn über die Dependency Injection jedem Modul zur Verfügung zu stellen. Der Service exponiert ein „Behaviour Subject“, über das die Komponenten die aktualisierten Daten erhalten. Dieses Subject soll lediglich die Daten konsumieren. Für Änderungen stellt der Service eine Reihe von Methoden bereit.
Über diese können Angular-Entwickler beispielsweise Datensätze hinzufügen, verändern oder löschen. Neben dieser einheitlichen Schnittstelle für den Datenzugriff speichert der Data Service auch die Informationen auf dem Server – und das spart wiederum Arbeit.
Die Schnittstelle mit dem Service besteht aus verschiedenen Methoden, mit denen sich Datensätze hinzufügen oder verändern lassen. Sie sorgen zunächst für die Persistenz der Informationen zum Server und lösen dann auf dem „Behavior Subject“ ein Event aus, das alle Subscriber benachrichtigt.
Angular Data-Service:
@Injectable() export class AccountService { private accounts: Account[]; public accounts$: BehaviorSubject<Account[]>; constructor(private http: Http) {} addAccount(account: Account) { this.http.post('/todo', todo).subscribe((res) => { this.accounts.push(account); this.accounts$.next(this.accounts); }); } }
Angular-Erweiterung: Flux-Architektur und Redux
Mit den Data Services löst Angular das Problem, bestimmte Informationen zentral vorzuhalten. In einer umfangreichen Applikation existieren jedoch meist zahlreiche Data Services parallel. Das wiederum kann unübersichtlich sein. App-Entwickler können dieses Problem mit einer zentralen Stelle umgehen, die den Applikationsstatus hält und überwacht. Dieses Architekturmuster ist vor allem durch die Flux-Architektur von Facebook populär geworden.
Der Ansatz Flux soll die Umsetzung umfangreicher Applikationen mit React – der View-Bibliothek von Facebook – ermöglichen. Reacts Fokus liegt auf der Implementierung von Benutzerschnittstellen und nicht auf der Gesamtarchitektur einer Applikation. Der Kerngedanke von Flux ist es, dass es eine zentrale Stelle gibt, über die sich der Applikationsstatus verändern lässt: den Dispatcher. Bei ihm sind die verschiedenen Stores registriert, die wiederum die Datenlieferanten für die Views der Applikation sind.
Die Views wiederum können über Actions des Dispatchers die Daten der App ändern. Diese Architekturform erzeugt einen zentralen und gerichteten Informationsfluss, mit dem sich selbst sehr große Applikationen umsetzen lassen. Neben dem ursprünglichen Flux gibt es mittlerweile noch weitere Bibliotheken, die dieses Architekturmuster mit einigen Abweichungen oder Erweiterungen implementieren. Eine der Bekanntesten ist Redux.
Die Flux-Architektur lässt sich jedoch nicht nur auf React-Applikationen anwenden, sondern auch mit Angular kombinieren. Dafür binden Entwickler beispielsweise die Redux-Bibliothek in ihre Angular-Applikation ein und können dann auf die Elemente der Bibliothek zugreifen. Im Gegensatz zu Flux legt Redux fest, dass es nur einen Store für die komplette Applikation geben soll. Dadurch können Entwickler, die Redux einsetzen, auf den Dispatcher verzichten und über die Actions direkt mit dem Store interagieren.
Die Actions implementiert man über reduce-Funktionen. Das sind Funktionen, die aus einem initialen State und einer Action einen neuen State erzeugen. Dadurch entsteht automatisch ein immutable State. Das bedeutet, dass Entwickler den State nicht modifizieren, sondern eine neue Repräsentation erzeugen. Durch diesen Ansatz kann das Framework Optimierungen durchführen, da es davon ausgehen kann, dass der State unveränderbar ist.
Natürlich müssen Angular-Nutzer auch hier das Rad nicht neu erfinden, sondern können bereits existierende Implementierungen wie beispielsweise ng2-redux einsetzen. Wer ng2-redux in seine Applikation eingebunden hat, kann ihn über die Dependency Injection wie einen gewöhnlichen Service nutzen.
Verwendung von ng2-redux:
@Component({ selector: 'account-form', templateUrl: 'form.component.html' }) class FormComponent { private account: Account; constructor(private ngRedux: NgRedux<IAppState>) {} onSave() { this.ngRedux.dispatch({ type: ADD, account: this.account }); } }
Fazit
Angular macht zwar einige Vorgaben zum Aufbau einer Applikation. Es liegt jedoch am Entwickler, wie er die Datenflüsse in einer App modelliert. Die Spanne der Möglichkeiten ist dabei groß und reicht von einfachen Data Services bis zur Einbindung zusätzlicher Bibliotheken wie Redux. Die meisten Implementierungen setzen auf einen gerichteten Datenfluss und die RxJS-Bibliothek – aus gutem Grund: So lassen sich Applikationen so strukturieren, dass Änderungen und Erweiterungen einfach möglich sind.