2013年6月16日日曜日

値オブジェクトのためのカスタムモデルバインダー

値オブジェクト

単一の値を表すオブジェクトのことで、いわいる不変オブジェクトのことです。(イミュータブルってやつですね)
例えばこんな感じのヤツですね。

バージョン情報 (e.g: "1.2", "11.101") を単一の値として扱う
public class Version
{
    public Version(int major, int minor)
    {
        Major = major;
        Minor = minor;
    }

    public int Major { get; private set; }

    public int Minor { get; private set; }

    public override string ToString()
    {
        return string.Format("{0}.{1}", Major, Minor);
    }
}

Major, Minor といったプリミティブ値を Version という型にまとめ、状態を変えれないようにすることで扱いがシンプルになります。

これはオブジェクト指向プログラミングのプラクティスとしてよく言われるものですが、ASP.NET MVC で不変オブジェクトを扱うと困ることがあります。

困る例

例えばこんなURLを使いたいと思って、アクションを用意するとします。

/Home/SomeAction?version=1.2
public ActionResult SomeAction(Models.Version version)
{
    ViewBag.Version = version; // version は null で、ModelState にもエラーが入る
    return View("SomeView");
}

当然 version 引数にはクエリパラメータで指定した "1.2" が Major と Minor に入ってきてほしいと思うわけですが、これはうまくいきません。

それもそのはずで、Version という型にどのように値を入れていいか、モデルバインダが知らないからです。

カスタムのモデルバインダを作る

せっかく値オブジェクトをコツコツ作っても、モデルバインダで使えないのでは寂しいので、カスタムのモデルバインダを作りましょう。

上記の Version 型の場合はこんな感じのモデルバインダを作ります。

public class VersionBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var valueKey = bindingContext.ModelName;
        var valueResult = bindingContext.ValueProvider.GetValue(valueKey);
        string rawValue = null;

        if (valueResult != null)
            rawValue = valueResult.AttemptedValue;

        if (string.IsNullOrEmpty(rawValue))
            return null;

        Version result;

        if (Version.TryParse(rawValue, out result))
        {
            return result;
        }
        else
        {
            bindingContext.ModelState.AddModelError(
                valueKey,
                string.Format("'{0}' は無効な値です。", rawValue)
                );

            return null;
        }
    }
}

実際の文字列からの変換は TryParse パターン とかで適当に実装します。

上記のように作ったモデルバインダを、Application_Start 時に登録します。
App_Start ディレクトリに、以下の様な起動用クラスをまとめるといいと思います。

public class BinderConfig
{
    public static void RegisterBinders(ModelBinderDictionary binders)
    {
        binders.Add(typeof(Models.Version), new VersionBinder());
    }
}

これを Global.asax の Application_Start メソッドで呼んであげます。

protected void Application_Start()
{
    // その他いろいろな起動設定...
    BinderConfig.RegisterBinders(ModelBinders.Binders);
    // その他いろいろな起動設定...
}

これで Version 型の値がしっかりモデルバインディングされます。

これたくさん作るの面倒だよね?

モデルバインディングしたい値オブジェクトがたくさんあったら、一つ一つカスタムのモデルバインダを実装するのは恐ろしく面倒です。

コードも定形なのでスーパークラスにまとめると楽です。

public abstract class ValueObjectBinder<T> : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var valueKey = bindingContext.ModelName;
        var valueResult = bindingContext.ValueProvider.GetValue(valueKey);
        string rawValue = null;

        if (valueResult != null)
            rawValue = valueResult.AttemptedValue;

        if (string.IsNullOrEmpty(rawValue))
            return null;

        T result;

        if (TryParse(rawValue, out result))
        {
            return result;
        }
        else
        {
            bindingContext.ModelState.AddModelError(
                valueKey,
                GetParseErrorMessage(rawValue)
                );

            return null;
        }
    }

    protected abstract bool TryParse(string input, out T result);

    protected virtual string GetParseErrorMessage(string rawValue)
    {
        return string.Format("'{0}' は無効な値です。", rawValue);
    }
}

さっきの Version 型の場合、コレを使うとこうなります。

public class VersionBinder : ValueObjectBinder<Version>
{
    protected override bool TryParse(string input, out Version result)
    {
        return Version.TryParse(input, out result);
    }
}

これならまあ作ってもいいかなってレベルじゃないでしょうか。もし大規模なアプリケーションでこれでも大変なら、T4 とか使う手もありそうです。

まとめ

これで値オブジェクトをたくさん使えるよ。

TFT 10.14 Peeba Comp

こちらのガイドの自分用まとめです。 https://www.reddit.com/r/CompetitiveTFT/comments/hraunp/tft_1014_break_the_meta_new_peeba_comp_set_35/ 難しいですが完成すると非常に強く、プレ...