前言

开发接口,是给客户端(Web前端、App)用的,前面说的RESTFul,是接口的规范,有了统一的接口风格,客户端开发人员在访问后端功能的时候能更快找到需要的接口,能写出可维护性更高的代码。

而接口的数据返回格式也是接口规范的重要一环,不然一个接口返回JSON,一个返回纯字符串,客户端对接到数据时一脸懵逼,没法处理啊。

合格的接口返回值应该包括状态码、提示信息和数据。

就像这样:

{
"statusCode": 200,
"successful": true,
"message": null,
"data": {}
}

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

在前面的文章中,可以看到本项目的接口返回值都是 ApiResponse 及其派生类型,这就是在StarBlog里定制的统一返回格式。事实上我的其他项目也在用这套接口返回值,这已经算是一个 Utilities 性质的组件了。

PS:今天写这篇文章时,我顺手把这个返回值发布了一个nuget包,以后在其他项目里使用就不用复制粘贴了~

分析一下

在 AspNetCore 里写 WebApi ,我们的 Controller 需要继承 ControllerBase 这个类

接口 Action 可以设置返回值为 IActionResultActionResult<T> 类型,然后返回数据的时候,可以使用 ControllerBase 封装好的 Ok(), NotFound() 等方法,这些方法在返回数据的同时会自动设置响应的HTTP状态码。

PS:关于 IActionResultActionResult<T> 这俩的区别请参考官方文档。

本文只提关键的一点:ActionResult<T>返回类型可以让接口在swagger文档中直观看出返回的数据类型。

所以我们不仅要封装统一的返回值,还要实现类似 Ok(), NotFound(), BadRequest() 的快捷方法。

显然当接口返回类型全都是 ApiResponse<T> 时,这样返回的状态码都是200,不符合需求。

而且有些接口之前已经写好了,返回类型是 List<T> 这类的,我们也要把这些接口的返回值包装起来,统一返回格式。

要解决这些问题,我们得了解一下 AspNetCore 的管道模型。

AspNetCore 管道模型

最外层,是中间件,一个请求进来,经过一个个中间件,到最后一个中间件,生成响应,再依次经过一个个中间件走出来,得到最终响应。

常用的 AspNetCore 项目中间件有这些,如下图所示:

最后的 Endpoint 就是最终生成响应的中间件。

在本项目中,Program.cs 配置里的最后一个中间件,就是添加了一个处理 MVC 的 Endpoint

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

这个 Endpoint 的结构又是这样的:

可以看到有很多 Filter 包围在用户代码的前后。

所以得出结论,要修改请求的响应,我们可以选择:

  • 写一个中间件处理
  • 使用过滤器(Filter)

那么,来开始写代码吧~

定义ApiResponse

首先是这个出现频率很高的 ApiResponse,终于要揭晓了~

StarBlog.Web/ViewModels/Response 命名空间下,我创建了三个文件,分别是:

  • ApiResponse.cs
  • ApiResponsePaged.cs: 分页响应
  • IApiResponse.cs: 几个相关的接口

ApiResponse.cs 中,其实是两个类,一个 ApiResponse<T> ,另一个 ApiResponse,带泛型和不带泛型。

PS:C#的泛型有点复杂,当时搞这东西搞得晕晕的,又复习了一些逆变和协变,不过最终没有用上。

接口代码

上代码,先是几个接口的代码

public interface IApiResponse {
public int StatusCode { get; set; }
public bool Successful { get; set; }
public string? Message { get; set; }
} public interface IApiResponse<T> : IApiResponse {
public T? Data { get; set; }
} public interface IApiErrorResponse {
public Dictionary<string,object> ErrorData { get; set; }
}

保证了所有相关对象都来自 IApiResponse 接口。

ApiResponse<T>

接着看 ApiResponse<T> 的代码。

public class ApiResponse<T> : IApiResponse<T> {
public ApiResponse() {
} public ApiResponse(T? data) {
Data = data;
} public int StatusCode { get; set; } = 200;
public bool Successful { get; set; } = true;
public string? Message { get; set; } public T? Data { get; set; } /// <summary>
/// 实现将 <see cref="ApiResponse"/> 隐式转换为 <see cref="ApiResponse{T}"/>
/// </summary>
/// <param name="apiResponse"><see cref="ApiResponse"/></param>
public static implicit operator ApiResponse<T>(ApiResponse apiResponse) {
return new ApiResponse<T> {
StatusCode = apiResponse.StatusCode,
Successful = apiResponse.Successful,
Message = apiResponse.Message
};
}
}

这里使用运算符重载,实现了 ApiResponseApiResponse<T> 的隐式转换。

等下就能看出有啥用了~

ApiResponse

继续看 ApiResponse 代码,比较长,封装了几个常用的方法在里面,会有一些重复代码。

这个类实现了俩接口:IApiResponse, IApiErrorResponse

