🔖

.NET 8 の Blazor で WASM + API のプロジェクトを作る

2024/05/16に公開

この記事は以下のツイートから始まる疑問に対する回答みたいな記事です。

https://x.com/yuma_prog/status/1790701289778848166

満たしたい要件としては、ASP.NET Core Blazor WebAssembly をフロントにしつつ、サーバーサイドは ASP.NET Core で Web API も作れて、同じサイトにフロントエンドとバックエンドの両方を配置できるようなプロジェクトを作ることです。

箇条書きにすると以下のような感じですね。

  • フロントエンドは ASP.NET Core Blazor WebAssembly
  • バックエンドは ASP.NET Core Web API (もしくは Minimal APIs)
  • 発行すると1つに纏まってフロントエンドとバックエンドと同じサイトに配置できる

先に解説

ASP.NET Core は DI コンテナ内にどんなサービスを登録するかということと、どんなミドルウェア(リクエストを処理するパイプライン)を登録するかによって柔軟にカスタマイズ出来るようになっています。ASP.NET Core MVC 用のサービスとミドルウェアを登録して構成すれば ASP.NET Core MVC が使えるようになり、ASP.NET Core Razor Pages 用のサービスとミドルウェアを登録して構成すれば ASP.NET Core Razor Pages が使えるようになります。
認証と認可用のサービスとミドルウェアを構成すると、MVC でも Razor Pages でも同じように認証・認可が出来るようになります。

このような仕組みになっているため ASP.NET Core のプロジェクト テンプレートに様々な種類が用意されていますが、基本的には Program.cs で登録しているサービスとミドルウェアが違って、それぞれの機能 (MVC, Razor Pages, etc...) のためのファイルが追加で生成されているだけになります。
そのため、MVC 用に作ったプロジェクトに対して Razor Pages や Web API などを足すことも簡単にできます。同じ要領で Blazor Web App で WASM を使うように設定して作ったプロジェクトに Web API 系のサービスやミドルウェアを追加することで、WASM と Web API の両方を持つプロジェクトを作ることができます。

やってみよう

ということでやってみましょう。まずは Blazor Web App プロジェクトで以下のような選択をしてプロジェクトを作ります。

  • フレームワーク: .NET 8
  • 認証の種類: なし
  • HTTPS 用の構成: チェック有り
  • Interactive render mode: WebAssembly
  • Interactivity location: Global
  • Include sample pages: チェック有り
  • Do not use top-level statements: チェック無し

画面的には以下のような感じです。

Interactive render mode と Interactivity location の設定が重要なポイントになります。この選択にすることでフロントエンドが Blazor WebAssembly 主体になります。

作られたプロジェクトの構成配下のような感じになります。

  • ソリューション
    • サーバーサイドプロジェクト
      • Components
        • Pages
          • Error.razor: エラーページ
        • _Imports.razor: Razor コンポーネントに共通で仕込む using 宣言など
        • App.razor: ルートにアクセスした際に表示されるページ (Blazor の Static SSR)
      • Program.cs: サーバーサイドのエントリポイント
    • クライアントサイドプロジェクト
      • ここに WASM 用のファイルが全部入ってる

画面のほぼ全体が Blazor WebAssembly になっていることは以下のように App.razor に書かれている Routes タグで確認できます。

App.razor
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="BlazorApp47.styles.css" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <HeadOutlet @rendermode="InteractiveWebAssembly" />
</head>

<body>
    @* ここで全体に InteractiveWebAssembly を設定している *@
    <Routes @rendermode="InteractiveWebAssembly" />
    <script src="_framework/blazor.web.js"></script>
</body>

</html>

API を足そう

では本題の API を足していきましょう。
作成する API はプロジェクトテンプレートで作成される Weather.razor の天気情報を返す API を作ることにします。

Minimal APIs で作ってみる

サーバーサイドの
では、Minimal APIs で作ってみましょう。サーバーサイドの Program.cs に以下のように MapGet メソッドを使って定義することが出来ます。

Program.cs
using BlazorApp47.Client.Pages;
using BlazorApp47.Components;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveWebAssemblyComponents();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseAntiforgery();

// ここに API 定義を追加
app.MapGet("/api/WeatherForecast", async () =>
{
    // とりあえずページ側にあったロジックをそのまま持ってきた
    // Simulate asynchronous loading to demonstrate a loading indicator
    await Task.Delay(500);

    var startDate = DateOnly.FromDateTime(DateTime.Now);
    var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = startDate.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = summaries[Random.Shared.Next(summaries.Length)]
    }).ToArray();
});

app.MapRazorComponents<App>()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(BlazorApp47.Client._Imports).Assembly);

app.Run();

// レスポンスはとりあえずクライアントサイドで定義されているものと同じクラスで
class WeatherForecast
{
    public DateOnly Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

これで API が生えました。/api/WeatherForecast にアクセスすると天気情報が返ってくるようになります。実際にデバッグ実行をしてブラウザーで叩いてみると以下のように JSON が返ってきます。

後は、この API をクライアント側から叩くだけです。Program.csHttpClient を DI コンテナに登録しておくコードを追加します。

Program.cs
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
// 自分の置かれているサーバーを叩くための HttpClient を追加
builder.Services.AddSingleton(sp => 
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

await builder.Build().RunAsync();

そして Weather.razor を以下のように変更して API を叩くようにします。

Weather.razor
@page "/weather"
@* HttpClient を受け取る *@
@inject HttpClient HttpClient
<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates showing data.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync() => 
        // API を呼び出してデータを取得
        forecasts = await HttpClient.GetFromJsonAsync<WeatherForecast[]>("api/WeatherForecast");

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF{ get; set; }
    }
}

