WebSurfer's Home

Filter by APML

RemoteAttribute による検証 (MVC5)

by WebSurfer 11. February 2022 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 6. February 2022 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

異なるフォルダのファイルをアップロード

by WebSurfer 9. July 2020 21:33

ブラウザを使って自分の PC の複数のフォルダから複数のファイルを一度にアップロードするにはどうしたらよいかという話を書きます。

複数ファイルをアップロード

html の input type="file" 要素を使うことが前提です。ブラウザがサポートしていれば input type="file" 要素に multiple="multiple" を追加すれば、ユーザーは複数のファイルを選択して一度にアップロードすることができます。

ファイルを選択する際、上の画像の[ファイル選択]ボタン(画像は Chrome の例です。ブラウザによって異なります)をクリックすると下の画像のダイアログが表示されますので、Shift キーや Ctrl キーを使って複数のファイルを選択できます。

送信するファイルの選択

その後[開く(O)]ボタンをクリックすると送信準備完了となり、form を method="post" で submit してやればサーバーに選択された複数のファイルが送信されます。

ただし、上の操作で送信するファイルを選択できるのは一回だけです。この後、もう一度同じ操作を行って別のファイルを追加することはできません。それをすると、前の操作で選択したファイルは送信対象には含まれなくなり、後の操作で選択したファイルのみが送信されるようになります。それは同じフォルダで繰り返し行う場合も異なるフォルダに移動して行う場合も同じです。

ということは、フォルダが異なるファイルを選択するには各フォルダに移動して選択操作を行わざるを得ませんが、送信できるのは最後の操作で選択したファイルのみになります。なので、複数のフォルダにある複数のファイルを一度にアップロードするのは普通のやり方(form を submit する方法)ではできないようです。

代案は HTML5 File APIFormData を利用し Ajax で送信することです。

以下に .NET Framework 版の ASP.NET MVC5 アプリの例を書いておきます。これは一番上の画像を表示したものです。詳しい説明はコードの中のコメントに書きましたのでそれを見てください。手抜きでスミマセン。

Model

MultipleUploadModels クラスをアクションメソッドの引数に設定すれば、送信されてきた複数ファイルは PostedFiles プロパティにモデルバインドされます。CustomField プロパティはファイル以外の追加情報を一緒に送信するためのものです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace Mvc5App.Models
{
    public class MultipleUploadModels
    {
        public string CustomField { get; set; }
        public IList<HttpPostedFileBase> PostedFiles { get; set; }
    }
}

View

input type="file" 要素と input type="button" 要素(ボタン)を配置します。form 要素を生成するための html ヘルパー Html.BeginForm の引数(特に enctype = "multipart/form-data" とすること)に注意してください。

@section Scripts { ... } 内の JavaScript / jQuery のコードが HTML5 File API と FormData を利用し Ajax で複数フォルダ内の複数ファイルを送信するものです。このスクリプトがこの記事のキモです

@model Mvc5App.Models.MultipleUploadModels

@{
    ViewBag.Title = "MultipleUpload2";
}

<h2>MultipleUpload2</h2>

@using (Html.BeginForm("MultipleUpload2", "File", 
    FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.AntiForgeryToken()

    <div class="form-horizontal">
        <h4>MultipleUploadModels</h4>
        <hr />
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })

        <div class="form-group">
            @Html.LabelFor(model => model.PostedFiles, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                <input id="mutiplefileupload" type="file" 
                       name="postedfiles" multiple="multiple" />
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="button" id="ajaxUpload" value="Ajax Upload"
                       class="btn btn-default" />
                <br />
                <div id="result"></div>
            </div>
        </div>
        
    </div>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")

    <script type="text/javascript">
        //<![CDATA[

        $(function () {
            // 複数回送信する場合、送信し終わったら fd をクリアして最初から
            // 始めないと、次に送信するとき前回選んだファイルも送信されダブ
            // ってしまう。このコードはそこは未対応なので注意。

            // ブラウザの HTML5 File API サポートを確認
            if (window.File && window.FileReader && window.FileList) {

                // CSRF 用のトークンを含んだ FormData オブジェクトを取得
                var fd = new FormData(document.querySelector("form"));

                // input type="file" 要素のオブジェクトを取得
                var fileUpload = document.getElementById("mutiplefileupload");

                // input type="file" 要素でダイアログを開いてファイルを選択
                // し[開く]ボタンをクリックすると、その都度 change イベン
                // トが発生する。それにリスナ(下のコードの function )を
                // アタッチして FormData オブジェクトを操作する
                fileUpload.addEventListener('change', function (e) {
                    // files プロパティで FileList オブジェクトを取得
                    var filelist = fileUpload.files;

                    // 一回の操作で複数のファイルを選択できるので、以下
                    // のようにループを回して
                    for (let i = 0; i < filelist.length; i++) {
                        // File オブジェクトを FormData に追加していく
                        fd.append("postedfiles", filelist[i]);
                    }
                });

                // [Ajax Upload] ボタンクリックの処置
                $('#ajaxUpload').on('click', function (e) {

                    // 追加データを以下のようにして送信できる。フォーム
                    // データの一番最後に追加されて送信される
                    fd.append("CustomField", "This is some extra data");

                    $.ajax({
                        url: '/file/multipleupload2',
                        method: 'post',
                        data: fd,
                        processData: false, // jQuery にデータを処理させない
                        contentType: false  // contentType を設定させない
                    }).done(function (response) {
                        $("#result").empty;
                        $("#result").html(response);
                    }).fail(function (jqXHR, textStatus, errorThrown) {
                        $("#result").empty;
                        $("#result").text('textStatus: ' + textStatus +
                            ', errorThrown: ' + errorThrown);
                    });
                });
            } else {
                $("#result").empty;
                $("#result").text('File API がサポートされてません。');
            }
        });
        //]]>
    </script>
}

