Serverless Blazor Applications: Echtzeit Push Updates

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.

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

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.

Serverless Blazor Applications: Serverloses Hosting auf Azure

Im Rahmen einer dreiteiligen Flurfunkserie zeige ich, wie mithilfe von ASP.NET Blazor und Microsoft Azure schnell und effizient serverlose Web-Anwendungen in .NET entwickelt und bereitgestellt werden können.

In diesem ersten Teil geht es ausschließlich um das serverlose Hosting. Das gezeigte Vorgehen lässt sich ohne Weiteres auch auf andere Single Page Applications (z.B. in Angular, React, …) übertragen. In den nachfolgenden Artikeln werde ich aber auch auf die konkrete Entwicklung mit Blazor eingehen und zudem zeigen, wie ein serverloses Backend inklusive Datenbank und Realtime-Updates auf Azure aussehen kann.

Zum Verständnis zuerst ein Überblick über die Begriffe Serverless und Blazor:

Was bedeutet Serverless?

Serverless bedeutet, eine Anwendung ohne den Einsatz beziehungsweise die Zuweisung fester Ressourcen zu betreiben, das heißt ohne einen dedizierten physischen oder virtuellen Server. Stattdessen werden Ressourcen für die Ausführung bei Bedarf automatisch – also ohne explizite Mitwirkung der Anwendung oder des Entwicklers – zugewiesen und anschließend wieder für andere Zwecke freigegeben. Ein solches Vorgehen findet insbesondere in Cloud-Umgebungen wie Microsoft Azure statt, wo die vorhandene physische Rechenleistung von vielen Kunden gleichzeitig in Anspruch genommen wird und eine bedarfsgesteuerte Umverteilung entsprechend zu einer effizienteren Auslastung führt.

Ein Beispiel für ein klassisches, server-basiertes Hosting wäre die Bereitstellung einer (Web-) Anwendung in einer Virtuellen Maschine oder auf einem Azure Web Service. Hier werden konkrete Ressourcen dauerhaft für den Betrieb der Anwendung reserviert, sodass auch dann Kosten entstehen, wenn gerade kein Zugriff erfolgt.

Mithilfe von serverlosen Architekturen wollen wir dieses Problem lösen und eine effizientere Verwendung von Ressourcen erreichen.

Was ist Blazor?

Dieser Artikel beschäftigt sich explizit nicht mit der Entwicklung von Blazor-Anwendungen, sondern mit dem Hosting auf Azure. Dennoch möchte ich kurz einen Überblick darüber geben, was sich hinter dem Namen genau verbirgt und wo der Unterschied zu bisherigen ASP.NET-Anwendungen liegt.

ASP.NET Blazor ist ein Single Page Application Framework, mit dem sich interaktive Web-Anwendungen in .NET entwickeln lassen. Als Single Page Applications bezeichnen wir Web-Anwendungen, die nur aus einer initialen HTML-Seite bestehen und sämtliche Änderungen mithilfe von lokalem Code durchführen. Es kommt also nie zu einem Neu-Laden der Seite wie bei statischen oder Server Page-Anwendungen. Dafür muss allerdings der gesamte Anwendungs-Code beim initialen Aufruf heruntergeladen werden.

Bisher war JavaScript die einzige Möglichkeit, solche Client-Logik browser-nativ zu implementieren (also ohne zusätzliche Plugins wie Silverlight, Java oder Flash). Mit ASP.NET Blazor wird erstmals auch eine Umsetzung in C# möglich – mit Zugriff auf das gesamte .NET-Ökosystem inklusive NuGet-Bibliotheken. Dies funktioniert auf zwei verschiedenen Wegen:

Blazor Server

Die erste Variante von ASP.NET Blazor ist serverbasiert und wurde mit dem Release von .NET Core 3.0 veröffentlicht. In diesem Fall läuft die eigentliche (.NET)-Anwendung wie eine klassische ASP.NET-Webseite auf dem Webserver (IIS, Azure Web Service, …). Interaktionen und Events auf Client-Seite werden über eine SignalR-Verbindung an den Server übermittelt und dort verarbeitet. Anschließend wird dem Client ebenfalls über SignalR mitgeteilt, welche Elemente der GUI sich entsprechend des Ereignisses verändert haben.

Der Browser führt in dieser Variante also weiterhin nur JavaScript-Code aus, der Entwickler braucht sich darum jedoch nicht zu kümmern, sondern kann seine Anwendung vollständig in C#/.Net implementieren.

Vorteilhaft ist dabei, dass sämtliche Anwendungs-Logik auf dem Server verbleibt und so die physische Trennung zwischen Client und Backend entfällt. Damit ist die Anwendung auch weitgehend unabhängig von der Leistungsfähigkeit oder Aktualität des Client-Browsers. Nachteilig ist die ständige Verbindung zwischen Server und Client, die zum einen Performance kostet und zum anderen keine Offline-Funktionalität in der App unterstützt.

Die permanente, aktive Verbindung zum Backend sowie die Ausführung der Programmlogik auf dem Server schließen außerdem ein serverloses Hosting in diesem Modell aus. Für die Bereitstellung von Blazor Server braucht es einen aktiven und permanent verfügbaren Server wie zum Beispiel einen Azure Web Service.

Blazor Client

Demgegenüber steht die Alternative Blazor Client, die im Mai 2020 für  .NET Core 3.1 veröffentlicht wurde und auf WebAssembly basiert. WebAssembly ist ein Web-Standard, der die Ausführung von kompiliertem C++-Code im Browser ermöglicht und nativ von allen modernen Browsern unterstützt wird. Ursprünglich angedacht war der Einsatz von WebAssemblies für komplexe und performance-kritische Berechnungen wie mathematische Funktionen oder Machine-Learning, die von den Vorteilen kompilierten Maschinen-Codes profitieren. Ein Verlassen der Browser-Sandbox wird allerdings auch in WebAssemblies verhindert.

Blazor Client nutzt die Funktionalität von WebAssembly, um die .NET-(Mono-)Runtime im Browser auszuführen und auf dieser dann den .NET-Code der eigentlichen Anwendung. Der dafür zu entwickelnde Code gleicht dem aus der Blazor Server-Variante, es ändert sich lediglich das Hosting-Modell.

Für den Anwender bedeutet diese Architektur, dass beim initialen Aufruf einer Blazor Client-Webseite zuerst sowohl die Mono-DLL als auch sämtliche Anwendungs-Bibliotheken heruntergeladen werden müssen. Dies kann insbesondere bei mobilen Verbindungen von Nachteil sein.

Zugleich ist die Anwendung dadurch nicht spezielle auf Software oder Browser-Plugins auf dem Client-Rechner angewiesen (wie zum Beispiel Silverlight oder Java). Da die App ihre Runtime selbst mitbringt, muss kein .NET installiert sein.

Zudem läuft eine Blazor Client-Anwendung wie eine in JavaScript geschriebene Single Page Application komplett im Browser des Anwenders. Das bedeutet zum einen, dass volle Offline-Fähigkeit unterstützt wird, und zum anderen, dass aufseiten des Web-Servers kaum Rechenleistung erforderlich ist. Es müssen nur die statischen Daten der Anwendung in Form von HTML-, CSS-, und DLL-Dateien ausgeliefert werden.

Den letzten Punkt wollen wir uns im Folgenden zu Nutze machen.

Serverless Blazor

1.      Anlegen einer Blazor Client Anwendung

Da ich in diesem Artikel nicht tiefer in die eigentliche Entwicklung mit Blazor einsteigen möchte, begnügen wir uns mit der Beispiel-Anwendung. Sie kann, wie hier beschrieben, in wenigen Schritten über die Kommando-Zeile erzeugt werden. Alternativ gibt es auch Templates für Visual Studio.

  1. Zuerst ist das SDK für .NET Core 3.1 erforderlich.
  2. Dann sollten die Blazor Templates installiert werden (Stand Mai 2020, am besten die aktuelle Version von der Webseite verwenden und auf das jeweils erforderliche .NET Core SDK achten):
dotnet new -i Microsoft.AspNetCore.Components.WebAssembly.Templates::3.2.0
  1. Anschließend kann die Beispielanwendung erzeugt werden:
dotnet new blazorwasm -o BlazorSample

und nach einem Wechsel in den Ordner BlazorSample mit

dotnet run

ausgeführt werden.

Im Browser kann die Anwendung aufgerufen werden (standardmäßig unter http://localhost:5000):

Durch das Klicken auf die Menu-Elemente können wir in der Anwendung navigieren, ohne dass es zum Neuladen kommt. Genauso können wir unter „Counter“ einen Zähler innerhalb der Anwendung hochzählen, ohne dass eine Kommunikation mit dem Server nötig wäre. Dies wirkt trivial, ist aber insofern bemerkenswert, dass eine Umsetzung dieser Logik auf dem Client bisher nur in JavaScript möglich war. Im klassischen ASP.NET hätten wir dagegen bei jedem Klick auf den Counter-Button die komplette Seite neu generieren und außerdem auf dem Server den aktuellen Zählerstand speichern müssen.

In den Developer-Tools des Browsers können wir sehen, welche Dateien beim ersten Aufruf der Seite heruntergeladen werden:

Neben den HTML- und CSS-Dateien sehen wir hier vor allem die .NET-Runtime in Form der dotnet.wasm sowie die DLL unserer Anwendung zusammen mit allen referenzierten DLLs (System.dll, …), ganz wie bei einer gewöhnlichen .NET Core-Anwendung. Nach dem initialen Download können wir in der Anwendung arbeiten, ohne dass es zu einer weiteren Verbindung mit dem Server kommt. (Mit Ausnahme des HTTP-Calls unter „Fetch Data“).

Unsere Anwendung besteht also ausschließlich aus statischen Dateien, die vom Browser des Users heruntergeladen und interpretiert bzw. ausgeführt werden.

2.      Bereitstellen der Anwendung

Um die Anwendung zu hosten, brauchen wir lediglich diese statischen Dateien an einem passenden Ort bereitzustellen. Der Azure Storage Account bietet dafür die nötige Funktionalität, inklusive HTTPS.

Die Konfiguration als statische Webseite lässt sich wie im obigen Bild über das Azure-Portal vornehmen. Im Rahmen dieses Artikels verwenden wir allerdings einen anderen Weg und lassen Visual Studio Code die Arbeit für uns machen.

Zuerst erzeugen wir einen Build unserer Anwendung mit dem Befehl

dotnet publish -c Release -o ./publish

Dann benötigen wir Visual Studio Code mitsamt der Azure Storage-Extension. Ein neuer Storage-Account kann entweder über das Portal angelegt werden oder im Folgenden direkt beim Publishing:

Die Azure Storage-Extension erlaubt es uns, einen beliebigen Ordner als statische Webseite auf einen Azure Storage zu deployen. In diesem Fall muss es der wwwroot-Ordner (unterhalb von publish) unserer gebauten Anwendung sein, wie im Bild zu sehen:

Hier kann entweder ein bestehender Storage Account ausgewählt oder ein neuer erzeugt werden. Im Fall eines bestehenden Accounts erfolgt nun die Frage, ob und wie der Storage für die Verwendung als statische Webseite konfiguriert werden soll. Hier sollte als Index-Dokument die index.html-Datei und als Error-Dokument nichts angegeben werden (darauf kommen wir später noch zurück).

Nach erfolgreichem Deployment können wir zum Web-Endpoint unseres Blob-Storages navigieren und sehen unsere Beispiel-Anwendung, nun für jeden erreichbar, der die Adresse kennt:

Wie zuvor auf dem lokalen Rechner können wir hier auf die Navigations-Elemente klicken und den Counter-Button betätigen. Die Anwendung läuft in unserem Browser und funktioniert selbst dann weiter, wenn wir die Internet-Verbindung trennen oder den Storage Account löschen.

Es gibt allerdings eine Sache, die nicht funktioniert:

3.      Routing

Wenn wir von der Hauptseite der Anwendung auf „Counter“ klicken, verhält sich die Anwendung wie erwartet. Die entsprechende Seite wird angezeigt und es ändert sich die URL in https://[storage_account]/counter

Geben wir diese Adresse allerdings manuell ein, erhalten wir einen Fehler (vorausgesetzt, die Konfiguration ist wie oben beschrieben erfolgt):

Dies liegt daran, dass der Storage Account sich weiterhin wie ein File-System verhält. In diesem Fall wird also versucht, eine Datei oder ein Verzeichnis mit dem Namen counter unterhalb des root-Verzeichnisses ($web) zu finden. Ein solches Verzeichnis gibt es aber nicht. Das Routing auf unsere eigentliche Counter-Seite findet ausschließlich innerhalb der Logik unserer Anwendung statt. Dazu müsste der Blob Storage aber zuerst die Anwendung selbst ausliefern.

Es gibt zwei unterschiedliche Möglichkeiten, das Problem zu beheben:

Verwenden einer Error-Page

In der Konfiguration unseres Storage Accounts als statische Webseite haben wir das Feld für Error-Page leer gelassen. Die erste und unsaubere Option, das Routing-Problem zu lösen, besteht darin, im Fall eines Fehlers ebenfalls auf die index.html zu verweisen. Dies kann über das Portal auch nachträglich konfiguriert werden.

Tun wir das, verweist die URL immerhin auf die richtige Unter-Seite unserer Anwendung. Allerdings erhalten wir bei einem solchen Aufruf trotzdem einen 404-Fehler in der Konsole.

Für wen das kein Hindernis darstellt oder wer ohnehin keine direkte URL-Navigation innerhalb seiner Applikation benötigt, der hat an dieser Stelle eine vollständig funktionsfähige Single Page Application in .NET Blazor auf Azure gehostet.

Für alle, die mehr Wert auf das korrekte Verhalten ihrer Anwendung legen, gibt es eine aufwendigere aber letztendlich vorteilhafte Vorgehensweise:

Azure CDN

Ein CDN (Content Delivery Network) dient dazu, Inhalte von Webseiten zu cachen und in physischer Nähe zum Aufrufer bereitzuhalten. Dies gewährleistet sowohl eine geringere Verzögerung für den Nutzer als auch eine geringere Last auf dem eigentlichen Anwendungsserver. Natürlich ist dies nur für statischen oder deterministischen Content sinnvoll.

Das Azure CDN bietet eine solche Funktionalität. Darüber hinaus lässt sich – je nach ausgewähltem Plan – mithilfe von Regeln Einfluss auf das Ergebnis von Aufrufen nehmen. Für unseren Fall interessant ist die Möglichkeit von URL Rewrites.

Dazu erstellen wir zuerst ein Azure CDN Profile. An dieser Stelle muss darauf geachtet werden, dass als Pricing Tier „Premium Verizon“ ausgewählt wird. Dies ist die einzige Option mit der Möglichkeit für URL Rewrites.

Im gleichen Schritt oder anschließend kann ein Endpoint definiert werden. Dieser muss vom Typ „Custom origin“ sein (nicht „Storage“, auch wenn das naheliegender wäre) und auf die URL unserer Anwendung auf dem Blob Storage verweisen (ohne Protokoll davor).

Anschließend kann die Webseite über den neuen Endpunkt ([endpointname].azureedge.net) aufgerufen werden. Es kann ein paar Minuten dauern, bis tatsächlich der erwartete Inhalt geliefert wird. Bisher leitet das CDN unsere Aufrufe allerdings nur 1:1 weiter. Das manuelle Anfügen von /counter am Ende der URL führt also weiterhin zu einem Fehler.

Um den URL-Rewrite zu definieren, müssen wir in das Verizon-Portal. Das ist über den Button „Manage“ im CDN Profil (nicht im Endpunkt) erreichbar. Hier im Menu „HTTP Large“ auf „Rules Engine“ klicken. Dann lässt sich eine neue Regel definieren. Wir müssen ein neues Feature mit dem Klick auf das entsprechende „+“ hinzufügen und „URL Rewrite“ auswählen. Danach können wir bestimmen, welche Arten von URLs wir wohin umleiten wollen. In unserem Fall wollen wir alle Anfragen, die nicht auf Dateien abzielen (z.B. muss „/_framework/wasm/dotnet.wasm“ weiterhin die korrekte Datei liefern), auf die index.html umleiten, damit die Blazor-Anwendung dann das eigentliche Routing übernehmen kann.

Dazu können wir die einfache Regel [^.]*$ in das Source-Feld eintragen und „index.html“ in das Destination-Feld.

Hinweis: Diese Regel leitet alles auf die index.html-Datei um, was keinen Punkt enthält. Für die meisten Blazor-Anwendungen sollte das reichen. Falls jedoch geplant ist, Query-Parameter mit Punkt zu verwenden (z.B. ?date=01.01.2020), dann muss die Regel erweitert werden. In dem Fall funktioniert [^?.]*(\?.*)?$ als Source.

Credit an dieser Stelle an folgenden Artikel, aus dem diese Regel stammt und der mir auf dem Weg sehr geholfen hat:  https://medium.com/@antbutcher89/hosting-a-react-js-app-on-azure-blob-storage-azure-cdn-for-ssl-and-routing-8fdf4a48feeb

Die Anwendung der Regel kann bis zu vier Stunden dauern. Danach sollte das Routing über den Endpoint wie gewünscht funktionieren:

4 Caching fixen

Nun funktioniert also alles. Oder?

Tatsächlich gibt es noch ein Problem. Dieses manifestiert sich allerdings erst dann, wenn wir versuchen, unsere Anwendung zu updaten. Denn wie beschrieben cached das CDN die gelieferten Daten für den Aufrufer, sodass zukünftige Aufrufe nicht mehr auf den eigentlichen Storage zugreifen müssen. Leider cached es sie so gut, dass der Aufrufer auch nichts davon mitbekommt, wenn sich der Inhalt dieser Dateien (also der Code unserer Anwendung) ändert.

Dies scheint insbesondere ein Problem mit Nicht-Web-Dateien zu sein, also konkret den DLL-Files unserer Blazor-Anwendung. Während Änderungen an der index.html nach ein paar Stunden vom CDN erkannt und übernommen werden, wurden geänderte DLL-Files z.T. auch nach mehreren Tagen nicht neu ausgeliefert. Auch der „Purge“-Button im Azure Portal, der eigentlich den Cache des CDNs leeren sollte, zeigte keine Wirkung. Möglicherweise funktioniert er nicht für Verizon-CDNs.

Es gibt jedoch eine Möglichkeit, das Caching-Verhalten des CDNs über den CacheControl-Header der Dateien zu steuern. Dies kann beispielsweise über eine Azure Function mit Blob-Trigger erfolgen:

Wobei „cacheHeader“ ein string der Form „public, max-age=3600“ ist und die Anzahl an Sekunden angibt, die die Datei im Cache verbleiben darf. Zu beachten ist dabei natürlich, dass eine geringere Zahl die Umsetzung von Änderungen beschleunigt, aber auch den Performance-Vorteil des CDNs reduziert.

Wenn die Anwendung über eine CI-Umgebung (z.B. Azure DevOps) deployed wird, bietet es sich an, stattdessen ein PowerShell-Skript zu verwenden, um den Header direkt beim Kopieren der Dateien zu setzen.

Zusammenfassung

Wie gezeigt, bietet Microsoft Azure als Cloudplattform einige interessante Möglichkeiten, um Single Page Applications – wie ASP.NET Blazor-Anwendungen – schnell, einfach und kostengünstig zu hosten. Zwar sind dabei einige Hindernisse zu überwinden – oder Einschränkungen hinzunehmen –, die werden aber durch die sehr geringen Kosten im Vergleich zu einem klassischen Server-Hosting wieder wettgemacht.

Insgesamt liegen die minimalen Kosten für eine komplette Anwendung wie hier beschrieben bei ca. 20ct im Monat – und dürften selbst bei wachsenden Zugriffszahlen vergleichsweise langsam steigen. Weiterhin kommt dazu, dass Azure in diesem Szenario die erforderlichen Ressourcen bei steigender Last automatisch bereitstellt. Anders als bei einer virtuellen Maschine oder einem Service-Plan sind hier keine manuellen Anpassungen an der Leistungsfähigkeit nötig.

Ausblick

Die Anwendung in unserem Beispiel ist relativ simpel. Es ist jedoch nicht schwer, sie um weitere Funktionalität zu erweitern. In zwei folgenden Artikeln werde ich beschreiben, wie sich mithilfe von Azure Functions und dem Azure SignalR-Service eine serverlose Chat-Anwendung in Blazor entwickeln lässt.

Data Analytics automatisieren mit der Azure Data Factory

In meinem letzten Flurfunk-Artikel “Aus der Praxis: Beschleunigte Analyse von Log-Dateien mit Azure Data Lake Analytics” habe ich beschrieben, wie mithilfe von Azure Functions und Azure Data Lake Analytics Logdateien aus komprimierten Archiven extrahiert und parallel analysiert werden können.

Ursprünglich wurde die beschriebene Verarbeitungsstrecke für den manuellen Einsatz mit flexiblen Parametern entwickelt. So kann der Anwender entscheiden, welche Dateien aus welchen Archiven gebraucht werden, und sein U-SQL-Skript entsprechend der jeweiligen Datenstruktur und der zu beantwortenden Fragestellung anpassen.

Für ganz konkrete, wiederkehrende Fragestellungen lässt sich dieser Ablauf jedoch auch leicht automatisieren. Das bevorzugte Werkzeug auf Microsoft Azure ist dabei die Azure Data Factory. In diesem Artikel möchte ich das Beispiel der Log-Analyse anhand eines umgesetzten Prototypen auf einen bestimmten Anwendungsfall konkretisieren und die Möglichkeiten und Einschränkungen bei der Automatisierung mithilfe der Azure Data Factory aufzeigen.

Die Azure Data Factory

Vor der Beschreibung des konkreten Beispiels soll hier kurz ein Überblick über die Azure Data Factory gegeben werden. Die Data Factory ist ein Orchestrierungsservice auf Microsoft Azure, mit dessen Hilfe Daten zwischen Systemen verschoben, Aktionen ausgelöst und andere Services gesteuert werden können. Die Verarbeitungsstrecke wird dabei über drei verschiedene Arten von Komponenten definiert:

Linked Services

Ein Linked Service kann grundsätzlich alles sein, das über eine Schnittstelle ansprechbar ist. Dazu gehören die meisten Azure Services – von Storage Accounts über Azure Function Apps bis hin zu Data Lake Analytics und Machine Learning Studio – aber auch externe Datenquellen wie AWS-Speicher, On-Premises Datenbanken und jeder beliebige Webservice mit einem http-Endpunkt. Über die Definition eines Linked Services werden diese Endpunkte der Data Factory bekannt gemacht. In der Regel müssen dabei entsprechende Credentials angegeben werden, die es der Factory erlauben, sich gegenüber dem jeweiligen Dienst zu authentifizieren. Dazu mehr im Abschnitt Authentifizierung.

Datasets

Datasets kommen immer dann zum Einsatz, wenn die Data Factory selbst auf Daten zugreifen und diese verarbeiten (auslesen oder verschieben) soll. Ein Dataset stellt dabei eine konkrete Datenstruktur (Datei, Tabelle, WebResponse) innerhalb eines Connected Service dar und kann sowohl als Input als auch als Output dienen. Je nach Einsatzzweck müssen die Daten mit einem konkreten Schema beschrieben werden (z.B. zum Kopieren in eine SQL-Tabelle) oder können als Binary Data behandelt werden (z.B. zum Kopieren von Bilddateien). Die Data Factory unterstützt auch die automatische Kompression bzw. Dekompression bestimmter Formate (zip, gzip, bzip2). Die Verwendung von Parametern erlaubt es, Datasets flexibel zu definieren und an verschiedenen Stellen wiederzuverwenden.

Pipelines

Eine Pipeline beschreibt den konkreten Ablauf einer automatischen Verarbeitung. Hier werden sogenannte Activities definiert, in denen Connected Services und Datasets verwendet werden, um Aktionen durchzuführen oder Daten zu verschieben. Sie weisen dadurch eine große Ähnlichkeit mit SSIS-Paketen auf. Wie Datasets können auch Pipelines parametrisiert und entsprechend bei der Ausführung gesteuert werden.

Definition der Komponenten

Alle drei Arten von Komponenten der Data Factory können entweder über die grafische Benutzeroberfläche des Azure Portals oder mithilfe von ARM-Templates definiert werden. Beide Varianten lassen sich jederzeit ineinander überführen. So bietet jeder Teilbereich in der Oberfläche der Data Factory einen „Code“-Button, mit dem sich die JSON-ARM-Definition des gerade bearbeiteten Elements anzeigen und bearbeiten lässt.

Ein Klick auf die Schaltfläche (blau umrandet) ruft in Fall dieses Datasets die folgende Definition auf:

Azure Data Factory Code

An diesem Beispiel lässt sich unter anderem die Definition und Verwendung von Parametern erkennen. Sowohl der Ordner als auch der Dateiname, auf die das Dataset verweist, können von außen vorgegeben werden. Damit lässt sich dieses Dataset für sämtliche zip-komprimierten Dateien innerhalb des verlinkten Blob-Storages verwenden. Der Storage Account selbst wird nicht hier im Dataset, sondern im Connected Service (an dieser Stelle „AzureBlob“ genannt) definiert, sodass die Verbindungsinformationen nur einmal hinterlegt werden müssen.

Trigger

Eine vierte zentrale Komponente innerhalb der Azure Data Factory sind die Trigger, mit denen sich die Ausführung einer Pipeline einplanen bzw. anstoßen lässt. Zwar kann eine Pipeline grundsätzlich auch manuell gestartet werden, das ist jedoch eher für Entwicklungs- und Testzwecke relevant.

Im Wesentlichen stehen zwei Arten von Triggern zur Verfügung: Zeitgesteuert und Eventgesteuert.

Zeitgesteuerte Trigger können benutzt werden, um Pipelines in regelmäßigen Abständen (Minuten bis Monate) auszuführen. Interessant ist dabei, dass das Startdatum auch in der Vergangenheit liegen darf. Die Data Factory wird dann bei Aktivierung des Triggers sämtliche verpassten Zeitfenster nachholen. Das jeweilige Datum der Ausführung kann deshalb als Parameter innerhalb der Pipeline genutzt werden, um bspw. die passenden Daten für den Zeitraum auszuwählen.

Eventgesteuerte Trigger können aktuell nur auf das Anlegen oder Löschen einer Datei im Azure Blob Storage reagieren. Möchte man eine Pipeline über andere Events (http, Queue-Storage, Eventhub, …) auslösen, führt der beste Weg deshalb über eine Azure Function mit entsprechendem Trigger, die lediglich einen Blob auf den Storage legt, auf den wiederum die Data Factory reagiert. Da die Data Factory grundsätzlich für große, langlaufende Verarbeitungen gedacht ist, wäre bei solchen „Notlösungen“ allerdings zu hinterfragen, ob eine eventgesteuerte Ausführung wirklich der optimale Weg ist.

Authentifizierung

Ein durchaus relevanter Faktor beim Einsatz der Azure Data Factory ist die Authentifizierung gegenüber den angebundenen Datenquellen. Anders als beispielsweise für Azure SQL Server gibt es im Fall der Data Factory keine Option, die den automatischen Zugriff auf andere Azure Services in derselben Subscription zulässt. Auch ein Übertragen der Rechte des erstellenden Nutzers auf die Factory oder eine Pipeline ist nicht möglich. Stattdessen wird für jede Data Factory im Azure Active Directory ein eigener Principal angelegt, unter dem sämtliche Pipelines ausgeführt werden. Zur Authentifizierung beispielsweise auf den Data Lake Storage kann diesem Principal das Zugriffsrecht auf den benötigten Ordnern verliehen werden. Beim Anlegen des Connected Services für den ADLS muss dann nur noch die Option „Managed Identity“ ausgewählt werden.

Grundsätzlich ist die Art der möglichen Authentifizierung je nach Connected Service jedoch stark unterschiedlich. Azure Storage unterstützt neben dem Zugriff über die Managed Identity auch die Angabe des Account Keys oder einer Shared Access Signature (SAS).

Eine für die meisten Azure Services geeignete und daher empfohlene Vorgehensweise ist die Verwendung eines Service Principals, also das manuelle Anlegen einer Identität mit entsprechenden Rechten im Azure Active Directory (Anleitung) und einem oder mehreren Secrets. Diese können beim Erstellen des Connected Services entweder direkt oder als Eintrag in der Azure Key Vault angegeben werden. Für den Zugriff von der Data Factory auf Azure Data Lake Analytics und für die Ausführung von U-SQL-Skripten ist der Service Principal sogar die einzige unterstützte Option.

Für den Zugriff auf Endpunkte außerhalb von Azure (AWS, http, Office365, …) ist eine individuelle Betrachtung der erforderlichen bzw. möglichen Authentifizierungs-Optionen notwendig.

Das Beispiel – Hintergrund und Zielstellung

Für diejenigen, die den eingangs verlinkten Artikel nicht gelesen haben, sei an dieser Stelle der Hintergrund kurz zusammengefasst: Die Mitarbeiter eines Kundenunternehmens verwenden Geräte mit angepasster Software. Auf jedem dieser Geräte werden die Logdaten des Systems und der einzelnen Applikationen gesammelt und in komprimierter Form auf einem Azure Blob Storage abgelegt. Aufgrund der Vielzahl von Geräten (7000+), des Datenumfangs (ca. 2,5 GB pro Gerät) und der Heterogenität in Art und Struktur der einzelnen Logs ist eine manuelle Analyse auf einem lokalen Entwicklergerät schwierig und aufwendig. Deshalb wurde wie eingangs beschrieben ein Prozess in der Cloud entwickelt, mit dem einzelne Logdateien je nach Bedarf extrahiert und analysiert werden können.

Für die Automatisierung braucht es dagegen eine ganz konkrete und immer wiederkehrende Fragestellung. Im Beispiel dieses Prototypen sollen aus allen neu hochgeladenen Logdaten automatisch die Fehlermeldungen aus der Datei ErrorLogging.log ausgelesen und mit denen anderer Geräte verglichen werden, um Anomalien, also ungewöhnliche Häufungen von bestimmten Fehlern, zu erkennen und darauf zu reagieren.

Die eigentliche Erkennung der Abweichungen soll nicht Thema dieses Artikels sein. Sie wurde für den Prototypen mithilfe von Clustering-Algorithmen umgesetzt. Eine ausführliche Beschreibung würde an dieser Stelle jedoch zu weit führen und den Umfang des Beitrags sprengen.

Umsetzung

Die für den Prototypen aufgesetzte Pipeline ist im folgenden Bild dargestellt. Die Pipeline wird eventgesteuert getriggert und reagiert auf den Upload eines Log-Archivs.

ADF Umsetzung

Die Verarbeitung lässt sich dabei in folgende wesentliche Punkte zusammenfassen:

Extraktion: Zuerst muss aus dem hochgeladenen Archiv die Datei ErrorLogging.log extrahiert werden. Dazu wird die bereits vorhandene Azure Function aus dem eingangs verlinkten Artikel verwendet. Sie bekommt das aktuelle Archiv und den Namen der gesuchten Datei als Parameter beim http-Request übergeben und legt die Datei auf dem Azure Data Lage Storage ab.

Fehler-Aufbereitung: Sobald die Log-Datei in extrahierter Form im Data Lake vorliegt, müssen die darin enthaltenen Fehlermeldungen verarbeitet und in eine einheitliche Form gebracht werden. Konkret werden sämtliche Parameter entfernt und die unhandlichen Text-Nachrichten durch Zahlen ersetzt. Diese werden anschließend aggregiert, um die Häufigkeit der einzelnen Fehler für das aktuelle Gerät mit denen der anderen Geräte vergleichen zu können. Dies geschieht in insgesamt drei U-SQL-Skripten.

Reaktion: Für den Fall, dass auffällige Abweichungen in den Fehlermeldungen gefunden wurden, wird eine automatische E-Mail-Nachricht über SendGrid on Azure versendet. Der Code dafür steckt in einer zweiten Azure Function. Ein Beispiel für eine solche E-Mail ist im folgenden Bild zu sehen.

Alle weiteren, bisher nicht erwähnten Activities lassen sich unter dem Schlagwort „Datentransfer“ zusammenfassen und sind überwiegend Aktionen, die die Data Factory selbst übernimmt (Copy Data, Delete). Sie sind allerdings gut geeignet, um ein Gefühl für die Möglichkeiten und die Einschränkungen bei der Arbeit mit der Data Factory und insbesondere Data Lake Analytics zu gewinnen:

Wie beschrieben werden die Texte aus den Fehlermeldungen während der Analyse auf IDs gemappt. Das gleiche gilt für den Namen des Gerätes. Da allerdings mehrere dieser Pipelines gleichzeitig ausgeführt werden können, braucht es eine zentrale Instanz, die die Vergabe dieser IDs regelt, um die Eindeutigkeit zu gewährleisten. Dazu bietet sich eine relationale Datenbank an. Im Rahmen des Prototypen wurden zwei Tabellen mit Identity-Spalte in einer Azure SQL DB verwendet.

Azure Data Lake Analytics bzw. U-SQL unterstützt auch die Einbindung von SQL-Server-Datenbanken als external Data Source. Allerdings kann darüber nur lesend auf die Daten zugegriffen werden. Das Einfügen in Datenbank-Tabellen muss außerhalb von U-SQL erfolgen. Deshalb gibt es in der Pipeline die beiden Aktivitäten InsertNewMessages, die alle bisher unbekannten Fehlermeldungen aus dem Ergebnis der ersten U-SQL-Ausführung in die Datenbank überträgt, und InsertDeviceID, die die ID des aktuellen Geräts über eine Stored Procedure anlegt, falls sie noch nicht vorhanden ist. Danach kann davon ausgegangen werden, dass sowohl alle Message-Texte als auch alle bekannten Geräte eindeutige numerische IDs besitzen.

Ein zweites Hindernis beim Verknüpfen von U-SQL-Aktivitäten ist die Übertragung von Daten aus einem Skript zum nächsten. Der Azure Data Lake bietet neben dem reinen Dateispeicher auch interne Datenbanken an. In diesen können aus U-SQL heraus relationale Tabellen angelegt, befüllt und abgefragt werden. Diese Tabellen haben allerdings einen eingeschränkten Funktionsumfang. Zum Beispiel gibt es keine Identity-Spalten und es werden lediglich Insert und Select unterstützt. Ein Update oder Delete ist nur über das Löschen ganzer Partitionen möglich. Deshalb ist es oft einfacher, die Ergebnisse eines Skripts für die Weiterverarbeitung stattdessen in Dateiform auf dem Data Lake Storage abzulegen. Die Delete-Aktivitäten in der Pipeline sorgen dafür, dass diese temporären Dateien wieder entfernt werden, sobald sie für die Verarbeitung nicht mehr benötigt werden.

Orchestrierung der Pipeline

Die oben beschriebene Pipeline zum Entpacken und Analysieren des ErrorLogs arbeitet auf den Daten eines einzelnen Gerätes und reagiert auf den Upload neuer Daten in den Blob-Storage. Um diese Verarbeitung auf bereits vorhandene Daten anzuwenden bzw. allgemein, um Pipelines auf größeren Batches auszuführen, bietet die Azure Data Factory die Option, Pipelines als Aktivität innerhalb anderer Pipelines auszuführen.

Für den konkreten Prototypen wurde eine zweite Pipeline definiert, die diese Aufgabe für eine Menge von Geräten übernimmt. Diese ist in der folgenden Abbildung dargestellt, wobei die eigentliche Ausführung der Sub-Pipeline innerhalb der ForEach-Aktivität stattfindet.

Leider scheint es in der Data Factory keine direkte Option zu geben, um direkt über eine Menge von Dateien im Blob Storage zu iterieren. Stattdessen wurde hier erneut auf eine Azure Function zurückgegriffen, um die Namen aller hochgeladenen Archive aufzusammeln. Man hätte diese Liste dann entweder direkt von der Function zurückgeben lassen können oder, wie hier geschehen, in eine neue Datei schreiben lassen. Die Lookup-Aktivität lädt die Namensliste aus dieser Datei und übergibt sie an die ForEach-Aktivität, die wiederum für jeden Eintrag in der Liste die Analyse-Pipeline aufruft. Am Ende wird die erzeugte Datei wieder entfernt.

Mithilfe dieser äußeren Pipeline war es möglich, die Verarbeitung ohne Anpassungen über eine größere Menge von Dateien laufen zu lassen. Das Vorgehen hat sich jedoch als ineffizient erwiesen. Zum einen kann durch die Einzelverarbeitung die hohe Parallelität von Data Lake Analytics nicht genutzt werden. Zum anderen entstehen auf diese Weise vergleichsweise hohe Kosten aufseiten der Data Factory. Für gerade einmal 800 verarbeitete Geräte lag der Preis allein für die Factory bei ca. 18 Euro – gegenüber 5 Euro für Data Lake Analytics, wo die eigentliche Analyse stattfand. Von diesen 18 Euro entfallen mehr als 11 auf den Block „data movement“ – obgleich jede der bewegten Dateien nur wenige hundert bis tausend Byte groß gewesen sein dürfte. Die übrigen 7 Euro stehen für die „orchestration“, werden also durch den massenhaften Aufruf der Unter-Pipelines verursacht.

Ein effizienteres und wohl auch billigeres Vorgehen wäre es also, nur die Azure Funktion zum Entpacken der ErrorLogs aus den Archiven in einer ForEach-Aktivität aufzurufen und die nachfolgenden U-SQL-Skripte so anzupassen, dass sie direkt auf der Gesamtmenge der Daten arbeiten. Auf diese Weise könnte die Stärke von Data Lake Analytics zum Tragen kommen und der Aufruf hunderter Sub-Pipelines entfallen.

Fazit

Mithilfe der Azure Data Factory können einzelne Komponenten innerhalb und außerhalb von Azure bequem und schnell zu zusammenhängenden Pipelines verbunden und orchestriert werden. Während gewisse funktionale Einschränkungen und Authentifizierungs-Hürden den Einstieg erschweren, ermöglicht die Parametrisierung sämtlicher Komponenten innerhalb der Pipeline einen guten Grad an Flexibilität und Wiederverwertbarkeit. Fehlende Funktionalität kann zudem recht einfach durch eigenen Code in Form von Azure Functions oder Custom-Activities ergänzt werden.

Für einen Prototypen zur allgemeinen Demonstration und Analyse der verschiedenen Optionen und Möglichkeiten innerhalb der Azure Data Factory hat sich die umgesetzte Lösung als vielversprechend erwiesen. Sie zeigt eindrucksvoll das Potential wie auch die Hindernisse dieser Art der Automatisierung auf. Sie verdeutlicht aber auch, dass beim Aufbau der Pipeline, wie bei vielen Azure Services, sorgfältige Planung und Optimierung entscheidend sind, um tatsächlich von den Vorteilen der Cloud hinsichtlich Geschwindigkeit, Kosten und Skalierung zu profitieren.

Aus der Praxis: Beschleunigte Analyse von Log-Dateien mit Azure Data Lake Analytics

Vernetzung und Community sind zentrale Elemente der SDX. Ein gutes Beispiel dafür ist die Anfrage eines Kollegen im Kundenprojekt an das gerade neu aufgestellte Data Analytics Team zur Unterstützung bei einem konkreten „Big Data-Problem“.
Dieses „Big-Data-Problem“ und dessen Lösung sollen im Verlauf dieses Artikels beschrieben werden.

Der Hintergrund – Log File Analyse

Im Rahmen des genannten Projekts unterstützen wir unseren Kunden bei der Entwicklung von Software für portable Mitarbeiter-Geräte im produktiven Einsatz. Da die Kunden-Mitarbeiter oft in zeitkritischen Situationen arbeiten, muss die entwickelte Software zum Teil tief in das zugrundeliegende System der Geräte eingreifen, um z.B. die automatischen Windows-Updates durch einen manuellen Patch-Vorgang zu ersetzen. Entsprechend gründlich muss das Verhalten der Anwendung und des Gerätes protokolliert werden, um auftretende Fehler möglichst schnell aufspüren und beheben zu können.

Die dabei entstehende Datenmenge beträgt zwischen 2 und 3 GB pro Device – verteilt über mehr als 1.000 unterschiedliche Einzeldateien wie die Application Logs der einzelnen Anwendungen, diverse Konfigurationsdateien und die von Windows selbst bereitgestellten Eventlogs. Bei etwa 5.000 eingesetzten Geräten entspricht das einer Gesamtdatenmenge von mehreren Terabyte.

Diese Logdateien werden zur einfacheren Übertragung und Speicherung noch auf dem Mitarbeiter-Gerät in zip-Archive komprimiert. Als zentrale Ablage kommt ein NAS-Server im internen Netzwerk des Kunden zum Einsatz. Die Netzwerkanbindung dieses Fileshares verfügt nur über eine begrenzte Bandbreite, sodass der Download der Logdaten auf einen lokalen Rechner zu Analysezwecken erhebliche Zeit in Anspruch nimmt.

Während Auswertungen und Fehleranalysen für einzelne Geräte noch lokal auf dem Rechner des Entwicklers möglich sind und von passenden Tools unterstützt werden, erweisen sich geräteübergreifende Analysen aufgrund der gewaltigen Datenmenge und der technischen Rahmenbedingungen als mühsam und zeitaufwendig. Konkret wurde eine durchschnittliche Vorbereitungszeit von 18 Stunden pro Analyse genannt.

Als weitere Herausforderung stellt sich die Form der Daten. Die meisten der generierten Logfiles bestehen aus Einträgen in der folgenden Form:

<![LOG[Current display settings is Width: [1024], Height: [768], Orientation: [Landscape]]LOG]!><time="09:03:09.818+000" date="01-07-2019" component=" SystemManager.Display.DisplayController" context="" type="DEBUG" thread="18" file="SystemManager.Display.DisplayController.GetCurrentSetting">

Die Einträge sind eine Kombination aus Freitext-Meldungen mit Parametern sowie Key-Value-Paaren für Meta-Informationen. Für die Analyse müssen oft erst die relevanten Informationen aus den Meldungen extrahiert werden. Als besonderes Hindernis kommt dazu, dass, je nach gewünschter Information, nicht immer alle Daten in einer Zeile stehen, sondern z.T. aus mehreren Zeilen – oder gar aus mehreren Dateien – kombiniert werden müssen (bspw. Beginn und Ende einer Operation). Dies macht den Einsatz eines zustandsbasierten Programms anstelle eines rein zeilenbasierten Skripts zur Auswertung erforderlich.

Die Lösung – Big Data mit Microsoft Azure

Um die Herausforderung der großen Datenmengen und Netzwerkbeschränkungen zu bewältigen, ist die Verschiebung der Analysen in die Cloud eine naheliegende Lösung. Im Rahmen eines Prototypen haben wir verschiedene Ansätze der Speicherung und Extraktion ausprobiert, um die effizienteste und vor allem kostengünstigste Option zu finden.

Die finale Lösung setzt sich aus vier wesentlichen Komponenten zusammen:

Die einzelnen Komponenten und die Gründe für die Wahl gerader dieser Services werden in den folgenden Absätzen dargestellt.

Speicherung

Für die Ablage der komprimierten Logdaten in der Cloud fiel die Wahl letztendlich auf einen Azure Blob Storage. Wir haben uns dagegen entschieden, die Archiv-Dateien direkt im Data Lake Storage abzulegen, da letzterer sowohl beim Speicherverbrauch als auch bei Zugriffen merklich teurer ist. Weder der unbegrenzte Speicherplatz noch die größere maximale Bandbreite, mit denen diese Mehrkosten begründet werden, sind für unseren Anwendungsfall relevant. Der Upload der Dateien in die Cloud wird ohnehin durch die Internet-Anbindung des jeweiligen Gerätes begrenzt und das Extrahieren durch die Verarbeitungskapazität der Function App.

Um die Kosten gering zu halten, haben wir uns des Weiteren dagegen entschieden, die Logdaten direkt in extrahierter Form abzulegen. Aufgrund der wesentlich größeren Datenmenge (Faktor 10) gegenüber den komprimierten Dateien und der Vielzahl an Einzeldateien wäre eine solche Vorgehensweise nur dann sinnvoll gewesen, wenn für die Analyse regelmäßig sämtliche oder wenigstens ein Großteil aller Logfiles erforderlich gewesen wären. In der Regel zur Beantwortung einer konkreten Fragestellung aber nur ein bis zwei Dateien pro Archiv benötigt. Deshalb ist es günstiger, nur die jeweils konkret relevanten Dateien bei Bedarf zu extrahieren und zur Weiterverarbeitung auf den Data Lake Storage zu legen.

Extraktion

Für diesen Zweck, das Entpacken einzelner Dateien aus den Zip-Archiven, bietet sich eine Azure Function App an. Ähnlich wie ein Web Job dient eine Azure Function dazu, ein bestimmtes Programm (z.B. in C#, JavaScript, Python o.ä.) direkt in der Cloud auszuführen. Die Ausführung kann dabei zeitgesteuert erfolgen, über einen http-Request angestoßen oder von anderen Ereignis-Quellen (EventHub, ServiceBus etc.) ausgelöst werden. Auch die automatische Reaktion auf das Hochladen einer Datei in einen Blobstorage kann als Trigger verwendet werden. Diesen benutzen wir in einer zweiten Function, um beim Upload ältere Log-Archive desselben Gerätes zu suchen und zu entfernen. Die Azure Function zum Extrahieren der Logdaten reagiert dagegen auf http-Requests, die über ein PowerShell-Skript oder eine beliebige andere Client-Anwendung ausgelöst werden können.

Zur Abrechnung von Azure Functions kann zwischen einem Consumption-Plan und App-Service-Plan gewählt werden. Beim Consumption-Plan wird nach Ausführungen und Rechenleistung bezahlt, beim App-Service-Plan nach gewählter Leistung mit konstantem Preis. Gerade für sporadisch genutzte Functions wie in diesem Fall bietet sich der Consumption-Plan an, da er die größte Flexibilität bietet. (Mehr dazu hat unser Chief eXpert Alexander Jung bereits in einem verwandten Beitrag geschrieben).

Bei der Verwendung des Consumption-Plans sind allerdings die richtige Verteilung der Last sowie die daraus resultierende Laufzeit der Function App von großer Bedeutung. So ist es zwar technisch valide, eine Function zu bauen, die über sämtliche Archive iteriert und aus jedem die gewünschten Dateien extrahiert. Dies ist jedoch nicht zielführend, da Functions im Consumption-Plan nicht auf Mehrkern-Systemen ausgeführt werden und außerdem über ein Laufzeitlimit verfügen. Dies ließe sich zwar über einen anderen Abrechnungsplan ändern, wäre aber wiederum mit tendenziell höheren Kosten verbunden.

Effizienter ist es, für jede Archivdatei einen eigenen Aufruf zu starten. Dadurch wird die Aufgabe der Parallelisierung an das Management der Cloud übergeben. Konkret können von einer Function App im Consumption-Plan gleichzeitig bis zu 200 Instanzen gestartet werden, von denen jede bis zu 100 Requests bearbeiten kann. Das Hochfahren dieser Instanzen und das Verteilen der Requests erfolgt dabei automatisch und ohne Aufwand für den Anwender. (Die angegebenen Zahlen stellen die Standardwerte dar. Sie sind aber für den beschriebenen Anwendungsfall vollkommen ausreichend.)

Mithilfe der Azure Function können die gewünschten Dateien aus den komprimierten Archiven in etwa 30 Minuten extrahiert werden. Das mehrstündige Herunterladen entfällt.

Analyse

Für die eigentliche Auswertung haben wir den Azure Data Lake (ADL) gewählt. Dazu gehören die Komponenten Data Lake Storage (ADLS) als Speicher sowie Data Lake Analytics (ADLA) für die Ausführung von Abfragen gegen die Daten. Beim Anlegen von ADLA muss zwingend ein Data Lake Storage verknüpft werden. Da dieser für die Datenverarbeitung mit ADLA optimiert wurde, ist es sinnvoll, die zu analysierenden Daten auf diesem Speicher abzulegen, auch wenn Data Lake Analytics grundsätzlich auch Dateien vom Blob Storage als Input verwenden kann.

Azure Data Lake Analytics ist gegenüber einem Hadoop- bzw. HDInsight-basierten Ansatz nicht nur einfacher und schneller einzurichten, sondern kann auch wesentlich flexibler skaliert werden. So kann die benötigte parallele Rechenleistung einfach individuell pro Anfrage festgelegt werden. Zugleich entstehen keinerlei Kosten, solange keine Analysen ausgeführt werden – ohne den Service explizit herunterfahren oder löschen zu müssen, wie es bei einem HDInsight- oder VM-Cluster der Fall wäre.

Die Skript-Sprache, die Data Lake Analytics verwendet, wird U-SQL genannt. Sie besteht aus SQL-ähnlichen Queries, in denen Datentypen, Methoden und ganze Klassen aus .Net verwendet werden können. Das reicht von einfachen Methoden nativer Datentypen wie string.Replace() bis hin zur Einbindung eigener Assemblies.

Ein USQL-Skript besteht dabei typischerweise aus drei Arten von Statements:

Extract: Mit EXTRACT werden Daten aus einer Menge gleichartiger Dateien vom Data Lake Storage eingelesen. Dabei können vorhandene Extraktoren (z.B. für CSV-Dateien) oder eigene in C# entwickelte Klassen verwendet werden. Durch die eigenen Klassen können auch nicht-relational strukturierte Daten effizient geladen werden.

Select: SELECT-Statements dienen wie in SQL dazu, Daten zu transformieren, zu filtern oder zu aggregieren. Es gibt auch hier Klauseln wie WHERE, FROM und GROUP BY. Neben dem aus SQL bekannten SELECT gibt es noch weitere verwandte Statements zur Verarbeitung, Projektion und Reduktion von Datensätzen mithilfe von eigenen C#-Klassen.

Output: Analog zu Extract dienen Output-Statements dazu, die verarbeiteten Daten zurück auf den Data Lake Storage zu schreiben. Auch hier können entweder vorhandene Outputter (z.B. für CSV-Dateien) oder eigene Klassen verwendet werden.

Für das Einlesen der Logdaten im oben beschriebenen Format wurde eine eigene C#-Extraktor-Klasse geschrieben. Diese extrahiert pro Zeile den Message-Text sowie die relevanten Metadaten und gibt diese in relationaler Form zurück. Zum Umgang mit Informationen, die über mehrere Zeilen verteilt sind, haben wir zusätzlich eigene Reducer-Klassen implementiert. Diese arbeiten ähnlich wie Aggregations-Funktionen in SQL auf einer Menge gruppierter Zeilen, um daraus eine neue Ergebniszeile zu generieren. Allerdings verarbeiten sie ganze Zeilen anstelle von einzelnen Spalten bzw. Werten.

Die folgende Abbildung zeigt am Beispiel eines Reducers die Definition einer eigenen Klasse für USQL. In diesem Fall werden lediglich Name sowie Laufzeit von ausgeführten Skripten aus verschiedenen, aber zusammengehörenden Zeilen des Logs extrahiert.

Ein Script, in dem diese Klasse eingesetzt wird, sieht dann wie folgt aus:

An diesem Skript lassen sich bereits einige interessante Aspekte von U-SQL verdeutlichen:

Als erstes sollte die Ähnlichkeit zu SQL auffallen. Die Sprache wurde mit bewusster Nähe zu SQL entwickelt und ist für Datenbankentwickler entsprechend einfach und intuitiv zu erlernen. Die wesentlichen Unterschiede liegen in der Verwendung von C#-Datentypen und -Ausdrücken sowie dem Lesen und Schreiben aus bzw. in Dateien anstelle von Datenbank-Tabellen.

An der Definition des Input-Pfades lässt sich erkennen, dass ADLA von Haus aus mit flexiblen Dateipfaden umgehen kann. Jeder Ausdruck in geschweiften Klammern ist ein Platzhalter, der entweder beliebige oder definierte Werte (z.B. Jahreszahlen) annehmen kann. Bei der Ausführung werden entsprechend alle Dateien verwendet, die sich über diesen Pfad abbilden lassen. Die jeweils konkreten Werte für die Platzhalter wiederum werden automatisch in die gleichnamigen Spalten des EXTRACT-Statements gefüllt, sodass im Weiteren darauf zugegriffen werden kann.

In diesem Beispiel stimmt der Platzhalter {device:} mit der Kennung des Mitarbeiter-Gerätes überein, von dem die Logfiles ursprünglich generiert wurden, während {filename:} eine konkrete Datei innerhalb des entpackten Archivs beschreibt. Zur Erinnerung: An dieser Stelle liegen die Logdateien bereits unkomprimiert auf dem Data Lake Storage vor. Dateipfade in U-SQL beziehen sich, sofern nicht explizit anders angegeben, auf den zugrundeliegenden Data Lake Storage.

Wenn mehr als eine Input-Datei existiert (sei es über Platzhalter oder mehrere Pfad-Strings), werden diese automatisch parallel verarbeitet. Je nach Typ und Größe können auch einzelne Dateien parallelisiert eingelesen werden (z.B. Dateien mit fester Spaltenbreite).

Sowohl für Input- als auch für Output-Dateien wird nativ das .gz-Komprimierungsformat unterstützt. Dieses muss lediglich in der Dateiendung angegeben werden, damit ADLA die Dateien bei der Ausführung automatisch komprimiert bzw. dekomprimiert. Das ist am Output-Pfad im Beispiel zu sehen.

Des Weiteren zeigt das Beispiel den oben erwähnten allgemeinen Aufbau von U-SQL-Skripten aus Extraktoren, Select-Statements und Outputtern. Sowohl für den Extraktor als auch für den Reducer werden hier eigene C#-Klassen verwendet. An den Namespaces sieht man eine Eigenheit der Sprache: Anders als SQL ist U-SQL komplett case-sensitiv und erlaubt Ausdrücke in Großbuchstaben ausschließlich für Schlüsselwörter. Deshalb darf der Namespace Sdx.Adla.Usql nicht SDX.ADLA.USQL heißen. Außerdem muss jedes Statement zwingend mit einem Semikolon abgeschlossen werden.

Aus dem U-SQL-Skript wird bei der Ausführung ein Job-Graph generiert, ähnlich dem Ausführungsplan von SQL. Die Abbildung zeigt einen solchen Graphen eines abgeschlossenen Jobs.

 

In diesem Fall wurden die Error-Logs von 4.845 verschiedenen Devices eingelesen. Zusammen hatten diese eine Größe von 11 GB. Die Angaben der Vertices sind ein Maß für die Menge an parallelisierbaren Schritten, die der Optimizer vorgesehen hat. In diesem Fall hätte ADLA die 4.845 Dateien also mit bis zu 26 Knoten parallel einlesen können. Die tatsächlich mögliche Parallelität wurde für die Ausführung aber auf 13 AUs (Analytics Units) festgelegt.

Wie in der Abbildung zu sehen, benötigte die Abarbeitung dieser Anfrage über alle Log-Archive hinweg weniger als 7 Minuten. Der Großteil davon wurde für das Einlesen der Daten benötigt. Die meisten Abfragen  dieses Projekts bewegen sich in einem ähnlichen Bereich, oftmals sogar schneller. Die Optimierung der Anfrage und insbesondere des eigenen Codes in den C#-Funktionen und -Klassen kann auch hier wesentlich zur Performance beitragen. Für das Testen und Entwickeln können U-SQL-Skripte auch lokal gegen einzelne Dateien ausgeführt werden.

Zusammenfassung

Durch die Verwendung von Microsoft Azure Komponenten war es mit vergleichsweise wenig Aufwand möglich, eine alternative Struktur zur Speicherung, Extraktion und Analyse der Logdaten zu etablieren. Die Verlagerung in die Azure Cloud und die dadurch verfügbaren Ressourcen ermöglichten es, den durchschnittlichen Aufwand für eine geräteübergreifende Loganalyse von mehr als einem halben Tag auf unter eine Stunde zu senken.

Die vorgestellte Lösung ist ein Beispiel dafür, wie in der Azure-Cloud schnell und simpel neue Lösungsansätze ausprobiert, umgesetzt und langfristig etabliert werden können. Insbesondere die Kombination von intuitivem SQL und funktionell mächtigem C# in Azure Data Lake Analytics hat die Auswertung der Logdaten nicht nur schneller, sondern auch wesentlich bequemer gemacht.