WebSurfer's Home

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

コレクションのデータアノテーション検証

by WebSurfer 2014年9月1日 18:25

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

Tags: ,

Validation

Web サービス経由で非接続型データアクセス

by WebSurfer 2014年8月23日 16:50
2018/3/4 追記:
Microsoft によると Web サービスは "Legacy Technology" なので WCF を使えとのことゆえ、接続先を WCF サービスに変更し、非同期にアクセスするサンプルを作ってみました。興味がありましたら記事「WCF サービス経由で非接続型データアクセス」を見てください。

DataGridView と型付 DataSet + TableAdapter を用いて非接続型データアクセスを行う Windows アプリケーションで、Web サービス経由で SQL Server のデータの取得更新を行う例の紹介です。(Windows アプリから直接 SQL Server に接続するのではなく)

DataGridView

Visual Studio のデータソース構成ウィザードを使うと、MSDN ライブラリの Windows フォーム アプリケーションでのデータへの接続 の図のような構成の Windows アプリケーションを作成できます。具体的な作成手順例は、チュートリアル「10 行でズバリ !! 非接続型のデータ アクセス」を見てください。

上に紹介したチュートリアルの例では Windows アプリから直接 SQL Server に接続していますが、セキュリティ上の問題などで、Windows アプリ ⇔ サーバー ⇔ SQL Server というようにサーバーを介してアクセスしたいということがあると思います。

Web サーバー (IIS) を利用し、それに Web サービス(.asmx)を実装して、Windows アプリケーションとの間で型付 DataSet をやりとりすることで Windows アプリ ⇔ サーバー ⇔ SQL Server という構成を実現できます。

(1) Web サービス

まず Web サービスですが、(a) SQL Server から型付 DataSet にデータを取得して Windows アプリケーション渡すメソッド、(b) ユーザーが編集済みの型付 DataSet を受け取って SQL Server のレコードを更新するメソッドを実装します。

具体例は以下のようになります。GetDataSet メソッドが上記 (a)、Update メソッドが上記 (b) に該当します。

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

namespace WebApplication1
{
  using ProductsDataSetTableAdapters;
    