うまくいくと思った?

さて、これでうまく行きそうな気配があるのですがトップページから Weather ページに行くのには問題ありません。

しかし、Weather ページで F5 キーを押してリフレッシュしたり、最初から Weather ページの URL を決め打ちで開くと以下のようにエラーになります。

これは Weather ページが最初に開くページの場合にはサーバーサイドでプリレンダリングが行われるためです。その際にサーバーサイドのプロジェクトでは HttpClient が DI コンテナに登録されていないためエラーになります。

とりあえず、今回のゴールは .NET 7 まであった ASP.NET Core hosted の Blazor WASM プロジェクトなので、そのプロジェクトと同じような挙動を実現するために Static SSR の時にはローディング画面を出すような感じにしてお茶を濁そうと思います。

ドキュメントの以下のページのクライアント側の読み込み状況インジケーターの部分を参考にします。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/fundamentals/startup?view=aspnetcore-8.0#client-side-loading-progress-indicators

ドキュメントでは LoadingProgress コンポーネントを作成して、それで各ページをラップするような感じになっていますが、今回は Router コンポーネント自体にこの実装を組み込むようにしました。Router.razor を以下のように変更します。Static SSR の時は Loading の表示をして、Blazor WASM の時にはページを表示するようにします。

Router.razor
@if (!OperatingSystem.IsBrowser())
{
    <HeadContent>
        <style>
            .loading-progress {
                position: relative;
                display: block;
                width: 8rem;
                height: 8rem;
                margin: 20vh auto 1rem auto;
            }

                .loading-progress circle {
                    fill: none;
                    stroke: #e0e0e0;
                    stroke-width: 0.6rem;
                    transform-origin: 50% 50%;
                    transform: rotate(-90deg);
                }

                    .loading-progress circle:last-child {
                        stroke: #1b6ec2;
                        stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
                        transition: stroke-dasharray 0.05s ease-in-out;
                    }

            .loading-progress-text {
                position: relative;
                text-align: center;
                font-weight: bold;
                top: -90px;
            }

                .loading-progress-text:after {
                    content: var(--blazor-load-percentage-text, "Loading");
                }

            code {
                color: #c02d76;
            }
        </style>
    </HeadContent>
    <svg class="loading-progress">
        <circle r="40%" cx="50%" cy="50%" />
        <circle r="40%" cx="50%" cy="50%" />
    </svg>
    <div class="loading-progress-text"></div>
}
else
{
    <Router AppAssembly="typeof(Program).Assembly">
        <Found Context="routeData">
            <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
            <FocusOnNavigate RouteData="routeData" Selector="h1" />
        </Found>
    </Router>
}

実行すると以下のようになります。ちゃんとリフレッシュした時には Blazor WASM が立ち上がるまでローディング画面が出ていることが確認できます。また Weather ページをリロードしてもエラーが出なくなっています。

Controller で作ってみる

最後にオマケです。API を Minimal APIs ではなく Controller で作ってみます。

Controller を使った ASP.NET Core の Web API を作成する場合はサーバーサイドの Program.cs に対して AddControllers メソッドをつかってサービスを登録して MapControllers メソッドを使ってルーティングを設定します。

Program.cs は以下のようになります。

Program.cs
using BlazorApp47.Components;

var builder = WebApplication.CreateBuilder(args);

// コントローラーのサービスを登録
builder.Services.AddControllers();

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveWebAssemblyComponents();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseAntiforgery();

// コントローラのルーティングを追加
app.MapControllers();

app.MapRazorComponents<App>()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(BlazorApp47.Client._Imports).Assembly);

app.Run();

そして Controllers フォルダーを追加して、その下に WeatherController.cs を作成して以下のようにします。

WeatherController.cs
using Microsoft.AspNetCore.Mvc;

namespace BlazorApp47.Controllers;

[Route("api/[controller]")]
[ApiController]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public async Task<IEnumerable<WeatherForecast>> GetAsync()
    {
        // Simulate asynchronous loading to demonstrate a loading indicator
        await Task.Delay(500);

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
        }).ToArray();
    }
}

public class WeatherForecast
{
    public DateOnly Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

これでコントローラーを使った Web API が作成できました。アプリケーションを起動して Weather ページを開くと以下のようになります。ちゃんと API からの結果が表示されてますね。

まとめ

ということで .NET 8 の Blazor Web App を使って .NET 7 まであった ASP.NET Core hosted の Blazor WebAssembly のプロジェクトテンプレートっぽいことを実現する方法を試してみました。

ASP.NET Core について知っていればなんとなく当たりがつくことではありますが、なかなか、そのものズバリのプロジェクトテンプレートがないとわかりにくいのは確かですね。今回の記事が少しでも参考になれば幸いです。

Microsoft (有志)

Discussion