Blazor版 YelpCampデモ

今回はBlazorのUnited(Web App)版でYelpCampを実装します。YelpCampとはUdemyさんの以下の講座の中で紹介されているWEBアプリで、オリジナル版はJavaScriptとNode.jsで構築されています。基本的なWEBアプリに必要と思われるほとんどの機能を網羅しているので、他のフレームワークでもこのアプリを実装できればかなり効率よく学習できると思われます。なお、日本人講師の方から移植版掲載の許可をいただき、本記事を作成しています。

Udemy

【世界で90万人が受講】Web Developer Bootcamp(日本語版)

Blazorには、大きく分けてブラウザ側で動作する「WebAssembly」版と、サーバー側でUI部品を作成(レンダリング)する「Server」版があります。今回使用する「United (Blazor Web App)」版は、ページやUI部品をどちら側で処理するか個別に指定することができます。使用している技術や通信方式は違いますが、この構成はReactにおける「React SSR(サーバーサイドレンダリング)」と「React Server Components」、および「クライアントSPA」を組み合わせた仕組みに概念的に非常に近いと思われます。いずれも、単一ページアプリケーション(SPA)として動作します。これに対してWordpressやテンプレートベース構成のLaravelは複数ページアプリケーション(MPA)と呼ばれている様です。

SPAは初回のみHTMLページを読み込みその後はコードで画面遷移を行いますが、MPAはページ毎に毎回HTMLとスクリプトを読み込みます。SPAは必要な部分のみ描画するため高速に動作しますが、初回のページ作成時に時間がかかるというデメリットがあります。United版はこれらのデメリットを解消するため、初回は軽量なHTMLを送信してとりあえず画面を表示して、その後バックグラウンドでデータの取得や画面の組み立てを行います。

WebAssembly版のBlazorでデータベースを使用したい場合は、別途サーバー側のアプリ(ASP.NET Core等)が必要になりますが、United版であればサーバー側の処理でデータベースに直接アクセスできるので、単一のプロジェクトでWEBアプリが作成できます。(正確にはサーバー側はUI部品(razor)を使用できるようにしたASP.NET Coreが実装されていて、フロントエンド側とは「SignalR」というリモート関数呼び出しが可能な通信(rpc)で接続されています。これらの仕組みは巧妙に隠蔽されていますので、仕組みを知らなくても開発可能です。) WebAssembly版のBlazorはWindowsまたはMac上以外でデバッグするのに制限がありましたが、United版であればWSL2上でも制限なくデバッグ可能です。

今回の記事を作成するにあたり、以下の講座で勉強させていただきました。外国人の講師の方ですがとても流暢な日本語を話されています。この講座はシリーズ化されていて現在はパート3まで配信されています。

Udemy

BlazorでのWebアプリケーション開発手法1【C#】

Blazorで開発を行う場合は「Visual Studio」を使用するのが一般的と思われますが、VSCodeでも開発可能なのでこの記事ではWindows版のVSCodeを使用しています。データベースはMAMPに付属のMySQLサーバーを使用しています。独自にインストールしても動作すると思われますが、アプリを配置するにはリバースプロキシが必要になるため、そこまで試されたい方はMAMPやWSL2等を使用したほうがいいかも知れません。

アプリケーションのひな型とデータベースを作成します

この記事ではMAMPのホームディレクトリ(C:\MAMP\htdocs)に、「YelpCampBlazor」というアプリケーションを作成する前提でご説明しています。ご自身の環境に合わせて適宜ディレクトリ名を読み替えてください。基本的にこちらの記事でご紹介したアプリの移植作業となります。最終的にWEBサーバー(Apache2)のリバースプロキシで動作させることを目指します。

エクスプローラ等を起動して「C:\MAMP\htdocsYelpCampBlazor」というフォルダーを作成してVSCodeで開きます。「Ctrl + @」と入力してターミナル画面を表示します。今回はMAMP上で動作させたいので、お手数ですがこちらの記事を参照して現在最新の.NET SDK(9.0)のインストールとBlazorでMySQLを使うための設定を行っておいてください。記事の中ではデータベース名が「blazor」、ユーザー名も「blazor」になっていますが、それぞれ「yelpcampblazor」と読み替えて設定してください。他の名称を使いたい場合はご自身の環境に合わせて適宜修正してください。

ビューを作成します

