さて次は何を書こうかなぁと思ってましたが、前回のエントリのウケが比較的よかった様子なので、もうちょっと初心者向けのエントリを続けてみようと思ったり。ということで、今回は .NET の例外処理について書いてみたいと思います。
なぜにいまさら例外処理……? と思われる方も多いと思うのですが、理由はただ一点。現場レベルのソースコード読んでると、未だに例外処理がめちゃくちゃなアプリがホントに多いのです。
私が昔、Java を初めて触ったときに感動した機構の一つが例外処理(とそれに関連するスタックトレース)で、例外処理を正しく書くだけで、アプリの内部動作の大部分はわかるし、障害解析も圧倒的にラクになる。しかしその一方で、例外処理についてわかりやすくまとめられた書籍が少ないために、なかなか理解するのが大変なところでもあるんですよね。自分の書いた開発技術大全 vol.3 だけではどうもまだ分かりにくいようなので、少し加筆しながらここで説明しよう、と思った次第です。そんなわけで、今日は比較的初心者向けのトピックですが、気楽にお付き合いいただけると嬉しいかもです。
# といいつつ、たぶんそんなに気楽に読めるエントリでもないかもしれません。 # 例外処理は、実はアプリケーションデザインにもかかわる部分があり、例えば BC や DAC の # メソッドの引数・戻り値の設計にも影響を与えてくるからです。なので、一度じっくり腰を据えて学習 # してみてください。一度きちんと勉強すれば済む話ですので^^。
まず、そもそも .NET アプリケーションにおける「例外」は、どのような状況を表現するために用意されている機能なのか、というところから解説していくことにします。
[.NET アプリケーションにおける例外と業務エラーの違い]
一般的に、業務アプリケーションを開発する場合には、業務フローチャートを作成し、その流れを C# や VB のコードによって実装します。
通常、業務処理の終了(=各メソッドの終了)は、正常終了と業務エラーのパターンに大別されますが、例外はこうした正常終了や業務エラーを表現するために利用してはいけません。例外はその名の通り、例外的な状況、すなわち業務フローチャートからはみ出してしまったという、想定外の事象を表現するために利用します。
具体的には、データベース接続エラーやネットワークエラー、メモリ不足などが発生した場合には、この状況を例外によって表現する、というのが .NET における大原則、と理解してください。
しかし、この例だけだと分かりにくいと思いますので、もうちょっと具体的な例を取り上げて説明してみましょう。例えば、重複ユーザ ID の利用を認めないような、新規顧客登録業務を考えてみます。
このような業務アプリにおいて、どのようなケースが業務エラーに相当し、どのようなケースが例外に相当するのかを考えてみます。ボタンを押したあとの応答は、以下の 3 つのパターンに分類できますが、.NET アプリケーションでは、③のパターンのみを例外とし、②のケースを例外として表現してはならない、というのがポイントになります。
この説明だけだとまだ業務エラーと例外の違いがわかりにくいので、もう少し突っ込んで説明をしてみます。
今、このアプリケーションで③に相当するケース、すなわちデータベース接続エラーやネットワークエラーが発生したとします。通常、これらのエラーはエンドユーザに対して通知するべき内容ではありません。というか、「データベースに接続できませんでした」、とかいわれても、エンドユーザ側としてはどないせいっちゅーねん、状態になりますよね?^^ おそらくこのような場合には、エンドユーザに対しては「ごめんなさい、またしばらくしたらアクセスしてください」「ヘルプデスクに電話してください」などのメッセージを通知するようにし、エラーの詳細情報は隠ぺいすることになるでしょう。
ところが、ID 重複エラーのようなものは、エンドユーザ側で対処のしようのある状況です。このような場合には、エンドユーザに対して適切なガイダンスメッセージを表示し、再入力などを求めることになるでしょう。
このように考えてみると分かりやすいと思いますが、
というのが .NET 開発における原則、ということになります。
[2 種類の業務エラー]
なお、エンドユーザに対して再試行を促すようなメッセージを表示する業務エラーというのも、大別すると以下の 2 種類にわけることができます。すなわち、
となります。このことからわかるように、実は「何が業務エラーなのか」は、コンポーネント種別によってもかわってきます。上記の例でいうと、ビジネスロジッククラス(BC)部では、
となります。
[具体的なシグネチャ設計例]
ではここまでの話を元に、具体的に、このアプリケーションにおいてビジネスロジッククラス(BC)のメソッドシグネチャ(入力パラメータとメソッド戻り値)をどのような形にすればよいのかを考えてみましょう。
ビジネスロジッククラスでは、メソッド入力値として、希望する ID や名前、メールアドレス、生年月日などを受け取って処理を行いますが、その終了パターンは、① 成功、② 顧客 ID 重複、の 2 通りあります。これらを戻り値として表現できなければなりません。そこで、enum 値(列挙型)を利用すると、以下のようにメソッド定義をすることができます。
public class CustomerBizLogic {
public RegistCustomerResult ResistCustomer(string id, string name, string mail,
DateTime birthday) {
// ...
}
public enum RegistCustomerResult {
Success,
DuplicateCustomerIDError
ここで、メソッド戻り値としては、データベース接続エラーやネットワークエラー、あるいはメモリ不足などのケースは全く出てこないことに気を付けてください。こうした状況が発生した場合には例外が発生し、呼び出し元に例外として通知されるため、メソッド戻り値としては表現する必要がないからです。
今度は、このビジネスロジッククラスを利用する UI 部(*.aspx ファイル)の実装がどのようになるのかを考えてみます。業務エラーとは、エンドユーザに適切に通知する必要のあるエラーケースなので、正しくケース分類して、後処理を書かなければなりません。よって、UI 部のコードは次のようになります。
protected void btnRegist_Click(object sender, EventArgs e) {
if (Page.IsValid == false) return;
CustomerBizLogic biz = new CustomerBizLogic();
CustomerBizLogic.RegistCustomerResult result = biz.ResistCustomer(tbxId.Text,
tbxName.Text, tbxMail.Text, DateTime.Parse(tbxBirthday.Text));
switch (result) {
case CustomerBizLogic.RegistCustomerResult.Success:
lblResult.Text = "正しく顧客登録を行いました。";
break;
case CustomerBizLogic.RegistCustomerResult.DuplicateCustomerIDError:
lblResult.Text = "指定された ID はすでに利用されています。";
基本的に UI 部では、BC からのメソッド戻り値を switch 文によって寄り分けて、その後の処理を書く、と考えると分かりやすいでしょう。(=try-catch などが出てくることはない、ということになります。詳しくは後述。)
[業務エラーが複雑なパターンとなる場合]
なお、上述の例では業務エラーが比較的単純なパターンのみを想定したため、メソッド戻り値としては「どの業務エラーになったのか」だけを表現すれば十分(=enum 型を使えば十分)でした。しかし、場合によってはサーバ側から、各業務エラーごとに異なる付帯情報を返したいこともあります。(例えば、顧客 ID が重複した場合には、サーバ側からおすすめ ID 情報をくっつけて返す、など) このような場合には、メソッド戻り値として構造体を利用するようなこともあります。
ちなみに、ビジネスロジッククラスのメソッド戻り値を使った業務エラーの表現方法は、おおまかに以下の 3 パターンにわけることができます。
もう少し具体的に書くと、以下の通りです。
① null 型
正常終了の場合には、通常の戻り値(オブジェクトインスタンス)を返し、業務エラーの場合には、null値を返す、という方式。
public AuthorsDataSet GetDataByAuId(string au_id);
→ 正常終了:オブジェクトインスタンスを返す
→ 業務エラー(著者IDが見当たらない):nullを返す
public decimal? GetPriceByTitleId(string title_id);
→ 正常終了:価格データを返す
→ 業務エラー(書籍IDが見当たらない):nullを返す
※ この方式、個人的にはあんまり好きではありません。というのも、"null" の意味付けが一定しなくなり、後からこのメソッドを読んだときに、null が何を意味するのかが曖昧になりやすいからです。
※ また、上記の例では、著者 ID が見つからなかった場合には、null を返す方式と、中身の行数がゼロ行の AuthorsDataSet インスタンスを返す方式が考えられます。実装上は後者の方がラク。どちらを使うにせよ、どの方式を採用しているのかは明確に仕様として管理しておかないと、実装ブレや後からのメンテナンス時のトラブルの元になりますので要注意。
② enum 型
成功/失敗などの情報のみを返せばよい場合には、各ケースを列挙型(enum)で表現して返します。(業務エラーのパターンが一つしかない場合には、bool型を利用しても OK。)
public OrderResult OrderBook(string customerId, …);
(OrderResultは列挙型)
→ 正常終了:OrderResult.Successを返す
→ 業務エラー(書籍の在庫がない):
OrderResult.NoStockを返す
→ 業務エラー(納品が間に合わない):
OrderResult.CannotDeliveryUntilRequiredDate
→ …
個人的には、コードの可読性が高まるので非常に好きな方式。呼び出し元でも switch 構文で後処理がきれいに書けるというメリットがあります。
なお、絶対にやってはいけないのは、数値コードを使って戻り値を表現する方法。例えば上記の例の場合、int 型を戻り値にしておいて、0 だったら正常終了、1 だったら在庫不足、2 だったら納期が間に合わない、などとするのは NG。これをしてしまうと、数値コード対応表がないと、アプリケーションプログラムの意味がわからなくなってしまうためです。アプリケーションプログラミングの大原則の一つに「Self-Descriptive(自己記述的)」(アプリケーションコード自体が、その意味を表現できていなければならない)という原則がありますが、その原則を守るためには、数値コードは使うべきではありません。
③ 構造体クラス型
正常終了/業務エラーなどの各ケースにおいて何かしらの付随情報を返す必要がある場合には、付随情報などをラッピングしたクラスを作成し、これを用いて戻り値を表現します。
public UpdateResult UpdateAuthorInfo(AuthorsDataSet ads)
{
...
[Serializable]
public class UpdateResult
public bool IsSuccess; // 正常終了 or 業務エラー
public UpdateErrorResultEnum BizError; // 業務エラーの理由
public int NewestCacheVersionNumber; // 業務エラー①の付帯情報
public AuthorsDataSet NewestInfoOnServer; // 業務エラー③の付帯情報
public enum UpdateErrorResultEnum
StateDataIsStale,
DuplicatePhoneNumber,
OptimisticCurrencyViolation
[例外の処理方法]
さて、ここまで業務エラーの設計方法と取り扱い方について説明してきました。ポイントとしては、業務エラーはメソッドシグネチャの一部として設計しなければならない、ということでしたが、では例外についてばとのように扱えばよいのでしょうか?
まず、大原則を覚えてください。
よほどのことがない限り、アプリケーションで try-catch を書いてはいけません。
もう一度繰り返します。
もう一度(ry、すみませんしつこいですね^^。でもこれ、めちゃめちゃ重要なのです。
C# や VB を学習すると、割と早い段階で try-catch 構文による例外処理方法を学習すると思います。が、実際のアプリケーションコードでは、特殊な事情(後で説明します)がない限りは、try-catch を記述してはいけないのです。では try-catch を書かずにどうやって例外の後処理をすればよいのか....というと、通常、集約例外ハンドラと呼ばれる機能を利用します。
まず、例外処理(try-catch)や集約例外ハンドラを一切記述しなかった場合にどのような挙動をするのかを考えてみます。例えば、UI / ビジネスロジッククラス(BC) / データアクセスクラス(DAC)の論理 3 階層型構造で作成したアプリケーションを取り上げてみましょう。
この場合、業務エラーと例外は、次のように処理されます。
ちなみにランタイムが捕捉した場合の挙動は、アプリケーション種別ごとに異なります。
このような画面は、開発時にはデバッグがすぐにできるので便利でしょうが、運用時には不適切です(運用時には、「しばらく時間をおいてやりなおしてください」とか「ヘルプデスクに電話してください」といったメッセージを表示する必要があるため)。このため、ASP.NET ランタイムや Windows フォームなどでは、これらの画面を差し替えるための機能を備えています。詳細はまた後で詳しく解説しますので、ここでは概要だけ説明しますと。
① ASP.NET ランタイムの場合
未処理例外が発生した場合には、global.asax ファイル内の Application_Error() メソッドが自動で呼び出されます。また、web.config ファイルの <customErrors> セクションを利用すると、エラー時に表示する画面を差し替えることができます。
<configuration>
<system.web>
<customErrors defaultRedirect="error.htm" mode="RemoteOnly" />
</system.web>
</configuration>
② Windows フォームの場合
未処理例外が発生した場合には、 Application.ThreadException イベントハンドラが自動で呼び出されます。よって、ここにエラーメッセージ通知を行うコードを記述しておくと good。最も簡単なサンプルコードを示すと以下のようになります。
namespace WindowsFormsApplication1
static class Program
[STAThread]
static void Main()
Application.ThreadException += new System.Threading.ThreadExceptionEventHandler(Application_ThreadException);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e)
MessageBox.Show("致命的な例外が発生しました。\nヘルプデスクに電話してください。");
Application.Exit();
③ コンソールアプリケーションの場合
未処理例外が発生した場合には、AppDomain.CurrentDomain.UnhandledException イベントハンドラが自動で呼び出されます。よって、ここにエラーメッセージ通知を行うコードを書きます。
namespace ConsoleApplication1
class Program
static void Main(string[] args)
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
Environment.Exit(-1);
よって、これらの処理をカスタマイズしておけば、異常事態に対する後処理を個別に作り込む必要はない、ということになります。簡単にいえば、try-catch による例外の後処理を個別に作り込む必要性はない、ということです。
# 実際、私もいくつかの開発現場で、アプリケーションコード中に書かれた try-catch 文を片っぱしから除去してもらったことがあります。それぐらい、初学者は「なんとなく try-catch」を書いてしまうことが多いんですよね。この点は初学者が陥りがちな典型的なミスの一つなので注意してください。
[業務フローチャートと throw / try-catch の関係]
さて、ここまで try-catch 文は書くな、と解説してきたのですが、実際の業務アプリケーションの場合には、try-catch 文や throw 文を記述することもあります。どのような場合にこれらを記述する必要があるのかというと、それは業務フローチャートの流れの調整を行いたい場合です。もう一度、最初の方に掲載した図を再掲します。
この絵からわかるように、例外とは業務フローチャートの想定から外れた場合を表現する目的で利用します。が、場合によっては以下のようなケースもあるはずです。
このような場合には、try-catch 命令、あるいは throw 命令を利用します。
上記の説明だけだとわかりにくいと思いますので、具体例を取り上げてみましょう。
① try-catch 命令を利用して、業務フローチャートに戻るケース
例えば、前述の顧客新規登録業務における、ビジネスロジッククラス(BC)の実装を考えてみましょう。Visual Studio 2008 の開発では、データアクセスクラス(DAC)の実装にテーブルアダプタを利用しますが、テーブルアダプタを介して INSERT 命令を発行すると、重複顧客がいる場合には、INSERT 処理が PK 制約違反により失敗します。
このとき、DAC から BC へは INSERT 命令失敗により、SqlException 例外が通知されますが、このケースは BC の立場からすると、例外として取り扱うべき事象ではなく、業務エラーとして取り扱うべき事象です。このため、BC 上では、try-catch 命令によりこの例外を捕捉し、通常の戻り値に変換して UI に返すことになります。
※ 以下のコードにはまだ問題があるので、続きの説明を読んでください。
DateTime birthday)
// テーブルアダプタを利用
CustomersTableAdapter ta = new CustomersTableAdapter();
try
// INSERT 命令を実施
ta.InsertCustomer(id, name, mail, birthday);
catch (SqlException sqle)
return RegistCustomerResult.DuplicateCustomerIDError;
return RegistCustomerResult.Success;
しかし、ここで留意しなければならないことがあります。それは、SqlException 例外は、PK 制約違反以外のケースでも起こりうる、ということです。下表は、SQL Server 内部で起こる代表的なエラーと、それに対する例外の発生有無の概要表なのですが、これからわかるように、SqlException 例外は、ディスク枯渇やテーブルデータ破損といった、致命的なトラブルによっても発生します。このような事象は業務エラーでは当然ありませんが、上記のコードではすべて「重複顧客登録業務エラー」として取り扱われてしまいます。(=本来業務エラーとすべきではない SqlException まで業務エラーに変換してしまっている)
このような場合に役立つのが、catch ブロック内に記述する「throw」(引数なし)という命令です。この命令を使うと、catch したあとでありながら、catch しなかったことにできます。
if (sqle.Number == 2627) { // PK 制約違反だった場合には後処理を行う
else {
throw; // それ以外だった場合には catch しなかったことにする
以上の話からわかるように、try-catch 命令を書く場合には、業務エラーなどに変換したくない例外を、間違って捕捉しないようにしなければなりません。具体的には、以下のようなコードは絶対に書いてはいけません。
try // 間違い① 複数行を try-catch で一気に囲む
int a = 0;
int b = 0;
int c = a + b;
Console.WriteLine(c);
catch (Exception e) // 間違い② あらゆる例外を捕捉してしまう
// 間違い③ 補足したあと何もしない
このようなコードを書いてはいけない理由は、try-catch する目的を考えてみていただければ明らかでしょう。
よって、例外を try-catch する場合には、以下の大原則を守らなければなりません。
※ リソースの確実な解放のために記述する try-finally の記述ではこれらに反するコードを記述することがありますが、これはそもそも目的が異なるためです。これについてはまた別の機会に。
② throw 命令を使って自爆するケース
では次に、throw 命令について説明します。throw 命令とは、アプリケーション内部から例外を発生させる処理ですが、これは自ら業務フローチャートの外に出て、アプリケーションを止めようとする自爆処理であると言えます。
例えば以下のような例を考えてみます。先ほどの新規顧客登録業務において、ビジネスロジッククラスの部分で、UI (*.aspx)から引き渡された電子メールアドレスの文字列が、○○@○○.○○ といった電子メールアドレスのフォーマットになっていなかった場合を考えてみます。
通常、ブラウザから入力される電子メールアドレスは、まず UI (*.aspx)上に貼り付けられた検証コントロールによってチェックします。このため、アプリケーションが適切に実装されている限り、ビジネスロジッククラス(BC)が、フォーマットのおかしい電子メールアドレスを UI 部から受け取ることはあり得ません。もしそのような事態が発生したとすると、それは以下のような状況が想定されます。
これらはいずれもヤバい状況ですが、このようなヤバい状況であるにもかかわらずそのままアプリケーション処理を続行すると、どんな事態が発生するのか分からず、極めて危険です。このように、アプリケーション内部で異常事態であることが判明した場合には、throw 命令を使うことにより、アプリケーションを停止させます。
例えば、先のこちらのコードに対して、「異常事態の判定コード」を追加してみましょう。
このコードの場合には、以下のような「異常事態」の検知コードを追加することができます。
よって、このような異常事態チェックコードを前出のコードに追加すると以下のようになります。
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:
このような異常事態検知コードについては、以下の点に注意してください。
[まとめ]
というわけで例外処理についてひたすら解説してきましたが、ここまでのキーポイントをまとめると以下のようになります。
上記 3 つの原則を守るだけで、アプリケーションの障害解析は圧倒的にラクになるので、まずはこれらの原則をしっかり守ったコーディングを行ってください。次回は引き続き、ASP.NET ランタイムの持つ 3 つの例外処理機能の使い方などを解説していきます。(と思いましたが、ちょっと回り道するかもしれません....。)
.NETはまるっきり素人(というか、今も使っていない)なのですが、興味深く読ませていただきました。
「私だったら…」って思ったのが一点。
「業務エラーはメソッドシグネチャの一部として設計しなければならない」ということでしたが、私なら、業務エラー用のExceptionを定義して、それをthrowして上位に拾わせることでそのあとの処理を分岐させるように作るかなと思いました。
そうすると、その後の「よほどのことがない限り、アプリケーションで try-catch を書いてはいけません。」にもあてはまらなくなっちゃいますが。
ご意見を頂けると嬉しいです。
コメント書いた後で読み返してみて「BCのところでそれをやるのか」と気づきました。そうすれば、UIは同じことになりますね。
失礼しました。
> y_ko_no さん
> 「業務エラーはメソッドシグネチャの一部として設計しなければならない」
> ということでしたが、私なら、業務エラー用のExceptionを定義して、それを
> throwして上位に拾わせることでそのあとの処理を分岐させるように作るかなと
> 思いました。
はい、そういう考え方があることも理解していますが、この点については一つ
重要なポイントがあります。いずれ書くつもりでいるのですが、先回りして
キーポイントだけ書くと、次のようになります。
まず、業務エラー用の Exception というのは、Java の言語構文には存在するの
ですが、C# や VB などの CLR 系言語には存在しないものです。
Java では、例外が二種類あり、検査例外(Checked Exception)と、実行時例外
(Runtime Exception)というのがあるのですが、CLR 系には Java の検査例外の
仕組みが存在しません。
Java の検査例外には、以下の 2 つの特徴があります。
・メソッドシグネチャとして throws 句を書かないとダメ。
・呼び出し側で try-catch を書かないとダメ。
この 2 つの特徴は、要するに「必ず処理ルートとして考慮しなくちゃいけない
ケース」ということを意味しています。端的に言えば、業務エラーを表現する
機能として、検査例外というものが用意されている、ということなんですよね。
ところが CLR 系言語にはこの検査例外の仕組みが備わっていません。
Java の実行時例外や CLR の例外は、「throws 書かなくてOK」、「呼び出し側で
try-catch 書かなくて OK」、つまり端的に言えば「インタフェース約款として
規約しない例外」ということになるのですね。
業務エラーというのは、インタフェース約款として規定すべきもの。
だから、Java の場合には検査例外として、CLR の場合には(検査例外に相当する
仕組みがないので)戻り値の一部として定義する、ということになります。
ちなみにこの点に関して言うと、私は Java の言語仕様の方が好きです。
検査例外を使って業務エラーを表現した方が、コードとして業務エラーを綺麗に
表現できるからです。しかし、一方で検査例外と業務エラーの関係を正しく理解
していない Java プログラマも非常に多いのが実情で、Java 自体の例外設計にも
この点に関する混乱が見られるほどです。(有名どころで言うと、EJB 1.0 では
EJB の例外が検査例外である RemoteException で定義されていたのですが、
これが 1.1 からは EJBException という実行時例外に変更され��、とか。)
Java の世界でも、検査例外を言語構文として作ったことは失敗だったんじゃ?
という議論があると聞いたことがあるのですが、正しく使わないと、検査例外は
かえって危険なのは確かです。実際、Java の場合は SqlException や Remote
Exception などがかたっぱしから検査例外として定義されているのですが、
検査例外→実行時例外への変換などを意識できていないプログラマがコードを
書くと、結構とんでもないことになります。
この辺も、いずれちゃんとエントリとしてまとめるつもりなので、よかったら
また覗きにきてみてください。
はじめまして、よこけんと申します。
業務エラーに関する箇所についてコメントさせて頂きます。
「業務エラーは戻り値で表現する」とのことですが、他にも例外を使わない表現方法はたくさんあると思います。
戻り値ではなくプロパティ (例えば、発生した業務エラーを格納する Errors プロパティ) で表現することもできますし、事前検証用のメソッドを用意しておくこともできます。業務エラーを UI 操作と直結させることもできます。
どのようにするかは、アーキテクチャやレイヤによって異なると思います。
戻り値に限定している理由は何かあるのでしょうか?
ここからは特に下位レイヤの話なのですが、処理結果を戻り値で返す場合、後から列挙値が追加されたら呼び出し元全てに修正を入れなければならず、修正漏れが発生する可能性があります。
「例外を投げる + 事前検証メソッドの提供」という形態ならば、呼び出し元の修正漏れの検出が容易になります。事前検証できるので、こちらも業務エラーに例外を使わずに済むと思います。(この場合は、例外には InvalidOperationException 系が妥当ですね。)
前述の通りアーキテクチャにもよりますが、下位のレイヤは上位から処理を委譲されてくるので、こちらの形態の方が良い場合もあると思います。
ID の重複などは実行時にしか (厳密な) 検出ができませんが、同様の理由で、戻り値ではなく例外を使うのもアリだと思います。(この場合は InvalidOperationException のような汎用な例外を投げるのではなく、具体的な例外を投げますが。)
# これも下位のレイヤの話です。上位では戻り値や Errors プロパティに変換できます。
下位レイヤで例外を使っても、上位レイヤで「業務エラーというのは、インタフェース約款として規定すべきもの」を満たすことができるので、下位レイヤでは ID の重複を例外で通知しても何ら問題ないことになる (※) と思います。
# これまたアーキテクチャ次第ではあると思いますが。
※ 検査例外は必須ではなくなり、例外は C# や VB の XML ドキュメントコメントで明示すれば充分となります。
文章がわかりづらかったらすみません。要は「.NET では業務エラーは戻り値」、「上位レイヤも下位レイヤも同様」 (こちらは明記されてはいませんが) という二点についての意見です。
よこけんさん、コメントありがとうございます。
> どのようにするかは、アーキテクチャやレイヤによって異なると思います。
> 戻り値に限定している理由は何かあるのでしょうか?
はい、アーキテクチャ次第なので、この通り「でなければならない」という類のものではないと思います。「絶対に」という表現を使ってはいますが、これはその方が(大半の人には)分かりやすいと思ったからで、よこけんさんぐらい深く考察されている方であれば、例外やプロパティ表現を使っても構わないと思います。
この辺については次回のエントリにきっちり書くつもりでいるのですが、私が業務エラーに例外を使うべきではない、としている大きな理由は以下の 2 つです。
① 業務エラーは、メソッドシグネチャ(インタフェース規約)の一部として表現すべき。
② Java の検査例外のように、メソッドシグネチャの一部として例外を表現したり取り扱ったりする機能が、.NET にはない。
問題は②の点で、②を例外(実行時例外)に倒すか戻り値に倒すかは、これはアーキテクチャ次第です。私は、①の点に加えて、
・業務フローチャートとアプリケーションコードは 1:1 に対応しているべき。(特に BC)
・業務フローチャートの関心領域は、明示的にシグネチャとして表現されるべき。
という考え方を持っており、このために、シグネチャ表現できない例外を使うことを嫌っています。
ただ、いくつかツッコミを入れると、
> 発生した業務エラーを格納する Errors プロパティ
これは、BC はステートレスであるべきというコンセプトから避けています。(UI 部のダイアログの設計とかではアリかもしれませんが。)
> ここからは特に下位レイヤの話なのですが、処理結果を戻り値で返す場合、後から列挙値が追加されたら呼び出し元全てに修正を入れなければならず、修正漏れが発生する可能性があります。
こちらは上位レイヤの実装コード次第だと思います。まず、下位レイヤの I/F 仕様が変更された場合には上位レイヤは絶対に見直しが必要です(I/F 約款がくずれたことを意味するので)。そして(修正漏れを避けるためにというわけではないのですが)、上位レイヤでは、予め下位レイヤが想定外の列挙値を返してくるようなら自爆コードを書かなければなりません。
default:
throw ApplicationException("想定外の結果が返ってきました。" + result.ToString());
# というか、こんなめんどい話になるのは端的にいえば「.NET に検査例外がないから」なのですが;。
# 検査例外があればこんなことしなくてもコンパイルエラーになりますからね....
検査例外が万能とは言いませんが(これは次回のエントリにて)、検査例外相当のケースをどのように取り扱うべきかは設計ポリシー(アーキテクチャ)次第です。私は、比較的汎用性が高く、一貫性を取りやすいアーキテクチャを好むので上記のような指針を使っていますが、よこけんさんのように適切な理由があれば、他の設計指針を使ってもよいと思っています。
よこけんさんの Web サイトの方、今、拝見させていただきました。なかなか熱い議論があるようで^^。ちょっと追加で捕捉させてください。
私のエントリでは話を単純化する目的で書いていないのですが、業務エラーと例外に関するもう一つ重要なトピックとして、「何を業務エラーと見なすのか」(何をフローチャート上の想定ケースとするのか)というポイントがあります。
例えば、(ちょっと TryParse は忘れるという前提で)int.Parse() メソッドを考えてみると、
・「入力文字列が不正フォーマットである」という事態は十二分に考えられる。だから「考えられるかどうか」という意味でいえば十分に想定範囲内。また、多くの場合には業務エラー的に扱う必要がある。
・けれども、いつでもそうとは限らない。確実に数字であることが保障されている文字をパースしたい場合、むしろ「入力文字列が不正フォーマットである」ケースを業務エラーとしてライブラリ設計されるとうっとおしい。
という状況があり、結果として、
・「入力文字列が不正フォーマットである」という事態は例外として扱う。
・もしこれが業務エラーに相当するのであれば、try-catch によるフロー修正を行う。
という形になっているのだと私は理解しています。(実際、.NET の BCL はそうやって設計されているので、業務アプリの実装がスマートになる)
実際、BCL みたいなものは、利用者側(=アプリケーション側)の都合によって、何が業務エラーで何がシステムエラーなのかが大きく変わるために、BCL 側の適当な判断で「これは業務エラー『のはず』」とか設計されると、ものすごく面倒になることがしばしばあります。(この典型例が Java の検査例外の SQLException と RemoteException) だから、BCL みたいなものは、「とりあえずことごとくシステムエラー扱いにして API 設計する」としてもらった方が、利用者側からすると便利になるんですよね。
でも、これと同じ議論は業務アプリには当てはまらないと思います。例えば業務アプリの UI, BC, DAC を考えてみると、
・UI, BC, DAC の実装者は非常に近いところにいる。
・BCL と違い、I/F 設計の汎用性を考える必要性が薄い。
・むしろメソッド約款を明確化し、かちっと作る方がよい。(=呼び出し側の都合で、あるケースが業務エラーかシステムエラーかの判断がぶれるという事態は少ないし、それは業務アプリ設計のブレを意味する)
ということがあります。なので、BCL と同様の議論に基づいて「業務エラー表現に『とりあえず』例外を使っておき、ハンドルするか否か(=そのケースを業務エラーとみなすかシステムエラーとみなすのか)は呼び出し元に任せる」という考え方は当てはまらないと考えます。
となると本質的な問題は、「業務エラーケース(=呼び出し元との間で「約款」として定められるべきケース)をどうやって表現するのが最も適切なのか?」 という議論であると思います。が、結局のところ、その議論に対する万人が納得しうる正解は「Java の検査例外に相当する仕組みがあること」なので、この話って誰もが納得する一意の答えは得られないのではないか、と……残念ながら;。
ご返答ありがとうございます。
> はい、アーキテクチャ次第なので、この通り「でなければならない」という類のものではないと思います。
> 「絶対に」という表現を使ってはいますが、これはその方が(大半の人には)分かりやすいと思ったからで、
なるほど、そういうことでしたか、納得です。
> > 発生した業務エラーを格納する Errors プロパティ
> これは、BC はステートレスであるべきというコンセプトから避けています。
こんな感じでインスタンスを毎回生成しても NG ですか?
CustomerRegisterLogic biz = new CustomerRegisterLogic();
biz.Id = tbxId.Text;
biz.Name = tbxName.Text;
biz.Mail = tbxMail.Text;
biz.Birthday = DateTime.Parse(tbxBirthday.Text);
biz.Execute();
switch (biz.Error) {
case CustomerRegisterLogicErrors.None:
case CustomerRegisterLogicError.DuplicateCustomerIDError:
throw ApplicationException("想定外の結果が返ってきました。" + biz.Error.ToString());
> 上位レイヤでは、予め下位レイヤが想定外の列挙値を返してくるようなら自爆コードを書かなければなりません。
確かにその通りなんですが、使う側にその責任が移動してしまうことにちょっとだけ抵抗があるんですよね…。(無視できるレベルですけど^^;)
> よこけんさんの Web サイトの方、今、拝見させていただきました。なかなか熱い議論があるようで^^。
わざわざブログをご覧になって下さったとは、ありがとうございます。
以前は「業務エラーは例外で表現すべき」と考えていたのですが (http://csharper.blog57.fc2.com/blog-entry-99.html)、最近は違う考え (http://csharper.blog57.fc2.com/blog-entry-236.html) になって、更に今回のとあるコンサルタントさん (名前は伏せておいた方がいいんですよね?^^;) とのやり取りでまた色々学んだこと・考えさせられることがあり…という具合です (苦笑)
> でも、これと同じ議論は業務アプリには当てはまらないと思います。例えば業務アプリの UI, BC, DAC を考えてみると、
> ・UI, BC, DAC の実装者は非常に近いところにいる。
> ・BCL と違い、I/F 設計の汎用性を考える必要性が薄い。
> ・むしろメソッド約款を明確化し、かちっと作る方がよい。(=呼び出し側の都合で、あるケースが業務エラーかシステムエラーかの判断がぶれるという事態は少ないし、それは業務アプリ設計のブレを意味する)
> ということがあります。なので、BCL と同様の議論に基づいて「業務エラー表現に『とりあえず』例外を使っておき、ハンドルするか否か(=そのケースを業務エラーとみなすかシステムエラーとみなすのか)は呼び出し元に任せる」という考え方は当てはまらないと考えます。
確かに汎用性はあまり考えなくても良いとは思いますが、今まで業務エラーとして扱っていなかったものを後から業務エラーとして扱うことになった時に、全体の見直しが必要となってしまうのは一つのデメリットかなと思います。(もちろん、「だからダメ」なんて主張をする気は毛頭ありません。その時の状況に応じてメリットとデメリットを天秤にかける、ですね。)
ちなみに、僕が好んでいるアーキテクチャでは、アプリケーションとしての動作を上位レイヤで担当し、下位レイヤではほとんど意識しないようにしています。この場合、下位レイヤが (アプリケーションの動作の一種である) 業務エラーを意識する必要がありません。
僕は、例外は本来、「メソッドが本来の目的を果たせなかったことを通知するためのもの」だと思っています。下位レイヤではこの思想に純粋に従うだけです。事前検証が可能ならば事前検証メソッド (や状態プロパティなんか) を用意します。事前検証を用意したら例外は InvalidOperationException 等を、用意しないのならば厳密な型の例外を投げます。
下位レイヤは基本的に、「例外は投げるが事前検証で回避すべきである」という考え方です。
> となると本質的な問題は、「業務エラーケース(=呼び出し元との間で「約款」として定められるべきケース)をどうやって表現するのが最も適切なのか?」という議論であると思います。が、結局のところ、その議論に対する万人が納得しうる正解は「Java の検査例外に相当する仕組みがあること」なので、この話って誰もが納得する一意の答えは得られないのではないか、と……残念ながら;。
そうですね、誰もが納得する一意の答えは得られないですね。
ただ、Java の検査例外があっても、答えは唯一にはならないんじゃないかなぁと思います。僕が下位レイヤに事前検証メソッド (や状態プロパティなんか) を積極的に用意するのは、使いやすさ (処理の実行はしないけど検証したい、状態を知りたい、って時もあると思います) や可読性の観点からですので、僕の場合は検査例外があったとしても「全て検査例外で」とはならない気がします。(実際に使ってみるとまた違う答えになるかもですが…w)
よこけんさん、書き込みありがとうございます。つらつらと。
> こんな感じでインスタンスを毎回生成しても NG ですか?
はい、というかこの設計は(例外や業務エラー云々を抜きにして)避けるべきだと私は考えます。この設計の場合、ビジネスロジッククラスはステートフルになりますが、よこけんさんの例でいうと、
・.Execute() メソッド
・.Error プロパティ
については、内部的に「不正な順番でアクセスされた場合に対する対処コード」を記述しなければならなくなります。(例:.Execute() の前に .Error を呼び出された場合には InvalidOperationException を投げるとか。) それはめんどいので、ステートレス設計にしてしまおう、と。(極端なことを言うと、ビジネスロジッククラスについては「全部 static なメソッドで書く」ぐらいの勢いでもよい、と私は思っています。)
> 僕が下位レイヤに事前検証メソッド (や状態プロパティなんか) を積極的に用意するのは、使いやすさ (処理の実行はしないけど検証したい、状態を知りたい、って時もあると思います) や可読性の観点からですので、僕の場合は検査例外があったとしても「全て検査例外で」とはならない気がします。(実際に使ってみるとまた違う答えになるかもですが…w)
ちなみに個人的には、事前検証メソッドや状態プロパティなどの確認コードを用意することは、どちらかというと避けます。(メソッドの数などがむやみに増えるため) たぶん正解は、
・言語仕様として、検査例外の仕組みを導入する。(← .NET では×、Java では◎)
・汎用性の高いライブラリ(クラスライブラリ)については、検査例外を使わず、すべて実行時例外で表現する。(← .NET では◎、Javaでは×)
・業務アプリレベルでは、業務エラーの表現に、検査例外(またはそれに相当する仕組み)を利用する。
です。ポイントは、「callerとcalleeが近くにいない場合には、何を業務エラー、何をアプリエラーとすべきかを、callee側の一方的な判断で決められない」ということで、Java のライブラリが多少使いにくいのはその辺に原因があります。すべてのメリットをカバーした言語やライブラリが存在しないことが悩みの種ですねぇ....。
と、それはともかく。
> とあるコンサルタントさん (名前は伏せておいた方がいいんですよね?^^;)
まあすでにバレバレですが(笑)、nakama さん、とか nakama っち、とか呼んでもらえればおっけーですw。
というわけで、前回まで 3 回(+α)に渡って .NET の例外処理の適切な書き方について解説してきましたが、ここまでの解説にもまして重要なのは、 表示またはロギングされた例外情報を、正確に読み取れるようになること。