単体入力エラーチェックの実装パターン
29 September 09 01:11 PM

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

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

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

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

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

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

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

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

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

image

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

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

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

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

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

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

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

image

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

image

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

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

image

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

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

image

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

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

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

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

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

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

image

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

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

image

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

image

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

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

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

image

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

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

image

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

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

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

image

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

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

image

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

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

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

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

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

image

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

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

image

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

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

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

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

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

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

image

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

image

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

image

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

  • 単体入力チェック処理を、バインドオブジェクトに固めることができる。
    このため、モジュールの役割分担が明確になる上に、単体入力チェックロジック部分だけを重点的に単体機能テストすることもできます。
  • コードビハインドの記述が簡単になる。
    コードビハインドのイベントハンドラでは、バインドオブジェクトだけを操作すればよく、UI コントロールを触る必要がなくなります。このため、コードビハインドのコードの見通しも非常によくなります。
  • 入力仕掛り状態の維持が簡単にできる。
    バインドオブジェクトをそのままシリアル化して保存すれば、入力しかけのデータをそのまま保存しておくこともできます。

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

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

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

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

image

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

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

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

[まとめ]

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

  • 単体データ入力検証フレームワークを使う上では、そもそも業務エラーとシステムエラーの分類や、単体入力エラーの分類を正しく行うことが必要になる。
  • .NET Framework が備えている各種の単体入力エラーチェック機能は、下図の枠線内の実装(開発効率)を高めるためのものである。
    image

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

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

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

Postedby nakama | 4 Comments    
エラーチェックの体系的な分類方法
29 September 09 01:05 PM

まず最初のエントリでは、「エラーチェック」とひとくくりにされている「エラー」を、体系的に分類することを試みてみます。このエントリでは、Web / Windows、あるいは Java / .NET などといった技術論とは無関係な部分についての解説を進めていきたいと思います。

  • エラーチェック(ユーザ入力検証)の意味
  • 正常終了/業務エラー/システムエラーの分類
  • 業務エラーの細分化
  • アーキテクチャから見たエラーチェックの実装場所

※ なお、本エントリで解説されている分類方法や命名方法は、あくまで nakama 個人の考え方・整理方法です。もしかしたらもっとよい設計パターンなどがあるかもしれませんので、その辺についてはあしからずご了承ください;。

[エラーチェック(ユーザ入力検証)の意味]

まずは、そもそもどのようなケースでエラーチェックが必要になるのか、ユーザ入力検証にどのような目的があるのかを考えてみましょう。

一般的に、業務アプリケーションは、参照系アプリケーションと更新系アプリケーションに大別されます。

image_thumb[1]

参照系と更新系では、求められるアプリケーションの機能が大きく異なりますが、更新系アプリケーションでは、適切な入力エラーチェックをどのように実装するのかが、極めて重要なポイントになります。そもそもなぜ更新系アプリケーションでは、適切な入力エラーチェックが重要になるのか? ユーザ入力検証(エラーチェック)には、大別して以下の 2 つの目的があります。

  • アプリケーションの保護
    ユーザから入力された値をそのまま利用すると、エラーやセキュリティ脆弱性の原因になってしまうため、適切にフォーマットチェックなどを行う必要があります。(SQL 挿入、Cross-Site Scripting など)
  • ユーザビリティの向上
    エンドユーザに親切なエラーメッセージを表示するように作成すると、使いやすいアプリケーションを実現することができます。

image_thumb[5]

しかし、このようなユーザ入力検証機能(エラーチェック機能)を場当たり的に実装すると、開発生産性を大きく損なうことになります。このため、通常のアプリケーション開発では、ランタイムが持っている「ユーザ入力検証のための機能」をうまく活用して実装していくことが重要になるわけです。

こうしたランタイムのユーザ入力検証機能を利用する上で欠かせないのが、以下の 3 つのポイントに関する知識です。

  • アプリケーションの終了パターンの分類
    正常終了/業務エラー/システムエラーを正しく分類できること。
    業務エラーが、さらに単体入力エラーと突合せエラーに分類されることを知っていること。
  • アーキテクチャ的な観点から見た、エラーチェックの実装方法
    論理 3 階層型アプリケーションにおいて、どこで何をチェックすべきかを判断できること。
  • ランタイムが持つバリデーション機能(エラーチェック機能)の狙い
    ランタイムが持つバリデーション機能のコンセプトの違いを理解していること。
    どの部分をカバーする目的で作られているのか、どこまでできるのか、その限界点を理解していること。

本エントリでは、まずこのうち最初の、「エラーパターンの分類」の部分について解説します。

[正常終了/業務エラー/システムエラーの分類]

業務アプリケーションの「入力エラー」を考える上で欠かせないのが、ひとくくりに「エラー」とされているものを体系的に分類することです。これを考えるには、業務処理が終了するパターンを、以下の 3 つに分類するのが最初のスタートポイントになります。(※ この終了パターン分類は、nakama による勝手な分類で、業界標準でもなんでもありません;。あしからずご了承ください、とつぶやいてみる^^)

  • 正常終了 : 特に問題なく、期待通りに業務処理が終了できた
  • 業務エラー : ユーザ入力値の問題で、処理が完遂できなかった
  • システムエラー : システムトラブルで、処理が完遂できなかった

image

業務アプリケーションの振る舞いを、まずこの 3 通りに分類することは極めて重要なので、具体例を取り上げながら考えてみましょう。

例えば、下図のような、重複顧客 ID を認めていないような、新規顧客登録業務を考えてみます。この場合、ユーザが画面からデータを入力し、ボタンを押下した場合の終了パターンは

  • 正常に顧客情報を登録できたパターン → 正常終了
  • 指定された顧客 ID がすでに他で使われていたパターン → 業務エラー
  • データベースサーバに接続できなかった → システムエラー

の 3 通りに分類することができます。

image

この終了パターンの分類は、以下のように考えるとわかりやすいでしょう。一般的に、業務画面でボタンを押下した場合、その終了パターン(終了に応じた画面の表示方法)は、下図のように 3 通りに分けられます。この分類に当てはめれば、簡単に終了パターンを分類できると思います。

  • 正常終了して、当該画面内に黒文字でメッセージを表示する。→ 正常終了
  • 何かしらの問題があり、当該画面内に赤文字でメッセージを表示する。→ 業務エラー
  • 何かしらの問題があり、別画面の「ごめんなさい」メッセージ画面(誰かがお詫びしているような画面、イメージ的には下図みたいな感じ^^)を表示する。→ システムエラー

image

[業務エラーの細分化]

さて、上述した業務エラーは、実はさらに以下のように分類することができます。(なお、以下の業務エラーの分類も、nakama による勝手な分類です。名称も一般的なものではないのでご注意ください;。)

  • 単体入力エラー
    ユーザ入力値「のみ」で正誤判定ができるエラー。これはさらに、フィールド単位の入力エラー(個々のフィールドのデータだけで正誤判定できるエラー)と、インスタンス単位の入力エラー(複数のフィールドを同時に確認しないと正誤判定できないエラー)とに分類できる。
  • 突き合わせエラー
    ユーザ入力値のみでは正誤判定できず、データベース上のデータなど、他のデータとの「突き合わせ」を行わないと正誤判定ができないエラー。

前述の、正常終了/業務エラー/システムエラーとまとめて分類パターンを示すと、下図のようになります。

image

この分類の考え方や方法は、Web アプリケーション/Windows アプリケーションを問いません。Web アプリケーションと Windows アプリケーションでは、これらのエラーの表示方法(ユーザへの通知方法)や実装方法が異なりますが、分類方法そのものは、どちらであっても変わりがありません

そのことを示すために、以下のような、新規顧客登録画面(単票形式のデータ入力フォーム)を取り上げて、パターン分類をしてみましょう。

image

上記のような画面の場合、Web アプリケーション、Windows アプリケーションを問わず、ボタン押下に伴って発生する事象は、以下のように分類することができます。

image

このように、正常終了/業務エラー/システムエラーの分類方法は、アーキテクチャタイプ(Web アプリ/スマクラアプリ/Windows アプリ)などによって変化するわけではない、ということを覚えておいてください。

[アーキテクチャから見たエラーチェックの実装場所]

さて、前述の分類のうち、システムエラーに相当するものは、.NET では例外(Java の場合には実行時例外)と集約例外ハンドラを利用して実装します。この実装方法については、本 blog にイヤというほどしつこくまとめたので、以下のエントリをご覧ください。

簡単にいえば、.NET の場合には、システムエラーに相当するケースについては集約例外ハンドラでまとめて処理するため、個々のアプリケーションモジュール(UI/BC/DAC 部)にシステムエラーを後処理するロジックを実装する必要は(基本的には)ありません(※ 例外処理を行うのは、フローチャートの流れを調整したい場合に限ります。これについては、上記の「.NET の例外処理 Part.2」に詳細に解説していますので、こちらを参照してください。)

では、前述した業務エラーの処理はどこに実装するべきでしょうか? こうした業務エラー処理を実装する際に、必ず守るべき、以下の 2 つの基本的な実装セオリーがあります。

  • 業務エラーのチェックは、可能な限り、ユーザに近い場所で行う
    エンドユーザにとって、「UI が即時反応すること」はユーザビリティ上重要です。このため、UI 部でできるチェックは必ず UI 部で行い、即座にエラーを表示するようにします。
  • 信頼境界の端点では、必ずデータの再チェックを行う
    信頼境界(Trust Boundary)とは、ネットワーク的に不正な攻撃を受ける危険性のある境界のことを指します。例えば、下図のような Web アプリケーションの場合、BC や DAC はリモートからの通信を受け付けるわけではないので、これらのモジュールが直接、不正なユーザから攻撃を受ける危険性はほとんどないと言えます。一方、UI 部分は、不正なユーザから攻撃を受ける可能性が極めて高いと言えます。このため、リモートからのネットワークアクセスを受け付ける場所では、受け取ったデータを必ず再チェックする必要があります

image

先に述べたように、業務エラーは以下の 2 つに大別できますが、このうち、突き合わせエラーについては、UI 部のみで正誤判定することはできません。このため、突き合わせエラーは、バックエンドの BC や DAC と連携してエラーチェックを行うことになります。

  • 単体入力チェック → UI 部のみでチェックが可能
  • 突き合わせ入力チェック → BC や DAC と連携したチェックが必要

このことを踏まえ、業務エラー実装の基本セオリーを、論理 3 階層型の Web アプリケーションと、スマートクライアントアプリケーションに適用すると、業務エラーのチェック場所は以下のようになります。

A. Web アプリケーションにおける業務エラーの実装場所

まず、Web アプリケーションの場合には、単体入力チェックや突き合わせチェックを以下のように実装することになります。

image

  • 単体入力チェックについて
    ブラウザ上で 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 部の設計
    image
  • UI 部 → BC 部呼び出しの部分の処理コード
    image

B. スマートクライアントアプリケーションにおける業務エラーの実装場所

一方、スマートクライアントアプリケーションの場合には、単体入力チェックや突き合わせチェックを以下のように実装することになります。(※ この実装パターンは、Silverlight などの RIA タイプのアプリケーションでも同じになります。)

image 

  • 単体入力チェックについて
    UI 部に、双方向データバインドを使って実装します。また、SI 部(サービスインタフェース、具体的には XML Web サービス)が信頼境界端点になるため、SI 部にも単体入力チェックを重複実装する必要があります。ただし、先の Web アプリケーションの場合と異なり、SI 部で単体入力エラーが発見された場合にはシステムエラー扱いとします。これは、クライアント側で正しい UI アプリケーション(Windows フォームや WPF アプリケーションなど)を使っている場合、SI 部に単体入力エラーを含むデータが送られてくるということはあり得ないからです。
  • 突合せ入力チェックについて
    BC, DAC 部で、業務処理の一部として実装します。そして、SI から UI 部に対して、業務エラー情報として返し、UI 部ではエラーラベルに表示を行います。

前述の例の場合、画面設計と実装コードは、おおまかに以下のようになります。(フルソースコードは、サンプルコードを参照してください。ここでは要点だけ示します。また UI 部の詳細な実装は、さらに 2 パターンに細分化できるのですが、これについては Part 2 のエントリで解説します。ここでは「なんとなく」理解していただければ十分です。)

  • UI 部の設計
    image
  • UI 部 → SI 部呼び出しの部分の処理コード
     image

上記の解説をまとめると、以下のようになります。

image

このように、業務エラーのチェックを実装する場所・方法に関しては、守るべき基本セオリーが存在します。実装方式が異なっても、これらの基本セオリーは変化しないものですので、しっかり覚えておくようにしてください。

[本エントリのまとめ]

では、ここまでの解説をいったんまとめておくことにします。

  • アプリケーションの終了パターンは、正常終了/業務エラー/システムエラーの 3 パターンに分類される。
  • システムエラーは、例外と集約例外ハンドラによって処理する。このため、通常のアプリケーションコードの中には、システムエラーに関する処理コードは出てこない。(=例外をむやみに try-catch したり throw したりすることはしない。)
  • 業務エラーは、単体入力エラーと突き合わせエラーに細分化される。単体入力エラーは、さらに、フィールド単位の入力エラーと、インスタンス単位の入力エラーに細分化される。
  • アプリケーションアーキテクチャ的な観点から見た場合、業務エラーチェック(単体入力チェックと突き合わせ入力チェック)を行う場所には、基本セオリーが存在する。

アプリケーションの終了パターン分類

image

Web アプリケーションにおける業務エラーチェックの実装場所

image

スマートクライアントにおける業務エラーチェックの実装場所

image 

さて、ここまではアプリケーションの一般的な設計原則論のようなものを解説しましたが、これらを実装する際には、ランタイムが持つ入力検証機能を活用していくことが望ましいと言えます。引き続き Part 2. では、これらのデータ入力チェックを、各ランタイムでどのように実装していくのかを解説し、それを通して、各ランタイムが持つ入力検証機能を比較してみることにしましょう。

Postedby nakama | 0 Comments    
Filed under: , , ,
エラーチェックの体系的な分類と実装パターン
29 September 09 12:58 PM

というわけで久しくエントリをアップしていなかったこの 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]

では、順番に解説していきましょう。

Postedby nakama | 0 Comments    
Filed under: , , ,
Part 4. Visual Studio によるマルチスレッドアプリの開発
09 April 09 02:57 PM

さて、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 フォームアプリケーションを開発してみます。

image

まず、新規に Windows フォームアプリケーションを作成し、そこに Web サイトプロジェクトを追加します。

image

次に、*.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 ベースのプロキシクラスを作成する画面を表示し、ここでプロキシクラスを作成してください。

image

プロキシクラスを作成したら、ボタンやテキストボックスなどを貼り付けて画面を作成し、いったんコンパイルを行います。すると、ツールボックス上に、XML Web サービスプロキシのクラスが現れますので、これを当該画面上に貼り付けます

image

そののち、以下の 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 開発サーバを終了させて、ボタンを押下すると、ちゃんと集約例外ハンドラがフックされる。

image

image ← XML Web サービスを呼び出せなかった場合 

内部動作の概念図を下に示します。この処理のキーポイントは、UI スレッドへの戻りが自動的に行われる、という点です。webService1.GetMessageAsync() メソッドにより、Web サービス呼び出し自体は背後のスレッド(具体的にはプールスレッド)上で行われますが、

  • 呼び出しが正常終了した場合に呼び出される webService1_GetMessageCompleted() イベントハンドラは、UI スレッド上で呼び出される。このため、このイベントハンドラ内では自由に UI コントロールを操作してよい
  • 呼び出しが正常終了せず例外が発生した場合でも、webService1_GetMessageCompleted() イベントハンドラが UI スレッド上で呼び出される。そして、e.Result プロパティにアクセスした瞬間に、発生した例外がリスローされる

という挙動をします。

image

この挙動の中でも後者は非常に上手いところで、このような機能があるため、特に追加のコードを書かなくても、XML Web サービス呼び出し中に発生した例外を、Application.ThreadException 集約例外ハンドラで捕捉することができます。よって、上記のようなコードだけで、XML Web サービス呼び出しを非同期化することができる(背後のタスクスレッド上で動かすことができる)のです。

※ (注意) ただし、この実装方法では、XML Web サービス呼び出しをキャンセルすることはできません。一応 XML Web サービスプロキシには .CancelAsync() というメソッドがあるものの、これは「まだ未送信状態だったら呼び出しを取り消す」というものです。このため、実際にタスクスレッドで XML Web サービス呼び出しが行われてしまった後に .CancelAsync() したところで、行われてしまった呼び出しは取り消せません(=確実な呼び出し取り消しができるメソッドではありません)。もともとこの問題は、タスクスレッドを使っている以上は原理的に発生するものなので、設計時に注意しておくことが必要です。

※ (注意&参考) また、本題からは若干それますが、プロキシクラスを画面に貼り付けて利用する場合は、URL プロパティを構成設定ファイルから自動的に読み取らなくなってしまうため、下図のようにして明示的に紐付けを行ってください。(プロキシクラスのコード生成ツールとの兼ね合いで発生するトラブルのようです。明示的に紐付けすればきちんと読み取るようになります。)

image

[WCF サービス呼び出しの非同期処理化]

では今度は、同じことを .NET Framework 3.0 ベースの WCF プロキシクラスで行ってみましょう。話を簡単にするために、サーバ側は上記のサンプルと同じく、*.asmx を使うことにして、クライアント側に(サービス参照の追加機能を利用して) WCF のプロキシクラスを作成します。

image

作成したプロキシクラスは(先と異なり)フォーム上に貼り付けることはできません。しかし、以下のようなコードを書くことで、先ほどと同じようにコーディングすることができます。

image

このように、WCF プロキシクラスの場合には、画面上に貼り付けることはできないものの、きちんと UI スレッド上で呼び出し終了イベントハンドラを呼び出してもらうことができます。

※ (注意) .NET Framework 2.0 ベースの ASP.NET XML Web サービスプロキシの場合には、画面上に貼り付けなければなりません。コード上で Completed イベントハンドラの登録を行うと、UI スレッドへの戻りが発生しないため、注意してください。

さて、ここまで Web サービス呼び出しを非同期化する方法について解説してきましたが、最後に、より一般的なタスクを簡単に非同期化する方法について解説します。

[BackgroundWorker コンポーネントによる一般的なタスクの非同期処理化]

ここまでの解説からわかるように、Windows フォームにおけるマルチスレッドアプリケーションの難しさは、UI スレッドとタスクスレッド間での連携によるところが大きいです。この連携処理を簡素化するために .NET Framework 2.0 で導入されたのが、ここで解説する BackgroundWorker コンポーネントです。この BackgroundWorker コンポーネントは、UI スレッドとタスクスレッド(プールスレッド)との間の協調連携動作を支援するコンポーネントとして機能します。概念図を下に示します。

image

この概念図だけだとわかりにくいと思いますので、実際に BackgroundWorker コンポーネントを使って、長時間処理を背後で行う以下のようなアプリケーションを作ってみることにしたいと思います。

image

具体的な実装手順は、以下の通りです。(何をやっているのかをわかりやすく示すため、Step by Step で実装していきます。)

① UI の作成

  • まずは画面上に 2 つのボタン、ラベル、プログレスバーを置きます。
  • それぞれのボタンに、btnStart, btnCancel と名前をつけ、キャンセルボタンの Enable プロパティを false にしておきます。
  • 画面上に、BackgroundWorker コンポーネントを貼り付けます。

image

② 長時間処理の作成

  • btnStart_Click() イベントハンドラを作り、ここに、BackgroundWorker コンポーネントに対して非同期処理を開始する指示を出すコードを記述します。
  • 次に、backgroundWorker1_DoWork() イベントハンドラを作り、ここに実際の長時間処理を記述します。
  • 最後に、backgroundWorker1_RunWorkerCompleted() イベントハンドラを作り、ここに終了後の処理を記述します。

実際の処理の流れを以下に示します。重要なのは、UI スレッド → プールスレッド → UI スレッドの流れが自動的に制御される、という点です。従来だと、自力で .BeginInvoke() などを記述しなければなりませんでしたが、そうした処理はすべて BackgroundWorker が肩代わりしてくれます。

image

③ 起動パラメータと処理結果の引き渡し

さて、上記のサンプルだと、タスクスレッドの起動パラメータの受け渡しや、タスクスレッドの処理結果の受け取りがありません。これらのコードを追加すると、以下のようになります。

image

image (30msec × 321 回なので 10 秒ぐらいかかります)

④ 進捗状態表示機能の追加

