Building a serverless Blazor Application: Eine Chat-App in Blazor

15. Juni 2020

Im ersten Artikel dieser Serie habe ich gezeigt, wie ASP.NET Blazor-Anwendungen mithilfe von Microsoft Azure schnell und kostengünstig serverlos gehostet werden können. Die dort beschriebene Anwendung war allerdings recht simpel und bot keine echte Funktionalität. Das wollen wir in diesem Artikel ändern.

Zum Verständnis der Begriffe ASP.NET-Blazor und Serverless sei auf den eingangs erwähnten ersten Artikel verwiesen. Auch im Weiteren geht es vorrangig um Blazor Client (WebAssembly)-Anwendungen. Die Code-Beispiele funktionieren wahrscheinlich auch für Blazor Server-Projekte, allerdings gelten dort z.T. andere Konzepte. Dort brauchen wir beispielsweise keine zusätzlichen serverlosen Komponenten zum Zugriff auf unser Backend, da die Anwendung bereits auf einem Server läuft.

Das Beispiel

Um nicht nur abstrakt über theoretische Konzepte zu sprechen, wollen wir im Folgenden eine einfache Chat-Anwendung entwickeln. Diese soll folgende Funktionalität bieten:

  • Der Anwender kann sich mit einem Nutzernamen anmelden
  • Der Anwender kann anderen Nutzern Nachrichten senden
  • Der Anwender kann Nachrichten von anderen Nutzern ansehen
  • Die Nachrichten werden online gespeichert

Im ersten Schritt werden wir folgende Vereinfachungen treffen:

1. Die Anmeldung erfolgt nur mit Nutzernamen (ohne Passwort)

Security ist ein wichtiges, aber auch komplexes Thema, das den Rahmen dieses Artikels sprengen würde. Deshalb kann sich erst einmal jeder Anwender als jeder Nutzer ausgeben. Eventuell werde ich die Anwendung in einem vierten Artikel um eine echte Nutzer-Authentifizierung erweitern. Aktuell gibt es zudem noch einen Fehler in der entsprechenden Bibliothek von Microsoft.

2. Die Nachrichten werden nicht zum Empfänger gepushed.

Die Erweiterung um Echtzeit-Funktionalität ist eigentlich der Hauptschwerpunkt einer Chat-Anwendung, führt für diesen Artikel allerdings zu weit. Das Thema werden wir uns im dritten Part dieser Serie in Ruhe ansehen.

Hinweis: Der Code der fertigen Anwendung steht auf GitHub bereit. Dieser enthält allerdings bereits die Erweiterungen für die Echtzeit-Funktionalität, die wir in Artikel 3 betrachten werden.

Erstellung der Chat-App

.
Architektur

Da wir in diesem Beispiel mehr als ein Projekt verwenden, bietet es sich an, eine Visual Studio Solution anzulegen. Alternativ kann aber auch mit dem Visual Studio Code gearbeitet werden. Insgesamt brauchen wir drei Projekte:

1. Eine .NET Standard Class Library, in der gemeinsam genutzte Klassen abgelegt werden. Wird im Folgenden als „Shared“ bezeichnet.

2. Das Blazor Client-Projekt. Dieses kann über Visual Studio oder über die Kommandozeile erstellt werden, wie im ersten Artikel beschrieben. In beiden Fällen ist darauf zu achten, dass eine WebAssembly-Anwendung erstellt und dabei nicht „ASP.NET Core hosted“ ausgewählt wird.

3. Ein Azure Function-Projekt. Dieses kann ebenfalls über Visual Studio oder Visual Studio Code erstellt werden. Sinnvoll ist es hier, die .NET-Core-Runtime auszuwählen, um die Class-Library aus Punkt 1 referenzieren zu können.

In der „Shared“-Bibliothek benötigen wir zunächst nur eine einzige Klasse, „Message.cs“:

   1:

   2: public class Message

   3: {

   4:     public string SourceUserName { get; set; }

   5:     public string TargetUserName { get; set; }

   6:     public string Content { get; set; }

   7:     public DateTime DateTime { get; set; }

   8: }

Diese repräsentiert eine Nachricht zwischen zwei Usern und besteht aus Zeitstempel und Inhalt. Dank ASP.NET Blazor können wir dieselbe Klasse sowohl im Frontend als auch im Backend verwenden und brauchen keine Entsprechung in JavaScript zu entwickeln und zu pflegen.

