💨

Blazor でバリデーション ロジックを部品化したい

2023/01/29に公開

ASP.NET Core Blazor のバリデーションでは DataAnnotation を使ったバリデーションのほかに EditContextValidationMessageStore を使って画面内でバリデーションのロジックを実行する方法もあります。

以下のドキュメントの基本検証あたりがそれになります。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/forms-and-input-components?view=aspnetcore-7.0

そのほかに検証コンポーネントというセクションでは独自のコンポーネントを検証をサポートするコンポーネントの作り方が解説されています。これを少し応用すると特定のフィールドを検証するコンポーネントの作成が可能です。

愚直に実装するなら基本検証にある通り EditContext のイベントをハンドリングしてロジックを実装していく形になりますが、その場合は検証ロジックが画面に埋め込まれることになるので出来れば部品化したいですよね。そんな時は、検証コンポーネントにあるようなコードを少しカスタマイズして検証部品化しておくことで再利用可能な形に出来ます。

以下のように特定のプロパティの変更に応じてバリデーションを行うためのコンポーネントを作ります。このコンポーネントを継承して ValidateValue メソッドをオーバーライドして検証ロジックを実装する使い方を想定しています。

FieldValidatorBase.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using System.Linq.Expressions;
using System.Reflection;

namespace EmptyBlazorApp2.Validators;

public abstract class FieldValidatorBase<TItem> : ComponentBase, IDisposable
{
    private PropertyInfo? _propertyInfoCache;

    [CascadingParameter]
    protected EditContext? CurrentEditContext { get; private set; }

    private EditContext? _previousEditContext;

    [Parameter]
    [EditorRequired]
    public Expression<Func<TItem>>? For { get; set; }

    [Parameter]
    [EditorRequired]
    public string? ErrorMessage { get; set; }

    private FieldIdentifier Field { get; set; }

    private ValidationMessageStore? _messageStore;
    private bool _disposedValue;

    protected override void OnParametersSet()
    {
        // 必須パラメーターチェック
        if (CurrentEditContext is null)
            throw new InvalidOperationException($"{nameof(FieldValidatorBase<TItem>)} requires a cascading parameter of type {nameof(EditContext)}.");
        if (For is null)
            throw new InvalidOperationException($"{nameof(For)} requires.");
        if (string.IsNullOrWhiteSpace(ErrorMessage))
            throw new InvalidOperationException($"{nameof(ErrorMessage)} requires.");


        if (CurrentEditContext != _previousEditContext)
        {
            // バリデーションに必要なオブジェクトの初期化
            DetachEditContext();
            CurrentEditContext.OnValidationRequested += EditContext_OnValidationRequested;
            CurrentEditContext.OnFieldChanged += EditContext_OnFieldChanged;
            _messageStore = new(CurrentEditContext);

            _previousEditContext = CurrentEditContext;
        }

        // FieldIdentifier の初期化
        Field = FieldIdentifier.Create(For);
    }

    private void DetachEditContext()
    {
        // イベントハンドラー等の切り離し
        if (_previousEditContext is not null)
        {
            _previousEditContext.OnValidationRequested -= EditContext_OnValidationRequested;
            _previousEditContext.OnFieldChanged -= EditContext_OnFieldChanged;

            _messageStore = null;
        }
    }

    private void EditContext_OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
    {
        // Validation を要求されたら無条件にバリデーションを実行
        Validate();
    }

    private void EditContext_OnFieldChanged(object? sender, FieldChangedEventArgs e)
    {
        // 自分の監視対象のフィールドならバリデーションを実行
        if (e.FieldIdentifier.Model == Field.Model && e.FieldIdentifier.FieldName == Field.FieldName)
        {
            Validate();
        }
    }

    private void Validate()
    {
        // プロパティの値を取得してバリデーションを実行
        if (_propertyInfoCache is null || _propertyInfoCache.Name != Field.FieldName)
        {
            _propertyInfoCache = Field.Model.GetType().GetProperty(Field.FieldName);
        }

        var value = (TItem)_propertyInfoCache!.GetValue(Field.Model)!;
        _messageStore!.Clear(Field);
        if (ValidateValue(value) is false)
        {
            _messageStore.Add(Field, ErrorMessage!);
        }

        CurrentEditContext!.NotifyValidationStateChanged();
    }

    // このメソッドをオーバーライドして検証を行う
    protected abstract bool ValidateValue(TItem value);

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                DetachEditContext();
            }

            _disposedValue = true;
        }
    }

    public void Dispose()
    {
        // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

これを継承して各種検証部品を作っていきます。例えば必須入力チェックをする場合は以下のような感じになります。

RequiredValidator.cs
namespace EmptyBlazorApp2.Validators
{
    public class RequiredValidator : FieldValidatorBase<string?>
    {
        protected override bool ValidateValue(string? value) => 
            !string.IsNullOrWhiteSpace(value);
    }
}

これを使うと以下のように EditForm の中に置いて使うことが出来ます。以下の例では EditForm の上に固めて定義していますが、何処においても大丈夫です。

Index.razor
@page "/"
@using System.ComponentModel.DataAnnotations;
@using EmptyBlazorApp2.Validators;

<EditForm Model="_model" OnValidSubmit="OnValidSubmit" OnInvalidSubmit="OnInvalidSubmit">
    @* 必須入力チェック! *@
    <RequiredValidator For="() => _model.FirstName" ErrorMessage="First name は必須です。" />
    <RequiredValidator For="() => _model.LastName" ErrorMessage="Last name は必須です。" />

    <ValidationSummary />
    <fieldset>
        <legend>Name</legend>
        <div>
            <label>First name</label>
            <InputText @bind-Value="_model.FirstName" />
            <ValidationMessage For="() => _model.FirstName" />
        </div>
        
        <div>
            <label>Last name</label>
            <InputText @bind-Value="_model.LastName" />
            <ValidationMessage For="() => _model.LastName" />
        </div>
    </fieldset>

    <button type="submit">Submit</button>
</EditForm>

<p>@_message</p>

@code {
    private Person _model = new();

    private string _message = "";

    private void OnValidSubmit(EditContext editContext)
    {
        _message = $"Valid: {_model.FullName}";
    }

    private void OnInvalidSubmit(EditContext editContext)
    {
        _message = $"Invalid: {_model.FullName}";

    }

    class Person
    {
        public string? FirstName { get; set; }

        public string? LastName { get; set; }

        public string? FullName => (FirstName, LastName) switch
        {
            (null, null) => null,
            (null, string lastName) => lastName,
            (string firstName, null) => firstName,
            (string firstName, string lastName) => $"{firstName} {lastName}",
        };
    }
}

実行してみると、ちゃんと動くことが確認できます。いい感じ。


検証エラー有り


検証エラー無し

まとめ

ということで、検証ロジックを画面に埋め込む場合でも再利用可能な部品として定義する方法を試してみました。個人的には画面入力のフォームに対応するクラスを作って、そこに DataAnnotations を付ける方法のほうが好みですが、そうではなく何らかの理由 (あんまり思いつきませんが) でその方法が取れない場合は、このような部品を作っておくのもありな気がします。

Microsoft (有志)

Discussion