単体入力エラーチェックの実装パターン

さて Part 1. のエントリでは、業務処理の終了パターンの分類と、各アプリケーションタイプにおける基本的な実装パターンを整理しました。要点をまとめると、以下のようになります。

  • 業務処理の終了パターンは、以下のように分類される。
    image
  • 突き合わせエラーについては、バックエンドのモジュール(BC や DAC)との連携によるチェック作業が必要になる。UI 部単体でチェックが可能なのは、単体入力エラーに限られる。

.NET Framework では、UI 開発技術として、ASP.NET, Silverlight, WPF, Windows フォームなど、様々なテクノロジが提供されています。これらの技術には、いずれにも、UI 部において、単体入力エラーチェックを効率よく実装していくための機能が備わっています。(これらの機能は、いずれも単体入力チェックを効率よく実装するための機能であり、突き合わせエラーのチェックや、システムエラーに関する対処を実装するための機能ではありません。いや無理矢理使えば使えるかもしれませんが;、それはこれらの機能が用意された目的や意図とはズレた使い方だと考えるべきだと思います。)

  • ① ASP.NET Web フォーム : 入力検証コントロール
  • ② Silverlight 3, WPF 3 : 例外ベースの双方向データバインド
  • ③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド

さてこれらの機能は、いずれも「単体入力チェックを行う」「フィールド単位のチェックとインスタンス単位のチェックを行う」という点においては違いがありませんしかし、その実装方法や、エラーチェックに対する考え方は、全くといっていいほど違います。この実装方法の特性の違いを理解しておかないと、単体入力エラーチェックをうまく実装できないばかりか、開発生産性をかえって大幅に損なう結果に繋がりかねません。特に、ASP.NET Web アプリケーション開発の入力検証コントロールの使い方に慣れた人が、Windows フォームや WPF などのテクノロジを遣うと、おそらく入力検証のやり方が全くといっていいほど違うため、相当に戸惑うことになるはずです。(というよりも私はむちゃくちゃ戸惑いましたよ....orz)

本エントリの目的は、これらの各テクノロジにおける、実装パターンの違い(実装方法やエラーチェックに対する考え方の違い)を明確化することです。

  • ① ASP.NET Web フォーム : 入力検証コントロール
    検証コントロールを使って、「正しい文字列」を作成する方式
  • ② Silverlight 3, WPF 3 : 例外ベースの双方向データバインド
    双方向データバインドを使うものの、反映に失敗するケースがある方式
  • ③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド
    双方向データバインドを使うが、反映に失敗するケースがない方式

なお、以下に順番に各テクノロジの実装方式を解説していきますが、基本的にはどのテクノロジであっても、UI 部でやるべきことは以下の 3 つです。

  • UI 上のテキストボックスなどから値を入力してもらう
  • 入力された値を、コードビハインドのデータ変数に取り出す
  • 単体入力チェックが済んだ値を、BC/DAC に送出する

image

実装テクノロジによる差異は、下線部のやり方の部分に出てきます。この点を意識しながら、以降の解説を読んでください。

※ (参考)なお本エントリは、各テクノロジでの単体入力エラーチェックの実装方法について、ある程度知識がある、という前提で解説を進めます。もし、各テクノロジでの単体入力エラーチェックの実装方法をまったく知らないという場合には、以下の情報を併読されることをお勧めします。

※ (注意)また本エントリは、各データ検証方式の考え方の違いを明確化することを狙っていますので、解説をかなり単純化しています。例えば、Silverlight 3 には、①に近いデータ検証を可能とする ValidationRule や、属性ベースでデータ検証を行う DataAnnotation などの機能が備わっていますが、これらについては触れません。詳細にデータ検証をご存じの方は「え゛ー?」とツッコミ入れたいところがたくさんあると思いますが、そこはちょっとだけ目をつぶっていただけるとうれしいです^^。

では、以下に順番に解説していきます。

[① ASP.NET Web フォームの場合:入力検証コントロール]

ASP.NET Web フォームの場合、単体入力チェックは検証コントロールを使って実装します。

  • 4 種類の標準のチェックロジックが用意されています。
    (必須入力チェック、フォーマットチェック、比較チェック、範囲チェック)
  • 上記の 4 種類でカバーできないチェックは、CustomValidator を使って自力で実装します。
    (インスタンス単位の単体入力チェックなどは、CustomValidator で実装します)

image

この場合の、UI 部のコードビハインドの制御コード(ボタン押下のイベントハンドラのコード)は以下のようになります。

image

このコードについて、改めてじっくり考えてみると、以下のような特徴があることがわかります。

  • ASP.NET Web フォームの検証コントロールは、 「テキストボックスに、適切な値を作る」 ように動作します。
  • 検証コントロールによるチェックを通過できていれば(IsValid = true なら)、データ変数への取り出しや型変換などで失敗したりすることは絶対にありません。つまり、コードビハインド内で値をテキストボックスから取り出す際には、すでに単体入力チェックが終わっている状態になっている、ということになります。
  • ただし、UI からコードビハインド内へのデータ取り出し作業自体は、自力で記述する必要があります

image

上記のような特性は、Silverlight や WPF、Windows フォームなどとは全く異なります。

まず、一般的に、Silverlight, WPF, Windows フォームといった、リッチクライアント系のアプリケーション開発技術では、通常、双方向データバインドと呼ばれるテクニックを用いて、データ検証とデータ取り出しを同時に行います

image

Silverlight, WPF, Windows フォームそれぞれで、双方向データバインドの実装方法は少しずつ異なりますが、根本にある基本的な考え方は、 「UI コントロールの表示と、データソースオブジェクト間の値を、双方向にリアルタイムに同期させる」 というものです。このため、双方向データバインドを利用すると、UI コントロールからのデータ取り出し作業(例:string customerName = tbxCustomerName.Text; などといった取り出し作業や、decimal price = decimal.Parse(tbxPrice.Text); といったパース処理)が不要となり、バインドされているオブジェクトを、UI から入力されたデータであるとみなしてそのまま使うことができます。これが、双方向データバインドを用いたデータ入力制御の根底にある、基本的な考え方です。

しかし、双方向データバインドにおける入力データの検証方法(単体入力チェック方法)に関しては、いくつかの方法があります。.NET Framework 内で使われている双方向データバインド時のデータ検証方法は、大別すると以下の 2 つに分類されます。

  • ② Silverlight 3, WPF 3 : 例外ベースの双方向データバインド
  • ③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド

これらは 、単体入力チェックロジックを持たせる場所と持たせる方法に違いがあり、また双方向データバインドの挙動についても多少の違いがあります。このため、以下に順番に解説していきます。

[② Silverlight 3, WPF 3 の場合:例外ベースの双方向データバインド]

まず、Silverlight 3, WPF 3 の場合について解説します。これらの場合には、以下のようにして単体入力チェックロジックを実装します。

image

  • バインドするオブジェクト側に、フィールド単位のデータチェックロジックを持たせる。
    具体的には、下図 A のように、バインドオブジェクトのプロパティ setter に対して、フィールド単位のチェックロジックを持たせる。もし、UI から不適切なデータが投入された(テキストボックスから不適切な値が入力された)場合には、例外(通常は ArgumentException 例外)を throw し、値を受け取らないようにする
  • 双方向データバインドの "ValidatesOnException" 機能を使う。
    具体的には、下図 B のように、UI 部(XAML コード)にて、バインドするオブジェクトの各プロパティと、UI 項目との紐付けを行う。これにより、UI 部から入力された値が、バインドされたオブジェクトに自動反映されるようになる。ここで、ValidatesOnException 機能を有効化しておくと、バインドオブジェクトのプロパティへの反映時に失敗した場合(=例外が throw された場合)、これをエラーメッセージとして赤枠やツールチップにより表示してくれるようになる
    (※ エラーメッセージを赤枠やツールチップ表示するためには適切なスタイル定義が必要ですが、これについてはサンプルコードを参照してください。)

A. 例外ベース双方向データバインドで利用する、バインドオブジェクトの実装例

image

B. 例外ベース双方向データバインドでの、双方向データバインドの実装例(UI 部)

image

さて、一見するとわかりやすそうなこの実装方法ですが、実際には厄介な問題を抱えています。それが、UI 上に実際に表示されている値と、バインドされたオブジェクトが持っている値とのずれです。

例えば上記のアプリケーションに対して、下記のような操作を行った場合(オブジェクトへの反映に成功したり失敗したりするケースが混在する場合)を考えてみてください。

  • 顧客 ID として “3214” を設定する。(→ 反映に成功する)
  • 顧客 ID を “12345” に変更する。(→ 顧客 ID は 4 桁英数大文字のため、反映に失敗する)
  • 顧客名として “Nobuyuki” を設定する。(→ 反映に成功する)
  • 生年月日として “1973/06/07” を設定する。(→ 反映に成功する)
  • 生年月日を “1973/55/41” に変更する。(→ 日付として正しくないため、反映に失敗する)

image

この場合、UI 上に表示されている値と、バインドされたオブジェクトの中に設定されている値とがずれています。このため、業務処理のために UI から入力された値を使おう、と思った場合には、まず、双方向データバインドにエラー(反映失敗)があるか否かを確認する必要があります。バインドされたオブジェクトの中に入っている値をいきなり使うと、実は UI から入力された過去の正しい値を使ってしまうことがある、ということになってしまいます。

また、次のような問題もあります。一般的なデータエントリシートの場合、最初に画面を表示した際には何も記入されていないのが普通でしょう。しかし、そのためには、バインドされたオブジェクト側が空の状態(例えば null や空文字が入っている)でなければなりません。がしかし、このようなオブジェクトは、そもそも値として、本来正しくない値を抱えている状態になっています。

image

また、インスタンス単位の単体入力チェックを行うロジックについては、バインドオブジェクトに持たせることができません(この例だと電話番号と電子メールアドレスの少なくとも片方が入力されている、というチェック)。なぜなら、電話番号と電子メールの入力項目は、UI からずれたタイミングでひとつずつバインドオブジェクトに反映されてくるため、バインドオブジェクト側のフィールドに持たせることが困難だからです。

こうした事情から、例外ベースの双方向データパインドでは、UI 部のボタン押下のイベントハンドラを、以下のように実装することになります。

  • まず、バインドにエラーが発生していないか否かをチェックし、フィールド単位の単体入力エラーがあるか否かをチェックする。
  • 次に、バインドされたオブジェクトを見て、インスタンス単位の単体入力エラーがあるか否かをチェックする。
  • 最後に、バインドされたオブジェクトに含まれるデータを使って、業務処理を行う。

image

つまり、ここまでの解説をまとめると、例外ベースの双方向データバインドの動作イメージは以下の通りになります。

  • バインドエラーがない場合に限り、UI からの入力がすべてバインドオブジェクトに反映されている、という動作になる。このためイベントハンドラ内では、まずバインドエラーのチェックが必要。
  • 仮にバインドエラーがなかったとしても、インスタンス単位のチェックをイベントハンドラ内で行う必要がある

image

例外ベースの双方向データバインドでは、バインドオブジェクト側に、例外を使った検証ロジックを持たせているのですが、これは、バインドオブジェクトが不正な状態になることがないようにする、という考え方に基づいています。この考え方は、それだけ見ると、一般的なオブジェクト指向設計の考え方からして特に間違ってはいません。ところが、双方向データパインドは、UI 表示とバインドオブジェクトの内容との二点間同期を保つ、という考え方に基づいているため、根本的なところで概念的な相反があります。このため、上記のような厄介な実装上の工夫を行わなければならなくなるのだろうと思います。

