SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

現役エンジニア直伝! 「現場」で使えるコンポーネント活用術(SPREAD)(AD)

2022年版実践WPF業務アプリケーションのアーキテクチャ【設計編/中編】 ~ドメイン駆動設計&Clean Architectureとともに

  • X ポスト
  • このエントリーをはてなブックマークに追加

例外処理アーキテクチャ

 つづいて例外処理アーキテクチャについて設計します。例外処理は、WPFとgRPCでまったく異なります。そのため、それぞれ個別に設計していきましょう。

WPFの例外処理アーキテクチャ

 WPFの例外処理は、特別な意図がある場合を除いて、標準で提供されている各種の例外ハンドラーで一括して処理することにします。

 実際問題、起こりうる例外をすべて正しく把握して、個別に設計・実装することはそもそも現実味がありません。特定の例外のみ発生箇所で個別に例外処理をしても、全体としての一貫性が失われることが多いです。また、例外の隠ぺいや必要なログ出力のもれにつながりやすいです。であれば、グローバルな例外ハンドラー系に基本的には任せて一貫した例外処理をまずは提供するべきかと思います。

 ただもちろんすべてを否定するわけではありません。

 例えば、何らかのファイルを操作するときに、別のプロセスによって例外がでることは普通に考えられます。このような場合にシステムエラーとするのではなくて、対象のリソースが処理できなかったことを明確に伝えるために、個別の例外処理をすることは、十分考えられます。

 このように、正常なビジネス処理において起こりうる例外については、そもそもビジネス的にどのように対応するか仕様を明確にして、個別に対応してあげた方が好ましいものも多いでしょう。

 逆に例えば、サーバーサイドのAPIを利用しようとした場合、通信状態が悪ければ例外が発生するでしょう。これらは個別に扱わず、必要であれば適当なリトライ処理の上で、特別な処理は行わずにシステムエラーとしてしまった方が良いでしょう。

  • 業務シナリオとして起こりうるケースの判定に、例外を用いる必要がある場合は個別処理をする。
  • 業務シナリオとは関係なく、システム的な要因による例外は、例外ハンドラーで共通処理をする。

 おおまかな方針としては、こんな感じが好ましいと考えています。ここでは共通の例外ハンドラーの扱いについて設計していきましょう。

例外ハンドリングの初期化

 今回は画面処理フレームワークにKamishibaiをもちいて、WPFアプリケーションはGeneric Host上で動作させます。そのため、例外ハンドリングの初期化はつぎのように行います。

cs
var builder = KamishibaiApplication<TApplication, TWindow>.CreateBuilder();

// 各種DIコンテナーの初期化処理

var app = builder.Build();
app.Startup += SetupExceptionHandler;
await app.RunAsync();

 ビルドしたappのStartupイベントをフックして、アプリケーションが起動した直後にSetupExceptionHandlerを呼び出して、例外ハンドリングを初期化します。

 SetupExceptionHandlerの中では、次の3つのハンドラーを利用して例外処理を行います。

  • Application.Current.DispatcherUnhandledException
  • AppDomain.CurrentDomain.UnhandledException
  • TaskScheduler.UnobservedTaskException

Application.Current.DispatcherUnhandledException

 具体的な実装はつぎの通りです。

cs
Application.Current.DispatcherUnhandledException += (sender, args) =>
{
    Log.Warning(args.Exception, "Dispatcher.UnhandledException sender:{Sender}", sender);
    // 例外処理の中断
    args.Handled = true;

    // システム終了確認
    var confirmResult = MessageBox.Show(
        AdventureWorks.Wpf.ViewModel.Properties.Resources.SystemErrorOccurredConfirm,
        AdventureWorks.Wpf.ViewModel.Properties.Resources.SystemErrorOccurredCaption,
        MessageBoxButton.YesNo,
        MessageBoxImage.Warning,
        MessageBoxResult.Yes);
    if (confirmResult == MessageBoxResult.No)
    {
        Environment.Exit(1);
    }
};

 例外情報をログに出力したあと、例外処理を中断します。

 その後に、システムの利用を継続するかどうか、ユーザーに確認を取り、継続が選ばれなかった場合はアプリケーションを終了します。

 WPFの例外ハンドラーは、基本的にはApplication.DispatcherUnhandledExceptionで例外を処理します。Application.DispatcherUnhandledExceptionでは例外チェーンを中断できますが、それ以外では中断できないためです。

 Environment.Exit(1)を呼び出さなくても、最終的にはアプリケーションは終了します。しかし、Environment.Exit(1)を呼び出さないと、つづいてAppDomain.CurrentDomain.UnhandledExceptionが呼び出されます。例外の2重処理になりやすいため、明示的に終了してしまうのが好ましいでしょう。

AppDomain.CurrentDomain.UnhandledException

 先のApplication.DispatcherUnhandledExceptionでは、つぎのように、明示的に作成したThreadで発生した例外は補足できません。

cs
var thread = new Thread(() =>
{
    throw new NotImplementedException();
});
thread.Start();

 この場合は、AppDomain.UnhandledExceptionを利用して例外を補足します。

 AppDomain.CurrentDomain.UnhandledExceptionでは、次のようにログ出力の後に、ユーザーにエラーを通知してアプリケーションを終了します。AppDomain.CurrentDomain.UnhandledExceptionでは例外チェーンを中断できず、この後アプリケーションは必ず終了されるため、確認はせずに通知だけします。

