Blazorのバリデーションで外部サービスのDI(注入)するには?

2023年8月7日月曜日

Blazor

t f B! P L

はじめに

近年の .NET開発では、サービスなどの依存性はDIによって注入するのが一般的です。
そして、アノテーションベースの検証で独自のチェック処理を実現する ValidationAttribute を継承したクラスにおいても、DIによって外部サービスの依存性を注入します。

ここで問題が…

通常、ValidationAttributeを継承したバリデーションで、外部のサービスをDIする場合は、オーバーライドしたIsValidメソッドの引数であるvalidationContextGetServiceメソッドを使います。

しかし、Blazorのアプリケーションから、このカスタムバリデーションを呼び出すとGetServiceメソッドも戻り値がnullになります。
ちなみに、他のASP .NET Core MVCなどのアプリケーションでは、このコードで正常に外部サービスが取得できます。

protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) {

  var serivce = validationContext.GetService<UserService>();
  //なぜか「serivce」がnullになる
}

問題の原因は「DataAnnotationsValidator」コンポーネント

この問題の解決方法が、Stack Overflowの以下の投稿で紹介されている。
How to get/inject services in custom ValidationAttributes

原因をざっくり言うと、Blazorでフォーム検証を実現する<DataAnnotationsValidator/> コンポーネントの初期化時に、依存関係の解決に必要なServiceProvider の指定がされていないため、GetServiceの結果がすべてnullになっているようだ。
じゃあ、<DataAnnotationsValidator/> を直せば?という話であるが、これは .NET のコンポーネントであるため、ソースはいじれない。

解決方法としては、次に紹介するDataAnnotationsValidatorをベースとした独自のバリデータークラスを作成する方法がある。

BlazorのカスタムバリデーションでDIを実現する方法

前述の問題を回避し、カスタムバリデーションのクラスからDIを実現方法を紹介する。

まず .NET標準のDataAnnotationsValidatorのソースコードをベースに、独自のCustomValidatorというクラスを作る。

How to get/inject services in custom ValidationAttributes

上のリンクにも掲載されているコードであるが、この記事にも掲載しておく。

using System.Reflection;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;

/// <summary>
/// blazorの入力検証で呼ばれるデータアノテーションからDI(インジェクション)するためのカスタムバリデーター
/// </summary>
public class CustomValidator : ComponentBase, IDisposable
{
    private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> PropertyInfoCache = new ConcurrentDictionary<(Type, string), PropertyInfo>();

    [CascadingParameter] EditContext CurrentEditContext { get; set; }
    [Inject] private IServiceProvider serviceProvider { get; set; }
    
    private ValidationMessageStore messages;

    protected override void OnInitialized()
    {
        if (CurrentEditContext == null)
        {
            throw new InvalidOperationException($"{nameof(CustomValidator)} requires a cascading " +
                                                $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(CustomValidator)} " + "inside an EditForm.");
        }

        this.messages = new ValidationMessageStore(CurrentEditContext);
        CurrentEditContext.OnValidationRequested += validateModel;
        CurrentEditContext.OnFieldChanged += validateField;
    }

    private void validateModel(object sender, ValidationRequestedEventArgs e)
    {
        var editContext = (EditContext) sender;
        var validationContext = new ValidationContext(editContext.Model);
        validationContext.InitializeServiceProvider(type => this.serviceProvider.GetService(type));
        var validationResults = new List<ValidationResult>();
        Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);

        messages.Clear();
        foreach (var validationResult in validationResults)
        {
            if (!validationResult.MemberNames.Any())
            {
                messages.Add(new FieldIdentifier(editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage);
                continue;
            }

            foreach (var memberName in validationResult.MemberNames)
            {
                messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
            }
        }

        editContext.NotifyValidationStateChanged();
    }

    private void validateField(object? sender, FieldChangedEventArgs e)
    {
        if (!TryGetValidatableProperty(e.FieldIdentifier, out var propertyInfo)) return;

        var propertyValue = propertyInfo.GetValue(e.FieldIdentifier.Model);
        var validationContext = new ValidationContext(CurrentEditContext.Model) {MemberName = propertyInfo.Name};
        validationContext.InitializeServiceProvider(type => this.serviceProvider.GetService(type));

        var results = new List<ValidationResult>();
        Validator.TryValidateProperty(propertyValue, validationContext, results);
        messages.Clear(e.FieldIdentifier);
        messages.Add(e.FieldIdentifier, results.Select(result => result.ErrorMessage));

        CurrentEditContext.NotifyValidationStateChanged();
    }

    private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo propertyInfo)
    {
        var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);

        if (PropertyInfoCache.TryGetValue(cacheKey, out propertyInfo)) return true;

        propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
        PropertyInfoCache[cacheKey] = propertyInfo;

        return propertyInfo != null;
    }

    public void Dispose()
    {
        if (CurrentEditContext == null) return;
        CurrentEditContext.OnValidationRequested -= validateModel;
        CurrentEditContext.OnFieldChanged -= validateField;
    }
}

次に、Blazorの<EditForm>タグの下に <CustomValidator />を置く。

<EditForm Model="@model" OnSubmit="@Submit">
    <CustomValidator />
    <ValidationSummary />
・・・
</EditForm>

それ以外は、普通のバリデーションと同じ要領で、入力用の要素と検証メッセージを表示するタグを置いていく。

<EditForm Model="@model" OnSubmit="@Submit">
    <CustomValidator />
    <ValidationSummary />
    <div>
        <label>名前</label>
        <input type="text" @bind="@model.Name" />
        <ValidationMessage For="@(() => model.Name)" />
    </div>
    <div>
        <label>年齢</label>
        <input type="number" @bind="@model.Age" />
        <ValidationMessage For="@(() => model.Age)" />
    </div>
    <div>
        <button type="submit">SUBMIT</button>
    </div>
</EditForm>

まとめ

ASP .NET Core Blazorでカスタムバリデーションから外部サービスをDI(依存性の注入)する方法を紹介しました。正直、.NETのバグっぽい動きな気がしますが、何とか解決方法が見つかりました。

スポンサーリンク
スポンサーリンク

このブログを検索

Profile

自分の写真
Webアプリエンジニア。 日々新しい技術を追い求めてブログでアウトプットしています。
プロフィール画像は、猫村ゆゆこ様に書いてもらいました。

仕事募集もしていたり、していなかったり。

QooQ