-
さて Part 1. のエントリでは、業務処理の終了パターンの分類と、各アプリケーションタイプにおける基本的な実装パターンを整理しました。要点をまとめると、以下のようになります。
- 業務処理の終了パターンは、以下のように分類される。
- 突き合わせエラーについては、バックエンドのモジュール(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 に送出する
実装テクノロジによる差異は、下線部のやり方の部分に出てきます。この点を意識しながら、以降の解説を読んでください。
※ (参考)なお本エントリは、各テクノロジでの単体入力エラーチェックの実装方法について、ある程度知識がある、という前提で解説を進めます。もし、各テクノロジでの単体入力エラーチェックの実装方法をまったく知らないという場合には、以下の情報を併読されることをお勧めします。
※ (注意)また本エントリは、各データ検証方式の考え方の違いを明確化することを狙っていますので、解説をかなり単純化しています。例えば、Silverlight 3 には、①に近いデータ検証を可能とする ValidationRule や、属性ベースでデータ検証を行う DataAnnotation などの機能が備わっていますが、これらについては触れません。詳細にデータ検証をご存じの方は「え゛ー?」とツッコミ入れたいところがたくさんあると思いますが、そこはちょっとだけ目をつぶっていただけるとうれしいです^^。
では、以下に順番に解説していきます。
[① ASP.NET Web フォームの場合:入力検証コントロール]
ASP.NET Web フォームの場合、単体入力チェックは検証コントロールを使って実装します。
- 4 種類の標準のチェックロジックが用意されています。
(必須入力チェック、フォーマットチェック、比較チェック、範囲チェック) - 上記の 4 種類でカバーできないチェックは、CustomValidator を使って自力で実装します。
(インスタンス単位の単体入力チェックなどは、CustomValidator で実装します)
この場合の、UI 部のコードビハインドの制御コード(ボタン押下のイベントハンドラのコード)は以下のようになります。
このコードについて、改めてじっくり考えてみると、以下のような特徴があることがわかります。
- ASP.NET Web フォームの検証コントロールは、「テキストボックスに、適切な値を作る」ように動作します。
- 検証コントロールによるチェックを通過できていれば(IsValid = true なら)、データ変数への取り出しや型変換などで失敗したりすることは絶対にありません。つまり、コードビハインド内で値をテキストボックスから取り出す際には、すでに単体入力チェックが終わっている状態になっている、ということになります。
- ただし、UI からコードビハインド内へのデータ取り出し作業自体は、自力で記述する必要があります。
上記のような特性は、Silverlight や WPF、Windows フォームなどとは全く異なります。
まず、一般的に、Silverlight, WPF, Windows フォームといった、リッチクライアント系のアプリケーション開発技術では、通常、双方向データバインドと呼ばれるテクニックを用いて、データ検証とデータ取り出しを同時に行います。
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 の場合について解説します。これらの場合には、以下のようにして単体入力チェックロジックを実装します。
- バインドするオブジェクト側に、フィールド単位のデータチェックロジックを持たせる。
具体的には、下図 A のように、バインドオブジェクトのプロパティ setter に対して、フィールド単位のチェックロジックを持たせる。もし、UI から不適切なデータが投入された(テキストボックスから不適切な値が入力された)場合には、例外(通常は ArgumentException 例外)を throw し、値を受け取らないようにする。 - 双方向データバインドの "ValidatesOnException" 機能を使う。
具体的には、下図 B のように、UI 部(XAML コード)にて、バインドするオブジェクトの各プロパティと、UI 項目との紐付けを行う。これにより、UI 部から入力された値が、バインドされたオブジェクトに自動反映されるようになる。ここで、ValidatesOnException 機能を有効化しておくと、バインドオブジェクトのプロパティへの反映時に失敗した場合(=例外が throw された場合)、これをエラーメッセージとして赤枠やツールチップにより表示してくれるようになる。
(※ エラーメッセージを赤枠やツールチップ表示するためには適切なスタイル定義が必要ですが、これについてはサンプルコードを参照してください。)
A. 例外ベース双方向データバインドで利用する、バインドオブジェクトの実装例
B. 例外ベース双方向データバインドでの、双方向データバインドの実装例(UI 部)
さて、一見するとわかりやすそうなこの実装方法ですが、実際には厄介な問題を抱えています。それが、UI 上に実際に表示されている値と、バインドされたオブジェクトが持っている値とのずれです。
例えば上記のアプリケーションに対して、下記のような操作を行った場合(オブジェクトへの反映に成功したり失敗したりするケースが混在する場合)を考えてみてください。
- 顧客 ID として “3214” を設定する。(→ 反映に成功する)
- 顧客 ID を “12345” に変更する。(→ 顧客 ID は 4 桁英数大文字のため、反映に失敗する)
- 顧客名として “Nobuyuki” を設定する。(→ 反映に成功する)
- 生年月日として “1973/06/07” を設定する。(→ 反映に成功する)
- 生年月日を “1973/55/41” に変更する。(→ 日付として正しくないため、反映に失敗する)
この場合、UI 上に表示されている値と、バインドされたオブジェクトの中に設定されている値とがずれています。このため、業務処理のために UI から入力された値を使おう、と思った場合には、まず、双方向データバインドにエラー(反映失敗)があるか否かを確認する必要があります。バインドされたオブジェクトの中に入っている値をいきなり使うと、実は UI から入力された過去の正しい値を使ってしまうことがある、ということになってしまいます。
また、次のような問題もあります。一般的なデータエントリシートの場合、最初に画面を表示した際には何も記入されていないのが普通でしょう。しかし、そのためには、バインドされたオブジェクト側が空の状態(例えば null や空文字が入っている)でなければなりません。がしかし、このようなオブジェクトは、そもそも値として、本来正しくない値を抱えている状態になっています。
また、インスタンス単位の単体入力チェックを行うロジックについては、バインドオブジェクトに持たせることができません(この例だと電話番号と電子メールアドレスの少なくとも片方が入力されている、というチェック)。なぜなら、電話番号と電子メールの入力項目は、UI からずれたタイミングでひとつずつバインドオブジェクトに反映されてくるため、バインドオブジェクト側のフィールドに持たせることが困難だからです。
こうした事情から、例外ベースの双方向データパインドでは、UI 部のボタン押下のイベントハンドラを、以下のように実装することになります。
- まず、バインドにエラーが発生していないか否かをチェックし、フィールド単位の単体入力エラーがあるか否かをチェックする。
- 次に、バインドされたオブジェクトを見て、インスタンス単位の単体入力エラーがあるか否かをチェックする。
- 最後に、バインドされたオブジェクトに含まれるデータを使って、業務処理を行う。
つまり、ここまでの解説をまとめると、例外ベースの双方向データバインドの動作イメージは以下の通りになります。
- バインドエラーがない場合に限り、UI からの入力がすべてバインドオブジェクトに反映されている、という動作になる。このためイベントハンドラ内では、まずバインドエラーのチェックが必要。
- 仮にバインドエラーがなかったとしても、インスタンス単位のチェックをイベントハンドラ内で行う必要がある。
例外ベースの双方向データバインドでは、バインドオブジェクト側に、例外を使った検証ロジックを持たせているのですが、これは、バインドオブジェクトが不正な状態になることがないようにする、という考え方に基づいています。この考え方は、それだけ見ると、一般的なオブジェクト指向設計の考え方からして特に間違ってはいません。ところが、双方向データパインドは、UI 表示とバインドオブジェクトの内容との二点間同期を保つ、という考え方に基づいているため、根本的なところで概念的な相反があります。このため、上記のような厄介な実装上の工夫を行わなければならなくなるのだろうと思います。
しかし次に解説する、IDataErrorInfo ベースの双方向データバインドでは、このような概念的な相反は発生しません。
[③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド]
引き続き、Windows フォーム 2.0 や WPF 3.5 で導入されている、IDataErrorInfo ベースの双方向データバインドについて解説します。
IDataErrorInfo ベースの双方向データバインドでは、バインドオブジェクト側に、IDataErrorInfo というインタフェースを持たせます。このインタフェースは、オブジェクトインスタンス内部にエラーが含まれていることを、文字列情報として返すためのもので、これを使うことにより、前述の問題をきれいに解決することができます。
IDataErrorInfo インタフェースを持つバインドオブジェクトの実装例は後述しますので、まず先に概念図を示しましょう。IDataErrorInfo ベースの双方データパインドでは、以下のようにしてデータバインドを行います。
- 入力値が正しかろうと間違っていようと、とにかくオブジェクトに反映してしまう。
- オブジェクトインスタンスが不正な状態にある場合には、これを IDataErrorInfo インタフェースから公開する。
- これにより、常に UI とオブジェクト内の値とが同期される。
前述したように、双方向データバインドは、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 インタフェースにかかわる部分ですが、コードのポイントをピックアップすると以下のようになります。
- バインドオブジェクトの各プロパティは、たとえ単体入力エラーがあるデータであったとしても、とりあえずデータを受け取ります。かわりに、内部にエラー情報(エラーメッセージ)を蓄積しておきます。
- IDataErrorInfo インタフェースには、Error プロパティ(オブジェクトインスタンス全体にかかわるインスタンス単位の単体入力エラー情報を返すためのもの)と、プロパティ名を使ったインデクサ(フィールド単位の単体入力エラー情報を返すためのもの)があります。これらを使って、単体入力エラー情報を UI 部に対して返します。

Windows フォーム 2.0 を使う場合には、UI 側に ErrorProvider コントロールを張り付けておきます。このようにしておくと、ErrorProvider コントロールがバインドされたオブジェクトの IDataErrorInfo インタフェースから自動的にエラー情報を取り出し、画面上にエラーメッセージを表示してくれるようになります。(※ 実装方法の詳細は、こちらのエントリを見てください。)
また、バインドされたオブジェクトにエラーがあるか否かは、バインドオブジェクトのみを見れば簡単に調べることができます。このため、UI 部のイベントハンドラ(Button_Click イベント)のコードは、以下のように非常に簡単になります。
このように、IDataErrorInfo インタフェースベースの双方向データバインドを使うと、綺麗な形での単体入力データチェックが実装できます。全体像を示すと以下の通りになります。
スマートクライアントにおける、双方向データバインドと IDataErrorInfo インタフェースを用いた単体入力チェックロジックの実装モデルには、以下のような特徴があります。
- 単体入力チェック処理を、バインドオブジェクトに固めることができる。
このため、モジュールの役割分担が明確になる上に、単体入力チェックロジック部分だけを重点的に単体機能テストすることもできます。
- コードビハインドの記述が簡単になる。
コードビハインドのイベントハンドラでは、バインドオブジェクトだけを操作すればよく、UI コントロールを触る必要がなくなります。このため、コードビハインドのコードの見通しも非常によくなります。
- 入力仕掛り状態の維持が簡単にできる。
バインドオブジェクトをそのままシリアル化して保存すれば、入力しかけのデータをそのまま保存しておくこともできます。
実装モデルが非常に綺麗になるので、ぜひ覚えておくとよいでしょう。
※ (注意) このモデルは Windows アプリケーションなどでは有効ですが、Web アプリケーションでは有効ではありません。なぜなら、Web アプリケーションでは、データが入力される場所(=ブラウザ上)と、データを取り出す場所(=サーバサイド)が分かれており、UI からリアルタイムでデータを取り出すことができないためです。
[3 つの単体入力チェック方式の比較]
さて、ここまでの解説を整理しつつ比較してみると、3 つの単体入力チェック方式には以下のような違いがあることがわかります。
ここで重要なのは、単体入力チェックモデルの優劣を議論することではありません。というのも、ぶっちゃけ、どのモデルを使ったところで単体入力チェックは実装できるわけで、好みの違いはあれど、どのモデルがより優れている、といった議論は宗教論争になりかねません;。そうではなくて、自分が業務アプリケーションを実装する際に、どのモデルを使って単体入力チェックを実装しようとしているのかを意識することが重要です。実際、.NET Framework の中に標準で含まれるデータ入力検証フレームワークを見ても 3 通りはあるわけで(実は私が気付いていないもっと別のモデルもあるかもしれません…とつぶやいておく;)、これらをごちゃまぜにしたような実装は避けなければなりません。
アプリケーションを実装する際は、一貫性が非常に重要です。どの方式を選ぶにせよ、ある特定のアプリケーションの中では「このパターンで実装する」といった具合に、モデルを定めて実装するようにしてください。
※ (参考) さらに追加のつぶやきですが、よくこうした単体データ入力検証フレームワークに関して、「○○のタイミングでエラーメッセージを表示できるようにできませんか?」「○○のような方式でエラーメッセージを表示できるようにできませんか?」といったことを聞かれます。こうしたカスタマイズは、できる場合とできない場合とがあります。というのも、もともとフレームワークというものは、「動作モデルに制約を加えるかわりに、開発生産性を大きく向上させよう」というコンセプトで作られているものであって、「どんなふうに動作させるものであっても開発生産性がよくなるもの(万能薬)」ではないからです。もし、.NET Framework などが標準で備える入力検証フレームワークの動作ではお客様要件を満たせない、ということであれば、独自に単体データ入力検証フレームワークを作成するか、または既存の単体データ入力検証フレームワークにカスタマイズを加えるしかありません。一般には、こうした問題が極力発生しないように、UI 設計段階(=業務設計段階)から、ある程度実装効率というものを意識して、フレームワークの想定している動作に併せた形での設計を行うようにします。
[まとめ]
というわけで、ここまで .NET Framework が備えている各種の単体データ入力検証フレームワークに関して、その実装モデルの違いを解説してきましたが、最も重要なポイントをまとめると、以下のようになります。
- 単体データ入力検証フレームワークを使う上では、そもそも業務エラーとシステムエラーの分類や、単体入力エラーの分類を正しく行うことが必要になる。
- .NET Framework が備えている各種の単体入力エラーチェック機能は、下図の枠線内の実装(開発効率)を高めるためのものである。
また、単体入力チェックに対するアプローチは、ランタイムによってかなり異なります。
- ① ASP.NET Web フォーム : 入力検証コントロール
検証コントロールを使って、「正しい文字列」を作成する方式
- ② Silverlight 3, WPF 3 : 例外ベースの双方向データバインド
双方向データバインドを使うものの、反映に失敗するケースがある方式
- ③ Windows フォーム 2.0, WPF 3.5 : IDataErrorInfo ベースの双方向データバインド
双方向データバインドを使うが、反映に失敗するケースがない方式
これらはそれぞれに特徴があるので、データ検証に対する考え方をよく理解した上で活用することが重要です。本エントリを参考にして、さらに優れた業務アプリケーション開発を目指していただければ幸いです。
-
まず最初のエントリでは、「エラーチェック」とひとくくりにされている「エラー」を、体系的に分類することを試みてみます。このエントリでは、Web / Windows、あるいは Java / .NET などといった技術論とは無関係な部分についての解説を進めていきたいと思います。
- エラーチェック(ユーザ入力検証)の意味
- 正常終了/業務エラー/システムエラーの分類
- 業務エラーの細分化
- アーキテクチャから見たエラーチェックの実装場所
※ なお、本エントリで解説されている分類方法や命名方法は、あくまで nakama 個人の考え方・整理方法です。もしかしたらもっとよい設計パターンなどがあるかもしれませんので、その辺についてはあしからずご了承ください;。
[エラーチェック(ユーザ入力検証)の意味]
まずは、そもそもどのようなケースでエラーチェックが必要になるのか、ユーザ入力検証にどのような目的があるのかを考えてみましょう。
一般的に、業務アプリケーションは、参照系アプリケーションと更新系アプリケーションに大別されます。
参照系と更新系では、求められるアプリケーションの機能が大きく異なりますが、更新系アプリケーションでは、適切な入力エラーチェックをどのように実装するのかが、極めて重要なポイントになります。そもそもなぜ更新系アプリケーションでは、適切な入力エラーチェックが重要になるのか? ユーザ入力検証(エラーチェック)には、大別して以下の 2 つの目的があります。
- アプリケーションの保護
ユーザから入力された値をそのまま利用すると、エラーやセキュリティ脆弱性の原因になってしまうため、適切にフォーマットチェックなどを行う必要があります。(SQL 挿入、Cross-Site Scripting など) - ユーザビリティの向上
エンドユーザに親切なエラーメッセージを表示するように作成すると、使いやすいアプリケーションを実現することができます。
しかし、このようなユーザ入力検証機能(エラーチェック機能)を場当たり的に実装すると、開発生産性を大きく損なうことになります。このため、通常のアプリケーション開発では、ランタイムが持っている「ユーザ入力検証のための機能」をうまく活用して実装していくことが重要になるわけです。
こうしたランタイムのユーザ入力検証機能を利用する上で欠かせないのが、以下の 3 つのポイントに関する知識です。
- アプリケーションの終了パターンの分類
正常終了/業務エラー/システムエラーを正しく分類できること。
業務エラーが、さらに単体入力エラーと突合せエラーに分類されることを知っていること。 - アーキテクチャ的な観点から見た、エラーチェックの実装方法
論理 3 階層型アプリケーションにおいて、どこで何をチェックすべきかを判断できること。 - ランタイムが持つバリデーション機能(エラーチェック機能)の狙い
ランタイムが持つバリデーション機能のコンセプトの違いを理解していること。
どの部分をカバーする目的で作られているのか、どこまでできるのか、その限界点を理解していること。
本エントリでは、まずこのうち最初の、「エラーパターンの分類」の部分について解説します。
[正常終了/業務エラー/システムエラーの分類]
業務アプリケーションの「入力エラー」を考える上で欠かせないのが、ひとくくりに「エラー」とされているものを体系的に分類することです。これを考えるには、業務処理が終了するパターンを、以下の 3 つに分類するのが最初のスタートポイントになります。(※ この終了パターン分類は、nakama による勝手な分類で、業界標準でもなんでもありません;。あしからずご了承ください、とつぶやいてみる^^)
- 正常終了 : 特に問題なく、期待通りに業務処理が終了できた
- 業務エラー : ユーザ入力値の問題で、処理が完遂できなかった
- システムエラー : システムトラブルで、処理が完遂できなかった
業務アプリケーションの振る舞いを、まずこの 3 通りに分類することは極めて重要なので、具体例を取り上げながら考えてみましょう。
例えば、下図のような、重複顧客 ID を認めていないような、新規顧客登録業務を考えてみます。この場合、ユーザが画面からデータを入力し、ボタンを押下した場合の終了パターンは
- 正常に顧客情報を登録できたパターン → 正常終了
- 指定された顧客 ID がすでに他で使われていたパターン → 業務エラー
- データベースサーバに接続できなかった → システムエラー
の 3 通りに分類することができます。
この終了パターンの分類は、以下のように考えるとわかりやすいでしょう。一般的に、業務画面でボタンを押下した場合、その終了パターン(終了に応じた画面の表示方法)は、下図のように 3 通りに分けられます。この分類に当てはめれば、簡単に終了パターンを分類できると思います。
- 正常終了して、当該画面内に黒文字でメッセージを表示する。→ 正常終了
- 何かしらの問題があり、当該画面内に赤文字でメッセージを表示する。→ 業務エラー
- 何かしらの問題があり、別画面の「ごめんなさい」メッセージ画面(誰かがお詫びしているような画面、イメージ的には下図みたいな感じ^^)を表示する。→ システムエラー
[業務エラーの細分化]
さて、上述した業務エラーは、実はさらに以下のように分類することができます。(なお、以下の業務エラーの分類も、nakama による勝手な分類です。名称も一般的なものではないのでご注意ください;。)
- 単体入力エラー
ユーザ入力値「のみ」で正誤判定ができるエラー。これはさらに、フィールド単位の入力エラー(個々のフィールドのデータだけで正誤判定できるエラー)と、インスタンス単位の入力エラー(複数のフィールドを同時に確認しないと正誤判定できないエラー)とに分類できる。 - 突き合わせエラー
ユーザ入力値のみでは正誤判定できず、データベース上のデータなど、他のデータとの「突き合わせ」を行わないと正誤判定ができないエラー。
前述の、正常終了/業務エラー/システムエラーとまとめて分類パターンを示すと、下図のようになります。
この分類の考え方や方法は、Web アプリケーション/Windows アプリケーションを問いません。Web アプリケーションと Windows アプリケーションでは、これらのエラーの表示方法(ユーザへの通知方法)や実装方法が異なりますが、分類方法そのものは、どちらであっても変わりがありません。
そのことを示すために、以下のような、新規顧客登録画面(単票形式のデータ入力フォーム)を取り上げて、パターン分類をしてみましょう。
上記のような画面の場合、Web アプリケーション、Windows アプリケーションを問わず、ボタン押下に伴って発生する事象は、以下のように分類することができます。
このように、正常終了/業務エラー/システムエラーの分類方法は、アーキテクチャタイプ(Web アプリ/スマクラアプリ/Windows アプリ)などによって変化するわけではない、ということを覚えておいてください。
[アーキテクチャから見たエラーチェックの実装場所]
さて、前述の分類のうち、システムエラーに相当するものは、.NET では例外(Java の場合には実行時例外)と集約例外ハンドラを利用して実装します。この実装方法については、本 blog にイヤというほどしつこくまとめたので、以下のエントリをご覧ください。
簡単にいえば、.NET の場合には、システムエラーに相当するケースについては集約例外ハンドラでまとめて処理するため、個々のアプリケーションモジュール(UI/BC/DAC 部)にシステムエラーを後処理するロジックを実装する必要は(基本的には)ありません。(※ 例外処理を行うのは、フローチャートの流れを調整したい場合に限ります。これについては、上記の「.NET の例外処理 Part.2」に詳細に解説していますので、こちらを参照してください。)
では、前述した業務エラーの処理はどこに実装するべきでしょうか? こうした業務エラー処理を実装する際に、必ず守るべき、以下の 2 つの基本的な実装セオリーがあります。
- 業務エラーのチェックは、可能な限り、ユーザに近い場所で行う
エンドユーザにとって、「UI が即時反応すること」はユーザビリティ上重要です。このため、UI 部でできるチェックは必ず UI 部で行い、即座にエラーを表示するようにします。 - 信頼境界の端点では、必ずデータの再チェックを行う
信頼境界(Trust Boundary)とは、ネットワーク的に不正な攻撃を受ける危険性のある境界のことを指します。例えば、下図のような Web アプリケーションの場合、BC や DAC はリモートからの通信を受け付けるわけではないので、これらのモジュールが直接、不正なユーザから攻撃を受ける危険性はほとんどないと言えます。一方、UI 部分は、不正なユーザから攻撃を受ける可能性が極めて高いと言えます。このため、リモートからのネットワークアクセスを受け付ける場所では、受け取ったデータを必ず再チェックする必要があります。
先に述べたように、業務エラーは以下の 2 つに大別できますが、このうち、突き合わせエラーについては、UI 部のみで正誤判定することはできません。このため、突き合わせエラーは、バックエンドの BC や DAC と連携してエラーチェックを行うことになります。
- 単体入力チェック → UI 部のみでチェックが可能
- 突き合わせ入力チェック → BC や DAC と連携したチェックが必要
このことを踏まえ、業務エラー実装の基本セオリーを、論理 3 階層型の Web アプリケーションと、スマートクライアントアプリケーションに適用すると、業務エラーのチェック場所は以下のようになります。
A. Web アプリケーションにおける業務エラーの実装場所
まず、Web アプリケーションの場合には、単体入力チェックや突き合わせチェックを以下のように実装することになります。
- 単体入力チェックについて
ブラウザ上で JavaScript による単体入力チェックを実装するとともに、サーバ側の UI 部にも、単体入力の再チェックロジックを実装します。すべてのブラウザが JavaScript をサポートしているわけではないため、ブラウザ内(JavaScript)で単体入力エラーが発見された場合だけでなく、UI 部(*.aspx など)で単体入力エラーが発見された場合も、エラーメッセージを表示するように設計・実装します。このため、単体入力チェックはブラウザ内(JavaScript)、サーバサイド(UI 部)の二か所に、重複する形で実装します。(※ なお、ASP.NET の場合、単体入力チェックは、UI 部(*.aspx 上)に検証コントロール(バリデータ)を使って実装します。検証コントロールはクライアントブラウザ上でのチェックのために JavaScript を自動出力するようになっています。このため、*.aspx 上に一度貼り付けただけで、ブラウザ上でのチェック(ユーザの手元での即時チェック)と、サーバ側での信頼境界端点の再チェックの両方をしてくれることになり、開発生産性が大きく向上することになります。) - 突合せ入力チェックについて
突合せ入力チェックに関しては、BC, DAC 部で、業務処理の一部として実装します。具体的には、BC から UI 部に対して、業務エラー情報として返し、UI 部ではエラーラベルに表示を行うことになります。
※ なお、ASP.NET 検証コントロールを利用した単体入力チェックの手法については、拙著「Visual Studio 2005 による Web アプリケーション構築技法」の第 5 章「入力検証コントロール」を、また突き合わせ入力チェックの実装方法については、本 blog の 「.NET の例外処理 Part.1」の中の、「2 種類の業務エラー」と「具体的なシグネチャ設計例」の項を参照してください。
前述の例の場合、画面設計と実装コードは、おおまかに以下のようになります。(フルソースコードは、サンプルコードを参照してください。ここでは要点だけ示します。)
- UI 部の設計
- UI 部 → BC 部呼び出しの部分の処理コード
B. スマートクライアントアプリケーションにおける業務エラーの実装場所
一方、スマートクライアントアプリケーションの場合には、単体入力チェックや突き合わせチェックを以下のように実装することになります。(※ この実装パターンは、Silverlight などの RIA タイプのアプリケーションでも同じになります。)
- 単体入力チェックについて
UI 部に、双方向データバインドを使って実装します。また、SI 部(サービスインタフェース、具体的には XML Web サービス)が信頼境界端点になるため、SI 部にも単体入力チェックを重複実装する必要があります。ただし、先の Web アプリケーションの場合と異なり、SI 部で単体入力エラーが発見された場合にはシステムエラー扱いとします。これは、クライアント側で正しい UI アプリケーション(Windows フォームや WPF アプリケーションなど)を使っている場合、SI 部に単体入力エラーを含むデータが送られてくるということはあり得ないからです。 - 突合せ入力チェックについて
BC, DAC 部で、業務処理の一部として実装します。そして、SI から UI 部に対して、業務エラー情報として返し、UI 部ではエラーラベルに表示を行います。
前述の例の場合、画面設計と実装コードは、おおまかに以下のようになります。(フルソースコードは、サンプルコードを参照してください。ここでは要点だけ示します。また UI 部の詳細な実装は、さらに 2 パターンに細分化できるのですが、これについては Part 2 のエントリで解説します。ここでは「なんとなく」理解していただければ十分です。)
- UI 部の設計
- UI 部 → SI 部呼び出しの部分の処理コード
上記の解説をまとめると、以下のようになります。
このように、業務エラーのチェックを実装する場所・方法に関しては、守るべき基本セオリーが存在します。実装方式が異なっても、これらの基本セオリーは変化しないものですので、しっかり覚えておくようにしてください。
[本エントリのまとめ]
では、ここまでの解説をいったんまとめておくことにします。
- アプリケーションの終了パターンは、正常終了/業務エラー/システムエラーの 3 パターンに分類される。
- システムエラーは、例外と集約例外ハンドラによって処理する。このため、通常のアプリケーションコードの中には、システムエラーに関する処理コードは出てこない。(=例外をむやみに try-catch したり throw したりすることはしない。)
- 業務エラーは、単体入力エラーと突き合わせエラーに細分化される。単体入力エラーは、さらに、フィールド単位の入力エラーと、インスタンス単位の入力エラーに細分化される。
- アプリケーションアーキテクチャ的な観点から見た場合、業務エラーチェック(単体入力チェックと突き合わせ入力チェック)を行う場所には、基本セオリーが存在する。
アプリケーションの終了パターン分類
Web アプリケーションにおける業務エラーチェックの実装場所
スマートクライアントにおける業務エラーチェックの実装場所
さて、ここまではアプリケーションの一般的な設計原則論のようなものを解説しましたが、これらを実装する際には、ランタイムが持つ入力検証機能を活用していくことが望ましいと言えます。引き続き Part 2. では、これらのデータ入力チェックを、各ランタイムでどのように実装していくのかを解説し、それを通して、各ランタイムが持つ入力検証機能を比較してみることにしましょう。
-
というわけで久しくエントリをアップしていなかったこの blog ですが、最近、複数方面からお叱りの言葉が……; 忙しかったこともあってエントリをサボっていたこともあったのですが、ちょうどいいネタがなかったのも実際のところ。がしかし、先日 2009/9/26(土) に行った、わんくま同盟さんでの勉強会のネタが blog 化するにはちょうどいいだろう、という感じなので、その資料を使いつつ、blog エントリを書いてみることにします。
今回の解説ネタは、更新系業務アプリケーションで求められることになる、エラーチェックの実装パターンを体系的に分類してみる、というものです。ASP.NET や Windows フォームなどには様々なデータ入力検証のフレームワークがあるのですが、名称こそ同じ「データ入力検証」となっていても、データ入力検証に対する考え方は、フレームワークごとにかなり異なっています。そこで、本エントリでは、特に .NET Framework 標準のデータ入力検証機能である、以下の 3 つを横並びにして解説してみて、各データ入力検証にどのような食い違いがあるのかを解説してみたいと思います。
- ASP.NET 検証コントロール
- Silverlight 3, WPF 3 の例外ベースの双方向データバインド
- Windows フォーム 2.0, WPF 3.5 の IDataErrorInfo ベースの双方向データバインド
なお、今回のサンプルコードは以下になります。エントリ中では、キーポイントとなるコード部分についてしか触れませんので、詳細なコードについてはこちらのサンプルコードをご覧ください。
[Agenda]
では、順番に解説していきましょう。
-
さて、Part 1~3 の解説で、Windows フォームにおけるマルチスレッドアプリケーションをスクラッチで開発する方法について述べてきました。結論としては、実は Windows フォームにおけるマルチスレッドアプリケーション開発は恐ろしく厄介で面倒である、ということになると思うのですが;、とはいえ
- 長時間を要する処理があるため、どうしてもマルチスレッドアプリにしなければならない。
ということも当然あると思います。幸い、.NET Framework 2.0/Visual Studio 2005 以降では、BackgroundWorker コンポーネントをはじめとして、マルチスレッドアプリを比較的簡単に書けるようにするための各種のコンポーネントやツールセットがいくつか追加されました。最後にこれらについて解説して、4 回にわたるエントリを締めくくっていきたいと思います。
今回のエントリで解説する内容は以下の 3 つです。
- XML Web サービス呼び出しの非同期処理化
- WCF サービス呼び出しの非同期処理化
- BackgroundWorker コンポーネントによる一般的なタスクの非同期処理化
なお、本エントリでは基本的な XML Web サービスの作り方・使い方に関する解説は行いません。*.asmx による XML Web サービス開発をご存じない方は、一般的な書籍や Web の情報などを参照してみてください。また、今回のサンプルコードはこちらになります。
では、順番に解説していきましょう。
[XML Web サービス呼び出しの非同期処理化]
ASP.NET 2.0 XML Web サービス(*.asmx)に対する Web サービス参照(.NET Framework 2.0 ベースのプロキシクラス)には、非常に簡単に XML Web サービス呼び出しを非同期処理化できる機能が備わっています。ここではこの機能を使って、長時間を要する XML Web サービス呼び出しを行う Windows フォームアプリケーションを開発してみます。
まず、新規に Windows フォームアプリケーションを作成し、そこに Web サイトプロジェクトを追加します。
次に、*.asmx ファイルを使って XML Web サービスを作ります。長時間呼び出しをシミュレートしたいので、Thread.Sleep() 命令を使って 5,000msec だけ待機するように実装しておきます。
1: <%@ WebService Language="C#" Class="WebService" %>
2:
3: using System;
4: using System.Web;
5: using System.Web.Services;
6: using System.Web.Services.Protocols;
7:
8: [WebService(Namespace = "http://tempuri.org/")]
9: [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
10: public class WebService : System.Web.Services.WebService {
11:
12: [WebMethod]
13: public string GetMessage(string name) {
14: System.Threading.Thread.Sleep(5000);
15: return "Hello World " + name;
16: }
17: }
18:
実装が終わったら、Windows フォームアプリケーション側で XML Web サービス参照を作成します。なお、Visual Studio 2008 を利用している場合、既定では .NET Framework 3.0 ベースの WCF サービスプロキシが作成されてしまいます。このため、「サービス参照の追加」→「詳細設定」→「Web 参照の追加」を選択しして、.NET Framework 2.0 ベースのプロキシクラスを作成する画面を表示し、ここでプロキシクラスを作成してください。
プロキシクラスを作成したら、ボタンやテキストボックスなどを貼り付けて画面を作成し、いったんコンパイルを行います。すると、ツールボックス上に、XML Web サービスプロキシのクラスが現れますので、これを当該画面上に貼り付けます。
そののち、以下の 2 つのイベントハンドラを記述します。
- button1_Click イベントハンドラ
ボタンが押されたときに発生するイベントハンドラ。画面に貼り付けた Web サービスプロキシ(webService1)を使って、XML Web サービスの非同期呼び出しを開始させる。
- webService1_GetMessageCompleted イベントハンドラ
非同期呼び出しが終了した場合に呼び出されるイベントハンドラ。ここに、XML Web サービス呼び出しが終わったときの処理(画面表示など)を書く。
※ 後者のイベントハンドラについては、プロパティ画面の上の方にあるイナズママークをクリックした上で、プロパティ画面内の「GetMessageCompleted」の項目をダブルクリックすると、作成することができます。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: // 二重押し防止のためのコード
4: button1.Enabled = false;
5: textBox1.Enabled = false;
6: // XML Web サービスの非同期呼び出し
7: webService1.GetMessageAsync(textBox1.Text);
8: }
9:
10: private void webService1_GetMessageCompleted(object sender, WindowsFormsApplication1.localhost.GetMessageCompletedEventArgs e)
11: {
12: // 終了結果取り出し(XML Web サービス呼び出し中に例外が発生した場合には、e.Resultプロパティにアクセスした際に例外が発生)
13: string result = e.Result;
14: // 結果表示
15: label1.Text = result;
16: button1.Enabled = true;
17: textBox1.Enabled = true;
18: }
さらに、Program.cs ファイルに集約例外ハンドラを記述します。
1: static class Program
2: {
3: [STAThread]
4: static void Main()
5: {
6: Application.EnableVisualStyles();
7: Application.SetCompatibleTextRenderingDefault(false);
8:
9: Application.ThreadException += new System.Threading.ThreadExceptionEventHandler(Application_ThreadException);
10: Application.Run(new Form1());
11: }
12:
13: static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e)
14: {
15: MessageBox.Show("システムエラーが発生しました。アプリケーションを終了します。");
16: // 通常は例外ロギングも併せて実施
17: Application.Exit();
18: }
19: }
以上で作業は終了です。実行してみて、以下のような挙動をすることを確認してみてください。
- XML Web サービスの呼び出し中に、ウィンドウがフリーズしない(きちんとドラッグできる)。
- アプリケーション起動後に ASP.NET 開発サーバを終了させて、ボタンを押下すると、ちゃんと集約例外ハンドラがフックされる。

