Part 2. スマートクライアントにおける単体入力データ検証

さて、前回のエントリでは Windows フォームにおける双方向データバインドの基本的な使い方を解説しました。要点をまとめると、以下の通りとなります。

  • 双方向データバインドを用いると、データソースから UI コントロールへ値を表示するだけでなく、UI コントロールからの入力をデータソースに反映できる。
  • データバインドには、2 種類のデータバインドがある。
    ① 単一値データバインド(単票形式データバインド)
    ② コレクションデータバインド(グリッド形式データバインド)
  • どちらの場合も、BindingSource コントロールを介して、UI コントロールとデータソースを紐づける。

image

さて前回のエントリでは、 双方向データバインドにより、テキストボックスから入力された値がデータソースオブジェクトに反映されることを確認しました。しかし、これらのデータはそのまま使えるとは限りません。例えば配達希望日を入力するテキストボックスの場合、

  • "ABCDE" や "2008/14/63" といった、そもそも日付ではない(型変換できない)データが入力された場合はどうすればよいか?
  • "1973/06/07" のように、日付としては有効でも、未来の日付ではないデータが入力された場合はどうすればよいか?

といった問題があるため、入力されたデータ値は検証を行った上で利用する必要があります。しかし、こうしたデータ入力検証を場当たり的に実装すると、コード量が膨大に膨れ上がり、アプリケーションの保守性も極端に悪化します。これを避けるためには、データ入力検証に関して、アプリケーション全体で一貫した考え方を使う必要があります。

本エントリでは、スマートクライアント(Windows フォーム)における、業務アプリケーションを想定した入力データ検証の考え方と、その実装方法について解説します。

  • エラーの分類
  • 単体入力エラーの分類
  • 単体入力チェックとエラーメッセージの関係
  • 双方向データバインドにおける値の同期の考え方
  • IDataErrorInfo インタフェースとは何か
  • 具体的な単体入力チェックの実装方法
  • DTO と UI バインドオブジェクトの違い

なお、今回の実装サンプルコードはこちらになります。

では、以下に解説していきましょう。

[エラーの分類]

Web アプリ、Windows アプリすべてに共通する考え方ですが、業務アプリケーションにおける「エラー」は、以下の 3 種類に分類されます。

  • 単体入力エラー : UI 内部のみで単体で正誤判定できるもの。
    例) 入力された電子メールアドレスが「nakama@ms」だった、入力された価格が 0 未満だった、生年月日として未来の日付を入力された、日付入力欄に “AAA” が入力されている、etc.
  • 業務エラー : サーバやデータベースまで連携しないと正誤判定できないもの。
    例) 登録しようとした希望ユーザ ID がすでに他のユーザにより使われていた、入力された商品 ID がすでに廃番のものだった、etc.
  • システムエラー : システムインフラの不具合やアプリケーションバグで発生するもの。
    例) メモリ不足、DB 破損、ネットワークエラー、etc.

スマートクライアントの場合を考えてみると、単体入力エラーに関してはサーバ側へ通信を行うまでもなく、UI 部(Windows フォームアプリケーション)の中で即時にチェックを行い、すぐさまエンドユーザにエラー情報通知を行うのが望ましいでしょう。

image

[単体入力エラーの分類]

さて、UI 部のみで単体チェックが可能な「単体入力エラー」ですが、実はこの単体入力エラーや単体入力チェックは、さらに 3 種類に細分化することができます。例えば下図のような、新規顧客登録画面を考えてみましょう。

image

 

この画面において実施する必要のある単体入力チェックは、以下の 3 種類に分類できます。

  1. データ型変換チェック
    例) 入力された生年月日が、DateTime? 型に変換できるか?
  2. フィールド単位の有効性チェック
    例) ID や電子メールアドレス、電話番号などが適切なフォーマットか?
    例) 入力された生年月日が、未来の日付ではないか?
  3. インスタンス(レコード)単位の有効性チェック
    例) 連絡先として、電話番号か電子メールアドレスかの少なくとも一方が入力されているか?

そして単体入力チェックでは、これらのチェックを、場当たり的ではない考え方で実装する必要があります。

[単体入力チェックとエラーメッセージの関係]

