WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

RemoteAttribute による検証 (MVC5)

by WebSurfer 2022年2月11日 13:23

ASP.NET MVC 5.2 で導入された RemoteAttribute クラスを使ってみました。

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);
}

Tags: , ,

Validation

jQuery.ajax を使っての事前検証

by WebSurfer 2022年2月6日 15:56

ASP.NET Web Forms アプリで jQuery Ajax を使ってポストバックの際にサーバー側で行われる処理が可能かどうかを事前に調べ、可能と分かったら JavaScript で Button クリックしてポストバックする、可能ではないと分かった場合はポストバックせずメッセージを表示してユーザーに知らせるという話です。

ポストバックして処理完了

元の話は Teratail のスレッド「asp:button 押下前にサーバサイドが応答しているか確認したいためPingのようなことを行いたい」のものです。実際のそのような検証が必要なケースはあまりなさそうですが、せっかく考えた話ですし jQuery Ajax の timeout の使い方が今後の参考になるかもしれないと思ったので備忘録として書いておくことにしました。

Button クリックでポストバックしてサーバー側でユーザーが送信したデータを受けて、データベースサーバーにクエリを投げる等の処理をすることはよくあると思います。

その際、Button クリックでいきなりポストバックするのではなく、その前に jQuery Ajax を使って事前にサーバー側での処理が可能かどうかを調べるというストーリーです。

(いきなりポストバックしても結果をきちんとユーザーに知らせることはできるし、やりすぎるとサーバーに無駄な負荷を増やすし軽くすると検証の意味が薄れるということで、そのような検証の必要性は低いとは思いますがそこは置いときます)

jQuery Ajax で呼び出してサーバー側で事前検証を行う HTTP ジェネリックハンドラを作ります。以下のコードがそのサンプルです。

HTTP ジェネリックハンドラ Handler1.ashx

using System.Web;

namespace WebForms1
{
    public class Handler1 : IHttpHandler
    {

        public void ProcessRequest(HttpContext context)
        {
            // Button クリックでポストバックされた時にサーバー
            // で行われる処理が可能か事前に検証する。処理に最
            // 長 3 秒かかるとして、ここでは単純に 3 待つ
            System.Threading.Thread.Sleep(3000);
            
            // 検証に成功したら "検証成功" という文字列を返す
            var validationResult = "検証成功";

            context.Response.ContentType = "text/plain";
            context.Response.Write(validationResult);
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}

上の Handler1.ashx を jQuery Ajax を使って呼び出します。Handler1.ashx は検証に成功すると "検証成功" という文字列を返すので、それを受けたら JavaScript で Button をクリックしてポストバックします。その結果がこの記事の一番上にある画像です。

Handler1.ashx を呼び出す JavaScript を含む Web Form ページのサンプルコードは以下の通りです。

WebForm1.aspx

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="WebForm1.aspx.cs"
    Inherits="WebForms1.WebForm1" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title></title>
    <script src="Scripts/jquery-3.4.1.js"></script>
    <script type="text/javascript">
        $(function () {
            $("#btnSec").on("click", function () {
                $.ajax({
                    type: "get",
                    url: "Handler1.ashx",
                    timeout: 5000
                }).done(function (data) {
                    // 呼び出し先 Handler1.ashx で検証が成功すると "検証成功" 
                    // という文字列が返ってきて引数 data に代入される
                    if (data == "検証成功") {
                        var hiddenbutton = document.getElementById("Button1");
                        // Button をクリック
                        hiddenbutton.click();
                    } else {
                        $("#Label1").text("処理できません - 検証結果 NG");
                    }
                }).fail(function (jqXHR, textStatus, errorThrown) {
                    $("#Label1").text("処理できません - " + textStatus);
                });
            });
        });
    </script>
    <style type="text/css">
        .style1
        {
            display: none;
        }
    </style>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <h2>WebForm1 - jQuery.Ajax を使っての事前検証</h2>
            
            <input id="btnSec" type="button" value="反映" />

            <%--ポストバックを行う Button コントロール。上の style1 で
                隠しボタンに設定。クリックは JavaScript で行う。クリ
                ックされるとポストバックが発生し、サーバー側のイベント
                ハンドラ ClickBtnSection で必要な処理が行われる--%>
            <asp:Button ID="Button1" runat="server" 
                OnClick="ClickBtnSection" CssClass="style1" />

            <asp:Label ID="Label1" runat="server">
                Label1 初期値
            </asp:Label>
        </div>
    </form>
</body>
</html>

WebForm1.aspx.cs

using System;

namespace WebForms1
{
    public partial class WebForm1 : System.Web.UI.Page
    {
        protected void ClickBtnSection(object sender, EventArgs e)
        {
            // サーバー側での必要な処理

            Label1.Text = "Button1 がクリックされました";
        }
    }
}

Handler1.ashx を呼び出して、そこでの処理中にサーバーエラーが発生した場合は以下のようになります。

サーバーエラー

Handler1.ashx を呼び出した後、jQuery Ajax の timeout オプションに設定した時間(上のコード例では 5 秒)待っても応答が返ってこない場合は以下のようになります。

timeout

Handler1.ashx から帰ってきた文字列が "検証成功" 以外の場合は(実際にそういうケースはないかもしれませんが)以下のようになります。

検証結果 NG

Tags: , , ,

Validation

CheckBox と DropDownList の検証 (CORE)

by WebSurfer 2021年3月7日 14:44

CheckBox には、例えばユーザーが条件を許諾したという意思表示のため、チェックを入れるのが必須というケースがあると思います。その検証をクライアント側とサーバー側の両方で行うためのカスタム検証属性を考えてみました。

CheckBox の検証

CheckBox の場合 RequiredAttribute ではクライアント側でもサーバー側でも検証はかかりません。RangeAttribute を使って [Range(typeof(bool), "true", "true")] というように設定するという手段がありますが、クライアント側での検証がかかりません。

というわけで、先の記事「ASP.NET Core MVC 検証属性の自作」とか「ファイルアップロード時の検証 (CORE)」で書いたようなカスタム検証属性を作って利用するのが良さそうです。

DropDownList にもそのようなカスタム検証属性を作ってみようと思いましたが、未選択で検証 NG とする場合は一番最初の option 要素が value="" となっていれば RequiredAttribute でクライアント/サーバー両方で検証がかかること、特定の項目を選ばなければならないというのは他の条件も絡むであろうから先の記事「CustomValidation 属性」で書いたようにすべきで、意味がなさそうなので止めました。

以下にそのコードをアップしておきます。上の画像を表示したものです。DropDownList 関係のコードもせっかく書いたので一緒に載せておきます。

Model とカスタム検証属性

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace MvcCoreApp.Models
{
    // ビューモデル
    public class Partner
    {
        [Required(ErrorMessage = "{0} is required.")]
        public string Name { get; set; }

        // Required 属性では検証はかからない
        [Required(ErrorMessage = "{0} is required.")]
        [CheckBoxValidate(true)]
        public bool PartnerAccepted { get; set; }

        [Required(ErrorMessage = "{0} is required.")]
        public string Area { get; set; }

        public SelectList AreaList { get; set; }
    }

    // DropDownList に渡す SelectList 生成用のクラス
    // AreaId は int? または string 型にしておけば、SelectList を
    // 生成する際、下の Controller のコード例のように null を設定
    // できる。結果 option value="" となり未選択で検証 NG とできる
    public class Area
    {
        public int? AreaId { set; get; }
        public string AreaName { set; get; }
    }

    // CheckBox 用の検証属性
    // true/false を引数で指定(要チェックなら true とする)
    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class CheckBoxValidateAttribute : ValidationAttribute, 
                                             IClientModelValidator
    {
        private readonly bool option;

        public CheckBoxValidateAttribute(bool option)
        {
            this.option = option;
            this.ErrorMessage = "{0} must be set to {1}.";
        }

        public override string FormatErrorMessage(string name)
        {
            return String.Format(CultureInfo.CurrentCulture, 
                                 ErrorMessageString, 
                                 name, 
                                 option);
        }

        public override bool IsValid(object value)
        {
            // テキストボックスに入力された文字列が検証対象の場合は
            // ここで value が空白か否かをチェックして空白の場合は
            // true を返すようにしていた(空白の検証は Required だけで
            // 行い、その他の検証属性は空白の場合は true を返すのが
            // 原則なので)。チェックボックスの場合は ASP.NET MVC では
            // 必ず true/false のいずれかが返されるようになっている。
            // なので null が帰ってきた場合は何か良からぬことが起こって
            // いるかもしれないということで例外をスローすることにした
            if (value == null) 
            {
                throw new ArgumentNullException("CheckBoxValidate");
            }

            if ((bool)value == option)
            {
                return true;
            }
            return false;
        }

        public void AddValidation(ClientModelValidationContext context)
        {
            MergeAttribute(context.Attributes, "data-val", "true");
            var errorMessage = 
                FormatErrorMessage(context.ModelMetadata.GetDisplayName());
            MergeAttribute(context.Attributes, 
                           "data-val-checkboxvalidate", 
                           errorMessage);
            MergeAttribute(context.Attributes, 
                           "data-val-checkboxvalidate-option", 
                           this.option.ToString());
        }

        // 上の AddValidation メソッドで使うヘルパーメソッド
        private bool MergeAttribute(IDictionary<string, string> attributes, 
                                    string key, 
                                    string value)
        {
            if (attributes.ContainsKey(key))
            {
                return false;
            }
            attributes.Add(key, value);
            return true;
        }
    }
}

Controller / Action Method

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using MvcCoreApp.Models;
using Microsoft.AspNetCore.Http;
using System.IO;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace MvcCoreApp.Controllers
{
    public class ValidationController : Controller
    {
        public IActionResult CheckBoxDropDown()
        {
            // DropDwonList に渡す SelectList の生成
            // AreaId を null に設定すると結果 option value="" となり
            // 未選択では RequiredAttribute による検証は NG となる
            var list = new List<Area>
            { 
                new Area { AreaId = null, AreaName = "--- Select One ---"},
                new Area { AreaId = 1, AreaName = "North" },
                new Area { AreaId = 2, AreaName = "South"},
                new Area { AreaId = 3, AreaName = "Eastt" },
                new Area { AreaId = 4, AreaName = "West" }
            };

            var partner = new Partner 
            { 
                AreaList = new SelectList(list, "AreaId", "AreaName")
            };

            return View(partner);
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult CheckBoxDropDown(Partner partner)
        {
            if (!ModelState.IsValid)
            {
                // 検証 NG で再表示する場合は SelectList も再生成要
                var list = new List<Area>
                {
                    new Area { AreaId = null, AreaName = "--- Select One ---"},
                    new Area { AreaId = 1, AreaName = "North" },
                    new Area { AreaId = 2, AreaName = "South"},
                    new Area { AreaId = 3, AreaName = "Eastt" },
                    new Area { AreaId = 4, AreaName = "West" }
                };

                partner.AreaList = new SelectList(list, "AreaId", "AreaName");

                return View(partner);
            }

            return RedirectToAction("Create", "Validation");
        }
    }
}

View

@model MvcCoreApp.Models.Partner

@{
    ViewData["Title"] = "CheckBoxDropDown";
}

<h1>CheckBoxDropDown</h1>

<h4>Pertner</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="CheckBoxDropDown">
            <div asp-validation-summary="ModelOnly" class="text-danger">
            </div>
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group form-check">
                <label class="form-check-label">
                    <input class="form-check-input" asp-for="PartnerAccepted" />
                    @Html.DisplayNameFor(model => model.PartnerAccepted)
                </label>
                <br />
                <span asp-validation-for="PartnerAccepted" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label asp-for="Area" class="control-label"></label>
                <select asp-for="Area" asp-items="Model.AreaList" 
                        class="form-control">
                </select>
                <span asp-validation-for="Area" class="text-danger"></span>
            </div>

            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}

<script type="text/javascript">
    //<![CDATA[
    $.validator.addMethod("checkboxvalidate",
        function (value, element, parameters) {
            // チェックされてないと value は undefined となり
            // value では判定できないので注意
            var result = element.checked;
            var isTrue = (parameters.toUpperCase() == 'TRUE');
            if (result == isTrue) {
                return true;
            }
            return false;
        });

    $.validator.unobtrusive.adapters.
        addSingleVal('checkboxvalidate', 'option');

    //]]>
</script>
}

Tags: , , , ,

Validation

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年5月  >>
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar