📖

.NET Core Blazor + MicroCMSでブログサイト作成⑨

2021/09/16に公開

遅くなりました。自宅にGitBucket構築したりSamba立てたりしてました。

今回の内容はエラー処理です。
クライアント側とサーバー側で少し違うので、それぞれ記述していきます。

クライアント側

404 Not Found

親の顔ほど見たエラーです。
ちょっと古い情報サイトのリンクを踏んだ時、404じゃねぇか!!となることもしばしば。

実はBlazorテンプレートにはページが見つからなかったときの処理がすでに書かれています。
なので適当にアクセスするとそれらしいメッセージが出てきます。


   👇

すいちゃん今日も小さ~~い!

これの記述がどこにあるかというと、BlazorBlog.Client/App.razorです。

App.razor
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

いままで触ってこなかったファイルがようやく登場しました。👏
見たまんまですが、Foundはリンク先が存在したとき、NotFoundが存在しなかったときのコンポーネントです。
NotFound内ではMainLayout.razorのレイアウトをベースに、中身(reactでいうchildren)に「Sorry, ~」のメッセージを埋め込むようになっています。

ゴッソリ中身を変えちゃいましょう。

App.razor
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <div class="d-flex flex-row">
                <div class="col-12 col-md-8">
                    <div class="mb-2">
                        <i class="bi bi-info-circle" style="font-size: 2.5rem;"></i><span class="h3" style="vertical-align: top; margin-left: 12px">ページが見つかりません。</span>
                    </div>
                    <div class="mb-2">
                        検索中のページは、削除された、名前が変更された、または現在利用できない可能性があります。
                    </div>
                    <hr />
                    <div class="mt-2">
                        <div class="mb-2">
                            次のことを試してください。
                        </div>
                        <ul>
                            <li>アドレスバーにページアドレスを入力した場合は、ページアドレスを正しく入力したかどうかを確認してください。</li>
                            <li><a href="/" class="link-primary">ホームページ</a>を開いてから、表示する情報へのリンクを探してください。</li>
                            <li>別のリンク先を表示するには、<i class="bi bi-arrow-left-square"></i><a href="" class="link-primary" onclick="javascript:window.history.back(-1);return false;">[戻る]</a>ボタンをクリックしてください。</li>
                            <li><i class="bi bi-search"></i>[検索]ボタンをクリックして、インターネット上の情報を検索してください。</li>
                        </ul>
                    </div>
                    <div class="mt-2">
                        <p>HTTP 404 - ファイル未検出</p>
                        <p>Blazor Blog</p>
                    </div>
                </div>
            </div>
        </LayoutView>
    </NotFound>
</Router>

サーバー側

サーバー側のプログラムはクライアント側と完全に切り離されており、サーバー側で起きたエラーを(メッセージに詰め込んだりして)例外を投げて知らせた場合、クライアント側に例外が渡されずサーバー側の中でキャッチしきってしまいます
そのためすべてのエラーが500 Server Errorに丸められてしまいます。
まぁ当然っちゃ当然ですわね。

以下、実験でエラーを発生させたスクショです。

MicroCMSController.cs
// 抜粋 今後実装する自己紹介ページのアクションメソッド。作りは他と全く同じ。
        [Route("About")]
        public async Task<AboutModel> GetAboutContents()
        {
            string url = apiSettings.BaseUrl + apiSettings.Endpoints.About
                + "?" + apiSettings.QueryParam.About;
            string apikey = apiSettings.ApiKey + "aa"; //X-API-KEYを改造

            AboutModel result = await Fetch<AboutModel>(url, apikey);

            return result;
        }

自己紹介ページを開いて、レスポンスを受け取った直後でデバッグを停止します。

ステータスが401 Unauthorizedになっています。
このまま直後の例外スローを行うと...

何ということでしょう!匠の技によって500に丸められてしまいました!!

ということで、サーバーで何かあったとき例外を投げるという手段は無効と分かったので、レスポンスステータスをモデルクラスに追加し、クライアント側でステータスコードを見て処理を分けて行こうと思います。