またもう一点重要なポイントとして、UI 部における単体入力エラーチェックでは、エラー発見時に即時にユーザに対する**ガイダンスメッセージ表示**を行う必要があります。

image

このエラーメッセージ表示に関しては、以下のポイントに留意する必要があります。これらが満たされていないと、エンドユーザにとって使いにくい画面になってしまいます。

  • 入力エラーを修正してもらえるようなガイダンス的なメッセージであること。
  • 入力エラーは、可能な限り即座にユーザに通知すること。
  • 入力エラーの通知が、ユーザ操作を妨げないこと(例:メッセージダイアログを出すと、ユーザにとって非常に煩わしい)
  • 入力エラーがある状態でも、他の入力欄にフォーカスを移せること。

こうした入力データ検証を実装しやすくするための機能として、.NET Framework 3.5 から追加されたのが、IDataErrorInfo インタフェースと呼ばれる機能(と、それに関連する BindingSource クラスの機能強化)です。が、これを説明する前にもうひとつ押さえておくべきことがあります。それは、双方向データバインドにおける値の同期の考え方です。

[双方向データバインドにおける値の同期の考え方]

もともとデータバインドというのは、二点間の値を常に同じに保つという意味を持っています。そして双方向データバインドの場合には、テキストボックスから入力された値をデータソースに反映することで、二点間のデータ値をリアルタイムに同期しようとします

ここで、年齢を入力できるよう、テキストボックスと int 型のデータとを双方向データバインドする場合を考えてみます。この場合、まず初期表示ではデータソース→UI にデータが表示されるので特に問題は生じません。しかし、テキストボックスから数値以外の文字列が入力された状態でロストフォーカスを認めてしまうと、テキストボックス上のデータと、データソースの値とにずれが生じてしまいます。このようなデータずれが生じないよう、Windows フォームのデータバインドでは、データずれが生じるようなロストフォーカスを認めないようになっています。このようにすることで、(入力仕掛かりの状態を除けば)二点間のデータ同期を保つわけです。

image

さて、そもそも int 型に変換できない文字列がテキストボックスから入力された場合にロストフォーカスを認めない、という挙動は至極当然でしょう。しかし、単体入力エラーとなる値、たとえば “-5” を入力した場合はどうなるでしょうか? ここで問題になるのは、単体入力エラーとなる値を、データソースオブジェクトが受け取るかどうか、というポイントです。データソースとなるオブジェクトにビジネスルールを直接実装してしまうと、単体入力エラーとなる値を受け取れなくなるため、双方向データバインドでデータの同期をうまく保つことができなくなります。下の例を見てください。

    1: public class Author
    2: {
    3:     private string _au_id;
    4:     public string au_id
    5:     {
    6:         get { return _au_id; }
    7:         set
    8:         {
    9:             if (value == null) throw new ArgumentException("au_id は null を設定できません。");
   10:             if (Regex.IsMatch(value, @"^\d{3}-\d{4}$)") == false) throw new ArgumentException("著者IDは123-4567のような形式です。");
   11:             _au_id = value;
   12:         }
   13:     }
   14:  
   15:     private string _au_name;
   16:     public string au_name
   17:     {
   18:         get { return _au_name; }
   19:         set
   20:         {
   21:             if (value == null) throw new ArgumentException("名前は null にできません。");
   22:             if (value == "") throw new ArgumentException("名前は空文字にできません。");
   23:             if (value.Length > 20) throw new ArgumentException("名前は20文字以内である必要があります。");
   24:             _au_name = value;
   25:         }
   26:     }
   27:  
   28:     private int _age;
   29:     public int age
   30:     {
   31:         get { return _age; }
   32:         set
   33:         {
   34:             if (value < 0) throw new ArgumentException("年齢は 0 未満にはできません。");
   35:             _age = value;
   36:         }
   37:     }
   38: }

一般的に、ビジネスオブジェクトは上記のような実装をするのが望ましい、と言われますが、このような Author オブジェクトを UI に双方向データバインドすると、非常に使いづらい UI になります。下図を見てみてください。

image

テキストボックスから –5 を入力した場合、これは当然 Author オブジェクトに反映できません。となると、ロストフォーカス時に、テキストボックスとデータソースの値の同期を取るためには、

  • 年齢入力テキストボックスからのロストフォーカスを認めない(入力しかけの状態で別のフィールドに移れない)(既定の挙動)
  • しれっとテキストボックスの表示を元に戻してしまう(入力したつもりがいつの間にか取り消されている) (※ こらちは作り込みが必要ですが)

のどちらかを取る必要があります。しかし、これでは使いやすい UI を実現することはとても不可能です。このような問題を解決するのが、IDataErrorInfo インタフェースです。

[IDataErrorInfo インタフェースとは何か]

IDataErrorInfo インタフェースは、簡単に言うと、以下のような特性を持ったオブジェクトのクラスを作成するために使うインタフェースです。

  • UI からの入力値を、エラーがあろうとなかろうとそのまま受け取る
  • そのかわりに、内部データにエラーが含まれる場合には、エラーに関する情報を文字列データで返すようにする。

image

    1: public interface IDataErrorInfo
    2: {
    3:     public string Error { get; }
    4:     public string this[string propertyName] { get; }
    5: }

このインタフェースを実装する前の Author オブジェクトと、実装した後の Author オブジェクトの比較コードを下記に示します。

IDataErrorInfo インタフェースを実装する前の Author オブジェクト

    1: public class Author
    2: {
    3:     public string au_id { get; set; }
    4:     public string au_name { get; set; }
    5:     public int age { get; set; }
    6: }

IDataErrorInfo インタフェースを実装した Author オブジェクト

    1: public class Author : System.ComponentModel.IDataErrorInfo
    2: {
    3:     public string au_id { get; set; }
    4:     public string au_name { get; set; }
    5:     public int? age { get; set; }
    6:  
    7:     public string Error
    8:     {
    9:         get { return null; }
   10:     }
   11:  
   12:     public string this[string columnName]
   13:     {
   14:         get
   15:         {
   16:             switch (columnName)
   17:             {
   18:                 case "au_id":
   19:                     if (au_id == null) return "au_id は null にできません。";
   20:                     if (Regex.IsMatch(au_id, @"^\d{3}-\d{4}$") == false) return "著者IDは123-4567のような形式です。";
   21:                     return null;
   22:                 case "au_name":
   23:                     if (au_name == null) return "名前は null にできません。";
   24:                     if (au_name == "") return "名前は空文字にできません。";
   25:                     if (au_name.Length > 20) return "名前は20文字以内である必要があります。";
   26:                     return null;
   27:                 case "age":
   28:                     if (age == null) return "年齢は必須入力です。";
   29:                     if (age < 0) return "年齢は 0 未満にはできません。";
   30:                     return null;
   31:                 default:
   32:                     throw new ArgumentException("不明なプロパティです。" + columnName);
   33:             }
   34:         }
   35:     }
   36: }

実装上のポイントは以下の通りです。

  • UI に双方向バインドするオブジェクトは、とりあえずどんな入力値でも受け付けるように実装する。(age プロパティが、int 型から int? 型に変更されているのは、テキストボックスが未入力状態(null 状態)でロストフォーカスすることを認めるための措置です。)
  • データに誤りがある場合には、IDataErrorInfo インタフェースにより、エラーメッセージを返すようにする。(エラーがない場合には null を返す)
  • エラーメッセージは以下の 2 種類を返せるようにする。
    ① プロパティ単位のチェック(public string this[string columnName])
    ② データ全体のチェック(public string Error)

このようなオブジェクトを UI にバインドした上で、さらに ErrorProvier コントロールを画面に貼り付けると、ErrorProvider コントロールが自動的に IDataErrorInfo インタフェースからエラー情報を取り出し、ツールチップ形式でエラーメッセージを表示してくれるようになります。

image

具体的な実装例を以下に示します。(ErrorProvider コントロールの DataSource プロパティに、BindingSource オブジェクトを割り当てることを忘れずに。)

 image

    1: public partial class AuthorForm : Form
    2: {
    3:     public AuthorForm()
    4:     {
    5:         InitializeComponent();
    6:     }
    7:  
    8:     private Author _author;
    9:  
   10:     private void AuthorForm_Load(object sender, EventArgs e)
   11:     {
   12:         _author = new Author();
   13:         bindingSource1.DataSource = this._author;
   14:     }
   15:  
   16:     private void bindingSource1_BindingComplete(object sender, BindingCompleteEventArgs e)
   17:     {
   18:         lblError.Text = _author.Error;
   19:     }
   20: }

このようにすれば、以下のことが実現できます。

  • 使いやすい UI 入力制御を容易に実現できる。
    入力仕掛の状態で他のフィールドにフォーカスを移動できると同時に、エラー情報をツールチップ形式で簡単に表示できます。
  • UI フォームの実装コードから、単体入力データ検証のためのコードを切り離すことができる。 UI のコードビハインドは一般的に非常に汚くなりがちですが、この方法を利用すれば、単体入力チェックにかかわるコードを別クラスに切り離すことができます。

  [具体的な単体入力チェックの実装方法]

ではもうひとつの具体的な実装例として、最初に挙げた顧客データ入力フォームの例を採り上げてみましょう。

image

このようなアプリケーションは、以下の手順で実装していきます。

① データバインド用の IDataErrorInfo オブジェクトの実装

まずは、IDataErrorInfo インタフェースを実装した、UI 双方向データバインド用のクラスを作成します。なお、実装上、以下の点にも気を付けるとよいでしょう。

  • 前述のサンプルでは this[columnName] インデクサ内で毎回チェックをしていましたが、実際には毎回チェックする必要はないので、エラー情報を蓄積しておくディクショナリを使うと便利。
  • (フィールド単位ではなく)オブジェクト全体(入力全体)の整合性チェックをしたい場合には、public string Error プロパティのところに記述する。(この例では、「電子メールと電話番号の少なくとも片方は入力が必要」というオブジェクト全体の整合性チェックロジックを実装しています。)
  • int 型や DateTime 型の入力フィールドを、int? 型や DateTime? 型で定義するのか、string 型で定義するのか、どちらにするのかはよく考えた方がよい。例えばこの例の場合、生年月日(Birthday)プロパティを DateTime? 型で定義しているが、この場合、UI から “1973/56/21” といった具合に、そもそも日付ではないデータが入力された場合にはロストフォーカスができなくなる(が、後続の処理を作る上では便利になる)。しかし、Birthday プロパティを string 型として定義した場合には、日付ではないデータ(例えば “19aA/s2/32f” などといった文字)が入力されていても、ロストフォーカスして別のテキストボックスに移動できる。(どちらも一長一短です。)
  • オブジェクトに一つもエラーがないか否かを確認するための public bool HasErrors; というプロパティを作っておくと便利。(ボタン押下時のイベントハンドラで、入力エラーが一つもないか否かを一発で確認できるためです。)
  • このクラスは、単体機能テストで動作確認を行うことを推奨。(UI から切り離されているクラスなので、実は単体機能テストが非常に書きやすいのです。)
    1: public class CustomerInput : IDataErrorInfo
    2: {
    3:     private Dictionary<string, string> _errors = new Dictionary<string, string>();
    4:  
    5:     private string _id;
    6:     public string ID
    7:     {
    8:         get { return _id; }
    9:         set
   10:         {
   11:             _id = value;
   12:             if (_id == null)
   13:             {
   14:                 _errors["ID"] = "ID は必須入力項目です。";
   15:             }
   16:             else if (Regex.IsMatch(value, @"^[0-9A-Z]{4}$") == false)
   17:             {
   18:                 _errors["ID"] = "ID は半角英数大文字 4 文字です。";
   19:             }
   20:             else
   21:             {
   22:                 _errors.Remove("ID");
   23:             }
   24:         }
   25:     }
   26:  
   27:     private string _name;
   28:     public string Name
   29:     {
   30:         get { return _name; }
   31:         set
   32:         {
   33:             _name = value;
   34:             if (_name == null || _name == "")
   35:             {
   36:                 _errors["Name"] = "名前は必須入力項目です。";
   37:             }
   38:             else
   39:             {
   40:                 _errors.Remove("Name");
   41:             }
   42:         }
   43:     }
   44:  
   45:     private string _email;
   46:     public string Email
   47:     {
   48:         get { return _email; }
   49:         set
   50:         {
   51:             _email = value;
   52:             if (value == null || Regex.IsMatch(value, @"\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*"))
   53:             {
   54:                 _errors.Remove("Email");
   55:             }
   56:             else
   57:             {
   58:                 _errors["Email"] = "電子メールアドレスとして有効な値を入力してください。";
   59:             }
   60:         }
   61:     }
   62:  
   63:     private string _phone;
   64:     public string Phone
   65:     {
   66:         get { return _phone; }
   67:         set
   68:         {
   69:             _phone = value;
   70:             if (value == null || Regex.IsMatch(value, @"(0\d{1,4}-|\(0\d{1,4}\) ?)?\d{1,4}-\d{4}"))
   71:             {
   72:                 _errors.Remove("Phone");
   73:             }
   74:             else
   75:             {
   76:                 _errors["Phone"] = "電話番号は (03)1234-5678 のように入力してください。";
   77:             }
   78:         }
   79:     }
   80:  
   81:     public DateTime? Birthday { get; set; }
   82:  
   83:     // 全体整合チェック
   84:     public string Error
   85:     {
   86:         get
   87:         {
   88:             if (_email == null && _phone == null)
   89:             {
   90:                 return "電子メールアドレスか電話番号かのいずれか一方は必須入力です。";
   91:             }
   92:             else
   93:             {
   94:                 return null;
   95:             }
   96:         }
   97:     }
   98:  
   99:     public bool HasErrors
  100:     {
  101:         get { return (_errors.Count != 0 || Error != null); }
  102:     }
  103:  
  104:     public string this[string columnName]
  105:     {
  106:         get
  107:         {
  108:             return (_errors.ContainsKey(columnName) ? _errors[columnName] : null);
  109:         }
  110:     }
  111: }