しかし次に解説する、IDataErrorInfo ベースの双方向データバインドでは、このような概念的な相反は発生しません。

[③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド]

引き続き、Windows フォーム 2.0 や WPF 3.5 で導入されている、IDataErrorInfo ベースの双方向データバインドについて解説します。

IDataErrorInfo ベースの双方向データバインドでは、バインドオブジェクト側に、IDataErrorInfo というインタフェースを持たせます。このインタフェースは、オブジェクトインスタンス内部にエラーが含まれていることを、文字列情報として返すためのもので、これを使うことにより、前述の問題をきれいに解決することができます。

image

IDataErrorInfo インタフェースを持つバインドオブジェクトの実装例は後述しますので、まず先に概念図を示しましょう。IDataErrorInfo ベースの双方データパインドでは、以下のようにしてデータバインドを行います。

  • 入力値が正しかろうと間違っていようと、とにかくオブジェクトに反映してしまう。
  • オブジェクトインスタンスが不正な状態にある場合には、これを IDataErrorInfo インタフェースから公開する。
  • これにより、常に UI とオブジェクト内の値とが同期される。

image

前述したように、双方向データバインドは、UI とバインドオブジェクトのデータを常に同期させる技術でした。この際、データとして誤りのある内容が UI から入力された場合にオブジェクトに反映させるのかどうか、が問題になったわけですが、IDataErrorInfo ベースの双方向データバインドでは、入力内容を常にオブジェクトに反映させます。すると、バインドオブジェクトが「単体入力エラーを含んだデータを抱える」ことになります。この単体入力エラーに関する情報を IDataErrorInfo インタフェースから公開させ、これを UI コントロールに拾わせて、画面上に表示を行う、ということをするわけです。

IDataErrorInfo インタフェースを持つバインドオブジェクトの実装コード例を以下に示します。

    1: using System;
    2: using System.Collections.Generic;
    3: using System.Text;
    4: using System.ComponentModel;
    5: using System.Text.RegularExpressions;
    6:  
    7: namespace WindowsFormsApplication1
    8: {
    9:     public class CustomerInput : IDataErrorInfo
   10:     {
   11:         private Dictionary<string, string> _errors = new Dictionary<string, string>();
   12:  
   13:         private string _id;
   14:         public string ID
   15:         {
   16:             get { return _id; }
   17:             set
   18:             {
   19:                 _id = value;
   20:                 if (value == null)
   21:                 {
   22:                     _errors["ID"] = "ID は必須入力項目です。";
   23:                 }
   24:                 else if (Regex.IsMatch(value, @"^[0-9A-Z]{4}$") == false)
   25:                 {
   26:                     _errors["ID"] = "ID は半角英数大文字 4 文字です。";
   27:                 }
   28:                 else
   29:                 {
   30:                     _errors.Remove("ID");
   31:                 }
   32:             }
   33:         }
   34:  
   35:         private string _name;
   36:         public string Name
   37:         {
   38:             get { return _name; }
   39:             set
   40:             {
   41:                 _name = value;
   42:                 if (value == null || value == "")
   43:                 {
   44:                     _errors["Name"] = "名前は必須入力項目です。";
   45:                 }
   46:                 else if (Regex.IsMatch(value, @"^[\u0020-\u007e]{1,40}$") == false)
   47:                 {
   48:                     _errors["ID"] = "名前は半角英数文字 40 字以内で入力してください。";
   49:                 }
   50:                 else
   51:                 {
   52:                     _errors.Remove("Name");
   53:                 }
   54:             }
   55:         }
   56:  
   57:         private string _email;
   58:         public string Email
   59:         {
   60:             get { return _email; }
   61:             set
   62:             {
   63:                 _email = value;
   64:                 if (value == null || Regex.IsMatch(value, @"\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*"))
   65:                 {
   66:                     _errors.Remove("Email");
   67:                 }
   68:                 else
   69:                 {
   70:                     _errors["Email"] = "電子メールアドレスとして有効な値を入力してください。";
   71:                 }
   72:             }
   73:         }
   74:  
   75:         private string _phone;
   76:         public string Phone
   77:         {
   78:             get { return _phone; }
   79:             set
   80:             {
   81:                 _phone = value;
   82:                 if (value == null || Regex.IsMatch(value, @"(0\d{1,4}-|\(0\d{1,4}\) ?)?\d{1,4}-\d{4}"))
   83:                 {
   84:                     _errors.Remove("Phone");
   85:                 }
   86:                 else
   87:                 {
   88:                     _errors["Phone"] = "電話番号は (03)1234-5678 のように入力してください。";
   89:                 }
   90:             }
   91:         }
   92:  
   93:         public DateTime? Birthday { get; set; }
   94:  
   95:         // 全体整合チェック
   96:         public string Error
   97:         {
   98:             get
   99:             {
  100:                 if (_email == null && _phone == null)
  101:                 {
  102:                     return "電子メールアドレスか電話番号かのいずれか一方は必須入力です。";
  103:                 }
  104:                 else
  105:                 {
  106:                     return null;
  107:                 }
  108:             }
  109:         }
  110:  
  111:         public bool HasErrors
  112:         {
  113:             get { return (_errors.Count != 0 || Error != null); }
  114:         }
  115:  
  116:         public string this[string columnName]
  117:         {
  118:             get
  119:             {
  120:                 return (_errors.ContainsKey(columnName) ? _errors[columnName] : null);
  121:             }
  122:         }
  123:     }
  124: }

コード中の 95 行目~122 行目が、IDataErrorInfo インタフェースにかかわる部分ですが、コードのポイントをピックアップすると以下のようになります。

  • バインドオブジェクトの各プロパティは、たとえ単体入力エラーがあるデータであったとしても、とりあえずデータを受け取ります。かわりに、内部にエラー情報(エラーメッセージ)を蓄積しておきます。 
    image
  • IDataErrorInfo インタフェースには、Error プロパティ(オブジェクトインスタンス全体にかかわるインスタンス単位の単体入力エラー情報を返すためのもの)と、プロパティ名を使ったインデクサ(フィールド単位の単体入力エラー情報を返すためのもの)があります。これらを使って、単体入力エラー情報を UI 部に対して返します。 
    image

Windows フォーム 2.0 を使う場合には、UI 側に ErrorProvider コントロールを張り付けておきます。このようにしておくと、ErrorProvider コントロールがバインドされたオブジェクトの IDataErrorInfo インタフェースから自動的にエラー情報を取り出し、画面上にエラーメッセージを表示してくれるようになります。(※ 実装方法の詳細は、こちらのエントリを見てください。)

image

また、バインドされたオブジェクトにエラーがあるか否かは、バインドオブジェクトのみを見れば簡単に調べることができます。このため、UI 部のイベントハンドラ(Button_Click イベント)のコードは、以下のように非常に簡単になります。

image

このように、IDataErrorInfo インタフェースベースの双方向データバインドを使うと、綺麗な形での単体入力データチェックが実装できます。全体像を示すと以下の通りになります。

image

スマートクライアントにおける、双方向データバインドと IDataErrorInfo インタフェースを用いた単体入力チェックロジックの実装モデルには、以下のような特徴があります。

  • 単体入力チェック処理を、バインドオブジェクトに固めることができる。
    このため、モジュールの役割分担が明確になる上に、単体入力チェックロジック部分だけを重点的に単体機能テストすることもできます。

  • コードビハインドの記述が簡単になる。
    コードビハインドのイベントハンドラでは、バインドオブジェクトだけを操作すればよく、UI コントロールを触る必要がなくなります。このため、コードビハインドのコードの見通しも非常によくなります。

  • 入力仕掛り状態の維持が簡単にできる。

    バインドオブジェクトをそのままシリアル化して保存すれば、入力しかけのデータをそのまま保存しておくこともできます。

実装モデルが非常に綺麗になるので、ぜひ覚えておくとよいでしょう。

※ (注意) このモデルは Windows アプリケーションなどでは有効ですが、Web アプリケーションでは有効ではありません。なぜなら、Web アプリケーションでは、データが入力される場所(=ブラウザ上)と、データを取り出す場所(=サーバサイド)が分かれており、UI からリアルタイムでデータを取り出すことができないためです。

[3 つの単体入力チェック方式の比較]

さて、ここまでの解説を整理しつつ比較してみると、3 つの単体入力チェック方式には以下のような違いがあることがわかります。

image

ここで重要なのは、単体入力チェックモデルの優劣を議論することではありません。というのも、ぶっちゃけ、どのモデルを使ったところで単体入力チェックは実装できるわけで、好みの違いはあれど、どのモデルがより優れている、といった議論は宗教論争になりかねません;。そうではなくて、自分が業務アプリケーションを実装する際に、どのモデルを使って単体入力チェックを実装しようとしているのかを意識することが重要です。実際、.NET Framework の中に標準で含まれるデータ入力検証フレームワークを見ても 3 通りはあるわけで(実は私が気付いていないもっと別のモデルもあるかもしれません…とつぶやいておく;)、これらをごちゃまぜにしたような実装は避けなければなりません。

アプリケーションを実装する際は、一貫性が非常に重要です。どの方式を選ぶにせよ、ある特定のアプリケーションの中では「このパターンで実装する」といった具合に、モデルを定めて実装するようにしてください。

※ (参考) さらに追加のつぶやきですが、よくこうした単体データ入力検証フレームワークに関して、「○○のタイミングでエラーメッセージを表示できるようにできませんか?」「○○のような方式でエラーメッセージを表示できるようにできませんか?」といったことを聞かれます。こうしたカスタマイズは、できる場合とできない場合とがあります。というのも、もともとフレームワークというものは、「動作モデルに制約を加えるかわりに、開発生産性を大きく向上させよう」というコンセプトで作られているものであって、 「どんなふうに動作させるものであっても開発生産性がよくなるもの(万能薬)」ではないからです。もし、.NET Framework などが標準で備える入力検証フレームワークの動作ではお客様要件を満たせない、ということであれば、独自に単体データ入力検証フレームワークを作成するか、または既存の単体データ入力検証フレームワークにカスタマイズを加えるしかありません。一般には、こうした問題が極力発生しないように、UI 設計段階(=業務設計段階)から、ある程度実装効率というものを意識して、フレームワークの想定している動作に併せた形での設計を行うようにします。

[まとめ]

というわけで、ここまで .NET Framework が備えている各種の単体データ入力検証フレームワークに関して、その実装モデルの違いを解説してきましたが、最も重要なポイントをまとめると、以下のようになります。

  • 単体データ入力検証フレームワークを使う上では、そもそも業務エラーとシステムエラーの分類や、単体入力エラーの分類を正しく行うことが必要になる。

  • .NET Framework が備えている各種の単体入力エラーチェック機能は、下図の枠線内の実装(開発効率)を高めるためのものである。

    image

また、単体入力チェックに対するアプローチは、ランタイムによってかなり異なります。

  • ① ASP.NET Web フォーム : 入力検証コントロール
    検証コントロールを使って、「正しい文字列」を作成する方式
  • ② Silverlight 3, WPF 3 : 例外ベースの双方向データバインド
    双方向データバインドを使うものの、反映に失敗するケースがある方式
  • ③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド
    双方向データバインドを使うが、反映に失敗するケースがない方式

これらはそれぞれに特徴があるので、データ検証に対する考え方をよく理解した上で活用することが重要です。本エントリを参考にして、さらに優れた業務アプリケーション開発を目指していただければ幸いです。