ASP.NET Core WebApi returns results Unified packaging practices

ASP.NET Core WebApi returns results Unified packaging practices

When WebApi unifies the return of results, I also thought further. First, how to better limit the return of unified formats, and secondly, the packaging of results must be simpler and more powerful. Through constant thinking and improvement, I finally had preliminary results, so I shared them. There was no end to learning and there was no end to thinking. I hope that I can share encouragement with you in this way.

最后更新 4/13/2022 7:12 AM
yi念之间
预计阅读 19 分钟
分类
ASP.NET Core
标签
.NET C# ASP.NET Core Web API

preface

Recently, I have been rebuilding a framework based on the ASP.NET Core WebAPI, which has indeed brought a lot of benefits. After all, when you want to build a framework, you will always unconsciously think about how to make this framework more complete and easier to use. Among them, when discussing WebApi's unified result return, I also thought further. First, how to better limit the return of a unified format, and secondly, the packaging of results must be simpler and more powerful. Through constant thinking and improvement, I finally had preliminary results, so I shared them. There was no end to learning and there was no end to thinking. I hope that I can share encouragement with you in this way.

Unified result class encapsulation

First of all, if the format of the returned results is uniform, there must be a unified wrapping class to wrap all returned results, because although the specific data returned has the same format, the type of the specific value is uncertain, so we need to define a generic class here. Of course, if you do not choose a generic class, you can also use dynamic or object types, but this may bring two shortcomings.

  • First, there may be operations of packing and unpacking.
  • Second, if swagger is introduced, there is no way to generate the return type, because the return type of dynamic or object types can only be determined when a specific action is executed, but the structure of swagger is obtained the first time it is run, so the specific type cannot be perceived.

Define packaging classes

We also talked about the advantages of defining generic classes above, but here we will not say much to directly encapsulate a wrapper class that returns results.

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的特性去描述枚举的含义是一个不错的选择,首先它可以统一管理每个状态的含义,其次是更方便的获取每个状态对应的描述。这样的话如果没有自定义的结果描述,则可以获取当前状态的描述来充当默认值的情况。这个时候在写具体 action 的时候会是以下的效果

[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 };
}

这样的话每次编写 action 的时候都可以返回一个ResponseResult<T>的结果了,这里就体现出了使用枚举定义状态码的优势了,相当一部分场景我们可以省略了状态码甚至是消息的编写,毕竟很多时候在保障功能的情况下,代码还是越简介越好的,更何况是一些高频操作呢。

Upgrade the operation

上面虽然我们定义了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="code">状态码</param>
    /// <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="code">状态码</param>
    /// <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 };
    }
}

Several methods are further encapsulated here. As for the specific encapsulation of several such methods, that sentence is enough. Here I encapsulate several commonly used operations, success state, failure state, abnormal state, and most complete state. These states can basically satisfy most scenarios. If not enough, you can further encapsulate several methods yourself. In this way, the action will be much simpler when using it, eliminating the need for manual attribute assignments.

[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);
}

further improve

上面我们通过完善ResponseResult<T>类的封装,确实在某些程度上节省了一部分操作,但是还是有点美中不足,那就是每次返回结果的时候,虽然定义了几个常用的静态方法去操作返回结果,但是每次还得通过手动去把ResponseResult<T>类给请出来才能使用,现在呢想在操作返回结果的时候不想看到它了。这个呢也很简单,我们可以借助微软针对 MVC 的 Controller 的封装进一步得到一个思路,那就是定义一个基类的 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="code">状态码</param>
    /// <param name="msg">失败信息</param>
    /// <returns></returns>
    protected ResponseResult<T> FailResult<T>(string? msg = null)
    {
        return ResponseResult<T>.FailResult(msg);
    }

    /// <summary>
    /// 异常状态返回结果
    /// </summary>
    /// <param name="code">状态码</param>
    /// <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);
    }
}

With the help of such a great god, everything seems to have taken a step towards the better. In this way, every time our custom Controller can inherit the ApiControllerBase class and use the simplified operations inside. So when you write the code, it will have roughly this effect

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>类中继续完善

/// <summary>
/// 隐式将T转化为ResponseResult<T>
/// </summary>
/// <param name="value"></param>
public static implicit operator ResponseResult<T>(T value)
{
    return new ResponseResult<T> { Data = value };
}

This provides a very simplified operation for most successful results. At this time, if you use action again, you can further simplify the operation of returning values.

[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();
}

因为我们定义了TResponseResult<T>的隐式转换,所以这个时候我们就可以直接返回结果了,而不需要手动对结果返回值进行包装。

