3
1

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

ASP.NET Core で配列プロパティへのバインドがnullになる問題を解決するModelBinderの作成

Last updated at Posted at 2023-07-04

前提

$.postで空の配列をPOSTしてASP.NET Core側でstring[] propで受けようとすると、propにはnullが入ってしまう。

これはなぜかというと、$.postもASP.NET Coreも(規定では)application/x-www-form-urlencoded、つまりForm形式でデータを送信/受信する為だ。
application/x-www-form-urlencodedは配列があった時、次のようにデータを送る。

prop[0]=xxx
prop[1]=xxx
prop[2]=xxx
:

よって、配列が空ならば、そもそもpropそのものが送信されない(送信できない)。ので、受け側ではそのプロパティは何もバインドされず、初期値のまま(=null)になる、というわけだ。

その為、受け側のModelクラスで次のように初期化コードを書いていれば、その値がnullの代わりとなる。

Model.cs
class Model
{
	public string[] prop { get; set;} = Array.Empty<string>();
}

もしくは、Form形式ではなくJSON形式で送受信すれば、そもそも空配列が送信されないということが起こらず、正しく[]で初期化されるようになる。

MyController.cs
	public Task<IActionResult> MyAction([FromBody] Model model)
	{
		// 要求を処理
	}

詳しくはこちらの記事を参照。

しかしそのような対応ができない場合の為に、配列型のモデルに対して何もバインドされなかった場合に空配列で初期化するモデルバインダーを作成した。

各クラス定義

NullToEmptyModelBinder
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;

/// <summary>
/// 配列型にnullがバインドされた時、空配列で初期化するモデルバインダー
/// </summary>
public class NullToEmptyArrayModelBinder<T> : IModelBinder
{
    private readonly IModelBinder _fallbackBinder;

    public NullToEmptyArrayModelBinder(IModelBinder fallbackBinder)
    {
        _fallbackBinder = fallbackBinder ?? throw new ArgumentNullException(nameof(fallbackBinder));
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        // まず、リクエストデータをチェックします
        var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);

        if (valueProviderResult == ValueProviderResult.None)
        {
            // 送信されたデータが存在しない場合、空の配列を設定します
            bindingContext.Result = ModelBindingResult.Success(Array.Empty<T>());
        }
        else
        {
            // 送信されたデータが存在する場合、通常のバインディングを行います
            await _fallbackBinder.BindModelAsync(bindingContext);
        }
    }

}

このModelBinderはfollbackBinderを提供するModelBinderProviderも併せて次のように定義する。

NullToEmptyModelBinderProvider
using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

/// <summary>
/// 配列型にnullがバインドされた時、空配列で初期化するモデルバインダープロバイダ
/// </summary>
public class NullToEmptyArrayModelBinderProvider : IModelBinderProvider
{

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        // モデルが配列型の場合、NullToEmptyArrayModelBiderを用いる。その時、標準のArrayModelBinderをfollbackbinderとして渡す。
        if (context.Metadata.IsEnumerableType && context.Metadata.ElementType != null)
        {
            var elementType = context.Metadata.ElementType;
            var binderType = typeof(NullToEmptyArrayModelBinder<>).MakeGenericType(elementType);
            var mvcOptions = context.Services.GetRequiredService<IOptions<MvcOptions>>().Value;
            var arrayBinderProvider = mvcOptions.ModelBinderProviders.FirstOrDefault(x => x.GetType() == typeof(ArrayModelBinder<>).MakeGenericType(elementType));
            return arrayBinderProvider != null ? (IModelBinder)Activator.CreateInstance(binderType, arrayBinderProvider.GetBinder(context)) : null;
        }

        return null;
    }

}

そしてこれを次のように登録する。

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers(options =>
    {
        // 追加: NullToEmptyArrayModelBinderProviderを最初に追加します
        options.ModelBinderProviders.Insert(0, new NullToEmptyArrayModelBinderProvider());
    });

    // その他のサービス設定...
}

あとがき

せっかく作った(大半はChatGPT-4との共作)のだが、最終的に使わないことになった(システムの既存部分への影響が大きすぎた為)ので、供養としてここにまとめる。

結局、アクションメソッドの最初に、対象のプロパティのnullチェックをして空配列を設定するコードを追加する地道な方法で対処した。

MyController.cs
    public Task<IActionResult> MyAction(Model model)
    {
    	// nullなら空配列で初期化
    	model.MyList ??= Array.Empty<string>();
    
        // 省略
    }

[FromForm]においては、配列プロパティに対してデータが送信されなかった場合、[]としてマッピングするオプションがあっても良いと思う。

ASP.NET Coreの設計者はモデルクラスに初期化コードを書けば済むと考えているのかもしれないが、モデルクラスが自動生成だったり外部から提供されていたりすると手が出せないこともある。

その場合は、やはりマッピングルール側で制御したいのだが、現状だと上記のような特殊なモデルバインダーを作る必要があり、手間が過ぎるし、パフォーマンスも心配になる。

結構いろんな人が苦労している(どうしても配列がnullになってしまうので毎回nullチェックをしたり、nullの場合に空配列を返す拡張メソッドやプロパティを定義したりなど)気がする。

追記:局所的にnullを空配列で初期化する為のアクションフィルタ属性

その後、いちいち次のように書いているのがやはりばからしくなってきた。

MyController.cs
    public Task<IActionResult> MyAction(Model model)
    {
    	// nullなら空配列で初期化
    	model.MyList ??= Array.Empty<string>();
    
        // 省略
    }

そこで、これを自動的に行う為の次のアクションフィルタ属性を作成した。

NullToEmptyEnumerableFilterAttribute.cs
/// <summary>
/// アクションメソッド引数のうち、配列やリストに対してnullが設定されていたら、空配列または空リストで初期化するフィルタ。
/// $.postで[]を送信した場合、アクションメソッドには何も送られてこない為、プロパティがnullになってしまうのを防ぎたい場合にアクションメソッドに指定する。
/// </summary>
public class NullToEmptyEnumerableFilterAttribute : ActionFilterAttribute
{
    /// <summary>
    /// アクション実行前フィルタ
    /// </summary>
    /// <param name="context"></param>
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        // 全てのアクションメソッド引数に対してフィルタを実行
        if (context?.ActionArguments != null)
        {
            var argumentKeys = context.ActionArguments.Keys.ToList();
            foreach (var key in argumentKeys)
            {
                context.ActionArguments[key] = NullToEmptyEnumerable(context.ActionArguments[key]);
            }
        }
    }

    /// <summary>
    /// 配列やリストに対してnullが設定されていたら、空配列または空リストで初期化するフィルタ。
    /// 自分自身のプロパティに対しても再帰的に処理する。
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    private object NullToEmptyEnumerable(object value)
    {
        var type = value?.GetType();
        if (type == null) return value;

        if (IsEnumerable(type))
        {
            if (value == null)
            {
                // IEnumerable且つnullならば、対応する型の空配列 or 空リストを返す
                var elementType = type.GetGenericArguments().FirstOrDefault();
                if (type.IsArray)
                {
                    // 空配列を生成
                    var arrayType = elementType.MakeArrayType();
                    return Activator.CreateInstance(arrayType, new object[] { 0 }); 
                }
                else
                {
                    // 空リストを生成
                    var listType = typeof(List<>).MakeGenericType(new Type[] { elementType });
                    return Activator.CreateInstance(listType);  
                }
            }
        }
        else if (type.IsClass)
        {
            if (value != null)
            {
                // クラス型且つnullでなければ、全プロパティに対して再帰的に呼び出す。
                var properties = type.GetProperties().Where(p => p.PropertyType.IsClass || IsEnumerable(p.PropertyType)).ToList();
                foreach (var property in properties)
                {
                    var propValue = property.GetValue(value);
                    property.SetValue(value, NullToEmptyEnumerable(propValue));
                }
            }
        }
        return value;
    }

    /// <summary>
    /// 型がIEnumerableか?
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private bool IsEnumerable(Type type)
    {
        return type.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>));
    }
}

次のように、アクションフィルタとして用いる。

MyController.cs
    [NullToEmptyEnumerableFilter]
    public Task<IActionResult> MyAction(Model model)
    {
    	// 省略
    }

繰り返しになるが、本来これはモデルクラスの初期化処理で対処すべき問題であるが、様々な理由からそれができない時の為の処置となる。

3
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1