まずはメイン画面から作成してきます。Blazorはのビューは「Components」フォルダ内に作成していく様です。Blazorのひな型のメイン(トップ)ページは「Components/Pages/Home.razor」ファイルになっているので、このファイルをYelpCampのトップページに差し替えます。「F5」キーを押して実行してみるとデフォルトのレイアウトが適用されている様です。YelpCampのトップページはヘッダーもフッターも必要ないのでレイアウトを無効化します。

個別にページをレイアウトするにはビューファイルの中で「@layout」というディレクティブを使用する様です。このディレクティブの引数にレイアウトファイルを指定するとページ毎にレイアウトが変更できる様です。しかしレイアウトを行わないという指定はできない様なので、空のレイアウトファイルを作成してレイアウトを無効化します。「Components\Layout」フォルダ内に「MainPageLayout.razor」というファイルを作成して以下の様に記述します。レイアウトページは「LayoutComponentBase」クラスから派生する必要がある様です。

@inherits LayoutComponentBase

@Body

次に「Components\Pages\Home.razor」ファイルの先頭付近にレイアウトディレクティブを追加します。余談ですが、Blazorのルーティングはビューファイルの先頭にある「@page」ディレクティブで行う様です。複数宣言できるので異なるアドレスを同じページで処理することもできる様です。

@page "/"
@layout Layout.MainPageLayout

「F5」キーで実行してみるとレイアウトが無効化されている様です。スタイルは後から適用するので残りの画面も追加していきます。

以下の様なフォルダとビューファイルを追加します。各ファイルの詳しい内容はお手数ですがソースファイルをご覧ください。

YelpCampBlazor
└─Components
  ├─Pages
  │    └─404.razor
  ├─Campgrounds
  │    ├─Edit.razor
  │    ├─Index.razor
  │    ├─New.razor
  │    └─Show.razor
  └─Users
       ├─Login.razor
       └─Register.razor

以下のファイルはもう使用しないので削除しておきます。

YelpCampBlazor
└─Components
    ├─Pages
    │  ├─Counter.razor
    │  └─Weather.razor
    └─UserPages
        ├─Create.razor
        ├─Delete.razor
        ├─Details.razor
        ├─Edit.razor
        └─Index.razor

以下のパッケージもデプロイ時に問題が起きるので、あらかじめ削除しておきます。Ctrl + @でターミナル画面を開いて以下のコマンドを入力します。

dotnet remove package Microsoft.AspNetCore.Components.QuickGrid
dotnet remove package Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter

「Program.cs」ファイルを開いて、以下の行があれば削除しておきます。

builder.Services.AddQuickGridEntityFrameworkAdapter();
テンプレートを作成します

他のページはヘッダーとフッターを持っていてすべて同じ構造なので以下の様なフォルダと空のファイルを作成します。

YelpCampBlazor
└─Components
    ├─Layout
    │   └─Boilerplate.razor
    └─Partials
        ├─Flush.razor
        ├─Footer.razor
        └─Navbar.razor

「Boilerplate.razor」ファイルに以下の内容を記述します。拡張子が「.razor」になっているファイルは特に何も定義しなくてもUI部品(コンポーネント)として利用可能です。「<Navbar />」の様に記述して任意の場所で使用できる様です。「@Body」の部分には各ページのコンテンツが埋め込まれて表示されます。

@inherits LayoutComponentBase
@using YelpCampBlazor.Components.Partials

<body class="d-flex flex-column vh-100">
    <Navbar />
    <main class="container mt-5">
        @Body
    </main>
    <Footer />
</body>

このレイアウトファイルをデフォルトで使用するために「Components\Routes.razor」ファイルの3行目にある「DefaultLayout」の値を「typeof(Layout.Boilerplate)」に変更します。

<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.Boilerplate)" />
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

「Navbar.razor」ファイルと「Footer.razor」ファイル内容はお手数ですがソースファイルをご覧ください。

コントローラーを作成します

Blazorは特にコントローラーを記述する場所等は決まっていない様です。以下の様に「@code」ディレクティブを使用すればビューファイルの中に直接C#コードを記述できますが、メンテナンスやテストを行うにはできるだけコードが分離されていた方が管理がし易くなります。

@page "/counter"
@rendermode InteractiveServer

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

そこでプロジェクト内に「Controllers」等のフォルダを作成して以下の様なファイルを作成してコードを分離します。

using System.Runtime.CompilerServices;

namespace YelpCampBlazor.Controllers
{
    public class UserInfo
    {
        public string UserName { get; set; }
        public string Password { get; set; }
    }

    public static class UsersController
    {
        public static bool Login(UserInfo info)
        {
            return true;
        }
    }
}

「@using」ディレクティブで定義をインポートすればビューの中からコントローラー内のメソッドを呼び出し可能になります。

@page "/login"
@using YelpCampBlazor.Controllers

<h3>ログイン</h3>

<EditForm Model="formData" OnValidSubmit="HandleSubmit">
    <InputText @bind-Value="formData.UserName" placeholder="名前" />
    <InputText @bind-Value="formData.Password" placeholder="パスワード" />
    <button type="submit">送信</button>
</EditForm>

@code {
    private UserInfo formData = new();

    private Task HandleSubmit()
    {
        if (!UsersController.Login(formData))
        {
            // 何らかの処理
        }
        return Task.CompletedTask;
    }
}
モデルを作成します

以下のモデルを追加で作成します。各モデルの詳細はソースコードをご参照ください。

YelpCampBlazor
└─Models
    ├─Campground.cs
    ├─Image.cs
    └─Review.cs

データテーブルに追加したくない項目は「NotMapped」属性をプロパティに追加しておくとテーブル作成(マイグレーション)から除外されます。

using System.ComponentModel.DataAnnotations.Schema;

namespace BlazorApp.Models
{
    public class CampGround
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public int Price { get; set; }
        public string Description { get; set; }
        public string Location { get; set; }

        // 画像スキーマのリスト
        [NotMapped]
        public List<Image> Images { get; set; }

        // ユーザー名
        public string Author { get; set; }

        public string Geometry { get; set; }

        // 削除対象の画像ファイル名のリスト
        [NotMapped]
        public List<string> DeleteImages { get; set; }
    }
}

データベースのコンテキストファイル(MySqlContext.cs)にモデル情報を追加します。

using Microsoft.EntityFrameworkCore;

public class MySqlContext(DbContextOptions<MySqlContext> options) : DbContext(options)
{
    public DbSet<BlazorApp.Models.User> User { get; set; } = default!;
    public DbSet<BlazorApp.Models.Image> Image { get; set; } = default!;
    public DbSet<BlazorApp.Models.Review> Review { get; set; } = default!;
    public DbSet<BlazorApp.Models.CampGround> CampGround { get; set; } = default!;
}

最後に以下のコマンドをターミナル画面に入力するとデータテーブルが更新されます。

dotnet ef migrations add CreateTable
dotnet ef database update

開発時に作成履歴が必要なくてデータも消していいのであれば、以下の様にデータベースとマイグレーションファイルをすべて消して最初から作り直してもいいかもしれません。

dotnet ef database drop
dotnet ef migrations remove

dotnet ef migrations add CreateTable
dotnet ef database update

データテーブル作成時にエラーが出る場合は以下の方法で治るかもしれません。ターミナル画面に以下のコマンドを入力してビルドデータを削除します。その後VSCodeの左側のエクスプローラー画面にある「obj」というフォルダーを削除してから再度マイグレーションを行います。それでもエラーが出る場合は「dotnet build」と入力して明示的にアプリをビルドするとエラーメッセージが表示されますので、何か原因が分かるかもしれません。

dotnet clean

以上で大まかなインフラ整備が出来ましたので移植作業を続けます。

登録機能を追加します

Blazorは「フロントエンド」の技術なのでデフォルトの設定ではブラウザーから送信されたデータを受け取れません。フォームからPOSTされたデータを受け取りたい場合は「@rendermode」ディレクティブに「InteractiveServer」という値を設定します。この設定を行うとフロントエンド側とバックエンド側が「SignalR」というリアルタイム通信で接続されますので、フロントエンドの入力データをそのままビューファイル内のコードで扱えます。(POSTせずにビューファイルのコード内で使用できます。)

@page "/register"
@rendermode InteractiveServer

<h3>ユーザー登録</h3>

<!-- ブラウザに表示される入力フォーム -->
<EditForm Model="formData" OnValidSubmit="HandleSubmit">
	<div><InputText @bind-Value="formData.UserName" placeholder="名前" /></div>
	<div><InputText @bind-Value="formData.Email" placeholder="メールアドレス" /></div>
	<div><InputText @bind-Value="formData.Password" placeholder="パスワード" /></div>
	<div><button type="submit">送信</button></div>
</EditForm>

@code {
	public class UserModel
	{
		public string UserName { get; set; }
		public string Email { get; set; }
		public string Password { get; set; }
	}

	private UserModel formData = new();

	//「送信」ボタンが押されるとサーバー側で実行されます
	private Task HandleSubmit()
	{
		// 登録処理 (formDataに入力データが入っています)

		return Task.CompletedTask;
	}
}

しかしこの設定を使うと、最初にサーバーで簡易的にページを作り(プリレンダリングという様です。)その後ブラウザで最終的なページが組み立てられるため、同じ処理が2回実行されることになります。大量のデータを最初から表示する等の理由で2回実行したくない場合は「@rendermode」ディレクティブを以下の様に修正するとプリレンダリングを抑制できる様です。

@* プリレンダリングを無効にします *@
@rendermode @(new InteractiveServerRenderMode(prerender: false))
認証機能を追加します

Blazorには「ASP.NET Core Identity」という認証のためのフレームワークがありますが、認証と認可の基本的な仕組みを学習するため今回はクッキーを使用したシンプルな認証方法を実装します。こちらのサイト様を参考にして、認証機能を実装していきます。まず「Program.cs」ファイルに以下の設定を追加します。

var builder = WebApplication.CreateBuilder(args);
.
.
(以下略)
.
.
// クッキーを使用して認証を行います
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie();
// SignalR経由で各ページで認証情報を参照できる様にします
builder.Services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
// 認証情報をコンポーネントツリー全体で使用できる様にします
builder.Services.AddCascadingAuthenticationState();


var app = builder.Build();
.
.
(以下略)
.
.
// 認証と認可のミドルウェアを追加します
app.UseAuthentication();
app.UseAuthorization();
.
.
(以下略)
.
.
app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

次に、ログイン用のビューを実装していきます。認証情報をクッキーに保存するには「HttpContext」というクラスのインスタンスを使用する様です。書き込み可能なHttpContextは静的なサーバーサイドレンダリング(SSR)でないと取得できない様なので「@rendermode」ディレクティブは使用しません。

通常、インタラクティブなモードでないとフォームデータを取得できませんが、23行目にある「SupplyParameterFromForm」という属性をフォームデータのプロパティに追加しておくと、HTTP(HTTPS)接続で送信されたPOSTデータから対応する値を自動的に取得してくれる様です。

26行目の[CascadingParameter]は、Blazor における特別なパラメータの一種で、コンポーネント間でデータを階層的に共有するための仕組みの様です。親コンポーネントやURLから明示的に子コンポーネントに値を渡す「Parameter」とは異なり、祖先となるコンポーネントのどこかで定義されていれば自動的に値を供給してもらえます。型さえあっていればプロパティの名称は何でもいい様です。

36~46行目が実際のログイン処理になります。流れ的には「Claim(ユーザー属性)」、「ClaimsIdentity(ユーザーID)」、「ClaimsPrincipal(ユーザー本人を表す情報)」を連鎖的に作成して、最終的にHttpContextの「SignInAsync」メソッドを実行すると認証される様です。

@page "/login"
@using System.Security.Claims
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@inject NavigationManager Navigation

<h3>ログイン</h3>

<!-- ブラウザに表示される入力フォーム -->
<EditForm Model="formData" FormName="LoginForm" OnValidSubmit="HandleSubmit">
	<div><InputText @bind-Value="formData.UserName" placeholder="名前" /></div>
	<div><InputText @bind-Value="formData.Password" placeholder="パスワード" /></div>
	<div><button type="submit">ログイン</button></div>
</EditForm>

@code {
	public class UserInfo
	{
		public string UserName { get; set; }
		public string Password { get; set; }
	}

	[SupplyParameterFromForm]
	private UserInfo formData { get; set; } = new();

	[CascadingParameter]
	private HttpContext? httpContext { get; set; }

	// 「ログイン」ボタンを押すとサーバー側で実行されます
	private async Task HandleSubmit()
	{
		if (httpContext == null || string.IsNullOrEmpty(formData.UserName)) return;

		// 有効なユーザーか確認

		var claims = new List<Claim>
		{
			new Claim(ClaimTypes.Name, formData.UserName),
		};

		var identity = new ClaimsIdentity(claims,
			CookieAuthenticationDefaults.AuthenticationScheme);
		var principal = new ClaimsPrincipal(identity);

		await httpContext.SignInAsync(
			CookieAuthenticationDefaults.AuthenticationScheme, principal);
		Navigation.NavigateTo("/");
	}
}

