🍣

.NET 6 の Blazor で改善されたエラー処理を使ってみる aka ErrorBoundary

2021/09/20に公開

.NET 6 RC1 が先日リリースされました。go-live がついたので色々気になってた所とかをいじっていきたいと思います。

MAUI が個人的には気になってたんですが、すこし先のリリースがアナウンスされたので暫くは Blazor WebAssembly を中心にいじっていこうと思います。ということで今回は個人的に一番気に入ってるエラーハンドリング関連の機能についてピックアップしてみようと思います!!

エラーハンドリング(.NET 6 より前の世界)

例外処理はアプリケーションを組んでいるとメンドクサイけど必ず実装しなければいけない処理の 1 つです。
.NET 5 までの Blazor WASM では、エラー処理は頑張るしかありませんでした。

どのように頑張るかというと、例えばボタンクリックの処理などのイベントハンドラーなどで自分たちで try-catch を書いて頑張るといった感じです。
以下のようなイメージですね。

private void OnClick()
{
    try
    {
        // 何らかの処理
    }
    catch (SomeException ex)
    {
        // 何か例外から復旧する処理 or もうだめなら諦める処理
    }
}

もし、例外を catch しなかった場合は Blazor によって自動的に blazor-error-ui css クラスに display; block が適用されて、該当タグが表示されます。プロジェクト テンプレートでは index.html で以下のようなタグが定義されています。

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

多分、すこしでも Blazor をいじってる人は以下のようなバーが出るという体験をしている人が多いのではないかと思います。

もうちょっと頑張ってグローバルに例外を処理する方法という内容がドキュメントに記載されていますが、これも結局以下のような try-catch を各所に書く必要がありました。

try
{
    ...
}
catch (Exception ex)
{
    Error.ProcessError(ex);
}

https://docs.microsoft.com/ja-jp/aspnet/core/blazor/fundamentals/handle-errors?view=aspnetcore-5.0&pivots=webassembly

.NET 6 の世界

.NET 6 では ErrorBoundary という新しいコンポーネントが追加されて、任意の範囲のコンポーネント以下で発生した例外に対して何らかの処理や UI のカスタマイズが出来るようになっています。

使い方は簡単です、エラーが発生したときの表示を切り替えたい範囲を ErrorBoundary コンポーネントで括るだけです。例えばページでエラーが発生したらページの表示をエラーが起きたことがわかるようにしたいといった場合は以下のように MainLayout.razor の @BodyErrorBoundary で括ることで実現できます。
ChildContent で通常時の表示、ErrorContent でエラー時の表示を定義できます。ErrorContent では context という変数に例外が入っているので、ここで色々見た目を変えたりとかが出来ます。

実際に定義すると以下のようになります。

MainLayout.razor
@using System.Text.Json
@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @* ページの Body で例外が出たら、例外が出たことがわかるようなメッセージに切り替える。 *@
            <ErrorBoundary>
                <ChildContent>
                    @Body
                </ChildContent>
                <ErrorContent>
                    <p>何もしてないのに壊れた!!「@context.Message」</p>
                </ErrorContent>
            </ErrorBoundary>
        </article>
    </main>
</div>

では動作確認をしてみましょう。Index.razor を以下のように変更して、押したら絶対例外が出るボタンを追加してみます。

Index.razor
@page "/"

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

<div>
    <button @onclick="InvokeClick">押したら壊れるので押さないでください</button>
</div>

@code {
    private void InvokeClick(MouseEventArgs args)
    {
        throw new InvalidOperationException("何かエラー");
    }
}

実行すると以下のようになります。

ボタンを押すと以下のように ErrorBoundary コンポーネントの ErrorContent に設定した見た目が表示されていることがわかります。

@context.Message で、ちゃんと例外のメッセージが取れていることもわかります。

因みに ErrorContent を指定しない場合は以下のようにタグを定義できます。

<ErrorBoundary>
    @Body
</ErrorBoundary>

この場合は例外発生時に以下のようなデフォルトのエラーが表示されます。

エラーからの復帰

一度エラーが発生してしまうと ErrorBoundary は ErrorContent の内容を表示し続けます。今回みたいにページ全体を囲ってしまったら一度エラーが起きると別のページ(Counter や Fetch data) に移動しても ErrorContent の内容が表示されてしまいます。

これだと困るので、ErrorBoundary にはエラー状態からリカバリーする Recover メソッドが定義されています。このメソッドを呼び出すと ErrorContent ではなく通常の ChildContent が表示されます。なのでエラーをリセットしたいタイミングで、このメソッドを読んであげる必要があります。

今回の例だと、ページ遷移時には必ずリセットされてほしいので OnParameterSet で Recover を呼んであげるといい感じになります。やってみましょう。

