Das Beste aus zwei Welten

Mit ASP.NET Core und Angular eine Webanwendung erstellen

Mit ASP.NET Core und Angular eine Webanwendung erstellen

Das Beste aus zwei Welten

Mit ASP.NET Core und Angular eine Webanwendung erstellen


Moderne verteilte Systeme im Web bestehen heutzutage aus vielen verschiedenen Teilen, Systemen und Technologien. Frontend und Backend sind zwei sehr wichtige Elemente einer aktuellen Webapplikation. Für maximale Flexibilität kann man diese Teile vollkommen trennen und eine im Browser laufende, eigene Applikation als Frontend mit einem REST-Service im Backend kommunizieren lassen.

Video: Mehr als nur Web: Cross-Plattform-Anwendungen mit Angular, Electron und Cordova

Die Auswahl eines Frontend-Frameworks ist nicht leicht, jedoch hat sich Angular in der letzten Zeit nicht nur durch ein gutes „Separation of Concerns“-Konzept und gute Synchronisierungsmechanismen von Model, View, Architektur und hoher Performance hervorgehoben. Auch die Regelmäßigkeit, mit der neue Versionen erscheinen, das Angular CLI und nicht zuletzt der Internetgigant Google mit Long Term Support tragen dazu bei, dass mehr und mehr Businessanwendungen im Web auf die Angular-Plattform setzen.

Im Backend hat Microsoft mit ASP.NET Core spätestens seit Version 2.x den alten Mantel abgeworfen und kommt neu, schlank und vor allem schnell daher. Live-Reload, Middleware, die Geschwindigkeit und Cross-Plattform-Fähigkeit sind nur einige Gründe, warum man ASP.NET Core mehr als nur einen Blick widmen sollte.

In diesem Artikel möchte ich die Bestandteile und auch die Vorteile eines Frontends mit Angular sowie eines REST Backends mit ASP.NET Core darstellen und zeigen, wie man diese Teile einer Webapplikation programmieren kann. Als Beispiel programmieren wir einen Booktracker, der eine Merkliste für Bücher enthält, die man als gelesen markieren kann. Außerdem kann man neue Bücher hinzufügen und bestehende bearbeiten. Den kompletten Quellcode gibt es natürlich wieder auf GitHub [1].

Das ASP.NET Core Backend

ASP.NET Core bietet ein Command Line Interface (CLI) an, mit dem wir uns über Templates das Grundgerüst unseres Web-APIs erstellen lassen können [2]. Eine Kommandozeile im gewünschten Ordner und der Befehl dotnet new webapi in der Konsole erzeugen hierbei ein neues Web-API für uns, das wir mit dotnet watch run laufen lassen können. Beim Erstellen des Web-API wurde gleich der dotnet restore-Command ausgeführt, mit dem alle unsere NuGet-Pakete heruntergeladen und für unsere Anwendung bereitgestellt wurden. Hierbei wird ein neuer Webserver „Kestrel“ in einer Konsole gehostet, der unsere Applikation hochfährt und für Requests bereitstellt. Dabei stehen uns in der neuesten Version ein HTTP- und ein HTTPS-Endpunkt zur Verfügung. dotnet watch run bietet zudem einen Live-Reload-Server, sodass jedes Mal, wenn wir eine Datei in unserem Projekt ändern, das Backend neu gestartet wird und wir es nicht manuell unterbrechen und wieder hochfahren müssen.

Konfiguration und Start des Web-API

ASP.NET-Core-Applikationen sind grundsätzlich Konsolenprogramme, die wir beispielsweise mit dem Befehl dotnet run von der Kommandozeile aus starten können. Somit ist der Startpunkt unseres Web-API eine einfache Konsolenapplikation, die uns einen Webserver bereitstellt, statt beispielsweise ein „Hello World“ auf der Konsole auszugeben (Listing 1).

Listing 1

public class Program
{
  public static void Main(string[] args)
  {
    CreateWebHostBuilder(args).Build().Run();
  }
 
  public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
    .UseStartup<Startup>();
}

Falls etwaige Konfigurationen für unsere Applikation vorgenommen werden sollen, können diese Konfigurationsfiles hier eingelesen werden. Standardmäßig werden die mitgenerierten appsettings.json und appsettings.*.json zur Konfiguration herbeigezogen und appliziert. Aber es werden auch weitere Konfigurationsformate wie *.xml oder sogar *.ini unterstützt. ASP.NET Core erstellt also neben einem Webserver auch ein Konfigurationsobjekt, das wir in der Startup.cs zur Verfügung gestellt bekommen und nutzen können.

Start-up und Dependency Injecton

Basierend auf der fertigen Konfiguration, die uns mitgegeben wird, können wir nun unser Web-API konfigurieren. ASP.NET Core macht dabei Gebrauch vom internen Dependency-Injection-System. In der Datei Startup.cs bekommen wir im Konstruktor der Klasse die Konfiguration übergeben (Listing 2). ASP.NET Core kommt also mit einem eigenen Dependency-Injection-System, auf das wir im weiteren Verlauf dieses Artikels noch eingehen werden.

Listing 2

public class Startup
{
  public Startup(IConfiguration configuration)
  {
    Configuration = configuration;
  }
 
