【5min+】美化API,包装AspNetCore的返回结果

588 阅读13分钟

系列介绍

【五分钟的dotnet】是一个利用您的碎片化时间来学习和丰富.net知识的博文系列。它所包含了.net体系中可能会涉及到的方方面面,比如C#的小细节,AspnetCore,微服务中的.net知识等等。

通过本篇文章您将Get:

  • 将API返回的数据自动包装为所需要的格式
  • 理解AspNetCoreAction返回结果的一系列处理过程

本文的演示代码请点击:Github Link

时长为大约有十分钟,内容丰富,建议先投币再上车观看😜

正文

当我们在使用AspNet Core编写控制器的时候,经常会将一个Action的返回结果类型定义为IActionResult,类似于下面的代码:

[HttpGet]
public IActionResult GetSomeResult()
{
    return OK("My String");
}

当我们运行起来,通过POSTMan等工具进行调用该API时就会返回My String这样的结果。

但是有的时候,您会发现,突然我忘记将返回类型声明为IActionResult,而是像普通定义方法一样定义Action,就类似下面的代码:

[HttpGet]
public string GetSomeResult()
{
    return "My String";
}

再次运行,返回结果依旧是一样的。

那么我们到底该使用怎样的返回类型呢?Controller里面都有OK()NotFound()Redirect()等方法,这些方法的作用是什么呢? 这些问题都将在下面的内容中得到答案。

合理的定义API返回格式

先回到本文的主题,谈一谈数据返回格式。如果您使用的是WebAPI,那么该问题对您来说可能更为重要。因为我们开发出来的API往往是面向的客户端,而客户端通常是由另外的开发人员使用前端框架来开发(比如Vue,Angular,React三巨头)。

所以开发的时候需要前后两端的人员都遵循某些规则,不然游戏可能就玩不下去了。而API的数据返回格式就是其中的一项。

默认AspNet CoreWebAPI模板其实是没有特定的返回格式,因为这些业务性质的东西肯定是需要开发者自己来定义和完成的。

来感受一下不使用统一格式的案例场景:

小明(开发人员):我开发了这个API,他将返回用户的姓名:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Kestrel

{"name":"张三"}

小丁(前端人员):哦,我知道了,当返回200的时候就是显示姓名吧?那我就把它序列化成JSON对象,然后读取name属性呈现给用户。

小明(开发人员):好的。

五分钟后......

小丁(前端人员): 这是个什么东西?不是说好了返回这个有name的对象吗?

HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
Server: Kestrel

at MiCakeDemoApplication.Controllers.DataWrapperController.NormalExceptionResult() in ……………………(此处省内1000个字符)

小明(开发人员):这个是程序内部报错了嘛,你看结果都是500呀。

小丁(前端人员): 好吧,那我500就不执行操作,然后在界面提醒用户“服务器返回错误”吧。

又过了五分钟......

小丁(前端人员): 那现在是什么情况,返回的是200,但是我又没有办法处理这个对象,导致界面显示了奇奇怪怪的东西。

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Kestrel

"操作失败,没有检测到该人员"

小明(开发人员):这是因为没有检测到这个人员呀,我就只能返回这个结果。。。

小丁(前端人员): *&&……&#¥%……&(省略N个字)。

上面的场景可能很多开发者都遇到过,因为前期没有构建一个通用的返回模型,导致前端人员不知道应该如果根据返回结果进行序列化和呈现界面。而后端开发者为了图方便,在api中随意返回结果,只负责业务能够调通就OK,但是却没有任何规范。

前端人员此时心里肯定有一万只草泥马在奔腾,心里默默吐槽:

这个老几写的啥子歪API哦!

以上内容为:地道四川话

x

因此,我们需要在API开发初期就协定一个完整的模型,在后期于前端的交互中,大家都遵守这个规范就可以避免这类问题。比如下方这个结构:

{
  "statusCode": 200,
  "isError": false,
  "errorCode": null,
  "message": "Request successful.",
  "result": "{"name":"张三"}"
}

{
  "statusCode": 200,
  "isError": true,
  "errorCode": null,
  "message": "没有找到此人",
  "result": ""
}

当业务执行成功的时候,都将以这种格式进行返回。前端人员可以将该json进行转换,而“result”代表了业务成功时候的结果,而当“isError”为true的时候,代表本次操作业务上存在错误,错误信息会在“message”中显示。

这样当大家都遵循该显示规范的时候,就不会造成前端人员不知道如何反序列结果,导致各种undefined或者null的错误。同时也避免了各种不必要的沟通成本。

但是后端人员这个时候就很不爽了,我每次都需要返回对应的模型,就像这样:

[HttpGet]
public IActionResult GetSomeResult()
{
    return new DataModel(noError,result,noErrorCode);
}

所以,有没有办法避免这种情况呢? 当然,对结果进行自动包装!!!

AspNet Core中的结果处理流程

在解决这个问题之前,我们得先来了解一下AspNetCoreAction返回结果之后都经历了哪些过程,这样我们才能对症下药。

对于一般的Action来说,比如下面这个返回类型为string的action:

[HttpGet]
public string GetSomeResult()
{
    return "My String";
}

在action结束之后,该返回结果会被包装成为ObjectResultObjectResultAspNetCore里面对于一般结果的常用返回类型基类,他继承自IActionResult接口:

 public class ObjectResult : ActionResult, IStatusCodeActionResult
{
}

比如返回基础的对象,string、int、list、自定义model等等,都会被包装成为ObjectResult

以下代码来自AspnetCore源码:

//获取action执行结果,比如返回"My String"
var returnValue = await executor.ExecuteAsync(controller, arguments);
//将结果包装为ActionResult
var actionResult = ConvertToActionResult(mapper, returnValue, executor.AsyncResultType);
return actionResult;

//转换过程
private IActionResult ConvertToActionResult(IActionResultTypeMapper mapper, object returnValue, Type declaredType)
{
    //如果已经是IActionResult则返回,如果不是则进行转换。
    //我们例子中返回的是string,显然会进行转换
    var result = (returnValue as IActionResult) ?? mapper.Convert(returnValue, declaredType);
    if (result == null)
    {
        throw new InvalidOperationException(Resources.FormatActionResult_ActionReturnValueCannotBeNull(declaredType));
    }

    return result;
}

//实际转换过程
public IActionResult Convert(object value, Type returnType)
{
    if (returnType == null)
    {
        throw new ArgumentNullException(nameof(returnType));
    }

    if (value is IConvertToActionResult converter)
    {
        return converter.Convert();
    }

    //此时string就被包装成为了ObjectResult
    return new ObjectResult(value)
    {
        DeclaredType = returnType,
    };
}

说到这儿就可以提一下咱们再初学AspNetCore的时候经常用的OK(xx)方法,它的内部是什么样子的呢?

public virtual OkResult Ok(object value)
            => new OkObjectResult(value);

public class OkObjectResult : ObjectResult
{
}

所以当使用OK()的时候,本质上还是返回了ObjectResult,这就是为什么当我们使用IActionResult作为Action的返回类型和使用一般类型(比如string)作为返回类型的时候,都会得到同样结果的原因。

其实这两种写法在大部分场景下都是一样的。所以我们可以根据自己的爱好书写API

当然,不是所有的情况下,结果都是返回ObjectResult哦,就如同下面这些情况:

  • 当我们显式返回一个IActionResult的时候
  • 当Action的返回类型为Void,Task等没有返回结果的时候

要记住:AspnetCore的action结果都会被包装为IActionResult,但是ObjectResult只是对IActionResult的其中一种实现。

我在这儿列了一个图,希望能给大家一个参考:

x

从图中我们就可以看出,我们通常在处理一个文件的时候,就不是返回ObjectResult了,而是返回FileResult。还有其它没有返回值的情况,或者身份验证的情况。

但是,对于大部分的情况,我们都是返回的基础对象,所以都会被包装成为ObjectResult

那么,当返回结果成为了IActionResult之后呢? 是怎么样处理成Http的返回结果的呢?

IActionResult具有一个名为ExecuteResultAsync的方法,该方法用于将对象内容写入到HttpContextHttpResponse中,这样就可以返回给客户端了。

public interface IActionResult
{
    Task ExecuteResultAsync(ActionContext context);
}

每一个具体的IActionResult类型,内部都有一个IActionResultExecutor<T>,该Executor实现具体的写入方案。就拿ObjectResult来说,它内部的Executor是这样的:

public override Task ExecuteResultAsync(ActionContext context)
{
    var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<ObjectResult>>();
    return executor.ExecuteAsync(context, this);
}