public class ApiResponse : IApiResponse, IApiErrorResponse {
public int StatusCode { get; set; } = 200;
public bool Successful { get; set; } = true;
public string? Message { get; set; }
public object? Data { get; set; } /// <summary>
/// 可序列化的错误
/// <para>用于保存模型验证失败的错误信息</para>
/// </summary>
public Dictionary<string,object>? ErrorData { get; set; } public ApiResponse() {
} public ApiResponse(object data) {
Data = data;
} public static ApiResponse NoContent(string message = "NoContent") {
return new ApiResponse {
StatusCode = StatusCodes.Status204NoContent,
Successful = true, Message = message
};
} public static ApiResponse Ok(string message = "Ok") {
return new ApiResponse {
StatusCode = StatusCodes.Status200OK,
Successful = true, Message = message
};
} public static ApiResponse Ok(object data, string message = "Ok") {
return new ApiResponse {
StatusCode = StatusCodes.Status200OK,
Successful = true, Message = message,
Data = data
};
} public static ApiResponse Unauthorized(string message = "Unauthorized") {
return new ApiResponse {
StatusCode = StatusCodes.Status401Unauthorized,
Successful = false, Message = message
};
} public static ApiResponse NotFound(string message = "NotFound") {
return new ApiResponse {
StatusCode = StatusCodes.Status404NotFound,
Successful = false, Message = message
};
} public static ApiResponse BadRequest(string message = "BadRequest") {
return new ApiResponse {
StatusCode = StatusCodes.Status400BadRequest,
Successful = false, Message = message
};
} public static ApiResponse BadRequest(ModelStateDictionary modelState, string message = "ModelState is not valid.") {
return new ApiResponse {
StatusCode = StatusCodes.Status400BadRequest,
Successful = false, Message = message,
ErrorData = new SerializableError(modelState)
};
} public static ApiResponse Error(string message = "Error", Exception? exception = null) {
object? data = null;
if (exception != null) {
data = new {
exception.Message,
exception.Data
};
} return new ApiResponse {
StatusCode = StatusCodes.Status500InternalServerError,
Successful = false,
Message = message,
Data = data
};
}
}

ApiResponsePaged<T>

这个分页是最简单的,只是多了个 Pagination 属性而已

public class ApiResponsePaged<T> : ApiResponse<List<T>> where T : class {
public ApiResponsePaged() {
} public ApiResponsePaged(IPagedList<T> pagedList) {
Data = pagedList.ToList();
Pagination = pagedList.ToPaginationMetadata();
} public PaginationMetadata? Pagination { get; set; }
}

类型隐式转换

来看这个接口

public ApiResponse<Post> Get(string id) {
var post = _postService.GetById(id);
return post == null ? ApiResponse.NotFound() : new ApiResponse<Post>(post);
}

根据上面的代码,可以发现 ApiResponse.NotFound() 返回的是一个 ApiResponse 对象

但这接口的返回值明明是 ApiResponse<Post> 类型呀,这不是类型不一致吗?

不过在 ApiResponse<T> 中,我们定义了一个运算符重载,实现了 ApiResponse 类型到 ApiResponse<T> 的隐式转换,所以就完美解决这个问题,大大减少了代码量。

不然原本是要写成这样的

return post == null ?
new ApiResponse<Post> {
StatusCode = StatusCodes.Status404NotFound,
Successful = false, Message = "未找到"
} :
new ApiResponse<Post>(post);

现在只需简简单单的 ApiResponse.NotFound(),就跟 AspNetCore 自带的一样妙~

包装返回值

除了这些以 ApiResponseApiResponse<T> 作为返回类型的接口,还有很多其他返回类型的接口,比如

public List<ConfigItem> GetAll() {
return _service.GetAll();
}

还有

public async Task<string> Poem() {
return await _crawlService.GetPoem();
}

这些接口在 AspNetCore 生成响应的时候,会把这些返回值归类为 ObjectResult ,如果不做处理,就会直接序列化成不符合我们返回值规范的格式。

这个不行,必须对这部分接口的返回格式也统一起来。

因为种种原因,最终我选择使用过滤器来实现这个功能。

关于过滤器的详细用法,可以参考官方文档,本文就不展开了,直接上代码。

创建文件 StarBlog.Web/Filters/ResponseWrapperFilter.cs

public class ResponseWrapperFilter : IAsyncResultFilter {
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) {
if (context.Result is ObjectResult objectResult) {
if (objectResult.Value is IApiResponse apiResponse) {
objectResult.StatusCode = apiResponse.StatusCode;
context.HttpContext.Response.StatusCode = apiResponse.StatusCode;
}
else {
var statusCode = objectResult.StatusCode ?? context.HttpContext.Response.StatusCode; var wrapperResp = new ApiResponse<object> {
StatusCode = statusCode,
Successful = statusCode is >= 200 and < 400,
Data = objectResult.Value,
}; objectResult.Value = wrapperResp;
objectResult.DeclaredType = wrapperResp.GetType();
}
} await next();
}
}

在代码中进行判断,当响应的类型是 ObjectResult 时,把这个响应结果拿出来,再判断是不是 IApiResponse 类型。

