ASP.NET Core WebApiの戻り結果統一包装の実践

ASP.NET Core WebApiの戻り結果統一包装の実践

WebApiの結果を統一して返す際に、より良い制限方法や、よりシンプルで強力な結果のラッピングについて考えるようになりました。絶えず考えを巡らせ改善する中で、ようやく初歩的な成果を得たので共有します。学びに終わりはなく、考えにも終わりはありません。皆様と共に励みたいと思います。

最終更新 2022/04/13 7:12
yi念之间
読了目安 11 分
カテゴリ
ASP.NET Core
タグ
.NET C# ASP.NET Core Web API

はじめに

最近、ASP.NET Core WebAPI ベースのフレームワークを再構築しているところですが、その過程で多くの収穫がありました。フレームワークを構築しようとするとき、どうしても「どうすればより完璧で使いやすくなるか」と考えてしまいます。特に WebAPI の統一結果返却について、さらに深く考えるようになりました。まず、統一フォーマットをより適切に制限する方法、次に、結果のラッピングをより簡単かつ強力にする方法です。考え抜いて改良を重ねた結果、ようやく初期的な成果が得られたので、ここで共有します。学びに終わりはなく、思考にも終わりはありません。この記事が皆さんの参考になれば幸いです。

統一結果クラスのカプセル化

まず、返される結果のフォーマットを統一するには、すべての返される結果をラップする統一ラッパークラスが必要です。返される具体的なデータのフォーマットは統一されていますが、具体的な値の型は不定です。そのため、ジェネリッククラスを定義する必要があります。もちろん、ジェネリッククラスを選択せずに dynamicobject 型を使用することも可能ですが、これには次の2つの欠点があります。

  • ボックス化/アンボックス化が発生する可能性がある。
  • Swagger を導入した場合、返却される型を生成できない。なぜなら、dynamicobject 型では具体的なアクションの実行時にしか型が確定しないが、Swagger の構造は初回実行時に取得されるため、具体的な型を認識できないからです。

ラッパークラスの定義

前述のジェネリッククラスの利点について述べました。ここでは早速、結果返却用のラッパークラスをカプセル化します。

public class ResponseResult<T>
{
    /// <summary>
    /// ステータス結果
    /// </summary>
    public ResultStatus Status { get; set; } = ResultStatus.Success;

    private string? _msg;

    /// <summary>
    /// メッセージ説明
    /// </summary>
    public string? Message
    {
        get
        {
            // カスタム結果説明がない場合、現在のステータスの説明を取得
            return !string.IsNullOrEmpty(_msg) ? _msg : EnumHelper.GetDescription(Status);
        }
        set
        {
            _msg = value;
        }
    }

    /// <summary>
    /// 返却結果
    /// </summary>
    public T Data { get; set; }
}

ここでの ResultStatus は、具体的な返却ステータスコードを定義するための列挙型で、結果が正常か異常かなどを判断するために使用します。ここでは簡単な例を定義しています。必要に応じて拡張可能です。

public enum ResultStatus
{
    [Description("要求成功")]
    Success = 1,
    [Description("要求失敗")]
    Fail = 0,
    [Description("要求異常")]
    Error = -1
}

このように列挙型を定義し、DescriptionAttribute の特性を利用して各列挙値の意味を説明するのは良い選択です。まず、各ステータスの意味を統一的に管理でき、次に各ステータスに対応する説明を簡単に取得できます。これにより、カスタム結果説明がない場合、現在のステータスの説明をデフォルト値として使用できます。具体的なアクションを記述する際には、次のようになります。

[HttpGet("GetWeatherForecast")]
public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
{
    var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    });
    return new ResponseResult<IEnumerable<WeatherForecast>> {  Data = datas };
}

これで、アクションを作成するたびに ResponseResult<T> の結果を返せるようになりました。ここで列挙型を使ってステータスコードを定義する利点が現れます。多くの場合、ステータスコードやメッセージの記述を省略できます。機能を保証しつつ、コードはシンプルであるほど良いからです。特に頻繁に行う操作ではなおさらです。

操作のアップグレード

上記では ResponseResult<T> を定義して戻り値を統一しましたが、毎回 new するのは不便です。さらに、毎回プロパティに値を代入する手間もかかります。そこで、関連するヘルパーメソッドがあれば操作を簡略化できると考えました。メソッドは多すぎず、シナリオを満たせば十分で、拡張可能であれば最適です。そこで、結果ラッパークラスをさらにアップグレードして操作を簡略化します。

