Angular Service Architektur & Routing

14.04.2021 Tom Trapp
Mobile angular foss javascript frontend framework handson tutorial howto

Im nächsten Teil der Angular Serie wollen wir unsere To Do Liste architektonisch etwas anpassen. Wir implementieren einen ToDoService, fügen eine zweite Page hinzu und steuern diese unter bestimmten Bedingungen an.

Service Architektur

In der Softwareentwicklung ist es grundsätzlich wünschenswert, alles so variablen und modular wie möglich zu halten. Dies gilt auch für Angular, bisher hatten wir die Liste mit unseren To Do Items in der ToDosComponent, dies wollen wir nun anpassen und auslagern.

Real World Case:

Die Architektur wäre bei einer realen Applikation ähnlich, lediglich die Datenspeicherung würde in einer externen Datenbank, z. B. per REST-Schnittstelle gemacht werden.

Zuerst wollen wir unseren ersten Service in Angular per CLI anlegen:

ng g service services/to-do

Was muss dieser Service alles können? Er sollte:

  • Die Liste von To Do Items speichern
  • Die Liste von To Do Items zurückgeben
  • Ein neues To Do Item hinzufügen
  • Ein To Do Item abzuhaken
  • Ein To Do Item zu löschen
  • Prüfen, ob alle To Do Items erledigt sind

Wir nutzen die LocalStorage Funktionalität von Angular um unsere To Do Liste zu speichern, dies erlaubt es uns, die Daten zu persistieren und wieder auslesen zu können.

Mittels der Annotation Injectable registrieren wir diese Klasse als Service. Erfahren Sie mehr über die Vorteile dieser Service Architektur in unseren Angular Profi Tipps.

Nachfolgend ist unser fertig implementierte ToDoService zu sehen:

//to-do.service.ts

import { Injectable } from '@angular/core';
import { ToDo } from '@models/ToDo';

@Injectable({
  providedIn: 'root'
})
export class ToDoService {
  
  toDos:ToDo[] = [];
    
  constructor() {
    let toDoString= localStorage.getItem("toDos");
    if(toDoString){
      this.toDos = JSON.parse(toDoString);
    } else {
      this.toDos = [
        {
          content: 'Get tea',
          completed: true
        },
        {
          content: 'Write blog post',
          completed: false
        },
        {
          content: 'Publish blog post',
          completed: false
        }
      ]
    }
  }

  getToDos():ToDo[]{
    return this.toDos
  }

  addToDo(toDo:ToDo):void{
    this.toDos.push(toDo);
    this.setOrUpdateLocalStorage();
  }
  
  toggleCompleted(indexToUpdate:number):void {
    this.toDos.map((item,index) => {
      if (index == indexToUpdate) {
        item.completed = !item.completed;
      }
    })
    this.setOrUpdateLocalStorage();
  }

  deleteToDo(indexToDelete:number):ToDo[] {
    this.toDos = this.toDos.filter((item,index) => index !== indexToDelete);
    this.setOrUpdateLocalStorage();
    return this.toDos;
  }

  areAllToDosCompleted():boolean {
    return this.toDos.every(x => x.completed === true);
  }

  private setOrUpdateLocalStorage(){
    localStorage.setItem("toDos",JSON.stringify(this.toDos));
  }
}

Nun haben wir eine gekapselte Logikklasse mit sämtlicher Businesslogik zu unseren To Do Items geschaffen. Diesen Service können wir nun über den Konstruktor jeder TypeScript Klasse als Dependency anziehen. Somit haben wir eine unabhängige, wiederverwendbare Klasse, die wir beliebig oft ansprechen können.

Nachfolgend ist zu sehen, dass wir weiterhin die Variable toDos in unsere ToDosComponent benötigen um im Template darauf zugreifen zu können. Ersetzen Sie den Inhalt Ihrer ToDosComponent Klasse mit nachfolgendem Code:

// to-dos.component.ts
...
export class ToDosComponent implements OnInit {

    toDos:ToDo[] = [];
    inputToDoText:string = 'abc';

    constructor(private toDoService:ToDoService) { }

    ngOnInit(): void {
        this.toDos=this.toDoService.getToDos();
    }

    handleToDoEvent(toDoEvent:ToDoEvent){
        if(toDoEvent.type === ToDoEventType.COMPLETE){
            this.toDoService.toggleCompleted(toDoEvent.index);
        } else if (toDoEvent.type === ToDoEventType.DELETE){
            this.toDos = this.toDoService.deleteToDo(toDoEvent.index);
        }
    }

    addToDo(){
        this.toDoService.addToDo({
            content: this.inputToDoText,
            completed: false
        });
        this.inputToDoText = "";
    }
}

Ein weitere grosser Vorteil dieser Architektur ist, dass Datenquelle und Template Komponente getrennt voneinander sind. Wir könnten beispielweise einen Rest Endpunkt implementieren und müssten nur unseren ToDoService anpassen, alle Komponenten bleiben gleich.

Die zweite Seite

Nun wollen wir aber etwas Leben in unsere Applikation bringen, wir erstellen eine zweite Seite!

Ziel ist es, dem Benutzer eine Seite anzeigen zu können, welche nur aufrufbar ist, wenn alle ToDo-Items abgehakt sind.

Eine Seite in Angular ist grundlegend nichts anderes wie eine Komponente, diese erstellen wir bekanntlich wie folgt:

ng g c components/success

Die HTML in diesem Fall soll sehr einfach behalten sein, dem Nutzer soll lediglich eine Nachricht angezeigt werden:

<!-- success.component.html -->