cs
AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
    Log.Warning(args.ExceptionObject as Exception, "AppDomain.UnhandledException sender:{Sender}", sender);
    
    // システム終了通知
    MessageBox.Show(
        AdventureWorks.Wpf.ViewModel.Properties.Resources.SystemErrorOccurredAlert,
        AdventureWorks.Wpf.ViewModel.Properties.Resources.SystemErrorOccurredCaption,
        MessageBoxButton.OK,
        MessageBoxImage.Error,
        MessageBoxResult.OK);

    Environment.Exit(1);
};

 このとき、Environment.Exit(1)を呼び出すことで、Windowsのアプリケーションのクラッシュダイアログの表示を抑制します。

TaskScheduler.UnobservedTaskException

 つぎのようにTaskをasync/awaitせず、投げっぱなしでバックグラウンド処理した際に例外が発生した場合は、TaskScheduler.UnobservedTaskExceptionで補足します。

cs
private void OnClick(object sender, RoutedEventArgs e)
{
    Task.Run(() =>
    {
        throw new NotImplementedException();
    });
}

 ただTaskScheduler.UnobservedTaskExceptionは例外が発生しても即座にコールされないため注意が必要です。ユーザーの操作とは無関係に、「いつか」発行されるため、ユーザーに通知したり、アプリケーションを中断しても混乱を招くだけです。

 未処理の例外は全般的に、あくまで最終手段とするべきものですが、特にTaskScheduler.UnobservedTaskExceptionは最後の最後の保険と考えて、つぎのようにログ出力程度に留めておくのが良いでしょう。

cs
TaskScheduler.UnobservedTaskException += (sender, args) =>
{
    Log.Warning(args.Exception, "TaskScheduler.UnobservedTaskException sender:{Sender}", sender);
    args.SetObserved();
};

 SetObservedは .NET Framework 4以前はアプリケーションが終了してしまうことがありましたが、現在は呼ばなくても挙動は変わらないはずです。一応念のため呼んでいます。

gRPCの例外処理アーキテクチャ

 Web APIで何らかの処理を実行中に例外が発生した場合、通常はリソースを解放してログを出力するくらいしかできません。ほかにできることといえば、外部リソース(例えばデータベース)を利用中に例外が発生したのであればリトライくらいでしょうか?

 特殊なことをしていなければリソースの解放はC#のusingで担保するでしょうし、解放漏れがあったとしても例外時にフォローすることも難しいです。そのため実質的にはログ出力くらいです。

 MagicOnionを利用してgRPCを実装する場合、通常はASP.NET Core上で開発します。ASP.NET Coreで開発している場合、一般的なロギングライブラリであれば、APIの例外時にはロガーの設定に則ってエラーログは出力されることが多いでしょう。

 では何もする必要がないのでしょうか? そんなことはありません。Web APIの実装側でも別途ログを出力しておくべきです。これはASP.NET Coreレベルでのログ出力では、接続元のアドレスは表示できても、例えば認証情報のようなデータはログに出力されないためです。誰が操作したときの例外なのか、障害の分析には最重要情報の1つです。ASP.NET Coreレベルのログも念のため残しておいた方が安全ですが、アプリケーション側ではアプリケーション側で例外を出力しましょう。

 MagicOnionを利用してこのような共通処理を組み込みたい場合、認証のときにも利用したMagicOnionFilterAttributeを利用するのが良いでしょう。

 このとき認証のときに利用したフィルターに組み込んでも良いのですが、つぎのように認証用のフィルターの後ろに例外処理用のフィルターを配置した方が良いと考えています。

 これは認証とログ出力は別の関心ごとだからです。関心の分離ですね。ログ出力を修正したら認証が影響を受けてしまった、またはその逆のようなケースを防ぐためには、別々に実装しておいて組み合わせたほうが良いでしょう。

 具体的な実装はつぎの通りです。

cs
public class ExceptionFilterAttribute : MagicOnionFilterAttribute
{
    private readonly ILogger<ExceptionFilterAttribute> _logger;
    private readonly IAuthenticationContext _authenticationContext;

    public ExceptionFilterAttribute(
        ILogger<ExceptionFilterAttribute> logger, 
        IAuthenticationContext authenticationContext)
    {
        _logger = logger;
        _authenticationContext = authenticationContext;
    }

    public override ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
    {
        try
        {
            return next(context);
        }
        catch (Exception e)
        {
            // 例外情報をログ出力した後に再スローする。
            _logger.LogError(・・・・
            throw;
        }
    }
}

 ILoggerとIAuthenticationContextをDIコンテナーから注入することで、認証情報を活用したログ出力が可能となります。具体的なログ出力については、つぎの章で詳細を設計しましょう。

次のページ
ロギングアーキテクチャ

関連リンク

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
現役エンジニア直伝! 「現場」で使えるコンポーネント活用術(SPREAD)連載記事一覧

もっと読む

この記事の著者

中村 充志(リコージャパン株式会社)(ナカムラ アツシ)

 Microsoft MVP for Visual Studio and Development Technologies リコージャパン株式会社 金融事業部 金融ソリューション開発部所属。 エンタープライズ領域での業務システム開発におけるアプリケーション アーキテクト・プログラマおよび中間管理職。 業務ではWPFおよびASP.NETを用いた業務システム開発が中心。 SI案件において、テスト・保守容易性を担保した、アーキテクチャ構築を得意としている。 GitHub:https://github.com/nuitsjp Twitter:@nuits_jp 著書 『Essential Xamarin ネイティブからクロスプラットフォームまで モバイル.NETの世界』(共著) 『Extensive Xamarin ─ひろがるXamarinの世界─』(共著)

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

提供:グレープシティ株式会社

【AD】本記事の内容は記事掲載開始時点のものです 企画・制作 株式会社翔泳社

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/17786 2023/06/15 12:00

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング