読者です 読者をやめる 読者になる 読者になる

自前でモデル バインドする

クロスワープの大鷲です。

ASP.NET MVC の強力な機能のひとつにモデル バインディングがあります。
フォームの入力値や URL を、アクション メソッドのパラメーターに変換してくれる機能ですね。

通常、これはアクション メソッドのパラメーターの型や名前を見て、ASP.NET MVC フレームワークがよしなにやってくれる仕事です。
しかし、私が現在開発中のアプリケーションでは、設定によってモデルの型が変わるというアーキテクチャを採用してしまいました。
そのため、アクション メソッドのパラメーターとしてモデルを受け取ることができません。
これは、大枠となる Web アプリケーション部分を変更することなく、データの入出力画面をアドイン的に追加したいと考えたためです。モデルの型は各画面固有なので、アクション メソッドの引数として固定できないのです。

というわけで今回は、アクション メソッドの中で、自前でモデル バインドする方法についてです。

なお、本記事で扱うのは、モデル バインドを行うタイミングの変更のみです。
ASP.NET MVC では、モデルの型やパラメーターごとに、使用するモデル バインダーを変更することや、モデル バインダーを自作することもできるのですが、それらは本記事では取り扱いません。

また、モデルの型は予めわかっているもの(アクション メソッドの処理の冒頭で、何らかの方法によって取得済みである)とします。*1

IModelBinder

モデル バインダーは IModelBinder インターフェイスを実装したオブジェクトです。
そのため、まずはこのオブジェクトをどうにかして取得する必要があります。
これは ModelBinders.Binders から得られます。

複数形になっていることからもわかるように、Binders プロパティの型はコレクション(ModelBinderDictionary)です。
先ほども言ったように、システムには特殊なケースのために複数のモデル バインダーが存在するからです。

今回、まずはそういった特殊ケースは考えないこととします。
そのためここでは ModelBinderDictionary.DefaultBinder を使います。

BindModel

IModelBinder インターフェイスのメンバーはただ一つ、BindModel メソッドだけです。
これを適切に呼び出してやれば、入力値をバインドしたモデル オブジェクトを返してくれます。

BindModel メソッドのパラメーターは 2 つ、ControllerContextModelBindingContext です。
ControllerContext の方は簡単に用意できます。コントローラーの ControllerContext プロパティから取得できるからです。
もう一方の ModelBindingContext は、ControllerContext のように出来合いのものを使うことはできなさそうです。

このあたり、デフォルトの(コントローラーのアクション メソッドを呼び出す前の)バインド処理はどうやっているのでしょうか。
アクション メソッド呼び出し前のモデル バインドをしているのは、ControllerActionInvoker.GetParameterValue です。
ソースはこちら
このメソッドのソースを参考に、ModelBindingContext のインスタンスを作ります。

とりあえず実装

というわけで、コードを書いてみます。
まず簡易版としては、こんな感じでしょうか(コントローラーのメソッドとして書いている想定です)。

private object BindModel(Type modelType)
{
  var bindingContext = new ModelBindingContext
  {
    ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(null, modelType),
    ModelState = this.ViewData.ModelState,
    ValueProvider = this.ValueProvider
  };

  var binder = ModelBinders.Binders.DefaultBinder;
  var model = binder.BindModel(this.ControllerContext, bindingContext);

  return model;
}

カスタム バインダーへの対応

さて、先にも少し書きましたが、ModelBinderAttributeModelBinderDictionary.Add 等によって、特定のモデル型にカスタム バインダーを関連付けることができます。
そうしたカスタム バインダーが必要な場合、DefaultBinder ではなく、ModelBinderDictionary.GetBinder メソッドを使ってバインダーを取得する必要があります。

GetBinder メソッドには 2 つのオーバーロードがあります。
追加で bool 引数を取る方は、指定したモデル型に対するバインダーが見つからない場合に、既定のモデル バインダーが取得されるかどうかを指定するようです。
ところで、bool 引数を取らない方は、既定のバインダーにフォールバックされるのでしょうか? ドキュメントには何の記述もありません。
MSDN はしばしばこういうことがあって困るのですが、メソッドのオーバーロードに、より詳細な引数を取る版と、デフォルト引数を提供する版がある場合、後者ではデフォルトの挙動を書いておいて欲しいと強く思います。

ドキュメントからわからないのであれば、仕方がないのでソースを見るしかありません。
ModelBinderDictionary のソースによると、既定値は true のようですね。

BindAttribute によるカスタマイズへの対応

さらに、モデル型に BindAttribute をつけることによって、バインドする方法を制御することもできます。
これまでに挙げたソースなどを参考にしつつ、カスタマイズにも対応したバインド処理を書くと、こんな感じになるでしょうか。
先程のサンプルはコントローラーのメソッドにしましたが、今回は複数のコントローラーから利用できるように拡張メソッドとして実装しました。
また、モデルを単一のクラスとしてだけでなく、各項目単位でもバインドできるように拡張しています。

public static class ControllerExtensions
{
  public static object BindModel(
    this ControllerBase controller,
    Type modelType)
  {
    return BindModel(controller, null, modelType);
  }

  public static object BindModel(
    this ControllerBase controller,
    string modelName,
    Type modelType)
  {
    var bindAttribute = modelType.GetCustomAttribute<BindAttribute>(true);

    string prefix = controller.ViewData.TemplateInfo.HtmlFieldPrefix;
    Predicate<string> propertyFilter = _ => true;

    if (bindAttribute != null)
    {
      if (string.IsNullOrEmpty(prefix))
      {
        prefix = bindAttribute.Prefix;
      }

      propertyFilter = propertyName => bindAttribute.IsPropertyAllowed(propertyName);
    }

    if (!string.IsNullOrEmpty(prefix))
    {
      if (string.IsNullOrEmpty(modelName))
      {
        modelName = prefix;
      }
      else
      {
        modelName = prefix + "." + modelName;  
      }
    }

    var bindingContext = new ModelBindingContext
    {
      ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(null, modelType),
      ModelState = controller.ViewData.ModelState,
      ValueProvider = controller.ValueProvider,
      ModelName = modelName,
      PropertyFilter = propertyFilter,
      FallbackToEmptyPrefix = !string.IsNullOrEmpty(prefix)
    };

    var binder = ModelBinders.Binders.GetBinder(modelType);
    var model = binder.BindModel(controller.ControllerContext, bindingContext);

    return model;
  }
}

PropertyFilter は、プロパティごとにバインドするかしないかを決めるもので、BindAttribute の Include プロパティExclude プロパティで指定できます。
この判定は IsPropertyAllowed メソッドに任せることができます。
同じプロパティを両方に指定すると、Exclude の方が勝つようです。

ModelName は、バインドするモデルの名前を指定します。
先の簡易版のコードでは、入力項目をすべて、単一のモデル オブジェクトにバインドする想定でしたが、こちらの改良バージョンでは、項目単位でのバインドをサポートしています。
IModelBinder.BindModel は内部的には、入力値の各項目を、モデル オブジェクトのプロパティやアクション メソッドのパラメーターにバインドする場合にも使用され、その場合は、ModelName に項目名(プロパティ名や引数名)が渡されます。
この名前を使って、(ValueProvider が)フォームや URL から値を取りだすわけです。
項目を指定せず、丸ごとモデル オブジェクトにバインドする場合は、ModelName は空で構いません。

Prefix は、例えば Partial View を使う際に、そのままではフィールドの名前が同じになってしまう時、各フィールド名にプレフィックスを付けて区別する用途などに利用します。
ModelName は指定しない(項目単位ではなく丸ごとバインドする)けれど Prefix は使用する場合、ModelName に Prefix を指定します。
ModelName と Prefix の両方を使用する場合は、両者をドットで繋いで ModelName に指定しています。

今のところ、この実装で問題は出ていませんが(もともと、そんなに複雑な使い方をしていないので、最初の簡易版で十分ではあるのですが)、ここはこうした方が良いといったご指摘がありましたらお寄せください。

*1:現在開発中のアプリケーションではデータベースから取って来ています