  public IConfiguration Configuration { get; }
   // ...
}

Die Startup.cs-Datei stellt zwei weitere Methoden bereit: ConfigureServices und Configure. Erstere füllt den Dependency-Injection-Container, den wir von ASP. NET Core übergeben bekommen:

public void ConfigureServices(IServiceCollection services) { /*...*/ }

Auf diesem Container können wir unsere abhängigen Services eintragen und später in unseren Klassen via Dependency Injection injiziert bekommen und somit nutzen. Auch MVC selbst wird hier mitsamt seinen Services in den Container gelegt.

Die Methode Configure erstellt eine Pipeline für alle unsere Requests, bevor sie von unseren Controllern bearbeitet werden. Hierbei ist die Reihenfolge der hinzugefügten Middleware wichtig. Das heißt, jeder eingehende Request durchläuft die Middleware, die wir in dieser Methode angeben können. Ebenso die ausgehende Response, diesmal wird die Middleware jedoch in umgekehrter Reihenfolge abgearbeitet. Also können Features wie Authentication etc. hier in die Pipeline für unsere Requests „eingehängt“ werden. Auch MVC selbst wird als Middleware hinzugefügt. Die Services haben wir schon in der ConfigureServices-Methode angegeben, die zugehörige Middleware nutzen wir mit app.UseMvc(). Somit kann unser Web-API Requests in Controllern empfangen, Routing nutzen etc.

Verwenden des Dependency-Injection-Containers

In unserer Beispielapplikation brauchen wir ein Repository, mit dem wir unsere Entities in der Datenbank abspeichern können:

public interface IBookRepository { ... }
public class BookRepository : IBookRepository

Dieses Repository können wir nun in unserem Dependency-Injection-Container vor dem Interface registrieren:

services.AddScoped<IBookRepository, BookRepository>();

AddScoped sorgt hierbei dafür, dass die Instanz so lange gehalten wird, wie der Request bearbeitet wird, und dass mit jedem Request eine neue Instanz erstellt wird. Die weitere Methode AddSingleton() legt eine Instanz des Service beim Hochfahren der Applikation an, AddTransient () würde eine Instanz pro Konstruktorinjection erstellen.

Definieren eines REST-Endpunkts mit Controllern

Der eigentliche REST-Endpunkt wird in ASP.NET Core in Controllern abgebildet. Die HTTP-Verben wie GET, POST, PUT, PATCH, DELETE etc. können in Controllern implementiert werden (Listing 3).

Listing 3

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
  //
}

Das Route-Attribut über der Controllerklasse legt die Adresse des Endpunkts fest. Hierbei ist der Präfix herkömmlicherweise api/, gefolgt von einem generischen [controller]. Das wird ersetzt durch den Namen der Klasse ohne das Suffix „Controller“. In diesem Fall also api/values.

Mit dem ApiController-Attribut legen wir fest, dass es sich um einen HTTP-Endpunkt handelt, der HTTP-Antworten verschickt. Mit dem Ableiten von ControllerBase verzichten wir auf alle View-Funktionalitäten, die wir im Fall einer kompletten MVC-Applikation bräuchten, da wir als Antwort keine komplett gerenderten Seiten, sondern JSON verschicken wollen.

Innerhalb dieses Controllers können wir nun den REST-Endpunkt definieren, mit dem sich unsere Bücher abspeichern und aktualisieren lassen. In unserem Beispiel erstellen wir uns einen BooksController, für den wir den Endpunkt api/books definieren. Da das Repository schon im Container registriert wurde, können wir es nun im Controller einfach injecten (Listing 4).

Listing 4

[Route("api/[controller]")]
[ApiController]
public class BooksController : ControllerBase
{
  private readonly IBookRepository _bookRepository;
 
  public BooksController(IBookRepository repository)
  {
    _bookRepository = repository;
  }
}

Methode

Link

GET

api/books/

GET

api/books/{id}

POST

api/books/

PUT

api/books/{id}

DELETE

api/books/{id}

Tabelle 1: REST-Endpunkt

„AddMvc()“

Mit dem Aufruf AddMvc() in der Startup-Klasse haben wir bereits einen JSON-Serialisierer hinzugefügt, der uns die Daten automatisch von Objekten zu JSON und von JSON zu Objekten parst. ASP.NET Core liefert diese Funktionalität also out of the box.

Um nun alle Bücher abrufen zu können, müssen wir auf das HTTP-GET-Verb reagieren (Tabelle 1) und als Antwort alle Bücher als JSON serialisiert zurückgeben (Kasten: „AddMvc()“). ASP.NET Core bietet hierbei als Rückgabetyp einer Methode unter anderem das IActionResult-Interface oder eine gar generische Klasse ActionResult<T> an, um HTTP-Antworten zu definieren. Gleichzeitig bietet das Ableiten der Controllerklasse von ControllerBase einen weiteren Vorteil: Man kann kleine Helfermethoden wie zum Beispiel Ok(...) oder BadRequest(...) benutzen, die dem Entwickler helfen, die richtige HTTP-Antwort mit dem korrekten HTTP-Statuscode zurück zum Client zu schicken. Das ist bei entkoppelten Systemen und einer solchen Service-Architektur von elementarer Wichtigkeit.

Eine Methode, die auf ein HTTP-Verb reagieren soll, kann mit dem jeweiligen HTTP-Verb als Attribut gekennzeichnet werden (Listing 5).

Listing 5

[HttpGet]
public IActionResult GetAll()
{
  List<Book> items = _bookRepository.GetAll().ToList();
  IEnumerable<BookDto> toReturn = items.Select(x => Mapper.Map<BookDto>(x));
  return Ok(toReturn);
}

Diese Methode reagiert auf einen HTTP-GET-Aufruf und gibt einen 200er-Statuscode zurück, der für Ok steht. Die Helfermethode Ok() mit den Daten, die als Body mit der Response geschickt werden sollen, macht uns diese HTTP-Konvention sehr einfach.

Falls wir ein einzelnes Buch abfragen wollen, können wir auch Parameter im Routing angeben und auslesen. Hierfür geben wir das Routing-Attribut ebenfalls auf der Methode an und reichen es als Parameter in die Funktion selbst (Listing 6).

Listing 6

[HttpGet]
[Route("{id:int}")]
public IActionResult GetSingle(int id)
{
  Book item = _bookRepository.GetSingle(id);
 
  if (item == null)
  {
    return NotFound();
  }
 
  return Ok(Mapper.Map<BookDto>(item));
}

Falls das Buch nicht gefunden wird, geben wir einen 404-Statuscode zurück, ansonsten unseren bekannten Ok-Statuscode 200. Alle weiteren Endpunkte implementieren wir entsprechend. Ein Beispiel für einen kompletten Endpunkt kann in Listing 7 gefunden werden.

Listing 7

[Route("api/[controller]")]
  [ApiController]
  public class BooksController : ControllerBase
  {
    private readonly IBookRepository _bookRepository;
 
    public BooksController(IBookRepository repository)
    {
      _bookRepository = repository;
    }
 
    [HttpGet(Name = nameof(GetAll))]
    public IActionResult GetAll()
    {
      List<Book> items = _bookRepository.GetAll().ToList();
      IEnumerable<BookDto> toReturn = items.Select(x => Mapper.Map<BookDto>(x));
      return Ok(toReturn);
    }
 
    [HttpGet]
    [Route("{id:int}", Name = nameof(GetSingle))]
    public IActionResult GetSingle(int id)
    {
      Book item = _bookRepository.GetSingle(id);
 
      if (item == null)
      {
        return NotFound();
      }
 
      return Ok(Mapper.Map<BookDto>(item));
    }
 
    [HttpPost(Name = nameof(Add))]
    public ActionResult<BookDto> Add([FromBody] BookCreateDto bookCreateDto)
    {
      if (bookCreateDto == null)
      {
        return BadRequest();
      }
 
      Book toAdd = Mapper.Map<Book>(bookCreateDto);
 
      _bookRepository.Add(toAdd);
 
      if (!_bookRepository.Save())
      {
        throw new Exception("Creating an item failed on save.");
      }
 
      Book newItem = _bookRepository.GetSingle(toAdd.Id);
 
      return CreatedAtRoute(nameof(GetSingle), new { id = newItem.Id },
        Mapper.Map<BookDto>(newItem));
    }
 
    [HttpPatch("{id:int}", Name = nameof(PartiallyUpdate))]
    public ActionResult<BookDto> PartiallyUpdate(int id, [FromBody] JsonPatchDocument<BookUpdateDto> patchDoc)
    {
      if (patchDoc == null)
      {
        return BadRequest();
      }
 
      Book existingEntity = _bookRepository.GetSingle(id);
 
      if (existingEntity == null)
      {
        return NotFound();
      }
 
      BookUpdateDto bookUpdateDto = Mapper.Map<BookUpdateDto>(existingEntity);
      patchDoc.ApplyTo(bookUpdateDto, ModelState);
 
      TryValidateModel(bookUpdateDto);
 
      Mapper.Map(bookUpdateDto, existingEntity);
      Book updated = _bookRepository.Update(id, existingEntity);
 
      if (!_bookRepository.Save())
      {
        throw new Exception("Updating an item failed on save.");
      }
 
      return Ok(Mapper.Map<BookDto>(updated));
    }
 
    [HttpDelete]
    [Route("{id:int}", Name = nameof(Remove))]
    public IActionResult Remove(int id)
    {
      Book item = _bookRepository.GetSingle(id);
 
      if (item == null)
      {
        return NotFound();
      }
 
      _bookRepository.Delete(id);
 
      if (!_bookRepository.Save())
      {
        throw new Exception("Deleting an item failed on save.");
      }
 
      return NoContent();
    }
 
    [HttpPut]
    [Route("{id:int}", Name = nameof(Update))]
    public ActionResult<BookDto> Update(int id, [FromBody] BookUpdateDto updateDto)
    {
      if (updateDto == null)
      {
        return BadRequest();
      }
 
      var item = _bookRepository.GetSingle(id);
 
      if (item == null)
      {
        return NotFound();
      }
 
      Mapper.Map(updateDto, item);
 
      _bookRepository.Update(id, item);
 
      if (!_bookRepository.Save())
      {
        throw new Exception("Updating an item failed on save.");
      }
 
      return Ok(Mapper.Map<BookDto>(item));
    }
  }

Hinzufügen von Cross-Origin Resource Sharing (CORS)

Damit unser API fähig ist, Aufrufe von anderen Domains statt nur der eigenen zu empfangen, können wir CORS konfigurieren. Da die Konfiguration des APIs über die Startup.cs-Datei passiert, können wir auch hier die CORS-Policy eintragen, die für uns passend ist.

Zuerst müssen wir CORS als Service hinzufügen und können es gleichzeitig konfigurieren (Listing 8).

Listing 8

services.AddCors(options =>
{
  options.AddPolicy("AllowAngularDevClient",
    builder =>
    {
      builder
        .WithOrigins("http://localhost:4200")
        .AllowAnyHeader()
        .AllowAnyMethod();
    });
});

Hierbei erstellen wir eine Regel, bei der wir nur Anfragen von unserem Development-Client der Angular Application zulassen. Diese Regel nennen wir AllowAngularDevClient und müssen diese nun noch in unserer Pipeline – also in der Configure-Methode – verwenden (Listing 9).

Listing 9

public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory,
IHostingEnvironment env)
{
  // ...
  app.UseCors("AllowAngularDevClient");
  // ...
}

Dokumentation mit Swagger

Nicht nur wenn ein API von anderen, eventuell projektfernen Entwicklern oder Teams verwendet werden soll, bietet sich eine Dokumentation des APIs an. Auch für die bessere eigene Übersicht ist eine gute Dokumentation sehr hilfreich. Damit wir keine endlosen Word-Dokumente ausfüllen, pflegen und aushändigen müssen, gibt es die Lösung, unser API elektronisch dokumentieren zu lassen und die Dokumentation via Endpunkt zur Verfügung zu stellen. Swagger [3] bietet eine solche Lösung an, die wir mit wenigen Handgriffen in unserem Web-API nutzen können.

Mit dotnet add Backend.csproj package Swashbuckle.AspNetCore können wir das NuGet-Paket hinzufügen. Nachdem es in die *.csproj eingefügt wurde, können wir es in der Startup.cs-Datei verwenden (Listing 10).

Listing 10

public void ConfigureServices(IServiceCollection services)
{
  // ...
  services.AddSwaggerGen(c =>
  {
    c.SwaggerDoc("v1", new Info { Title = "My first ASP.NET Core WebAPI", Version = "v1" });
  });
  // ...
}
 
public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory, IHostingEnvironment env)
{
  // ...
  app.UseSwagger();
  app.UseSwaggerUI(c =>
  {
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "Version 1");
  });
  // ...
}

Swagger generiert ein JSON-File als Beschreibung unseres APIs, das wir mittels der SwaggerUi in ein leserlicheres User Interface gießen lassen können. Nachdem das API gestartet ist, können wir das UI via https://localhost:5001/swagger anzeigen lassen (Abb. 1).

gosebrink_asp.net_core_1.tif_fmt1.jpgAbb. 1: Von Swagger generiertes UI

Um das Suffix swagger nach der Adresse des Servers zu sparen, können wir auch den RoutePrefix auf einen leeren String setzen:

app.UseSwaggerUI(c =>
{
  c.SwaggerEndpoint("/swagger/v1/swagger.json", "Version 1");
  c.RoutePrefix = string.Empty;
});

Jetzt ist die Swagger-Dokumentationsseite ebenfalls unter der Serveradresse https://localhost:5001/ sichtbar, was das Anzeigen noch einmal etwas vereinfacht.

Natürlich bietet ASP.NET Core noch viel mehr Möglichkeiten: WebSockets mit SignalR, Exception Handling, Configuration, Queryparameter und Arbeiten mit unterschiedlichen Environments sind einige der Möglichkeiten, die den Rahmen dieses Artikels sprengen würden.

Widmen wir uns nun der Clientseite mit Angular. Im nächsten Abschnitt wollen wir das erstellte ASP.NET-Core-Web-API benutzen, Bücher eintragen, als gelesen markieren und auch wieder löschen.

Erstellen einer Angular-Applikation

Um eine Angular-Applikation zu erstellen, benutzen wir das Angular CLI, das Command Line Interface von Angular [4]. Mit ng new angular-booktracker können wir die Applikation erstmals erstellen lassen.

Nachdem die Applikation mit ihren Dateien erstellt wurde, können wir sie mit npm start starten. Ein Webserver zur Entwicklung steht uns nach ein paar Sekunden unter http://localhost:4200 zur Verfügung. Angular baut auf dem Prinzip der Komponenten auf. Diese geben uns die Möglichkeit, unsere Applikation in viele kleine Teile zu unterteilen und diese einzeln zu implementieren.

Aufteilung der Applikation in Module

Um eine Architektur auf dem Client zu erstellen, bietet Angular neben den Webkomponenten selbst eine weitere Abstrahierungsstufe: das Aufteilen der Applikation in verschiedene Angular-Module. Ein Modul separiert unsere Applikation in logische Bereiche und einzelne Features. Sie dienen als Container für Angular Components, Pipes, Directives etc., die von dem Modul benötigt werden. Durch die Aufteilung in Module wird die Applikation wartbarer, übersichtlicher und einfacher testbar (Abb. 2).