← XML Web サービスを呼び出せなかった場合
内部動作の概念図を下に示します。この処理のキーポイントは、UI スレッドへの戻りが自動的に行われる、という点です。webService1.GetMessageAsync() メソッドにより、Web サービス呼び出し自体は背後のスレッド(具体的にはプールスレッド)上で行われますが、
- 呼び出しが正常終了した場合に呼び出される webService1_GetMessageCompleted() イベントハンドラは、UI スレッド上で呼び出される。このため、このイベントハンドラ内では自由に UI コントロールを操作してよい。
- 呼び出しが正常終了せず例外が発生した場合でも、webService1_GetMessageCompleted() イベントハンドラが UI スレッド上で呼び出される。そして、e.Result プロパティにアクセスした瞬間に、発生した例外がリスローされる。
という挙動をします。
この挙動の中でも後者は非常に上手いところで、このような機能があるため、特に追加のコードを書かなくても、XML Web サービス呼び出し中に発生した例外を、Application.ThreadException 集約例外ハンドラで捕捉することができます。よって、上記のようなコードだけで、XML Web サービス呼び出しを非同期化することができる(背後のタスクスレッド上で動かすことができる)のです。
※ (注意) ただし、この実装方法では、XML Web サービス呼び出しをキャンセルすることはできません。一応 XML Web サービスプロキシには .CancelAsync() というメソッドがあるものの、これは「まだ未送信状態だったら呼び出しを取り消す」というものです。このため、実際にタスクスレッドで XML Web サービス呼び出しが行われてしまった後に .CancelAsync() したところで、行われてしまった呼び出しは取り消せません(=確実な呼び出し取り消しができるメソッドではありません)。もともとこの問題は、タスクスレッドを使っている以上は原理的に発生するものなので、設計時に注意しておくことが必要です。
※ (注意&参考) また、本題からは若干それますが、プロキシクラスを画面に貼り付けて利用する場合は、URL プロパティを構成設定ファイルから自動的に読み取らなくなってしまうため、下図のようにして明示的に紐付けを行ってください。(プロキシクラスのコード生成ツールとの兼ね合いで発生するトラブルのようです。明示的に紐付けすればきちんと読み取るようになります。)
[WCF サービス呼び出しの非同期処理化]
では今度は、同じことを .NET Framework 3.0 ベースの WCF プロキシクラスで行ってみましょう。話を簡単にするために、サーバ側は上記のサンプルと同じく、*.asmx を使うことにして、クライアント側に(サービス参照の追加機能を利用して) WCF のプロキシクラスを作成します。

