Serverless Blazor Applications: Echtzeit Push Updates

8. Juli 2020

Im ersten Artikel dieser Serie habe ich beschrieben, wie sich eine Blazor Client Anwendung serverlos auf Azure hosten lässt. Im zweiten Artikel ging es um die Entwicklung einer einfachen Chat-Anwendung mit Blazor Frontend und einem Backend auf Basis von Azure Functions und der Azure Cosmos DB.

In diesem dritten Artikel zeige ich nun, wie sich mit wenigen Schritten serverlos echte Push-Funktionalität in die App integrieren lässt. Das heißt, dass Nachrichten augenblicklich beim Empfänger angezeigt werden, ohne dass dafür permanent die Liste der Nachrichten abgefragt werden muss.

Die Lösung dafür lautet SignalR, beziehungsweise in diesem Fall Azure SignalR Service.

Azure SignalR Service

SignalR ist ein Framework, das es Anwendungen ermöglicht, aktive Verbindungen zwischen Server und Client aufrechtzuerhalten. Darüber können in beide Richtungen Funktionen aufgerufen und Nachrichten ausgetauscht werden. Auf diese Weise kann der Server einen oder mehrere Clients informieren, wenn neue Daten verfügbar sind, statt dass alle Clients beständig nach Aktualisierungen fragen. Dabei wählt SignalR selbstständig das jeweils beste verfügbare Protokoll aus, das der Client unterstützt. Wenn ein Browser beispielsweise keine WebSockets unterstützt, wird auf ein anderes Verfahren wie Long-Polling umgeschwenkt, ohne dass die Anwendung oder der Nutzer etwas davon mitbekommt.

Um unsere Chat-App um Push-Funktionalität zu erweitern, müssen wir erst einmal einen Azure SignalR Service erstellen. Die einzig wichtige Einstellung ist hier, „ServiceMode“ auf „Serverless“ zu setzen. Das kann notfalls auch nachträglich noch geändert werden.

Sobald die Ressource erstellt ist, können wir auf der linken Seite unter „Settings“ den Punkt „Keys“ auswählen und einen der beiden Connection Strings kopieren. Den brauchen wir später für die Azure Function App.

SignalR Functions

In einem normalen ASP.NET Server-Szenario ist der SignalR-Workflow recht einfach: Die Anwendung definiert einen speziellen Controller, der als SignalR-Hub dient. Mit diesem verbindet sich der Client dann genauso wie mit dem Rest der Backends.

In einem serverlosen Szenario besteht das Backend allerdings nur aus Azure Functions und dem gerade erstellten SignalR-Service. Der SignalR-Service selbst ist größtenteils in sich abgeschlossen und dient ausschließlich als Nachrichten-Bus. Anwendungslogik und Datenzugriffe müssen wir außerhalb davon programmieren. Damit kommen wir zurück zur Function App.

Wir brauchen zwei neue Funktionen in unserer App:

Die Negotiate-Funktion

Die erste ist vom SignalR-Service vorgegeben.

   1: [FunctionName("negotiate")]

   2: public static SignalRConnectionInfo GetSignalRInfo(

   3:     [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req,

   4:     [SignalRConnectionInfo(HubName = "chat")] SignalRConnectionInfo connectionInfo)

   5: {

   6:     return connectionInfo;

   7: }

Diese Funktion muss per Konvention auf den Endpunkt „negotiate“ hören. Ansonsten besteht ihre Aufgabe lediglich darin, über das SignalRConnection-Binding eine Verbindung zum SignalR Service herzustellen und die entsprechenden Infos zurückzugeben. Konkret liefert sie die URL des SignalR-Service und ein individuelles Access Token für den Client aus.

Damit das SignalR-Binding funktioniert, muss das Package Microsoft.Azure.WebJobs.Extensions.SignalRService referenziert werden. Außerdem müssen wir den oben kopierten Connection String in den Settings hinzufügen. Standardmäßig sucht das Binding nach einem Eintrag mit dem Namen „AzureSignalRConnectionString“. Alternativ kann dem Binding-Attribut aber auch ein anderer Name mitgegeben werden.

Die Message-Funktion

Als zweites brauchen wir nun noch eine Funktion, die auf neu hereinkommende Nachrichten reagiert und den SignalR-Service anspricht. Wir könnten diese Funktionalität direkt in die „SendMessage“-Funktion integrieren, es ist aber sinnvoll, pro Aufgabe eine eigene Funktion zu verwenden.

Außerdem haben wir so verschiedene Optionen, diese Benachrichtigungs-Funktion anzustoßen. Wir könnten wieder einen HTTP-Trigger verwenden und sie von „SendMessage“ aufrufen lassen. Wir könnten die beiden auch über einen Queue-Storage verbinden. Eine dritte Option ist, dass sich „SendMessage“ überhaupt nicht darum kümmert, sondern wir direkt auf Änderungen an der Datenbank lauschen. Dann nämlich würden auch Nachrichten erfasst werden, die (aus welchen Gründen auch immer) nicht über „SendMessage“ hereinkamen:

   1: [FunctionName("messages")]

   2: public static async Task NewMessage(

   3:     [CosmosDBTrigger(databaseName: "Chat", collectionName: "Messages", ConnectionStringSetting = "CosmosDBConnection", LeaseCollectionName = "leases", CreateLeaseCollectionIfNotExists = true, FeedPollDelay = 500, StartFromBeginning = true)] IReadOnlyList<Document> documents,

   4:     [SignalR(HubName = "chat")] IAsyncCollector<SignalRMessage> signalRMessages,

   5:     ILogger log)

   6: {

   7:     log.LogInformation($"Received {documents.Count} new messages.");

   8:     foreach(var document in documents)

   9:     {

  10:         var message = new Message

  11:         {

  12:             DateTime = document.GetPropertyValue<DateTime>(nameof(Message.DateTime)),

  13:             Content = document.GetPropertyValue<string>(nameof(Message.Content)),

  14:             SourceUserName = document.GetPropertyValue<string>(nameof(Message.SourceUserName)),

  15:             TargetUserName = document.GetPropertyValue<string>(nameof(Message.TargetUserName)),

  16:         };

  17:

  18:         await signalRMessages.AddAsync(

  19:             new SignalRMessage

  20:             {

  21:                 Target = $"newMessage-{message.TargetUserName}",

  22:                 Arguments = new object[] { message }

  23:             });

  24:     }

  25: }

Der Name dieser Funktion spielt keine große Rolle, da sie nicht über HTTP angesprochen werden kann. Stattdessen verbindet sie sich ebenfalls mit der Cosmos DB und lässt sich von dieser über Änderungen informieren. Damit das geht, müssen ein paar zusätzliche Parameter gesetzt werden, die eine neue Collection namens „lease“ beschreiben. In dieser verwaltet die Cosmos DB den jeweiligen Lese-Stand des Bindings, also welche Nachrichten noch unbekannt sind und entsprechend zu einem Aufruf führen. Das FeedPollDelay definiert, wie oft die CosmosDB nach Änderungen scannt, es hat keinen Einfluss auf die Aktualisierungsrate der Client-Anwendung. Dennoch bestimmt es natürlich die maximale Verzögerung, bis eine Nachricht beim Empfänger ankommt.

Das „Target“ der SignalR-Message ist gewissermaßen das Topic, über das sie verteilt wird. Nur Clients, die auf dieses Target subscribed sind, bekommen die entsprechende Nachricht. Wir verwenden hier den Empfänger-Usernamen als Teil des Targets, damit Nachrichten bei genau dem Client ankommen, in dem der jeweilige Empfänger angemeldet ist.

Hinweis: Dieses Vorgehen lässt sich nicht mit dem Azure Table Storage nachbilden. In dem Fall müsste die Funktion manuell beim Speichern einer neuen Nachricht aufgerufen werden.

Anpassungen am Client

In der Blazor Chat App müssen kaum Änderungen vorgenommen werden. Die Views sind bereits über EventHandler mit der ChatService-Instanz verbunden, können also jederzeit über Änderungen benachrichtigt werden. Das einzige, was wir tun müssen, ist die Verbindung zum SignalR Service herzustellen und auf das richtige Target zu lauschen.

Die nötigen Klassen dazu stammen aus dem Package Microsoft.AspNetCore.SignalR.Client. Dieses muss referenziert werden.

In der ChatService-Klasse legen wir zunächst ein neues Feld für die SignalR-HubConnection an

   1: Private readonly HubConnection _hubConnection;

und instanziieren diese dann im Constructor:

   1: _hubConnection = new HubConnectionBuilder()

   2:                 .WithUrl(Configuration["API_URL_SignalR_Negotiation"])

   3:                 .Build();

Der URL-String in der Settings-Datei für „API_URL_SignalR_Negotiation“ sollte die komplette URL zur Funktion-App enthalten, aber ohne das „negotiate“ am Ende. Also https://<yourapp>.azurewebsites.net/api

Danach fügen wir eine neue Methode in die ChatService-Klasse ein und rufen sie nach dem Setzen des Users auf:

   1: public async Task SignInUser(User newUser)

   2: {

   3:     User = newUser;

   4:     await RetrieveMessages();

   5:     await ConnectToSignalR();

   6: }

   7: private async Task ConnectToSignalR()

   8: {

   9:     if (User != null)

  10:     {

  11:         _hubConnection.On<Message>($"newMessage-{User.UserName}", MessageReceived);

  12:         await _hubConnection.StartAsync();

  13:     }

  14: }

Hier registriert sich die Anwendung auf das Target für den aktuellen Nutzer. Danach wird die Verbindung aufgebaut. In der Methode MessageReceived fügen wir neu erhaltene Nachrichten in das Dictionary ein und aktualisieren die entsprechenden Views:

   1: private void MessageReceived(Message message)

   2: {

   3:     var sourceUser = message.SourceUserName;

   4:     if (!_messages.ContainsKey(sourceUser))

   5:     {

   6:         _messages.Add(sourceUser, new List<Message>());

   7:         UsersChanged?.Invoke();

   8:     }

   9:

  10:     _messages[sourceUser].Add(message);

  11:     NewMessage?.Invoke(sourceUser);

  12: }

Das war’s auch schon. Jetzt können wir, sobald die Anwendung gestartet ist, Nachrichten senden und in (fast) Echtzeit empfangen.

Aktuell gibt es in der UI keine Möglichkeit, den User abzumelden. Wenn diese Funktionalität hinzugefügt werden soll, müssen wir uns auch wieder vom SignalR-Service abmelden. Wichtig ist dabei, die Connection zu stoppen, bevor eine neue Verbindung mit geändertem User-Target aufgebaut wird:

   1: _hubConnection.Remove($"newMessage-{User.UserName}");

   2: await _hubConnection.StopAsync();

Fazit

Mithilfe von Azure SignalR Service und Azure Functions lässt sich in wenigen Schritten serverlose Echtzeit-Funktionalität für Apps bereitstellen. Auch die Integration in unsere Blazor Chat App erfolgte schnell und ohne Änderungen an bestehendem Code.

Was an dieser Stelle noch fehlt, ist das Thema Authentifizierung. Aktuell kann sich jeder Anwender mit jedem beliebigen Benutzernamen anmelden, Messages verschicken und sich mit dem SingalR Service verbinden. Für letzteres ist nicht einmal der Host Key der Function App notwendig, sodass sich auch andere Anwendungen ungehindert auf unsere Nachrichten subscriben können. Hier müsste in allen drei Functions noch eine Überprüfung des aktuellen Users – z.B. über Anmelde-Token – erfolgen.

Das wäre allerdings ein Thema für einen weiteren Artikel. Die Grundlagen zur Authentifizierung eines Users in Blazor Client finden sich hier: https://docs.microsoft.com/en-us/aspnet/core/security/blazor/webassembly/?view=aspnetcore-3.1 und sind recht simpel, können aber insbesondere im Zusammenspiel mit Azure-Services recht schnell komplex werden. Zum Zeitpunkt, an dem ich diesen Artikel geschrieben habe, lieferte die Funktion „RequestAccessToken“ außerdem kein sinnvolles Ergebnis zurück, weshalb ich mit einem Artikel zu dem Thema noch etwas warten wollte.

Insgesamt lässt sich festhalten, dass ASP.NET Blazor eine sehr spannende Technologie ist – insbesondere, aber nicht nur für .NET-Entwickler – um modernde Single Page Web Applications zu entwickeln. Mithilfe von Electron und Mobile Bindings sollen diese Apps in Zukunft auch direkt auf Desktop- und Mobile-Geräten laufen können (https://gunnarpeipman.com/blazor-roadmap-2019/).

Durch die Möglichkeiten von Microsoft Azure lassen sich Blazor Client Anwendungen zudem komplett serverlos und damit flexibel und kosteneffizient bereitstellen, wie in dieser Artikelserie demonstriert.

Wie zuvor findet sich der Code der Beispielanwendung auch auf GitHub.

autor Michel Richter

Michel Richter

Senior eXpert