Das Backend

Schauen wir uns nun zunächst das Backend an. Wir wollen eine serverlose Architektur entwickeln, weshalb wir auf Azure Functions zurückgreifen. Zum Speichern der übertragenen Nachrichten brauchen wir zusätzlich noch einen Datenspeicher. Am komfortabelsten ist hier die Azure CosmosDB, da wir die Nachrichten einfach als semi-strukturierte Dokumente ablegen können, ohne zuerst relationale Tabellen zu definieren.

Wir erstellen also zunächst eine Function App und eine CosmosDB in Azure. Hier können jeweils die einfachsten Optionen ausgewählt werden, wichtig sind nur der passende Runtime stack (.NET Core) und die Einstellung „Consumption (Serverless)“ als Plan bei der Function App sowie „Core (SQL)“ als API bei der CosmosDB.

Im erstellten CosmosDB Account erstellen wir dann eine neue Datenbank „Chat“ und darin einen Container „Messages“. Der Datenbank-Durchsatz kann auf minimal 400 RU/s herabgesetzt werden. Das Indexing kann entweder auf Automatic gelassen oder zur Kostenersparnis deaktiviert werden. Als Partition Key genügt für das Beispiel eine der Properties (z.B. „/TargetUserName“). Sinnvoller wäre an dieser Stelle eine Message-ID als Partition Key und jeweils ein Range-Index auf den beiden UserName-Properties, nach denen wir filtern werden. Mehr zum Thema Partitioning und Indexing in der CosmosDB unter den jeweiligen Links.

(Hinweis: Die CosmosDB kann im Vergleich zu den übrigen verwendeten Services (Function App, Storage Account, CDN) spürbar teurer werden. Eine einfachere Alternative ist der Azure Table Storage. Für die Konzepte in diesem Artikel bietet der Table Storage vergleichbare SDKs und Schnittstellen. Er bietet aber nicht die Funktionalität für die Real-Time-Updates in Artikel 3.)

blazor_2

Schauen wir uns nun die Azure Functions an. Insgesamt brauchen wir zwei Funktionen innerhalb unserer Function App, eine zum Senden und eine zum Abfragen von Nachrichten:

   1: [FunctionName("GetMessages")]

   2: public static async Task<IActionResult> Run(

   3:     [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "GetMessages/{userName}")] HttpRequest req,

   4:     [CosmosDB(

   5:         databaseName: "Chat",

   6:         collectionName: "Messages",

   7:         ConnectionStringSetting = "CosmosDBConnection",

   8:         SqlQuery = "SELECT * FROM Messages m WHERE m.SourceUserName = {userName} OR m.TargetUserName = {userName}")

   9:     ] IEnumerable<Message> messages,

  10:     ILogger log)

  11: {

  12:     log.LogInformation($"{messages.Count()} items loaded");

  13:     return new OkObjectResult(messages);

  14: }

Die Funktion GetMessages erwartet als URL-Parameter einen Nutzernamen und lädt dann aus der eben erstellten CosmosDB-Datenbank alle Nachrichten von oder zu diesem Nutzer. Der Datenbank-Zugriff erfolgt dabei direkt über ein CosmosDB-Binding, wir brauchen hier also keine eigenen API-Calls durchzuführen. Dadurch wird der Rumpf der Funktion sehr simpel.

Mehr Informationen zu Azure Function Trigger und Bindings gibt es in diesem Flurfunkartikel von Alexander Jung.

