Blazor • WASM • .NET • ASP.NET Core

Blazor WebAssembly Umgebungskonfiguration

16. November 2022

Die Möglichkeit umgebungsabhängiger Konfiguration für serverseitig gerenderte Web-Anwendungen ist bereits seit Version 1.0 fester Bestandteil des ASP.NET Core-Frameworks. Seit Version 5 steht in .NET mit Blazor WebAssembly außerdem ein SPA-Framework im .NET-Universum zur Verfügung, das dieses Feature ebenfalls unterstützt. In diesem Artikel geht es um die Besonderheiten, die beim Einsatz des Standalone-Ansatzes von Blazor WebAssembly zu beachten sind. Wie so oft gilt: Es ist nicht besonders kompliziert, man muss nur wissen, was zu tun ist.

autor Patrick Schlarmann

Patrick Schlarmann

Principal eXpert

Ausgangssituation: Serverseitiges ASP.NET Core

Die Konfiguration eines Environments eröffnet im Wesentlichen zwei Möglichkeiten: Zum einen lässt sich die Konfiguration laufzeitabhängig gestalten, indem z.B. mehrere Konfigurations-Files nach dem Schema appsettings.{Environment}.json definiert werden, von denen zur Laufzeit nur das der aktuellen Umgebung zugehörige geladen wird. Zum anderen kann auch der Programmablauf selbst umgebungsabhängig gestaltet werden. Die Default-Templates nutzen das z.B. bei der Erstellung der Middleware-Pipelines – globales Exception Handling und HSTS werden nur eingesetzt, wenn keine Development-Umgebung vorliegt:

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

Das Environment-Setting steht standardmäßig auf “Production”, wird von Visual Studio zum Debuggen auf “Development” gestellt (via launchSettings.json), kann aber prinzipiell jeden beliebigen Wert annehmen. Dabei existieren für serverseitige Anwendungen zwei Möglichkeiten, es beim Programmstart auf einen bestimmten Wert zu setzen: Entweder gebe ich der Anwendung ein entsprechendes Kommandozeilen-Flag mit, d.h. ich starte die Anwendung z.B. via

dotnet MyApp.dll --environment Staging

oder ich setze die Umgebungsvariable ASPNETCORE_ENVIRONMENT auf den gewünschten Wert, dieser wird dann von der Anwendung beim Start ebenfalls aufgegriffen. Die beim Programmstart generierten Logs zeigen den gewünschten Effekt:

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7003
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5067
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Staging
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\Path\to\project

Bei Blazor WebAssembly-Anwendungen ergibt sich nun das Problem, dass keiner dieser beiden Wege verfügbar ist, denn diese läuft in der Browser-Sandbox des Users, nicht auf dem von mir kontrollierten Server. Trotzdem möchte ich möglicherweise umgebungsabhängige Konfiguration verwenden, z.B. um Produktions- oder Test-URLs von Backend-Services zu konfigurieren, oder bestimmte Features zu (de-)aktivieren. Wie teile ich der Anwendung also mit, welche Umgebungskonfiguration verwendet werden soll?

Hosted vs. standalone Blazor WebAssembly

Wichtig ist an dieser Stelle die Unterscheidung zwischen gehosteten und standalone Blazor WASM-Anwendungen. Auf den Code, der letztlich vom Broswer ausgeführt wird, ergibt sich zwar kein Unterschied, aber während das WebAssembly-Binary bei der gehosteten Variante von einer typischen serverseitigen ASP.NET Core-Anwendung als statisches Asset z.B. neben einer Web-API ausgeliefert wird, muss ich mich bei der Standalone-Variante um die Auslieferung selbst kümmern (z.B. via nginx-Webserver o.ä.). Die Standalone-Variante ist zwar in den meisten Fällen etwas komplexer, kann aber verschiedene Vorteile bieten – beispielsweise ist das Container-Image einer innerhalb eines nginx-Containers gehosteten standalone Hello-World-App um etwa 75% kleiner als ein vergleichbares Image einer Hosted-WASM-App.

Zurück zur Umgebungskonfiguration: Verwende ich das Template für eine in ASP.NET Core gehostete App, scheint alles auf magische Weise zu funktionieren – wenn ich die Serverseite (per Kommandozeile oder Umgebungsvariable) anweise, als Umgebung “Staging” zu verwenden, läuft auch die clientseitige WebAssembly-App in dieser Konfiguration (und lädt damit z.B. die korrekte appsettings.Staging.json-Datei vom Server). Ein Blick hinter die Kulissen zeigt, wie das funktioniert und was zu tun ist, wenn ich ohne ASP.NET Core-Host in der Standalone-Variante auskommen möchte.