Controller / Action Method

Ajax 呼び出しのみ可能としていますが、普通に input type="submit" ボタンをクリックして post した場合でもファイルのアップロードはできます。ただしその場合は最後のファイル選択操作で選んだファイルのみが送信されます。

using System.Web;
using System.Web.Mvc;
using Mvc5App.Models;
using System.IO;
using System.Collections.Generic;

namespace Mvc5App.Controllers
{
    public class FileController : Controller
    {
        public ActionResult MultipleUpload2()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult MultipleUpload2(MultipleUploadModels model)
        {
            if (!Request.IsAjaxRequest())
            {
                return Content("Ajax 呼び出しのみ可能");
            }

            string result = "";
            string customFiled = model.CustomField;
            IList<HttpPostedFileBase> postedFiles = model.PostedFiles;

            // ファイルが選択されてない場合でも postedFiles は null にならないし、
            // postedFiles.Count は 0 にならない(1 になる)。従い、以下のコード
            // では制御が else に飛ぶことはないので注意
            if (postedFiles != null && postedFiles.Count > 0)
            {
                foreach (HttpPostedFileBase postedFile in postedFiles)
                {
                    if (postedFile != null && postedFile.ContentLength > 0)
                    {
                        // アップロードされたファイル名を取得。ブラウザが IE の
                        // 場合 postedFile.FileName はクライアント側でのフル
                        // パスになることがあるので Path.GetFileName を使う
                        string filename = Path.GetFileName(postedFile.FileName);

                        // 保存ホルダの物理パス\ファイル名
                        string path = Server.MapPath("~/UploadedFiles") + 
                                      "\\" + filename;

                        // アップロードされたファイルを保存
                        postedFile.SaveAs(path);

                        result += filename + " (" + postedFile.ContentType + 
                                  ") - " + postedFile.ContentLength.ToString() +
                                  " bytes アップロード完了<br />";
                    }
                }
                result += "CustomField の文字列: " + customFiled;
            }
            else
            {
                result = "ファイルアップロードに失敗しました";
            }

            return Content(result);
        }
    }
}

注意した方がよさそうと思う点を以下に書いておきます。

  1. 複数回送信する場合、送信し終わったら View の JavaScript のコードにある fd をクリアして最初から始めないと、次に送信するとき前回選んだファイルも送信されダブってしまいます。上のコードはそこは未対応なので注意してください。
  2. ファイルを選択しないで送信した場合でも、上の Controller / Action Method のコードにある postedFiles は null にならないし、postedFiles.Count は 0 になりません(1 になる)。従い、上のコードでは "ファイルアップロードに失敗しました" というエラーメッセージは出ません。  
  3. View のコードにある fd.append("CustomField", "This is some extra data"); でデータを送信して Model の CustomField プロパティにバインドできます。テキストボックスを配置してユーザーに入力してもらい送信してモデルバインドすることも考えましたが、送信前に FormData オブジェクトに append するのが難しく、それは今後の検討課題です。
  4. 上の 2 に書いたファイルを選択しないで送信した場合でも postedFile が null にならず、postedFiles.Count は 1 になる理由は、内容が空のパート(下の Fiddler による要求のキャプチャ画像の 2 つ目のパート)が送信されてくるからです。普通にファイルを選択して input type="submit" ボタンクリックで送信すれば空のパートはなくなりますが、この記事のように File API と FormData を JavaScript で細工するとそれが残ってしまいます。

    想像ですが、var fd = new FormData(document.querySelector("form")); で下の画像の 2 つ目までのパートが取得され、その後の操作で 3 つ目以降が追加されていくからであろうと思います。空のパートを削除する方法は分かりませんでした。

要求ヘッダとコンテンツ

Tags: , , , ,

Upload Download

About this blog

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

Calendar

<<  December 2024  >>
MoTuWeThFrSaSu
2526272829301
2345678
9101112131415
16171819202122
23242526272829
303112345

View posts in large calendar