認証に成功すると、ログイン中のユーザー情報を他のビューファイルからも参照できるようになります。ログインユーザーの情報は、@context という読み取り専用の HttpContext クラスのインスタンスを経由して取得できる様です。

@page "/"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Authorization

<PageTitle>Home</PageTitle>

<AuthorizeView>
    <Authorized>
        <p>こんにちは @context.User.Identity!.Name さん。</p>
    </Authorized>
    <NotAuthorized>
        ゲストさんこんにちは。
    </NotAuthorized>
</AuthorizeView>

<h1>Hello, world!</h1>

Welcome to your new app.

ページ単位で認証したい場合は以下の様に「@attribute」ディレクティブを使用するようです。認証されていない場合はデフォルトで「/Account/Login」ページにリダイレクトされる様です。

@page "/secret"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]

<h3>これは認証が必要なページです</h3>

ログインまたはログアウトするアドレスを変更したい場合は「Program.cs」ファイルを以下の様に修正するとログインやログアウトするページアドレスを変更できるようです。

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie();

↓

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.LogoutPath = "/logout";
    });
ログアウトするには

ログアウト機能を追加するには、「Program.cs」ファイルに以下の設定を追加して「/logout」にアクセスするとログアウト処理を行います。この設定の代わりに「Logout.razor」等のページを作成してそちらにリダイレクトしても同様の操作が可能ですが、状態を変更できるHttpContextを取得する必要があるので静的SSRページにします。

// 認証と認可のミドルウェアを追加します
app.UseAuthentication();
app.UseAuthorization();

// ログアウト用エンドポイント
app.MapGet("/logout", async (HttpContext context) =>
{
    await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    return Results.Redirect("/");
});

.
.
(以下略)
.
.
app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

また、サーバー側でログアウトしただけではフロント側が更新されないので、ログイン状態によって「ログイン」や「ログアウト」などのナビゲーションバーの表示等を切り替えていた場合は状態の不整合が起きます。以下のコードをログアウトするボタンやリンクがあるビューページに追加しておくと、ブラウザー側を強制的にリロードすることができます。

@rendermode @(new InteractiveServerRenderMode(prerender:false))
@inject IJSRuntime JS

<a @onclick="Logout" href="/logout">ログアウト</a>

@code {
    private async Task Logout()
    {
        // サーバーにリクエストしてリダイレクト & ページ全体をリロード
        await JS.InvokeVoidAsync("eval", "window.location.href='/logout'");
    }
}
移植作業メモ
・フラッシュメッセージを表示するには

Blazor United(Web App)はバックエンドとフロントエンドがSignalR(Web Socket)で接続されていますので、従来のWEBアプリの様にフラッシュメッセージをセッション等のストレージに保存できません。そこで新たに保存領域を作成する必要があります。(多分もっといい方法があると思われますが現在知っている範囲でご紹介いたします。)

以下の様なメッセージを保持するクラスを新たに作成します。フラッシュメッセージなので一度読み取ったら値をクリアする様にしています。

public class FlashService
{
    private string? _message;

    public string? Message
    {
        // _messageの値を返してからnullにリセットします。
        get => Interlocked.Exchange(ref _message, null);
        set => _message = value;
    }

    public bool HasMessage => _message != null;
}

「Program.cs」ファイルに以下の設定を追加して、他のすべてのコンポーネントで使用できる(依存性を注入できる)様にします。

builder.Services.AddScoped<FlashService>();

以下の様なフラッシュメッセージ表示用のrazorコンポーネントを作成します。

@inject FlashService FlashService

@if (IsVisible && FlashService.HasMessage)
{
	<div class='alert alert-success alert-dismissible fade show' role='alert'>
		<div>@FlashService.Message</div>
		<button @onclick="Hide" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
	</div>
}
@code {
	private bool IsVisible = true;
	private void Hide() => IsVisible = false;
}

フラッシュメッセージを表示したいrazorコンポーネントに設定を追加します。(設定例ではひな型で作成されるCounterコンポーネントを使用しています。)

@page "/counter"
@rendermode InteractiveServer
@inject FlashService FlashService

<PageTitle>Counter</PageTitle>

<Flash @key="flashKey" />

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private Guid flashKey = Guid.NewGuid();
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
        FlashService.Message = "フラッシュメッセージ";
        flashKey = Guid.NewGuid();
    }
}

ひな型アプリのカウンターページで「Click me」ボタンをクリックするとフラッシュメッセージが表示されます。

