ASP.NET MVC Web アプリケーションでユーザー入力の検証を行う場合、MVC2 以降ではデータアノテーション検証を標準で使用することができます。
データアノテーション検証とは、簡単に言えば、入力モデルのクラスの public プロパティにアノテーション属性を付与することによって、クライアントから送信されてきたデータをアクションメソッドのパラメータにバインド(モデルバインディング)する際に、付与した属性に従って検証を実行し、その結果を ModelStateDictionary(Controller.ModelState プロパティで取得できます)に格納することです。
記事としてはちょっと古いのですが、Microsoft が提供するチュートリアルの [C#] #21. データ アノテーション検証コントロールでの検証 がまとまっていて理解しやすいと思います。← このチュートリアルはリンク切れになってしまいました。別の記事「データ注釈検証コントロールに (c#) を使用した検証」を紹介しておきます。
上記のサーバーサイドの検証に加えて、MVC3 以降ではクライアントサイドでの jQuery ライブラリによる検証もデフォルトで実装されるようになっています。
モデルに付与したアノテーション属性と EditorFor などの Html ヘルパーが連動して、レンダリングされる html 要素(例: <input type="text" ... />)に jQuery ライブラリによる検証機能が働くように必要な属性(例: data-val="true" など)を追加します。
クライアントサイドで検証結果 NG になるとエラーメッセージを表示し、submit はキャンセルされます。それゆえ、検証結果 NG のときはクライアントとサーバーの間の無駄なやりとりは起こりません。
さて、ちょっと前置きが長くなりましたが、ここでの本題、入力モデルが複合型のコレクションで入れ子になっているような複雑な場合、モデルバインディングとデータアノテーション検証(クライアントサイドを含む)をどう実装できるかという話を以下に書きます。
まずコレクションのモデルバインディングですが、それがうまく行われるようにするには、レンダリングされる html 要素の name 属性が連番のインデックスを含むようにします。
具体的には、name="prefix[index].Property" というパターンにします。prefix の部分にはアクションメソッドのパラメータ(仮引数)名が入ります。index は 0 から始まる連番です。数字の連続が途切れた場合は解析が停止し、0 から途切れる前までのデータが返されます。
マイクロソフト公式解説書の「プログラミング ASP.NET MVC」という本の 3.2.2 章に例がありますが、そこではビューで以下のようなコードで name 属性に連番のインデックスを持つ名前を設定しています。
var index = 0;
foreach (var country in Model.CountryList)
{
<fieldset>
<div>
<b>Name</b>
<br />
<input type="text"
name="countries[@index].Name"
value="@country.Name" />
<br />
<b>Capital</b>
<br />
<input type="text"
name="countries[@index].Details.Capital"
value="@country.Details.Capital" />
<br />
<b>Continent</b>
<br />
@{
var id = String.Format(
"countries[{0}].Details.Continent",
index++);
}
@Html.TextBox(id, country.Details.Continent)
<br />
</div>
</fieldset>
}
上記のコードで countries はコントローラーの当該アクションメソッドの IList<Country> 型の仮引数の名前と一致させています。そうすることにより、クライアントから送信されてきたデータが正しくモデルバインディングされ、アクションメソッドに引き渡されます。
次に、これにデータアノテーション検証(クライアントサイドを含む)を追加するにはどうすればいいでしょう?
必要なアノテーション属性を入力モデルに追加するだけでもサーバーサイドでの検証は可能になります。しかし、それだけではクライアントサイドでの検証は動きません。
なぜなら、ASP.NET がレンダリングする html 要素には、クライアントサイドでの jQuery ライブラリによる検証に必要な属性(例: data-val="true" など)は追加されないからです。
検証に必要な属性が追加されるようにするにはビューに EditorFor のような Html ヘルパーを使うことです。これにより name 属性に設定する連番のインデックスも ASP.NET が自動的に追加してくれます。
モデル、コントローラー、ビューのコード例は以下の通りです。これを動かした結果が上の画像です。
(1) Model
モデルには普通に RequiredAttribute 他必要なアノテーション属性を追加すれば良いです。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel.DataAnnotations;
namespace Mvc4App.Models
{
public class Country
{
public Country()
{
Details = new CountryInfo();
}
[Required(ErrorMessage = "{0} は必須")]
[StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
[Display(Name = "国名")]
public String Name { get; set; }
public CountryInfo Details { get; set; }
}
public class CountryInfo
{
[Required(ErrorMessage = "{0} は必須")]
[StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
[Display(Name = "首都")]
public String Capital { get; set; }
[Required(ErrorMessage = "{0} は必須")]
[StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
[Display(Name = "大陸")]
public String Continent { get; set; }
}
public class ListCountriesViewModel
{
public ListCountriesViewModel()
{
CountryList = new List<Country>();
SelectedCountries = new List<Country>();
}
public IList<Country> CountryList { get; set; }
public IList<Country> SelectedCountries { get; set; }
}
}
(2) Controller
アクションメソッドの仮引数の名前は、本にあったコード例の countries から countrylist に変更しています。これは、モデルの ListCountriesViewModel クラスのプロパティ名 CountryList と合わせる必要があるためです。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using Mvc4App.Models;
namespace Mvc4App.Controllers
{
public class ComplexController : Controller
{
[ActionName("Countries")]
public ActionResult ListCountriesForGet()
{
var defaultCountries = GetDefaultCountries();
var model = new ListCountriesViewModel {
CountryList = defaultCountries };
return View(model);
}
[HttpPost]
[ActionName("Countries")]
public ActionResult ListCountriesForPost(
IList<Country> countrylist)
{
if (!ModelState.IsValid)
{
var model = new ListCountriesViewModel
{
CountryList = countrylist
};
return View(model);
}
else
{
var defaultCountries = GetDefaultCountries();
var model = new ListCountriesViewModel
{
CountryList = defaultCountries,
SelectedCountries = countrylist
};
return View(model);
}
}
private static IList<Country> GetDefaultCountries()
{
var countries = new List<Country>();
countries.Add(new Country() {
Name = "Italy",
Details = new CountryInfo {
Capital = "Rome",
Continent = "Europe" } });
countries.Add(new Country() {
Name = "Spain",
Details = new CountryInfo {
Capital = "Madrid",
Continent = "Europe" } });
countries.Add(new Country() {
Name = "USA",
Details = new CountryInfo {
Capital = "Washington",
Continent = "NorthAmerica" } });
return countries;
}
}
}
(3) View
EditorFor(m => m.CountryList[0].Name) からは name="CountryList[0].Name" という name 属性が生成されます。それゆえ、コントローラーのアクションメソッドの仮引数の名前を countrylist にしています。(注:モデルバインディングでは大文字小文字は区別されません)
@model Mvc4App.Models.ListCountriesViewModel
@{
ViewBag.Title = "Countries";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@using (Html.BeginForm())
{
<h2>Select your favorite countries</h2>
for (int i = 0; i < Model.CountryList.Count; i++)
{
<fieldset>
<div>
@Html.LabelFor(m => m.CountryList[i].Name)
@Html.EditorFor(m => m.CountryList[i].Name)
@Html.ValidationMessageFor(m => m.CountryList[i].Name)
<br />
@Html.LabelFor(m => m.CountryList[i].Details.Capital)
@Html.EditorFor(m => m.CountryList[i].Details.Capital)
@Html.ValidationMessageFor(m => m.CountryList[i].Details.Capital)
<br />
@Html.LabelFor(m => m.CountryList[i].Details.Continent)
@Html.EditorFor(m => m.CountryList[i].Details.Continent)
@Html.ValidationMessageFor(m => m.CountryList[i].Details.Continent)
<hr />
</div>
</fieldset>
}
<input type="submit" value="Send" />
}
<hr />
<h2>Countries submitted</h2>
<ul>
@foreach (var country in Model.SelectedCountries)
{
<li>@country.Name</li>
}
</ul>
@section Scripts {
@Scripts.Render("~/bundles/jqueryval")
}