AspNetCore内置了很多这样的Executor:

services.TryAddSingleton<IActionResultExecutor<ObjectResult>, ObjectResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<PhysicalFileResult>, PhysicalFileResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<VirtualFileResult>, VirtualFileResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<FileStreamResult>, FileStreamResultExecutor>();
and more.....

所以可以看出,具体的实现都是由IActionResultExecutor来完成,我们拿上面一个稍微简单一点的FileStreamResultExecutor来介绍,它就是将返回的Stream写入到HttpReponse的body中:

public virtual async Task ExecuteAsync(ActionContext context, FileStreamResult result)
{
    if (context == null)
    {
        throw new ArgumentNullException(nameof(context));
    }

    if (result == null)
    {
        throw new ArgumentNullException(nameof(result));
    }

    using (result.FileStream)
    {
        Logger.ExecutingFileResult(result);

        long? fileLength = null;
        if (result.FileStream.CanSeek)
        {
            fileLength = result.FileStream.Length;
        }

        var (range, rangeLength, serveBody) = SetHeadersAndLog(
            context,
            result,
            fileLength,
            result.EnableRangeProcessing,
            result.LastModified,
            result.EntityTag);

        if (!serveBody)
        {
            return;
        }

        await WriteFileAsync(context, result, range, rangeLength);
    }
}

所以从现在我们心底就有了一个大致的流程:

  1. Action返回结果
  2. 结果被包裹为IActionResult
  3. IActionResult使用ExecuteResultAsync方法调用属于它的IActionResultExecutor
  4. IActionResultExecutor执行ExecuteAsync方法将结果写入到Http的返回结果中。

这样我们就从一个Action返回结果到了我们从POSTMan中看到的结果。

返回结果包装

在有了上面的知识基础之后,我们就可以考虑怎么样来实现将返回的结果进行自动包装。

结合AspNetCore的管道知识,我们可以很清楚的绘制出这样的一个流程:

x

图中的Write Data过程就对应上面IActionResult写入过程

所以要包裹Action的结果,我们大致就有了三种思路:

  1. 通过中间件的方式:在MVC中间件完成后,就可以得到Reponse的结果,然后读取内容,再进行包装。
  2. 通过Filter:在Action执行完成后,会穿过后面的Filter,再把数据写入到Reponse,所以可以利用自定义Filter的方式来进行包装。
  3. AOP:直接对Action进行拦截,返回包装的结果。

该三种方式分别从 起始中间结束 三个时间段来进行操作。也许还有其它的骚操作,但是这里就不提及了。

那么来分析一下这三种方式的优缺点:

  1. 中间件的方式,由于在MVC中间件之后处理,此时得到的数据往往是已经被MVC层写好的结果,可能是XML,也可能是JSON。所以很难把控到底应该将结果序列化成什么格式。 有时候需要把MVC已经序列化好的数据再次反序列化操作,有不必要的开销。
  2. Filter方式,能够利用MVC的格式化优势,但是有很小的几率结果可能可能会被其它Filter所冲突掉。
  3. AOP方式:虽然这样做更干脆,但是代理会带来一些成本开销,虽然比较小。

所以最终我个人是比较偏向第二种和第三种方式,但是既然AspNetCore给我们提供了那么好的Filter,所以就利用Filter的优势来完成的结果包装。

从上面的内容我们知道了,IActionResult有许许多多的实现类,那么我们到底该包装哪些结果呢?全部?一部分?

经过考虑之后,我打算仅仅对ObjectResult类型进行包装,因为对于其它的类型来说,我们更期望他直接返回结果,比如文件流,重定向结果等等。(你希望文件流被包装成一个模型吗?😂)

所以很快就会有了下面的一些代码:

internal class DataWrapperFilter : IAsyncResultFilter
{
    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
    {
        if (context.Result is ObjectResult objectResult)
        {
            var statusCode = context.HttpContext.Response.StatusCode;

            var wrappContext = new DataWrapperContext(context.Result,
                                                        context.HttpContext,
                                                        _options,
                                                        context.ActionDescriptor);
            //_wrapperExecutor 负责根据传入的内容进入的内容进行包装
            var wrappedData = _wrapperExecutor.WrapSuccesfullysResult(objectResult.Value, wrappContext);
            //将ObjectResult的Value 替换为包装后的模型类
            objectResult.Value = wrappedData;
            }
        }