public class ResponseResult<T>
{
    /// <summary>
    /// ステータス結果
    /// </summary>
    public ResultStatus Status { get; set; } = ResultStatus.Success;

    private string? _msg;

    /// <summary>
    /// メッセージ説明
    /// </summary>
    public string? Message
    {
        get
        {
            return !string.IsNullOrEmpty(_msg) ? _msg : EnumHelper.GetDescription(Status);
        }
        set
        {
            _msg = value;
        }
    }

    /// <summary>
    /// 返却結果
    /// </summary>
    public T Data { get; set; }

    /// <summary>
    /// 成功ステータスの返却結果
    /// </summary>
    /// <param name="result">返却データ</param>
    /// <returns></returns>
    public static ResponseResult<T> SuccessResult(T data)
    {
        return new ResponseResult<T> { Status = ResultStatus.Success, Data = data };
    }

    /// <summary>
    /// 失敗ステータスの返却結果
    /// </summary>
    /// <param name="msg">失敗情報</param>
    /// <returns></returns>
    public static ResponseResult<T> FailResult(string? msg = null)
    {
        return new ResponseResult<T> { Status = ResultStatus.Fail, Message = msg };
    }

    /// <summary>
    /// 異常ステータスの返却結果
    /// </summary>
    /// <param name="msg">異常情報</param>
    /// <returns></returns>
    public static ResponseResult<T> ErrorResult(string? msg = null)
    {
        return new ResponseResult<T> { Status = ResultStatus.Error, Message = msg };
    }

    /// <summary>
    /// カスタムステータスの返却結果
    /// </summary>
    /// <param name="status"></param>
    /// <param name="result"></param>
    /// <returns></returns>
    public static ResponseResult<T> Result(ResultStatus status, T data, string? msg = null)
    {
        return new ResponseResult<T> { Status = status, Data = data, Message = msg };
    }
}

ここではいくつかのメソッドをカプセル化しました。具体的にいくつカプセル化するかは、「十分であれば良い」という考え方です。ここではよく使われる操作(成功、失敗、異常、完全)をカプセル化しており、これでほとんどのシナリオをカバーできます。足りない場合はさらにメソッドを追加してください。これにより、アクションで使用する際に手動でプロパティを代入する手間が省け、大幅に簡略化されます。

[HttpGet("GetWeatherForecast")]
public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
{
    var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    });
    return ResponseResult<IEnumerable<WeatherForecast>>.SuccessResult(datas);
}

さらなる改良

上記では ResponseResult<T> クラスのカプセル化を改善することで、ある程度操作を簡略化できました。しかし、まだ不十分な点があります。それは、結果を返すたびに、一般的な静的メソッドを定義したとはいえ、毎回手動で ResponseResult<T> クラスを指定して使用しなければならないことです。さて、結果を返すときにそのクラスを意識したくないという要望があります。これは簡単です。Microsoft の MVC の Controller のカプセル化からヒントを得て、基本となる Controller を定義します。その Controller ベースクラスでよく使われる返却結果をメソッドとしてカプセル化すれば、子クラスで直接これらのメソッドを呼び出せ、戻り値の型を記述する必要がなくなります。早速、Controller ベースクラスをカプセル化しましょう。

[ApiController]
[Route("api/[controller]")]
public class ApiControllerBase : ControllerBase
{
    /// <summary>
    /// 成功ステータスの返却結果
    /// </summary>
    /// <param name="result">返却データ</param>
    /// <returns></returns>
    protected ResponseResult<T> SuccessResult<T>(T result)
    {
        return ResponseResult<T>.SuccessResult(result);
    }

    /// <summary>
    /// 失敗ステータスの返却結果
    /// </summary>
    /// <param name="msg">失敗情報</param>
    /// <returns></returns>
    protected ResponseResult<T> FailResult<T>(string? msg = null)
    {
        return ResponseResult<T>.FailResult(msg);
    }

    /// <summary>
    /// 異常ステータスの返却結果
    /// </summary>
    /// <param name="msg">異常情報</param>
    /// <returns></returns>
    protected ResponseResult<T> ErrorResult<T>(string? msg = null)
    {
        return ResponseResult<T>.ErrorResult(msg);
    }

    /// <summary>
    /// カスタムステータスの返却結果
    /// </summary>
    /// <param name="status"></param>
    /// <param name="result"></param>
    /// <returns></returns>
    protected ResponseResult<T> Result<T>(ResultStatus status, T result, string? msg = null)
    {
        return ResponseResult<T>.Result(status, result, msg);
    }
}