MainLayout.razor
@using System.Text.Json
@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @* ページの Body で例外が出たら、例外が出たことがわかるようなメッセージに切り替える。 *@
            <ErrorBoundary @ref="_errorBoundary">
                <ChildContent>
                    @Body
                </ChildContent>
                <ErrorContent>
                    <p>何もしてないのに壊れた!!「@context.Message」</p>
                </ErrorContent>
            </ErrorBoundary>
        </article>
    </main>
</div>

@code {
    // @ref でコンポーネントをフィールドに保持
    private ErrorBoundary? _errorBoundary;
    protected override void OnParametersSet()
    {
        // OnParametersSet のタイミングでエラーからリカバリー
        _errorBoundary?.Recover();
    }
}

こうすると、一度エラーが起きても画面遷移を行うとエラーの表示から通常の表示になります。
他にも、エラー画面内にボタンを置いて明示的にリカバリーをさせるようにユーザーに促すこともできます。例えば以下のような感じで。

MainLayout.razor
@using System.Text.Json
@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @* ページの Body で例外が出たら、例外が出たことがわかるようなメッセージに切り替える。 *@
            <ErrorBoundary @ref="_errorBoundary">
                <ChildContent>
                    @Body
                </ChildContent>
                <ErrorContent>
                    <p>何もしてないのに壊れた!!「@context.Message」</p>
                    <div>
                        @* ボタンを押したらエラーからリカバリーする *@
                        <button @onclick="() => _errorBoundary?.Recover()">やり直す</button>
                    </div>
                </ErrorContent>
            </ErrorBoundary>
        </article>
    </main>
</div>

@code {
    // @ref でコンポーネントをフィールドに保持
    private ErrorBoundary? _errorBoundary;
    protected override void OnParametersSet()
    {
        // OnParametersSet のタイミングでエラーからリカバリー
        _errorBoundary?.Recover();
    }
}

この状態で実行すると以下のようになります。エラーが起きても、その場でユーザーアクションにより復帰できてることがわかります。

これまでの例ではエラー時の表示を完全に別物にしていますが、以下のように @Body を書いてあげることで通常の画面も出しつつエラーがあったことを伝えることも出来ます。

MainLayout.razor
@using System.Text.Json
@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @* ページの Body で例外が出たら、例外が出たことがわかるようなメッセージに切り替える。 *@
            <ErrorBoundary @ref="_errorBoundary">
                <ChildContent>
                    @Body
                </ChildContent>
                <ErrorContent>
                    @* エラーも出しつつ、普通の内容も出す *@
                    <p>何もしてないのに壊れた!!「@context.Message」<a href="" @onclick="() => _errorBoundary?.Recover()">わかった</a></p>
                    @Body
                </ErrorContent>
            </ErrorBoundary>
        </article>
    </main>
</div>

@code {
    // @ref でコンポーネントをフィールドに保持
    private ErrorBoundary? _errorBoundary;
    protected override void OnParametersSet()
    {
        // OnParametersSet のタイミングでエラーからリカバリー
        _errorBoundary?.Recover();
    }
}

この状態でエラーを起こすと以下のような画面になります。「わかった」というリンクを押すと Recover メソッドが呼ばれて通常の見た目になります。

これまでの例のようにページ全体を ErrorBoundary で囲う以外にもリストの 1 項目単位で ErrorBoundary で囲ったり、任意の領域を囲うことが出来るので細かな制御が出来るようになり、これまでのエラー処理よりもかなり改善されました。全部の処理に try-catch 書かなくてもよくなるというのは個人的にはとても大きなメリットだと感じてます!

もっとカスタムしたい

一般的な例外発生時の処理は ErrorBoundary をそのまま使えばいいと思うのですが、ErrorBoundary を継承したカスタムコンポーネントを作ることで、もうちょっとカスタムの幅がひろがります。

例えば以下のようにデフォルトのエラーコンテンツの内容を変えたり、OnErrorAsync をオーバーライドして、例外発生時に何らかのカスタム処理を入れることが出来ます。

MyErrorBoundary.razor
@inherits ErrorBoundary

@if (CurrentException is null)
{
    @ChildContent
}
else if (ErrorContent is not null)
{
    @ErrorContent(CurrentException)
}
else
{
    <div>俺のオリジナル デフォルト エラー コンテンツ</div>
}

@code {
    protected override async Task OnErrorAsync(Exception exception)
    {
        await base.OnErrorAsync(exception);

        // 例外発生時の何らかの処理
    }
}

実際に何か大き目のアプリを作るときは、ErrorBoundary を継承したカスタムコンポーネントを作っておいて、皆に使ってもらうようにするといいのかなと思います。

まとめ

グローバル エラーハンドリング的なものを C# の世界でやる方法が今までは無かったのですが、ErrorBoundary のおかげで色々出来るようになりそうです。これは捗る。

Microsoft (有志)

Discussion