作成したプロキシクラスは(先と異なり)フォーム上に貼り付けることはできません。しかし、以下のようなコードを書くことで、先ほどと同じようにコーディングすることができます。
このように、WCF プロキシクラスの場合には、画面上に貼り付けることはできないものの、きちんと UI スレッド上で呼び出し終了イベントハンドラを呼び出してもらうことができます。
※ (注意) .NET Framework 2.0 ベースの ASP.NET XML Web サービスプロキシの場合には、画面上に貼り付けなければなりません。コード上で Completed イベントハンドラの登録を行うと、UI スレッドへの戻りが発生しないため、注意してください。
さて、ここまで Web サービス呼び出しを非同期化する方法について解説してきましたが、最後に、より一般的なタスクを簡単に非同期化する方法について解説します。
[BackgroundWorker コンポーネントによる一般的なタスクの非同期処理化]
ここまでの解説からわかるように、Windows フォームにおけるマルチスレッドアプリケーションの難しさは、UI スレッドとタスクスレッド間での連携によるところが大きいです。この連携処理を簡素化するために .NET Framework 2.0 で導入されたのが、ここで解説する BackgroundWorker コンポーネントです。この BackgroundWorker コンポーネントは、UI スレッドとタスクスレッド(プールスレッド)との間の協調連携動作を支援するコンポーネントとして機能します。概念図を下に示します。
この概念図だけだとわかりにくいと思いますので、実際に BackgroundWorker コンポーネントを使って、長時間処理を背後で行う以下のようなアプリケーションを作ってみることにしたいと思います。
具体的な実装手順は、以下の通りです。(何をやっているのかをわかりやすく示すため、Step by Step で実装していきます。)
① UI の作成
- まずは画面上に 2 つのボタン、ラベル、プログレスバーを置きます。
- それぞれのボタンに、btnStart, btnCancel と名前をつけ、キャンセルボタンの Enable プロパティを false にしておきます。
- 画面上に、BackgroundWorker コンポーネントを貼り付けます。
② 長時間処理の作成
- btnStart_Click() イベントハンドラを作り、ここに、BackgroundWorker コンポーネントに対して非同期処理を開始する指示を出すコードを記述します。
- 次に、backgroundWorker1_DoWork() イベントハンドラを作り、ここに実際の長時間処理を記述します。
- 最後に、backgroundWorker1_RunWorkerCompleted() イベントハンドラを作り、ここに終了後の処理を記述します。
実際の処理の流れを以下に示します。重要なのは、UI スレッド → プールスレッド → UI スレッドの流れが自動的に制御される、という点です。従来だと、自力で .BeginInvoke() などを記述しなければなりませんでしたが、そうした処理はすべて BackgroundWorker が肩代わりしてくれます。
③ 起動パラメータと処理結果の引き渡し
さて、上記のサンプルだと、タスクスレッドの起動パラメータの受け渡しや、タスクスレッドの処理結果の受け取りがありません。これらのコードを追加すると、以下のようになります。
(30msec × 321 回なので 10 秒ぐらいかかります)
④ 進捗状態表示機能の追加
では次に、進捗状態を UI 上に表示する機能を追加します。進捗状態は、プールスレッドから UI スレッドへの通知が必要ですが、これを行うために、以下の 2 つの作業を行います。
- backgroundWorker1 の WorkerReportsProgress プロパティを true に変更する。
- backgroundWorker1_DoWork() メソッドの中に、進捗報告のためのコードを追加する。(backgroundWorker1.ReportProgress() メソッド)
- backgroundWorker1_ProgressChanged() イベントハンドラを追加し、UI に表示する。
このようにすると、進捗状態が UI に表示されるようになります。
ここで注意していただきたいのは、プールスレッドで動作している backgroundWorker1_DoWork() メソッドから、UI 更新を行う backgroundWorker1_ProgressChanged() メソッドを直接呼び出しているわけではない、という点です。
- プールスレッドからは、backgroundWorker1 の .ReportProgress() メソッドを叩き、backgroundWorker1 にスレッド同期を依頼する。
- backgroundWorker1 は、UI スレッド上で backgroundWorker1_ProgressChanged イベントハンドラを呼び出すように、内部で .BeginInvoke() 命令を利用する。
ここでもう一度、最初に示した内部動作の模式図を示します。
最初からの流れをもう一度追いかけてみると、
- BackgroundWorker コンポーネントを用いたタスクスレッドの起動
① UI スレッドから BackgroundWorker コンポーネントの RunWorkerAsync() を叩く
② BackgroundWorker が自動的にプールスレッドに処理開始要求を投入する
③ その結果、DoWork イベントハンドラに登録されたメソッド(backgroundWorker1_DoWork() メソッド)が、プールスレッド上で起動する
- BackgroundWorker コンポーネントを用いた進捗状況の UI への通知
① プールスレッドから適当なタイミング(なるべく頻繁に)で BackgroundWorker コンポーネントの ReportProgress() メソッドを叩く
② BackgroundWorker は、内部で .BeginInvoke() を行い、メッセージキューにメッセージを投入
③ その結果、ProgressChanged イベントハンドラに登録されたメソッド(backgroundWorker1_ProgressChanged() メソッド)が、UI スレッド上で起動する
- BackgroundWorker コンポーネントを用いた終了通知
① プールスレッド上で、backgroundWorker1_DoWork() メソッドが終了する
② BackgroundWorker は、内部で .BeginInvoke() を行い、メッセージキューにメッセージを投入
③ その結果、RunWorkerCompleted イベントハンドラに登録されたメソッド(backgroundWorker1_RunWorkerCompleted() メソッド)が、UI スレッド上で起動する
となります。つまり、UI スレッドとプールスレッドの橋渡しを、BackgroundWorker コンポーネントが行ってくれている、ということになるわけです。
改めて、どの処理がどのスレッド上で動作するのかをまとめてみると、
- BackgroundWorker コンポーネント上の各メソッドをどのスレッド上で叩くか?
① BackgroundWorker.RunWorkerAsync() メソッドは、UI スレッド上から叩く。
② BackgroundWorker.ReportProgress() メソッドは、プールスレッドから叩く。
③ BackgroundWorker.CancelAsync() メソッド(後述)は、UI スレッド上から叩く。
- BackgroundWorker コンポーネントに登録したイベントハンドラはどのスレッド上で動くか?
① DoWork イベントに登録したハンドラは、プールスレッド上で動く。(=UI 操作不可)
② ReportProgress イベントに登録したハンドラは、UI スレッド上で動く。(=UI 操作可)
③ RunWorkerCompleted イベントに登録したハンドラは、UI スレッド上で動く。(=UI 操作可)
ということになります。
では最後に、キャンセル処理についても実装してみましょう。
⑤ キャンセル機能の追加
Part 3. で述べたように、UI スレッドからタスクスレッドを強制的に停止させることはできないため、キャンセル処理は「UI スレッドからフラグを立てる」「タスクスレッドからフラグをチェックして自主的に止まる」ことになります。具体的には、以下の実装を行います。
- backgroundWorker1 の WorkerSupportsCancellation プロパティを true に変更する。
- btnCancel_Click() メソッドに、backgroundWorker1.CancelAsync() メソッドを呼び出す処理を記述する。
- backgroundWorker1_DoWork イベントハンドラ内(タスクスレッドの長時間処理の中)に、キャンセルフラグを(なるべく頻繁に)チェックする処理を入れる。
追加されたコードは赤字部分です。ここまでの解説が理解できていれば、容易に理解できるのではないかと思います。
※ ちなみに実際に実行すると、キャンセルボタンを押した直後にプログレスバーが停止しませんが、これは Vista 以降でのコントロールのアニメーションの変更によるもの(アニメーションの遅延により発生する)です。XP などで実行すると、停止したタイミングでぴたっと止まります。
※ あと、書き忘れましたが、タスクスレッド上の例外処理についても書く必要がありません。タスクスレッド上で未処理例外が発生した場合には、RunWorkerCompleted イベントハンドラにて、e.Result で結果を取り出す際に例外がリスローされるため、特に例外処理のコードを追加しなくても、上のコードのままで集約例外ハンドラで例外を捕捉することができます。
このように、BackgroundWorker コンポーネントを利用すると、UI スレッド ⇔ タスクスレッドのスレッドスイッチに関連する処理を書く必要がなくなり、コードもかなりすっきりします。しかし、どの処理がどのスレッド上で動作しているのかを正確に理解しないと、非常に危険であるのも確かです。先に示した動作模式図を意識しながら、アプリケーションコードを記述するようにしてください。
[本エントリのまとめ]
では最後に、本エントリのまとめです。
- ASP.NET 2.0 XML Web サービスのプロキシクラスは、フォーム画面上に貼り付けて使うことにより、Web サービス呼び出し処理を非同期処理化できる。
- WCF サービスのプロキシクラスは、非同期処理メソッドを追加して使うことにより、呼び出し処理を非同期処理化できる。
- 一般的なタスクについては、BackgroundWorker コンポーネントを使うことで非同期処理化ができる。
というわけで、4 回に渡ってマルチスレッドアプリケーションの開発手法について解説してきましたが、総じて言えば、
マルチスレッドアプリケーションを書くのはかなり難しい;。
ということになります。正しい知識を持って記述しないと、とにかくトラブルを引き起こしがちな技術になりますので、記述するのであれば十分な知識を持った上で、正しく記述するように心掛けていただければと思います。
※ なお、今回は Windows フォームに限定して解説を進めてきましたが、WPF や Silverlight にも同様な UI スレッド制限があります。WPF などを利用する場合には、こちらの MSDN マガジンのエントリなどを参考にしながら開発を進めていただければ幸いです。
-
さて、前回の Part 2. のエントリでは、タスクスレッド(UI の背後で動作させる処理を動作させるスレッド、すなわちマニュアルスレッドやプールスレッドの総称)の様々な起動方法について解説しました。主な方法として、以下の 4 つの方法がありました。
- マニュアルスレッドの新規作成
- プールスレッドの利用
- 非同期デリゲートの利用
- タイマの利用
さて、いずれの方法を利用する場合であっても守る必要のあるルールとして、UI スレッド以外から UI コントロールの読み書きをしてはならない、というものがありました。
ここまで実際にプログラミングをしてみた方は感じられていると思うのですが、実はこの制限はかなり厄介です。例えば、以下のような処理を簡単に書くことはできません。
- 背後で行っているタスク処理の進捗状況や完了結果を、UI 上に表示する。
(これはすでに解説済み。.BeginInvoke() 処理が必要。) - 背後で行っているタスク処理から、UI 上のコントロールのプロパティを読み出す。
(タスクスレッドから、textBox1.Text プロパティを読み出すことは NG だがどうすればよい?) - 背後で行っているタスク処理を、UI 上のキャンセルボタン押下により中断させる。
(どうやって UI のイベントハンドラから背後のスレッドに通知を行えばよいのか?) - 背後で行っているタスクスレッド上で発生した例外の情報を、UI 上に表示する。
(どうやってタスクスレッドで発生した未処理例外を UI に通知すればよいのか?)
マルチスレッド処理を行う Windows フォームアプリケーションを書く上では、上記のような問題が発生したときの対処方法を知っておく必要があります。本エントリでは、これらについて解説します。
- タスクスレッドからの UI 画面の更新方法
- タスクスレッドからの UI 画面上のデータの読み取り方法
- UI 画面からのタスクスレッドの制御方法
- タスクスレッド上で発生した未処理例外の取り扱い方法
本エントリのサンプルコードはこちらになります。
では、以下に順に解説していきます。
[タスクスレッドからの UI 画面の更新方法]
さて、まずはおさらいです。タスクスレッド(マニュアルスレッドやプールスレッド)から UI 画面を更新する際には、タスクスレッドからコントロールのプロパティを直接いじってはいけません。このような場合には、BeginInvoke() 命令を利用し、メッセージキューに処理要求を投入する必要があります。具体的な作業手順は以下の通りです。
- UI 画面更新用の private メソッドを作成する。
画面更新に必要なデータをパラメータとして受け取るように書いておきます。(注意点は後述) - それにあわせた形で private デリゲートを定義します。
名称は任意ですが、××Delegate といった名前で定義しておくと便利です。 - タスクスレッドから BeginInvoke() 命令でメソッド呼び出しを実施する。
パラメータは object[] 配列として引き渡します。
なお、.BeginInvoke() 命令はどのコントロールに対して発行しても OK ですが、通常は親フォームオブジェクト(= this)に対して発行するのが便利です。
なお、関連する注意点として、以下についても知っておくと便利です。
① 現在のスレッドが UI スレッドかどうかは、InvokeRequired プロパティでチェックすることができる。
例えば上記の LongTask() メソッドは、マニュアルスレッド上だけでしか動作させられないかというとそんなことはなく、書き方や呼び方次第では、プールスレッド上や UI スレッド上で動作させることもできます。このため、あるメソッドが UI スレッド上で呼び出されることも、マニュアルスレッドやプールスレッド上で呼び出されることも、どちらもありうるような場合には、.InvokeRequired プロパティを確認することで、UI スレッド上で動作しているか否かを確認することができます。
ただし実際には、そもそも上記のようなチェックが必要とならないようにすることが望ましいです。ここまで取り扱ってきたサンプルは、必ず、あるメソッドはマニュアル/プール/UI のどれかの上でしか動作しないように設計してきました。実際、UI スレッドで行うべき処理とタスクスレッドで行うべき処理は異なっているのが当然で、UI スレッド/タスクスレッド両用になるようなメソッドというのは、(ユーティリティ的な処理を除けば)あまりないはずです。
UI フォームを設計・実装する際には、どのメソッドがどのスレッド上で動くのかを明確に意識しながら作業することが非常に重要です。InvokeRequired プロパティを使わなければならなくなった場合には、そもそもアプリケーションの設計としての是否をきちんと確認するようにしてください。
② 基本データ型及びイミュータブルオブジェクト以外を引き渡す場合には、オブジェクトの同期制御が必要になる。
UI スレッドからタスクスレッドを起こす場合でも、またタスクスレッドから UI スレッドへ制御を戻す場合でも、どちらでも共通する内容ですが、一般に、オブジェクトを引数として渡す場合には、参照渡しが行われます。例えば、下図のように、タスクスレッドから UI スレッドに StringBuilder の変数を引き渡すと、同一インスタンスがマニュアルスレッドと UI スレッドの両方から操作されることになります。
当然ですが、このような処理を行うと、同一インスタンスを同時に複数スレッドから操作することになり、データが破損することになります。よって、このようなコードは書いてはいけません。StringBuilder のインスタンスに対して同期制御を行うか、インスタンスをコピー(ディープコピー)して渡す必要があります。
このことからわかるように、UI スレッドとタスクスレッド間で、引数として基本データ型やイミュータブルオブジェクト以外のデータを引き渡す場合には、オブジェクトの同期制御が必要になります。(データ変数の同期制御に関しては、こちらとこちらのエントリに詳しく書いてありますので、よくわからないという方は確認してみてください。)
では引き続き、タスクスレッドからの UI 画面上のデータの読み取り方法について解説します。
[タスクスレッドからの UI 画面上のデータの読み取り方法]
上記では、タスクスレッドから UI 画面上へのデータ表示について考えたわけですが、実は、タスクスレッドから UI 画面上のデータを読み取ることも NG です。
UI コントロールを操作するためには、UI スレッドからの操作が必要になり、そのためにはメッセージキューへのメッセージ投入が必要....なのですが、ここまで解説してきた BeginInvoke() 命令では、メッセージを投入した後「やりっぱなし」の状態になってしまい、コントロールから値を読み取るなどした後の結果値を受け取ることができません。このような場合には、.Invoke() 命令を利用します。これを利用すると、UI スレッド上で行われた処理の結果を、タスクスレッド側で受け取ることができます。具体的な実装例を以下に示します。
なお、この .Invoke() 命令による、「UI スレッド上での処理結果の受け取り」はむやみに利用しないようにしてください。.BeginInvoke() 命令と異なり、.Invoke() 命令は、投入したメッセージが UI スレッド上で処理されるのを同期的に待つ(=タスクスレッド側は UI スレッド上での処理の終了を待機する)形になります。このため、以下のようなコードを書くとハングアップする危険性があります。
- 例1. Invoke() 命令で呼び出したメソッド内から、さらに別のスレッドを起こす ⇒ スレッドプールの枯渇や無限ループの発生
- 例2. 共有データに対して lock を取得している最中に Invoke() 命令を利用し、呼び出し先で当該データを操作 ⇒ デッドロックの発生
このようなことから、以下のルールを守ることをおすすめします。
- .Invoke() 命令は極力使わないようにする。
- 使う場合であっても、UI 上のプロパティを読み取ったらすぐに終了する、といった極力単純なコードのみを書くようにする。
- ロックを取得している最中に .Invoke() を呼び出さない。
では引き続き、UI 画面からのタスクスレッドの制御方法について解説します。
[UI 画面からのタスクスレッドの制御方法]
タスクスレッド上で時間を要する処理を起動した場合、UI からそのタスクを制御したいと思うことがよくあります。例えば、
- キャンセルボタンを押したら、背後で行っている処理を中断したい。
- チェックボックスやラジオボタンなどを押すと、背後で行っている処理が変更されるようにしたい。
といったことはしばしばあります。しかし、このような処理を作り込むのは意外に厄介です。なぜなら、UI からタスクスレッドで行われている処理に対して、割り込みをかけるような形で強制通知を行うことはできないからです。
例えば、以下のような画面を作る場合を考えてみます。
タスクスレッドでの処理を止めるため、キャンセルボタンを押したら、背後で行っている処理が中断されるように、Thread.Abort() 命令を使ったとします。この機能を使うと、タスクスレッドの処理を強制的に中断することができますが、そのスレッドがどんな状態であるかを全く無視して強制中断してしまうため、場合によっては共有データ変数の破損などの問題を引き起こすリスクがあります。このため、タスクスレッドへ強制的に割り込みをかけるような形で処理をキャンセルさせることはできないのです。
このような場合には、以下のような解決が必要になります。
- 状態を保存しておく共有データ変数を用意しておく。
- UI からタスクスレッドへ通知したい場合、共有データ変数に格納する。
- タスクスレッドが定期的に(=タスクの節目節目で)共有データ変数をチェックし、それに従った動作をする。
具体的な内部設計図を下図に示します。
実装コードを以下に示します。
コード上、特に注目していただきたいのが、共有データ領域の使い方です。UI スレッドから、強制的にタスクスレッドの処理に割り込みをかけることはできません。このため、button2_Click() メソッドでは、フラグ情報を立てておくだけにとどめます。一方、タスクスレッド側は、なるべく頻繁にこのフラグをチェックし、そのフラグに応じて処理をキャンセルしたり挙動を変更したりします。
このように、UI からタスクスレッドへ何かしらの通知を行う、というのは意外に実装が厄介なものである、ということを覚えておいてください。再度ポイントをまとめると、以下のようになります。
- 状態を保存しておく共有データ変数を用意しておく。
- UI からタスクスレッドへ通知したい場合、共有データ変数に格納する。
- タスクスレッドが定期的に(=タスクの節目節目で)共有データ変数をチェックし、それに従った動作をする。
なお、UI からタスクスレッドへの通知に関してもう一点注意していただきたいのが、Windows フォームのクローズ(アプリケーションの終了)に伴う動作です。Windows フォームの代表的なプロセス終了命令には、以下の 2 種類があり、それぞれ内部動作が異なります。
- Application.Exit() 命令
すべてのメッセージループを停止し、アプリケーションウィンドウを閉じ、メインスレッドの Application.Run() 命令を終了する。この際、フォアグラウンドスレッドが残留しているとプロセスが終了しない。 - Environment.Exit() 命令
強制的にプロセスを切り落とす。この際、フォアグラウンドスレッド、バックグラウンドスレッドの残留状態を問わず、プロセスが強制終了する。
後者の Environment.Exit() 命令はアプリケーションの強制終了に相当するものですから、通常、Windows フォームのアプリケーションを穏やかに終了させるには、前者の Application.Exit() 命令を利用します(この動作はフォームのクローズ時も同様です)。
しかし、Application.Exit() 命令でアプリケーションを終了させる際に問題になるのが、以下の 2 つです。
- フォアグラウンドスレッドが残っていると、プロセスが終了しない。
- バックグラウンドスレッドについては、強制終了となる。
ここまで、フォアグラウンドスレッドとバックグラウンドスレッドについての解説をしてきませんでしたので、まずこれについて解説します。.NET アプリケーションで取り扱う各スレッドには、フォアグラウンドスレッドか、バックグラウンドスレッドかのマーキングを行うプロパティ .IsBackground が存在します。そして、
- フォアグラウンドとマークされたすべてのスレッドが終了すると、CLR はバックグラウンドスレッドが残っていても当該プロセスを終了します。
- フォアグラウンドとマークされたスレッドが一つでも残っていると、Main() メソッドが終了しても CLR は当該プロセスを終了しません。
という挙動をします。例えば、下記のコンソールアプリケーションのサンプルを見てください。
このサンプルにおいて、
- バックグラウンド化した上でマニュアルスレッドを起動すると、Main() 関数が終了した直後にプロセスが終了します。
- しかし、バックグラウンド化せずに(t.IsBackground = false; として)マニュアルスレッドを起動すると、Main() 関数が終了しても、プロセスが終了しません。
両者の動作を以下に示します。
バックグラウンド化した場合
フォアグラウンドで動作させた場合
デフォルト状態での各スレッドの状態は、以下の通りです。
- Main() 関数を動作させるスレッド(メインスレッド) → フォアグラウンド
- マニュアルスレッド → フォアグラウンド
- プールスレッド → バックグラウンド
つまり、Windows フォームアプリケーションでマルチスレッドアプリを作成する場合、うかつにマニュアルスレッドを使うと、Application.Exit() 命令ではプロセスが終了しなくなります。
例えば、以下のようなアプリケーションを作ってみます。
※ ここでは、UI がなくなってもプロセスが残留することを簡単に示すために、DebugView を使います。このツールの詳細はここでは解説しませんが、簡単にいうと、System.Diagnostics.Debug.WriteLine() 命令によって出力したデータを、外部で簡単に参照できるようにしたツールです。Web アプリケーションなどの UI を持たないツールにおいて、内部動作を簡単に表示・モニタできるというメリットがあって便利です。ここからダウンロードできます。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: Thread t = new Thread(new ThreadStart(Task));
4: t.Start(); // バックグラウンド化せずに起動する
5: }
6:
7: private void Task()
8: {
9: while (true)
10: {
11: Thread.Sleep(300);
12: System.Diagnostics.Debug.WriteLine("タスク実行中...");
13: }
14: }
15:
16: private void button2_Click(object sender, EventArgs e)
17: {
18: Application.Exit();
19: }
フォアグラウンドでマニュアルスレッドを起動しておくと、フォームを閉じてもアプリケーションプロセスは終了せず、デバッガに情報を出力し続けます。(タスクマネージャを見ると、フォーム画面は消えているのにフォームの .exe プロセスが残っていることが確認できます。)
つまり、Windows フォームを適切に終了させるためには、残留しているフォアグラウンドスレッドをすべて終了させなければならない、ということになります。このことから、Windows フォームアプリケーションの開発に関する推奨事項として、次のようなことが言えます。
- 基本的に、タスクスレッドは「いつ強制終了されても困らない」ような防御的プログラミングをしておいた上で、さらにバッグラウンド化(.IsBackground = true)しておくことが望ましい。
「防御的プログラミング」というのは、「強制終了などのトラブルが起こったとしても、問題が起こらないようにプログラミングしておく」ことを指します。具体的には、以下のようなプログラミングの工夫を指します。
- ローカルキャッシュファイルが破損していた場合には、自動的に最新データをサーバから取り寄せる。
- ローカル設定ファイルが破損していた場合は、デフォルト値を利用する。
※ なお、このような防御的プログラミングの考え方は、Windows フォームアプリケーションでは特に重要です。というのも、Windows フォームアプリケーションは、「閉じる」ボタンなどで簡単に終了できるのはもちろんのこと、タスクマネージャからの強制終了や Windows OS シャットダウンなど、「完全な後片付け」ができないまま強制的に切り落とされるケースが多数考えられるからです。このため、Windows フォームアプリケーションでは『強制的に切り落とされても問題なく再起動ができる』形で実装することが望ましいといえます。タスクスレッド上の処理に限らず、「再起動時に何らかのローカルキャッシュデータファイルなどが破損していても自動復旧できる」ような設計をしておくことは非常に重要である、ということを覚えておいてください、とつぶやいておく^^。
では最後に、タスクスレッド上で発生した未処理例外の取り扱い方法について解説します。
[タスクスレッド上で発生した未処理例外の取り扱い方法]
一般的に、Windows フォーム上で発生した未処理例外に関しては、集約例外ハンドラを利用した後処理を行います。Windows フォームにおける集約例外ハンドラの書き方についてはこちらのエントリに記述しましたが、Application.ThreadException イベントハンドラで捕捉される例外は、メッセージループが捕捉した例外、すなわち UI スレッド上で発生した例外に限定されます。このため、マニュアルスレッドやタスクスレッド上で発生した未処理例外については、適切なハンドリングが必要です。これを怠ると、以下のような問題が発生します。
- タスクスレッドが例外によって終了してしまったにもかかわらず、タスクスレッドの正常終了を UI 側が永遠に待ち続けてしまう。
- タスクスレッド上で発生した例外情報がロギングされなかったため、障害解析ができなくなってしまう。
これらの問題を解決するため、以下のような対処を行います。
- AppDomain に対して集約例外ハンドラを仕掛けておく。
- タスクスレッド上で発生した例外を UI スレッド上でリスローする。
これらについて解説します。
AppDomain に対して集約例外ハンドラを仕掛けておく
CLR 上でアプリケーションを動作させる場合、プロセス内にはアプリケーションドメイン(AppDomain)と呼ばれる論理区画領域が作成されます。この AppDomain の UnhandledException イベントをフックすると、マニュアルスレッド上の未捕捉例外をすべて捕捉することができます。通常は、Application.ThreadException イベントハンドラも利用しますので、以下のようなコードを書くことになります。
ただし、この方法には以下の 2 つの難点があります。
- UI 通知ができない。 (タスクスレッド ≠ UI スレッドであるため)
- プールスレッド上で発生した例外を補足できない。
後者は特に大きな問題です。このため、実際には以下に述べる例外のリスローを使って、問題を解決することが望ましいです。これについて解説します。
タスクスレッド上で発生した例外を UI スレッド上でリスローする
そもそも UI 表示は UI スレッド上でしか行えない、という前提条件を考えると、タスクスレッドで発生した例外を UI 上で通知するためには、例外オブジェクトを UI スレッド側に伝搬する必要があります。そこで、タスクスレッド上で例外が発生した場合には、この例外オブジェクトを .BeginInvoke() 命令で UI スレッド側に伝搬し、UI スレッド上でそれをリスローします。
もともと UI スレッド上で発生する例外に関しては、Application.ThreadException イベントハンドラの集約例外ハンドラで捕捉することができますので、この方法を利用すれば、すべての例外を UI 側の集約例外ハンドラで補足することができます。
例えば、XML Web サービスの呼び出し処理をタスクスレッドに切り出す場合について考えてみます。XML Web サービス呼び出しに失敗すると、例外が発生します。このような例外をキャッチし、UI スレッドに伝搬すれば、集約例外ハンドラ(Application.ThreadException イベントハンドラ)でまとめて処理することができます。
# ちなみに、この場合の例外処理(try-catch)は、タスクスレッド上で発生したすべての例外をことごとく捕捉する必要がありますので、全体を大きく囲むことになります。この try-catch は、「例外→業務エラーの変換のための try-catch」でも「リソース解放のための try-finally」でもないので、注意してください。
[今回のエントリのまとめ]
というわけで、今回のエントリのキーポイントをまとめると、以下のようになります。
- タスクスレッドから UI 画面を更新したい場合には、.BeginInvoke() 命令を使って、メッセージキューにメッセージを投入する。
- タスクスレッドから UI 画面上のデータを読み取りたい場合には、データを読み取って返すメソッドを作成しておき、これを .Invoke() 命令でタスクスレッドから同期的に呼び出す。ただし気をつけてプログラミングしないと、スレッドプールの枯渇やデッドロック問題を引き起こすので注意が必要。
- UI 画面からタスクスレッドを直接操作することはできない。共有データ変数領域を用意しておき、① UI スレッドからはこの共有データ変数領域にフラグを立てる。② タスクスレッド側ではこの共有データ変数領域を頻繁にチェックする。という方法で間接的に制御を行う。
- タスクスレッド上で発生した未処理例外を取り扱うため、タスクスレッド全体を try-catch で囲む必要がある。捕捉した例外は、.BeginInvoke() 命令で UI スレッドに搬送し、リスローする。
さて、ここまで読んでみていただいて、
ちょーめんどい;。
と思われた方も多いと思います。はい、私もそう思います;;;。だったら説明するなー!と言われそうですが;、実際、マルチスレッドアプリケーションというのは、思っているよりも遥かに実装が厄介なものです。例えばボタンが押されたら XML Web サービスを呼び出すアプリケーションを作ろう、と思った場合、
同期型で実装する場合には、たったの 4 行で話が済みます。
しかし、これをタスクスレッドに切り出す場合、以下のようなポイントに対する考慮が必要になります。
- ボタンの二重押しの防止
- ステータスの表示
- タスクスレッドから UI スレッドへの書き戻し
- 集約例外処理
結果、以下のような膨大なコードになります。
なので、やはり個人的におすすめしたいのは、
マルチスレッドアプリケーションはなるべく作らない。
これに尽きます。って、まるでここまでの解説すべてを放り出すような発言ですが;;;、ここまでの延々とした説明をきちんと理解した上でコーディングをしようと思うと、相当大変であることは容易にご理解いただけるのではないでしょうか。(というか正直言ってこのエントリ書いてる自分もサクサク書けません、とても;。)
Visual Studio 2008 を利用すると、こうした問題を多少緩和するために、いくつかの機能が利用できるようになるのですが、そうはいってもここまでの解説をきちんと理解した上でないと、やはりそうしたウィザード類の利用もやはり危険です。Part 4. では Visual Studio 2008 の機能について解説しますが、Part 3. までの内容をよく理解した上で利用していただくことをお勧めします。
-
さて、前回のエントリでは、Windows フォーム内部におけるスレッドの構成や、メッセージループの働きなどについて解説しました。中でも重要なこととして、以下のようなキーポイントがありました。
- UI スレッド上で、長時間処理を動かしてはならない。
長時間処理は、マニュアルスレッドやプールスレッドなどの、他のスレッドに切り出す。 - UI スレッド以外から、UI コントロールを触ってはいけない。
マニュアルスレッドやプールスレッド上から、UI コントロールを読み書き・操作してはいけない。
上記の 2 つの重要ルールについて、Part 2~4 にてより実践的な解説を行っていきます。
- Part 2. タスクスレッドの起動方法
まず、マニュアルスレッドやスレッドプールの起動方法について解説します。 - Part 3. タスクスレッドと UI の協調動作
マニュアルスレッドやプールスレッドから UI コントロールを操作したり読み書きしたりすることはできないため、その回避方法について解説します。 - Part 4. Visual Studio を使ったマルチスレッドアプリケーション開発
上記 Part 2, 3 の作業を簡素化するために用意されている、Visual Studio の機能について解説します。
まず本エントリでは、UI スレッドから切り離した処理を動かすために利用するマニュアルスレッドやプールスレッドのことを、タスクスレッドと呼ぶことにし、その作成方法について解説します。
- デリゲートとは何か
- マニュアルスレッドの新規作成
- スレッドプールへのワークアイテムの追加
- 非同期デリゲートの利用
- タイマの利用
なお、以降の説明では様々なスレッドの起動方法を解説していきますが、突き詰めると、タスクを動かすスレッドには、マニュアルスレッドかプールスレッドかのどちらかを使っています。ただ、その起動方法が様々な種類がある、というだけの話ですので、見かけの多様性に惑わされず、しっかり学習していただければと思います。(今回はサンプルらしいサンプルはないですが、一応くっつけておきます。)
では、順番に解説していきます。
[デリゲートとは何か]
マニュアルスレッドやプールスレッドの起動処理を記述する上で欠かせない技術のひとつが、デリゲートです。デリゲートとは、オブジェクトに対して、「関数や処理ロジック」を引数として渡す際に利用される技術であり、.NET Framework の基盤技術の一つになっています。
そもそも「関数」や「処理」を引き渡すイメージがわかない、という方も多いと思いますので、まずここで簡単に解説します。以降の解説は、スレッディングの話からはちょっとそれますが、非常に重要なので必ず理解してください。(※ すでにデリゲートをご存じの方は、この項目は飛ばして先へ進んでください)
まず、なぜ「関数」を引数として引き渡す必要があるのかを理解するために、「コレクションから、ある条件を満たすものだけを抽出する処理」を考えてみることにします。例えば、List<int>型(動的配列)に含まれる整数値から、3で割り切れるものだけを取り出す処理を行う場合、おそらく多くの方は以下のようなコードを記述すると思います。
1: List<int> data1 = new List<int>();
2: for (int i = 0; i < 100; i++) data1.Add(i);
3:
4: // 自力抽出方式
5: List<int> data2 = new List<int>();
6: foreach (int i in data1)
7: {
8: if (i % 3 == 0)
9: {
10: data2.Add(i);
11: }
12: }
13:
14: foreach (int i in data2) Console.WriteLine(i);
この処理方式は、イラストであらわすと次のように示されます。
この方式は、「ユーザーがコレクション内のデータを1つずつ取り出しては吟味し、手作業で移し変えていく」ようなモデルです。もちろん、この処理自体は正しく動作するのですが、そもそも「何らかの条件に基づいてデータの抽出を行う」という処理自体、非常によく出てくる処理です。
そこで、以下のようなモデルを取ることができないか否かを考えてみます。
つまり、List<int> 型のコレクションに対して、「3で割り切れるか否かを確認するロジック」(=抽出条件のロジック)を外部から与えて、これに基づいて、コレクションクラス自身がデータを自動抽出するようなモデルで実装できないか、と考えるわけです。
実は、.NET Framework の「デリゲート」と呼ばれる仕組みを利用すると、これが実現できます。具体的には、以下の通りです。
- List<int> 型には .FindAll() というメソッドが備わっている。
- このメソッドには、引数として、関数(ロジック)そのものを渡すことができる。
実装方法を以下に示します。
- まず、CheckData() 関数(引き渡すことになる抽出条件判定関数)を作成しておく。
- これを「デリゲート」と呼ばれるオブジェクト(ここではPredicateオブジェクト)にラッピングして 、引数として .FindAll() メソッドに引き渡す。
このようにすると、List<int> 型コレクション(data1)自身が、渡されたロジックに基づいて抽出処理を行い、抽出結果を返します。
1: static void Main(string[] args)
2: {
3: List<int> data1 = new List<int>();
4: for (int i = 0; i < 100; i++) data1.Add(i);
5:
6: // デリゲート(関数ポインタ)方式
7: List<int> data2 = data1.FindAll(new Predicate<int>(CheckData));
8:
9: foreach (int i in data2) Console.WriteLine(i);
10: }
11:
12: static bool CheckData(int i)
13: {
14: return (i % 3 == 0);
15: }
ここで重要なのは、以下のポイントです。
- 本来、引数として渡せるものは、文字列や数値といった「具体的なモノ」。
- しかし、デリゲートのインスタンスでラッピングすると、処理(=関数)を引き渡すことができる。
また、デリゲートで重要なもう一つのポイントは、あるデリゲートクラスがラッピングできる関数は、そのデリゲートが定義している引数/戻り値と完全に一致していなければならない、という点です。上記のサンプルの場合、Predicate<int> というデリゲートでラッピングできる関数は、int 引数ひとつを取り、bool 型を返すような関数に限られています。引数や戻り値の型などがひとつでもずれていると、そのデリゲートでのラッピングはできませんので、注意してください。
※ (注意) 上記のコードでは少し簡略化して書いていますが、デリゲートインスタンス(関数をラッピングしたもの)はそれ自体を変数によって保持することができます。例えば、上記のコードは以下のようにも書けます。(普通は面倒なのでまとめて一行で書いてしまいますが)
List<int> data2 = data1.FindAll(new Predicate<int>(CheckData));
↓
Predicate<int> d = new Predicate<int>(CheckData);
List<int> data2 = data1.FindAll(d);
※ (注意) .NET Framework 2.0 以降では、下記のように、デリゲートを使わずに、直接、関数名をメソッド引数として渡すことができるようなコードを書くことができるようになっています。しかし、これはコンパイラが自動的にコードを補正してくれるためで、内部的にはデリゲートにラッピングされたコードとしてコンパイルが行われます。この機能は実装時には便利なこともあるのですが、今回は簡略化コードを使わずに、きちんとデリゲートでラッピングしたコードを書いていくことにしたいと思います。
1: // .NET Framework 2.0 以降のコンパイラでは、下記のコードでもコンパイルが通るが...
2: List<int> data2 = data1.FindAll(CheckData);
3:
4: // 内部的には、下のようなコードに変換された上で、コンパイルされている
5: List<int> data2 = data1.FindAll(new Predicate<int>(CheckData));
では引き続き、タスクスレッドの作成方法について順番に解説していきます。
[マニュアルスレッドの新規作成]
まず、最も基本となる、マニュアルスレッドの新規作成方法について解説します。マニュアルスレッドを新規で作成するには、System.Threading 名前空間にある、Thread クラスのインスタンスを作成し、これを起動します。基本的な実装方法は、以下の通りです。
- マニュアルスレッド上で動作させたい処理を、引数なし、戻り値なしのメソッドとして作成する。
- このメソッドを ThreadStart デリゲートにラッピングして、Thread オブジェクトのコンストラクタに引渡す。
- スレッドインスタンスの .Start() メソッドをたたくと、新規にマニュアルスレッドが起動し、引き渡しておいたメソッドが起動する。
コードサンプルを以下に示します。
※ (注意) スレッドを起動する前に、t.IsBackground = true; という設定をしていますが、この設定を行うとこのスレッドがバックグラウンドスレッドとしてマークされます。Windows フォームアプリケーションを終了する際に利用する Application.Exit() 命令は、「すべてのメッセージループを停止し、アプリケーションウィンドウを閉じ、メインスレッドの Application.Run() 命令を終了する」というものですが、フォアグラウンドスレッドが残留しているとプロセスが終了しません。このため、マニュアルスレッドは、バックグラウンドスレッド設定をしてから起動することが望ましいと言えます。
なお、この ThreadStart デリゲートは、System.Threading 名前空間の下側に定義されており、引数なし、戻り値が void 型のメソッドをラッピングすることができるデリゲートになっています。よって、この方法では、UI スレッドからマニュアルスレッドへとデータを直接引き渡すことができません。もちろん、上記のコードに示したように、共有変数領域を作成しておき、この領域を使ってデータを引き渡すこともできますが、この方法の場合、UI スレッドとマニュアルスレッドが同時にこのデータを操作する危険性があるため、排他制御が必要になります。(スレッド間での処理競合については、以前のエントリ(こちらとこちら)を参考にしてください。)
この問題を解決するために、.NET Framework 2.0 で導入されたのが、ParameterizedThreadStart デリゲートです。以下に具体的な実装方法を示します。
- マニュアルスレッド上で動作させたい処理を、引数 object 型ひとつ、戻り値なしのメソッドとして作成する。
- このメソッドを、ParameterizedThreadStart デリゲートにラッピングして、Thread オブジェクトのコンストラクタに引き渡す。
- スレッドインスタンスの .Start() メソッドに、object 型のパラメータを一つ渡して叩くと、新規にマニュアルスレッドが起動する。
この方法を利用すれば、明示的にデータ変数を引き渡すことができます。なお、この方法で認められている、UI スレッドからマニュアルスレッドへ引き渡せるパラメータは object 型変数 1 つだけですが、object 型ですのでなんでも渡すことが可能です。(複数のデータ項目を引き渡したい場合には、構造体にまとめたり object[] 配列などにして、これを引き渡せばよい) データを受け取ったメソッド側では、これを元のデータ型にキャストしてから利用してください。
では次に、スレッドプールの使い方について解説します。
[スレッドプールへのワークアイテムの追加]
スレッドプールは、マニュアルスレッドと異なり、自力でスレッドを新規に作成して利用するというものではありません。以前のエントリで解説したように、すでに起動しているスレッドに対して、メソッドを引き渡して処理してもらう、という形になります。
具体的には、以下の作業を行います。
- まず、引数として object 型変数を一つ、戻り値として void 型となるメソッドを用意する。
- これを WaitCallback デリゲートに包んで、プールのキューに追加する。(ThreadPool クラスの QueueUserWorkItem() メソッドを利用する)
実装コードサンプルは以下の通りです。
なお、スレッドプールのワークアイテムキューに追加できるデリゲートは、WaitCallback デリゲートのみになっています。WaitCallback デリゲートは、object 型引数ひとつ、void 型戻り値を持つメソッドしかラッピングできませんので、逆に言うと、スレッドプールによって非同期化できるメソッドのパラメータは object 型一つに限られる、ということになります。複数のパラメータを引き渡したい場合には、
- object 型の配列を使って一個の引数にまとめる。
- 構造体のようなクラスを使って一つの引数にまとめる。
- 後述する非同期デリゲートを使う。
のいずれかの方法を使う必要があります。
[ここまでのまとめ]
さて、ここまでの解説を一度まとめておきます。
- マニュアルスレッドを作成するためには、Thread クラスを作成する。
Thread クラスによりマニュアルスレッド上で起動できるメソッドは、引数なし/戻り値 void 型のメソッドか、引数 object 型ひとつ/戻り値 void 型のメソッドに限定される。
- プールスレッドを利用して処理を行わせるには、ThreadPool クラスを利用する。
ThreadPool クラスの .QueueUserWorkItem メソッドを使って、処理を投入する。投入できるメソッドは、引数 object 型ひとつ/戻り値 void 型のメソッドに限定される。
なお重要な注意点ですが、マニュアルスレッドやプールスレッド上で動作しているメソッドから、UI コントロールを読み書き・操作しては絶対にいけません。当然、マニュアルスレッドやプールスレッド上で長時間処理が終わったら、それをエンドユーザに通知したりする必要はあるのですが、そのためにうかつに button1.Enabled = true; とか label1.Text = “処理が完了しました”; とか MessageBox.Show(“Congulaturations!”); とか書いてはいけません。うっかりこれをやってしまうと、アプリケーションがクラッシュする危険性がありますので、十分に注意してください。
※ (参考) なお、マニュアルスレッドとプールスレッドの使い分けに関しては、Windows フォームアプリケーションの場合にはそれほどシビアになる必要はありません。スレッドプールのスレッドには上限があるため、スレッドプールが枯渇しないよう、長時間処理についてはマニュアルスレッドを使う、というのが一般的なベストプラクティスです。しかし多くの業務アプリケーションでは、そもそもそんなにたくさんの処理を同時に走らせるわけではありません。ASP.NET ランタイムなどでは内部的に大量のプールスレッドが利用されますが、Windows フォームでは、背後で走らせたいタスク処理はせいぜい数個程度でしょうから、プールスレッドを使ったところで枯渇現象を起こすことはないでしょう。このため、Windows フォームアプリケーションの場合には、長時間処理であってもプールスレッド上で動作させてしまうことがしばしばあります。
さて、ここまで解説してきた方法だと、マニュアルスレッドやプールスレッドに引き渡せるデータ変数にかなりの制限があることがわかると思います。もちろん object 型なのでなんでも渡せるといえばその通りなのですが、もうちょっと柔軟に引数として好きなものを渡す方法はないのか....と思うのも人情というものでしょう。実は、以下に解説する非同期デリゲートと呼ばれる機能を利用すると、複数の引数を持つメソッドを、プールスレッド上で処理することができるようになります。これについて解説します。
[非同期デリゲートの利用]
ここまでマニュアルスレッドやプールスレッドの使い方について解説してきましたが、これらに対して引き渡すために利用したデリゲートは、すべて .NET Framework 側で定義されているものであり、我々がデリゲートを定義することはしていませんでした。しかし、デリゲートは我々自身で定義することもできます。例えば、string 型引数と int 型引数を 1 つずつ取り、string 型を返すようなメソッドをラップするデリゲートは、以下のように定義することができます。
1: public delegate string MySampleDelegate(string a, int b);
このデリゲートを使うと、string 型引数と int 型引数を 1 つずつ取り、string 型を返すようなメソッドをラッピングしたオブジェクトを作ることができます。
1: // 以下のようなメソッドを作っておいて...(※ パラメタ名は一致していなくても OK)
2: public string MySampleMethod(string x, int y)
3: {
4: ... (何らかの処理) ...
5: }
6:
7: // このメソッドをラッピングしたデリゲートインスタンスを作る
8: MySampleDelegate del = new MySampleDelegate(MySampleMethod);
9:
さて、このデリゲート(上の例では MySampleDelegate)のインスタンスには、.BeginInvoke() というメソッドが定義されています。この .BeginInvoke() メソッドを叩くと、デリゲートがラップしているメソッドに対する呼び出し要求が、スレッドプールのワークアイテムキューにキューイングされます。そしてその結果、ラップしているメソッドが、プールスレッド上で呼び出されることになります。
1: MySampleDelegate del = new MySampleDelegate(MySampleMethod);
2: del.BeginInvoke("Nobuyuki", 123, null, null); // 後ろ 2 つのパラメータはコールバックに利用するもの(今回は解説しません。)
もう少し具体的な使い方として、Windows フォーム上で、非同期デリゲートを使って、ある長時間処理メソッドをプールスレッド上で動かすための手順を示すと、以下のようになります。
- プールスレッド上で実行したいメソッドを作成する。
このときのメソッドパラメータは任意ですが、戻り値は void としてください。
- 作成したメソッドの引数・戻り値に併せた形でデリゲートを定義する。
デリゲートの名称は、メソッド名 + Delegate とすると良いでしょう。(例: MethodX() に対して、MethodXDelegate とする) このデリゲートはここでしか利用しないので、public 宣言する必要はありません。private 宣言で十分です。
- UI スレッドからスレッドプールのワークアイテムキューにワークアイテムを投入する。
デリゲートインスタンスを作成し、BeginInvoke() 命令を発行します。これにより、当該メソッドへの呼び出しがワークアイテムキューに投入され、プールスレッドにより処理されます。(なお、呼び出しの際には、引数の末尾に null を二つつけてください。これらのパラメータは、コールバック処理や戻り値のハンドリングのために利用されますが、複雑なので今回は解説しません。)
具体的なコード例は以下の通りです。
さて、ここで解説したデリゲートが持っている .BeginInvoke() メソッドと、前回のエントリで解説した Windows フォームのコントロールが持っている .BeginInvoke() メソッドは、名前こそ同じですが全く別物であることに注意してください。
- デリゲートが持っている .BeginInvoke() メソッドは、スレッドプールのワークアイテムキューへの投入である。
- Windows フォームのコントロールが持っている .BeginInvoke() メソッドは、メッセージキューへのメッセージ投入である。
この違いをはっきりさせるため、以下のようなアプリケーション(ボタンを押すとプログレスバーが進んでいき、終了するとメッセージが表示される)を作成してみることにしましょう。

UI の内部設計図 は、以下の通りです。
具体的な実装方法は、以下のようになります。
UI スレッドからの、処理タスクの起動
- まず長時間を要する処理を、LongTask() メソッドとして定義します。ここでは例のため、名前と処理回数最大値を引数として取るようにしておくことにしましょう。
- 次に、LongTask() メソッドに対して、引数や戻り値を合わせたデリゲートを定義します。メソッドのすぐ上に定義しておくと都合がよいでしょう。名前は任意ですが、ここではメソッド名+Delegate という名前をつけることにします。
- ボタンが押されたら、デリゲートを使って、プールスレッド上でこの LongTask() メソッドを動作させるようにします。
プールスレッドからの UI の更新
- ここまで解説してきたように、プールスレッドから直接 UI を更新することはできません。そこで、UI を更新したい処理(プログレスバーへの表示とラベルへの表示処理)を、メソッドとして定義し(UpdateProgressBar(), UpdateLabel() メソッド)、それぞれに対してデリゲートを作っておきます。
- さらにメッセージキューへメッセージを投入するため、コントロールの .BeginInvoke() メソッドを使い、これらの処理を UI スレッド上で動作させます。

このように、UI スレッドとプールスレッドの連携協調動作には、デリゲートや .BeginInvoke() メソッド(2 種類)が利用されることを覚えておいてください。
なお、上記のサンプルでは、プールスレッドから UI スレッドへ処理を移す際に、this.BeginInvoke() メソッドを叩いていますが、この “this” はフォームそのもの(Form1)を示しています。実は、通常の Windows フォームアプリケーションでは、すべてのコントロールが同一の UI スレッドに属しており、そのような場合には、どのコントロールの .BeginInvoke() メソッドを叩いても同じ結果となります。ですので、上記のサンプル中の this.BeginInvoke() メソッドは、button1.BeginInvoke(), label1.BeginInvoke(), progressBar1.BeginInvoke() などと書いても同じ結果となります。
※ 参考(ちょっと難しいので、わからない人は読み飛ばしてください。)
デリゲートが持っている .BeginInvoke() メソッドを利用する場合は、本来のメソッド引数の後ろにさらに 2 つの引数を付与する必要があり、上記のサンプルでは null をつけていました。この 2 つの引数をうまく使うと、戻り値を持つメソッドへの呼び出しを非同期化したり、その結果を取り出したりすることができます。しかし、特殊な理由がなければ、Windows フォームのプログラミングではこの機能は利用する必要はありません。
例えば、非同期デリゲートを利用して、戻り値を持つメソッドへの呼び出しを非同期化する例を考えてみます。この場合には、以下のような設計と実装になります。
上記のコードを見てみると、確かに、後ろ 2 つのパラメータを使うことにより、プールスレッド上で開始した非同期処理の戻り値を受け取るメソッド(これをコールバック関数といいます)を作ることができます。しかし、この処理は UI スレッド上では動作していないため、結局、ここから UI の更新を行うことができません。結果として、上記のような面倒なコーディングが必要になってしまいます。これならいっそ、下に示すコードのように、コールバック関数を使わず、普通にメッセージキューへメッセージを投入するようなプログラムを書いた方が単純です。
このように、デリゲートが持つコールバック機能を利用すると、
- 戻り値を持ったメソッドへの呼び出しを非同期化する(プールスレッド上で動作させる)。
- その戻り値を、別のメソッド(コールバック関数)で受け取る。
ということが可能になるのですが、どちらかというと、コールバック関数を使わずに済ませるプログラミングの方が素直でしょう。(もともとコールバック関数は UI を持たない通常のマルチスレッドプログラミングで使うものなので、UI を持つアプリの場合には、コントロールの .BeginInvoke() だけを使った方が簡単なのですね^^。) なので、この機能については忘れてしまって OK です。
では、最後にちょっとした応用として、タイマの使い方について解説します。
[タイマの利用]
定期的に何らかのタスク処理を行いたい場合、タスクスレッドを起こしてそこでビジーループを作って待機することは、リソース利用上望ましくありません。むしろこのような場合には、.NET Framework 内に用意されているタイマオブジェクトを利用すると便利でしょう。
ただし注意したいのは、.NET Framework の中には 3 種類のタイマがあり、適切な使い分けが必要になる、という点です。具体的には、以下の 3 種類のタイマを使い分けていただく必要があります。(いずれも名称は “Timer” クラスですが、中身や機能は全くといっていいほど異なります。)
- System.Windows.Forms.Timer クラス (Windows アプリ向け)
定期的に Windows メッセージキューにメッセージを投入してくれるもの。
- System.Timers.Timer クラス (汎用タイマ)
定期的にスレッドプールのワークアイテムキューにワークアイテムを投入してくれるもの。
- System.Threading.Timer クラス (低水準タイマ)
低水準 API を提供するタイマ。1. や 2. の内部で使われている。
このうち、3. の System.Threading.Timer クラスは低水準タイマであるため、ほとんど使う必要はありません。基本的には 1. を中心に使い、場合によって 2. を併用する、という形になります。それぞれについて、具体的なコード例を示します。
1. System.Windows.Forms.Timer クラス (Windows アプリ向け)
まず、System.Windows.Forms.Timer クラスは、定期的な UI 更新処理に利用するタイマです。Windows フォームのツールボックス一覧に表示されている部品になりますので、画面に貼り付けて利用します。
利用する際は、timer1_Tick イベントハンドラの記述と、Interval プロパティへのタイマ発生間隔(msec)の設定を行います。このようにすると、System.Windows.Forms.Timer クラスは、定期的にメッセージキューにメッセージを投入してくれます。コード例と、内部動作の概念図を以下に示します。
1: private void timer1_Tick(object sender, System.EventArgs e)
2: {
3: // ここでは長時間処理は絶対にしないこと!(0.1sec ルール)
4: label1.Text = DateTime.Now.ToString(); // BeginInvoke()は不要
5: }
6:
7: private void button1_Click(object sender, EventArgs e)
8: {
9: timer1.Enabled = !timer1.Enabled;
10: }
この System.Windows.Forms.Timer コントロールは、メッセージキューに定期的にメッセージを投入するコントロールです。このため、動作上、以下の特徴や制約があります。
- イベントハンドラ(timer1_Tick)は UI スレッド上で動作するため、直接、UI を更新してよい。(.BeginInvoke() メソッドを使う必要はない。)
- イベントハンドラ(timer1_Tick)は UI スレッド上で動作するため、長時間処理をしてはならない。
よって、この Timer コントロールは、時計の表示などの単純な画面更新タスクの非同期化に利用するのが都合がよいでしょう。
2. System.Timers.Timer クラス (汎用タイマ)
次に、System.Timers.Timer クラスについて解説します。こちらは、定期的な業務処理を行うために利用するタイマで、スレッドプールのワークアイテムキューにワークアイテムを定期的に投入してくれるものです。
先ほどの Windows フォームの System.Windows.Forms.Timer クラスとは異なり、こちらは画面に貼り付けて利用する部品(コントロール)ではなく、通常のオブジェクトになります。実装例を以下に示します。
この System.Timers.Timer クラスのタイマーには、以下のような特徴があります。
- スレッドプールのワークアイテムキューに定期的に処理を投入する。
- UI スレッドをブロックしないため、時間のかかる処理も実施できる。
- 半面、UI 更新のためには BeginInvoke() を利用する必要がある。
特に、最後のポイントについては注意してください。このタイマーのイベントハンドラ(上記のコード例の場合には t_Elapsed() メソッド)は、プールスレッド上で動作しますので、直接、UI コントロールを操作してはいけません。必ず、UI コントロールの BeginInvoke() メソッドにより、UI スレッドへの処理投入を行う必要があります。
(参考&応用) なお、少し裏ワザ的な機能になりますが、System.Timers.Timer クラスの Synchronized プロパティを使うと、t_Elapsed イベントハンドラを UI スレッド上で動作させることができます。しかし、この機能を使うぐらいなら最初から System.Windows.Forms.Timer コントロールを使った方がラクなので、そちらをお勧めします。
3. System.Threading.Timer クラス (低水準タイマ)
上記 2 つのタイマは、それぞれ
- 定期的な UI 更新タスクを動かしたい → System.Windows.Forms.Timer コントロール
- 定期的な業務処理を動かしたい → System.Timers.Timer クラス
というように使い分けますが、これらの内部で低水準 API として利用されているのが、ここで解説するSystem.Threading.Timer クラスになります。ただし、こちらは低水準 API であるため、基本的に使いません。参考までに実装例を以下に示しますが、通常は使わない、ということを覚えておいてください。
以上で解説した 3 種類のタイマの使い分け・比較をすると、以下のようになります。実際に利用するのは、1. と 2. のタイマである、ということを覚えておいていただければと思います。
[今回のエントリのまとめ]
というわけで、ここまで様々なタスクスレッドの起動方法について解説してきましたが、それぞれのタスクスレッドの起動方法には様々なトレードオフがあります。
- 起動パラメータ引渡し可否
- 動作スレッドの種類
- 記述できる処理の長さ
- UI オブジェクト操作時のスレッド同期要否、etc…
これらを比較表としてまとめると、以下のようになります。
もちろん、いずれも一長一短があるわけですが、基本的には以下のように使い分けるとよいでしょう。
- 通常のタスクスレッドの起動には、非同期デリゲートを使う。
非同期デリゲートは最も汎用性が高く、制限が少ないためです。
- 定期的な UI 更新処理については、System.Windows.Forms.Timer コントロールを使う。
ただし、イベントハンドラでは XML Web サービス呼び出しなどの長時間処理は行ってはいけません。
- 定期的な業務処理については、System.Timers.Timer クラスを使う。
ただし、イベントハンドラでは UI を直接操作してはいけません。
- プールの枯渇を考えなければならないような場合は、マニュアルスレッドの利用を検討する。
タスクスレッドの起動については、実装コードを見て「なるほど」と思っても、実際に自分でプログラミングしてみると意外に手詰まりしてしまうことが多いです。今回示したサンプルコードを実際に一度手を動かして組んでみると、なるほどと納得できるところも多いと思いますので、ぜひ一度トライしてみてください。
-
さて、Windows フォームは、Windows OS が持つ様々なウィンドウ制御の仕組みに基づいて開発されている UI 技術です。このため、Windows フォームのマルチスレッド処理を理解するためには、まず Windows OS がどのようにして Windows フォームアプリケーションを動作させているのかについて理解する必要があります。その中でも特に重要なのが、メッセージキューとメッセージループです。これらを理解することで、なぜ UI が固まるのか、また固まることを防ぐにはどうしたらよいのか、といったことが理解できるようになります。これについて解説します。
- メッセージキューとメッセージループ
- UI フリーズの発生理由
- Windows フォーム上でのマルチスレッド処理の基本ルール
- BeginInvoke() 命令
- 最も簡単なマルチスレッドアプリケーションの例
- Windows フォームにおけるスレッドの種類
なお、今回のサンプルは以下の通りです。ご活用ください。
[メッセージキューとメッセージループ]
Windows フォームは、メッセージループと呼ばれる仕組みを使うことにより、イベント駆動型のプログラミングモデルを実現しています。まず、概略図を以下に示します。