  [WebService(Namespace = "http://tempuri.org/")]
  [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
  [System.ComponentModel.ToolboxItem(false)]
  public class ProductsWebService : WebService
  {
    [WebMethod]
    public ProductsDataSet GetDataSet()
    {
      ProductsDataSet dataset = new ProductsDataSet();
      ProductsTableAdapter adapter = 
                          new ProductsTableAdapter();            
      adapter.Fill(dataset.Products);
      return dataset;
    }

    [WebMethod]
    public int Update(ProductsDataSet dataset)
    {
      TableAdapterManager manager = new TableAdapterManager();
      manager.ProductsTableAdapter = new ProductsTableAdapter();
      return manager.UpdateAll(dataset);
    }
  }
}

SQL Server データベースには、Microsoft が無償で提供しているサンプルデータベース Northwind の Products テーブルを使っています。

ProductsDataSet, ProductsTableAdapter, TableAdapterManager は、 Visual Studio のデータソース構成ウィザードを使って自動生成させた .xsd ファイル下の .Designer.cs に含まれる型付 DataSet + TableAdapter + TableAdapterManager です。

(2) クライアント・アプリケーション

上記の Web サービスをクライアント・アプリケーションでは以下のように利用できます。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace UpdateUsingWebService
{
  public partial class Form2 : Form
  {
    private ProductsServiceReference.
                        ProductsWebServiceSoapClient client;
    private ProductsServiceReference.
                        ProductsDataSet productsDataSet;

    public Form2()
    {
      InitializeComponent();
      InitializeComponent2();
      this.client = new ProductsServiceReference.
                            ProductsWebServiceSoapClient();
    }

    private void productsBindingNavigatorSaveItem_Click(
        object sender, EventArgs e)
    {
      this.Validate();
      this.productsBindingSource.EndEdit();
            
      // Web サービスに渡すのはユーザーが編集した行のみで可
      ProductsServiceReference.ProductsDataSet ds =
                  (ProductsServiceReference.ProductsDataSet)
                           this.productsDataSet.GetChanges();

      // 編集済みの型付 DataSet を Web サービスに渡し、更新
      // をかける。戻り値 n は更新されたレコード数
      int n = this.client.Update(ds);

      // 更新後の型付 DataSet を取得。DataGridView 書き換え
      this.productsDataSet = this.client.GetDataSet();
      this.productsBindingSource.DataSource = 
                                    this.productsDataSet;
    }

    private void Form2_Load(object sender, EventArgs e)
    {
      // Web サービスより型付 DataSet を取得
      this.productsDataSet = this.client.GetDataSet();
      this.productsBindingSource.DataSource = 
                                     this.productsDataSet;
    }

    // ・・・中略・・・

    private void InitializeComponent2()
    {
      // ・・・中略・・・
    }

  }
}

まず、Visual Studio のサービス参照の追加ウィザードを利用して、上記 (1) で作成した Web サービスを参照し、プロキシクラスと型付 DataSet(上記のコード例では ProductsWebServiceSoapClient と ProductsDataSet)を自動生成させます。

次に、Visual Studio のデータソース構成ウィザードを使って、上記 (1) で作成した型付 DataSet + TableAdapter + TableAdapterManager と全く同じものを自動生成させます。

その上で、Visual Studio のデザイン画面で、「データソース」ウィンドウの項目を Form 画面にドラッグ&ドロップすると、DataGridView, BindingSource, BindingNavigatorなどのコードが自動生成されます。

それらを組み合わせれば容易に実装できるはずです。

Tags: , ,

.NET Framework

Server.Transfer と fragment 識別子

by WebSurfer 2014年8月3日 13:42

Server.Transfer メソッド を使って別のページに遷移する場合、fragment 識別子を使用して指定する場所にジャンプさせることができるでしょうか?

結論から言うとダメです。でも、それでは記事にならないので、何故ダメなのかの理由と、ジャンプさせるための別の方法を書きます。

(1) Server.Transfer で fragment 識別子を使用できない理由

html の a 要素や、ASP.NET でよく使われる Response.Redirect メソッド を使用する場合は問題ありません。

例えば、Response.Redirect メソッドを使う場合、以下のように fragment 識別子(#positionToMove の部分)を設定すれば、リダイレクト先の default2.aspx ページで html 要素の id または name 属性に positionToMove が指定されていれば(例: id="positionToMove")、その場所にジャンプします。

protected void Button1_Click(object sender, EventArgs e)
{
  Response.Redirect("~/default2.aspx#positionToMove", false);
}

これを Server.Transfer("~/default2.aspx#positionToMove") のようにすると HttpException がスローされ、"要求の種類 'POST' の http ハンドラーが見つかりませんでした。" というエラーメッセージが出ます。これは、.aspx#positionToMove という拡張子のファイルを処理して応答を返す HTTP ハンドラが見つからないということを言ってます。

何故このような違いが出るかは、Response.Redirect と Server.Transfer のサーバー側での処理の違いと、ブラウザでの fragment 識別子の扱いが分かると理解できると思います。

まず、Response.Redirect と Server.Transfer の違いですが、これは @IT の記事 第16回 ASP.NETにおけるページの遷移 (1/4) が参考になると思いますので見てください。

具体的には、上に書いたコードの例で言うと、(a) Response.Redirect はサーバーからブラウザがリダイレクト指示(HTTP 302 応答)を受け、ブラウザがサーバーに default2.aspx を GET 要求に行く、(b) Server.Transfer はサーバー側だけで処理が行われ default2.aspx#positionToMove を返そうとする・・・ところが大きな違いです。

上記 (a) で、HTTP 302 応答のヘッダには Location: /default2.aspx#positionToMove が含まれますが、それを受けたブラウザは #positionToMove を取り除いて GET /default2.aspx という要求をサーバーに出します。サーバーには .aspx ファイルを扱うハンドラはもちろん定義されていますので、HTTP ハンドラが見つからないなどという問題はなく、default2.aspx が処理されて応答が返ってきます。

ブラウザはジャンプ先の情報 #positionToMove を保持していますので、default2.aspx の応答が返ってきた後でその位置にジャンプすると言う仕組みになってます。

というわけで、そもそも Server.Transfer("~/default2.aspx#positionToMove") などどするのは間違いのようです。

と言うよりは、その前に、本当に Server.Transfer を使う必要があるのかということをよく考えるべきと思います。

普通は Server.Transfer を使う必要はないはずです。個人的には使うべきではないと思っています。使うケースがあるとすると、自分が知る限りですが、エラーページに Transfer して、そこで遷移元で起こった例外の情報を取得する場合ぐらいでしょうか。

(2) Server.Transfer で遷移して指定の場所にジャンプする方法

MSDN ライブラリの Transfer メソッドの説明によると "QueryString コレクションおよび Form コレクションを保持します" ということです。

なので、クエリ文字列でジャンプ先のアンカー名を渡し、遷移先のページでアンカー名を取得し、JavaScript を使ってアンカー名に指定された位置にジャンプさせることが可能です。

遷移元のページでは以下のよう Server.Transfer メソッドを記述します。

protected void Button2_Click(object sender, EventArgs e)
{
  // クエリ文字列でジャンプ先のアンカー名を渡す。
  Server.Transfer("~/default2.aspx?anchorName=positionToMove");
}

遷移先のページに移動する位置を示すアンカー(フラグメント)を設定します。例えば、アンカー名が上記のコードのように positionToMove とすると以下のような感じです。html 要素は p 要素でなくても、div でも span でも可です。古いブラウザでは name 属性に指定しないと認識しないものもあるようですので、id と name 属性の両方にアンカー名を指定しておいた方がいいと思います。

<p id="positionToMove" name="positionToMove">
    ジャンプする場所
</p>

さらに、遷移先のページで、クエリ文字列の有無を調べて、アンカー名の指定があればその位置にジャンプさせる JavaScript のコードを登録します。以下のような感じです。

protected void Page_Load(object sender, EventArgs e)
{
  string anchorName = Request.QueryString["anchorName"];

  // null チェックおよび適切性の検証。
  if (anchorName != null && IsAnchorNameValid(anchorName))
  {
    String csname = "FragmentScript";
    Type cstype = this.GetType();

    ClientScriptManager cs = Page.ClientScript;

    if (!cs.IsStartupScriptRegistered(cstype, csname))
    {
      String cstext = 
          "window.location.hash = '" + anchorName + "';";
      cs.RegisterStartupScript(cstype, csname, cstext, true);
    }
  }
}

// anchorName として適切かどうかを検証。
protected bool IsAnchorNameValid(string anchorName)
{
  // パターンは実際に合わせて適宜変更してください。
  string pattern = "^positionToMove$";

  // anchorName が null の場合 ArgumentNullException
  // がスローされるので注意。
  return Regex.IsMatch(anchorName, pattern);
}

上記のコードには、悪意のあるユーザーによるスクリプトの注入に対応するため、クエリ文字列がアンカー名として適切かどうかを検証する例を含めています。アンカー名が固定で良ければ、クエリ文字列でスクリプトを組み立てない方がいいと思います。

Tags: ,

ASP.NET

About this blog

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

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar