ASP.NET MVC 5.2 で導入された RemoteAttribute クラスを使ってみました。
サーバー側でなければユーザー入力の検証ができないケースがあります。例えばデータベースに存在する名前との重複を確認するなど。RemoteAttribute 属性を使えばそのようなケースでのクライアント側での検証が可能になるそうです。
どのような仕組みかを簡単に書くと、JavaScript で Ajax を使って検証用のアクションメソッドを呼び出し、返ってきた検証結果をクライアント側での検証に反映するというものです。詳しくは Microsoft のドキュメント「ASP.NET Core MVC および Razor Pages でのモデルの検証」の [Remote] 属性のセクションを見てください。
その記事に書かれているのは Microsoft.AspNetCore.Mvc 名前空間の RemoteAttribute クラスで ASP.NET Core 用ですが、.NET Framework でも ASP.NET MVC 5.2 以降であれば System.Web.Mvc 名前空間に同様な検証属性が用意されています。
実は最近までその存在を知りませんでした。(汗) 試しに使ってみましたので備忘録としてこの記事を書いた次第です。
上の画像を表示したサンプルコードを下に載せておきます (View は省略)。品名テキストボックスへのユーザー入力と Northwind サンプルデータベースの Products テーブルの ProductName フィールドにある品名との重複をチェックし、重複していたら検証結果を NG にしています。
Model
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;
namespace Mvc5App.Models
{
// RemoteAttribute を使用
public class ProductInfo3
{
[Display(Name = "品名")]
[Required]
[Remote(action: "VerifyName", controller: "Validation")]
public string Name { get; set; }
[Display(Name = "単価")]
[Required]
public decimal UnitPrice { get; set; }
}
}
Controller / Action Method
using System.Web.Mvc;
using System.Threading.Tasks;
using Mvc5App.Models;
using System.Data.Entity;
namespace Mvc5App.Controllers
{
public class ValidationController : Controller
{
private NORTHWINDEntities db = new NORTHWINDEntities();
public ActionResult Index()
{
return View();
}
// RemoteAttribute を使ってみる
[AcceptVerbs("GET", "POST")]
public async Task<ActionResult> VerifyName(string name)
{
// 応答に時間がかかる時どうなるかの検証用
//await Task.Delay(5000);
// サーバーエラーが起こるとどうなるかの検証用
//throw new System.Exception();
Products product = await db.Products
.FirstOrDefaultAsync(m => m.ProductName == name);
if (product != null)
{
return Json($"品名 {name} は重複しています",
JsonRequestBehavior.AllowGet);
}
return Json(true, JsonRequestBehavior.AllowGet);
}
public ActionResult Create4()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Create4(ProductInfo3 model)
{
Products product = await db.Products
.FirstOrDefaultAsync(m => m.ProductName == model.Name);
if (product != null)
{
ModelState.AddModelError("Name",
$"品名 {model.Name} は重複しています");
}
if (ModelState.IsValid)
{
// 検証 OK なら Create 処理して Index にリダイレクト
db.Add(model);
await db.SaveChangesAsync();
return RedirectToAction("Index");
}
return View(model);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
db.Dispose();
}
base.Dispose(disposing);
}
}
}
RemoteAttribute が上の VerifyName アクションメソッドを呼び出して検証を行うのですが、その際クエリ文字列を使って品名テキストボックスへのユーザー入力を送信しています。サーバーへの要求は Ajax を使って行っています。下の画像はその時の要求ヘッダを Fiddler で見たものです。
上のサンプルコードのコメントに書いたように await Task.Delay(5000) を使って応答に時間がかかる時はどうなるかを調べてみました。
応答が返ってくる前に[Create]ボタンをクリックしても submit されることはなく (クリックしても無視される)、RemoteAttribute により検証結果 OK という応答が戻ってきてからクリックすると submit されます。
先に「CustomValidator で jQuery.ajax 利用」で jQuery Ajax を使って async オプションを false に設定して検証を行う記事を書きましたが、その際問題になった応答が返ってくるまでユーザー入力・操作ができなくなるということもありませんでした。どのようにしているのか不明ですが、そのあたりはうまく考えられているようです。
ただし、VerifyName アクションメソッドでサーバーエラーが発生した場合 (HTTP 500 応答とエラーメッセージは返ってくる)、何らかの問題で応答が返ってこなかった場合は、検証中とみなされるらしく何のメッセージも表示されないままそこで止まってしまいます。
サーバーエラーに対しては以下のように try - catch を使って例外がスローされたら catch 句で JSON 文字列を返してやることで対応できそうです。
[AcceptVerbs("GET", "POST")]
public async Task<ActionResult> VerifyName(string name)
{
try
{
Products product = await db.Products
.FirstOrDefaultAsync(m => m.ProductName == name);
if (product != null)
{
return Json($"品名 {name} は重複しています",
JsonRequestBehavior.AllowGet);
}
}
catch(Exception)
{
return Json("サーバーエラーで検証失敗",
JsonRequestBehavior.AllowGet);
}
return Json(true, JsonRequestBehavior.AllowGet);
}
応答が返ってこないことに対しては、タイムアウトの設定などで検証中で止まってしまう問題を回避できないか調べてみましたが、自分が調べた限りでは RemoteAttribute にはそのようなオプションは見つからなかったです。
なので、応答に時間がかかるとか返ってこないということがよく起こる環境では、RemoteAttribute は使わないでサーバー側だけでの検証にとどめておいた方が良いかもしれません。
ただし、検証を行うのが CancellationToken を引数に渡せる非同期メソッド主体の場合は、CancellationTokenSource.CancelAfter メソッドを使ったコードを書いて対応できるかもしれません。具体例は以下のようになります。
[AcceptVerbs("GET", "POST")]
public async Task<ActionResult> VerifyName(string name)
{
try
{
using (var cts = new CancellationTokenSource())
{
// 5 秒でタイムアウトに設定
cts.CancelAfter(5000);
CancellationToken token = cts.Token;
Products product = await db.Products
.FirstOrDefaultAsync(m => m.ProductName == name,
token);
// 応答に時間がかかる時どうなるかの検証用。
await Task.Delay(10000, token);
// 以下は無くても上の Task.Delay でタイムアウトして
// OperationCanceledException がスローされる
token.ThrowIfCancellationRequested();
if (product != null)
{
return Json($"品名 {name} は重複しています",
JsonRequestBehavior.AllowGet);
}
}
}
catch (OperationCanceledException)
{
return Json("タイムアウトで検証失敗",
JsonRequestBehavior.AllowGet);
}
catch(Exception)
{
return Json("サーバーエラーで検証失敗",
JsonRequestBehavior.AllowGet);
}
return Json(true, JsonRequestBehavior.AllowGet);
}