😸

.NET 8 の Blazor の SSR で何個もボタンを置いた画面を作りたい!

2024/01/16に公開2

.NET 8 の Blazor の SSR ですが拡張フォーム処理とストリーム レンダリングを組み合わせることで、1つのシンプルなフォームはいい感じに作れるようになっています。
ただ、この場合 formonsubmit イベントは 1 つしかないので、複数個のボタンがあるような画面を作るのがメンドクサイです。

複数ボタンに対応するには submit 用のボタンに name 属性で名前を指定して、HttpContext.Request.Form の中に name 属性の値が入っているかどうかで押されたボタンを判別することが出来ます。イメージとしては以下ようなコードになります。

<form @onsubmit="OnSubmit" @formname="counter" method="post" data-enhance>
    <AntiforgeryToken />
    <h3>Counter</h3>
    <input type="hidden" name="Model.Count" value="@Model.Count" />

    @* name 属性で、どのボタンが押されたかわかるようにする *@
    <button name="incrementButton" type="submit">Increment</button>
    <button name="decrementButton" type="submit">Decrement</button>

    <p>Count: @Model.Count</p>
</form>

@code {
    [CascadingParameter]
    public HttpContext HttpContext { get; set; } = null!;

    private void OnSubmit()
    {
        if (HttpContext.Request.Form.ContainsKey("incrementButton"))
        {
            // incrementButton が押されたときの処理
        }
        else if (HttpContext.Request.Form.ContainsKey("decrementButton"))
        {
            // decrementButton が押されたときの処理
        }
    }
}

正直いってかなりメンドクサイです。

簡略化しよう

ということで簡略化しようと思います。
HttpContext から指定されたキーの値があるか無いかをチェックして、キーの値に応じた処理を呼び分けるということが出来れば良いので、以下のようにフォームの処理を行うためのメソッドに FormHandler という属性をつけておいて、ボタンの name 属性には @nameof(FormHandler属性のついてメソッド) でメソッド名を指定するような感じで使えるようにしてみました。

FormHandler.cs
using System.Reflection;

namespace BlazorApp5;

/// <summary>
/// フォームの onsubmit 時に呼び出されるメソッドにつける属性です。
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class FormHandlerAttribute : Attribute { }

/// <summary>
/// フォームの onsubmit 時に呼び出されるメソッドを管理する。
/// </summary>
/// <typeparam name="TPage">ページの型</typeparam>
/// <param name="httpContextAccessor"></param>
public sealed class FormHandler<TPage>(IHttpContextAccessor httpContextAccessor)
{
    private static readonly MethodInfo[] _handlerMethods;

    /// <summary>
    /// onsubmit の処理中は true になります。
    /// </summary>
    public bool IsProcessing { get; private set; }
    /// <summary>
    /// onsubmit の処理中は false になります。
    /// </summary>
    public bool IsIdle => !IsProcessing;

    static FormHandler()
    {
        // ページの型から FormHandlerAttribute がついたメソッドを探す。
        _handlerMethods = typeof(TPage).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
            .Where(m => m.GetCustomAttribute<FormHandlerAttribute>() is not null)
            .ToArray();
    }

    /// <summary>
    /// フォームの onsubmit 時に呼び出されるメソッドを返します。
    /// </summary>
    /// <param name="page">ページのインスタンス</param>
    /// <returns>form の onsubmit に設定するデリゲート</returns>
    public Func<Task> HandleSubmit(TPage page) => 
        () => HandleSubmitCore(page);

    private async Task HandleSubmitCore(TPage page)
    {
        // HttpContext は必須なのでチェック
        if (httpContextAccessor.HttpContext is null) throw new InvalidOperationException("HttpContext is null.");

        try
        {
            // 処理中のフラグを立てる
            IsProcessing = true;
            // ページの FormHandler 属性のついたメソッドに対応する値がフォームに含まれていれば呼び出す。
            foreach (var method in _handlerMethods)
            {
                if (httpContextAccessor.HttpContext.Request.Form.ContainsKey(method.Name))
                {
                    var result = method.Invoke(page, null);
                    if (result is Task task)
                    {
                        // 非同期の場合は完了を待つ。
                        await task;
                    }
                    return;
                }
            }

            // 対応するメソッドが無い場合は設定ミスなので例外を投げる。
            throw new InvalidOperationException("Not found.");
        }
        finally
        {
            // 処理中のフラグを下ろす
            IsProcessing = false;
        }
    }
}

Program.cs に作成した FormHandlerHttpContextAccessor を登録しておきます。

Program.cs
// この 2 行を追加
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient(typeof(FormHandler<>));

ページのコードは以下のようになります。

@page "/counter"
@attribute [StreamRendering]
@* FormHandler を DI コンテナから受け取る *@
@inject FormHandler<Counter> FormHandler

@{
    _ = Model ?? throw new InvalidOperationException();
}

@* onsubmit で FormHandler の HandleSubmit で作成したデリゲートを設定しておく *@
<form @onsubmit="FormHandler.HandleSubmit(this)" @formname="counter" method="post" data-enhance>
    <AntiforgeryToken />
    <h3>Counter</h3>
    <input type="hidden" name="Model.Count" value="@Model.Count" />
    @* nameof でメソッド名を name 属性に設定する *@
    <button name="@nameof(IncrementAsync)" type="submit" disabled="@FormHandler.IsProcessing">Increment</button>
    <button name="@nameof(DecrementAsync)" type="submit" disabled="@FormHandler.IsProcessing">Decrement</button>

    <p>Count: @Model.Count</p>
</form>

@code {
    [SupplyParameterFromForm]
    public CounterModel? Model { get; set; }

    protected override void OnInitialized()
    {
        Model ??= new();
    }

    // FormHandler 属性で onsubmit 時に呼び出されるメソッドを指定する。
    [FormHandler]
    public async Task IncrementAsync()
    {
        _ = Model ?? throw new InvalidOperationException();
        await Task.Delay(500);
        var current = int.Parse(Model.Count);
        Model.Count = $"{current + 1}";
    }

    [FormHandler]
    public async Task DecrementAsync()
    {
        _ = Model ?? throw new InvalidOperationException();
        await Task.Delay(500);
        var current = int.Parse(Model.Count);
        Model.Count = $"{current - 1}";
    }

    public class CounterModel
    {
        public string Count { get; set; } = "0";
    }
}

以下のような感じで動作します。

まとめ

あんまり、こういう仕組みは自分で作りたくない(将来のバージョンアップで同じような機能で、よりよいものが入りそうな気がする…) のですが、とりあえずの繋ぎとして使うのにはいいかも。

それにしてもページ全体を form で括ってやるのは Classic ASP の頃のベストプラクティス的なものだったり ASP.NET WebForms では、フレームワークとしてそういう作りが前提になっていたのですが、それを思い出しました。歴史は繰り返す?

Microsoft (有志)

Discussion

くさばくさば

現時点ではこのケースだと普通に回線つなぎたいなあ。。。(身も蓋もない)

Kazuki OtaKazuki Ota

個人的にはスケールインするマシンに当たると死んじゃうのが対処できたら回線でいいなぁと思います。