Der Blick hinter die Kulissen

Das entscheidende Stück Code findet sich in der Methode app.UseBlazorFrameworkFiles(), die vom serverseitigen Projekt beim Programmstart standardmäßig aufgerufen wird, um das Ausliefern der statischen Framework-Files zu ermöglichen. Dort wird – verkürzt – folgender Code ausgeführt:

builder.MapWhen(ctx => 
    ctx.Request.Path.StartsWithSegments(pathPrefix, out var rest) 
    && rest.StartsWithSegments("/_framework") 
    && !rest.StartsWithSegments("/_framework/blazor.server.js"),
    subBuilder =>
    {
        subBuilder.Use(async (context, next) =>
        {
             context.Response.Headers.Append(
                "Blazor-Environment", 
                webHostEnvironment.EnvironmentName);
        });
    });

Sprich: Immer wenn der Server einen Request für eine statische Resource erhält, die zu den Framework-Dateien für die .NET-WebAssembly-Umgebung gehört, setzt er einen Response-Header “Blazor-Environment”, dessen Wert seine eigene (serverseitig!) konfigurierte Umgebung ist. Die Client-Anwendung wiederum, die via JavaScript-Interop mehrere solcher Requests zu Beginn ihrer eigenen Laufzeit an den Server absetzt, liest diesen Header-Wert:

const loaderResponse = 
    loadBootResource !== undefined 
        ? loadBootResource('manifest', 'blazor.boot.json', '_framework/blazor.boot.json', '') 
        : defaultLoadBlazorBootJson('_framework/blazor.boot.json');

let bootConfigResponse: Response;

if (!loaderResponse) {
    bootConfigResponse = await defaultLoadBlazorBootJson('_framework/blazor.boot.json');
} else if (typeof loaderResponse === 'string') {
    bootConfigResponse = await defaultLoadBlazorBootJson(loaderResponse);
} else {
    bootConfigResponse = await loaderResponse;
}

const applicationEnvironment = 
    environment 
    || bootConfigResponse.headers.get('Blazor-Environment') 
    || 'Production';

Anschließend erfolgt die DI-Registrierung des WebAssemblyHostEnvironment:

var applicationEnvironment = jsRuntime.InvokeUnmarshalled<string>(
    "Blazor._internal.getApplicationEnvironment");
            
var hostEnvironment = new WebAssemblyHostEnvironment(
    applicationEnvironment, 
    WebAssemblyNavigationManager.Instance.BaseUri);

Services.AddSingleton<IWebAssemblyHostEnvironment>(hostEnvironment);

ASP.NET Core ist Open Source, der Quellcode somit auf GitHub öffentlich verfügbar. Die in diesem Abschnitt betrachteten Code-Ausschnitte stammen von hier bzw. hier.

Die Lösung: Korrekte Konfiguration des Webservers

Um dieses Verhalten für Blazor WebAssembly-Apps nachzustellen, die nicht von einer ASP.NET Core-Anwendung gehostet werden, müssen wir also ebenfalls einen entsprechenden Response-Header mit ausliefern. Dafür verantwortlich ist dann jedoch der jeweils eingesetzte Webserver. Im Fall von nginx wird dies etwa durch den Konfigurations-Eintrag add_header Blazor-Environment Staging ermöglicht (die Filterung auf bestimmte Requests ist nicht zwangsläufig erforderlich).

Andere Webserver bieten dasselbe Feature, caddy beispielsweise über die Direktive header * Blazor-Environment Staging, Apache via Header set Blazor-Environment: Staging. Wird die Anwendung statisch auf einem Azure Storage Account gehostet, muss das CDN entsprechend konfiguriert werden.

Die Umgebungskonfiguration steht nun per Dependency Injection bereit und kann wie gewohnt genutzt werden:

@page "/"
@using Microsoft.AspNetCore.Components.WebAssembly.Hosting
@inject IWebAssemblyHostEnvironment HostEnvironment

<h1>Current environment: @HostEnvironment.Environment</h1>

Zum Thema “Hosting von Blazor WebAssembly-Anwendungen auf Azure-Storage” bietet dieser Artikel im SDX-Flurfunk weitere Informationen.

Fazit

Wer auf die aus serverseitigen ASP.NET Core-Anwendungen bekannten Möglichkeiten der umgebungsabhängigen Konfiguration auch in Standalone Blazor WebAssembly-Apps nicht verzichten möchte, muss auf die korrekte Konfiguration des verwendeten Webservers achten und den Blazor-Environment-Header mit ausliefern.