② データソースの登録と UI の構築

BindingSource コントロールと ErrorProvider を貼り付け、さらにテキストボックスなどを貼り付けていって UI を構築します。なお、オブジェクト全体に関するエラー(.Error プロパティの情報)は、ErrorProvider コントロールによる自動表示ができません(多分....)。このため、余白領域に全体エラー表示用のラベルを貼り付けておいてください。

image

③ コードビハインドの実装

あとは、bindingSource1 の .DataSource プロパティに実際のインスタンスを割り当てて、データ入力画面を作成します。全体エラーをリアルタイムに表示するために、bindingSource1 の BindingComplete イベントハンドラを利用していることに注意してください。

    1: public partial class Form3 : Form
    2: {
    3:     public Form3()
    4:     {
    5:         InitializeComponent();
    6:     }
    7:  
    8:     private CustomerInput _data;
    9:  
   10:     private void Form3_Load(object sender, EventArgs e)
   11:     {
   12:         _data = new CustomerInput();
   13:         bindingSource1.DataSource = _data;
   14:     }
   15:  
   16:     private void bindingSource1_BindingComplete(object sender, BindingCompleteEventArgs e)
   17:     {
   18:         lblError.Text = _data.Error;
   19:     }
   20:  
   21:     private void button1_Click(object sender, EventArgs e)
   22:     {
   23:         if (_data.HasErrors)
   24:         {
   25:             MessageBox.Show("入力データに誤りがあります。修正してください。");
   26:             return;
   27:         }
   28:  
   29:         // 単体入力チェックを通過したデータを使って
   30:         // XML Web サービス呼び出しなどを実施
   31:         MessageBox.Show("OK");
   32:     }
   33: }

実行結果を以下に示します。

image

なお、以上は単票形式データバインドに関しての実施方法を示しましたが、グリッド形式データバインドの場合でも同様の方法で実装することができます。以下の点に気をつけて実装してみてください。

  • ErrorProvider コントロールを貼り付ける必要はありません。(フィールド単位のエラー、インスタンス単位のエラーを自動的にアイコン表示してくれるようになっています。)
  • パースエラー(型変換エラー)に関してはフォーカス移動が抑止されず例外メッセージが表示されてしまう、という仕様になっているため、型変換エラー時のフォーカス移動を防止するイベントハンドラを組み込んでください。

image

    1: private void dataGridView1_DataError(object sender, DataGridViewDataErrorEventArgs e)
    2: {
    3:     // パースできない状態で他のセルに移動することは禁止する
    4:     if (e.Context == DataGridViewDataErrorContexts.Parsing) e.Cancel = true;
    5: }

ここまでに解説してきた内容を用いれば、単票形式およびグリッド形式で、単体入力エラーチェックをかけながらデータ入力を行わせる Windows フォームを開発していくことができるはずです。というわけで以上で解説はおしまい……としたいところなのですが、もうひとつ解説しておかなければならないことがあります。それは、DTO (Data Transfer Object)と UI バインドオブジェクトの違いです。最後に、このことについて解説します。

[DTO と UI バインドオブジェクトの違い]

一般に、マスタメンテナンスのように、

  • サーバから一括してデータを取り寄せ、
  • クライアント内でデータをまとめて書き換えて、
  • サーバ側にそれを再度アップロードして一括データ更新を行う。

といったタイプのスマートクライアントアプリケーションでは、通常、型付きデータセットを使ったデータのやり取りが行われます。

image

一般に、クラス間やプロセス間でデータをごそっと引き渡すために使う BEC(ビジネスエンティティクラス)のことを、DTO (データトランスファオブジェクト) と呼びます。.NET Framework によるアプリケーション開発では、DTO として使えるオブジェクトとして、データセット及び型付きデータセットが用意されており、これを使うと、一括してデータを引き渡せる上に、楽観同時実行制御に基づくデータ更新処理も作りやすくなるというメリットがあります(これについての詳細は、拙著「Visual Studio 2005によるWebアプリケーション構築技法」の 第13章「楽観同時実行制御による対話型トランザクション処理の開発」を見てください)。

が、ここで重要なのは、だからといって Windows フォーム上で、サーバから取り寄せた型付きデータセットを直接 DataGridView にバインドしてはいけない(しない方がよい) 、という点です。

例えば、データベース上の書籍マスタを編集する、下図のような画面を考えてみてください。

image

この例の場合、XML Web サービスから書籍データを含む型付きデータセットを取り寄せてデータバインドしたくなる……と思うのですが、データの更新処理を行うことを念頭に置いた場合、データセットを直接グリッドにバインドしてしまうと、入力エラー制御のコードを場当たり的に書かざるを得なくなってしまいます。(表示するだけであればデータセットをバインドしてもよいのですが、データを入力させることを考えた場合、単体入力チェックのコードを一か所に固めることが難しい)

このような場合には、XML Web サービスから取り寄せた型付きデータセットのデータを、UI バインド用の IDataErrorInfo オブジェクトに移し替えてバインドした方が、むしろコードがすっきりします

image

なぜこのようなことが起こるのかというと、DTO と UI バインド用オブジェクトには以下のような特性の違いがあり、DTO を UI バインド用オブジェクトにそのまま転用できないことがほとんどだからです。

DTO (データトランスファオブジェクト)

  • 「データベーススキーマ」と同様の検証ロジックが搭載されていることが多い
    例) データベース上で必須なら、null 値を受け付けない
  • 入力値にエラーがある場合は例外を発生させ、値そのものを受け付けないことが多い
    例) 単体入力エラーを含むデータを設定した場合、ArgumentException 例外が発生する
  • その際、懇切丁寧なガイダンスメッセージも表示しない

UI バインド用オブジェクト(IDataErrorInfo オブジェクト)

  • UI 上の表示との同期を常に取るために、不正な入力値も受け付けなければならないこともある
    例) 必須入力フィールドであっても、一時的に null を受け付ける
  • 入力値にエラーがある場合には、エラーメッセージをどこかで作成しなければならない

つまり、データ参照(表示)だけなら DTO を直接バインドしても問題はありませんが、更新処理を含む場合には、DTO は UI バインドオブジェクトとしての要件を満たさないのです。このため、更新系アプリケーションでは、DTO と UI バインドオブジェクトを分けなければならないことが多い、ということになります。