では次に、進捗状態を UI 上に表示する機能を追加します。進捗状態は、プールスレッドから UI スレッドへの通知が必要ですが、これを行うために、以下の 2 つの作業を行います。

  • backgroundWorker1 の WorkerReportsProgress プロパティを true に変更する。
  • backgroundWorker1_DoWork() メソッドの中に、進捗報告のためのコードを追加する。(backgroundWorker1.ReportProgress() メソッド)
  • backgroundWorker1_ProgressChanged() イベントハンドラを追加し、UI に表示する。

image

このようにすると、進捗状態が UI に表示されるようになります。

image

ここで注意していただきたいのは、プールスレッドで動作している backgroundWorker1_DoWork() メソッドから、UI 更新を行う backgroundWorker1_ProgressChanged() メソッドを直接呼び出しているわけではない、という点です。

  • プールスレッドからは、backgroundWorker1 の .ReportProgress() メソッドを叩き、backgroundWorker1 にスレッド同期を依頼する。
  • backgroundWorker1 は、UI スレッド上で backgroundWorker1_ProgressChanged イベントハンドラを呼び出すように、内部で .BeginInvoke() 命令を利用する。

ここでもう一度、最初に示した内部動作の模式図を示します。

image

最初からの流れをもう一度追いかけてみると、

  • 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 イベントハンドラ内(タスクスレッドの長時間処理の中)に、キャンセルフラグを(なるべく頻繁に)チェックする処理を入れる。

追加されたコードは赤字部分です。ここまでの解説が理解できていれば、容易に理解できるのではないかと思います。

image

image

※ ちなみに実際に実行すると、キャンセルボタンを押した直後にプログレスバーが停止しませんが、これは Vista 以降でのコントロールのアニメーションの変更によるもの(アニメーションの遅延により発生する)です。XP などで実行すると、停止したタイミングでぴたっと止まります。

※ あと、書き忘れましたが、タスクスレッド上の例外処理についても書く必要がありません。タスクスレッド上で未処理例外が発生した場合には、RunWorkerCompleted イベントハンドラにて、e.Result で結果を取り出す際に例外がリスローされるため、特に例外処理のコードを追加しなくても、上のコードのままで集約例外ハンドラで例外を捕捉することができます。

このように、BackgroundWorker コンポーネントを利用すると、UI スレッド ⇔ タスクスレッドのスレッドスイッチに関連する処理を書く必要がなくなり、コードもかなりすっきりします。しかし、どの処理がどのスレッド上で動作しているのかを正確に理解しないと、非常に危険であるのも確かです。先に示した動作模式図を意識しながら、アプリケーションコードを記述するようにしてください。

[本エントリのまとめ]

では最後に、本エントリのまとめです。

  • ASP.NET 2.0 XML Web サービスのプロキシクラスは、フォーム画面上に貼り付けて使うことにより、Web サービス呼び出し処理を非同期処理化できる。
  • WCF サービスのプロキシクラスは、非同期処理メソッドを追加して使うことにより、呼び出し処理を非同期処理化できる。
  • 一般的なタスクについては、BackgroundWorker コンポーネントを使うことで非同期処理化ができる。

というわけで、4 回に渡ってマルチスレッドアプリケーションの開発手法について解説してきましたが、総じて言えば、

マルチスレッドアプリケーションを書くのはかなり難しい;。

ということになります。正しい知識を持って記述しないと、とにかくトラブルを引き起こしがちな技術になりますので、記述するのであれば十分な知識を持った上で、正しく記述するように心掛けていただければと思います。

※ なお、今回は Windows フォームに限定して解説を進めてきましたが、WPF や Silverlight にも同様な UI スレッド制限があります。WPF などを利用する場合には、こちらの MSDN マガジンのエントリなどを参考にしながら開発を進めていただければ幸いです。

Postedby nakama | 3 Comments    
Filed under: ,
Part 3. タスクスレッドと UI の協調動作
07 April 09 02:47 PM

さて、前回の Part 2. のエントリでは、タスクスレッド(UI の背後で動作させる処理を動作させるスレッド、すなわちマニュアルスレッドやプールスレッドの総称)の様々な起動方法について解説しました。主な方法として、以下の 4 つの方法がありました。

  • マニュアルスレッドの新規作成
  • プールスレッドの利用
  • 非同期デリゲートの利用
  • タイマの利用

さて、いずれの方法を利用する場合であっても守る必要のあるルールとして、UI スレッド以外から UI コントロールの読み書きをしてはならない、というものがありました。

image

ここまで実際にプログラミングをしてみた方は感じられていると思うのですが、実はこの制限はかなり厄介です。例えば、以下のような処理を簡単に書くことはできません

  • 背後で行っているタスク処理の進捗状況や完了結果を、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に対して発行するのが便利です。

image

なお、関連する注意点として、以下についても知っておくと便利です。

① 現在のスレッドが UI スレッドかどうかは、InvokeRequired プロパティでチェックすることができる。

例えば上記の LongTask() メソッドは、マニュアルスレッド上だけでしか動作させられないかというとそんなことはなく、書き方や呼び方次第では、プールスレッド上や UI スレッド上で動作させることもできます。このため、あるメソッドが UI スレッド上で呼び出されることも、マニュアルスレッドやプールスレッド上で呼び出されることも、どちらもありうるような場合には、.InvokeRequired プロパティを確認することで、UI スレッド上で動作しているか否かを確認することができます。

image

ただし実際には、そもそも上記のようなチェックが必要とならないようにすることが望ましいです。ここまで取り扱ってきたサンプルは、必ず、あるメソッドはマニュアル/プール/UI のどれかの上でしか動作しないように設計してきました。実際、UI スレッドで行うべき処理とタスクスレッドで行うべき処理は異なっているのが当然で、UI スレッド/タスクスレッド両用になるようなメソッドというのは、(ユーティリティ的な処理を除けば)あまりないはずです。

UI フォームを設計・実装する際には、どのメソッドがどのスレッド上で動くのかを明確に意識しながら作業することが非常に重要です。InvokeRequired プロパティを使わなければならなくなった場合には、そもそもアプリケーションの設計としての是否をきちんと確認するようにしてください。

② 基本データ型及びイミュータブルオブジェクト以外を引き渡す場合には、オブジェクトの同期制御が必要になる。

UI スレッドからタスクスレッドを起こす場合でも、またタスクスレッドから UI スレッドへ制御を戻す場合でも、どちらでも共通する内容ですが、一般に、オブジェクトを引数として渡す場合には、参照渡しが行われます。例えば、下図のように、タスクスレッドから UI スレッドに StringBuilder の変数を引き渡すと、同一インスタンスがマニュアルスレッドと UI スレッドの両方から操作されることになります。

image

当然ですが、このような処理を行うと、同一インスタンスを同時に複数スレッドから操作することになり、データが破損することになります。よって、このようなコードは書いてはいけません。StringBuilder のインスタンスに対して同期制御を行うか、インスタンスをコピー(ディープコピー)して渡す必要があります。

このことからわかるように、UI スレッドとタスクスレッド間で、引数として基本データ型やイミュータブルオブジェクト以外のデータを引き渡す場合には、オブジェクトの同期制御が必要になります。(データ変数の同期制御に関しては、こちらこちらのエントリに詳しく書いてありますので、よくわからないという方は確認してみてください。)

では引き続き、タスクスレッドからの UI 画面上のデータの読み取り方法について解説します。

[タスクスレッドからの UI 画面上のデータの読み取り方法]

上記では、タスクスレッドから UI 画面上へのデータ表示について考えたわけですが、実は、タスクスレッドから UI 画面上のデータを読み取ることも NG です。

image

UI コントロールを操作するためには、UI スレッドからの操作が必要になり、そのためにはメッセージキューへのメッセージ投入が必要....なのですが、ここまで解説してきた BeginInvoke() 命令では、メッセージを投入した後「やりっぱなし」の状態になってしまい、コントロールから値を読み取るなどした後の結果値を受け取ることができません。このような場合には、.Invoke() 命令を利用します。これを利用すると、UI スレッド上で行われた処理の結果を、タスクスレッド側で受け取ることができます。具体的な実装例を以下に示します。

image

なお、この .Invoke() 命令による、「UI スレッド上での処理結果の受け取り」はむやみに利用しないようにしてください。.BeginInvoke() 命令と異なり、.Invoke() 命令は、投入したメッセージが UI スレッド上で処理されるのを同期的に待つ(=タスクスレッド側は UI スレッド上での処理の終了を待機する)形になります。このため、以下のようなコードを書くとハングアップする危険性があります

  • 例1. Invoke() 命令で呼び出したメソッド内から、さらに別のスレッドを起こす ⇒ スレッドプールの枯渇や無限ループの発生
  • 例2. 共有データに対して lock を取得している最中に Invoke() 命令を利用し、呼び出し先で当該データを操作 ⇒ デッドロックの発生

このようなことから、以下のルールを守ることをおすすめします。

  • .Invoke() 命令は極力使わないようにする。
  • 使う場合であっても、UI 上のプロパティを読み取ったらすぐに終了する、といった極力単純なコードのみを書くようにする。
  • ロックを取得している最中に .Invoke() を呼び出さない。

では引き続き、UI 画面からのタスクスレッドの制御方法について解説します。

[UI 画面からのタスクスレッドの制御方法]

タスクスレッド上で時間を要する処理を起動した場合、UI からそのタスクを制御したいと思うことがよくあります。例えば、

  • キャンセルボタンを押したら、背後で行っている処理を中断したい。
  • チェックボックスやラジオボタンなどを押すと、背後で行っている処理が変更されるようにしたい。

といったことはしばしばあります。しかし、このような処理を作り込むのは意外に厄介です。なぜなら、UI からタスクスレッドで行われている処理に対して、割り込みをかけるような形で強制通知を行うことはできないからです。

例えば、以下のような画面を作る場合を考えてみます。

image

タスクスレッドでの処理を止めるため、キャンセルボタンを押したら、背後で行っている処理が中断されるように、Thread.Abort() 命令を使ったとします。この機能を使うと、タスクスレッドの処理を強制的に中断することができますが、そのスレッドがどんな状態であるかを全く無視して強制中断してしまうため、場合によっては共有データ変数の破損などの問題を引き起こすリスクがあります。このため、タスクスレッドへ強制的に割り込みをかけるような形で処理をキャンセルさせることはできないのです。

このような場合には、以下のような解決が必要になります。

  • 状態を保存しておく共有データ変数を用意しておく。
  • UI からタスクスレッドへ通知したい場合、共有データ変数に格納する。
  • タスクスレッドが定期的に(=タスクの節目節目で)共有データ変数をチェックし、それに従った動作をする。

具体的な内部設計図を下図に示します。

image

実装コードを以下に示します。

image

コード上、特に注目していただきたいのが、共有データ領域の使い方です。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 は当該プロセスを終了しません。

という挙動をします。例えば、下記のコンソールアプリケーションのサンプルを見てください。

image

このサンプルにおいて、

  • バックグラウンド化した上でマニュアルスレッドを起動すると、Main() 関数が終了した直後にプロセスが終了します。
  • しかし、バックグラウンド化せずに(t.IsBackground = false; として)マニュアルスレッドを起動すると、Main() 関数が終了しても、プロセスが終了しません

両者の動作を以下に示します。

バックグラウンド化した場合

image

フォアグラウンドで動作させた場合

image

デフォルト状態での各スレッドの状態は、以下の通りです。

  • Main() 関数を動作させるスレッド(メインスレッド) → フォアグラウンド
  • マニュアルスレッド → フォアグラウンド
  • プールスレッド → バックグラウンド

つまり、Windows フォームアプリケーションでマルチスレッドアプリを作成する場合、うかつにマニュアルスレッドを使うと、Application.Exit() 命令ではプロセスが終了しなくなります

例えば、以下のようなアプリケーションを作ってみます。

※ ここでは、UI がなくなってもプロセスが残留することを簡単に示すために、DebugView を使います。このツールの詳細はここでは解説しませんが、簡単にいうと、System.Diagnostics.Debug.WriteLine() 命令によって出力したデータを、外部で簡単に参照できるようにしたツールです。Web アプリケーションなどの UI を持たないツールにおいて、内部動作を簡単に表示・モニタできるというメリットがあって便利です。ここからダウンロードできます。

image

   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 プロセスが残っていることが確認できます。)

image

image

つまり、Windows フォームを適切に終了させるためには、残留しているフォアグラウンドスレッドをすべて終了させなければならない、ということになります。このことから、Windows フォームアプリケーションの開発に関する推奨事項として、次のようなことが言えます。

  • 基本的に、タスクスレッドは「いつ強制終了されても困らない」ような防御的プログラミングをしておいた上で、さらにバッグラウンド化(.IsBackground = true)しておくことが望ましい。

「防御的プログラミング」というのは、「強制終了などのトラブルが起こったとしても、問題が起こらないようにプログラミングしておく」ことを指します。具体的には、以下のようなプログラミングの工夫を指します。

  • ローカルキャッシュファイルが破損していた場合には、自動的に最新データをサーバから取り寄せる。
  • ローカル設定ファイルが破損していた場合は、デフォルト値を利用する。

※ なお、このような防御的プログラミングの考え方は、Windows フォームアプリケーションでは特に重要です。というのも、Windows フォームアプリケーションは、「閉じる」ボタンなどで簡単に終了できるのはもちろんのこと、タスクマネージャからの強制終了や Windows OS シャットダウンなど、「完全な後片付け」ができないまま強制的に切り落とされるケースが多数考えられるからです。このため、Windows フォームアプリケーションでは『強制的に切り落とされても問題なく再起動ができる』形で実装することが望ましいといえます。タスクスレッド上の処理に限らず、「再起動時に何らかのローカルキャッシュデータファイルなどが破損していても自動復旧できる」ような設計をしておくことは非常に重要である、ということを覚えておいてください、とつぶやいておく^^。

では最後に、タスクスレッド上で発生した未処理例外の取り扱い方法について解説します。

[タスクスレッド上で発生した未処理例外の取り扱い方法]

一般的に、Windows フォーム上で発生した未処理例外に関しては、集約例外ハンドラを利用した後処理を行います。Windows フォームにおける集約例外ハンドラの書き方についてはこちらのエントリに記述しましたが、Application.ThreadException イベントハンドラで捕捉される例外は、メッセージループが捕捉した例外、すなわち UI スレッド上で発生した例外に限定されます。このため、マニュアルスレッドやタスクスレッド上で発生した未処理例外については、適切なハンドリングが必要です。これを怠ると、以下のような問題が発生します。

  • タスクスレッドが例外によって終了してしまったにもかかわらず、タスクスレッドの正常終了を UI 側が永遠に待ち続けてしまう。
  • タスクスレッド上で発生した例外情報がロギングされなかったため、障害解析ができなくなってしまう。

これらの問題を解決するため、以下のような対処を行います。

  • AppDomain に対して集約例外ハンドラを仕掛けておく。
  • タスクスレッド上で発生した例外を UI スレッド上でリスローする。

これらについて解説します。

AppDomain に対して集約例外ハンドラを仕掛けておく

CLR 上でアプリケーションを動作させる場合、プロセス内にはアプリケーションドメイン(AppDomain)と呼ばれる論理区画領域が作成されます。この AppDomain の UnhandledException イベントをフックすると、マニュアルスレッド上の未捕捉例外をすべて捕捉することができます。通常は、Application.ThreadException イベントハンドラも利用しますので、以下のようなコードを書くことになります。

image

ただし、この方法には以下の 2 つの難点があります。

  • UI 通知ができない。 (タスクスレッド ≠ UI スレッドであるため)
  • プールスレッド上で発生した例外を補足できない。

後者は特に大きな問題です。このため、実際には以下に述べる例外のリスローを使って、問題を解決することが望ましいです。これについて解説します。

タスクスレッド上で発生した例外を UI スレッド上でリスローする

そもそも UI 表示は UI スレッド上でしか行えない、という前提条件を考えると、タスクスレッドで発生した例外を UI 上で通知するためには、例外オブジェクトを UI スレッド側に伝搬する必要があります。そこで、タスクスレッド上で例外が発生した場合には、この例外オブジェクトを .BeginInvoke() 命令で UI スレッド側に伝搬し、UI スレッド上でそれをリスローします。

image

もともと UI スレッド上で発生する例外に関しては、Application.ThreadException イベントハンドラの集約例外ハンドラで捕捉することができますので、この方法を利用すれば、すべての例外を UI 側の集約例外ハンドラで補足することができます。

例えば、XML Web サービスの呼び出し処理をタスクスレッドに切り出す場合について考えてみます。XML Web サービス呼び出しに失敗すると、例外が発生します。このような例外をキャッチし、UI スレッドに伝搬すれば、集約例外ハンドラ(Application.ThreadException イベントハンドラ)でまとめて処理することができます。

image

# ちなみに、この場合の例外処理(try-catch)は、タスクスレッド上で発生したすべての例外をことごとく捕捉する必要がありますので、全体を大きく囲むことになります。この try-catch は、「例外→業務エラーの変換のための try-catch」でも「リソース解放のための try-finally」でもないので、注意してください。

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

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

  • タスクスレッドから UI 画面を更新したい場合には、.BeginInvoke() 命令を使って、メッセージキューにメッセージを投入する。
  • タスクスレッドから UI 画面上のデータを読み取りたい場合には、データを読み取って返すメソッドを作成しておき、これを .Invoke() 命令でタスクスレッドから同期的に呼び出す。ただし気をつけてプログラミングしないと、スレッドプールの枯渇やデッドロック問題を引き起こすので注意が必要。
  • UI 画面からタスクスレッドを直接操作することはできない。共有データ変数領域を用意しておき、① UI スレッドからはこの共有データ変数領域にフラグを立てる。② タスクスレッド側ではこの共有データ変数領域を頻繁にチェックする。という方法で間接的に制御を行う。
  • タスクスレッド上で発生した未処理例外を取り扱うため、タスクスレッド全体を try-catch で囲む必要がある。捕捉した例外は、.BeginInvoke() 命令で UI スレッドに搬送し、リスローする。

さて、ここまで読んでみていただいて、

ちょーめんどい;。

と思われた方も多いと思います。はい、私もそう思います;;;。だったら説明するなー!と言われそうですが;、実際、マルチスレッドアプリケーションというのは、思っているよりも遥かに実装が厄介なものです。例えばボタンが押されたら XML Web サービスを呼び出すアプリケーションを作ろう、と思った場合、

image

同期型で実装する場合には、たったの 4 行で話が済みます。

image

しかし、これをタスクスレッドに切り出す場合、以下のようなポイントに対する考慮が必要になります。

  • ボタンの二重押しの防止
  • ステータスの表示
  • タスクスレッドから UI スレッドへの書き戻し
  • 集約例外処理

結果、以下のような膨大なコードになります。

image

なので、やはり個人的におすすめしたいのは、

マルチスレッドアプリケーションはなるべく作らない。

これに尽きます。って、まるでここまでの解説すべてを放り出すような発言ですが;;;、ここまでの延々とした説明をきちんと理解した上でコーディングをしようと思うと、相当大変であることは容易にご理解いただけるのではないでしょうか。(というか正直言ってこのエントリ書いてる自分もサクサク書けません、とても;。)

Visual Studio 2008 を利用すると、こうした問題を多少緩和するために、いくつかの機能が利用できるようになるのですが、そうはいってもここまでの解説をきちんと理解した上でないと、やはりそうしたウィザード類の利用もやはり危険です。Part 4. では Visual Studio 2008 の機能について解説しますが、Part 3. までの内容をよく理解した上で利用していただくことをお勧めします。

Postedby nakama | 4 Comments    
Filed under: ,
タスクスレッドの起動方法
02 April 09 10:31 AM

さて、前回のエントリでは、Windows フォーム内部におけるスレッドの構成や、メッセージループの働きなどについて解説しました。中でも重要なこととして、以下のようなキーポイントがありました。

  • UI スレッド上で、長時間処理を動かしてはならない。
    長時間処理は、マニュアルスレッドやプールスレッドなどの、他のスレッドに切り出す。
  • UI スレッド以外から、UI コントロールを触ってはいけない。
    マニュアルスレッドやプールスレッド上から、UI コントロールを読み書き・操作してはいけない。

image

上記の 2 つの重要ルールについて、Part 2~4 にてより実践的な解説を行っていきます。

  • Part 2. タスクスレッドの起動方法
    まず、マニュアルスレッドやスレッドプールの起動方法について解説します。
  • Part 3. タスクスレッドと UI の協調動作
    マニュアルスレッドやプールスレッドから UI コントロールを操作したり読み書きしたりすることはできないため、その回避方法について解説します。
  • Part 4. Visual Studio を使ったマルチスレッドアプリケーション開発
    上記 Part 2, 3 の作業を簡素化するために用意されている、Visual Studio の機能について解説します。

まず本エントリでは、UI スレッドから切り離した処理を動かすために利用するマニュアルスレッドやプールスレッドのことを、タスクスレッドと呼ぶことにし、その作成方法について解説します。

  • デリゲートとは何か
  • マニュアルスレッドの新規作成 
  • スレッドプールへのワークアイテムの追加
  • 非同期デリゲートの利用
  • タイマの利用

なお、以降の説明では様々なスレッドの起動方法を解説していきますが、突き詰めると、タスクを動かすスレッドには、マニュアルスレッドかプールスレッドかのどちらかを使っています。ただ、その起動方法が様々な種類がある、というだけの話ですので、見かけの多様性に惑わされず、しっかり学習していただければと思います。(今回はサンプルらしいサンプルはないですが、一応くっつけておきます。)

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

[デリゲートとは何か]

マニュアルスレッドやプールスレッドの起動処理を記述する上で欠かせない技術のひとつが、デリゲートです。デリゲートとは、オブジェクトに対して、「関数や処理ロジック」を引数として渡す際に利用される技術であり、.NET Framework の基盤技術の一つになっています。

image

そもそも「関数」や「処理」を引き渡すイメージがわかない、という方も多いと思いますので、まずここで簡単に解説します。以降の解説は、スレッディングの話からはちょっとそれますが、非常に重要なので必ず理解してください。(※ すでにデリゲートをご存じの方は、この項目は飛ばして先へ進んでください)

まず、なぜ「関数」を引数として引き渡す必要があるのかを理解するために、「コレクションから、ある条件を満たすものだけを抽出する処理」を考えてみることにします。例えば、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);

この処理方式は、イラストであらわすと次のように示されます。

image

この方式は、「ユーザーがコレクション内のデータを1つずつ取り出しては吟味し、手作業で移し変えていく」ようなモデルです。もちろん、この処理自体は正しく動作するのですが、そもそも「何らかの条件に基づいてデータの抽出を行う」という処理自体、非常によく出てくる処理です。

そこで、以下のようなモデルを取ることができないか否かを考えてみます。

image

つまり、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: }

ここで重要なのは、以下のポイントです。

  • 本来、引数として渡せるものは、文字列や数値といった「具体的なモノ」。
  • しかし、デリゲートのインスタンスでラッピングすると、処理(=関数)を引き渡すことができる。

image

また、デリゲートで重要なもう一つのポイントは、あるデリゲートクラスがラッピングできる関数は、そのデリゲートが定義している引数/戻り値と完全に一致していなければならない、という点です。上記のサンプルの場合、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() メソッドをたたくと、新規にマニュアルスレッドが起動し、引き渡しておいたメソッドが起動する。

コードサンプルを以下に示します。

image

※ (注意) スレッドを起動する前に、t.IsBackground = true; という設定をしていますが、この設定を行うとこのスレッドがバックグラウンドスレッドとしてマークされます。Windows フォームアプリケーションを終了する際に利用する Application.Exit() 命令は、「すべてのメッセージループを停止し、アプリケーションウィンドウを閉じ、メインスレッドの Application.Run() 命令を終了する」というものですが、フォアグラウンドスレッドが残留しているとプロセスが終了しません。このため、マニュアルスレッドは、バックグラウンドスレッド設定をしてから起動することが望ましいと言えます。

なお、この ThreadStart デリゲートは、System.Threading 名前空間の下側に定義されており、引数なし、戻り値が void 型のメソッドをラッピングすることができるデリゲートになっています。よって、この方法では、UI スレッドからマニュアルスレッドへとデータを直接引き渡すことができません。もちろん、上記のコードに示したように、共有変数領域を作成しておき、この領域を使ってデータを引き渡すこともできますが、この方法の場合、UI スレッドとマニュアルスレッドが同時にこのデータを操作する危険性があるため、排他制御が必要になります。(スレッド間での処理競合については、以前のエントリ(こちらこちら)を参考にしてください。)

この問題を解決するために、.NET Framework 2.0 で導入されたのが、ParameterizedThreadStart デリゲートです。以下に具体的な実装方法を示します。

  • マニュアルスレッド上で動作させたい処理を、引数 object 型ひとつ、戻り値なしのメソッドとして作成する。
  • このメソッドを、ParameterizedThreadStart デリゲートにラッピングして、Thread オブジェクトのコンストラクタに引き渡す。
  • スレッドインスタンスの .Start() メソッドに、object 型のパラメータを一つ渡して叩くと、新規にマニュアルスレッドが起動する。

image

この方法を利用すれば、明示的にデータ変数を引き渡すことができます。なお、この方法で認められている、UI スレッドからマニュアルスレッドへ引き渡せるパラメータは object 型変数 1 つだけですが、object 型ですのでなんでも渡すことが可能です。(複数のデータ項目を引き渡したい場合には、構造体にまとめたり object[] 配列などにして、これを引き渡せばよい) データを受け取ったメソッド側では、これを元のデータ型にキャストしてから利用してください。

では次に、スレッドプールの使い方について解説します。

[スレッドプールへのワークアイテムの追加]

スレッドプールは、マニュアルスレッドと異なり、自力でスレッドを新規に作成して利用するというものではありません。以前のエントリで解説したように、すでに起動しているスレッドに対して、メソッドを引き渡して処理してもらう、という形になります。

image 

具体的には、以下の作業を行います。

  • まず、引数として object 型変数を一つ、戻り値として void 型となるメソッドを用意する。
  • これを WaitCallback デリゲートに包んで、プールのキューに追加する。(ThreadPool クラスの QueueUserWorkItem() メソッドを利用する)

実装コードサンプルは以下の通りです。

image

なお、スレッドプールのワークアイテムキューに追加できるデリゲートは、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 を二つつけてください。これらのパラメータは、コールバック処理や戻り値のハンドリングのために利用されますが、複雑なので今回は解説しません。)

具体的なコード例は以下の通りです。

image

さて、ここで解説したデリゲートが持っている .BeginInvoke() メソッドと、前回のエントリで解説した Windows フォームのコントロールが持っている .BeginInvoke() メソッドは、名前こそ同じですが全く別物であることに注意してください。

  • デリゲートが持っている .BeginInvoke() メソッドは、スレッドプールのワークアイテムキューへの投入である。
  • Windows フォームのコントロールが持っている .BeginInvoke() メソッドは、メッセージキューへのメッセージ投入である。

この違いをはっきりさせるため、以下のようなアプリケーション(ボタンを押すとプログレスバーが進んでいき、終了するとメッセージが表示される)を作成してみることにしましょう。

image

UI の内部設計図 は、以下の通りです。

image

具体的な実装方法は、以下のようになります。

UI スレッドからの、処理タスクの起動

  • まず長時間を要する処理を、LongTask() メソッドとして定義します。ここでは例のため、名前と処理回数最大値を引数として取るようにしておくことにしましょう。
  • 次に、LongTask() メソッドに対して、引数や戻り値を合わせたデリゲートを定義します。メソッドのすぐ上に定義しておくと都合がよいでしょう。名前は任意ですが、ここではメソッド名+Delegate という名前をつけることにします。
  • ボタンが押されたら、デリゲートを使って、プールスレッド上でこの LongTask() メソッドを動作させるようにします。

プールスレッドからの UI の更新

  • ここまで解説してきたように、プールスレッドから直接 UI を更新することはできません。そこで、UI を更新したい処理(プログレスバーへの表示とラベルへの表示処理)を、メソッドとして定義し(UpdateProgressBar(), UpdateLabel() メソッド)、それぞれに対してデリゲートを作っておきます。
  • さらにメッセージキューへメッセージを投入するため、コントロールの .BeginInvoke() メソッドを使い、これらの処理を UI スレッド上で動作させます。

image

このように、UI スレッドとプールスレッドの連携協調動作には、デリゲートや .BeginInvoke() メソッド(2 種類)が利用されることを覚えておいてください。

なお、上記のサンプルでは、プールスレッドから UI スレッドへ処理を移す際に、this.BeginInvoke() メソッドを叩いていますが、この “this” はフォームそのもの(Form1)を示しています。実は、通常の Windows フォームアプリケーションでは、すべてのコントロールが同一の UI スレッドに属しており、そのような場合には、どのコントロールの .BeginInvoke() メソッドを叩いても同じ結果となります。ですので、上記のサンプル中の this.BeginInvoke() メソッドは、button1.BeginInvoke(), label1.BeginInvoke(), progressBar1.BeginInvoke() などと書いても同じ結果となります。

※ 参考(ちょっと難しいので、わからない人は読み飛ばしてください。)

デリゲートが持っている .BeginInvoke() メソッドを利用する場合は、本来のメソッド引数の後ろにさらに 2 つの引数を付与する必要があり、上記のサンプルでは null をつけていました。この 2 つの引数をうまく使うと、戻り値を持つメソッドへの呼び出しを非同期化したり、その結果を取り出したりすることができます。しかし、特殊な理由がなければ、Windows フォームのプログラミングではこの機能は利用する必要はありません。

例えば、非同期デリゲートを利用して、戻り値を持つメソッドへの呼び出しを非同期化する例を考えてみます。この場合には、以下のような設計と実装になります。

image

image

上記のコードを見てみると、確かに、後ろ 2 つのパラメータを使うことにより、プールスレッド上で開始した非同期処理の戻り値を受け取るメソッド(これをコールバック関数といいます)を作ることができます。しかし、この処理は UI スレッド上では動作していないため、結局、ここから UI の更新を行うことができません。結果として、上記のような面倒なコーディングが必要になってしまいます。これならいっそ、下に示すコードのように、コールバック関数を使わず、普通にメッセージキューへメッセージを投入するようなプログラムを書いた方が単純です。

image

このように、デリゲートが持つコールバック機能を利用すると、

  • 戻り値を持ったメソッドへの呼び出しを非同期化する(プールスレッド上で動作させる)。
  • その戻り値を、別のメソッド(コールバック関数)で受け取る。

ということが可能になるのですが、どちらかというと、コールバック関数を使わずに済ませるプログラミングの方が素直でしょう。(もともとコールバック関数は UI を持たない通常のマルチスレッドプログラミングで使うものなので、UI を持つアプリの場合には、コントロールの .BeginInvoke() だけを使った方が簡単なのですね^^。) なので、この機能については忘れてしまって OK です。

では、最後にちょっとした応用として、タイマの使い方について解説します。

[タイマの利用]

定期的に何らかのタスク処理を行いたい場合、タスクスレッドを起こしてそこでビジーループを作って待機することは、リソース利用上望ましくありません。むしろこのような場合には、.NET Framework 内に用意されているタイマオブジェクトを利用すると便利でしょう。

ただし注意したいのは、.NET Framework の中には 3 種類のタイマがあり、適切な使い分けが必要になる、という点です。具体的には、以下の 3 種類のタイマを使い分けていただく必要があります。(いずれも名称は “Timer” クラスですが、中身や機能は全くといっていいほど異なります。)

  1. System.Windows.Forms.Timer クラス (Windows アプリ向け)
    定期的に Windows メッセージキューにメッセージを投入してくれるもの。
  2. System.Timers.Timer クラス (汎用タイマ)
    定期的にスレッドプールのワークアイテムキューにワークアイテムを投入してくれるもの。
  3. System.Threading.Timer クラス (低水準タイマ)
    低水準 API を提供するタイマ。1. や 2. の内部で使われている。

このうち、3. の System.Threading.Timer クラスは低水準タイマであるため、ほとんど使う必要はありません。基本的には 1. を中心に使い、場合によって 2. を併用する、という形になります。それぞれについて、具体的なコード例を示します。

1. System.Windows.Forms.Timer クラス (Windows アプリ向け)

まず、System.Windows.Forms.Timer クラスは、定期的な UI 更新処理に利用するタイマです。Windows フォームのツールボックス一覧に表示されている部品になりますので、画面に貼り付けて利用します

image

利用する際は、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: }
image

この System.Windows.Forms.Timer コントロールは、メッセージキューに定期的にメッセージを投入するコントロールです。このため、動作上、以下の特徴や制約があります。

  • イベントハンドラ(timer1_Tick)は UI スレッド上で動作するため、直接、UI を更新してよい。(.BeginInvoke() メソッドを使う必要はない。)
  • イベントハンドラ(timer1_Tick)は UI スレッド上で動作するため、長時間処理をしてはならない。

よって、この Timer コントロールは、時計の表示などの単純な画面更新タスクの非同期化に利用するのが都合がよいでしょう。

2. System.Timers.Timer クラス (汎用タイマ)

次に、System.Timers.Timer クラスについて解説します。こちらは、定期的な業務処理を行うために利用するタイマで、スレッドプールのワークアイテムキューにワークアイテムを定期的に投入してくれるものです。

image

先ほどの Windows フォームの System.Windows.Forms.Timer クラスとは異なり、こちらは画面に貼り付けて利用する部品(コントロール)ではなく、通常のオブジェクトになります。実装例を以下に示します。

image

この System.Timers.Timer クラスのタイマーには、以下のような特徴があります。

  • スレッドプールのワークアイテムキューに定期的に処理を投入する。
  • UI スレッドをブロックしないため、時間のかかる処理も実施できる。
  • 半面、UI 更新のためには BeginInvoke() を利用する必要がある。

特に、最後のポイントについては注意してください。このタイマーのイベントハンドラ(上記のコード例の場合には t_Elapsed() メソッド)は、プールスレッド上で動作しますので、直接、UI コントロールを操作してはいけません。必ず、UI コントロールの BeginInvoke() メソッドにより、UI スレッドへの処理投入を行う必要があります。

(参考&応用) なお、少し裏ワザ的な機能になりますが、System.Timers.Timer クラスの Synchronized プロパティを使うと、t_Elapsed イベントハンドラを UI スレッド上で動作させることができます。しかし、この機能を使うぐらいなら最初から System.Windows.Forms.Timer コントロールを使った方がラクなので、そちらをお勧めします。

image

3. System.Threading.Timer クラス (低水準タイマ)

上記 2 つのタイマは、それぞれ

  • 定期的な UI 更新タスクを動かしたい → System.Windows.Forms.Timer コントロール
  • 定期的な業務処理を動かしたい → System.Timers.Timer クラス

というように使い分けますが、これらの内部で低水準 API として利用されているのが、ここで解説するSystem.Threading.Timer クラスになります。ただし、こちらは低水準 API であるため、基本的に使いません。参考までに実装例を以下に示しますが、通常は使わない、ということを覚えておいてください。

image

以上で解説した 3 種類のタイマの使い分け・比較をすると、以下のようになります。実際に利用するのは、1. と 2. のタイマである、ということを覚えておいていただければと思います。

image

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

というわけで、ここまで様々なタスクスレッドの起動方法について解説してきましたが、それぞれのタスクスレッドの起動方法には様々なトレードオフがあります。

  • 起動パラメータ引渡し可否
  • 動作スレッドの種類
  • 記述できる処理の長さ
  • UI オブジェクト操作時のスレッド同期要否、etc…

これらを比較表としてまとめると、以下のようになります。

image

もちろん、いずれも一長一短があるわけですが、基本的には以下のように使い分けるとよいでしょう。

  • 通常のタスクスレッドの起動には、非同期デリゲートを使う。
    非同期デリゲートは最も汎用性が高く、制限が少ないためです。
  • 定期的な UI 更新処理については、System.Windows.Forms.Timer コントロールを使う。
    ただし、イベントハンドラでは XML Web サービス呼び出しなどの長時間処理は行ってはいけません。
  • 定期的な業務処理については、System.Timers.Timer クラスを使う。
    ただし、イベントハンドラでは UI を直接操作してはいけません。
  • プールの枯渇を考えなければならないような場合は、マニュアルスレッドの利用を検討する。

タスクスレッドの起動については、実装コードを見て「なるほど」と思っても、実際に自分でプログラミングしてみると意外に手詰まりしてしまうことが多いです。今回示したサンプルコードを実際に一度手を動かして組んでみると、なるほどと納得できるところも多いと思いますので、ぜひ一度トライしてみてください。

Postedby nakama | 5 Comments    
Filed under: ,
Part 1. Windows フォームのマルチスレッド処理の基礎
31 March 09 12:25 AM

さて、Windows フォームは、Windows OS が持つ様々なウィンドウ制御の仕組みに基づいて開発されている UI 技術です。このため、Windows フォームのマルチスレッド処理を理解するためには、まず Windows OS がどのようにして Windows フォームアプリケーションを動作させているのかについて理解する必要があります。その中でも特に重要なのが、メッセージキューメッセージループです。これらを理解することで、なぜ UI が固まるのか、また固まることを防ぐにはどうしたらよいのか、といったことが理解できるようになります。これについて解説します。

  • メッセージキューとメッセージループ
  • UI フリーズの発生理由
  • Windows フォーム上でのマルチスレッド処理の基本ルール
  • BeginInvoke() 命令
  • 最も簡単なマルチスレッドアプリケーションの例
  • Windows フォームにおけるスレッドの種類

なお、今回のサンプルは以下の通りです。ご活用ください。

[メッセージキューとメッセージループ]

Windows フォームは、メッセージループと呼ばれる仕組みを使うことにより、イベント駆動型のプログラミングモデルを実現しています。まず、概略図を以下に示します。

image

エンドユーザがマウスやキーボードによって Windows フォームのアプリケーションを操作した際に Button_Click などのイベントが発生するのは、以下のようなメカニズムによります。

  • マウスやキーボードからの入力は、まず Windows OS が受け取る。
  • Windows OS は、その操作内容(キーが押された、マウスが動いた、マウスのボタンがクリックされた、etc)を、メッセージ構造体(MSG 構造体)に固め、それを各アプリケーション用のメッセージキューに放り込む。
  • 各アプリケーション内部では、メッセージループと呼ばれる処理が走っている。
  • メッセージループは、自分用のメッセージキューからメッセージ構造体をひとつずつ取り出し、そのデータを解析し、イベントハンドラ呼び出し(Button_Click 呼び出しなど)を行う。

※ なお、ここでいうメッセージキューとは、MSMQ (Microsoft Message Queue)のことではありません。Windows OS が持っている、GUI 処理のための特殊なキューです。

C# で Windows フォームのアプリケーションを記述した方であれば、Main 関数の中に、以下のような Application.Run() 命令を記述したことがあると思います。この命令は、メインスレッド上でメッセージループを起動するためのものです。(VB だと内部的にこの処理が隠ぺいされるためこのコードが見えませんが、内部的には同じ処理が行われています。)

image 

このメッセージループには、以下のような特徴があることを覚えておいてください。

  • メッセージループは、一種の無限ループです。つまり、メッセージキューからメッセージを取り出して処理し、次のメッセージを取り出して処理し、...をひたすら繰り返します。メッセージがなくなると、次のメッセージが届くまで待機しますが、いずれにしてもこのメッセージループは終了しません。メッセージループのコードは .NET Framework 内部に実装されているため見ることができませんが、コードイメージとして、以下のような処理が行われていると思っていただけるとわかりやすいでしょう。
    while (true)
    {
    メッセージを取り出す処理(); // (取りだせなかった場合は待機する)
    メッセージの内容を解析する処理();
    メッセージの内容に基づいて、イベントハンドラを呼び出したりする処理();
    }
  • メッセージループから、(開発者が記述した)イベントハンドラが呼び出されるまでの流れは、スタックトレースを見てみるとわかります。

image

さて、このメッセージループによるメッセージの取り出しにおいて重要なことは、メッセージの取り出し作業がシングルスレッド処理である、という点です。つまり、

  • ひとつのメッセージを取り出して、イベントハンドラ処理(Button_Click 処理など)を行っている最中は、次のメッセージが取り出されることはありません。

ということになります。実はこれが、UI フリーズが発生する主な原因になります。

[UI フリーズの発生理由]

では次に、UI のフリーズ(UI が固まって操作できなくなる現象)がなぜ発生するのかについて解説します。先ほど、メッセージキューに OS が投入する代表的なメッセージとして、以下のようなものを挙げました。

  • キーが押された
  • マウスが動いた
  • マウスのボタンがクリックされた

しかし、実は OS が投入するメッセージには、これ以外にも次のようなものがあります。

  • UI を描画しなさい

例えば画面上で、最小化されていたフォームがタスクバーからクリックされ、非アクティブだったフォームがアクティブ化されたとします。この場合、OS は、当該アプリに対して「UI を描画しなさい」という命令(メッセージ)を、(メッセージキューを介して)送ります。これを受け取った Windows フォームアプリは自分を描画することで、フォームを表示することになります。

