ALL半角スペースがnullでモデルバインドされてしまうのをカスタムモデルバインドで回避する(ASP.NET 6 CORE MVC)

概要

ASP.NET Framework4では、画面でALL半角スペースを入力すると、入力ありと判定され、モデルバインドによって入力モデルにALL半角スペースがセットされたが、ASP.NET 6 Coreでは、入力なしと判定され、nullがセットされてしまう。

大抵は上記の動作で問題ないのだが、移行プロジェクトの場合、今までと同じ挙動であることが求められるためなんとかしなければならない。
解決方法は、カスタムモデルバインダーを作成し、ALL半角スペースの場合にnullに変換せずにそのままモデルにバインドする。

なぜ.NET6 CoreでALL半角スペースが入力なしと判定されるかというと、.NET6標準のモデルバインダーの「SimpleTypeModelBinder」が入力値をstring.IsNullOrWhiteSpaceで判定しているからである。
なので、「SimpleTypeModelBinder」をもとにしてカスタムモデルバインダーを作成する。

なお、RequiredAttributeのほうは、.NET Framework4の時代からALL半角スペースの場合に入力なしと判定していた。
なのでRequiredがついている項目については上記対応を行う必要はない。

モデルバインダーとモデルバインダープロバイダーの仕組み

モデルバインダーの仕事は画面から入力された値をバインド先のモデルにセットすることと、ModelStateにセットすることである。
入力値のValidateはモデルバインダーでは行わない。

モデルバインダーは複数あり、バインド先のモデルの型毎(および条件毎)に使用するモデルバインダーを選択することができる。
今回問題になるのはモデルの型がstringの場合なので、バインド先のモデルの型がstringの場合だけカスタムモデルバインダーが適用されるようにする。
「この型だったらこのモデルバインダーを使ってね」というのを登録するのがモデルバインダープロバイダーである。

Program.csに、バインド先がstringだった場合にカスタムモデルバインダーを返却するモデルバインダープロバイダーを追加することでALL半角スペースの値のバインドが可能になる。
なので、やることは以下の3つである。
・カスタムモデルバインダーを作成する
・カスタムモデルバインダーを返却するモデルバインダープロバイダーを作成する
・モデルバインダープロバイダーをProgram.csに追加する

カスタムモデルバインダー

説明はソースのコメントを参照のこと
AllSpaceStringModelBinder.cs

#nullable enable

using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace TestWebCore3.Filters
{
    /// <summary>
    /// All半角スペース対応Stringモデルバインダー
    /// バインド先モデルがstringの場合のみ処理する
    /// </summary>
    public class AllSpaceStringModelBinder : IModelBinder
    {
        /// <summary>
        /// コンストラクター
        /// </summary>
        public AllSpaceStringModelBinder()
        {
        }

        /// <summary>
        /// バインド処理
        /// </summary>
        /// <param name="bindingContext">バインド元の値</param>
        /// <returns></returns>

        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            ArgumentNullException.ThrowIfNull(bindingContext);

            // 本家「SimpleTypeModelBinder」ではモデルの値をDEBUGモードでログに出力しているが
            // 本クラスでは不要なので出力しない
            // MvcCoreLoggerExtensions.AttemptingToBindModelがinternalなので使えない
            // _logger.AttemptingToBindModel(bindingContext);
            
            // 画面の入力値を取得する
            var valueProviderResult = bindingContext.ValueProvider
                .GetValue(bindingContext.ModelName);

            // 値が取得できなかった場合はスキップする
            if (valueProviderResult == ValueProviderResult.None)
            {
                //_logger.FoundNoValueInRequest(bindingContext);

                // no entry
                //_logger.DoneAttemptingToBindModel(bindingContext);
                return Task.CompletedTask;
            }

            // ModelStateに入力値を設定する
            bindingContext.ModelState.SetModelValue(bindingContext.ModelName
                , valueProviderResult);

            // 入力値の先頭の値を取得する
            // 同じname属性の値は配列にセットされるが、モデルバインドする際は常に先頭の値を使用する
            // (CheckBoxなどのようにCheckBox自身の値と、CheckBoxをチェックしなかったときのHidden項目の値で
            //  同じname属性で2つ値がセットされるような場合を想定)
            string? value = valueProviderResult.FirstValue;

            object? model;

            // ConvertEmptyStringToNullはDisplayFormatAttributeで設定することができる。
            // ConvertEmptyStringToNullのデフォルトはtrueで、trueの場合は""をnullに置換する
            // falseの場合は置換しない
            // 本家「SimpleTypeModelBinder」は、IsNullOrWhiteSpaceを使っているため
            // "  "もnullに置換してしまう
            // 画面からの未入力の入力値は""で設定されるため、
            // ConvertEmptyStringToNull=falseでは""をnullに置換できない
            // (.NET Framework4 MVC5と動作が変わってしまう)
            if (bindingContext.ModelMetadata.ConvertEmptyStringToNull
                && string.IsNullOrEmpty(value))
            {
                model = null;
            }
            else
            {
                model = value;
            }

            // バインド先のモデルがnull非許容型でmodelがnullの場合はエラーにする
            // 問題なければmodelの値をbindingContext.Resultにセットする
            // bindingContext.Resultにmodelをセットすると、return先で実際にバインド先の
            // モデルに値が設定される
            CheckModel(bindingContext, valueProviderResult, model);

            //_logger.DoneAttemptingToBindModel(bindingContext);
            return Task.CompletedTask;
        }

        /// <summary>
        /// バインド先のモデルがnull非許容型でmodelがnullの場合はエラーにする
        /// 問題なければmodelの値をbindingContext.Resultにセットする
        /// </summary>
        /// <param name="bindingContext">コンテキスト</param>
        /// <param name="valueProviderResult">入力値</param>
        /// <param name="model">入力値から取り出した値</param>
        protected virtual void CheckModel(
            ModelBindingContext bindingContext,
            ValueProviderResult valueProviderResult,
            object? model)
        {
            /// バインド先のモデルがnull非許容型でmodelがnullの場合はエラーにする
            if (model == null && !bindingContext.ModelMetadata.IsReferenceOrNullableType)
            {
                bindingContext.ModelState.TryAddModelError(
                    bindingContext.ModelName,
                    bindingContext.ModelMetadata.ModelBindingMessageProvider
                        .ValueMustNotBeNullAccessor(
                        valueProviderResult.ToString()));
            }
            else
            {
                // モデルの値をModelBindingResult型に変換して返却する
                // そうすることでmodelの値がバインド先のモデルにセットされる
                bindingContext.Result = ModelBindingResult.Success(model);
            }
        }
    }
}

モデルバインダープロバイダー

説明はソースのコメントを参照のこと
AllSpaceStringModelBinderProvider.cs

#nullable enable

using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace TestWebCore3.Filters
{

    /// <summary>
    /// All半角スペース対応Stringモデルバインダプロバイダ
    /// </summary>
    public class AllSpaceStringModelBinderProvider : IModelBinderProvider
    {
        /// <summary>
        /// モデルバインダ返却
        /// </summary>
        /// <param name="context">コンテキスト</param>
        /// <returns>モデルバインダ</returns>
        public IModelBinder? GetBinder(ModelBinderProviderContext context)
        {
            ArgumentNullException.ThrowIfNull(context);

            // バインド先のモデルがstringの場合のみAllSpaceModelBinderを使用する
            // そのほかの型の場合は他のモデルバインダ(SimpleTypeModelBinder)が処理する
            // モデルバインダプロバイダはProgram.csで、
            // AddControllersWithViews
            // (またはAddControllers またはAddMvc またはAddMvcCore)の
            // オプションとして追加する
            // なお追加する順番でモデルバインダプロバイダの呼び出し順が決まる
            if (context.Metadata.ModelType == typeof(string))
            {
                return new AllSpaceStringModelBinder();
            }
            // nullを返却すると他のモデルバインダープロバイダを呼び出してくれる
            return null;
        }
    }
}

プロバイダーの追加

Program.cs
抜粋

// 抜粋
var mvcBuilder = builder.Services.AddControllersWithViews(options =>
{
    options.ModelBinderProviders.Insert(0
        , new AllSpaceStringModelBinderProvider());
});