さて、前回のエントリでは Windows フォームにおける双方向データバインドの基本的な使い方を解説しました。要点をまとめると、以下の通りとなります。
さて前回のエントリでは、 双方向データバインドにより、テキストボックスから入力された値がデータソースオブジェクトに反映されることを確認しました。しかし、これらのデータはそのまま使えるとは限りません。例えば配達希望日を入力するテキストボックスの場合、
といった問題があるため、入力されたデータ値は検証を行った上で利用する必要があります。しかし、こうしたデータ入力検証を場当たり的に実装すると、コード量が膨大に膨れ上がり、アプリケーションの保守性も極端に悪化します。これを避けるためには、データ入力検証に関して、アプリケーション全体で一貫した考え方を使う必要があります。
本エントリでは、スマートクライアント(Windows フォーム)における、業務アプリケーションを想定した入力データ検証の考え方と、その実装方法について解説します。
なお、今回の実装サンプルコードはこちらになります。
では、以下に解説していきましょう。
[エラーの分類]
Web アプリ、Windows アプリすべてに共通する考え方ですが、業務アプリケーションにおける「エラー」は、以下の 3 種類に分類されます。
スマートクライアントの場合を考えてみると、単体入力エラーに関してはサーバ側へ通信を行うまでもなく、UI 部(Windows フォームアプリケーション)の中で即時にチェックを行い、すぐさまエンドユーザにエラー情報通知を行うのが望ましいでしょう。
[単体入力エラーの分類]
さて、UI 部のみで単体チェックが可能な「単体入力エラー」ですが、実はこの単体入力エラーや単体入力チェックは、さらに 3 種類に細分化することができます。例えば下図のような、新規顧客登録画面を考えてみましょう。
この画面において実施する必要のある単体入力チェックは、以下の 3 種類に分類できます。
そして単体入力チェックでは、これらのチェックを、場当たり的ではない考え方で実装する必要があります。
[単体入力チェックとエラーメッセージの関係]
またもう一点重要なポイントとして、UI 部における単体入力エラーチェックでは、エラー発見時に即時にユーザに対するガイダンスメッセージ表示を行う必要があります。
このエラーメッセージ表示に関しては、以下のポイントに留意する必要があります。これらが満たされていないと、エンドユーザにとって使いにくい画面になってしまいます。
こうした入力データ検証を実装しやすくするための機能として、.NET Framework 3.5 から追加されたのが、IDataErrorInfo インタフェースと呼ばれる機能(と、それに関連する BindingSource クラスの機能強化)です。が、これを説明する前にもうひとつ押さえておくべきことがあります。それは、双方向データバインドにおける値の同期の考え方です。
[双方向データバインドにおける値の同期の考え方]
もともとデータバインドというのは、二点間の値を常に同じに保つという意味を持っています。そして双方向データバインドの場合には、テキストボックスから入力された値をデータソースに反映することで、二点間のデータ値をリアルタイムに同期しようとします。
ここで、年齢を入力できるよう、テキストボックスと int 型のデータとを双方向データバインドする場合を考えてみます。この場合、まず初期表示ではデータソース→UI にデータが表示されるので特に問題は生じません。しかし、テキストボックスから数値以外の文字列が入力された状態でロストフォーカスを認めてしまうと、テキストボックス上のデータと、データソースの値とにずれが生じてしまいます。このようなデータずれが生じないよう、Windows フォームのデータバインドでは、データずれが生じるようなロストフォーカスを認めないようになっています。このようにすることで、(入力仕掛かりの状態を除けば)二点間のデータ同期を保つわけです。
さて、そもそも 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 になります。下図を見てみてください。
テキストボックスから –5 を入力した場合、これは当然 Author オブジェクトに反映できません。となると、ロストフォーカス時に、テキストボックスとデータソースの値の同期を取るためには、
のどちらかを取る必要があります。しかし、これでは使いやすい UI を実現することはとても不可能です。このような問題を解決するのが、IDataErrorInfo インタフェースです。
[IDataErrorInfo インタフェースとは何か]
IDataErrorInfo インタフェースは、簡単に言うと、以下のような特性を持ったオブジェクトのクラスを作成するために使うインタフェースです。
1: public interface IDataErrorInfo
3: public string Error { get; }
4: public string this[string propertyName] { get; }
5: }
このインタフェースを実装する前の Author オブジェクトと、実装した後の Author オブジェクトの比較コードを下記に示します。
IDataErrorInfo インタフェースを実装する前の Author オブジェクト
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
5: public int? age { get; set; }
6:
7: public string Error
9: get { return null; }
10: }
11:
12: public string this[string columnName]
13: {
14: get
15: {
16: switch (columnName)
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: }
実装上のポイントは以下の通りです。
このようなオブジェクトを UI にバインドした上で、さらに ErrorProvier コントロールを画面に貼り付けると、ErrorProvider コントロールが自動的に IDataErrorInfo インタフェースからエラー情報を取り出し、ツールチップ形式でエラーメッセージを表示してくれるようになります。
具体的な実装例を以下に示します。(ErrorProvider コントロールの DataSource プロパティに、BindingSource オブジェクトを割り当てることを忘れずに。)
1: public partial class AuthorForm : Form
3: public AuthorForm()
4: {
5: InitializeComponent();
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)
18: lblError.Text = _author.Error;
19: }
20: }
このようにすれば、以下のことが実現できます。
[具体的な単体入力チェックの実装方法]
ではもうひとつの具体的な実装例として、最初に挙げた顧客データ入力フォームの例を採り上げてみましょう。
このようなアプリケーションは、以下の手順で実装していきます。
① データバインド用の IDataErrorInfo オブジェクトの実装
まずは、IDataErrorInfo インタフェースを実装した、UI 双方向データバインド用のクラスを作成します。なお、実装上、以下の点にも気を付けるとよいでしょう。
1: public class CustomerInput : IDataErrorInfo
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)
14: _errors["ID"] = "ID は必須入力項目です。";
15: }
16: else if (Regex.IsMatch(value, @"^[0-9A-Z]{4}$") == false)
18: _errors["ID"] = "ID は半角英数大文字 4 文字です。";
20: else
21: {
22: _errors.Remove("ID");
23: }
24: }
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"] = "名前は必須入力項目です。";
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 コントロールによる自動表示ができません(多分....)。このため、余白領域に全体エラー表示用のラベルを貼り付けておいてください。
③ コードビハインドの実装
あとは、bindingSource1 の .DataSource プロパティに実際のインスタンスを割り当てて、データ入力画面を作成します。全体エラーをリアルタイムに表示するために、bindingSource1 の BindingComplete イベントハンドラを利用していることに注意してください。
1: public partial class Form3 : Form
3: public Form3()
8: private CustomerInput _data;
10: private void Form3_Load(object sender, EventArgs e)
12: _data = new CustomerInput();
13: bindingSource1.DataSource = _data;
18: lblError.Text = _data.Error;
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: }
実行結果を以下に示します。
なお、以上は単票形式データバインドに関しての実施方法を示しましたが、グリッド形式データバインドの場合でも同様の方法で実装することができます。以下の点に気をつけて実装してみてください。
1: private void dataGridView1_DataError(object sender, DataGridViewDataErrorEventArgs e)
3: // パースできない状態で他のセルに移動することは禁止する
4: if (e.Context == DataGridViewDataErrorContexts.Parsing) e.Cancel = true;
ここまでに解説してきた内容を用いれば、単票形式およびグリッド形式で、単体入力エラーチェックをかけながらデータ入力を行わせる Windows フォームを開発していくことができるはずです。というわけで以上で解説はおしまい……としたいところなのですが、もうひとつ解説しておかなければならないことがあります。それは、DTO (Data Transfer Object)と UI バインドオブジェクトの違いです。最後に、このことについて解説します。
[DTO と UI バインドオブジェクトの違い]
一般に、マスタメンテナンスのように、
といったタイプのスマートクライアントアプリケーションでは、通常、型付きデータセットを使ったデータのやり取りが行われます。
一般に、クラス間やプロセス間でデータをごそっと引き渡すために使う BEC(ビジネスエンティティクラス)のことを、DTO (データトランスファオブジェクト)と呼びます。.NET Framework によるアプリケーション開発では、DTO として使えるオブジェクトとして、データセット及び型付きデータセットが用意されており、これを使うと、一括してデータを引き渡せる上に、楽観同時実行制御に基づくデータ更新処理も作りやすくなるというメリットがあります(これについての詳細は、拙著「Visual Studio 2005によるWebアプリケーション構築技法」の 第13章「楽観同時実行制御による対話型トランザクション処理の開発」を見てください)。
が、ここで重要なのは、だからといって Windows フォーム上で、サーバから取り寄せた型付きデータセットを直接 DataGridView にバインドしてはいけない(しない方がよい)、という点です。
例えば、データベース上の書籍マスタを編集する、下図のような画面を考えてみてください。
この例の場合、XML Web サービスから書籍データを含む型付きデータセットを取り寄せてデータバインドしたくなる……と思うのですが、データの更新処理を行うことを念頭に置いた場合、データセットを直接グリッドにバインドしてしまうと、入力エラー制御のコードを場当たり的に書かざるを得なくなってしまいます。(表示するだけであればデータセットをバインドしてもよいのですが、データを入力させることを考えた場合、単体入力チェックのコードを一か所に固めることが難しい)
このような場合には、XML Web サービスから取り寄せた型付きデータセットのデータを、UI バインド用の IDataErrorInfo オブジェクトに移し替えてバインドした方が、むしろコードがすっきりします。
なぜこのようなことが起こるのかというと、DTO と UI バインド用オブジェクトには以下のような特性の違いがあり、DTO を UI バインド用オブジェクトにそのまま転用できないことがほとんどだからです。
DTO (データトランスファオブジェクト)
UI バインド用オブジェクト(IDataErrorInfo オブジェクト)
つまり、データ参照(表示)だけなら 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)
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
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 部のコードビハインドのコードが # ものすごくすっきりするのでかなりびっくりしました。……とつぶやいてみる。
[今回のエントリのまとめ]
というわけで、今回のエントリをまとめると、以下のようになります。
というわけで、Windows フォームにおける双方向データバインドを活用した単体入力データ検証の実装方法について解説してきましたが、ここで解説した IDataErrorInfo を使う方式は、WPF でもほぼ同じになります。今回は WPF の場合についての解説は割愛しますが、興味がある方は以下の記事を参照することをおすすめします。
何かと場当たり的な実装がされることが多い Windows フォームのデータ入力検証ですが、.NET Framework 3.5 で導入された IDataErrorInfo インタフェースを使うと、コードをかなりきれいな形に持っていくことができると思いますし、さらにひと工夫を行えば、IDataErrorInfo インタフェースを実装するクラスを作ることもより容易化できると思います。ぜひ本エントリを活用して、Windows フォームの実装コードを少しでも美しい形にしていただければと思います。