Für unseren Booktracker sehen wir drei Module vor: ein BooksModule, ein CoreModule und ein SharedModule. Das AppModule, das auch zum Starten unserer Applikation benutzt wird, ist natürlich ebenfalls dabei.

Das BooksModule ist für das Buchfeature zuständig. Das CoreModule bietet einen Container für alle unsere Services, das SharedModule wird in unserem Beispiel zur Abstrahierung von Model-Klassen und den Angular-Material-Modulen benutzt, und das AppModule brauchen wir, um unsere Applikation zu starten; zudem dient es als Einstiegsmodul.

Module bieten außerdem die Möglichkeit des Lazy Loadings. Wir können also das erforderliche Featuremodul erst dann laden, wenn der Benutzer es explizit anwählt, oder wir laden es automatisch, nachdem alle Module ohne Lazy Loading bereits geladen wurden.

gosebrink_asp.net_core_2.tif_fmt1.jpgAbb. 2: Aufteilung in Module

Mithilfe der Routen können wir das Lazy Loading im app.routing.ts festlegen (Listing 11).

Listing 11

export const AppRoutes: Routes = [
  { path: '', redirectTo: 'books', pathMatch: 'full' },
  {
    // Falls dieser Pfad angewählt wird ...
    path: 'books',
    // ... lade dieses Modul nach
    loadChildren: './books/books.module#BooksModule'
  },
  {
    path: '**',
    redirectTo: 'books'
  }
];

Verwenden von Angular Material

Um fertige UI-Controls wie eine Liste oder ein Menü benutzen zu können, greifen wir auf Angular Material zurück [5]. Alle benötigten Elemente werden bei Angular Material in einzelnen Modulen exportiert, die wir in einem material.module.ts zusammenfassen und unserer Applikation mithilfe des SharedModule zur Verfügung stellen (Listing 12).

Listing 12

import { NgModule } from '@angular/core';
import { ... } from '@angular/material';
 
const materialModules: any[] = [
  // ... alle benötigten Material Modules
];
 
@NgModule({
  imports: materialModules,
  exports: materialModules
})
export class MaterialModule {}

Das SharedModule re-exportiert nun das MaterialModule, um anderen Modulen, die das SharedModule importieren, ebenfalls Zugriff auf die Exports des MaterialModule zu bieten (Listing 13).

Listing 13

import { NgModule } from '@angular/core';
import { MaterialModule } from './material.module';
 
@NgModule({
  imports: [MaterialModule],
  exports: [MaterialModule]
})
export class SharedModule {}

Die Kommunikation mit dem API

Bevor wir Daten anzeigen oder darstellen können, müssen wir sie vom API abrufen, das wir soeben erstellt haben. Dazu erstellen wir einen Service, der uns die Kommunikation mit dem API abstrahiert und mittels Methoden zur Verfügung stellt. Der HttpClient aus dem @angular/common/http-Modul sowie der Import des HttpClientModule aus Angular stellt uns genau die HTTP-Verben zur Verfügung, die wir brauchen (Listing 14).

Listing 14

@Injectable({ providedIn: 'root' })
export class BookService {
  private url = `https://localhost:5001/api/books`;
  constructor(private readonly http: HttpClient) {}
  // alle Methoden
}

Die @Injectable({ providedIn: 'root' })-Syntax fügt unseren Service in den Root Injector von Angular ein. Somit steht der Service in unserer Anwendung zur Verfügung. Durch die Syntax constructor(private readonly httpBase: HttpClient) {} benutzen wir die Dependency Injection von Angular auf der einen Seite, auf der anderen Seite registrieren wir eine private Property in der Klasse BookService namens "http", auf das wir von allen Methoden aus zugreifen können.

Um nun beispielsweise alle Bücher abzufragen, schicken wir einen GET-Request an den URL https://localhost:5001/api/books:

getAllBooks() {
    return this.http.get<Book[]>(this.url);
}

Wir rufen die GET-Methode auf, legen den generischen Rückgabetyp auf Book[] fest und verwenden als Ziel den URL, der im Service fix hinterlegt ist (Kasten „Konfiguration des URLs“). Der Rückgabetyp der Methode ist das Observable, also der Stream der Daten, die wir von dem REST Call erwarten. Von außen kann man sich auf die Antwort registrieren und, je nach Antwort, reagieren.

Die Book-Klasse ist eine reine DTO-Klasse, die wir gemäß der zu erwartenden JSON-Antwort festlegen können. Durch die Angabe des Typs wird die JSON-Antwort automatisch in diese Klasse serialisiert (Listing 15).

Listing 15

export class Book {
  id: number;
  read: boolean;
  title: sting;
  author: string;
  description: string;
  genre: string;
}

Konfiguration des URLs

Der URL kann durch verschiedene Möglichkeiten konfigurierbar gemacht werden: Environments, Config-Url-Requests etc.

Die weiteren Methoden auf dem Service sind genau die, die wir am API implementiert haben und auch in unserer Applikation brauchen (Listing 16).