毎回コンポーネント内で「@inject」を書くのが面倒な場合は、「Components\_Imports.razor」ファイルに以下の設定を追加することで、どのビューファイルからでも参照できる様になります。

@inject FlashService FlashService

余談ですが、Blazorコンポーネントは(Reactと同様に)状態が変わると自動的に再描画されます。通常のWebアプリの様なリダイレクト処理は不要です。変数(currentCount等)の値が変わるだけで対応するコンポーネントが再描画されます。逆に状態が変わらなければ再描画されないので、子コンポーネント等を再描画したい場合は@keyタグを追加して参照値を変えると強制的に再描画されます。

・.envファイルを使用するには

各種WEBフレームワークで、開発時にAPIキーの保存用途などに使用する「.env」ファイルをBlazorでも使用できます。ターミナル画面を開いて以下の様に入力して、「DotNetEnv」というパッケージをインストールします。

dotnet add package DotNetEnv

「Program.cs」ファイルを開いて以下の設定を追加します。

var builder = WebApplication.CreateBuilder(args);

// 開発環境のときだけ「.env」を読み込みます
if (builder.Environment.IsDevelopment())
{
    DotNetEnv.Env.Load();  
}
builder.Configuration.AddEnvironmentVariables();

「.env」ファイルをプロジェクトフォルダ直下に作成してキーと値を追加します。

MAPBOX_TOKEN=<APIキー>

あとはC#コード内で以下の様に記述するとキーの値が取得できます。

public async Task<string> ForwardGeocodeAsync(string location)
{
    var apiKey = Environment.GetEnvironmentVariable("MAPBOX_TOKEN");

ビュー(razor)コンポーネント内で使用したい場合は、まず以下の様なサービスクラスを作成ます。

public class MyService
{
    public string? ApiKey { get; }

    public MyService(IConfiguration configuration)
    {
        ApiKey = configuration["MAPBOX_TOKEN"];
    }
}

次に「Program.cs」ファイルを開いて、以下の設定を追加します。

builder.Services.AddScoped<MyService>();

ビュー(razor)コンポーネントに「@inject」ディレクティブを追加します。

@inject MyService myService

あとはHTML部分やコード部分で以下の様に参照可能です。(実際の実装ではHTML部分に重要な情報は表示しないでください。)

<p>@myService.ApiKey</p>

@code {
	protected override async Task OnInitializedAsync()
	{
		var apiKey = myService.ApiKey;
	}
}

リリース(デプロイ)環境では「.env」ファイルを使わずに、環境変数に値をセットして運用します。

・「F5」キーでデバッグがはじまらない場合は

何らかの理由で「F5」キーでデバッガーが起動しない場合は、プロジェクト内に「.vscode」フォルダーを作成して、フォルダー内に「launch.json」というファイルと「tasks.json」というファイルを作成して、以下の様に設定するといい様です。うまく起動しない場合は、9行目の「${workspaceFolderBasename}」という変数をプロジェクト名に変更するか、実際にあるプロジェクト名のDLLファイル名に変更すると起動すると思われます。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": ".NET Blazor United (Apache Reverse Proxy)",
      "type": "coreclr",
      "request": "launch",
      "preLaunchTask": "build",
      "program": "${workspaceFolder}/bin/Debug/net9.0/${workspaceFolderBasename}.dll",
      "args": [],
      "cwd": "${workspaceFolder}",
      "stopAtEntry": false,
      "serverReadyAction": {
        "action": "openExternally",
        "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
      },
      "env": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ASPNETCORE_URLS": "http://localhost:5000"
      },
      "envFile": "${workspaceFolder}/.env",
    }
  ]
}
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "build",
      "command": "dotnet",
      "type": "process",
      "args": ["build"],
      "problemMatcher": "$msCompile"
    }
  ]
}
Windows上にYelpCampをデプロイします

Ctrl + @と入力して、ターミナル画面を開いて以下のコマンドを実行します。

dotnet publish -c Release -o ./publish

リリース(デプロイ)版では.envファイルを使用しないので、Windowsで環境変数を設定します。ターミナル画面に以下の様に入力します。

control sysdm.cpl

「システムのプロパティ」ダイアログが開きますので、「詳細設定」タブ内にある「環境変数」ボタンを押します。

表示された画面で「新規(N)...」ボタンを押して「MAPBOX_TOKEN」環境変数を追加します。変数値にMapboxのAPIキーを設定します。