ステータスインターフェイス

モデルが持つ内容とレスポンスステータスは別物なので、インターフェイスを使って機能追加します。

.ShareにInterfaceフォルダを作って配置します。

IResponseStatus.cs
namespace BlazorBlog.Shared.Interface
{
    public interface IResponseStatus
    {
        // 正常ならtrue、異常ならfalse。初期値は0(false)なので注意。
        bool IsSuccess { get; set; }

        // ステータスコード(404とか)
        string Code { get; set; }

        // ステータス(Not Foundとか)
        string Status { get; set; }

        // レスポンスが正常でなかったときに、画面に表示させるメッセージ
        string GetErrorMessage();
    }
}

各モデルクラスでインターフェイスの実装

例:ブログモデル

BlogModel.cs
// 追加
using BlazorBlog.Shared.Interface;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace BlazorBlog.Shared.MicroCMS
{
    // インターフェイスを実装
    public class BlogModel : IResponseStatus
    {
        [JsonPropertyName("contents")]
        public List<BlogContents> BlogList { get; set; }

        public int TotalCount { get; set; }

        public int Offset { get; set; }

        public int Limit { get; set; }

        // ここから追加
        public bool IsSuccess { get; set; }

        public string Code { get; set; }

        public string Status { get; set; }

        public string GetErrorMessage()
        {
            return $"ブログデータの取得に失敗しました。Code:{Code},Status:{Status}";
        }
    }

    public class BlogContents : IResponseStatus
    {
        public string Id { get; set; }

        public DateTime PublishedAt { get; set; }

        public string Title { get; set; }

        public TagContents Tag { get; set; }

        public string Body { get; set; }

        // ここから追加
        public bool IsSuccess { get; set; }

        public string Code { get; set; }

        public string Status { get; set; }

        public string GetErrorMessage()
        {
            return $"ブログ本文データの取得に失敗しました。Code:{Code},Status:{Status}";
        }
    }
}

他のモデルクラスにも同じように追加します。

レスポンスステータスを格納

レスポンスステータスが正常でなかった場合、ステータスを格納してデータは一切空の状態でクライアントに返します。正常だった場合は今まで通り取得データを詰めてクライアントに返します。

MicroCMSController.cs
// 抜粋
        // whereは後述
        private static async Task<T> Fetch<T>(string url, string apikey) where T : IResponseStatus, new()
        {
            using var client = new HttpClient();
            // リクエストヘッダーにX-API-KEYを追加
            var request = new HttpRequestMessage(HttpMethod.Get, url);
            request.Headers.Add("X-API-KEY", apikey);

            // リクエスト送信
            HttpResponseMessage response = await client.SendAsync(request);

            // ステータスが正常でない場合、ステータスを格納して返す
            if (!response.IsSuccessStatusCode)
            {
                var errorResult =  new T
                {
                    IsSuccess = false,
                    Code = ((int)response.StatusCode).ToString(),
                    Message = response.ReasonPhrase
                };
                return errorResult;
            }

            // レスポンスから結果を取り出し
            var result = await response.Content.ReadAsStringAsync();

            // デシリアライズ(プロパティ名の大文字/小文字の差異を無視)
            var options = new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true
            };
            T deserialisedResult = JsonSerializer.Deserialize<T>(result, options);
	    // 取得は正常なのでtrueを入れる
            deserialisedResult.IsSuccess = true;

            return deserialisedResult;
        }

What is "where"?

これまで型引数Tはどんなクラスでも受け付けるようになっていました。
しかし今回の修正で、Tクラスには次の2つの条件が課されることになります。

  1. IResponseStatusを実装していること
  2. newできること

1つ目の条件は、単純にIResponseStatusを実装していないとIsSuccessとかが未定義だと言われてしまうためです。
2つ目の条件は、どんなクラスでも受け付けるということは抽象クラスも受け付けるということです。抽象クラスが指定された場合newすることはできないので、newできるクラス(具象クラス?)しか受け付けませんという制限をつける必要があります。
これらを記述した部分がwhere T : IResponseStatus, new()です。このような機能をジェネリック型制約といいます。医薬品みたいな名前です。

https://docs.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/where-generic-type-constraint

試しにそれぞれの制約をはずすと、コンパイルエラーになります。

エラーメッセージを画面表示

とりあえずアラートを出せばいいんじゃないすか(適当)。

BlogTop.razor
// 抜粋
    protected override async Task OnInitializedAsync()
    {
        try
        {
            //Task作成
            Task<SiteDataModel> taskSiteData = Http.GetFromJsonAsync<SiteDataModel>("MicroCMS/SiteData");
            Task<TagModel> taskTag = Http.GetFromJsonAsync<TagModel>("MicroCMS/TagList");
            Task<BlogModel> taskBlog = Http.GetFromJsonAsync<BlogModel>("MicroCMS/BlogList");

            // 並列処理
            await Task.WhenAll(taskSiteData, taskTag, taskBlog);

            if (!(taskSiteData.Result.IsSuccess && taskTag.Result.IsSuccess && taskBlog.Result.IsSuccess))
            {
                // MicroCMSからのレスポンスが正常でなかった場合
                if (!taskSiteData.Result.IsSuccess)
                {
                    await JSRuntime.InvokeVoidAsync("alert", taskSiteData.Result.GetErrorMessage());
                }
                if (!taskTag.Result.IsSuccess)
                {
                    await JSRuntime.InvokeVoidAsync("alert", taskTag.Result.GetErrorMessage());
                }
                if (!taskBlog.Result.IsSuccess)
                {
                    await JSRuntime.InvokeVoidAsync("alert", taskBlog.Result.GetErrorMessage());
                }
                await JSRuntime.InvokeVoidAsync("alert", "トップページに戻ります。");
                Navigation.NavigateTo("/", false);
            }

            // 正常だった場合のみ結果取り出し
            sitedata = taskSiteData.Result;
            tagList = taskTag.Result.TagList;
            allBlogContentsList = taskBlog.Result.BlogList;
            filteredBlogContentsList = taskBlog.Result.BlogList;
        }
        catch (Exception ex)
        {
            // Server側でエラーが発生した場合
            await JSRuntime.InvokeVoidAsync("alert", ex.Message);
            await JSRuntime.InvokeVoidAsync("alert", "トップページに戻ります。");
            Navigation.NavigateTo("/", false);
        }
    }

動作確認

ブログ記事一覧を取得するエンドポイントをappsettings.jsonで改造してデバッグします。


   👇

   👇

   👇

次にエンドポイントを戻してX-API-KEYを改造します。


   👇

   👇

   👇

   👇

   👇

(・∀・)イェイ!!

レスポンスが正常でもうまくいかない場合がある

これでレスポンスステータスが正常でなかった場合の処理は(多分)完成しました。
しかしステータスが正常でも思うようにデータを取れていないときがあります。
それはクエリパラメータを間違えた場合です。

例えばhttps://blazorblog.microcms.io/api/v1/tag?fields=id,nameにリクエストを送るはずだったのにhttps://blazorblog.microcms.io/api/v1/tag?fields=is,naneとしてしまった場合(id->is, name->nane)、画像のように登録されている件数分、空のデータが送られてきます。

とはいえ、このシリーズの最後に行う予定のデプロイ作業の際に環境変数としてappsettings.jsonの値を登録するので、この現象はプログラムとは無関係な部分に起因するものとなります。もしこの現象が起きても画面が真っ白になるだけです。
よってプログラムの実装不足ではなくただの設定ミスとみなして、今回はプログラム内で対応しないこととしました。

なんか情報処理試験みたいな記述になりました。

次回

一度取得したデータをキャッシュに保存できるようにします。
ページを戻ったときに同じデータを再度取りに行ってしまうので、キャッシュがあればそこから取り、なければフェッチしにいくという処理を追加します。
と思ったのですが、結構面倒そうだし見てる最中に新しい記事が入ってくることもあるかなぁと思ったので(逃げ)、やめました。

次回から一番のヤマである「Herokuにデプロイ」をやっていきたいと思います。

Discussion