エンドユーザがマウスやキーボードによって Windows フォームのアプリケーションを操作した際に Button_Click などのイベントが発生するのは、以下のようなメカニズムによります。
- マウスやキーボードからの入力は、まず Windows OS が受け取る。
- Windows OS は、その操作内容(キーが押された、マウスが動いた、マウスのボタンがクリックされた、etc)を、メッセージ構造体(MSG 構造体)に固め、それを各アプリケーション用のメッセージキューに放り込む。
- 各アプリケーション内部では、メッセージループと呼ばれる処理が走っている。
- メッセージループは、自分用のメッセージキューからメッセージ構造体をひとつずつ取り出し、そのデータを解析し、イベントハンドラ呼び出し(Button_Click 呼び出しなど)を行う。
※ なお、ここでいうメッセージキューとは、MSMQ (Microsoft Message Queue)のことではありません。Windows OS が持っている、GUI 処理のための特殊なキューです。
C# で Windows フォームのアプリケーションを記述した方であれば、Main 関数の中に、以下のような Application.Run() 命令を記述したことがあると思います。この命令は、メインスレッド上でメッセージループを起動するためのものです。(VB だと内部的にこの処理が隠ぺいされるためこのコードが見えませんが、内部的には同じ処理が行われています。)
このメッセージループには、以下のような特徴があることを覚えておいてください。
- メッセージループは、一種の無限ループです。つまり、メッセージキューからメッセージを取り出して処理し、次のメッセージを取り出して処理し、...をひたすら繰り返します。メッセージがなくなると、次のメッセージが届くまで待機しますが、いずれにしてもこのメッセージループは終了しません。メッセージループのコードは .NET Framework 内部に実装されているため見ることができませんが、コードイメージとして、以下のような処理が行われていると思っていただけるとわかりやすいでしょう。
while (true)
{
メッセージを取り出す処理(); // (取りだせなかった場合は待機する)
メッセージの内容を解析する処理();
メッセージの内容に基づいて、イベントハンドラを呼び出したりする処理();
} - メッセージループから、(開発者が記述した)イベントハンドラが呼び出されるまでの流れは、スタックトレースを見てみるとわかります。
さて、このメッセージループによるメッセージの取り出しにおいて重要なことは、メッセージの取り出し作業がシングルスレッド処理である、という点です。つまり、
- ひとつのメッセージを取り出して、イベントハンドラ処理(Button_Click 処理など)を行っている最中は、次のメッセージが取り出されることはありません。
ということになります。実はこれが、UI フリーズが発生する主な原因になります。
[UI フリーズの発生理由]
では次に、UI のフリーズ(UI が固まって操作できなくなる現象)がなぜ発生するのかについて解説します。先ほど、メッセージキューに OS が投入する代表的なメッセージとして、以下のようなものを挙げました。
- キーが押された
- マウスが動いた
- マウスのボタンがクリックされた
しかし、実は OS が投入するメッセージには、これ以外にも次のようなものがあります。
例えば画面上で、最小化されていたフォームがタスクバーからクリックされ、非アクティブだったフォームがアクティブ化されたとします。この場合、OS は、当該アプリに対して「UI を描画しなさい」という命令(メッセージ)を、(メッセージキューを介して)送ります。これを受け取った Windows フォームアプリは自分を描画することで、フォームを表示することになります。
つまり、メッセージキューに投入されるメッセージの中には、Windows OS からの再描画要求やサイズ変更要求などもあります。こうしたメッセージをすぐに処理できないと、UI が固まったり、正しくウィンドウが表示されなくなったりするように見える、ということになるわけです。
ところが先ほど述べたように、メッセージループによるメッセージキューからのメッセージの取り出しは、ひとつずつ順次行われます。このため、メッセージループを持つスレッド上で時間のかかる処理を行ってしまうと、再描画処理が即座に行われず、UI がフリーズします。
例えば、以下のようなコードを書いたとします。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: System.Threading.Thread.Sleep(5000);
4: }
このようなコードを書いて実行すると、ボタン押下中はウィンドウがうんともすんとも言わなくなり(=ウィンドウを移動することなどができなくなり)、UI がフリーズします。理由は簡単です。
- メッセージループを動作させているメインスレッドが、button1_click() を処理している最中は、次のメッセージを処理できない。
- このため、OS からウィンドウの移動や再描画要求メッセージが送られても、このアプリケーションはそれに反応できない。
つまり、
- Windows フォームのイベントハンドラ(Button_Click や TextBox_TextChanged など)は、メッセージループから同期的に呼び出される。
- これらの中で時間のかかる処理を行ってしまうと、簡単に UI がフリーズする。
ということになります。言い換えれば、
- UI を全くフリーズさせないためには、メッセージループから呼び出されるイベントハンドラで、時間のかかる処理(具体的には 0.1 sec 以上かかる処理)を行わないようにすればよい。
ということになります。
なお、イベントハンドラ内では何秒程度までの処理なら認められるのか? については、なんとも言い難いものがあります。一般的に、人間の視覚速度は 30fps (秒間 30 フレーム、1 フレームあたり約 30msec)と言われており、これ以下であればスムーズに動作しているように見える、と言われています。とはいえ 30msec はかなり厳しい制限です。現実的には、各イベントハンドラ内の処理を 3 フレーム程度、つまり 100msec 程度以内に収まるように設計・実装すれば、ほとんどフリーズが感じられない、応答性の高いアプリになります。(もっとも、これはアプリの特性などによっても変わりますので、一概に言える数字ではありませんが。)
逆に言えば、次のようなことがいえます。
- UI がフリーズしないアプリを作るためには、時間のかかる処理(具体的には 0.1sec 以上かかる処理)を別スレッドに切り離して実行しなければならない。
業務アプリケーションには、多かれ少なかれ、こうした「時間のかかる処理」があります。典型的なものとしてはネットワークアクセス、具体的にはデータベースアクセスや XML Web サービスへのアクセスがあります。こうした時間のかかる処理をうかつにイベントハンドラに記述してしまうと、メッセージループをブロックし、UI がフリーズすることになります。今回のエントリで解説するのは、このような処理をいかにして別スレッドに切り出すのか、についてです。
では引き続き、別スレッドへの処理の切り出し方の具体的な説明を....と言いたいところですが、その前にもうひとつ説明すべきことがあります。それは、Windows フォーム上てのマルチスレッド処理に関する基本ルールです。
[Windows フォーム上でのマルチスレッド処理の基本ルール]
マルチスレッド処理を行う Windows フォームアプリケーションを開発する際には、必ず以下のルールを守る必要があります。
- 親子関係を持つコントロールは、必ず同一スレッドに所属させること。
Windows フォームのコントロール(UI 部品)は、インスタンス生成時に、それが生成されたスレッドに自動的に紐付けられるように設計されています。このため、フォーム上にテキストボックスやラベルなどがある場合、それらはすべてフォームのインスタンスを生成したスレッドと同一スレッド上で生成しなければなりません。
通常は、すべての UI 部品をメインスレッド上で生成し、ここでメッセージループを起動します。(このため、メインスレッドは UI スレッドとも呼ばれます。以降の解説は、すべてこの前提条件に基づいて解説します。)
- コントロールを生成したスレッド以外から、コントロールを操作しないこと。
Windows フォームのコントロールは、スレッドセーフではありません。このため、当該コントロールを作成したスレッド(通常は UI スレッド)以外から直接プロパティなどを操作してはいけません。
特に後者は非常に重要です。Windows フォーム内でバックグラウンドタスクを実行するために背後のスレッドを起動した場合、そこから UI 上に進捗状況を表示したり、処理結果を表示したりしたいことが多々あります。しかし、このような際に、背後のスレッドから直接 label1.Text = “…(処理結果)…”; といった具合に直接コントロールを操作すると、最悪の場合、アプリケーションがクラッシュします。
この問題を解決するために用意されているのが、BeginInvoke() 命令です。
[BeginInvoke() 命令]
先に述べたように、UI 部品はそれを作成したスレッド、通常は UI スレッド(=メッセージループを動作させているスレッド)から操作しなければなりません。では、上図のように独自に起動した処理スレッドから UI を更新したい場合にはどのようにすればよいかというと、BeginInvoke() 命令を利用します。この命令は、すべての Windows フォームコントロールが備えているメソッドで、簡単にいうと、「特定のメソッドを呼び出せ」という命令を、メッセージ構造体としてメッセージキューに投入するためのものです。
具体例を出しながら解説した方がわかりやすいと思いますので、一例として、以下のような画面(バックグラウンドで処理を進めている際に、進捗状況を ProgressBar に表示する)を考えてみます。
まず、バックグラウンドスレッドから、直接 ProgressBar を操作するのは厳禁です。つまり、progressBar1.Value = 87; といった具合に、ProgressBar コントロールを UI 以外のスレッドから直接操作する(上図の青いスレッドから操作する)ことは厳禁です。これを避けるために、以下の作業を行います。
- まず、画面更新用のメソッドをコードビハインド中に作成します。
private void UpdateProgressBar(int val)
{
progressBar1.Value = val;
}
- 次に、このメソッドのポインタ情報をラップするための、デリゲートクラスを定義します。(UpdateProgressBar メソッドのすぐ上に、UpdateProgressBarDelegate などの名前で配置するとわかりやすいです。なおデリゲートの詳細は、Part 2 にて解説します。)
private delegate void UpdateProgressBarDelegate(int val);
private void UpdateProgressBar(int val)
{
progressBar1.Value = val;
}
- 最後に、背後で動作するバックグラウンドタスクのスレッドから、BeginInvoke() 命令を利用してメッセージを投入します。(コードの全体像は後で示します。なお、通常はすべてのコントロールが UI スレッドに所属しているため、どのコントロールの BeginInvoke() 命令を利用しても同じ結果となります。通常は Form クラスの BeginInvoke() 命令を叩くとよいでしょう。)
// 進捗状態として画面を更新
this.BeginInvoke(new UpdateProgressBarDelegate(UpdateProgressBar),
new object[] {i});
このようにすると、バックグラウンドタスクのスレッドから、メッセージキューに UpdateProgressBar() メソッドを呼び出すメッセージが投入され、これが処理されると画面が更新されます。BeginInvoke() 経由で投入されたメッセージに含まれるメソッド呼び出しは、UI スレッド上で処理されることになるので、このメソッドでは安全に UI を更新することができます。
[最も簡単なマルチスレッドアプリケーションの例]
では上記のコードサンプルを利用して、以下のような「進捗表示アプリケーション」を作ってみることにしましょう。
具体的な作業手順は以下の通りです。まず、フォーム上にボタンとプログレスバーを貼り付けます。
次に、ボタンのクリックイベントハンドラに以下のようなコードを書き、実行してみてください。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: button1.Enabled = false; // いったんボタンを無効化
4: for (int i = 0; i < 100; i++)
5: {
6: // 何らかのタスクを実施...
7: Thread.Sleep(100);
8: // 進捗状態として画面を更新
9: progressBar1.Value = i;
10: }
11: button1.Enabled = true; // 再びボタンを有効化
12: }
実際に実行してみると、(プログレスバーの表示は一部きちんと動いてくれるものの)ウィンドウを動かそうとすると UI がフリーズしてしまったりします。これは、この button1_Click() メソッドが UI スレッド(メッセージループの処理)を占有してしまっており、OS からの再描画要求に応答できなくなってしまっているためです。
このような形になるのを避けるためには、この処理をバックグラウンドのスレッドに切り離す必要があります。バックグラウンドのスレッドとしては、プールスレッドとマニュアルスレッドの 2 種類がありますが、比較的長時間を要する処理(ここに示したような数秒以上かかるような処理)については、マニュアルスレッドを使う方がよいでしょう。まず、上記の for ループ処理をバックグラウンド処理に切り離します。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: button1.Enabled = false; // いったんボタンを無効化
4: Thread t = new Thread(new ThreadStart(LongTask));
5: t.IsBackground = true; // バックグラウンド化してから起動
6: t.Start();
7: }
8:
9: private void LongTask()
10: {
11: for (int i = 0; i <= 100; i++)
12: {
13: // 何らかのタスクを実施...
14: System.Threading.Thread.Sleep(100);
15:
16: // 進捗状態として画面を更新
17: progressBar1.Value = i;
18: }
19: button1.Enabled = true; // 再びボタンを有効化
20: }
上記のコードの 17 , 19 行目に着目してください。
- button1_Click() は、メッセージループから呼び出されます。つまり UI スレッド上で動作します。
- しかし、上記に示した LongTask() は、新規に作成されたマニュアルスレッド上で動作します。
つまり、この 17 行目や 19 行目のコードは、UI スレッド以外からコントロールを操作しているので、アプリケーションをクラッシュさせる危険性があります。この問題を避けるためには、17 行目や 19 行目の処理を UI スレッド上で動作させるように、BeginInvoke() 命令を使う必要があります。
具体的には、まず UI 更新を行うためのメソッドと、そのメソッド呼び出しをラッピングするためのデリゲートを定義します。
1: private delegate void UpdateProgressBarDelegate(int val);
2: private void UpdateProgressBar(int val)
3: {
4: progressBar1.Value = val;
5: if (val == 100) button1.Enabled = true;
6: }
次に、LongTask() メソッド内の UI 更新処理を、BeginInvoke() によるメッセージ投入処理に切り替えます。
1: private void LongTask()
2: {
3: for (int i = 0; i <= 100; i++)
4: {
5: // 何らかのタスクを実施...
6: System.Threading.Thread.Sleep(100);
7:
8: // 進捗状態として画面を更新
9: this.BeginInvoke(
10: new UpdateProgressBarDelegate(UpdateProgressBar),
11: new object[] { i });
12: }
13: }
以上により、フリーズしない UI を持った進捗状況表示画面が作成されます。
完成したソースコードと、それぞれのメソッドがどこで動作するのかを下図に示します。
このようにすることで、時間のかかる処理を背後のスレッドに分離し、フリーズしない UI を作成することができます。
[Windows フォームにおけるスレッドの種類]
さて、上記では処理の切り離しにマニュアルスレッド(新規に作成したスレッド)を使いましたが、スレッドプールを使って処理を別スレッド化することもあります。(※ スレッドプールがどのようなものであるかについては、以前に記述したエントリを参照してください。)
つまり、Windows フォームでは、UI スレッド(メインスレッド)から処理を切り離す方法として、マニュアルスレッドとプールスレッドの 2 種類がある、ということになります。結果として、Windows フォームアプリケーション内部では、主に以下のようなスレッドが利用されることになります。
- メインスレッド (UI スレッド)
当該プロセス内に最初に作られ、Main() メソッドを呼び出すスレッド。このスレッド上で UI コントロールを作成し、Application.Run() メソッドを呼び出し、メッセージループを動作させる。メッセージキュー内のメッセージ処理や、各種のイベントハンドラ呼び出しはこのスレッド上で発生する。
- マニュアルスレッド
非同期処理(バックグラウンドタスク)を行うために、自力で Thread オブジェクトを生成することにより作ったスレッド。
- プールスレッド
非同期処理(バックグラウンドタスク)を行う際、CLR の機能であるスレッドプール機能を用いる場合に利用されるスレッド。
- その他のスレッド
ファイナライザスレッドやアンマネージスレッドなどが動作するスレッド。通常は気にしなくて OK。
まとめると、下図のようになります。注意すべき点は、マニュアルスレッドやプールスレッドから UI コントロールを直接更新してはならないという点です。UI を更新したい場合には、BeginInvoke() 命令を使って、メッセージキューにメソッド呼び出し要求を投入してください。
[今回のエントリのまとめ]
というわけで、今回のエントリでは Windows フォームのマルチスレッド処理の基礎について解説してきました。キーポイントは以下の通りです。
- Windows OS は、メッセージキューにメッセージ(MSG 構造体)を投入することによって、マウスの移動やキーボードの押下を通知している。
- Windows フォームアプリケーションは、内部でメッセージループを使い、メッセージキューから一件ずつ、逐次でメッセージを取り出していくことで処理を進める。
- UI フリーズが発生する原因は、メッセージループ上(UI スレッド上)で長時間処理を行ってしまうことである。イベントハンドラなどに長時間処理を記述すると、OS からの再描画要求が実行されなくなり、UI がフリーズする。
- 通常、UI コントロールはすべてメインスレッド上でインスタンス化し、このスレッド上から操作する。このメインスレッドのことを、別名で UI スレッドと呼ぶ。
- マニュアルスレッドやプールスレッドといった、メイン以外のスレッドから UI コントロールを操作してはならない。このような場合には、BeginInvoke() 命令を使って、UI スレッド上で画面描画更新処理を動作させる。
以上が基本的な Windows フォームのマルチスレッド処理の大原則です。しかしこれだけではまだマルチスレッド動作する Windows フォームアプリケーションを開発するにはやや知識が足りません。次回のエントリでは、上記の大原則に従った、より詳細なマルチスレッドアプリの開発方法について解説してきます。
-
というわけでまたしてもかなり日にちが空いてしまいました;。年度末ということもあって仕事が立て込んでいたのですが、ほぼ一段落したので久しぶりにエントリを。どうしてもまとまった話題を書こうとすると時間がかかっちゃいますね....
今回の話題は、Windows フォームにおけるマルチスレッド処理の正しい書き方です。以前、マルチスレッドアプリケーションにおけるデータ変数の排他制御(スレッドセーフか否かの判定)についてこちらとこちらのエントリに書きましたが、さらにもう少し応用的なトピックとして、Windows フォームにおけるマルチスレッド処理について解説したいと思います。
この辺の話は、C++ でアプリケーションを作られている方には当たり前の話(らしい)のですが、私のような pure .NET デベロッパーな人にはあまり馴染みのない話だと思います。しかし、C# や VB でしか Windows フォームのアプリケーションを書いたことがない人であっても、うまくコードを書かないと、下図のような「フリーズしてしまう」アプリケーションができあがってしまいます。
今回のエントリでは、以下について解説します。
- そもそもなぜ UI のフリーズ現象は発生するのか? それはどうすれば回避できるのか?
- Windows フォームにおける UI スレッドの位置づけ
- Windows フォーム上で、背後にスレッドを起こすための適切な方法
では、以降のエントリでこれらについて解説していきます。
[Part 1. Windows フォームのマルチスレッド処理の基礎]
- メッセージキューとメッセージループ
- UI フリーズの発生理由
- Windows フォーム上でのマルチスレッド処理の基本ルール
- BeginInvoke() 命令
- 最も簡単なマルチスレッドアプリケーション
- Windows フォームにおけるスレッドの種類
[Part 2. タスクスレッドの起動方法]
- マニュアルスレッドの新規作成
- スレッドプールへのワークアイテムの追加
- 非同期デリゲートの利用
- タイマの利用
[Part 3. タスクスレッドと UI の協調動作]
- タスクスレッドからの UI 画面の更新方法
- タスクスレッドからの UI 画面上のデータの読み取り方法
- UI 画面からのタスクスレッドの制御方法
- タスクスレッド上で発生した未処理例外の取り扱い方法
[Part 4. Visual Studio によるマルチスレッドアプリの開発]
- XML Web サービス呼び出しの非同期処理化
- WCF サービス呼び出しの非同期処理化
- BackgroundWorker コンポーネントによる一般的なタスクの非同期処理化
-
さて、前回のエントリでは Windows フォームにおける双方向データバインドの基本的な使い方を解説しました。要点をまとめると、以下の通りとなります。
- 双方向データバインドを用いると、データソースから UI コントロールへ値を表示するだけでなく、UI コントロールからの入力をデータソースに反映できる。
- データバインドには、2 種類のデータバインドがある。
① 単一値データバインド(単票形式データバインド)
② コレクションデータバインド(グリッド形式データバインド)
- どちらの場合も、BindingSource コントロールを介して、UI コントロールとデータソースを紐づける。
さて前回のエントリでは、 双方向データバインドにより、テキストボックスから入力された値がデータソースオブジェクトに反映されることを確認しました。しかし、これらのデータはそのまま使えるとは限りません。例えば配達希望日を入力するテキストボックスの場合、
- "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 フォームアプリケーション)の中で即時にチェックを行い、すぐさまエンドユーザにエラー情報通知を行うのが望ましいでしょう。
[単体入力エラーの分類]
さて、UI 部のみで単体チェックが可能な「単体入力エラー」ですが、実はこの単体入力エラーや単体入力チェックは、さらに 3 種類に細分化することができます。例えば下図のような、新規顧客登録画面を考えてみましょう。
この画面において実施する必要のある単体入力チェックは、以下の 3 種類に分類できます。
- データ型変換チェック
例) 入力された生年月日が、DateTime? 型に変換できるか?
- フィールド単位の有効性チェック
例) ID や電子メールアドレス、電話番号などが適切なフォーマットか?
例) 入力された生年月日が、未来の日付ではないか?
- インスタンス(レコード)単位の有効性チェック
例) 連絡先として、電話番号か電子メールアドレスかの少なくとも一方が入力されているか?
そして単体入力チェックでは、これらのチェックを、場当たり的ではない考え方で実装する必要があります。
[単体入力チェックとエラーメッセージの関係]
またもう一点重要なポイントとして、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 インタフェースは、簡単に言うと、以下のような特性を持ったオブジェクトのクラスを作成するために使うインタフェースです。
- UI からの入力値を、エラーがあろうとなかろうとそのまま受け取る。
- そのかわりに、内部データにエラーが含まれる場合には、エラーに関する情報を文字列データで返すようにする。
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 インタフェースからエラー情報を取り出し、ツールチップ形式でエラーメッセージを表示してくれるようになります。
具体的な実装例を以下に示します。(ErrorProvider コントロールの DataSource プロパティに、BindingSource オブジェクトを割り当てることを忘れずに。)
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 のコードビハインドは一般的に非常に汚くなりがちですが、この方法を利用すれば、単体入力チェックにかかわるコードを別クラスに切り離すことができます。
[具体的な単体入力チェックの実装方法]
ではもうひとつの具体的な実装例として、最初に挙げた顧客データ入力フォームの例を採り上げてみましょう。
このようなアプリケーションは、以下の手順で実装していきます。
① データバインド用の 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"] = "名前は必須入力項目です。";