なお実際のアプリケーションでは、データを取り寄せてきて表示するタイミングでは DTO → UI バインドオブジェクトへのデータコピーを、またサーバへの書き戻しのタイミングでは UI バインドオブジェクトから DTO へのデータコピーを行う必要があります。この処理に関しては、LINQ to Objects などを使うと便利だと思います。(for ループ回してもたかがしれていますが^^)

    1: TitlesMaintenance.EditTitlesService.EditTitlesDataSet _originalData = null;
    2: List<TitleInput> _titleInputs;
    3:  
    4: private void btnGetData_Click(object sender, EventArgs e)
    5: {
    6:     EditTitlesService.EditTitlesWebService proxy = new EditTitlesService.EditTitlesWebService();
    7:     _originalData = proxy.GetData();
    8:     _titleInputs =
    9:         (from t in _originalData.titles.Cast<EditTitlesService.EditTitlesDataSet.titlesRow>()
   10:          select new TitleInput
   11:          {
   12:              title_id = t.title_id,
   13:              title = t.title,
   14:              price = (t.IspriceNull() ? (decimal?)null : t.price),
   15:              pubdate = t.pubdate
   16:          }).ToList();
   17:     bindingSource1.DataSource = _titleInputs;
   18: }
   19:  

# 実はこの点は、私自身が書籍を読んで勉強していて昔からずーっとひっかかっていたことでした。
# そもそも DB 上の非 NULL フィールドでも、UI 上から入力される場合には一時的に空欄にする
# ことが当然あるわけで、このようなケースをどうハンドリングすればいいのか? ……というのを
# 突き詰めて考えていって、要するに、DTO と UI バインドオブジェクトは本質的に違うものだ、という
# 結論に自分は達しました。そのことに気付いたのは IDataErrorInfo インタフェースの仕様。
# 値にエラーがあるときに string 型を返す、という設計は一般的にはよくないはず(コードを返して
# UI 部でメッセージに変換すべき)なのですが、string 型にしているのは、UI 部でしか使わない、
# という前提条件に立っているから、なんですよね。実際に、DTO と UI バインドオブジェクトを分けて
# 実装してみると、データの移し替えの手間はかかるものの、UI 部のコードビハインドのコードが
# ものすごくすっきりするのでかなりびっくりしました。……とつぶやいてみる。

[今回のエントリのまとめ]

というわけで、今回のエントリをまとめると、以下のようになります。

  • スマートクライアントアプリケーションなどにおけるエラーは、以下の 3 種類に大別される。
    ① 単体入力エラー : UI 内部のみで単体で正誤判定できるもの
    ② 業務エラー : サーバやデータベースまで連携しないと正誤判定できないもの
    ③ システムエラー : システムインフラの不具合やアプリケーションバグで発生するもの
    このうち、①を実装する際には、Windows フォームの双方向データバインドを活用すると便利。
  • 単体入力チェックは、さらに以下の 3 つに分類することができる。
    1. データ型変換チェック
    2. フィールド単位の有効性チェック
    3. インスタンス(レコード)単位の有効性チェック
    このうち、1. についてはロストフォーカスを認めず、2., 3. については即座にエラーアイコンやツールチップを使って通知を行う UI を作りたいと思った場合には、IDataErrorInfo オブジェクトによる双方向データバインドを行うとよい。
  • IDataErrorInfo とは、以下のような特性を持つ UI バインド用オブジェクトを作るためのインタフェースである。
    A. UI からの入力値を、エラーがあろうとなかろうとそのまま受け取る
    B. そのかわりに、内部データにエラーが含まれる場合には、エラーに関する情報を文字列データで返すようにする。
    image
  • IDataErrorInfo インタフェース、BindingSource コントロール、ErrorProvider コントロールによる即時エラー通知は、単票形式データバインドでもグリッド形式データバインドでも利用することができる。
    image
    image
  • DTO (データ転送用オブジェクト)と、UI バインド用オブジェクトとを混同してはならない。.NET Framework での開発の場合には、前者に型付きデータセットが、後者に IDataErrorInfo オブジェクトが利用される。

というわけで、Windows フォームにおける双方向データバインドを活用した単体入力データ検証の実装方法について解説してきましたが、ここで解説した IDataErrorInfo を使う方式は、WPF でもほぼ同じになります。今回は WPF の場合についての解説は割愛しますが、興味がある方は以下の記事を参照することをおすすめします。

何かと場当たり的な実装がされることが多い Windows フォームのデータ入力検証ですが、.NET Framework 3.5 で導入された IDataErrorInfo インタフェースを使うと、コードをかなりきれいな形に持っていくことができると思いますし、さらにひと工夫を行えば、IDataErrorInfo インタフェースを実装するクラスを作ることもより容易化できると思います。ぜひ本エントリを活用して、Windows フォームの実装コードを少しでも美しい形にしていただければと思います。