つまり、メッセージキューに投入されるメッセージの中には、Windows OS からの再描画要求やサイズ変更要求などもあります。こうしたメッセージをすぐに処理できないと、UI が固まったり、正しくウィンドウが表示されなくなったりするように見える、ということになるわけです。

ところが先ほど述べたように、メッセージループによるメッセージキューからのメッセージの取り出しは、ひとつずつ順次行われます。このため、メッセージループを持つスレッド上で時間のかかる処理を行ってしまうと、再描画処理が即座に行われず、UI がフリーズします

例えば、以下のようなコードを書いたとします。

image

   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 = “…(処理結果)…”; といった具合に直接コントロールを操作すると、最悪の場合、アプリケーションがクラッシュします

image

この問題を解決するために用意されているのが、BeginInvoke() 命令です。

[BeginInvoke() 命令]

先に述べたように、UI 部品はそれを作成したスレッド、通常は UI スレッド(=メッセージループを動作させているスレッド)から操作しなければなりません。では、上図のように独自に起動した処理スレッドから UI を更新したい場合にはどのようにすればよいかというと、BeginInvoke() 命令を利用します。この命令は、すべての Windows フォームコントロールが備えているメソッドで、簡単にいうと、「特定のメソッドを呼び出せ」という命令を、メッセージ構造体としてメッセージキューに投入するためのものです。

image

具体例を出しながら解説した方がわかりやすいと思いますので、一例として、以下のような画面(バックグラウンドで処理を進めている際に、進捗状況を ProgressBar に表示する)を考えてみます。

image

まず、バックグラウンドスレッドから、直接 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 を更新することができます。

[最も簡単なマルチスレッドアプリケーションの例]

では上記のコードサンプルを利用して、以下のような「進捗表示アプリケーション」を作ってみることにしましょう。

image

具体的な作業手順は以下の通りです。まず、フォーム上にボタンとプログレスバーを貼り付けます。

image

次に、ボタンのクリックイベントハンドラに以下のようなコードを書き、実行してみてください。

   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 からの再描画要求に応答できなくなってしまっているためです。

image

このような形になるのを避けるためには、この処理をバックグラウンドのスレッドに切り離す必要があります。バックグラウンドのスレッドとしては、プールスレッドとマニュアルスレッドの 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 を持った進捗状況表示画面が作成されます。

image

完成したソースコードと、それぞれのメソッドがどこで動作するのかを下図に示します。

image

このようにすることで、時間のかかる処理を背後のスレッドに分離し、フリーズしない UI を作成することができます。

[Windows フォームにおけるスレッドの種類]

さて、上記では処理の切り離しにマニュアルスレッド(新規に作成したスレッド)を使いましたが、スレッドプールを使って処理を別スレッド化することもあります。(※ スレッドプールがどのようなものであるかについては、以前に記述したエントリを参照してください。)

image 

つまり、Windows フォームでは、UI スレッド(メインスレッド)から処理を切り離す方法として、マニュアルスレッドとプールスレッドの 2 種類がある、ということになります。結果として、Windows フォームアプリケーション内部では、主に以下のようなスレッドが利用されることになります。

  • メインスレッド (UI スレッド)
    当該プロセス内に最初に作られ、Main() メソッドを呼び出すスレッド。このスレッド上で UI コントロールを作成し、Application.Run() メソッドを呼び出し、メッセージループを動作させる。メッセージキュー内のメッセージ処理や、各種のイベントハンドラ呼び出しはこのスレッド上で発生する。
  • マニュアルスレッド
    非同期処理(バックグラウンドタスク)を行うために、自力で Thread オブジェクトを生成することにより作ったスレッド。
  • プールスレッド
    非同期処理(バックグラウンドタスク)を行う際、CLR の機能であるスレッドプール機能を用いる場合に利用されるスレッド。
  • その他のスレッド
    ファイナライザスレッドやアンマネージスレッドなどが動作するスレッド。通常は気にしなくて OK。

まとめると、下図のようになります。注意すべき点は、マニュアルスレッドやプールスレッドから UI コントロールを直接更新してはならないという点です。UI を更新したい場合には、BeginInvoke() 命令を使って、メッセージキューにメソッド呼び出し要求を投入してください。

image

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

というわけで、今回のエントリでは Windows フォームのマルチスレッド処理の基礎について解説してきました。キーポイントは以下の通りです。

  • Windows OS は、メッセージキューにメッセージ(MSG 構造体)を投入することによって、マウスの移動やキーボードの押下を通知している。
  • Windows フォームアプリケーションは、内部でメッセージループを使い、メッセージキューから一件ずつ、逐次でメッセージを取り出していくことで処理を進める。
  • UI フリーズが発生する原因は、メッセージループ上(UI スレッド上)で長時間処理を行ってしまうことである。イベントハンドラなどに長時間処理を記述すると、OS からの再描画要求が実行されなくなり、UI がフリーズする。
  • 通常、UI コントロールはすべてメインスレッド上でインスタンス化し、このスレッド上から操作する。このメインスレッドのことを、別名で UI スレッドと呼ぶ。
  • マニュアルスレッドやプールスレッドといった、メイン以外のスレッドから UI コントロールを操作してはならない。このような場合には、BeginInvoke() 命令を使って、UI スレッド上で画面描画更新処理を動作させる。

以上が基本的な Windows フォームのマルチスレッド処理の大原則です。しかしこれだけではまだマルチスレッド動作する Windows フォームアプリケーションを開発するにはやや知識が足りません。次回のエントリでは、上記の大原則に従った、より詳細なマルチスレッドアプリの開発方法について解説してきます。

Postedby nakama | 2 Comments    
Filed under: ,
マルチスレッド Windows フォームアプリケーションの開発
30 March 09 11:13 PM

というわけでまたしてもかなり日にちが空いてしまいました;。年度末ということもあって仕事が立て込んでいたのですが、ほぼ一段落したので久しぶりにエントリを。どうしてもまとまった話題を書こうとすると時間がかかっちゃいますね....

今回の話題は、Windows フォームにおけるマルチスレッド処理の正しい書き方です。以前、マルチスレッドアプリケーションにおけるデータ変数の排他制御(スレッドセーフか否かの判定)についてこちらこちらのエントリに書きましたが、さらにもう少し応用的なトピックとして、Windows フォームにおけるマルチスレッド処理について解説したいと思います。

この辺の話は、C++ でアプリケーションを作られている方には当たり前の話(らしい)のですが、私のような pure .NET デベロッパーな人にはあまり馴染みのない話だと思います。しかし、C# や VB でしか Windows フォームのアプリケーションを書いたことがない人であっても、うまくコードを書かないと、下図のような「フリーズしてしまう」アプリケーションができあがってしまいます。

image

今回のエントリでは、以下について解説します。

  • そもそもなぜ 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 コンポーネントによる一般的なタスクの非同期処理化
Postedby nakama | 0 Comments    
Filed under: ,
Part 2. スマートクライアントにおける単体入力データ検証
26 February 09 08:29 PM

さて、前回のエントリでは 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 フォームの実装コードを少しでも美しい形にしていただければと思います。

Postedby nakama | 3 Comments    
Filed under: ,
Part 1. 双方向データバインドの基本的な使い方
26 February 09 08:15 PM

Windows フォームにおけるデータ入力において、スマートに単体入力エラーチェックを行いたい場合には、双方向データバインドと IDataErrorInfo インタフェースを利用する方式が便利です。ただし、この Windows フォームのデータバインドは Web フォームのデータバインドとは大きく異なる点があります。

  • 双方向データバインドが利用できる。(※ Web フォームでも使えますが、極めて限定的です。)
  • 入力データのデータソースへの反映がリアルタイムに行われる。(リアルタイムにデータが同期。)

このため、ASP.NET Web アプリケーションでのデータバインドにすでに慣れ親しんでいる方でも、Windows フォームのデータバインドについては、全く新しい技術として学習しなおす、ぐらいの感覚で取り組むことが重要です。今回のエントリでは、まず基本的な Windows フォームのデータバインドの実装方法を解説したいと思います。具体的な解説トピックは以下の通りです。

  • 双方向データバインドとは何か
  • 2 種類のデータバインド
  • データバインドの基本的な使い方 : データソースとなりうるオブジェクト
  • データバインドの基本的な使い方 : 単票形式データバインド
  • データバインドの基本的な使い方 : グリッド形式データバインド
  • null 値の入力制御方法(パース処理のカスタマイズ方法)

なお、今回のサンプルを最後まで実装したサンプルコードはこちらになります。併せてご利用ください。

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

[双方向データバインドとは何か]

双方向データバインドとは、UI コントロール(DataGrid や TextBox, Label コントロールなど)の特定のプロパティと、データソースとなるオブジェクト(型付きデータセットやカスタムオブジェクト)とを紐づけておき、

  • データソースから自動的にデータを抽出して UI コントロールに表示する。
  • UI コントロールから入力された値を、自動的にデータソースに反映する。

という動作をさせるものになります。

image 

ASP.NET Web フォームなどのデータバインドでは、ほとんどの場合、データソース内のデータを UI コントロールに表示するという目的のみで利用されます。このため、更新系アプリケーションにおけるデータ入力シートを作成する目的でデータバインドを使うことはまずありません、しかし、Windows フォームや WPF などでは、更新系アプリケーションにおけるデータ入力画面を作成する際に、今回解説する双方向データバインドを利用します

例えば、下図のような新規顧客登録画面を作ろうと思った場合を考えてみてください。ASP.NET Web フォームの場合には、通常のテキストボックスと検証コントロール(Validation Control)によりこのような画面の単体入力チェック機能を作るはずです。しかし、Windows フォームの場合には、検証コントロールは使いません(というより存在しません)。このような場合に、双方向データバインドと IDataErrorInfo インタフェースを利用して、単体入力チェック機能を実装します。

  • ASP.NET Web フォームの場合 : 検証コントロールで実装
    image
  • Windows フォームの場合 : 双方向データバインドと IDataErrorInfo で実装
    image

この双方向データバインドによるエラーチェックは、単票形式でもグリッド形式でもどちらでも利用可能ですが、いきなりこれらの解説に入る前に、まずは Windows フォームによるデータバインドの基本についてもう少し解説しましょう。

[2 種類のデータバインド]

上にちょろっと書いたように、データバインドには、単票形式のデータバインドと、グリッド形式のデータバインドの 2 種類があります。(ちなみにこの分類方法は、Windows フォームだけでなく WPF でも同じです。)

  1. 単一値データバインド (単票形式のデータバインド)
    データソースとして、「あるオブジェクトインスタンス(の一つ)」を使うもの
  2. コレクションデータバインド (グリッド形式のデータバインド)
    データソースとして、「オブジェクトインスタンスのコレクション」を使い、一括表示するもの

image

基本的に単一値データバインド(単票形式データバインド)は、1 レコード分のデータを保有するオブジェクトインスタンスを、単票形式に表示するためのものです。そしてこれを n 回、縦方向に繰り返したものがコレクションデータバインド(グリッド形式データバインド)になります。このため、まずは単一値データバインド(単票形式データバインド)のことを正しく理解することが重要です。これを正しく理解すれば、コレクションデータバインドもおのずと理解できるようになります。

[データバインドの基本的な使い方 : データソースとなりうるオブジェクト]

さて、データバインドは、UI コントロール(バインドされたデータを表示する UI 部品) とデータソースオブジェクト(実際のデータとなるもの)とが、BindingSource オブジェクト(データバインドを制御するもの)により連結制御されることにより実現されます。

image 

よって、このような連結関係を組み立て上げればデータバインドが使えるようになるのですが、このデータバインドのデータソースとして利用できる代表的なオブジェクトとしては、以下の 2 つがあります。これについて解説しておきましょう。

  • カスタムオブジェクトおよびそのコレクション
  • 型付きデータ行および型付きデータテーブル

① カスタムオブジェクトおよびそのコレクション

public フィールドや public プロパティを持つオブジェクトは、そのまま単票形式にバインド(単一値データバインド)することができます。具体的には、下図のサンプルに示すようなクラスのインスタンスを単票形式データバインドのデータソースに、またそのコレクションをグリッド形式データバインドのデータソースに利用できます。

image

② 型付きデータ行および型付きデータテーブル

また、.xsd ファイルを用いて作成できる型付きデータセットの中に含まれる、型付きデータテーブルと型付きデータ行は、それぞれグリッド形式データバインドと単票形式データバインドに利用できます。

image

②の型付きデータセットや型付きデータテーブルを利用するデータバインドについては、ASP.NET Web フォームにおけるデータ表示用データバインドなどでもお馴染みだと思います。しかし、Windows フォームでデータバインドを行う場合、

  • データ表示しか行わない場合には、②を利用する。
  • 表示したデータをユーザに更新させる場合には、①を利用する。

という使い分けを行います。特に後者が重要で、Windows フォームで更新系アプリケーションを作る場合には、型付きデータセットを使った双方向データバインドは行いません。このことを頭の片隅に置いておいてください。(理由はおいおいわかってくると思いますが、簡単に記述すると、型付きデータセットを使うと単体入力データ検証ロジックの実装が分かりづらくなるからです。)

では引き続き、具体的なデータバインドの実装方法(Visual Studio 2008の使い方)について解説していきます。今回の blog エントリのゴールは単体入力チェック機能を持つデータ入力ページを作成することであるため、①の方法を中心に解説します。また、ここではまだ単体入力チェック機能は実装しません。まず、双方向データバインドの組み立て方(BindingSource オブジェクトによる連結方法)に絞って解説します。

[データバインドの基本的な使い方 : 単票形式データバインド]

まずは単票形式のデータバインドから解説していきましょう。具体的には、以下の 4 つの作業を行います。

  1. データバインドするオブジェクトの準備
  2. データソースの登録
  3. BindingSource コントロールの貼り付け
  4. データの紐付け

ここでは、以下のような画面を双方向データバインドで組み上げる方法について解説します。

image

Step 1. データバインドするオブジェクトの準備

まず、UI とバインドを行うためのクラスを作成します。具体的には、新規に Windows フォームアプリケーションプロジェクトを作成し、TitleInput.cs という名前でクラスを追加します。

image

ここに、データ 1 レコード分に相当するオブジェクトのクラスを定義します。なお、null 入力値を受け付けるフィールドについては、Nullable<T> 型で定義をしてください。

   1: namespace WindowsFormsApplication1
   2: {
   3:     public class TitleInput
   4:     {
   5:         public string title_id { get; set; }
   6:         public string title { get; set; }
   7:         public decimal? price { get; set; }
   8:         public DateTime? pubdate { get; set; }
   9:     }
  10: }

Step 2. データソースの登録

次に、Visual Studio でビジュアルデザイン機能を利用できるようにするため、データソース登録を行います。以下の作業を行ってください。

  • プロジェクト全体をまずリビルドする。
  • ツールバーの「データ」→「データソースの表示」を行って、データソースウィンドウを表示する。
  • データソースウィンドウ内の「新しいデータソースの追加」をクリックする。
  • データソース構成ウィザードが起動するので、TitleInput オブジェクトを選択する。

image

これにより、データソースウィンドウに、TitleInput クラスのデータ構造情報が取り込まれ、GUI からのデータバインド設定操作ができるようになります。

Step 3. BindingSource コントロールの貼り付け

次に、単票形式の画面を作成します。具体的には、以下の作業を行います(が、できればウィザードを使わないでできるようになってほしいので、まずは後述する方法を使ってください)。

  • Form1.cs のデザイン画面を開く。
  • データソースの TitleInput の貼り付け方式を「DataGridView」から「詳細」に切り替えた上で、フォーム上にドラッグ&ドロップする。
  • 単票形式のデータバインドの場合、BindingNavigator コントロールは不要なので削除する。
  • あとは適宜、画面を編集する。(ラベルの追加やボタンの追加など)

image

実際の開発では上記の作業を行っていくのですが、これだとウィザードがかなりの部分を自動的にやってしまうので、何をやっているのかがわからないと思います。以下のようにすると、上記の作業をきちんと一つずつ手作業で行うことができます。最初のうちはこちらの方法でやってください。

  • BindingSource コントロールを画面に貼り付ける。
  • BindingSource コントロールの DataSource プロパティに、作業 2. でデータソース登録したクラス(この例の場合は TitleInput クラス)を指定する。(この作業により、以降のステップで、データソースのプロパティとの紐づけが GUI から簡単に設定できるようになる)
    image
  • 画面上に、ラベルやテキストボックスを貼り付けて並べていく。
  • データバインド対象となる UI コントロール(例えばテキストボックスなど)の DataBindings プロパティを開き、データソースオブジェクトのターゲットプロパティとの紐づけ関係を指定する。
    image

Step 4. データの紐付け

最後に、フォームのロードイベントなどにデータの紐づけコードを作成します。具体的には、以下の作業を行います。

  • フォームの private メンバ変数として、データバインドするオブジェクトインスタンスの変数を宣言しておく。(下記コードの _data 変数)
  • Form_Load() メソッド内で、データバインドするオブジェクトインスタンスを作成し、BindingSource コントロールに割り当てる。
   1: public partial class Form1 : Form
   2: {
   3:     public Form1()
   4:     {
   5:         InitializeComponent();
   6:     }
   7:  
   8:     private TitleInput _data;
   9:  
  10:     private void Form1_Load(object sender, EventArgs e)
  11:     {
  12:         this._data = new TitleInput()
  13:         {
  14:             title_id = "BU1032",
  15:             title = "Book of .NET",
  16:             price = 30,
  17:             pubdate = new DateTime(1973, 6, 7)
  18:         };
  19:         this.bindingSource1.DataSource = this._data;
  20:         // ※ ウィザードで作成した場合は
  21:         // this.titleInputBindingSource.DataSource = this._data;
  22:     }
  23: }

以上の作業により、下図のようなデータバインドが構成され、画面上にデータの内容が自動的に表示されるようになります。

  • UI 画面と BindingSource コントロールとデータソースオブジェクトの関係
    image
  • 実行結果
    image

なお、注意していただきたいのは Step 3 と Step 4 で行うプロパティへの設定の違いです。

  • Step 3 では、BindingSource コントロールの DataSource プロパティに、データソースウィンドウで設定した TitleInput クラスを設定している。
  • Step 4 では、BindingSource コントロールの DataSource プロパティに、実際に Form_Load() イベント内で作成した TitleInput オブジェクトのインスタンスを割り当てている。

実際に UI 画面上にデータを表示するためには、DataSource プロパティにオブジェクトのインスタンスを割り当てなければなりません(Step 4)。しかしながら、UI 画面のデザイン時には、まだオブジェクトインスタンスが作成されていません。そこで、UI 画面のデザイン時(Step 3)には、インスタンスのかわりにクラスを割り当てておくことで、デザイン機能を有効化する、というわけです。

現時点での実装上の問題

ここまでで、_data 変数の内容が自動的に UI 上に表示されることが確認できましたので、今度は UI からの入力がデータソースである _data 変数に反映されることを確認してみましょう。まず、button1_Click() メソッドに以下のような処理を組み込んでおきます。

   1: private void button1_Click(object sender, EventArgs e)
   2: {
   3:     MessageBox.Show(
   4:         "書籍ID : " + _data.title_id + "\n" +
   5:         "書籍名 : " + _data.title + "\n" +
   6:         "価格   : " + _data.price + "\n" +
   7:         "出版日 : " + _data.pubdate);
   8: }

その上で、以下のような作業をしてみてください。

  • 不適切なデータの入力
    書籍 ID に不適切なコードを入力したり、価格にマイナスの値を入力しても、問題なく入力が通り、そのまま背後の _data 変数に入力内容が反映されてしまいます。
  • フォーカス制御(フォーカス移動の抑止)
    例えば、価格欄に “ABC” などの型変換ができないデータを入力した場合には、フォーカス移動ができなくなります。
  • null 値の入力
    価格や出版日は null 値が設定できるはずですが、入力を空欄(空文字 “”)にしても、null 値が設定されません。

image

確かに、UI からの入力内容は BinidngSource コントロールによりデータソースに反映されています。しかしながら、空文字として入力された値を null 値に変換したり、不適切なデータ入力を抑止させたりするといった制御が行われておらず、このままでは業務アプリケーションのデータ入力フォームとしては利用できません。(これらの問題の解決方法については後述します。)

 

 

 

 

では引き続き、グリッド形式のデータバインドの実施方法の基本を解説します。

 

[データバインドの基本的な使い方 : グリッド形式データバインド]

グリッド形式のデータバインドには、通常 DataGridView コントロールを利用します。具体的には、以下の作業を行います。

  1. データバインドするオブジェクトの準備
  2. データソースの登録
  3. DataGridView コントロールの貼り付けとデータソースの指定
  4. DataGridView の表示列のカスタマイズ
  5. データソースへのコレクションの割り当て

これについて解説します。

1. データバインドするオブジェクトの準備、2. データソースの登録

先に解説したように、コレクションデータバインドは単一値データバインドの n 件繰り返しになります。このため、データソースとして利用するオブジェクトは、先の単票形式データバインドの場合とまったく同じになります。1 レコード分に相当するオブジェクトとして TitleInput クラスを作成し、データソースとして登録を行っておいてください。

3. DataGridView コントロールの貼り付けとデータソースの指定

次に、コレクションデータバインドを行う画面を作ります。アプリケーションにもうひとつフォームを追加し、以下の作業を行います。

image 

  • BindingSource コントロールと DataGridView コントロールを貼り付ける。
  • bindingSource1 の DataSource プロパティに、TitleInput クラスを指定する。
  • dataGridView1 の DataSource プロパティに、bindingSource1 インスタンスを指定する。

以上の作業により、下図のようなプレビュー画面が表示されます。

image

4. DataGridView の表示列のカスタマイズ

次に、DataGridVIew のタスクペイン(コントロールの右上に表示される小さな矢印から開かれるタスク一覧)から列の編集を行います。これにより、表示列の絞り込みや、列ヘッダーや列幅、表示フォーマットなどの調整ができます。(※ 価格列については、DefaultCellStyle プロパティを変更すると通貨表示ができます。)

 

 

image

image

image

5. データソースへのコレクションの割り当て

最後に、Form_Load() イベントなどで、bindingSource1 の DataSource プロパティにて、実際に画面上に表示するデータコレクションを割り当てます。(なお、今回は簡単のため、データをその場で作成して割り当てますが、実際の業務アプリケーションでは、XML Web サービスなどからデータを取り寄せて、そのデータを紐づけ表示することになります。)

   1: public partial class Form2 : Form
   2: {
   3:     public Form2()
   4:     {
   5:         InitializeComponent();
   6:     }
   7:  
   8:     private List<TitleInput> _dataItems;
   9:  
  10:     private void Form2_Load(object sender, EventArgs e)
  11:     {
  12:         _dataItems = new List<TitleInput>()
  13:         {
  14:             new TitleInput() { title_id="BU1032", title="Book of .NET", 
  15:             price=10.0m, pubdate=new DateTime(1973, 6, 7) },
  16:             new TitleInput() { title_id="PT0572", title="All About MSFT", 
  17:             price=5.9m, pubdate=new DateTime(2008, 5, 3) }
  18:         };
  19:         bindingSource1.DataSource = _dataItems;
  20:     }
  21: }

なお、DataSource プロパティの割り当てを行う場合には、dataGridView1.DataSource プロパティではなく、bindingSource1.DataSource プロパティへの割り当てを行ってください。このサンプルでは、紐付け関係を明示的に bindingSource1 コントロールにより下図のように制御しているためです。

image

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

image