Die Klasse „Message“ entspricht der aus unserer Shared Classlibrary, die wir vom Funktion-Projekt aus referenzieren müssen. Zusätzlich brauchen wir das NuGet-Package Microsoft.Azure.WebJobs.Extensions.CosmosDB.

   1: [FunctionName("SendMessage")]

   2: public static async Task<IActionResult> Run(

   3:     [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,

   4:     [CosmosDB(databaseName: "Chat", collectionName: "Messages", ConnectionStringSetting = "CosmosDBConnection")] IAsyncCollector<Message> messagesOut,

   5:     ILogger log)

   6: {

   7:     string requestBody = await new StreamReader(req.Body).ReadToEndAsync();

   8:     var data = JsonConvert.DeserializeObject<Message>(requestBody);

   9:

  10:     if (data != null)

  11:         await messagesOut.AddAsync(data.Value);

  12:

  13:     return new OkResult();

  14: }

Die zweite Funktion zum Senden von Nachrichten sieht ein wenig komplexer aus. Hier erwarten wir gesendete Message als JSON-Objekt im Body des HTTP-POST-Aufrufs. Das CosmosDB-Binding dient dieses Mal als Output, in den wir das deserialisierte Objekt stecken können. Auch hier müssen wir uns dank des Bindings nicht mit den API-Calls der CosmosDB beschäftigen. Einzig das Umwandeln des JSONs in ein Objekt ist nötig. Azure Functions referenzieren standardmäßig Newtonsoft.Json, das wir hier verwenden.

Jetzt kann die Funktion App schon gepublished werden. Im Azure Portal müssen wir dann noch die nötigen Settings (unter <Function App> -> Configuration) setzen. Aktuell ist das nur der Wert für „CosmosDBConnection“, den wir im CosmosDB-Bindung verwenden. Den dafür nötigen Connection-String bekommen wir im Portal unter <CosmosDB> -> Keys.

Außerdem sollten wir unter <Function App> -> „Platform features“ -> CORS die URL unserer Blazor-App hinzufügen. Für Anwendungen, die über „dotnet run“ gestartet werden, ist das http://localhost:5000 bzw. https://localhost:5001. Visual Studio verwendet andere Ports. Außerdem muss der Haken bei „Enable Access-Control-Allow-Credentials” gesetzt werden. Wenn die App wie in Artikel 1 beschrieben auf einem Storage Account und/oder über von Azure CDN-Endpunkt gehostet wird, sollte die entsprechende URL hier ebenfalls angegeben werden.

Zuletzt können wir in der Übersicht der Function-App auf eine der Funktionen klicken und dort „Manage“ auswählen. Von hier benötigen wir später einen Function- bzw. Host-Key.

Der Client

In unserem Blazor Client-Projekt können wir erstmal überflüssige Funktionalität aus der Beispielanwendung entfernen. Dazu gehören die Pages „Counter“ und „FetchData“, die „SurveyPromt“ im Ordner „Shared“ und das Verzeichnis „sample-data“ unter „wwwroot“.

Als erste Ergänzung legen wir dann eine Model-Klasse namens „User“ an:

User.cs
   1: using System.ComponentModel.DataAnnotations;

   2:

   3: public class User

   4: {

   5:     [Required]

   6:     [StringLength(15, ErrorMessage="Name too long")]

   7:     public string UserName { get; set; }

   8: }

In diesem Beispiel enthält die Klasse nur den Namen eines Nutzers, sinnvollerweise könnte sie aber auch Informationen wie die E-Mail-Adresse oder die interne ID speichern. Die Attribute dienen zur Steuerung der Eingabe im folgenden Abschnitt:

Index.razor

Als nächstes nehmen wir uns die Index.razor-Datei vor und ersetzen den bisherigen Code durch folgenden:

   1: @page "/"

   2: @using BlazorChatApp.Models

   3: @using BlazorChatApp.Services

   4: @inject ChatService Service

   5:

   6: <h1>Hello, @(_sourceUser.UserName ?? "please sign in")!</h1>

   7:

   8: Welcome to your Blazor chat app.

   9:

  10:

  11: @if(!_loggedIn)

  12: {

  13: <h3>Login:</h3>

  14: <EditForm Model="@_sourceUser" OnSubmit="@HandleUserSubmit">

  15:     <DataAnnotationsValidator />

  16:     <ValidationSummary />

  17:

  18:     <InputText id="user-name" @bind-Value="_sourceUser.UserName" />

  19:

  20:     <button type="submit">Login</button>

  21: </EditForm>

  22: }

  23: else

  24: {

  25: <h3>Chat with user:</h3>

  26: <EditForm Model="@_newTargetUser" OnSubmit=@HandleTargetSubmit>

  27:     <DataAnnotationsValidator />

  28:     <ValidationSummary />

  29:

  30:     <InputText id="user-name" @bind-Value="_newTargetUser.UserName" />

  31:

  32:     <button type="submit">Create</button>

  33: </EditForm>

  34: }

  35:

  36:

  37: @code {

  38:     private User _sourceUser = new User();

  39:     private User _newTargetUser = new User();

  40:     private bool _loggedIn = false;

  41:

  42:     protected override void OnInitialized()

  43:     {

  44:         if(Service.User != null)

  45:         {

  46:             _sourceUser = Service.User;

  47:             _loggedIn = true;

  48:         }

  49:     }

  50:

  51:     private async Task HandleUserSubmit()

  52:     {

  53:         if(!string.IsNullOrWhiteSpace(_sourceUser.UserName))

  54:         {

  55:             _loggedIn = true;

  56:             await Service.SignInUser(_sourceUser);

  57:         }

  58:     }

  59:

  60:     private void HandleTargetSubmit()

  61:     {

  62:         Service.AddTargetUser(_newTargetUser.UserName);

  63:         _newTargetUser = new User();

  64:     }

  65: }

Diese Seite erfüllt zwei Aufgaben: Solange kein Benutzer angemeldet ist, fragt sie den Anwender nach seinem Nutzernamen. Alternativ, wenn ein Benutzer angemeldet ist, erlaubt sie das Hinzufügen neuer Chat-Partner über deren Nutzernamen.

Dazu bietet sie zwei EditForm-Elemente, mit denen die Namen für zwei User-Objekte abgefragt werden. Diese entsprechen HTML-Forms, verfügen allerdings über eingebaute Validierungs-Unterstützung, die über die zuvor besprochenen Attribute der Model-Properties gesteuert wird. In diesem Beispiel wird automatisch ein Fehler angezeigt und der Login verweigert, wenn mehr als 15 Zeichen eingegeben werden.

blazor_1

Im Fall einer erfolgreichen Eingabe werden die Methoden einer ChatService-Klasse aufgerufen, die am Beginn der Datei injiziert wird. Diese enthält die eigentliche Funktionalität unserer Chat-Anwendung und wird uns im Folgenden beschäftigen:

Die ChatService-Klasse

Zunächst brauchen wir ein paar Felder und Properties:

   1: public class ChatService

   2: {

   3:     private readonly HttpClient Http;

   4:     private readonly IConfiguration Configuration;

   5:

   6:     public User User {get; private set;}

   7:

   8:     private IDictionary<string, List<Message>> _messages;

   9:     public IEnumerable<string> TargetUsers => _messages.Keys;

  10:

  11:     public IEnumerable<Message> GetMessages(string user) => _messages[user];

Die Klasse Message ist die aus unserer Class Library, die wir dazu referenzieren müssen. IConfiguration stammt aus dem Package Microsoft.Extensions.Configuration, das bereits referenziert sein sollte. Mehr zum Thema Konfiguration in Blazor später noch. Der HttpClient stammt aus System.Net.Http und ist in Blazor standardmäßig über Dependency Injection verfügbar.

Ansonsten hält der Service den gerade angemeldeten User und eine Sammlung von Nachrichten, indiziert nach dem jeweiligen Chat-Partner. Die Liste der bekannten Chat-Partner ist entsprechend die Liste aller Keys im Nachrichten-Dictionary.

Im Constructor initialisieren wir die Felder und konfigurieren den HttpClient:

   1: public ChatService(HttpClient client, IConfiguration configuration)

   2: {

   3:     Configuration = configuration;

   4:     Http = client;

   5:     Http.BaseAddress = new System.Uri(Configuration["API_Base_URL"]);

   6:     Http.DefaultRequestHeaders.Add("x-functions-key", Configuration["API_Host_Key"]);

   7:

   8:     _messages = new Dictionary<string, List<Message>>();

   9: }

Anstelle der Konfigurations-Werte können hier zu Testzwecken auch feste String-Werte eingetragen werden. Die BaseAddress ist dabei die URL der Function App (https://<yourapp>.azurewebsites.net), der Host Key stammt von der oben beschriebenen Management-Seite der Function App. Da der Client alle Funktionen der Functions App aufruft, ist ein Host Key hier sinnvoller als ein individueller Function Key für jede der Funktionen. (Achtung: Niemals den „Master“-Key verwenden, am besten einen eigenen Host Key für die Anwendung generieren!)

Des Weiteren brauchen wir noch EventHandler, um die Views der Anwendung zu informieren, wenn sich etwas geändert hat, zum Beispiel ein neuer User oder eine neue Nachricht dazugekommen ist:

   1: public delegate void UserChangeEventHandler();

   2: public event UserChangeEventHandler UsersChanged;

   3:

   4: public delegate void NewMessageEventHandler(string user);

   5: public event NewMessageEventHandler NewMessage;

Jetzt können wir die beiden Hauptmethoden anlegen. Zum einen wollen wir einen Nutzer „anmelden“ und all seine Nachrichten herunterladen können:

   1: public async Task SignInUser(User newUser) {

   2:     User = newUser;

   3:     await RetrieveMessages();

   4: }

   5:

   6: private async Task RetrieveMessages() {

   7:     if(User != null)

   8:     {

   9:         var messages = await Http.GetJsonAsync<IEnumerable<Message>>($"{Configuration["API_URL_PART_GetMessages"]}/{User.UserName}");

  10:         if(messages != null)

  11:         {

  12:             Console.WriteLine($"Received {messages.Count()} messages");

  13:             _messages = messages

  14:                 .GroupBy(m => m.TargetUserName != User.UserName ? m.TargetUserName : m.SourceUserName)

  15:                 .ToDictionary(g => g.Key, g => g.OrderBy(e => e.DateTime).ToList());

  16:

  17:             UsersChanged?.Invoke();

  18:         }

  19:     }

  20: }

Der neue User wird einfach als aktueller User gesetzt. Dann wird die zuvor definierte Azure Function „GetMessages“ aufgerufen. Diese liefert zum aktuellen UserName alle gespeicherten Nachrichten zurück. Dank der GetJsonAsync-Erweiterung erhalten wir die Antwort direkt als Liste von „Message“-Objekten – derselben Klasse, in der wir sie auf der anderen Seite ausgeliefert haben.

Zum anderen soll der User Nachrichten verschicken können:

   1: public async Task SendMessage(string targetUser, string content)

   2: {

   3:     if(User != null) {

   4:         var message = new Message {

   5:             SourceUserName = User.UserName,

   6:             TargetUserName = targetUser,

   7:             Content = content,

   8:             DateTime = DateTime.Now

   9:         };

  10:

  11:         await Http.PostJsonAsync(Configuration["API_URL_PART_SendMessages"], message);

  12:

  13:         _messages[targetUser].Add(message);

  14:         NewMessage?.Invoke(targetUser);

  15:     }

  16: }

Hier erzeugen wir ein neues Objekt der „Message“-Klasse und senden es in einem Post-Aufruf an die „SendMessages“-Function auf Azure. Dann wird die neue Nachricht zu den bisher bekannten hinzugefügt und der passende EventHandler aufgerufen.

Ansonsten brauchen wir noch eine Möglichkeit, einen neuen Chat-Partner anzulegen:

   1: public void AddTargetUser(string newTargetUser)

   2: {

   3:     if(!_messages.ContainsKey(newTargetUser))

   4:     {

   5:         _messages.Add(newTargetUser, new List<Message>());

   6:         UsersChanged?.Invoke();

   7:     }

   8: }

Jetzt fehlen nur noch ein paar View-Anpassungen:

Views

Zuvor erweitern wir die _Imports.razor-Datei um die Configuration, damit wir das Objekt überall verfügbar haben:

   1: @using Microsoft.Extensions.Configuration

   2: @inject IConfiguration Configuration

Dann zu den eigentlichen Views.

Zuerst bauen wir das NavMenu um. Statt der gelöschten Unterseiten Counter und FetchData wollen wir jetzt eine Liste aller bekannten Chat-Partner anzeigen und auf deren Nachrichtenverlauf verweisen:

   1: @using BlazorChatApp.Services

   2: @inject ChatService Service

   3: @implements IDisposable

   4:

   5: <div class="top-row pl-4 navbar navbar-dark">

   6:     <a class="navbar-brand" href="">@Configuration["Title"]</a>

   7:     <button class="navbar-toggler" @onclick="ToggleNavMenu">

   8:         <span class="navbar-toggler-icon"></span>

   9:     </button>

  10: </div>

  11:

  12: <div class="@NavMenuCssClass" @onclick="ToggleNavMenu">

  13:     <ul class="nav flex-column">

  14:         <li class="nav-item px-3">

  15:             <NavLink class="nav-link" href="" Match="NavLinkMatch.All">

  16:                 <span class="oi oi-plus" aria-hidden="true"></span> Add User

  17:             </NavLink>

  18:         </li>

  19:         @foreach(var userName in Service.TargetUsers)

  20:         {

  21:             var href = $"/chat/{userName}";

  22:             <NavLink class="nav-link" href="@href" Match="NavLinkMatch.All">

  23:                 <span aria-hidden="true"></span> @userName

  24:             </NavLink>

  25:         }

  26:     </ul>

  27: </div>

  28:

  29: @code {

  30:     private bool collapseNavMenu = true;

  31:     private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;

  32:     private void ToggleNavMenu()

  33:     {

  34:         collapseNavMenu = !collapseNavMenu;

  35:     }

  36:

  37:     protected override void OnInitialized()

  38:     {

  39:         Service.UsersChanged += Refresh;

  40:     }

  41:

  42:     void IDisposable.Dispose()

  43:     {

  44:         Service.UsersChanged -= Refresh;

  45:     }

  46:

  47:     private void Refresh() => StateHasChanged();

  48: }

Der wichtige Teil ist die Foreach-Schleife, die einen Navigations-Eintrag für jeden Chat-Partner anlegt. Im @Code-Teil registriert sich die View für den UsersChanged-Eventhandler, damit beim Bekanntwerden eines neuen Users die Liste neu generiert wird.

Dann legen wir noch eine neue Page an (Chat.razor), die den Nachrichtenverlauf mit einem Chat-Partner repräsentiert:

   1: @page "/chat/{UserName}"

   2: @using BlazorChatApp.Services

   3: @inject ChatService Service

   4: @implements IDisposable

   5:

   6: <h1>Chat with @UserName</h1>

   7:

   8: @foreach(var message in Service.GetMessages(UserName))

   9: {

  10:     var directionClass = message.TargetUserName == UserName ? "send" : "received";

  11:

  12:     <div class="chat-message @(directionClass)">

  13:         <span class="datetime">@message.DateTime.ToString()</span>

  14:         <p class="message-content">@message.Content</p>

  15:     </div>

  16: }

  17:

  18: <input placeholder="Message" @bind="CurrentMessage" />

  19: <button class="btn btn-primary" @onclick="SendMessage">Send</button>

  20:

  21: @code {

  22:     [Parameter]

  23:     public string UserName {get;set;} = "";

  24:

  25:     private string CurrentMessage {get;set;}

  26:

  27:     protected override void OnInitialized()

  28:     {

  29:         Service.NewMessage += HandleNewMessage;

  30:     }

  31:

  32:     void IDisposable.Dispose()

  33:     {

  34:         Service.NewMessage -= HandleNewMessage;

  35:     }

  36:     private async Task SendMessage()

  37:     {

  38:         if(!string.IsNullOrWhiteSpace(CurrentMessage))

  39:         {

  40:             await Service.SendMessage(UserName, CurrentMessage);

  41:             CurrentMessage = "";

  42:         }

  43:     }

  44:

  45:     private void HandleNewMessage(string user)

  46:     {

  47:         if(user == UserName)

  48:         {

  49:             StateHasChanged(); // Löst Neuberechnung der Komponente aus

  50:         }

  51:     }

  52: }

Der Name des Chat-Partners wird hier als URL-Parameter übergeben („/chat/{UserName}“). Es wird der bisherige Nachrichtenverlauf dargestellt und ein Input-Feld zum Eingeben einer neuen Nachricht angeboten. Ansonsten registriert sich die View auf das NewMessage-Event und aktualisiert entsprechend die Ansicht, wenn eine neue Nachricht eintrifft. Dies geschieht auch beim Senden einer Nachricht, weshalb es keinen „StateHasChanged“-Aufruf in „SendMessage“ braucht.

Es fehlt noch ein letzter Schritt:

Program.cs

In der Main-Methode müssen wir zunächst einmal unsere ChatService-Klasse registrieren:

   1: builder.Services.AddSingleton<ChatService>();

Außerdem brauchen wir die Configuration.

Hier stoßen wir auf einen wesentlichen Unterschied zwischen Blazor Server und Blazor Client/WebAssembly. In Blazor Server Anwendungen können wir die Configuration mit normalen ASP.NET Core-Mitteln konfigurieren (z.B. über eine appsettings.json). Dort liegt das entsprechende File sicher auf dem Server, wo es nur von der Anwendung, nicht aber von außen erreichbar ist.

Bei einer Blazor Client-Anwendung wird jedoch der gesamte Anwendungs-Code auf den Rechner des Aufrufers heruntergeladen und dort ausgeführt. Eine auf die gleiche Weise zur Verfügung gestellte Config-Datei wäre also für den Nutzer sichtbar. Connection-Strings zu Datenbanken oder andere Credentials dürfen deshalb niemals in der Konfiguration einer Blazor Client-Anwendung auftauchen. Diese Systeme sollten immer hinter einer API (z.B. einer Azure Function) versteckt werden.

Trotzdem gibt es Informationen, die in einer Client-Konfiguration abgelegt werden müssen, z.B. für unterschiedliche Umgebungen. Um das zu erreichen haben wir grundsätzlich zwei Möglichkeiten:

1. Bereitstellen der Config als statisches File. Wir legen die Konfigurations-Datei direkt neben den Daten unserer Anwendung ab (z.B. unter wwwroot/config). Das macht es einfach, die Konfig auf Backend-Seite auszutauschen, dafür ist sie direkt im Traffic der Seite sichtbar und problemlos von außen lesbar. Hier dürften also bestenfalls sehr allgemeine Daten wie der umgebungsabhängige Titel der Anwendung abgespeichert werden.

2. Bereitstellen der Config als embedded resource. Dazu im Visual Studio die Datei auswählen -> Properties -> unter BuildAction „embedded resource“ wählen. Das sorgt dafür, dass die Datei mit in das Anwendungs-Assembly gepackt wird. Dort ist sie etwas sicherer als auf dem Webserver, allerdings keinesfalls unerreichbar für ausreichend versierte Nutzer. Für Daten wie die URL und die Function-Keys unserer Azure Function ist dies vermutlich trotzdem der beste Weg. Die eigentliche Absicherung der Function gegen unbefugte Zugriffe sollte ohnehin nicht allein auf dem Key, sondern auf einer individuellen Nutzer-Authentifizierung basieren!

Das in Variante 2 eingebettete File kann wie folgt in der Main-Funktion geladen werden:

   1: var assembly = Assembly.GetExecutingAssembly();

   2: var resource = assembly.GetName().Name + ".appsettings.json";

   3: var stream = assembly.GetManifestResourceStream(resource);

   4:

   5: var config = new ConfigurationBuilder()

   6:         .AddJsonStream(stream)

   7:         .Build();

   8:

   9: builder.Configuration.AddConfiguration(config);

(Das ist die Quintessenz aus den Blogs https://remibou.github.io/Configuring-a-Blazor-app/ sowie https://developingdane.com/configuration-in-blazor-client/ … Eine offizielle Anleitung von Microsoft zur Konfiguration von Blazor Client habe ich nicht gefunden).

Hinweis1: Die Erweiterungs-Methode AddJsonStream stammt aus dem Package Microsoft.Extensions.Configuration.Json.

Wer unnötige DLLs sparen möchte, bekommt ein äquivalentes Ergebnis mit:

   1: var config = await System.Text.Json.JsonSerializer

   2:     .DeserializeAsync<Dictionary<string, string>>(stream);

   3: builder

   4:     .Configuration

   5:     .AddInMemoryCollection(config)

   6:     .Build();

(System.Text.Json wird ohnehin referenziert.) Diese Option lässt sich aber deutlich schlechter um z.B. Sections erweitern als die obere.

Hinweis2: Der direkte Aufruf builder.Configuration.AddJsonStream(stream) funktioniert aktuell(?) nicht! Es muss der Umweg über new ConfigurationBuilder genommen werden. Dieses Verhalten kann in einer künftigen Version behoben sein.

Fazit

Mithilfe von Azure Function App und Azure CosmosDB haben wir mit wenigen Schritten eine grundlegend funktionsfähige, serverlose Chat-App entwickelt, in der Nutzer persistente Nachrichten miteinander austauschen können:

blazor_3

(Diese Abbildung erfordert noch einige hier nicht aufgeführte CSS-Anpassungen)

Insbesondere die Interaktion mit der API aus Blazor heraus ist sehr einfach und die Ergebnisse können dank der .NET Eventhandler schnell auf die Views übertragen werden. Einer der größten Vorteile von Blazor gegenüber JavaScript-Frameworks macht sich aber in der Verwendung der „Shared“-ClassLibrary bemerkbar, durch die Client und Backend direkt mithilfe derselben geteilten Klassen kommunizieren können.

Bisher funktioniert der Nachrichtenaustausch allerdings nicht synchron. Der Empfänger erhält Chat-Nachrichten erst nach einem Reload der Anwendung. Wer mag, kann zur Übung noch einen Refresh-Button einbauen. Alternativ werde ich im dritten Teil der Artikelserie beschreiben, wie wir mit Azure SignalR Service eine serverlose Echtzeit-Funktionalität in die Anwendung integrieren können.

autor Michel Richter

Michel Richter

Senior eXpert