「publish」フォルダ内にある「YelpCampBlazor.exe」というファイルをダブルクリックするとアプリが起動します。起動したアプリはローカルホストの5000番ポートで待ち受けている様です。(Windowsのサービスとして常に起動させることもできる様ですが、通常のデプロイ先はAzureかAWSなどになると思われます。ご興味のある方は「blazor kestrel UseWindowsService」などと検索してみてください。)

何かブラウザーを開いて「localhost:5000」にアクセスするとアプリが表示されます。キャンプ場一覧に移動すると地図も表示されているので、APIキーも特に問題なく読み取れている様です。

「localhost:5000」ではなくて、「<サーバー名>/YelpCampBlazor」等のサブディレクトリ形式でアクセスしたい場合は、リバースプロキシ設定が必要です。この設定を行うと「/YelpCampBlazor」へのアクセスをすべて「localhost:5000」に中継してくれる様になります。

MAMPで設定を行うには、「C:\MAMP\conf\apache\httpd.conf」ファイルを開いて、ファイルの最後の方に以下の設定を追加します。その後MAMPのメイン画面にある電源ボタン(Stop Servers)を2回押してサーバーを再起動するとリバースプロキシが有効になります。

<IfModule mod_proxy.c>
    ProxyRequests Off
    <Proxy *>
        Require all granted
    </Proxy>
    # 転送先サーバーとディレクトリ
    ProxyPass /YelpCampBlazor http://localhost:5000
    ProxyPassReverse /YelpCampBlazor http://localhost:5000

	# WebSocket(SignalR)用
    RewriteEngine On
    RewriteCond %{HTTP:Upgrade} =websocket [NC]
    RewriteRule ^/YelpCampBlazor/(.*) ws://localhost:5000/$1 [P,L]

    RewriteCond %{HTTP:Upgrade} !=websocket [NC]
    RewriteRule ^/YelpCampBlazor/(.*) http://localhost:5000/$1 [P,L]
</IfModule>
サブディレクトリで動作する様にアプリを修正します

Blazorアプリをサブディレクトリで動作させるには、まず「Program.cs」ファイルに以下の設定を追加します。

var app = builder.Build();

app.UsePathBase("/YelpCampBlazor");

次にComponentsフォルダ内にある、「App.razor」ファイルを以下の様に修正します。サブディレクトリ名の終端は「/」で終わる様にします。

<head>
    <base href="/" />
    ↓
    <base href="/YelpCampBlazor/" />
</head>

この設定で各ページ内のリンクには自動的に「/YelpCampBlazor/」が付加されます。各リンクの先頭は「/」を付けずに、すべて相対的なリンクにします。以上でリンク名の修正は終了です。

<a class="nav-link" href="login">ログイン</a>

リンク名の修正は簡単でしたが、意外な躓きポイントありました。ログイン用のコンポーネントは大まかに以下の様な構成になっています。

「localhost:5000」などの様にポート番号を指定してアクセスした場合は、ログインボタンを押すと「HandleSubmit」メソッドが実行されますが、「localhost/YelpCampBlazor」などの様にリバースプロキシ経由でアクセスすると「HandleSubmit」でブレークポイントがヒットしません。(コードが実行されません。)

@page "/login"

<h1>ログイン</h1>

<EditForm Model="userInfo" FormName="LoginForm" OnValidSubmit="HandleSubmit">
    <div>
        <InputText @bind-Value="userInfo.UserName"/>
    </div>
    <div>
        <InputText @bind-Value="userInfo.Password" />
    </div>
    <div>
        <button type="submit">ログイン</button>
    </div>
</EditForm>

@code {
    public class UserInfo
    {
        public string UserName { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
    }

    [SupplyParameterFromForm]
    private UserInfo userInfo { get; set; } = new();

    private async Task HandleSubmit()
    {
        // ログイン処理
    }
}

これは、デバッグモードで.NETの組み込みWEBサーバー(Kestrel)とポートを介して直接接続すると、すべてのページに対してSignalRで接続してくれるからと思われます。それに対し、リバースプロキシで接続すると「@rendermode」ディレクティブが無いため、単なる静的なHTMLページと解釈されてしまい、SignalR接続が発生しない様です。

「@rendermode」を指定すると、今度は書き込み可能なHttpContextが取得できなくなるので、ログインページからはJavaScriptを使用して「fetch」コマンドでHTTP(S)でデータをサーバーにポストする様に修正します。サーバー側は「Minimal API」と呼ばれるエンドポイントを作成して、送信されてきたデータを基に認証する仕組みに変更します。

まず、「Components\App.razor」ファイルの<head>セクションに以下のスクリプトを追加します。

<script>
    window.postWithAntiforgery = async function (apiUrl, userInfo, token) {
        const basePath = document.baseURI || "/";
        const postUrl = new URL(apiUrl, basePath).toString();

        const response = await fetch(postUrl, {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                'RequestVerificationToken': token
            },
            body: JSON.stringify(userInfo)
        });
        return response.ok;
    };