Treatment of fish that slipped through the net

在上面我们为了尽量简化 action 返回ResponseResult<T>的统一返回结构的封装,已经对ResponseResult<T>类进行了许多的封装,并且还通过封装ApiControllerBase基类进一步简化这一操作,但是终究还是避免不了一点,那就是很多时候可能想不起来对 action 的返回值去加ResponseResult<T>类型的返回值,但是我们之前的所有封装都得建立在必须要声明ResponseResult<T>类型的返回值的基础上才行,否则就不存在统一返回格式这一说法了。所以针对这些漏网之鱼,我们必须要有统一的拦截机制,这样才能更完整的针对返回结果进行处理,针对这种对 action 返回值的操作,我们首先想到的就是定义过滤器进行处理,因此笔者针对这一现象封装了一个统一包装结果的过滤器,实现如下

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 的过程中,我们的 action 绝大部分是直接返回ViewModelDto而并没有返回ActionResult类型相关,但是无妨,这个时候 MVC 的底层操作会为我们将这些自定义的类型包装成ObjectResult类型的,因此我们的ResultWrapperFilter过滤器也是通过这一机制进行操作的。这里有两点需要考虑的

  • 首先是,我们必须要允许并非所有的返回结果都要进行ResponseResult<T>的包装,为了满足这一需求我们还定义了NoWrapperAttribute来实现这一效果,只要 Controller 或 Action 有NoWrapperAttribute的修饰则不对返回结果进行任何处理。
  • 其次是,如果我们的 Action 上的返回类型已经是ResponseResult<T>类型的,则也不需要对返回结果进行再次的包装。

关于ResultWrapperFilter的定义其实很简单,因为在这里它只是起到了一个标记的作用

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

At this point, there is another special situation that needs to be noted, that is, when an exception occurs in the program, our above mechanisms have no way to take effect. Therefore, we also need to define an interception mechanism for global exception handling, which can also be operated using a unified exception handling filter, and the implementation is as follows

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;
        }
    }
}

After writing the filter, don't forget to register it globally, otherwise it will only be looked at and will not have any effect.

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

Another way to deal with fish that escape the net

Of course, for the above two treatments for those who have escaped the net, you can also process them through middleware on ASP.NET Core. As for the difference between filters and middleware, I believe everyone is very clear. The core differences can be summed up in one sentence.

The processing stages of the two are different, that is, the life cycle processing for pipelines is different. The middleware can handle any scenario with a life cycle after it, but the filter only manages every acre of the 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;
            }
        });
    }
}

相信通过上面的处理,我们就可以更容易的看出来,谁更容易的对统一结果进行包装处理了,毕竟我们是针对 Action 的返回结果进行处理,而过滤器显然就是为针对 Controller 和 Action 的处理而生的。但是通过中间件的方式能更完整的针对结果进行处理,因为许多时候我们可能是在自定义的中间件里直接拦截请求并返回,但是根据二八原则这种情况相对于 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());
            }
        });
    });
}

There are no side effects to using global exception combing middleware, mainly because we don't need to read Response.Body for reading operations when handling exceptions, so as to choose exception handling middleware or filter, you can choose based on your own actual scenarios. Both methods are okay.

summary

This article mainly demonstrates the relevant operations for the unified return format of ASP.NET Core WeApi results. Through examples, we demonstrate the realization of continuous upgrades to achieve this goal step by step. Although the overall looks relatively simple, it carries the author's thinking about upgrades again and again. Every time I complete a stage, I will think about whether there is a better way to improve it. There are also some ideas that come from the ideas provided for us by Microsoft's source code, so many times we still recommend that you take a look at the source code, which can provide us with ideas to solve problems in many times. As I saw a saying, reading source code is also a kind of siege. People outside do not want to go in, and people inside do not want to come out. If you have a better way to implement it, you are welcome to discuss it together. In the past, I would be happy that I had learned a new skill, but later I would be happy that I had a good idea or a good way to solve a problem. Reading thousands of books is important, but traveling thousands of miles is equally important. Reading is precipitation, and traveling is practice. Only when combined can it be better promoted, rather than just choosing one.

👇 Welcome to scan the code and pay attention to my public account👇

Keep Exploring

延伸阅读

更多文章
同分类 / 同标签 6/22/2022

Localization of ASP.NET Core WebAPI (single resource file)

Microsoft's default method is that one class corresponds to multiple resource files, which is quite troublesome to use. This article introduces the use of single resource files, that is, all classes of the entire project correspond to a set of multi-language resource files.

继续阅读