この強力なヘルパーにより、すべてがより良い方向に進みます。これで、カスタム Controller は ApiControllerBase クラスを継承し、その簡略化された操作を使用できます。コードは次のようになります。

public class WeatherForecastController : ApiControllerBase
{
    private static readonly string[] Summaries = new[]
    {
       "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    [HttpGet("GetWeatherForecast")]
    public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
    {
        var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        });
        return SuccessResult(datas);
    }
}

これでかなり良くなりましたが、それでもまだ ResponseResult<T> 型の戻り値を特定のメソッドで取得しなければならないことに変わりはありません。ResponseResult<T> クラスに静的メソッドをカプセル化したり、ApiControllerBase ベースクラスを定義したりしても、すべては操作を簡略化するためです。さて、この制限を取り払いたいと思います。返される結果を自動的に ResponseResult<T> 型に変換することはできないでしょうか?もちろん可能です。これも ASP.NET Core のカプセル化のアイデアから得たもので、implicit を使って暗黙の型変換を自動で行う方法です。これは ASP.NET Core の ActionResult<T> クラスでも見られます。

public static implicit operator ActionResult<TValue>(TValue value)
{
    return new ActionResult<TValue>(value);
}

この考え方に基づいて、ResponseResult<T> クラスの実装をさらに改善し、暗黙の型変換を追加します。ResponseResult<T> クラスに1つのメソッドを追加するだけです。

/// <summary>
/// TからResponseResult<T>への暗黙的変換
/// </summary>
/// <param name="value"></param>
public static implicit operator ResponseResult<T>(T value)
{
    return new ResponseResult<T> { Data = value };
}

これは、ほとんどの成功結果を返す場合に非常に簡略化された操作を提供します。これにより、アクションを使用する際に戻り値の操作をさらに簡略化できます。

[HttpGet("GetWeatherForecast")]
public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
{
    var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    });
    return datas.ToList();
}

T から ResponseResult<T> への暗黙的変換を定義したので、手動で戻り値をラップせずに直接結果を返せます。

漏れの処理

上記では、アクションが ResponseResult<T> の統一戻り値構造を返すように簡略化するために、ResponseResult<T> クラスに多くのカプセル化を施し、さらに ApiControllerBase ベースクラスをカプセル化して操作を簡略化しました。しかし、どうしても避けられない点があります。それは、アクションの戻り値に ResponseResult<T> 型を付けるのを忘れてしまう可能性があることです。これまでのすべてのカプセル化は、戻り値の型として ResponseResult<T> を宣言することを前提としています。そうでなければ、統一された戻り値フォーマットは実現しません。したがって、これらの漏れを捕まえるために、統一的なインターセプト機構が必要です。アクションの戻り値を操作するには、まず「フィルター」を定義することを考えます。そこで、統一ラッピング結果用のフィルターをカプセル化しました。実装は以下の通りです。

public class ResultWrapperFilter : ActionFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext context)
    {
        var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
        var actionWrapper = controllerActionDescriptor?.MethodInfo.GetCustomAttributes(typeof(NoWrapperAttribute), false).FirstOrDefault();
        var controllerWrapper = controllerActionDescriptor?.ControllerTypeInfo.GetCustomAttributes(typeof(NoWrapperAttribute), false).FirstOrDefault();
        // NoWrapperAttribute が含まれている場合、戻り値をラップせずそのまま返す
        if (actionWrapper != null || controllerWrapper != null)
        {
            return;
        }

        // 実際のニーズに応じて実装
        var rspResult = new ResponseResult<object>();
        if (context.Result is ObjectResult)
        {
            var objectResult = context.Result as ObjectResult;
            if (objectResult?.Value == null)
            {
                rspResult.Status = ResultStatus.Fail;
                rspResult.Message = "リソースが見つかりません";
                context.Result = new ObjectResult(rspResult);
            }
            else
            {
                // 戻り値がすでに ResponseResult<T> 型の場合は再ラップしない
                if (objectResult.DeclaredType.IsGenericType && objectResult.DeclaredType?.GetGenericTypeDefinition() == typeof(ResponseResult<>))
                {
                    return;
                }
                rspResult.Data = objectResult.Value;
                context.Result = new ObjectResult(rspResult);
            }
            return;
        }
    }
}

WebAPI を使用する際、アクションの多くは直接 ViewModelDto を返し、ActionResult 関連の型を返しません。しかし、問題ありません。MVC の基本操作がこれらのカスタム型を ObjectResult にラップしてくれるからです。そのため、ResultWrapperFilter フィルターもこのメカニズムを使って動作します。ここで考慮すべき点が2つあります。

  • まず、すべての戻り値を ResponseResult<T> でラップする必要はないということです。この要件を満たすために、NoWrapperAttribute を定義しました。Controller または Action に NoWrapperAttribute が付いている場合、戻り値は加工されません。
  • 次に、Action の戻り値の型がすでに ResponseResult<T> 型である場合、再度ラップする必要はありません。

NoWrapperAttribute の定義は非常にシンプルで、マーカーとしての役割しか持ちません。

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class NoWrapperAttribute:Attribute
{
}

ここで、もう1つの特殊なケースに注意する必要があります。それは、プログラムで例外が発生した場合です。上記のメカニズムは機能しません。そのため、グローバル例外処理のインターセプト機構も定義する必要があります。これも統一例外処理フィルターを使用して実装できます。実装は以下の通りです。

public class GlobalExceptionFilter : IExceptionFilter
{
    private readonly ILogger<GlobalExceptionFilter> _logger;
    public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
    {
        _logger = logger;
    }

    public void OnException(ExceptionContext context)
    {
        // 例外の戻り結果をラップ
        var rspResult = ResponseResult<object>.ErrorResult(context.Exception.Message);
        // ログ記録
        _logger.LogError(context.Exception, context.Exception.Message);
        context.ExceptionHandled = true;
        context.Result = new InternalServerErrorObjectResult(rspResult);
    }

    public class InternalServerErrorObjectResult : ObjectResult
    {
        public InternalServerErrorObjectResult(object value) : base(value)
        {
            StatusCode = StatusCodes.Status500InternalServerError;
        }
    }
}

フィルターを書いたら、必ずグローバルに登録することを忘れないでください。登録しなければ効果はありません。

builder.Services.AddControllers(options =>
{
    options.Filters.Add<ResultWrapperFilter>();
    options.Filters.Add<GlobalExceptionFilter>();
});

漏れの別の処理方法

もちろん、上記2つの漏れの処理に対して、ASP.NET Core ではミドルウェアを使用する方法もあります。フィルターとミドルウェアの違いはよく知られていると思います。その核心的な違いを一言でまとめると次の通りです。

処理段階が異なります。つまり、パイプラインのライフサイクルに対する処理が異なります。ミドルウェアはそれ以降の任意のライフサイクルを処理できますが、フィルターは Controller の領域のみを管理します。

しかし、結果ラッピングのシナリオでは、フィルターの方が扱いやすいと思います。なぜなら、Action の戻り値を操作するために、フィルター内で直接戻り値にアクセスできるからです。ミドルウェアでこれを行うには、Response.Body を読み取る必要があります。こちらも実装をカプセル化しました。以下の通りです。

public static IApplicationBuilder UseResultWrapper(this IApplicationBuilder app)
{
        var serializerOptions = app.ApplicationServices.GetRequiredService<IOptions<JsonOptions>>().Value.JsonSerializerOptions;
        serializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
        return app.Use(async (context, next) =>
        {
            var originalResponseBody = context.Response.Body;
            try
            {
                // Response.Body は直接読み取れないため、特殊な操作が必要
                using var swapStream = new MemoryStream();
                context.Response.Body = swapStream;
                await next();
                // 異常ステータスコードが発生した場合の特別処理
                if (context.Response.StatusCode == StatusCodes.Status500InternalServerError)
                {
                    context.Response.Body.Seek(0, SeekOrigin.Begin);
                    await swapStream.CopyToAsync(originalResponseBody);
                    return;
                }
                var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
                if (endpoint != null)
                {
                    // application/json の結果のみ処理
                    if (context.Response.ContentType.ToLower().Contains("application/json"))
                    {
                        // エンドポイントに NoWrapperAttribute が含まれているか確認
                        NoWrapperAttribute noWrapper = endpoint.Metadata.GetMetadata<NoWrapperAttribute>();
                        if (noWrapper != null)
                        {
                            context.Response.Body.Seek(0, SeekOrigin.Begin);
                            await swapStream.CopyToAsync(originalResponseBody);
                            return;
                        }
                        // Action の戻り値の型を取得
                        var controllerActionDescriptor = context.GetEndpoint()?.Metadata.GetMetadata<ControllerActionDescriptor>();
                        if (controllerActionDescriptor != null)
                        {
                            // ジェネリックの特別処理
                            var returnType = controllerActionDescriptor.MethodInfo.ReturnType;
                            if (returnType.IsGenericType && (returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
                            {
                                returnType = returnType.GetGenericArguments()[0];
                            }
                            // エンドポイントがすでに ResponseResult<T> の場合はラップしない
                            if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ResponseResult<>))
                            {
                                context.Response.Body.Seek(0, SeekOrigin.Begin);
                                await swapStream.CopyToAsync(originalResponseBody);
                                return;
                            }
                            context.Response.Body.Seek(0, SeekOrigin.Begin);
                            // 元の結果をデシリアライズ
                            var result = await JsonSerializer.DeserializeAsync(context.Response.Body, returnType, serializerOptions);
                            // 元の結果をラップ
                            var bytes = JsonSerializer.SerializeToUtf8Bytes(ResponseResult<object>.SuccessResult(result), serializerOptions);
                            new MemoryStream(bytes).CopyTo(originalResponseBody);
                            return;
                        }
                    }
                }
                context.Response.Body.Seek(0, SeekOrigin.Begin);
                await swapStream.CopyToAsync(originalResponseBody);
            }
            finally
            {
                // 元の Body を戻す
                context.Response.Body = originalResponseBody;
            }
        });
    }
}

上記の処理により、統一結果のラッピング処理がどちらで行いやすいかがより明確になったと思います。アクションの戻り値を処理するため、フィルターは明らかに Controller と Action の処理に特化しています。しかし、ミドルウェアの方がより完全に結果を処理できます。なぜなら、カスタムミドルウェアで直接リクエストをインターセプトして返す場合もあるからです。ただし、80/20 の原則に従えば、このようなケースは Action の戻り値に比べて少数です。その場合も ResponseResult<T> の静的メソッドを使って返すことができるので便利です。一方、例外処理については、グローバル例外処理ミドルウェアを使用することで、より多くのシナリオを副作用なく処理できます。その定義を見てみましょう。

public static IApplicationBuilder UseException(this IApplicationBuilder app)
{
    return app.UseExceptionHandler(configure =>
    {
        configure.Run(async context =>
        {
            var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
            var ex = exceptionHandlerPathFeature?.Error;
            if (ex != null)
            {
                var _logger = context.RequestServices.GetService<ILogger<IExceptionHandlerPathFeature>>();
                var rspResult = ResponseResult<object>.ErrorResult(ex.Message);
                _logger?.LogError(ex, message: ex.Message);
                context.Response.StatusCode = StatusCodes.Status500InternalServerError;
                context.Response.ContentType = "application/json;charset=utf-8";
                await context.Response.WriteAsync(rspResult.SerializeObject());
            }
        });
    });
}

グローバル例外ミドルウェアを使用する場合、副作用がありません。主な理由は、例外処理時に Response.Body を読み取る必要がないからです。したがって、例外処理ミドルウェアとフィルターのどちらを選ぶかは、実際のシナリオに応じて選択してください。どちらの方法でも問題ありません。

まとめ

本記事では、ASP.NET Core WebAPI における統一結果返却フォーマットに関する操作について紹介しました。サンプルを通じて、この目標を達成するための実装を段階的に進化させてきました。全体的にはシンプルですが、その背後には筆者の何度にもわたる思考と改良が込められています。各段階の実装が終わるたびに、より良い方法はないかと考え続けてきました。また、Microsoft のソースコードから得たアイデアも多く含まれています。皆さんにも、ソースコードを読むことをお勧めします。問題解決のヒントが得られることが多いからです。ある言葉にあるように、「ソースコードを読むことも一種の城塞であり、外の人は入りたがらず、中の人は出たがらない」のです。もしより良い実装方法があれば、ぜひ議論しましょう。かつては新しいスキルを学ぶことに喜びを感じていましたが、今では良いアイデアや問題解決の方法に出会うことに喜びを感じます。「万巻の書を読む」ことも重要ですが、「万里の道を行く」ことも同じくらい重要です。読書は沈殿であり、道を行くことは実践です。両者を組み合わせることで初めて、より良い促進が生まれます。どちらか一方だけを選ぶのではなく。

👇 ぜひ私の公式アカウントをフォローしてください 👇

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2022/06/22

ASP.NET Core WebAPI でローカリゼーションを実装する(単一リソースファイル)

Microsoft のデフォルトは、1 つのクラスに複数のリソースファイルを対応させる方法であり、使用がやや面倒です。本記事では、単一リソースファイルの使用方法を紹介します。つまり、プロジェクト全体のすべてのクラスが 1 セットの多言語リソースファイルに対応します。

続きを読む