</script>

ログインページを以下の様に修正します。

@page "/login"
@using Microsoft.AspNetCore.Antiforgery
@rendermode InteractiveServer
@inject IJSRuntime JS

<h1>ログイン</h1>

<EditForm Model="userInfo" FormName="LoginForm" OnValidSubmit="HandleSubmit">
    <div>
        <InputText @bind-Value="userInfo.UserName"/>
    </div>
    <div>
        <InputText @bind-Value="userInfo.Password" />
    </div>
    <div>
        <button type="submit">ログイン</button>
    </div>
</EditForm>

@code {
    [Inject] private IAntiforgery Antiforgery { get; set; } = default!;
    [Inject] private IHttpContextAccessor HttpContextAccessor { get; set; } = default!;
 
    public class UserInfo
    {
        public string UserName { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
    }

    [SupplyParameterFromForm]
    private UserInfo userInfo { get; set; } = new();

    private async Task HandleSubmit()
    {
        // ログイン処理
        var tokens = Antiforgery.GetAndStoreTokens(HttpContextAccessor.HttpContext!);
        var token = tokens.RequestToken;

        await JS.InvokeAsync<bool>("postWithAntiforgery", "api/login", formData, token);
    }
}

サーバー側(Program.cs)に以下の設定を追加します。

using YelpCampBlazor.Components;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using static YelpCampBlazor.Components.Pages.Login;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddAntiforgery();

builder.Services.AddHttpClient();
builder.Services.AddScoped(sp =>
{
    var navigationManager = sp.GetRequiredService<NavigationManager>();
    return new HttpClient
    {
        BaseAddress = new Uri(navigationManager.BaseUri)
    };
});

// クッキーを使用して認証を行います
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.LogoutPath = "/logout";
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
    });

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

var app = builder.Build();

app.UsePathBase("/YelpCampBlazor");

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    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.UseAntiforgery();

// 認証と認可のミドルウェアを追加します
app.UseAuthentication();
app.UseAuthorization();

app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.MapPost("/api/login", async (HttpContext context, UserInfo userInfo) =>
{
    // ここでログインチェック

    var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, userInfo.UserName),
            // 他に roles や id など必要なら追加
        };

    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    var principal = new ClaimsPrincipal(identity);

    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

    return Results.Ok();
});

app.Run();

以上でリバースプロキシ接続でもログインが可能になります。この他にユーザー登録時も同じ機構が必要になりますが、詳しい内容はお手数ですがソースコードをご覧ください。

アプリを実行するには

今回のソースコードはこちらからダウンロードできます。「.env」ファイルは付属していないのでご自身で作成してください。MapboxのAPIキーの取得方法はこちらのサイト様等をご参照ください。

データベースはCtrl + @でターミナル画面を開いて「dotnet ef database update」と入力すると作成されます。何かエラーが発生した場合は、お手数ですがこちらの記事等を参考にして作成してください。

データベースが作成出来たら「F5」キーを押すとアプリが実行されます。エラーが発生する場合は.envファイル等があるかご確認ください。

初期状態では「localhost:5000」で待ち受ける様になっています。リバースプロキシをご使用になる場合は、「Utils\AppConstants.cs」ファイルを開いて「BaseName」定数にサブフォルダ名を設定してください。(サブフォルダの末尾には「/」を付けないでください。)

namespace YelpCampBlazor
{
    public static class AppConstants
    {
        // public const string BaseName = "/YelpCampBlazor";
        public const string BaseName = "/";
    }
}

「localhost:5000/seeds」(リバースプロキシ使用時は「localhost/YelpCampBlazor/seeds」)にアクセスするとダミーデータが作成できます。

初期ユーザー名は「test」、パスワードは「password」になっています。このユーザーが所有するダミーのキャンプ場が50件作成されます。

以上です。よろしかったらお試しください。

React

前の記事

Reactのデバッグ