Blazor Software Architektur

Man liest gerne von Blazor als dem Nachfolger von ASP.NET Webforms. So etwas glaubt man, wenn man die Hello World Blazor Beispiele durchgeht. In diesem Blog Artikel geh ich tiefer und erforsche wie man eine Blazor App (egal ob Server oder Client) grundsätzlich strukturiert.

Der Anwendungsfall: Toast Notifications. Normalerweise nimmt der WPF oder UWP Programmierer dazu irgendeine Windows API. Der Browser hat aber kein Toast (oder hat er?).  Außerdem lernt man sehr schnell, wenn man sich voll auf Blazor verlässt, dann ist da nichts mit UI Interaktion, sonst landet man ganz schnell im JavaScriptLand und das will man ja nicht. Das UI wird konsequent an ein Model (Viewmodel) gebunden. So ähnlich wie in WPF, wobei ich da immer wieder gerne auch mal schnell einen Drei Zeiler C# Code im Code Behind tippe. Aber das wäre ja auch schon wieder JavaScript im Browser. Diese Konsequenz, die Komponenten und der Scope erinnern mich ganz stark an Angular.js.

Mein Disclaimer: ich habe das Beispiel mehrfach um gestaltet und bin mir nicht sicher ob die gezeigte Lösung für Toast Messages in Blazor optimal ist.

Der MVVM Ansatz

Ganz ähnlich wie in UWP/WPF wird eine Model Klasse und ein Viewmodel erstellt um die Daten für 1 bis unendlich Toast Nachrichten zu persistieren. In einem Blazor Prototyp habe ich eine UI Component als Model Typ verwendet und diese Ansatz dann aber verworfen und ein simples Toast Item erstellt. Beide Klassen lege ich in ein Model Verzeichnis

   1:   public class ToastItem
   2:      {
   3:          public string Titel { get; set; }
   4:          public string Text { get; set; }
   5:      }

Eigentlich würde in der ViewModel Klasse eine generische Liste der ToastItems genügen. Allerdings ergibt sich ein ähnliches Problem wie in WPF, das dort mit INotifyPropertyChanged gelöst wird. Das UI muss über Änderungen der Daten informiert werden. Oft erkennt der Blazor Renderer das von alleine, oft aber auch nicht. Entsprechend wird ein Event in die Klasse eingefügt, das jemand anderes abonnieren kann um die Sache mit dem UI Refresh hinzubekommen. Dazu später mehr.

   1:   public class ToastListe
   2:      {
   3:          public event Action OnToastsUpdated;
   4:          public ToastListe()
   5:          {
   6:              Toasts = new List<ToastItem>();
   7:           }
   8:          public void Add(string par1)
   9:          {
  10:              var t = new ToastItem();
  11:              t.Titel = par1;
  12:              Toasts.Add(t);
  13:   
  14:              OnToastsUpdated?.Invoke();
  15:          }
  16:          public void Remove(string par1)
  17:          {
  18:              var item = Toasts.Where(x => x.Titel == par1).FirstOrDefault();
  19:              Toasts.Remove(item);
  20:   
  21:              OnToastsUpdated?.Invoke();
  22:          }
  23:   
  24:          public List<ToastItem> Toasts { get; set; }
  25:      }

Wir sehen wir können Toasts per Add hinzufügen und per Remove wieder entfernen. Wo kommen nun aber diese Nachrichten hin im HTML DOM. Jetzt ist der Zeitpunkt sich auf die FInger zu klopfen. Das DOM ist Tabu oder ich muss diese eine verbotene Sache mit JS Extension machen.

Ich brauche einen Container, der bereits da sein muss und nenne diese Blazor Objekt ToastContainer. Dieser durchläuft alle Toast Nachrichten und rendert das passende HTML von Toast Elementen. Noch etwas versteckt im ToastPopup. Aktuell steht noch das Problem auf der Speisekarte, wie kommt man an die Liste. Noch grundlegender wie kommunizieren Blazor Objekte untereinander? Es gibt Parameter, kaskadierende Parameter und die Lösung die ich mal State Container nenne. Also ein C# Objekt das statisch in der Anwendung instanziert wird. Per Inject holt man sich die Instanz. Da gibt es wieder zwei Varianten mit @Inject und [Inject].

   1:  <div id="toastcontainer" style="position: absolute; top: 0; right: 0;">
   2:      @foreach (var toast in ToastListe.Toasts)
   3:      {
   4:          <ToastPopup HeadText="@toast.Titel" ></ToastPopup>
   5:      }
   6:  </div>
   7:  @code {
   8:      [Inject]
   9:      public toaster.Models.ToastListe ToastListe { get; set; }
  10:   
  11:      protected override void OnInitialized()
  12:          {
  13:         
  14:          base.OnInitialized();
  15:           ToastListe.OnToastsUpdated += () => InvokeAsync(StateHasChanged);
  16:      }
  17:   
  18:  }