Listing 16

  getAllBooks() {
    return this.http.get<Book[]>(this.url);
  }
 
  getSingle(bookId: number) {
    return this.http.get<Book>(`${this.url}/${bookId}`);
  }
 
  update(updated: Book) {
    return this.http.put<Book>(`${this.url}/${updated.id}`, updated);
  }
 
  add(book: Book) {
    return this.http.post<Book>(this.url, book);
  }
 
  delete(bookId: number) {
    return this.http.delete(`${this.url}/${bookId}`);
  }

Anzeigen der Daten

Um die Daten anzuzeigen, erstellen wir Components, die ein HTML-Template haben. Wir geben den eben erstellten Service via Dependency Injection in die Components hinein und rufen die entsprechenden Methoden auf.

Man kann hierbei Komponenten in zwei verschiedene Arten aufteilen: Presentational Components und Container Components [6]. Erstere fokussieren sich darauf, wie Daten dargestellt werden, aber kümmern sich nicht darum, wie Daten geladen werden oder woher sie kommen. Container Components haben eine Abhängigkeit zu einem Repository oder Daten-Service und empfangen Events von Presentational Components. Presentational Components bekommen die Daten übergeben und kümmern sich um die Darstellung, während Container Components sich darum sorgen, wo die Daten herkommen, und über Services mit beispielsweise einer REST-Schnittstelle kommunizieren können. Das macht Presentational Components extrem wiederverwendbar und die Stellen im Code, die den Status einer Applikation manipulieren, übersichtlicher.

So können wir auch die Komponenten in unserem Projekt aufteilen (Abb. 3).

gosebrink_asp.net_core_3.tif_fmt1.jpgAbb. 3: Komponentenaufteilung im Projekt

List Component

Die books-list.component.ts empfängt eine Collection von Büchern als Input und kümmert sich um die visuelle Darstellung dieser Auflistung mit einer Liste aus Angular Material. Sie sagt der sie verwendenden Component mittels eines Events Bescheid, falls jemand ein Buch als gelesen markiert hat.

Angular bietet uns hierfür die @Input()- und @ Output()-Decorators an, mit denen wir eingehende Daten und ausgehende Events auf Properties der Component-Klasse markieren können (Listing 17).

Listing 17

@Component({
  /*...*/
})
export class BookListComponent implements OnInit {
  @Input()
  books: Book[] = [];
 
  @Output()
  bookReadChanged = new EventEmitter();
  // ...
}

Um diese Properties mit Daten zu füllen bzw. sich auf Events der Component zu registrieren, verknüpfen wir die Component im HTML mit der Parent Component: Sie verwendet die Child Component mittels ihres Selectors im HTML, bindet Daten an die Properties und registriert sich auf Events (Listing 18).

Listing 18

<mat-tab-group dynamicHeight>
  <mat-tab *ngIf="unreadBooks$ | async as unreadBooks">
    <app-book-list [books]="unreadBooks" (bookReadChanged)="toggleBookRead($event)"></app-book-list>
  </mat-tab>
  <mat-tab *ngIf="readBooks$ | async as readBooks">
    <app-book-list [books]="readBooks" (bookReadChanged)="toggleBookRead($event)"></app-book-list>
  </mat-tab>
</mat-tab-group>

Wir binden also zwei Properties der Parent Component readBooks und unreadBooks an die Input-Property der Child Component app-book-list, die wir hier sogar wiederverwenden können.

Übersichts-Component

In der Component-Klasse der Parent Component füllen wir diese beiden Listen ab (Listing 19).

Listing 19

export class BooksOverviewComponent implements OnInit {
  unreadBooks$: Observable<Book[]>;
  readBooks$: Observable<Book[]>;
 
  constructor(private readonly bookService: BookService) {}
 
  ngOnInit() {
    this.getAllBooks();
  }
 
  private getAllBooks() {
    const allBooks$ = this.bookService.getAllBooks().pipe(
      publishReplay(1),
      refCount()
    );
 
    this.unreadBooks$ = allBooks$.pipe(
      map(books => books.filter(book => !book.read))
    );
 
    this.readBooks$ = allBooks$.pipe(
      map(books => books.filter(book => book.read))
    );
  }
}

Via Dependency Injection bekommen wir den BookService in den Konstruktor übergeben und speichern ihn in einer internen Variablen bookService. Danach speichern wir das Observable für alle Bücher, ungeachtet des read-Status, in einer Observable und erstellen zwei weitere Observables: unreadBooks$ und readBooks$ (Kasten „$-Suffix“).

„$“-Suffix

Das $-Zeichen am Ende einer Variable (auch finnische Notation genannt) macht es uns Entwicklern einfacher, zu erkennen, hinter welcher Variablen ein bereits aufgelöster Wert liegt und welche Variable einen Stream darstellt, dessen Wert irgendwann in Zukunft aufgelöst wird.

Async Pipe

Damit wir uns nicht manuell via subscribe(...) an die Auflösung der Observable hängen müssen, können wir sie an das Template binden und die Daten mit der Async Pipe auflösen. Das bietet den Vorteil, dass unser Code in der Component leserlicher wird und wir uns nicht um das unsubscribe kümmern müssen, falls die Component von Angular wieder zerstört wird. Die Async Pipe macht das automatisch für uns [7].