前面我们介绍过,所有 ApiResponse 都实现了 IApiResponse 这个接口,所以可以判断是不是 IApiResponse 类型来确定这个返回结果是否包装过。

没包装的话就给包装一下,就这么简单。

之后在 Program.cs 里注册一下这个过滤器。

var mvcBuilder = builder.Services.AddControllersWithViews(
options => { options.Filters.Add<ResponseWrapperFilter>(); }
);

搞定

这样就完事儿啦~

最后所有接口(可序列化的),返回格式就都变成了这样

{
"statusCode": 200,
"successful": true,
"message": null,
"data": {}
}

强迫症表示舒服了~

PS:对了,返回文件的那类接口除外。

在其他项目中使用

这个 ApiRepsonse ,我已经发布了nuget包

需要在其他项目使用的话,可以直接安装 CodeLab.Share 这个包

引入 CodeLab.Share.ViewModels.Response 命名空间就完事了~

不用每次都复制粘贴这几个类,还得改命名空间。

PS:这个包里不包括过滤器!

参考资料

系列文章

基于.NetCore开发博客项目 StarBlog - (24) 统一接口数据返回格式的更多相关文章

  1. 基于.NetCore开发博客项目 StarBlog - (16) 一些新功能 (监控/统计/配置/初始化)

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  2. 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 ... 基于. ...

  3. 基于.NetCore开发博客项目 StarBlog - (3) 模型设计

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  4. 基于.NetCore开发博客项目 StarBlog - (4) markdown博客批量导入

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  5. 基于.NetCore开发博客项目 StarBlog - (5) 开始搭建Web项目

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  6. 基于.NetCore开发博客项目 StarBlog - (6) 页面开发之博客文章列表

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  7. 基于.NetCore开发博客项目 StarBlog - (7) 页面开发之文章详情页面

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  8. 基于.NetCore开发博客项目 StarBlog - (8) 分类层级结构展示

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  9. 基于.NetCore开发博客项目 StarBlog - (9) 图片批量导入

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  10. 基于.NetCore开发博客项目 StarBlog - (10) 图片瀑布流

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

随机推荐

  1. echarts中setOption没有重新渲染表格

    setOption是merge,而非赋值,所以第二次setOption后,实际是更新了option setOption支持notMerge为true的方案,但是需要全量更新option(性能不好): ...

  2. 某云负载均衡获取客户端真实IP的问题

    某云负载均衡真实IP的问题,我们这边已经遇到过两次了.而且每次和售后沟通的时候都大费周折,主要是要给售后说明白目前文档的获取真实IP是有问题的,他们觉得文档上说明的肯定没问题,售后要是不明白,他们不会 ...

  3. Python 实现Tracert追踪TTL值

    Tracert 命令跟踪路由原理是IP路由每经过一个路由节点TTL值会减一,假设TTL值=0时数据包还没有到达目标主机,那么该路由则会回复给目标主机一个数据包不可达,由此我们就可以获取到目标主机的IP ...

  4. PHP全栈开发(八):CSS Ⅶ 表格 style

    表格默认是没有边框的,因此,我们在设置表格格式的时候,首先要设置的是表格边框的样式,也就是 table{ border-style:solid; } 设置完表格表格的样式之后,可以设置表格边框的粗细程 ...

  5. P6189 [NOI Online #1 入门组] 跑步 (DP/根号分治)

    (才了解到根号分治这样的妙方法......) 将每个数当成一种物品,最终要凑成n,这就是一个完全背包问题,复杂度O(n2),可以得80分(在考场上貌似足够了......) 1 #include < ...

  6. The project was not built due to "Could not delete '/XXX/WebRoot/WEB-INF/classes/library'.". Fix the problem, then try refreshing this project and building

    在构建插件时提示如下错误: Description Resource Path Location Type The project was not built due to "Could n ...

  7. 云原生时代的DevOps平台设计之道

    开发人员与运维人员是 IT 领域很重要的两大人群,他们都会参与到各种业务系统的建设过程中去.DevOps 是近年间火爆起来的一种新理念,这种理念被很多人错误的解读为"由开发人员(Dev)学习 ...

  8. JVM中的方法区

    JVM中的方法区 方法区存储什么? 用于存储已被虚拟机加载的类型信息.常量.静态变量.即时编译器编译后的代码缓存 1.类型信息 对每个加载的类型(类class.接口interface.枚举.注解)jv ...

  9. golang中的字符串

    0.1.索引 https://waterflow.link/articles/1666449874974 1.字符串编码 在go中rune是一个unicode编码点. 我们都知道UTF-8将字符编码为 ...

  10. 一步一图带你深入理解 Linux 虚拟内存管理

    写在本文开始之前.... 从本文开始我们就正式开启了 Linux 内核内存管理子系统源码解析系列,笔者还是会秉承之前系列文章的风格,采用一步一图的方式先是详细介绍相关原理,在保证大家清晰理解原理的基础 ...