Blazor WebAssembly, Teil 4: Zustandsverwaltung und Nachladen von Modulen

In diesem Teil des Tutorials werden Informationen lokal im Browser persistiert, sodass sie einen Neustart der MiracleList-Webanwendung überleben.

In Pocket speichern vorlesen Druckansicht 10 Kommentare lesen
Lesezeit: 13 Min.
Von
  • Dr. Holger Schwichtenberg
Inhaltsverzeichnis

Klassische Webanwendungen mit serverseitigem Rendering sind zustandslos: Ein HTTP-Request geht auf dem Webserver ein, wird verarbeitet und irgendwann wird eine Antwort zum Browser gesendet. Nach Absenden der Antwort werden alle auf dem Webserver gesetzten Variablen vernichtet. Das ist in Blazor anders: Der Komponentenzustand mit allen gesetzten Fields und Properties bleibt erhalten, solange eine Razor Component aktiv ist. Eine Komponente ist aktiv, wenn sie

  • entweder über eine Route (relative URL) direkt aktiviert ist.
  • in eine andere Komponente eingebettet ist, die über eine Route aktiviert ist.
  • in eine andere Komponente eingebettet ist, für die der vorhergehende Satz gilt.
Blazor-Tutorial

Für die Bewahrung des Komponentenzustands über die oben genannten Fälle hinaus gibt es mehrere Alternativen in Blazor, die sowohl in Blazor WebAssembly als auch Blazor Server funktionieren (s. Tabelle).

Strategie Nicht mehr Teil der aktuellen Ansicht Reload und Browserneustart Verbindungsverlust (nur bei Blazor Server)
Sitzungszustand innerhalb von Blazor hilft hilft nicht hilft nicht
Session Storage des Browsers bzw. Session Cookies hilft hilft nicht hilft
Local Storage des Browsers bzw. persistente Cookies hilft hilft hilft
Persistierung in Datenbank oder anderem persistenten Speicher auf dem Server hilft hilft hilft

Strategien für die Zustandsbewahrung in Blazor

Die in den ersten drei Teilen des Tutorials mit Blazor WebAssembly erstellte Single-Page-Webanwendung "MiracleList" soll nun so erweitert werden, dass Benutzer sich nach einem Neustart des Webbrowsers (oder des ganzen Client-Systems) nicht noch mal erneut anmelden müssen, sondern wieder direkt beim Aufrufen der URL in die Aufgabenverwaltung gelangen. Eine Persistierung im Server würde für diesen Fall nicht helfen; vielmehr braucht es eine Information im lokalen Speicher des Webbrowsers. Diese Information sollte nicht Benutzername und Kennwort sein (das wäre ein Sicherheitsrisiko), sondern das zeitlich begrenzte Token, das das Backend-System bei der WebAPI-Operation /Login vergibt (vgl. Teil 1 dieser Serie).

Im Browserspeicher kann man mehr Daten (mehrere Megabyte, je nach Browser) ablegen, als in Cookies erlaubt ist. Auch für die Arbeit mit Cookies gibt es in Blazor keine eingebaute Funktion; man müsste auf die JavaScript-Interoperabilität oder eine entsprechende Wrapper-Komponente zurückgreifen.

Für den Zugriff auf den lokalen persistenten Speicher (Local Storage) des Webbrowsers gibt es bisher keine direkt in das Blazor-Framework integrierte Lösung, sondern einige Zusatzpakete bei NuGet.org:

Das Paket Blazored.LocalStorage ist zum Zeitpunkt der Erstellung dieses Beitrags aktueller gewartet, hat mehr GitHub-Sterne und deutlich mehr NuGet-Downloads (330.700) als Blazor.Extensions.Storage (36.100). Daher fällt die Wahl auf ersteres. Zunächst ist das Paket Blazored.LocalStorage per grafischer NuGet-Benutzeroberfläche in Visual Studio oder in der NuGet Package Manager Console zu einem Blazor-Projekt hinzuzufügen:

Install-Package Blazored.LocalStorage

Abhängig von der verwendeten Blazor-Version müssen die Leser gegebenenfalls eine bestimmte, kompatible Version des Pakets installieren. Da sich das Paket unabhängig von Blazor versioniert, lässt sich hier nicht vorhersagen, welche Version das beim Erscheinen dieses Beitrags sein wird.

Dann ist der Local Storage Service der Komponente im Dependency-Injection-Container in Program.cs zu registrieren:

using Blazored.LocalStorage;
...
services.AddBlazoredLocalStorage();

Nun können sich Softwareentwickler in jeder Razor Component oder anderen Klassen eine Klasse injizieren lassen, die Blazored.LocalStorage.ILocalStorageService bereitstellt. Diese Schnittstelle bietet einige Methoden für das Speichern von Name-Wert-Paaren an:

  • SetItemAsync(): Setzen eines Werts im Local Storage unter Angabe von Namen und Wert
  • GetItemAsync(): Laden eines Werts anhand des Namens
  • RemoveItemAsync(): Entfernen eines Werts anhand des Namens
  • ClearAsync(): Entfernen aller Werte
  • LengthAsync(): Liefert die Anzahl der gespeicherten Werte
  • KeyAsync(): liefert einen Wert per numerischem Index
  • ContainsKeyAsync(): prüft, ob zu einem Namen einen Wert gibt

In diesem Fall wird eine Zeichenkette (das o. g. Token) persistiert; es lassen sich aber auch komplexe Objekte übergeben, sofern diese in JSON serialisierbar sind, denn der Zugriff auf die Web Storage API erfolgt über JavaScript.

Die Injizierung einer Klasse für die Schnittstelle Blazored.LocalStorage.ILocalStorageService wird in AuthenticationManager.cs benötigt:

public class AuthenticationManager : AuthenticationStateProvider 
 {
  MiracleListAPI.MiracleListProxy proxy { get; set; }
  Blazored.LocalStorage.ILocalStorageService storage { get; set; }

  public AuthenticationManager(MiracleListAPI.MiracleListProxy proxy, Blazored.LocalStorage.ILocalStorageService storage )
  {
   this.proxy = proxy;
   this.storage = storage;
  }
...
}

In der gleichen Klasse ergänzt man nun eine Methode CheckLocalTokenValid(), die mit GetItemAsync() versucht, ein Token aus dem Local Storage des Webbrowsers zu laden. Falls ein Token vorhanden ist, wird es durch einen Aufruf der flexiblen WebAPI-Operation /LoginAsync geprüft. Hier können Nutzer statt Benutzernamen und Kennwort auch ein Token übergeben. Wenn das Token noch gültig ist, liefert die WebAPI den Benutzernamen zurück, und durch Aufruf der eigenen Methode Notify() informiert der Code die Blazor-Infrastruktur über die erfolgreiche Benutzeranmeldung. Abbildung 1 zeigt in der Browserkonsole die Ausgaben des Authentication Manager beim Aufruf von CheckLocalTokenValid() bei vorhandenem Token im Local Storage.

Das noch gültige Token im Local Storage sorgt dafür, dass Benutzer sich nicht nochmals neu anmelden müssen (Abb. 1).
public async Task<bool> CheckLocalTokenValid()
  {
   bool result = false;
   string token = await localStorage.GetItemAsync<string>(STORAGE_KEY);
   if (!String.IsNullOrEmpty(token))
   {
    // Es gibt ein Token im Local Storage. Nachfrage beim Server, ob noch gültig.
    Console.WriteLine($"{nameof(AuthenticationManager)}.{nameof(CheckLocalTokenValid)}: Checking local token {token}...");
    var l = new LoginInfo() {Token=token, ClientID = AuthenticationManager.ClientID };
    this.CurrentLoginInfo = await proxy.LoginAsync(l);
    if (this.CurrentLoginInfo == null || String.IsNullOrEmpty(CurrentLoginInfo.Token))
    { // Token ungültig!
     Console.WriteLine($"{nameof(AuthenticationManager)}.{nameof(CheckLocalTokenValid)}: Token not valid: {CurrentLoginInfo?.Message}!");
     CurrentLoginInfo = null;
    }
    else
    { // Token gültig!
     Console.WriteLine($"{nameof(AuthenticationManager)}.{nameof(CheckLocalTokenValid)}: Found valid Token: {CurrentLoginInfo.Token} for User: {CurrentLoginInfo.Username}");
     Notify();
     result = true;
    }
   }
   else
   {
    Console.WriteLine($"{nameof(AuthenticationManager)}.{nameof(CheckLocalTokenValid)}: No local token!");
   }
   Notify();
   return result;
  }