Um die Daten der Observable im Template an eine Variable binden zu können, bietet Angular uns eine *ngIf-as-Syntax an, die wir hier im Einsatz sehen:

<mat-tab *ngIf="unreadBooks$ | async as unreadBooks" label="to buy ({{unreadBooks.length}})">
  <!-- child component -->
</mat-tab>

*ngIf="unreadBooks$ | async as unreadBooks" füllt uns den Inhalt der Observable an eine Variable unreadBooks, die wir im Scope des HTML-Elements verwenden.

Jede Verwendung einer Async Pipe im Template gleicht also der Verwendung eines subscribe(...) im TypeScript-Code. Dadurch, dass wir zwei Async Pipes verwenden, feuern wir die Observable zweimal, sodass eigentlich zwei Requests an unseren Server geschickt werden müssten.

Damit wir den Server und unser API nicht unnötig belasten, können wir einen „Cache“ einbauen, den RxJs uns anbietet:

const allBooks$ = this.bookService.getAllBooks().pipe(
  publishReplay(1),
  refCount()
);

publishReplay(1), refCount() speichert uns den letzten Call auf der Observable, ohne nochmal einen neuen Aufruf zum API zu starten. Die Alternative wäre ein manuelles subscribe(...), das Filtern des Ergebnisses mit der filter(...)-Funktion und die Zuweisung in zwei Arrays auf der Component. Dieser Mehraufwand würde die Async Pipe obsolet machen.

Routing

Um zwischen verschiedenen Templates von Components zu wechseln, arbeiten wir mit Routing auf der Clientseite. Um das Routing zu aktivieren, importieren wir das RoutingModule im AppModule und konfigurieren es mit der forRoot(...)-Methode (Listing 20).

Listing 20

@NgModule({
  declarations: [/*...*/],
  imports: [
    // ...
    RouterModule.forRoot([
      { path: '', redirectTo: 'books', pathMatch: 'full' },
      {
        path: 'books',
        loadChildren: './books/books.module#BooksModule',
      },
       {
      path: '**',
      redirectTo: 'books',
    },
    ], { useHash: true }),
  ],
  providers: [],
  bootstrap: [...],
})
export class AppModule {}

Somit können wir festlegen, bei welchem Begriff (path) zu welcher Komponente gesprungen werden soll. Mit der Property loadChildren und der bereits gezeigten Syntax können wir das Lazy Loading dieses Moduls aktivieren und die Routen des Child-Moduls verwenden. Ebendiese können wir mit der Methode forChild(...) konfigurieren, sie sollte zum Konfigureren in der Applikation nur einmal auf dem AppModule verwendet werden (Listing 21).

Listing 21

@NgModule({
  imports: [
    RouterModule.forChild([
      { path: '', redirectTo: 'overview', pathMatch: 'full' },
      { path: 'overview', component: BooksOverviewComponent },
      { path: 'create', component: BookFormComponent },
      { path: 'edit/:id', component: BookFormComponent },
      { path: 'details/:id', component: BookDetailsComponent }
    ])
  ]
})
export class BooksModule {}

Somit definiert das BooksModule seine eigenen Routen und wird komplett – also mit seinen Routen – geladen. Der Verweis im App Module ist quasi der Einstiegspunkt zum Modul. Routen des Root-Moduls und des Child-Moduls werden einfach konkateniert.

Um die Templates der Komponenten darstellen zu können, benötigen wir einen auswechselbaren Teil, den Angular mit den Templates der Komponenten ersetzt.

Das RouterModul exportiert hierbei das router-outlet, das uns genau diese Funktionalität bereitstellt. Im Template der AppComponent können wir dies an die Stelle schreiben, die dynamisch ersetzt werden soll:

<mat-sidenav-container class="app-root" fullscreen>
  <!-- ... -->
  <router-outlet></router-outlet>
  <!-- ... -->
</mat-sidenav-container>

Basierend auf der Route wird an der Stelle von router-outlet jetzt die jeweilige Komponente angezeigt.

Forms

Natürlich kann man Informationen mit Angular-Daten nicht nur anzeigen, sondern sie auch an den Server senden. Wir haben die entsprechende POST-Methode am Server und in unserem Service schon implementiert.

Um dem Benutzer die Möglichkeit zu geben, neue Bücher zu erstellen oder zu bearbeiten, können wir Reactive Forms verwenden. Zuallererst importieren wir das ReactiveFormsModule und alles, was dieses Modul exportiert, in unser Modul (Listing 22).

Listing 22

import { ReactiveFormsModule } from '@angular/forms';
 
@NgModule({
  imports: [
    // ...
    ReactiveFormsModule
  ]
})
export class BooksModule {}

Mit Reactive Forms [8] erstellen wir die Form im Code der Component, binden die erstellte FormGroup an das Template und stellen die FormControls dar (Listing 23).

Listing 23

import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
 
@Component({
  /*...*/
})
export class BookFormComponent implements OnInit {
  form: FormGroup;
 
  ngOnInit() {
    this.form = new FormGroup({
      id: new FormControl(''),
      title: new FormControl('', Validators.required),
      author: new FormControl('', Validators.required),
      description: new FormControl('', Validators.required),
      genre: new FormControl('')
    });
  }
}