なお、このサンプルでもやはり先に示した単票形式のサンプルと同様、以下のような問題があります。

  • 不適切なデータの入力
  • フォーカス制御(フォーカス移動の抑止)
  • null 値の入力

    これらのうち、不適切なデータ入力の問題やフォーカス制御の問題については次回以降のエントリで詳細に解説していきますが、null 値の入力の取り扱い方法に関しては本エントリで解説してしまうことにします。

    [null 値の入力制御方法(パース処理のカスタマイズ方法)]

    一般に、データ入力フォームにおいて null 設定を可とするプロパティに対して、どのようにしてエンドユーザに null 値を入力させるのかに関しては、かなり頭の痛い問題を抱えています。今回取り扱った例を考えてみましょう。

    image

    このケースでは、書籍 ID や書籍名のフィールドは string 型であるため null 値が設定できるはずです。しかし、TextBox との双方向データバインドが行われている場合、

    • データソースが null の場合、テキストボックスへの表示は “” (空文字)になる。
    • データソースが “” (空文字)の場合も、テキストボックスへの表示は “” (空文字)になる。
    • では、テキストボックスの入力が “” (空文字)の場合は、これをデータソースにどのように反映するべきか?(null として反映すべきか、それとも空文字として反映すべきか?)

     

     

     

    という問題があり、状況やバインド対象データによる case-by-case の判断が必要になります。

    実はこうした問題を回避するため、SQL Server Management Studio や DataGridView コントロールでは、”Ctrl + 0” という特殊入力機能を備えています。下図に示すように、一覧表示された表の上で “Ctrl + 0” を押すと、そのフィールドに null 値が設定できるようになっています。

    image

    しかし、この “Ctrl + 0” による null 入力機能には、以下のような制約があります。

    • TextBox をはじめとする大半のコントロールではこの機能を利用できない。(例えば DateTimePicker などでは当然使えない)
    • DataGridView に関しては、型付きデータセットや型なしデータセットとバインドする場合のみこの機能が利用できる。カスタムオブジェクトの持つ Nullabe<T> フィールドとのバインドの際にはこの機能をできない。

    現実的な話として、

    • テキストボックスなどからの空文字入力を “” (空文字)と解釈すべきか null 値と解釈すべきかは状況やバインド対象データによって変わる。
    • たとえば価格データなどについては空文字をストレートに decimal? の null と解釈してくれた方がユーザにとって使いやすいアプリとなる。
    • エンドユーザに ”Ctrl + 0” 入力方式をお願いすることは非現実的。

    などを考慮すると、テキストボックスからの入力値を必要に応じてパースし、空文字入力を null 値として解釈しなおすような処理を作り込んだ方が現実的でしょう。具体的には以下のようなことを知っておくと便利です。

    1. TextBox コントロールからの空文字入力を null 値に変換してデータソースに反映する方法
    2. (応用例) ドロップダウンリストからの入力値をデータソースに反映する方法
    3. 空文字入力を null 値に自動変換する NullableTextBox コントロールを作る方法
    4. DataGridView にて空文字入力を null 値に変換する方法

    これらについて解説します。

    1. TextBox コントロールからの空文字入力を null 値に変換してデータソースに反映する方法

    TextBox コントロールからの空文字入力を null 値に変換する最も簡単な方法は、データソースへの反映制御をつかさどっている BindingSource コントロールのパース処理をカスタマイズすることです。具体的には、価格テキストボックスに紐づけられているバインディングオブジェクトを取り出し、パース処理のイベントをフックします。具体的には、以下のようなコードを記述します。

       1: public partial class Form1 : Form
       2: {
       3:     private void Form1_Load(object sender, EventArgs e)
       4:     {
       5:         ... (中略) ...
       6:         Binding b = priceTextBox.DataBindings["Text"];
       7:         b.Parse += new ConvertEventHandler(b_Parse);
       8:     }
       9:  
      10:     void b_Parse(object sender, ConvertEventArgs e)
      11:     {
      12:         if (e.Value as string == "")
      13:         {
      14:             e.Value = null;
      15:         }
      16:     }

    このようにすると、下図のように、空文字入力した内容が自動的に null 値に変換されてデータソースオブジェクト(_data 変数)に反映されるようになります。

    image 

    2. (応用例) ドロップダウンリストからの入力値をデータソースに反映する方法

    このようなパース処理のカスタマイズは、特にドロップダウンリストからの入力値をデータソースに反映するような場合に役立ちます。例えば、下図の例を見てください。

    image

    この例では、契約有無による契約条件をドロップダウンリストから選択させています(空文字、「契約あり」「契約なし」の 3 つから選択)。この値を、これを背後にあるオブジェクトの bool? contract プロパティへと反映したい場合には、string 型から bool? 型への変換が必要になります。このような変換も、Binding オブジェクトの Parse イベントのフックにより実現できます。具体的には以下のようなコードを書きます。

       1: private void FindAuthors_Load(object sender, EventArgs e)
       2: {
       3:     ... (中略) ...
       4:     Binding cbxContractBinidng = cbxContract.DataBindings["Text"];
       5:     cbxContractBinidng.Parse += new ConvertEventHandler(cbxContractBinidng_Parse);
       6: }
       7:  
       8: void cbxContractBinidng_Parse(object sender, ConvertEventArgs e)
       9: {
      10:     switch (e.Value as string)
      11:     {
      12:         case "":
      13:             e.Value = null;
      14:             break;
      15:         case "契約あり":
      16:             e.Value = true;
      17:             break;
      18:         case "契約なし":
      19:             e.Value = false;
      20:             break;
      21:         default:
      22:             throw new ApplicationException(e.ToString());
      23:     }
      24: }
      25:  

     

     

     

     

    3. 空文字入力を null 値に自動変換する NullableTextBox コントロールを作る方法

    さて、このように Parse イベントを作り込めば、背後のオブジェクトへの反映方法を自由にカスタマイズできるのですが、空文字 → null 値への変換を行う TextBox は比較的頻出です。このため、TextBox コントロールを継承した NullableTextBox コントロールを作って使ってしまった方がラクでしょう。具体的には以下の作業を行います。

    • 適当なフォルダに、NullabeTextBox という名前でクラスファイルを一つ作成する。

    image

    • TextBox クラスを継承したクラスを作成し、Text プロパティをオーバライドする。
       1: using System;
       2: using System.Collections.Generic;
       3: using System.Linq;
       4: using System.Text;
       5:  
       6: using System.Windows.Forms;
       7: using System.ComponentModel;
       8:  
       9: namespace MCS.Japan.Framework.Client.WinForms
      10: {
      11:     public class NullableTextBox : TextBox
      12:     {
      13:         [DefaultValue(null)]
      14:         public override string Text
      15:         {
      16:             get
      17:             {
      18:                 return ((base.Text == "") ? null : base.Text);
      19:             }
      20:             set
      21:             {
      22:                 base.Text = ((value == null) ? "" : value);
      23:             }
      24:         }
      25:  
      26:         [Browsable(false)]
      27:         public new string[] Lines
      28:         {
      29:             get
      30:             {
      31:                 throw new InvalidOperationException("NullableTextBox では Lines プロパティへのアクセスを認めていません。");
      32:             }
      33:         }
      34:     }
      35: }
    • リビルドすると、ツールボックスから貼り付けられるようになるので、これを利用する。(バインドの指定方法などは通常の TextBox などと同じです。)

    image

    4. DataGridView にて空文字入力を null 値に変換する方法

    さてテキストボックスの場合には、null 値の入力を可能にする方法として、パース処理をカスタマイズする方法と、NullableTextBox を作成する方法があることを解説しました。しかし、DataGridView の場合には制約があり、前者のパース処理のカスタマイズによる null 値変換の方法を取れません(詳細はこちら)。このため、DataGridView の特定列を null 入力可にするためには、空文字入力を null 値として解釈するような列クラスを作成し、これを利用する必要があります。具体的には以下のようにします。

    • 適当なフォルダに、DataGridViewNullableTextBoxColumn.cs クラスを追加する。

    image

    • null 入力を可能にするようなテキストボックスセルとテキストボックス列を作成する。
       1: using System;
       2: using System.Collections.Generic;
       3: using System.Linq;
       4: using System.Text;
       5: using System.Windows.Forms;
       6: using System.ComponentModel;
       7:  
       8: namespace MCS.Japan.Framework.Client.WinForms
       9: {
      10:     public class DataGridViewNullableTextBoxColumn : DataGridViewTextBoxColumn
      11:     {
      12:         public DataGridViewNullableTextBoxColumn()
      13:         {
      14:             this.CellTemplate = new DataGridViewNullableTextBoxCell();
      15:         }
      16:  
      17:         public override DataGridViewCell CellTemplate
      18:         {
      19:             get
      20:             {
      21:                 return base.CellTemplate;
      22:             }
      23:             set
      24:             {
      25:                 if ((value is DataGridViewNullableTextBoxCell) == false)
      26:                 {
      27:                     throw new InvalidOperationException("DataGridViewTextBoxCell以外は指定できません。");
      28:                 }
      29:                 base.CellTemplate = value;
      30:             }
      31:         }
      32:     }
      33:  
      34:     public class DataGridViewNullableTextBoxCell : DataGridViewTextBoxCell
      35:     {
      36:         protected override object GetFormattedValue(object value, int rowIndex, ref DataGridViewCellStyle cellStyle, TypeConverter valueTypeConverter, TypeConverter formattedValueTypeConverter, DataGridViewDataErrorContexts context)
      37:         {
      38:             if (value == null) return null;
      39:             return base.GetFormattedValue(value, rowIndex, ref cellStyle, valueTypeConverter, formattedValueTypeConverter, context);
      40:         }
      41:  
      42:         public override object ParseFormattedValue(object formattedValue, DataGridViewCellStyle cellStyle, TypeConverter formattedValueTypeConverter, TypeConverter valueTypeConverter)
      43:         {
      44:             if ((formattedValue as string) == "") return null;
      45:             return base.ParseFormattedValue(formattedValue, cellStyle, formattedValueTypeConverter, valueTypeConverter);
      46:         }
      47:     }
      48: }

     

    • リビルドを行った上で DataGridView コントロールの列定義を開き、列のデータ型として、作成した NullableTextBoxColumn を割り当てる。

    image [

    以上により、空文字入力を null 値として解釈してくれる列を作成することができます。

    image

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

    というわけで、Part 1. では、双方向データバインドの基本的な使い方を解説してきました。キーポイントをまとめると、以下の通りとなります。

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

    image

    さて、今回のエントリでは UI からの入力値をデータソースへと反映する双方向データバインドの基本動作を解説しましたが、実際には反映できたからといってその入力値が正しいとは限りません。データ入力値が正しくない場合には、入力エラーメッセージを画面上に表示する必要がありますが、この方法については次のエントリで解説していきたいと思います。

     

     

     

     

  • Postedby nakama | 1 Comments    
    Filed under: ,
    双方向データバインドによる単体入力エラーチェック
    26 February 09 08:10 PM

    ふと気付いてみれば前回のエントリから一か月以上が経過。一回サボるとついエントリをサボり気味になってしまうものですね;。さて今度のエントリは何にしようかと迷っていたのですが、先日、マイミクの Gushwell さんと話していたときに、Windows フォームの双方向データバインドを使った単体入力エラーチェックの実装方法を書いたらどうか、という話に。確かにこの方法は知らない人が多く、私自身も数ヶ月前にわからなくて非常に苦しんだ経緯もあるので、今回はこれをネタにしてエントリを書いてみようと思います。

    今回のエントリのゴールは、下図のような単体入力エラーチェックを、Windows フォームで美しく実装する方法を理解することです。

    image

    このような単体入力エラーチェックは、「頑張れば」どうにでも実装できます。しかしながら、美しく実装しようと思うとこれが非常に難しく、検討しなければならないポイントが山ほど出てきます。幸い、.NET Framework 3.5 ではこのような単体入力エラーチェックを美しく実装できるようにするための技術として、IDataErrorInfo というインタフェースが導入されました。このインタフェースがどのような意味を持つのかについては、双方向データバインドの重要な特性である、リアルタイムのデータ同期という考え方を理解する必要がありますが、このあたりについて書かれたドキュメントがほとんどなく、現場でも全くといっていいほど使われていないのではないかと思います。

    そこで、今回のエントリでは、以下のようなポイントについて解説をしていきたいと思います。

    [Part 1. 双方向データバインドの基本的な使い方]

    • 双方向データバインドとは何か
    • 2 種類のデータバインド
    • データバインドの基本的な使い方 : データソースとなりうるオブジェクト
    • データバインドの基本的な使い方 : 単票形式データバインド
    • データバインドの基本的な使い方 : グリッド形式データバインド
    • null 値の入力制御方法

    [Part 2. スマートクライアントにおける入力データ検証の実施方法]

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

    では、順番に見ていきましょう。

    Postedby nakama | 1 Comments    
    Filed under: ,
    .NET の例外処理 Part. 4
    23 January 09 04:14 PM

    というわけで、前回まで 3 回(+α)に渡って .NET の例外処理の適切な書き方について解説してきましたが、ここまでの解説にもまして重要なのは、

    • 表示またはロギングされた例外情報を、正確に読み取れるようになること。

    です。実は例外に含まれる情報には、デバッグや障害解析に欠かせない情報が多数含まれており、これらを正しく読めるようになるだけで、以下のようなことがわかることが多いのです。

    • どこで例外が発生したのか
    • アプリケーションの内部構造がどんな形になっているのか
    • 例外がどんなシチュエーションで発生したのか
    • 上記から、アプリケーションで障害が発生した理由、またはバグの内容

    もちろん、例外に含まれる情報だけですべてのことがわかるというわけではありませんが、例外情報を正しく読めるようになるだけで、かなりのことがわかるようになるのも事実です。これについて解説します。

    なお、本エントリについては、前半部と後半部に分けて解説します。

    • 前半部では、すべての開発者に知っていただきたい、例外情報の基本的な読み方
    • 後半部では、多少経験を積んだ開発者に知っていただきたい、応用的な例外オブジェクトの使われ方

    自分のスキルレベルに合わせて以下の情報をお読みいただければと思います。

    [例外情報の基本的な読み方]

    • 例外オブジェクトに含まれる 3 つの情報
    • 例外情報の基本的な読み取り方

    [応用的な例外オブジェクトの使われ方]

    • 例外クラスの分類
    • 例外オブジェクトのメッセージ情報の使われ方
    • 例外オブジェクトのネスト
    • 例外オブジェクトのカスタムプロパティ

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

    [例外オブジェクトに含まれる 3 つの情報]

    ここまで解説してきたように、.NET における例外は、アプリケーションエラーやシステムエラーが発生したことを表現するために使われるものでした。

    image

    さて、この例外オブジェクトは、それが発生した場所から上位のモジュールに伝搬されてきますが、この例外オブジェクトの中には、発生した障害に関する重要な情報が多数含まれています。このため、集約例外ハンドラでこれを記録しておき、事後的にこの例外情報を解析すると、さまざまなことがわかるようになります。例外オブジェクトに含まれる情報の中でも、特に重要な情報は以下の 3 つです。

    • 例外オブジェクトのクラスそのもの(どんな例外クラスか?)
    • 例外オブジェクトに含まれているメッセージ情報(例外オブジェクトの .Message プロパティ)
    • 例外オブジェクトに含まれるスタックトレース情報(例外オブジェクトの .StackTrace プロパティ)

    これらは、実際の例外ログを見ながら理解するのが早いでしょう。

    [例外情報の基本的な読み取り方]

    というわけで、ひとつ具体例を出したいと思います。今、下図のような Web アプリケーションで、ユーザ名とパスワードを入力し、ログインボタンを押下したところ、例外が発生したとします。(ここでは話をわかりやすくするために、アプリケーションの内部構造や例外が発生した場所を示していますが、実際にはこれらがわからなくても OK です。例外ログを解析すると、アプリケーション内部のモジュール構成や、どこで例外が発生したのかがわかります。)

    image

    今、記録された例外情報が以下のようになっていたとしましょう。

       1: 【例外クラス】
       2: System.Data.SqlClient.SqlException
       3:  
       4: 【メッセージ】
       5: SQL Server は一時停止しています。新たに接続することはできません。ユーザー '(null)' はログインできませんでした。
       6:  
       7: 【スタックトレース】
       8: System.Data.SqlClient.SqlConnection.Open()
       9: System.Data.Common.DbDataAdapter.QuietOpen(IDbConnection connection, ConnectionState& originalState)
      10: System.Data.Common.DbDataAdapter.Fill(Object data, Int32 startRecord, Int32 maxRecords, String srcTable, IDbCommand command, CommandBehavior behavior)
      11: System.Data.Common.DbDataAdapter.Fill(DataSet dataSet, Int32 startRecord, Int32 maxRecords, String srcTable, IDbCommand command, CommandBehavior behavior)
      12: System.Data.Common.DbDataAdapter.Fill(DataSet dataSet)
      13: NETWSSample.CS.BizLogic.Login.DataAccess.LoginDAC.FillUserInfoWithReadCommitted(UserInfoDataSet ds, String userId) in c:\devprojects\netwssample\netwssample.cs.bizlogic\login\dataaccess\logindac.cs:line 108
      14: NETWSSample.CS.BizLogic.Login.BizFacade.LoginBizFacade.CheckPassword(String userId, String plainPassword) in C:\DevProjects\NETWSSample\NETWSSample.CS.BizLogic\Login\BizFacade\LoginBizFacade.cs:line 27
      15: NETWSSample.CS.WebUI.Login.btnLogin_Click(Object sender, EventArgs e) in C:\DevProjects\NETWSSample\NETWSSample.CS.WebUI\Login.aspx.cs:line 74
      16: System.Web.UI.WebControls.Button.OnClick(EventArgs e)
      17: System.Web.UI.WebControls.Button.System.Web.UI.IPostBackEventHandler.RaisePostBackEvent(String eventArgument)
      18: System.Web.UI.Page.RaisePostBackEvent(IPostBackEventHandler sourceControl, String eventArgument)
      19: System.Web.UI.Page.RaisePostBackEvent(NameValueCollection postData)
      20: System.Web.UI.Page.ProcessRequestMain()

    この場合には、以下のようにして例外情報を読み取っていきます。

    Step.1 まず、例外クラスの種類とメッセージ情報を読む。

    まず、例外情報の中の、クラスの種類とメッセージ情報を読み取ってください。これにより、おおよそどのようなトラブルが発生したのかが分かります。

    上記の例の場合には、例外オブジェクトのクラスが System.Data.SqlException になっています。このことから、この例外はデータベースに対する SQL 文処理にかかわる何かしらのトラブルで発生したことが類推されます。また、メッセージ情報は “SQL Server は一時停止しています。新たに接続することはできません。ユーザー '(null)' はログインできませんでした。” となっています。このことから、この例外(障害)は、データベースサーバが一時停止状態になっていて接続できなかったために発生したことが類推されます。このため、この障害の解決方法は、SQL Server の一時停止状態を解除することである、とわかります。

    ……と、この例の場合には例外クラスの種類とメッセージ情報だけで原因がつかめてしまいましたが;、実際の例外ではこれだけでは十分に理由が判断できないこともあります。この場合には、さらに以下のことを行います。

    Step.2 スタックトレース情報を読み取る。

    スタックトレースとは、アプリケーションのメソッドの呼び出しの積み重ねのことを言います。アプリケーションの中では、あるモジュールのメソッドからあるモジュールのメソッドが呼び出され、さらに別のモジュールのメソッドが....を繰り返します。この呼び出しの連鎖関係を表現したものを、スタックトレースといいます。

    スタックトレースは、通常、末尾から読み取っていきます。上記の例で言うと、8 行目の “System.Data.SqlClient.SqlConnection.Open()” から読み取るのではなく、20 行目の “System.Web.UI.Page.ProcessRequestMain()”、その次に 19 行目の “System.Web.UI.Page.RaisePostBackEvent(NameValueCollection postData)”、という順番で読み取っていきます。これらは、

    20 行目のメソッドから 19 行目のメソッドが呼び出され、
    19 行目のメソッドから 18 行目のメソッドが呼び出され、
    18 行目のメソッドから 17 行目のメソッドが呼び出され、
    ….
    2 行目のメソッドから 1 行目のメソッドが呼び出され、
    1 行目のメソッドの中で System.Data.SqlException 例外が発生した。

    という意味を持っています。このため、このメソッドの連鎖関係を追いかけることで、例外が発生した状況をある程度つかむことができる、というわけです。

    とはいえ、すべてのスタックトレースを頑張って読み取るのも大変です。このスタックトレースを読み取る場合にはコツがあって、以下のようにするとラクです。

    • まず、スタックトレースを、以下の 3 つの部位に分解する。
      ① ASP.NET ランタイムやクラスライブラリによる基盤処理部分
      ② 基盤処理部分から呼び出されたユーザアプリケーション部分
      ③ ユーザアプリケーションから呼び出されたクラスライブラリ部分
    • これにより、例外が発生したソースコードの『場所』を読み取る。

    上記のサンプルの場合、20 行目~1 行目の流れをイラスト化すると、次のようになります。どこが①、②、③に対応するのかは、スタックトレースに出てくるクラス名やメソッド名をよく見るとわかります。

    image

    このようなイメージが頭の中に思い浮かべられるようになると、例外が発生したシチュエーションが、アプリケーションコードの中身を見なくてもある程度想像できてしまいます。この例の場合だと、

    • ボタンのクリックイベントからパスワードチェック処理が走り、
    • パスワードチェックロジックの中で、データベースからユーザ情報を呼び出そうとした。
    • このとき、DataAdapter 経由でデータを取り出そうとしたが、内部的にコネクションを開こうとしたところで失敗した。

    というところまでは、アプリケーションのソースコードや内部構造などの情報を事前に持っていなくても推測できてしまうことになるわけです。

    # このため、例外情報はむやみにエンドユーザに見せてはいけません。特に、そこそこ知識を持った開発者が例外情報を読むと、アプリケーション内部構造に関する情報が漏えいすることになり、セキュリティ上のリスクにつながります。

    # ちなみにこのような 3 つの部位への分解については、ASP.NET アプリケーションだけでなく Windows フォームのようなアプリケーションで有用です。参考までに、Windows フォーム上で発生させた例外のサンプルをのせておきますので、この例外ログから、アプリケーションがどんな作りになっていて、どういったシチュエーションで、何が原因で例外が起きたのかを類推してみてください。

       1: 【Messageプロパティ】
       2: ユーザー 'sa' はログインできませんでした。
       3: 【StackTraceプロパティ】
       4:    at System.Data.SqlClient.SqlConnection.Open()
       5:    at VSWSSample701.CS.WinForms.DataAccess.MethodZ() in C:\DevProjects\VSWSSample701\VSWSSample701.CS.WinForms\DataAccess.cs:line 22
       6:    at VSWSSample701.CS.WinForms.Form1.button1_Click(Object sender, EventArgs e) in c:\devprojects\VSWSSample701\VSWSSample701.cs.winforms\form1.cs:line 238
       7:    at System.Windows.Forms.Control.OnClick(EventArgs e)
       8:    at System.Windows.Forms.Button.OnClick(EventArgs e)
       9:    at System.Windows.Forms.Button.OnMouseUp(MouseEventArgs mevent)
      10:    at System.Windows.Forms.Control.WmMouseUp(Message& m, MouseButtons button, Int32 clicks)
      11:    at System.Windows.Forms.Control.WndProc(Message& m)
      12:    at System.Windows.Forms.ButtonBase.WndProc(Message& m)
      13:    at System.Windows.Forms.Button.WndProc(Message& m)
      14:    at System.Windows.Forms.ControlNativeWindow.OnMessage(Message& m)
      15:    at System.Windows.Forms.ControlNativeWindow.WndProc(Message& m)
      16:    at System.Windows.Forms.NativeWindow.Callback(IntPtr hWnd, Int32 msg, IntPtr wparam, IntPtr lparam)

    なお、スタックトレースに関してもう一点注意すべきこととして、誤ったリスロー処理をしない、という点があります。例えば、SqlException 例外を処理する典型的なコードとして、次のような例を考えてみましょう。

       1: public bool InsertAuthors(string au_id, string au_fname, ...) {
       2:   SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;...");
       3:   SqlCommand sqlcmd = 
       4:     new SqlCommand("INSERT INTO authors VALUES ('172-32-1176', 'White', ...)", sqlcon);
       5:   try {
       6:     sqlcon.Open();
       7:     try {
       8:       sqlcmd.ExecuteNonQuery();
       9:     }
      10:     catch (SqlException sqle) {
      11:       if (sqle.Number == 2627) {
      12:         return false;
      13:       }
      14:       else {
      15:         throw;
      16:       }
      17:     }
      18:   }
      19:   finally {
      20:     sqlcon.Close();
      21:   }
      22:   return true;
      23: }

    この例においては、エラー番号 2627 の SqlException 例外(PK 制約違反)以外で発生した例外は、本当は捕捉してはいけなかった例外であるため、throw という命令により catch しなかったことにしています。しかし、この命令を throw sqle; と記述してしまうと、スタックトレースの起点がこの場所になってしまい、一部のスタックトレース情報が失われてしまいます。このため、例外を catch しなかったことにする場合には、必ず throw とのみ記述し、throw sqle; といった具合に書かないようにしてください。

     Step.3 例外ログやトレースログなどとの突合せを行う

    基本的な例外情報の読み方は以上ですが、残念ながら上記の情報だけでは、以下のようなことがわかりません。

    • 誰からのリクエストに対してこの障害が発生したのか?
    • テキストボックスなどから具体的にどんな値が入力されていたのか?

    スタックトレースは、メソッドの呼び出し連鎖に関する情報を保持していますが、そのときにどんなパラメータ値が渡されたのかの情報は保持していません。例外の中には、特定のパラメータ値を渡した場合や、特定の条件下でのみ発生するようなものもあり、こうしたものは、スタックトレース情報だけからでは完全に原因が追跡できない場合もあります。このため、その障害の発生条件をより詳細に掴むために追加の例外ログやトレースログを取得しておき、必要に応じて突合せを行うとよいでしょう。前回のエントリにおいて EMAB のカスタムパブリッシャとして、HTTP 関連情報をかたっぱしから出力する TraceLogFilePublisher というものを開発したのは、この問題を少しでも緩和するためのものです。

    さて、以上が例外情報の基本的な読み方です。ここまでの解説からわかるように、

    • 正しく書かれたアプリケーションコードから出力される例外ログは、スタックトレースなどが適切に出力されるため、有益な情報が多数含まれている。
    • 例外情報は正しく読めるようになると、システム障害のトラブルシュートやアプリケーションバグの修正において、非常に強力な力を発揮する。

    ということになります。まずアプリケーションコードにおいて、try-catch などを不要に書かないようにすること(=適切に処理すること)、そして例外が発生したときに誰かに丸投げするのではなく、その情報を読み解く努力をすることが大切です。特に後者は「訓練」みたいなところがありますので、ぜひ現場経験を積んで、例外ログをさくっと読めるようになってください。

    では引き続き、例外に関する応用的な内容をいくつか解説していきましょう。以降の解説はやや難しいですが、これらを理解すると例外の使い方がより一層うまくなると思います。

    [例外クラスの分類]

    .NET Framework のクラスライブラリの中には、かなりたくさんの例外クラスが定義されています。代表的なものをピックアップしてみましょう。

    • ArgumentException
    • ArgumentNullException
    • InvalidEnumArgumentException
    • InvalidOperationException
    • RemotingException
    • SqlException
    • OracleException
    • ArithmeticException

    と、挙げだすとキリがないのですが、.NET や Java では、こうした大量の例外を 2 つの観点から分類・設計し、使いやすい形で例外クラスを提供しています。

    • ネームスペースによる例外の利用場所・タイミングの分類
      その例外クラスを、いつどこで利用するか?
    • 継承による例外の意味的な分類
      その例外は、意味的にどのような関係になっているか?

    image

    もう少し突っ込んで解説してみましょう。

    ①  ネームスペースによる例外の利用場所・タイミングの分類

    まず、例外クラスは、その例外がスローされる名前空間の内で定義されています。例えば、SqlException 例外は System.Data.SqlClient 名前空間の中で定義されていますが、これは次のような理由によります。

    • この例外は、SQLServer に対するデータベース処理で発生したシステムエラーを表すためのもの。
    • つまり、System.Data.SqlClient 名前空間のクラス(SqlConnection, SqlCommand, SqlDataAdapter など)を利用したときに発生する。

    ※ ただし、極めて汎用性の高い例外については、システム全体で共通的に利用できるよう、System 名前空間内に定義されています。

    さらに、このような名前空間による分類に加えて、例外クラスの名称自体が分かりやすいものになっていますので、これにより、その例外クラスの利用場所やタイミングなどが、名前空間と例外クラス名だけからでもかなり類推できるようになっているわけです。

    そして、例外クラスの分類としてもうひとつ重要なのが、例外クラスの継承関係です。

    ② 継承による例外の意味的な分類

    例外は、その意味的関係が継承関係により表現されています。例えば、上図の例の中の ArgumentException, ArgumentNullException, ArgumentOutOfRangeException の 3  つを取り上げてみましょう。これらは以下のようなシチュエーションで発生します。

    • ArgumentException 例外 : あるメソッドに渡された引数が、本来あってはならない値だった場合に使う例外
    • ArgumentNullException 例外 : null であってはならないパラメータに対して null が与えられた場合に使う例外
    • ArgumentOutOfRangeException 例外 : インデックスなどの引数に範囲外の値が与えられた場合に使う例外(例えば要素数 4 の int[] 配列 a に対して、a[10] などと記述した場合)

    さて、この 3 つの例外の関係を考えてみると、下の 2 つの例外は、ArgumentException 例外のシチュエーションを、より細かく分類・説明したものであるといえます。このような場合には、これらの例外クラスの間に継承関係を持たせるようにします。(ArgumentNullException 例外や ArgumentOutOfRangeException 例外を、ArgumentException 例外の派生クラスとして設計・実装する)

    例外クラスにこのような継承関係を持たせることのメリットは、例外クラスが継承関係を持っていると、呼び出し元が例外の捕捉粒度を自由に決めることができるようになることです。もちろん .NET の場合、呼び出し元で例外を捕捉すること自体が少ないのですが(詳細は Part. 1 を参照)、仮に例外をハンドリングするのであれば、以下のように、捕捉粒度を変えることができます。

    ■ 引数不正を確認するようなサンプルプログラム
    ※ 本来はパラメータについての事前チェックを行ってから当該メソッドを呼び出すべきなので、以下のようなコードは書くべきではありません。ですが、ここではサンプルとして示します。

    image

    例1. 引数が null であるためにエラーが発生した場合には特別なメッセージを出したい場合

    この場合には、catch 文を分けて記述します。

       1: try {
       2:     objBizFacade.MethodInvoke(strParam1, intParam2); // メソッド呼び出しを行う
       3: }
       4: catch (ArgumentNullException ane) {
       5:     Console.WriteLine("引数として null が渡されました。");
       6: }
       7: catch (ArgumentException ae) {
       8:     Console.WriteLine("不正な引数が渡されました。");
       9: }

    例2. 引数が null である場合を特に区別する必要がない場合

    この場合には、catch 文を分ける必要がありません。なぜなら、ArgumentNullException は ArgumentException 例外の派生クラスなので、ArgumentException を catch すれば、ArgumentNullException もまとめて捕捉できてしまうからです。

       1: try {
       2:     objBizFacade.MethodInvoke(strParam1, intParam2); // メソッド呼び出しを行う
       3: }
       4: catch (ArgumentException ae) { // ArgumentNullException は ArgumentException でもある
       5:     Console.WriteLine("不正な引数が渡されました。");
       6: }

    このため、アプリケーション/システムエラーケースを体系化できる場合には、継承関係を用いて例外クラスを設計・実装するとよい、ということになります。

    しかし、上記のような例外の継承関係は、同時に危険性もはらんでいます。なぜなら、すべての例外クラスを Exception 例外で捕捉できてしまうからです。Part. 1 でも解説しましたが、以下のように Exception 例外を捕捉するようなコードは絶対に書いてはいけません。なぜなら、このようなコードを書いてしまうと、発生するすべての例外が捕捉されてしまうため、本来捕捉してはいけない例外をも捕捉してしまう危険性が生じるからです。

       1: public bool InsertAuthors(string au_id, string au_fname, ...) {
       2:   SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;...");
       3:   SqlCommand sqlcmd = new SqlCommand("INSERT INTO authors VALUES ('172-32-1176', 'White', ...)", sqlcon);
       4:   try 
       5:   {
       6:     sqlcon.Open();
       7:     sqlcmd.ExecuteNonQuery();
       8:   }
       9:   catch (Exception e) // このようなコードは絶対に書いてはいけない!
      10:   {
      11:     return false;
      12:   }
      13:   finally 
      14:   {
      15:     sqlcon.Close();
      16:   }
      17:   return true;
      18: }

    ※ もちろん、リソース解放のための try-finally などではすべての例外に対する処理が必要になるため、このような場合には catch (Exception) とすることもあるのですが、アプリケーションエラーを業務エラーに変換するような場合の try-catch 記述の場合には、Exception クラスを捕捉してはいけません

    いずれにしても上記のように、例外クラスは以下の 2 つの観点から分類・整理されていることを理解しておきましょう。

    • ネームスペースによる例外の利用場所・タイミングの分類
    • 継承による例外の意味的な分類

    次に、例外オブジェクトが内部に含んでいるメッセージ情報について解説します。

    [例外オブジェクトのメッセージ情報の使われ方]

    一般に、すべての例外オブジェクトはメッセージ情報を含んでいます。このメッセージ情報には、障害解析やバグ解析に有益な情報が多数含まれていますが、このメッセージ情報をエンドユーザに対して見せてはいけません。これは、セキュリティ上の理由もありますが、もうひとつ、そもそも発生した例外をどのようにエンドユーザに通知したり見せたりするのかは、UI が個別に決定すべきことだからです。

    image

    例外オブジェクトのメッセージ情報は、多くの場合、集約例外ハンドラによってイベントログなどに記録され、その後の障害解析に利用されます。そしてこのメッセージ情報は障害解析を行う場合に最初の手がかりになるので、自分で例外オブジェクトを throw する場合(典型的には自爆処理により例外を throw する場合)には、障害解析を行う際に有用となると思われる情報を含めておくようにしましょう。例えば、Part.1 でも採り上げた、新規顧客登録業務における BC 部の実装を考えてみましょう。

    image

       1: public RegistCustomerResult ResistCustomer(string id, string name, string mail, 
       2:                         DateTime birthday)
       3: {
       4:     if (Regex.IsMatch(id, "^[A-Z]{2}[0-9]{4}$") == false) throw new ArgumentException("id");
       5:     if (Regex.IsMatch(name, "^[\u0020-\u007e]{1,20}$" == false) throw new ArgumentException("name");
       6:     if (birthday >= DateTime.Now) throw new ArgumentException("birthday");
       7:  
       8:     // テーブルアダプタを利用
       9:     CustomersTableAdapter ta = new CustomersTableAdapter();
      10:     try
      11:     {
      12:         // INSERT 命令を実施
      13:         int affectedRows = ta.InsertCustomer(id, name, mail, birthday);
      14:         if (affectedRows != 1) throw new ApplicationException("INSERT 処理に失敗しました。");
      15:     }
      16:     catch (SqlException sqle)
      17:     {
      18:         if (sqle.Number == 2627) {
      19:             return RegistCustomerResult.DuplicateCustomerIDError;
      20:         }
      21:         else {
      22:             throw;
      23:         }
      24:     }
      25:     return RegistCustomerResult.Success;
      26: }
      27:  

    もちろん上記のような実装でも問題がないのですが、できることなら以下のような実装にしておくべきです

    ■ 変更前
     
    if (Regex.IsMatch(id, "^[A-Z]{2}[0-9]{4}$") == false) throw new ArgumentException("id");
    if (Regex.IsMatch(name, "^[\u0020-\u007e]{1,20}$" == false) throw new ArgumentException("name");
    if (birthday >= DateTime.Now) throw new ArgumentException("birthday");
    if (affectedRows != 1) throw new ApplicationException("INSERT 処理に失敗しました。");
     
    ■ 変更後
     
    if (Regex.IsMatch(id, "^[A-Z]{2}[0-9]{4}$") == false) throw new ArgumentException("id : " + id);
    if (Regex.IsMatch(name, "^[\u0020-\u007e]{1,20}$" == false) throw new ArgumentException("name " + name);
    if (birthday >= DateTime.Now) throw new ArgumentException("birthday " + birthday.ToString());
    if (affectedRows != 1) throw new ApplicationException("INSERT 処理に失敗しました。" + affectedRows.ToString());
     

    このように、例外オブジェクトのメッセージ情報の中に、「そのときに問題となったパラメータや実際の値や状況」に関する情報を含めておくと、例外ログのメッセージ情報を見ただけで原因が推測しやすくなることが多々あります

    もちろん、こうした例外というのは通常の利用時には発生しないもの(=システムエラーなどの障害発生時、クラッキングを受けた場合、アプリケーションバグがあった場合などにしか発生しない)なので、こうした実装は「転ばぬ先の杖」とでも呼ぶべきものです。ですが、後から「困った!なぜこの例外が発生したのかさっっっぱりわからん!」という素敵な状況にならないようにするためにも(往々にしてよくあるのですがorz)、こうした実装をしておくことを心がけるようにしましょう。

    なお、例外を自力で throw して自爆する場合に知っておくとよい Tips をいくつか書いておきます。

    ① throw する例外クラスの選択にはあまり凝らなくておっけー。

    前述したように、例外クラスには名前空間と継承による体系的な分類があるため、throw する例外クラスを適切に選択すると、呼び出し元で捕捉粒度を変えたりすることができます。がしかし、.NET ではそもそも呼び出し元で例外を catch すること自体が基本的にはないので、逆にいえば、catch されることを想定して例外クラスを細かく選定する必要はない、ということになります。具体的には以下のようなことがいえます。

    • アプリケーションから例外を throw する場合には、Exception クラスや ApplicationException クラスの派生クラスを自力で定義し、これを throw しなければならない、と書かれていることがありますが、どうせ誰も捕捉しない(集約例外ハンドラが処理する)ので、ぶっちゃけ不要です。
    • 自爆目的であれば、基本的には ApplicationException 例外をそのまま使ってこれを throw すれば十分。(Exception 例外を直接 throw すると他の例外との区別がつかなくなるので、これはさすがにやめましょう。)
    • 引数不正による例外や、権限不足によるセキュリティ例外などに関しても、ApplicationException 例外を throw して自爆してもらって全く問題ありません。が、これらについては System 名前空間に ArgumentException 例外や SecurityException 例外が用意されているので、これぐらいは使ってあげてもよいでしょう。というわけで、上記のサンプルではこれらの例外を使っていますが、ApplicationException 例外を throw してもぜんぜん問題ないです。

    なお、上記が当てはまるのは、業務アプリケーションの UI, BC, DAC などを実装する場合だけです。.NET Framework のクラスライブラリ、あるいは業務共通コンポーネントなどのように、汎用的に利用されるライブラリを設計する場合には、モジュール内から発生させた例外を、呼び出し元側で try-catch することがしばしばあります。このような場合には、throw する例外クラスの選択にきちんと留意する必要があります。(=throw する例外クラスのチョイスに気を払わなくてよいのは、業務アプリでは基本的には例外を try-catch しないから、なのです。)

    ② メッセージ情報を丁寧に書く必要はない。

    また、例外を throw して自爆するコードを書く際には、メッセージ情報を丁寧に書く必要はありません。例えば下記の例を見てください。

    ■ 悪い例
    if (Regex.IsMatch(id, "^[A-Z]{2}[0-9]{4}$") == false) 
        throw new ArgumentException("引数として渡された id パラメータのフォーマットが不正です。期待される id のフォーマットは、^[A-Z]{2}[0-9]{4}$ ですが、このフォーマットに合致していません。具体的に渡された値は " + id + "です。");
     
    ■ 良い例
    if (Regex.IsMatch(id, "^[A-Z]{2}[0-9]{4}$") == false) 
        throw new ArgumentException("id : " + id);

    上の例の場合、もしこの行にヒットして例外が発生すると、例外ログには非常にわかりやすいエラーログが出ることになります。がしかし、以下のような理由によりこれはやりすぎです。

    • 例外ログは、エンドユーザが見るものではなく、障害発生時にバックエンドの人たちが見るもの。つまり、バックエンドの人たちがわかるようなものであれば十分。多くの場合、例外ログのメッセージ情報は、主にアプリケーション開発担当者がデバッグや障害解析のために見るものなので、開発者がわかる情報であれば十分
    • そもそもこの例外が発生すること自体、普通はない。システム障害やアプリケーションクラックなどでしか発生しない「予防措置」的なコードは必須だが、その実装作業にはあまりお金をかけられない。ということは、極力簡単な実装で済ませる必要がある

    このケースの場合、絶対に必須であるメッセージ情報としては、

    • どのパラメータがまずかったのか?
    • 実際にどんな値だったのか?

    の 2 つだけです(ちなみに 1 点目については、例外が発生した行番号がわかればそこからどのパラメータのエラーだったのか解析することもできるのですが、さすがに面倒なのでメッセージ情報に入れた方がラクです)。よって、必要最低限の情報であるこの二つの情報のみをメッセージに含めるため、サンプルコードの下側のような実装を行うわけです。

    ちなみにこのルールが当てはまるのも、業務アプリケーションの UI, BC, DAC などを実装する場合だけです。.NET Framework のクラスライブラリ、あるいは業務共通コンポーネントなどのように、汎用的に利用されるライブラリを設計する場合には、上記の「悪い例」のような実装を取らなければなりません。なぜなら、この例外ログメッセージを見るのは、業務共通コンポーネントの開発者ではなく、業務アプリケーションの開発者だからです。業務アプリケーションの開発者が、(内部構造やコードがわからない)業務共通コンポーネントやクラスライブラリから発生した例外ログを見た際に何がトラブルの原因であるのかを正しく把握するためには、業務共通コンポーネントやクラスライブラリから発生した例外ログに詳細な情報が記載されている必要があります。このために、クラスライブラリや業務共通コンポーネント内で自爆処理を行う場合には、詳細でわかりやすいメッセージ情報を含めておく必要があります。(※ この場合であっても、このメッセージ情報を見るのはエンドユーザではなく、そのライブラリを使う開発者です。)

    ③ null に注意!

    自爆コードを記述する場合に気をつけなければならないのが二重障害です。例えば下のコードを見てください。

       1: if (Regex.IsMatch(id, "^[A-Z]{2}[0-9]{4}$") == false) 
       2:     throw new ArgumentException("id : " + id.Length.ToString());

    この自爆コードにおいて、id パラメータが null だった場合には、throw new ArgumentException() の処理の中でさらに NullReferenceException 例外が発生してしまうことになります。このようなトラブルを一般的に二重障害と呼ぶのですが、二重障害が発生すると、本来のトラブルがなんだったのかが非常にわかりにくくなります。よって、アプリを組む場合には二重障害を起こさないように注意しながらプログラミングする必要があります。上記のような null を考慮せずに組んだコードは、最も典型的な、二重障害リスクのあるコードとなりますので、十分に注意するようにしてください。

    以上でメッセージ情報の使い方に関する説明はおしまいです。次に、例外オブジェクトのネストについて解説しましょう。

    [例外オブジェクトのネスト]

    .NET や Java の例外オブジェクトには、例外のネストと呼ばれる機能がついており、ある例外オブジェクトが別の例外オブジェクトを内包することができるようになっています。具体的には、すべての例外クラスには .InnerException というプロパティがあり、ここに別の例外オブジェクトを格納することができるようになっいます。

    image

    例外オブジェクトのネストがどのような場合に使われるのかを示すために、文脈による例外オブジェクトのリスローについて解説します。下図の例を見てください。

    image

    ASP.NET Web アプリケーションでは、 一般に、ASP.NET ランタイムがまず動作しており、その基盤から我々が記述したユーザアプリケーションが呼び出され、実行されます。このとき、アプリケーションの中で何らかの例外(例えば DivideByZeoException 例外、ゼロ除算例外)が発生したとします。最終的には、発生した例外は、ASP.NET ランタイムの一番の親玉である HttpApplication オブジェクトまで伝えられ、そこに仕掛けられた集約例外ハンドラが動作するのですが、問題なのは、

    • HttpApplication オブジェクトが DivideByZeroException 例外を受け取ったとき、それがユーザアプリケーション内で発生した DivideByZeroException 例外なのか、ASP.NET ランタイム内で発生した DivideByZeroException 例外なのか、判断がつきにくい。

    という点です。いや、もちろん例外オブジェクトの中に含まれているスタックトレース情報を頑張って解析すればわからなくもないのですが、これは結構大変です。この問題を解決するため、ASP.NET ランタイムは内部で以下のようなことを行っています。

    • ユーザアプリケーションの処理を行っている最中に、ユーザアプリケーションから未処理例外が通知されたら、これを別の例外(HttpUnhandledException 例外)に取り換えて、上位モジュールに通知する。これにより、上位モジュールである HttpApplication クラスの方では、例外オブジェクトのクラスを見ることで、ユーザアプリケーションで発生した例外なのか、ASP.NET ランタイム内で生じたランタイム障害なのかを区別できるようにしている。
    • しかし、ユーザアプリケーション内部で発生した例外がなんだったのかがわからなくなると困る。このために、HttpUnhandledException 例外オブジェクトの中に、実際に発生した例外オブジェクトをネストさせて保持させ、アプリで発生した元の例外情報が失われないようにしている

    ASP.NET ランタイム内部のコードは次のようになっています。(※ 実際の ASP.NET ランタイム内部のコードはもっと複雑なのですが、わかりやすくイメージとして伝えると、次のようなコードが書かれています。)

       1: public class Page
       2: {
       3:   public void ProcessRequestMain()
       4:   {
       5:      try
       6:      {
       7:         // ... 実際にページの処理を行うコード
       8:         // (Page の Init() 処理やイベント処理などを順次進める)
       9:      }
      10:      catch (Exception e)
      11:      {
      12:          throw new System.Web.HttpUnhandledException("Web アプリケーション内で未処理例外が発生しました。", e);
      13:      }
      14:   }
      15: }

    このコードの中の肝となる部分は、以下のコードです。

    • throw new System.Web.HttpUnhandledException("Web アプリケーション内で未処理例外が発生しました。", e);

    下線部に着目してください。これは、HttpUnhandledException 例外オブジェクトの中に、Web アプリケーション内部で発生した例外オブジェクトをラッピングして持たせておく、というものです。これにより、ASP.NET アプリケーションは元の例外情報を失うことなく、例外情報をうまく上位モジュールに伝えていくことができるようになっています。

    このために、ASP.NET ランタイムの集約例外ハンドラで例外を記録した場合には、このネスト例外の情報が記録されていることがしばしばあります。例えば、例外ログに次のような情報が記録されていることがあります。

       1: System.Web.HttpUnhandledException: 種類 System.Web.HttpUnhandledException の例外がスローされました。 
       2: ---> System.DivideByZeroException: 0 で除算しようとしました。
       3:  
       4:    at WebApplication1.WebForm1.Button1_Click(Object sender, EventArgs e) in c:\inetpub\wwwroot\webapplication1\webform1.aspx.cs:line 52
       5:    at System.Web.UI.WebControls.Button.OnClick(EventArgs e)
       6:    at System.Web.UI.WebControls.Button.System.Web.UI.IPostBackEventHandler.RaisePostBackEvent(String eventArgument)
       7:    at System.Web.UI.Page.RaisePostBackEvent(IPostBackEventHandler sourceControl, String eventArgument)
       8:    at System.Web.UI.Page.RaisePostBackEvent(NameValueCollection postData)
       9:    at System.Web.UI.Page.ProcessRequestMain()
      10:    --- 内部例外スタック トレースの終わり ---
      11:    at System.Web.UI.Page.HandleError(Exception e)
      12:    at System.Web.UI.Page.ProcessRequestMain()
      13:    at System.Web.UI.Page.ProcessRequest()
      14:    at System.Web.UI.Page.ProcessRequest(HttpContext context)
      15:    at System.Web.CallHandlerExecutionStep.Execute()
      16:    at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously).
      17:  

    この例外ログでは、

    • HttpUnhandledException 例外の中に、(1 行目)
      • DivideByZeroException 例外が含まれていて、(2 行目)
          • DivideByZeroException 例外の中のスタックトレース情報がまず出ていて(3行目~9行目)
      • HttpUnhandledException 例外のスタックトレース情報が出ている(11行目~16 行目)

    という形で情報が出ていることがわかります。

    ネスト例外がどのような形でイベントログに出力されるのかは、集約例外ハンドラでの例外ログの記録方法次第なのですが、Part.3 で紹介した EMAB (Exception Management Application Block)を利用して例外ログを出力した場合には、もっと読みやすい形でネスト例外ログを出力してくれます。こんな感じ。

       1:  
       2: General Information 
       3: *********************************************
       4: Additional Info:
       5: ExceptionManager.MachineName: NAKAMA07
       6: ExceptionManager.TimeStamp: 2009/01/18 18:48:48
       7: ExceptionManager.FullName: Microsoft.ApplicationBlocks.ExceptionManagement, Version=1.0.3305.33708, Culture=neutral, PublicKeyToken=null
       8: ExceptionManager.AppDomainName: 670e817-12-128767457240733673
       9: ExceptionManager.ThreadIdentity: FAREAST\nakama
      10: ExceptionManager.WindowsIdentity: FAREAST\nakama
      11:  
      12: 1) Exception Information
      13: *********************************************
      14: Exception Type: System.Web.HttpUnhandledException
      15: ErrorCode: -2147467259
      16: Message: 種類 'System.Web.HttpUnhandledException' の例外がスローされました。
      17: Data: System.Collections.ListDictionaryInternal
      18: TargetSite: Boolean HandleError(System.Exception)
      19: HelpLink: NULL
      20: Source: System.Web
      21:  
      22: StackTrace Information
      23: *********************************************
      24:    場所 System.Web.UI.Page.HandleError(Exception e)
      25:    場所 System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)
      26:    場所 System.Web.UI.Page.ProcessRequest(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)
      27:    場所 System.Web.UI.Page.ProcessRequest()
      28:    場所 System.Web.UI.Page.ProcessRequestWithNoAssert(HttpContext context)
      29:    場所 System.Web.UI.Page.ProcessRequest(HttpContext context)
      30:    場所 ASP.default_aspx.ProcessRequest(HttpContext context)
      31:    場所 System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()
      32:    場所 System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)
      33:  
      34: 2) Exception Information
      35: *********************************************
      36: Exception Type: System.Data.SqlClient.SqlException
      37: Errors: System.Data.SqlClient.SqlErrorCollection
      38: Class: 14
      39: LineNumber: 65536
      40: Number: 15350
      41: Procedure: 
      42: Server: \\.\pipe\CA14CFCB-F631-49\tsql\query
      43: State: 1
      44: Source: .Net SqlClient Data Provider
      45: ErrorCode: -2146232060
      46: Message: ファイル C:\Users\nakama\Documents\Visual Studio 2008\Projects\WebSite1\WebSite1\App_Data\pubs.mdf の自動的に名前が付けられたデータベースをアタッチできませんでした。同じ名前のデータベースが既に存在するか、指定されたファイルを開けないか、UNC 共有に配置されています。
      47: Data: System.Collections.ListDictionaryInternal
      48: TargetSite: Void OnError(System.Data.SqlClient.SqlException, Boolean)
      49: HelpLink: NULL
      50:  
      51: StackTrace Information
      52: *********************************************
      53:    場所 System.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection)
      54:    場所 System.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj)
      55:    場所 System.Data.SqlClient.TdsParser.Run(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj)
      56:    場所 System.Data.SqlClient.SqlInternalConnectionTds.CompleteLogin(Boolean enlistOK)
      57:    場所 System.Data.SqlClient.SqlInternalConnectionTds.AttemptOneLogin(ServerInfo serverInfo, String newPassword, Boolean ignoreSniOpenTimeout, Int64 timerExpire, SqlConnection owningObject)
      58:    場所 System.Data.SqlClient.SqlInternalConnectionTds.LoginNoFailover(String host, String newPassword, Boolean redirectedUserInstance, SqlConnection owningObject, SqlConnectionString connectionOptions, Int64 timerStart)
      59:    場所 System.Data.SqlClient.SqlInternalConnectionTds.OpenLoginEnlist(SqlConnection owningObject, SqlConnectionString connectionOptions, String newPassword, Boolean redirectedUserInstance)
      60:    場所 System.Data.SqlClient.SqlInternalConnectionTds..ctor(DbConnectionPoolIdentity identity, SqlConnectionString connectionOptions, Object providerInfo, String newPassword, SqlConnection owningObject, Boolean redirectedUserInstance)
      61:    場所 System.Data.SqlClient.SqlConnectionFactory.CreateConnection(DbConnectionOptions options, Object poolGroupProviderInfo, DbConnectionPool pool, DbConnection owningConnection)
      62:    場所 System.Data.ProviderBase.DbConnectionFactory.CreatePooledConnection(DbConnection owningConnection, DbConnectionPool pool, DbConnectionOptions options)
      63:    場所 System.Data.ProviderBase.DbConnectionPool.CreateObject(DbConnection owningObject)
      64:    場所 System.Data.ProviderBase.DbConnectionPool.UserCreateRequest(DbConnection owningObject)
      65:    場所 System.Data.ProviderBase.DbConnectionPool.GetConnection(DbConnection owningObject)
      66:    場所 System.Data.ProviderBase.DbConnectionFactory.GetConnection(DbConnection owningConnection)
      67:    場所 System.Data.ProviderBase.DbConnectionClosed.OpenConnection(DbConnection outerConnection, DbConnectionFactory connectionFactory)
      68:    場所 System.Data.SqlClient.SqlConnection.Open()
      69:    場所 PubsDataSetTableAdapters.QueriesTableAdapter.CountAuthorsByState(String state)
      70:    場所 _Default.Button1_Click(Object sender, EventArgs e)
      71:    場所 System.Web.UI.WebControls.Button.OnClick(EventArgs e)
      72:    場所 System.Web.UI.WebControls.Button.RaisePostBackEvent(String eventArgument)
      73:    場所 System.Web.UI.WebControls.Button.System.Web.UI.IPostBackEventHandler.RaisePostBackEvent(String eventArgument)
      74:    場所 System.Web.UI.Page.RaisePostBackEvent(IPostBackEventHandler sourceControl, String eventArgument)
      75:    場所 System.Web.UI.Page.RaisePostBackEvent(NameValueCollection postData)
      76:    場所 System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)

    この場合には、12 行目~32 行目が外側の例外オブジェクト(HttpUnhandledException 例外)、34 行目~76 行目が内側の例外オブジェクト(SqlException 例外)になります。例外ログ記録までの全体の処理の流れは以下のようになります。

    image

    このように、場合によっては例外がネストされた形で報告されることもありますが、ネストされていたとしても正しく例外情報を読めるようになることが重要です。慌てず騒がず、例外ログ情報をまったり読むようにしましょう。

    [例外オブジェクトのカスタムプロパティ]

    さて最後に、例外オブジェクトのカスタムプロパティについて解説したいと思います。ここまで、例外情報のロギングには EMAB などの部品を使うといいですよ、という解説をしてきたのですが、その理由は単に様々な情報を自動的に追加で記録してくれる、というだけではありません。特に重要ポイントとして、例外オブジェクトのカスタムプロパティのデータを自動的に収集してくれる、という大きなメリットがあります。

    これを理解していただくために、SqlException 例外を例に取り上げてみます。SqlException クラスには、Message や StackTrace, InnerException という一般的なプロパティに加えて、SQL Server から通知される様々なエラー情報を格納するためのプロパティを多数持っています。

    image

    ここで問題になるのが、例外クラスの持つ .ToString() メソッドです。実は、例外オブジェクトの持っている .ToString() メソッドを呼び出しても、内部に持っているすべてのデータを文字列化してくれないことがしばしばあります。

    例えば、集約例外ハンドラとして以下のようなコードを書いたとします。

       1: void Application_Error(object sender, EventArgs e) {
       2:     Exception ex = Server.GetLastError(); // 未処理例外を取り出してイベントログに出力
       3:     string errMsg = String.Format("エラーメッセージ\n{0}\n\nスタックトレース\n{1}\n", 
       4:                         ex.Message, ex.StackTrace);
       5:     EventLog.WriteEntry("Application", errMsg, EventLogEntryType.Error);
       6: }

    この場合、例外ログにはエラーメッセージ情報やスタックトレース情報は記録されますが、エラー番号(Number プロパティ)や重大度レベル(Class プロパティ)といった、SQL Server が通知してくれる重要な情報がきれいに抜け落ちてしまいます

       1: System.Data.SqlClient.SqlException: DELETE ステートメントは REFERENCE 制約 "FK__titleauth__au_id__0AD2A005" と競合しています。競合が発生したのは、データベース "6C5868E0536A79C8F7BFC6FFAB648A06_OCUMENTS\VISUAL STUDIO 2005\PROJECTS\CONSOLEAPPLICATION1\CONSOLEAPPLICATION1\BIN\DEBUG\PUBS.MDF"、テーブル "dbo.titleauthor", column 'au_id' です。
       2: ステートメントは終了されました。
       3:  
       4:    場所 System.Data.SqlClient.SqlConnection.OnError(SqlException exception, Boolean breakConnection)
       5:    場所 System.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection)
       6:    場所 System.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj)
       7:    場所 System.Data.SqlClient.TdsParser.Run(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj)
       8:    場所 System.Data.SqlClient.SqlCommand.FinishExecuteReader(SqlDataReader ds, RunBehavior runBehavior, String resetOptionsString)
       9:    場所 System.Data.SqlClient.SqlCommand.RunExecuteReaderTds(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, Boolean async)
      10:    場所 System.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, String method, DbAsyncResult result)
      11:    場所 System.Data.SqlClient.SqlCommand.InternalExecuteNonQuery(DbAsyncResult result, String methodName, Boolean sendToPipe)
      12:    場所 System.Data.SqlClient.SqlCommand.ExecuteNonQuery()
      13:    場所 ConsoleApplication1.AuthorsDataSetTableAdapters.authorsTableAdapter.DeleteByAuId(String au_id) 場所 C:\Documents and Settings\nakama\My Documents\Visual Studio 2005\Projects\ConsoleApplication1\ConsoleApplication1\AuthorsDataSet.Designer.cs:行 1263
      14:    場所 ConsoleApplication1.Program.DeleteAuthor(String au_id) 場所 C:\Documents and Settings\nakama\My Documents\Visual Studio 2005\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:行 38
      15:    場所 ConsoleApplication1.Program.Main(String[] args) 場所 C:\Documents and Settings\nakama\My Documents\Visual Studio 2005\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:行 16

    この例では、幸いにしてメッセージだけでエラー情報が解析できましたが、Number プロパティなどが欠けていると十分な障害解析ができないわけです。ところが EMAB を使った場合には、こうしたカスタムプロパティを自動的に解析し、これらの情報もロギングしてくれます。同じ例外を EMAB でロギングした場合には、以下のようになります。

       1: General Information 
       2: *********************************************
       3: Additional Info:
       4: ExceptionManager.MachineName: NAKAMA26
       5: ExceptionManager.TimeStamp: 2005/11/28 17:56:19
       6: ExceptionManager.FullName: Microsoft.ApplicationBlocks.ExceptionManagement, Version=1.0.1815.31615, Culture=neutral, PublicKeyToken=null
       7: ExceptionManager.AppDomainName: ConsoleApplication1.exe
       8: ExceptionManager.ThreadIdentity: 
       9: ExceptionManager.WindowsIdentity: FAREAST\nakama
      10:  
      11: 1) Exception Information
      12: *********************************************
      13: Exception Type: System.Data.SqlClient.SqlException
      14: Errors: System.Data.SqlClient.SqlErrorCollection
      15: Class: 16
      16: LineNumber: 1
      17: Number: 547
      18: Procedure: 
      19: Server: \\.\pipe\2A2A18A2-5F95-46\tsql\query
      20: State: 0
      21: Source: .Net SqlClient Data Provider
      22: ErrorCode: -2146232060
      23: Message: DELETE ステートメントは REFERENCE 制約 "FK__titleauth__au_id__0AD2A005" と競合しています。競合が発生したのは、データベース "6C5868E0536A79C8F7BFC6FFAB648A06_OCUMENTS\VISUAL STUDIO 2005\PROJECTS\CONSOLEAPPLICATION1\CONSOLEAPPLICATION1\BIN\DEBUG\PUBS.MDF"、テーブル "dbo.titleauthor", column 'au_id' です。
      24: ステートメントは終了されました。
      25: Data: System.Collections.ListDictionaryInternal
      26: TargetSite: Void OnError(System.Data.SqlClient.SqlException, Boolean)
      27: HelpLink: NULL
      28:  
      29: StackTrace Information
      30: *********************************************
      31:    場所 System.Data.SqlClient.SqlConnection.OnError(SqlException exception, Boolean breakConnection)
      32:    場所 System.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection)
      33:    場所 System.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj)
      34:    場所 System.Data.SqlClient.TdsParser.Run(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj)
      35:    場所 System.Data.SqlClient.SqlCommand.FinishExecuteReader(SqlDataReader ds, RunBehavior runBehavior, String resetOptionsString)
      36:    場所 System.Data.SqlClient.SqlCommand.RunExecuteReaderTds(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, Boolean async)
      37:    場所 System.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, String method, DbAsyncResult result)
      38:    場所 System.Data.SqlClient.SqlCommand.InternalExecuteNonQuery(DbAsyncResult result, String methodName, Boolean sendToPipe)
      39:    場所 System.Data.SqlClient.SqlCommand.ExecuteNonQuery()
      40:    場所 ConsoleApplication1.AuthorsDataSetTableAdapters.authorsTableAdapter.DeleteByAuId(String au_id) 場所 C:\Documents and Settings\nakama\My Documents\Visual Studio 2005\Projects\ConsoleApplication1\ConsoleApplication1\AuthorsDataSet.Designer.cs:行 1263
      41:    場所 ConsoleApplication1.Program.DeleteAuthor(String au_id) 場所 C:\Documents and Settings\nakama\My Documents\Visual Studio 2005\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:行 38
      42:    場所 ConsoleApplication1.Program.Main(String[] args) 場所 C:\Documents and Settings\nakama\My Documents\Visual Studio 2005\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:行 16

    エラー情報の中の、14~22 行目に着目してください。これらの情報は、SqlException 例外オブジェクトが持っている非常に重要な情報ですが、EMAB であればこれらについてもきちんとロギングしてくれる、というわけです。

    こうした、例外オブジェクトのカスタムプロパティが持っている情報というのは障害解析に非常に有益な情報が多く、これらが適切にロギングされていると障害解析はかなりラクになります。こうした観点からも、ぜひ EMAB などの例外出力モジュールを活用するようにしてください。

    [まとめ]

    というわけで、全 4 回+αにわたって .NET の例外処理についていろんな解説をしてきましたが(All About .NET 例外とか名前つけたい....(笑))、全体を振り返って要点をまとめると、以下のようになります。

    • まず、アプリケーションコードとして正しい例外処理コードを書くことが重要。
      そのためには、業務エラーとアプリケーション/システムエラーをきちんと区別し、例外をアプリケーション/システムエラーのときだけに使うようにする必要がある。
    • むやみに try-catch や throw コードを書かない。
      業務フローチャートを意識し、必要な場所に絞ってこれらのコードを書く。基本は「書かない」。
    • 集約例外ハンドラを使って、正しく例外ログを取る。
      EMAB などの部品を使ってログを出力するようにすると便利。
    • 出力された例外ログを正しく読めるようになることが重要。
      例外オブジェクトに含まれる情報のうち、特に例外クラスそのもの、メッセージ情報、スタックトレース情報の 3 つが重要。これらを正しく読めるようになるだけで、障害解析やアプリケーションデバッグは遥かに容易になる。

    .NET や Java のアプリケーションにおいて、障害解析を困難にしている代表的な問題の一つが、正しく例外が取り扱われていないことなのですが、例外を正しく使えるようになることは、アプリケーション開発者にとって避けて通れないキーポイントです。ぜひここまでの 4 回のエントリを活用して、正しい例外処理コードを記述するようにしてください。

    Postedby nakama | 0 Comments    
    Filed under:
    .NETの例外処理 Part. 3
    18 January 09 07:29 PM

    さて、前回までの解説で、.NETアプリケーションにおける一般的な例外の取り扱い型の基本を学習してきました。キーポイントをまとめると、以下の通りです。

    • 例外は、アプリケーションエラーやシステムエラーの場合に限り、利用する。
    • 例外は、基本的にユーザアプリケーション内ではtry-catchをせず、ランタイムの持つ集約例外ハンドラの機能によって後処理する。

    というわけで今回解説をするのは、ASP.NET ランタイムが持っている集約例外ハンドラの機能についてです。(基本的にはこれを学習すれば、Windows フォームなどの他のランタイムでもどのように考えればよいのかがわかると思います。)

    • 例外発生時に行うべきことは何か?-開発時と運用時
    • デフォルトエラーページの差し替え機能
    • 集約例外ハンドラによる例外ログ出力
    • XML Webサービスの場合(SOAP拡張機能による集約例外ハンドラの組み込み)
    • WCFの場合(IErrorHandlerインタフェースによる集約例外ハンドラの組み込み)
    • Exception Management Application Block
    • カスタムパブリッシャの利用

    なお、以下の説明を最後まで作業した場合のサンプルを .zip 化しておきましたので、併せてお使いください。

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

    [例外発生時に行うべきことは何か?-開発時と運用時]

    さて、ここまで説明してきたように、例外とはアプリケーションエラーやシステムエラーといった、業務上の想定外の事象が起きたときに発生するものでした。代表的なアプリケーションエラーやシステムエラーには、

    • データベースサーバとうまく接続できなかった。
    • メモリ不足が発生した。
    • バックエンドのシステム連携がうまくいかなかった。
    • アプリケーションが想定外の状況に陥ったので、自ら例外を発生させて自爆した。

    などがありますが、こうした事象が発生した場合、例外の詳細な情報をエンドユーザに見せても(対処のしようがないので)意味がありません。このため、運用環境においては、エンドユーザに対して以下のような「穏やかな」エラー通知画面を表示することが望ましいといえるでしょう。

    image

    しかしながら、以下のようなことも考慮しなければなりません。

    • 開発時の場合
      例外の多くは、アプリケーションコードのバグが原因で発生しています。このため、発生した例外の中身の情報をうまく活用して、すぐさまデバッグに取り掛からなければなりません。
    • 運用時の場合
      エンドユーザに対しては上記のようなエラー通知画面を表示しますが、これに加え、バックエンドの運用担当者がすぐさまトラブルシュートを行い、システム障害を取り除く必要があります。このため、未処理例外が発生した場合には、すみやかに監視系などへの通知も行わなければなりません。

    このようなことを鑑みて、ASP.NET ランタイムは以下の 3 つの例外処理機能を備えています。

    • デバッグ用エラーページ(主に開発時に利用)
      デバッグのために、未処理例外の詳細情報をその場で表示する機能
    • エラーページ差し替え機能(主に運用時に利用)
      エンドユーザに穏やかなメッセージを表示するための機能
    • 集約例外ハンドラ(開発時・運用時の両方で利用)
      未処理例外に対する後処理(ロギングなど)を作り込むことができる機能

    image

    では、これらの各機能について解説します。

    [デバッグ用エラーページの表示機能(主に開発時に利用)]

    ASP.NET ランタイムには、組み込みでデバッグ用のエラーページが含まれており、開発中に未処理例外が発生した場合には、以下のような黄色いエラーページが即時で表示されるようになっています。

    image

    このデフォルトのエラーページには、エラー内容やソースコード、エラー位置、スタックトレース情報などがまとめて出力されますが、このエラーページの内容は正確に理解できるようになる必要があります。なぜなら、この例外ログを正しく読めるようになると、アプリケーションバグの原因をつかみやすくなることが多いからです。例外ログの読み方については次回のエントリで詳細に解説する予定なので、まだ例外ログをうまく読むことができないという人は、必ずそちらのエントリも読むようにしてください。

    [デフォルトエラーページの差し替え機能(主に運用時に利用)]

    さて、上記のエラーページは開発時には非常に便利ですが、運用環境でエンドユーザがこれを見せられても、「どないせいっちゅーねん;」となって困ってしまいます。このため最初にも書いたように、以下のような「穏やかなガイダンスメッセージ」を表示するページに差し替える必要があります。

    image

    このようなエラーページ差し替えのためには、以下の作業を行います。

    • ガイダンスメッセージを表示する HTML ファイル
      error.htm などの名前で用意します。静的な HTML ファイルで十分で、通常はここに、エンドユーザに対するお詫び、しばらく時間をおいてから再試行をしてもらうこと、ヘルプデスクの連絡先番号などを掲載しておきます。
    • web.config ファイルへの customErrors セクションの追加
      web.config ファイルの system.web セクション下に customErrors セクションを追加し、このエラーページを指定します。

     image

       1: <customErrors mode="On" defaultRedirect="error.htm" />

    以上の作業により、デフォルトの黄色いエラー画面を、適切なメッセージ通知画面に切り替えることができます。

    [集約例外ハンドラへの例外ログ取得機能の組み込み(開発時・運用時の両方で利用)]

    さて、エンドユーザに対するトラブルの通知メッセージについては適切なものに切り替わりましたが、これだけではまだ不十分です。というのも、依然としてシステムには何らかのトラブルが残っているわけで、発生したシステム障害については速やかに取り除き、システムを正常な状態に戻さなければなりません。

    基本的な考え方としては、下図の通りとなります。

    image

    • Web システムで異常事態が検知された場合、エンドユーザに対しては穏やかなメッセージを示す。
    • それと同時に、監視システムに対して何らかの方法で通知(障害が発生したことの通知)を行う。
    • 監視システムでは、トラブルが発生したことを検知したら、現地にトラブル対応を行うエンジニアを送り込む。
    • エンジニアが現地でトラブル情報の中身を見て、解決を図る。

    このトラブル対応の流れで、特に注意しなければならないのは以下のポイントです。

    • このようなトラブルシュートでは、人手による対応が必要になる。
      一般に、アプリケーションエラーやシステムエラー(いわゆる例外)が発生した場合には、人手による復旧作業が必要になり、自動的にリカバリすることはできません。例えば、アプリケーション内で、データベース接続オープンに関する例外が発生した場合を考えてみます。このような例外は、① データベースサーバとの間のネットワークケーブルが断線した。② DNS サーバのトラブルによりデータベースサーバ名の名前解決ができなかった。③ データベース側にディスク障害が発生し、データベース側が接続を受け入れられなかった。④ … といった具合に、様々な原因が考えられ、これらを適切に切り分けて障害を取り除くためには、どうしても人手による対応が必要になります。
    • トラブルシュートを行う際には、発生した例外に関する情報を保存しておく必要がある。
      トラブルシュートを行うためには、発生した障害(=例外)に関する情報をどこかにきちんと記録しておき、現地に行ったエンジニアがこれを見られるようにしておかなければなりません。特に、障害の中には、同一の障害を再現させにくいものが結構あるため、「障害が発生したときにきちんと記録」しておかないと、後から「なぜその障害が起きたのか?」「どういう障害が起きたのか?」を追跡することが極端に難しくなるケースがあります。このため、発生した例外の情報は、きちんとどこかに保存しておかなければなりません。

    通常、上記のような問題を解決する方法として、以下のような施策が使われます。

    • Web アプリケーションの集約例外ハンドラに、例外の情報(例外ログ)を記録する機能を組み込む。出力先はイベントログやファイルとする。
    • 監視システムでは、当該 Web システムのイベントログを監視し、イベントログに Web アプリケーションから例外ログが出力されたら、エンジニアを現地に派遣する。
    • 現地に行ったエンジニアは、例外の情報をイベントログやファイルを開いて確認し、トラブルシュートを行う。

    ASP.NET ランタイムでは、上記のような施策を行えるようにするために、global.asax というファイルを用意しています。global.asax ファイルは、当該 Web アプリケーションに対してさまざまな共通機能を組み込むためのモジュールとして利用することができます。このファイル中に Application_Error() メソッドを記述しておくと、当該 Web アプリケーション内のどこで未処理例外が発生しても、必ずこのメソッドを呼び出してくれるようになります。(つまり、ここ一か所に例外ログの出力機能を書いておけば、アプリケーション全体に対して未処理例外の処理機能が有効化される、ということになります。このため、この機能を集約例外ハンドラと呼んでいます。)

    image

    集約例外ハンドラの実装例を示します。

       1: <%@ Application Language="C#" %>
       2: <%@ Import Namespace="System" %>
       3: <%@ Import Namespace="System.Diagnostics" %>
       4:  
       5: <script runat="server">
       6:     void Application_Error(object sender, EventArgs e)
       7:     {
       8:         Exception ex = Server.GetLastError(); // 未処理例外を取り出してイベントログに出力
       9:         string errMsg = String.Format("エラーメッセージ\n{0}\n\nスタックトレース\n{1}\n",
      10:                             ex.Message, ex.StackTrace);
      11:         EventLog.WriteEntry("Application", errMsg, EventLogEntryType.Error);
      12:     }
      13: </script>

    このような機能を作り込んだ上で、Web アプリケーションを実行し、アプリケーションで未処理例外が発生すると、この集約例外ハンドラ機能が動作し、イベントログにエラーログが出力されます。

    image

    image

    イベントログには以下のようなメッセージが出力されるようになります。(内容については現時点ではわからなくて OK です。これについては次回のエントリで解説します。)

    ソース "Application" からのイベント ID 0 の説明が見つかりません。このイベントを発生させるコンポーネントがローカル コンピューターにインストールされていないか、インストールが壊れています。ローカル コンピューターにコンポーネントをインストールするか、コンポーネントを修復してください。
     
    イベントが別のコンピューターから発生している場合、イベントと共に表示情報を保存する必要があります。
     
    イベントには次の情報が含まれています: 
     
    エラーメッセージ
    種類 'System.Web.HttpUnhandledException' の例外がスローされました。
     
    スタックトレース
       場所 System.Web.UI.Page.HandleError(Exception e)
       場所 System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)
       場所 System.Web.UI.Page.ProcessRequest(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)
       場所 System.Web.UI.Page.ProcessRequest()
       場所 System.Web.UI.Page.ProcessRequestWithNoAssert(HttpContext context)
       場所 System.Web.UI.Page.ProcessRequest(HttpContext context)
       場所 ASP.default_aspx.ProcessRequest(HttpContext context)
       場所 System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()
       場所 System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)
     
     
    メッセージ リソースは存在しますが、メッセージが文字列テーブル/メッセージ テーブルに見つかりません。

    [ここまでのまとめ]

    では、ここまでの解説の要点をまとめておきます。

    • 開発時には、例外の多くは、アプリケーションコードのバグが原因で発生しています。このため、発生した例外の中身の情報をうまく活用して、すぐさまデバッグに取り掛からなければなりません。このため、ASP.NET 組み込みのデフォルトエラーページを活用して、デバッグを行います。
    • 運用時には、エンドユーザに対しては穏やかなエラーメッセージを表示しなければなりません。このために、エラーページの差し替え機能を利用します。しかし、これに加え、バックエンドの運用担当者がすぐさまトラブルに気づき、トラブルシュートを行い、システム障害を取り除く必要があります。このために、集約例外ハンドラ機能を利用し、例外情報をイベントログなどに記録します。

    さて、ASP.NET において最も基本となる例外処理の考え方は以上なのですが、もう少し追加で知っておくべきポイントがあります。具体的には以下の 2 つです。

    • 集約例外ハンドラの組み込み方法
      XML Web サービスや WCF では、集約例外ハンドラの組み込み方が異なりますので、これらについても理解しておく必要があります。
    • 詳細な例外ログの取得方法
      上記の方法では、簡単な例外ログしか取得できませんが、トラブルシュートのやりやすさを考えると、できる限り詳細かつ分かりやすい例外ログを出力しておく必要があります。

    これらについても解説しましょう。

    [XML Web サービス(.asmx)における集約例外ハンドラの組み込み方法]

    XML Web サービス(*.asmx ファイル)を使う場合には、global.asax ファイルの Application_Error() 関数を使った集約例外ハンドラの作り込みはできません。これは理由は簡単で、SOAP over HTTP の XML Web サービス呼び出しを行う場合、*.asmx ファイル内で発生した未処理例外は、ASP.NET ランタイムがこれを捕捉し、SOAP Fault 要素と呼ばれる SOAP メッセージに変換してクライアントに通知するためです。つまり、サーバの *.asmx ファイル内で未処理例外が発生しても、HTTP 通信としてはステータス 200 の正常通信扱いとなるため、global.asax ファイルの Application_Error() 関数は動作しません。

    image

    このため、*.asmx に対する集約例外ハンドラを作りたい場合には、SOAP 拡張と呼ばれる機能を利用し、ASP.NET ランタイム内での SOAP メッセージ組立処理のところをカスタマイズしてやります。具体的には以下のような作業を行います。

    • ソリューションファイルに、クラスライブラリプロジェクトを追加し、そこに SOAP 拡張クラスを作成。
    • SOAP 拡張クラスを使って、未処理例外があった場合にはイベントログに記録するコードを記述。
    • Web サイト側からプロジェクト参照を行った上で、web.config ファイルに SOAP 拡張クラスの組み込みを指示するセクションを記述。

    image

    (SOAP 拡張クラスの実装)

       1: using System;
       2: using System.Web.Services.Protocols;
       3: using System.Diagnostics;
       4: using System.Reflection;
       5:  
       6: namespace Microsoft.Japan.Mcs.Utilities.ExceptionManagement.SoapWebService
       7: {
       8:     public class ExceptionLoggingExtension : SoapExtension
       9:     {
      10:         public override object GetInitializer(Type WebServiceType)
      11:         {
      12:             return null;
      13:         }
      14:  
      15:         public override object GetInitializer(LogicalMethodInfo methodInfo, SoapExtensionAttribute attribute)
      16:         {
      17:             return null;
      18:         }
      19:  
      20:         public override void Initialize(object initializer)
      21:         {
      22:         }
      23:  
      24:         public override void ProcessMessage(SoapMessage message)
      25:         {
      26:             switch (message.Stage)
      27:             {
      28:                 case SoapMessageStage.BeforeDeserialize:
      29:                     break;
      30:                 case SoapMessageStage.AfterDeserialize:
      31:                     break;
      32:                 case SoapMessageStage.BeforeSerialize:
      33:                     break;
      34:                 case SoapMessageStage.AfterSerialize:
      35:                     if (message.Exception != null)
      36:                     {
      37:                         Exception ex = message.Exception;
      38:                         string warningMessage = "集約エラーハンドラにて例外を補足しました。例外内容は以下の通りです。" + ex.ToString();
      39:                         EventLog el = new EventLog();
      40:                         el.Source = "Application";