        await next();
    }
}


//_wrapperExecutor的方法
public virtual object WrapSuccesfullysResult(object orignalData, DataWrapperContext wrapperContext, bool isSoftException = false)
{
    //other code

    //ApiResponse为我们定义的格式类型
    return new ApiResponse(ResponseMessage.Success, orignalData) { StatusCode = statuCode };
}

然后将这个Filter交注册到MVC中,访问后的结果就会被包装成我们需要的格式。

可能有些同学会问,这个结果是怎么被序列化成json或者xml的,其实在ObjectResultIActionResultExecutor执行过程中,有一个类型为OutputFormatterSelector的属性,该属性从MVC已经注册了的格式化程序中选择一个最合适的程序把结果写入到Reponse。而MVC给大家内置了stringjson的格式化程序,所以大家默认的返回都是json。如果您要使用xml,则需要在注册时添加xml的支持包。 有关该实现的内容,后面有时间的话可以来写一篇文章单独讲。

总有一些坑

添加自动包装的过滤器的确很简单,我刚开始也是这么认为,特别是我写完第一版实现之后,通过调试返回了包装好的int结果的时候。但是,简单的方案可能有很多细节被忽略掉:

永远的statusCode = 200

很快我发现,被包装的结果中httpcode都是200。我很快定位到这一句赋值code的代码:

var statusCode = context.HttpContext.Response.StatusCode;

原因是IAsyncResultFilter在执行时,context.HttpContext.Response的具体返回内容还没有被写入,所以只会有一个200的值,而真实的返回值现在都还在ObjectResult身上。所以我将代码更改为:

var statusCode = objectResult.StatusCode ?? context.HttpContext.Response.StatusCode;

特殊的结果ProblemDetail

ObjectResultValue属性保存了Action返回的结果数据,比如"123",new MyObject等等。但是在AspNetCore中有一个特殊的类型:ProblemDetail

/// <summary>
/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807.
/// </summary>
public class ProblemDetails
{
    //****
}

该类型是一个规范格式,所以AspNetCore引入了这个类型。所以很多地方都有对该类型进行特殊处理的代码,比如在ObjectResult格式化的时候:

public virtual void OnFormatting(ActionContext context)
{
    if (context == null)
    {
        throw new ArgumentNullException(nameof(context));
    }

    if (StatusCode.HasValue)
    {
        context.HttpContext.Response.StatusCode = StatusCode.Value;

        if (Value is ProblemDetails details && !details.Status.HasValue)
        {
            details.Status = StatusCode.Value;
        }
    }
}

所以在包装时我开启了一项配置,WrapProblemDetails来提示用户是否对ProblemDetails来进行处理。

ObjectResult的DeclaredType

在最初,我都把注意力放在了ObjectResult的Value属性上,因为当我返回一个类型为int的结果是,它确实成功的包装为了我想要的结果。但是当我返回一个类型为string格式的时候,它抛出了异常。

因为类型为string的结果最终会交给StringOutputFormatter格式化程序进行处理,但是它内部会验证ObjectResult.Value的格式是否为预期,否则就会转换出错。

这是因为在替换ObjectResult的结果时,我们同时应该替换它的DeclaredType为对应模型的Type:

objectResult.Value = wrappedData;
//This line
objectResult.DeclaredType = wrappedData.GetType();

总结

本次为大家介绍了AspNetCoreAction从返回结果到写入Reponse的过程,在该知识点的基础上我们很容易就扩展出一个自动包装返回数据的功能来。

在下面的Github链接中,为大家提供了一个数据包装的演示项目。

Github Code:点此跳转

该项目在基础的包装功能上还提供了用户自定义模型的功能,比如:

 CustomWrapperModel result = new CustomWrapperModel("MiCakeCustomModel");

result.AddProperty("company", s => "MiCake");
result.AddProperty("statusCode", s => (s.ResultData as ObjectResult)?.StatusCode ?? s.HttpContext.Response.StatusCode);
result.AddProperty("result", s => (s.ResultData as ObjectResult)?.Value);
result.AddProperty("exceptionInfo", s => s.SoftlyException?.Message);

将得到下面的数据格式:

{
  "company": "MiCake",
  "statusCode": 200,
  "result": "There result will be wrapped by micake.",
  "exceptionInfo": null
}

最后,偷偷说一句:创作不易,点个推荐吧.....

x