Wir können der Property form eine neue FormGroup zuweisen, die ein Objekt mit Properties bekommt, die die einzelnen FormControls id: new FormControl('') etc. darstellen. Als Parameter empfängt das FormControl-Objekt hier den Defaultwert. Man kann zusätzlich auch Validatoren oder Optionen mit angeben. In unserem Fall geben wir den Required Validator mit, der festlegt, dass die Form diesen Wert benötigt, um valide zu sein.

Ist dieses Objekt abgefüllt, können wir es im Template verwenden und unsere Form aufbauen (Listing 24).

Listing 24

<form [formGroup]="form">
  <div class="form-container">
    <input formControlName="title">
    <input formControlName="author">
    <textarea formControlName="description"></textarea>
   <!-- more controls -->
  </div>
</form>

Wir erstellen eine Form mit dem HTML-<form>-Tag und binden sie an die Direktive formGroup, die vom ReactiveFormsModule exportiert wird. Innerhalb dieses HTML-Form-Tags stehen dann die Controls der Form zur Verfügung, die wir an die entsprechenden HTML Controls binden können.

Um die Form abzusenden, bietet uns Angular eine weitere Direktive ngSubmit an, die wir wie folgt verwenden können:

<form (ngSubmit)="addBook()" [formGroup]="form">

Die Methode addBook() wird also mittels Event Binding aufgerufen, wenn die Form abgeschickt wird. Der nächste Schritt ist ein Button, mit dem wir die Form abschicken können:

<form (ngSubmit)="addBook()" [formGroup]="form">
  <!-- more controls -->
  <button [disabled]="form.invalid || form.pristine">Add Book</button>
</form>

Angular bietet uns die Möglichkeit an, HTML Properties zu binden, wie im Codebeispiel die disabled-Property des Buttons. Wir können den Button inaktiv setzen, wenn die Form selbst invalid ist, also mindestens eine ihrer Controls nicht valide ist (form.invalid) oder die Form noch nicht verändert wurde (form.pristine).

Die Methode addGroup() auf der Component benutzt erneut den zur Verfügung gestellten BooksService, um das Buch abzusenden. Der Property Value auf der Form stellt uns alle Form-Controls zur Verfügung, die wir so absenden, am Backend entgegennehmen und eintragen können (Listing 25).

Listing 25

export class BookFormComponent {
  constructor(
    private readonly bookService: BookService,
    private readonly notificationService: NotificationService
  ) {}
 
  addBook() {
    this.bookService
      .add(this.form.value)
      .subscribe(() => this.notificationService.show('Book added'));
  }
}

Damit der Benutzer weiß, dass das Hinzufügen erfolgreich war, können wir noch eine Meldung anzeigen oder anderweitig entsprechend reagieren.

Zusammenfassung

Wir können mit einer serverseitigen Technologie wie ASP.NET Core moderne und flexible Backend-Applikationen bauen, die wir nicht nur von Angular aus konsumieren können. Tools wie das dotnet CLI, die Dependency Injection, das automatische Neustarten des Servers, die Geschwindigkeit, und dass man zum Entwickeln nicht nur Visual Studio, sondern jeden beliebigen Editor benutzen kann, machen ASP.NET Core extrem flexibel und das Erstellen von Webapplikationen auch jenseits des Web-API sehr einfach. Die Trennung von Front- und Backend setzt dazu auf eine sehr hohe Flexibilität und Entkopplung.

Angular als moderne Webplattform – mit Tooling wie etwa dem Angular CLI und Features wie Dependency Injection, das Aufteilen in Module, Lazy Loading etc. – ermöglicht es, auch große Applikationen im Web mit Struktur und Architektur in TypeScript umzusetzen. Wer hätte vor ein paar Jahren gedacht, dass man solche Architekturen – auf dem Client und letztendlich mit JavaScript – entwickeln kann?

gosebrink_fabian_sw.tif_fmt1.jpgFabian Gosebrink ist Google Developer Expert für Angular und Webtechnologien, Microsoft MVP und Webentwickler im Bereich ASP.NET Core und Angular. Als Professional Software Engineer, Consultant und Trainer berät und unterstützt er Kunden bei der Umsetzung von Webapplikationen im Front- bzw. Backend bis hin zum mobilen Bereich.

Fabian Gosebrink

Fabian Gosebrink ist Microsoft MVP, Google Developer Expert, Pluralsight-Autor und Angular- und ASP.NET-Core-Webentwickler. Als Softwareentwickler, Consultant, Speaker und Trainer mit mehr als zehn Jahren Erfahrung unterstützt er Kunden bei der Entwicklung von Webanwendungen. Fabian ist sehr aktiv in der Community: Er leitet z. B. die schweizerische Angular-Community SwissAngular und die .NET User Group in Zürich, außerdem die größte deutschsprachige C#-Community (mycsharp.de). Außerdem erstellt er Videokurse für egghead und Pluralsight. Fabian betreibt seine eigene Firma Offering Solutions Software GmbH und die Kurs- und Lernplattform Developer Academy. Er spricht in lokalen User Groups sowie auf nationalen und internationalen Konferenzen.


Weitere Artikel zu diesem Thema