Hier geht es ein wenig an die Innereien von Depencency Injection. Injizieren kann man nur was man vor deklariert hat. Das passiert in asp.net core in der Startup.cs Klasse. Blazor ist ein Subset von .net core 3.0. Die Liste der Toasts darf es nur einmal geben, deswegen Singleton (vom gleichnamigen Design Pattern).

   1:   public void ConfigureServices(IServiceCollection services)
   2:    {
   3:          services.AddSingleton<ToastListe>();
   4:   }

Kommen wir zurück auf die Aktualisierung des Userinterfaces.  Die Listenklasse (Toastliste) feuert das Event OnToastsUpdated (aus der ToastListe Klasse). Der Layout Container abonniert das Event per += und ruft StateHasChanged auf. Dies ist eine Blazor systemeigene API um den UI Refresh zu erzwingen. So reden die Komponenten miteinander.

Hatte ich das Layout vom Popup schon? Ne oder? Hier wird css und HTML von Bootstrap 4 missbraucht. Allerding auch hier komplett ohne JavaScript (weder Jquery noch das Bootstrap eigene). Verwendet wurde das ToastPopup.Razor Objekt schon in Listing 3 zum Binden der Daten an die Liste der Popups im Container.

Also Boostrap CSS Klassen und show damit es sichtbar ist. Mit @Headtext lässt sich im View das Property des ToastItems binden. Auch HTML Events wie Click werden an Methoden der Razor Page gebunden. Diese Methode ruft auf der injizierten Viewmodel Liste der Toast Objekte die Remove Methode auf. Als Parameter dient die Headline. Missachtet wird im Prototyp die Eindeutigkeit des Popups.

   1:  <div class="toast fade show" role="alert" aria-live="assertive" aria-atomic="true">
   2:      <div class="toast-header">
   3:          <img src="..." class="rounded mr-2" alt="...">
   4:          <strong class="mr-auto">@HeadText</strong>
   5:          <small>11 mins ago</small>
   6:          <button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close" 
   7:                  @onclick="Remove">
   8:              <span aria-hidden="true">&times;</span>
   9:          </button>
  10:      </div>
  11:      <div class="toast-body">
  12:           This is a toast message.
  13:      </div>
  14:  </div>
  15:  @code {
  16:      [Inject]
  17:      public toaster.Models.ToastListe ToastListe { get; set; }
  18:   [Parameter]
  19:      public string HeadText { get; set; }
  20:      public void Remove()
  21:      {
  22:          ToastListe.Remove(HeadText);
  23:      }
  24:  }

Eine erwähnenswerte Besonderheit die das Property Heaadtext das per Attribut zum Parameter befördert wird um letztendlich in der Nutzung des ToastPopup UI Objektes (Zeile 4- 2 Listings vorher) den Parameter per Datenbinding (@Toast.titel) einstreuen zu können.

Irgendwer muss die Toasts auch erzeugen. Hier kommt eine Razor Blazor Page mit einem Button zum Einsatz der per DI auf der Listeninstanz Add aufruft. Der Titel des Toasts (und damit seine ID) wird willkürlich gewählt und zur Kontrolle samt Anzahl der Toast angezeigt. Auch dies per Razor Databinding Syntax, ganz ohne JavaScript oder nur ans DOM zu denken.

   1:  @page "/ToastTest"
   2:  <h3>ToastTest</h3>
   3:  <div>
   4:      <input id="Button1" type="button" value="toaste" @onclick="Show" />
   5:      @Anzahl  <br>
   6:      @Par1
   7:  </div>
   8:  @code {
   9:      [Inject]
  10:      public toaster.Models.ToastListe ToastListe { get; set; }
  11:      public int Anzahl { get; set; }
  12:      public string Par1 { get; set; }
  13:      public void Show()
  14:      {
  15:          Anzahl++;
  16:          Par1 = "sample " + ToastListe.Toasts.Count().ToString();
  17:          ToastListe.Add(Par1);
  18:     }
  19:  }

Wo kommt nun der Container / Platzhalter für die Toastnotifications hin. Da ich das DOM nicht anfassen darf, muss er da sein im HTML. Die Startseite ist die index.html, in der zwingend die Referenz auf die Boostrap CSS Datei erfolgen muss.

   1:      <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
   2:      <link href="css/site.css" rel="stylesheet" />
   3:  </head>
   4:  <body>
   5:      <app >Loading...</app>
   6:      <script src="_framework/blazor.webassembly.js"></script>

Wie man sieht kein Container im Form eines DIV. Ich habe eine Ebene tiefer gewählt, den App Container. Dieser wird selbst als Blazor Objekt (app.razor) per Default im Visual Studio Blazor Projekt abgebildet. Zeile 1 inkludiert meinen Layout Container für die Notification Liste der Toast Objekte.

   1:  <toaster.Pages.ToastContainer></toaster.Pages.ToastContainer>
   2:  <Router AppAssembly="typeof(Program).Assembly">
   3:      <NotFoundContent>
   4:          <p>Sorry, there's nothing at this address.</p>
   5:      </NotFoundContent>
   6:  </Router>

Kaum zu glauben –funktioniert!

toasttest

Kommentare sind geschlossen