Listing 1: Erweiterung der Klasse AuthenticationManager.cs

Diese neue Methode CheckLocalTokenValid() aus Listing 1 lässt sich dann in der Razor Component "Login" in der Code-Behind-Datei aufrufen (s. Listing 2). Die Prüfung, ob ein Token vorhanden ist, sollte in der Lebenszyklusmethode OnInitializedAsync() erfolgen; hier kann man dann vor dem Rendern von Inhalten Benutzer mit dem NavigationManager schon auf die Hauptansicht leiten. Allerdings muss die Prüfung des Tokens im Programmcode erst nach der Abfrage folgen, ob die Benutzer eine Abmeldung wünschen; sonst würde diese niemals erreicht werden.

  protected override async System.Threading.Tasks.Task OnInitializedAsync()
  {
   // Reaktion auf die URL /logout
   if (this.NavigationManager.Uri.ToLower().Contains("/logout"))
   {
    await ((AuthenticationManager)asp).Logout(); return;
   }

   // Direkt zur Hauptseite, falls ein Token im Local Storage ist
   if (await (asp as AuthenticationManager).CheckLocalTokenValid())
   {
    this.NavigationManager.NavigateTo("/main");
   }
  }

Listing 2: Erweiterung von Login.razor.cs

Eine direkte Interaktion mit dem Local Storage ist noch an zwei weiteren Stellen in der Klasse AuthenticationManager erforderlich:

  • In der Methode Login() ist bei erfolgreicher Anmeldung das Token im Local Storage zu setzen: await localStorage.SetItemAsync<string>(STORAGE_KEY, this.CurrentLoginInfo.Token);
  • In der Methode Logout() muss man das Token mit await localStorage.RemoveItemAsync(STORAGE_KEY) entfernen, sonst würden Benutzer immer wieder neu angemeldet werden.

Nun sollte die Anmeldung bei der Webanwendung rundlaufen:

  • Wenn Benutzer sich erstmals anmelden, werden sie in der Methode AuthenticationManager.Login() angemeldet, ihr Token lokal persistiert und dann von Login.razor.cs mit /Main auf die Seite Index.razor gelenkt.
  • Wenn Benutzer den Browser schließen und die Webanwendung neu öffnen, prüft CheckLocalTokenValid() das persistierte Token erneut gegen das Backend und leitet die Benutzer dann auf /Main.
  • Sofern Benutzer direkt /Main anspringen, gibt es keine Benutzerprüfung. Sie werden aber – wie im Teil 3 implementiert – auf / geleitet und landen damit bei einem der ersten beiden Fälle.

Wenn Benutzer auf Logout klicken, wird per <a>-Tag die relative URL /Logout gerufen und die Komponente Login.razor reagiert darauf mit Abmeldung beim Backend und Löschen des Tokens im Local Storage.

Die bisher erstellte Blazor-WebAssembly-Anwendung ist nicht sehr groß, aber lädt dennoch schon rund 70 Dateien und rund 10 MByte in den Webbrowser. Schon seit der ersten Version Blazor WebAssembly 3.2 ist es möglich, Razor Components und sonstige C#-Klassen sowie auch klassische Webinhalte wie HTML-, CSS-, JavaScript- und Grafikdateien in sogenannte Razor Class Libraries (RCL) auszulagern. Das diente bisher aber nur der Wiederverwendbarkeit und nicht dem Nachladen bei Bedarf (Lazy Loading). Diese schmerzlich vermisste Funktion hat Microsoft in Blazor 5.0 nachgerüstet: Damit ist es möglich, die Downloadlast beim Start der Anwendung zu reduzieren.

In Fall des MiracleList-Frontends soll bei Anwendungsstart nur noch die Anmeldemaske geladen werden. Alle andere Razor Components sollen erst nach dem erfolgreichen Anmelden vom Webserver geholt werden. Weder Microsofts Standardprojektvorlage noch das bisher erstellte MiracleList-Projekt sind auf Lazy Loading vorkonfiguriert. Um das zu integrieren, sind folgende Schritte notwendig:

  • Anlegen eines weiteren Projekts vom Typ "Razor Class Library" mit Namen "MiracleListRCL" in der Projektmappe "MiracleList". Dazu nutzen Entwickler im Kontextmenü der Projektmappe Add | New Project | Razor Class Library. Wichtig ist, dass sie ".NET 5.0" als Target Framework wählen (und dafür Visual Studio 2019 ab Version 16.8 verwenden), aber "Support pages and views" nicht wählen, denn damit würde man eine serverseitige Razor Class Library für ASP.NET Core MVC und ASP.NET Core Razor Pages erschaffen. Entwickler sollten in MiracleListRCL.csproj <Project Sdk="Microsoft.NET.Sdk.Razor"> und <TargetFramework>net5.0</TargetFramework> sowie <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="5.0.x" /> sehen.
  • Erstellen einer weiteren, normalen .NET-5.0-Klassenbibliothek (<Project Sdk="Microsoft.NET.Sdk">, (<TargetFramework>net5.0</TargetFramework>() mit Namen "MiracleListUtil". Das ist als Heimat für die Klasse AuthenticationManager.cs notwendig, denn diese muss sowohl im Startprojekt als auch in der nachzuladenden MiracleListRCL.dll verfügbar sein.
  • Erstellen einer Referenz in MiracleListUtil.csproj und MiracleListRCL.csproj auf die Projekte "BO" und "MiracleListAPI_Proxy" (das geht in Visual Studio 2019 mittlerweile per Drag & Drop des Projekts auf den Ast "Dependencies"), denn beide DLLs müssen auf das Backend zugreifen.
  • Löschen der vordefinierten Dateien "Component1.razor", "ExampleJsInterop.cs" und des Inhalt des Ordners /wwwroot in MiracleListRCL.csproj.
  • Anlegen eines Ordners "/Pages" in MiracleListRCL.csproj.
  • Den Namen "Web" in MiracleListRCL.csproj festlegen, und zwar in den Projekteigenschaften in der Registerkarte "Application" als "Default Namespace". Das erlaubt, den bisherigen Programmcode ohne Namensraumänderungen hierher zu verschieben.
  • Verschieben der Razor Components "About.razor", "Index.Razor" und "TaskEdit.razor" per Drag & Drop mit gedrückter Umschalt-Taste von MiracleListBW.csproj nach MiracleListRCL.csproj in den Ordner "/Pages". Die Razor Components müssen in den Unterordner "/Pages", damit die Namensräume passen.
  • Verschieben der Klasse AuthenticationManager.cs von MiracleListBW.csproj nach MiracleListUtil.csproj
  • Erstellen einer Referenz von MiracleListBW.csproj zu MiracleListRCL.csproj und MiracleListUtil.csproj, damit die Blazor-WebAssembly-Anwendung wieder alle bisher bekannten Teile kennt.
  • Erstellen einer Referenz von MiracleListRCL.csproj zu MiracleListUtil.csproj, damit die nachzuladende DLL den AuthenticationManager kennt.
  • In MiracleListUtil.csproj die gleiche Version des NuGet-Pakets "Microsoft.AspNetCore.Components.Authorization" installieren, das in Teil 3 des Tutorials auch im Blazor-WebAssembly-Projekt installiert wurde, zum Beispiel Install-Package Microsoft.AspNetCore.Components.Authorization -Version 5.0.2
  • Die gleiche Version des NuGet-Pakets "Blazored.LocalStorage" in MiracleListUtil.csproj installieren, das im Teil 3 auch im Blazor-WebAssembly-Projekt installiert wurde.
  • Entfernen der Referenz auf "Blazored.LocalStorage" aus MiracleListBW.csproj.
  • Den Namen der nachzuladenden DLL mit ".dll" am Ende in der Projektdatei (.csproj) des Blazor-WebAssembly-Projekts MiracleListBW.csproj ergänzen. (Achtung: Vor der RC1-Version war hier die Angabe von .dll verboten.)
<ItemGroup>
  <BlazorWebAssemblyLazyLoad Include="MiracleListRCL.dll" />
 </ItemGroup> 
  • Die App.razor-Datei im Blazor-WebAssembly-Projekt mit dem Inhalt des Listings 3 ersetzen. Die Klasse LazyAssemblyLoader und das Ereignis OnNavigateAsync() sind neu in Blazor 5.0. Normalerweise würde bei AdditionalAssemblies ein fester Eintrag (z. B. new[] { typeof(Web.Pages.Index).Assembly }) stehen. Nun ist es die Property, die zur Laufzeit im Ereignis OnNavigateAsync() verändert wird, falls die URL auf main oder about endet.
@using System.Reflection
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.WebAssembly.Services
@inject LazyAssemblyLoader LazyAssemblyLoader

<CascadingAuthenticationState>
 <Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync">
  <Navigating>
   <div>
    <p>Loading the requested page...</p>
   </div>
  </Navigating>
  <Found Context="routeData">
   <AuthorizeRouteView RouteData="@routeData"
                       DefaultLayout="@typeof(MainLayout)">
    <NotAuthorized>
     Sie sind nicht angemeldet und können den Inhalt dieser Seite daher nicht sehen. Bitte melden Sie sich an in der <a href="/Login">Anmeldenmaske</a>
     @{
      NavigationManager.NavigateTo("/");
     }
    </NotAuthorized>
   </AuthorizeRouteView>
  </Found>
  <NotFound>
   <LayoutView Layout="typeof(MainLayout)">
    <p>Sorry, there is nothing at this address.</p>
   </LayoutView>
  </NotFound>
 </Router>
</CascadingAuthenticationState>

@code {
 private List<Assembly> lazyLoadedAssemblies =
    new List<Assembly>();

 private async Task OnNavigateAsync(NavigationContext args)
 {
  Console.WriteLine("OnNavigateAsync: " + args.Path);
  if (args.Path.EndsWith("main") || args.Path.EndsWith("about"))
  {
   Console.WriteLine("Lazy Loading MiracleListRCL.dll...");
   var assemblies = await LazyAssemblyLoader.LoadAssembliesAsync(new string[] { "MiracleListRCL.dll" });
   lazyLoadedAssemblies.AddRange(assemblies);
  }
 }
}

Listing 3: App.razor mit Lazy Loading

Nach diesen umfangreichen Änderungen sollte die MiracleList-Projektmappe so aussehen, wie sie Abbildung 2 zeigt. Die Anwendung verhält sich beim Start wie bekannt. Nur wenn man in die Netzwerkansicht der Entwicklerwerkzeuge im Browser schaut, sieht man, dass MiracleListRCL.dll erst nach der Benutzeranmeldung nachgeladen wird. Der Einspareffekt beim Anwendungsstart ist in diesem Fall nicht groß, weil die MiracleListRCL.dll nur rund 13 KByte groß ist. Aber wenn die Anzahl der Razor Components in der Webanwendung wächst, vergrößert sich der Effekt. Zudem kann man durch einen möglichst spartanischen Anmeldedialog auch Grafiken unter anderem Ressourcen erst später zu laden.

Umgestaltung der Projektmappe für das Lazy Loading (Abb. 2)

Das Lazy Loading funktioniert: MiracleListRCL.dll wird erst nach der Benutzeranmeldung nachgeladen (Abb. 3).

Die Erweiterung des Beispiels um die lokale Persistierung war einfach zu realisieren und verbessert das Benutzererlebnis dennoch ganz erheblich. Die nachträgliche Umstellung auf das Nachladen von Anwendungsteilen bei Bedarf war hinweg leider ein umfangreiches Prozedere, weil die Modularisierung nicht von Anfang an berücksichtigt wurde.

Im fünften und letzten Teil des Tutorials wird es um die Integration der bidirektionalen Kommunikation mit ASP.NET Core SignalR. Damit lässt der Inhalt mehrerer Browserfenster synchronisieren, worüber der Benutzer auch optische benachrichtigt werden soll.

Dr. Holger Schwichtenberg
ist Chief Technology Expert bei der MAXIMAGO-Softwareentwicklung. Mit dem Expertenteam bei www.IT-Visions.de bietet er zudem Beratung und Schulungen im Umfeld von Microsoft-, Java- und Web-Techniken an. Er ist Autor zahlreicher Bücher, u. a. "ASP.NET Core Blazor: Moderne Single-Page-Web-Applications mit .NET, C# und Visual Studio".

(ane)