<h2>Success! You've completed all To Do items, great!</h2>

Nun könnten wir die Komponente wie auch unsere To Dos Komponente per Selektor einbinden, wir wollen aber effektiv eine URL zum Aufrufen der Page haben.

Routing

Angular Routing erlaubt uns genau das, man bindet eine Komponente auf einen Pfad, welche dann im Browser aufrufbar ist.

Sämtliche Konfiguration machen wir in der app.module.ts Datei, dort werden alle URLs inklusive ihrer Komponente definiert. In unserem Fall sagen wir, dass standardmässig die ToDosComponent gerendert werden soll. Der Pfad "success" führt dann zur SuccessComponent.

//app.module.ts

import { RouterModule } from '@angular/router';
...
imports: [
...
RouterModule.forRoot([
      {path: '', component: ToDosComponent},
      {path: 'success', component: SuccessComponent},
    ])
...

Ein weiterer Change den wir machen müssen, um Routing überhaupt nutzen zu können, ist unser app.component.html Template anzupassen. Aktuell ist der Selektor der ToDosComponent fix dort eingetragen, diesen müssen wir durch den Selektor des RouterModules von Angular ersetzen:

<!-- app.component.html -->

<div class="app">
    <header>
        <h1>b-nova To Do List</h1>
    </header>
    <router-outlet></router-outlet>
</div>

Nun kann unsere neue Komponente erfolgreich per /success URL aufgerufen werden.

Wichtig zu beachten ist, dass die Reihenfolge der Routes korrekt sein muss, es wird eine sogenannte first match wins Strategie genutzt. Dies bedeutet, dass eine Wildcard Routes (klassischerweise führt eine solche zu einer 404 Page) am Ende stehen muss.

Selbstverständlich bietet das RouterModule noch weitere Möglichkeiten wie das Senden von Parametern, Redirects, Nested Routes und viele mehr.

Nun wollen wir noch automatisch beim Abhaken aller To Do Items auf die Url /success, hierfür nutzen die wie Angular Klasse Router. Wir prüfen bei jedem Complete Event, ob alle To Dos bereits erledigt.

//to-dos.component.ts

  handleToDoEvent(toDoEvent:ToDoEvent){
    if(toDoEvent.type === ToDoEventType.COMPLETE){
      this.toDoService.toggleCompleted(toDoEvent.index);
      if(this.toDoService.areAllToDosCompleted()){
        this.router.navigateByUrl('/success');
      }
    } else if (toDoEvent.type === ToDoEventType.DELETE){
      this.toDos = this.toDoService.deleteToDo(toDoEvent.index);
    }
  }

Guard

Wie erreichen wir nun aber, dass die SuccessComponent nur aufgerufen werden kann, wenn alle To Do Items abgehakt sind?

Natürlich konnten wir in der Komponente selbst eine Logik implementieren und im Fehlerfall einen Redirect abfeuern, Angular bietet uns aber auch hier eine schlankere Lösung.

Ein sogenannter Guard erlaubt es uns, bestimmte Aktionen auszuführen oder den Zugriff auf eine Route an eine Bedingung zu koppeln.

Grundsätzlich gibt es fünf verschiedene Arten von Guards:

  • CanActivate - Prüft, ob die Route aufgerufen werden darf
  • CanActivateChild - Prüft, ob eine child Route aufgerufen werden darf
  • CanDeactivate - Prüft, ob die aktuelle Route verlassen werden darf
  • Resolve - Daten abfragen und vorbereiten bevor eine Route aufgerufen wird
  • CanLoad - Prüft, ob Children geladen werden dürfen, nützlich bei Lazy Loading mit mehreren Modules

Zum Erreichen unseres Ziels reicht uns ein CatActivate Guard, den wir per Angular CLI wie folgt erstellen können:

ng g guard guards/to-do-completed --implements CanActivate

Nun wollen wir unseren Guard so implementieren, dass er im Fehlerfall wieder zurück auf die Root-Page leitet.

Hier kommt wieder der grosse Vorteil der Service Architektur zum Vorschein, da wir unseren ToDoService als Dependency injecten und nutzen können.

Mittels der Klasse Router können wir programmatisch zur Rootpage navigieren.

//to-do-completed.guard.ts

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { ToDoService } from '../services/to-do.service';

@Injectable({
    providedIn: 'root'
})
export class ToDoCompletedGuard implements CanActivate {

    constructor(private toDoService:ToDoService, private router:Router){}

    canActivate(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {

        const areAllToDosCompleted:boolean = this.toDoService.areAllToDosCompleted();
        if(!areAllToDosCompleted){
            this.router.navigateByUrl('/');
        }

        return areAllToDosCompleted;
    }

}

Nun müssen wir nur noch den Guard einer Route zuordnen, dass dieser auch genutzt wird.

//app.module.ts

    {path: 'success', component: SuccessComponent,canActivate: [ToDoCompletedGuard]},

Es weiteres nützliches Feature ist, dass die Guards als Array angegeben werden können und eine Route so nur aufgerufen werden kann, wenn alle Guards true zurückliefern. Ausserdem kann ein Guard beliebig oft verwendet werden.

Nun haben wir erfolgreich eine zweite Seite in unsere Service-basierten ToDo-Anwendung angelegt. Ein Guard erlaubt es uns, Zugriffsberechtigungen dieser Seite zu steuern.

Das komplette 'b-nova ToDo-Liste'-Projekt finden Sie auf GitHub.

Stay tuned!

Tom Trapp - problem solver, innovator, athlete. Tom prefers to work on modern software all day long and attaches great importance to objectively clean, lean code.