開發介面,是給使用者端(Web前端、App)用的,前面說的RESTFul,是介面的規範,有了統一的介面風格,使用者端開發人員在存取後端功能的時候能更快找到需要的介面,能寫出可維護性更高的程式碼。
而介面的資料返回格式也是介面規範的重要一環,不然一個介面返回JSON,一個返回純字串,使用者端對接到資料時一臉懵逼,沒法處理啊。
合格的介面返回值應該包括狀態碼、提示資訊和資料。
就像這樣:
{
"statusCode": 200,
"successful": true,
"message": null,
"data": {}
}
預設AspNetCore
的WebAPI
模板是沒有特定的返回格式,因為這些業務性質的東西需要開發者自己來定義和完成。
在前面的文章中,可以看到本專案的介面返回值都是 ApiResponse
及其派生型別,這就是在StarBlog裡客製化的統一返回格式。事實上我的其他專案也在用這套介面返回值,這已經算是一個 Utilities 性質的元件了。
PS:今天寫這篇文章時,我順手把這個返回值釋出了一個nuget包,以後在其他專案裡使用就不用複製貼上了~
在 AspNetCore 裡寫 WebApi ,我們的 Controller 需要繼承 ControllerBase
這個類
介面 Action 可以設定返回值為 IActionResult
或 ActionResult<T>
型別,然後返回資料的時候,可以使用 ControllerBase
封裝好的 Ok()
, NotFound()
等方法,這些方法在返回資料的同時會自動設定響應的HTTP狀態碼。
PS:關於
IActionResult
或ActionResult<T>
這倆的區別請參考官方檔案。本文只提關鍵的一點:
ActionResult<T>
返回型別可以讓介面在swagger檔案中直觀看出返回的資料型別。
所以我們不僅要封裝統一的返回值,還要實現類似 Ok()
, NotFound()
, BadRequest()
的快捷方法。
顯然當介面返回型別全都是 ApiResponse<T>
時,這樣返回的狀態碼都是200,不符合需求。
而且有些介面之前已經寫好了,返回型別是 List<T>
這類的,我們也要把這些介面的返回值包裝起來,統一返回格式。
要解決這些問題,我們得了解一下 AspNetCore 的管道模型。
最外層,是中介軟體,一個請求進來,經過一個個中介軟體,到最後一箇中介軟體,生成響應,再依次經過一個個中介軟體走出來,得到最終響應。
常用的 AspNetCore 專案中介軟體有這些,如下圖所示:
最後的 Endpoint 就是最終生成響應的中介軟體。
在本專案中,Program.cs
設定裡的最後一箇中介軟體,就是新增了一個處理 MVC 的 Endpoint
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
這個 Endpoint 的結構又是這樣的:
可以看到有很多 Filter 包圍在使用者程式碼的前後。
所以得出結論,要修改請求的響應,我們可以選擇:
那麼,來開始寫程式碼吧~
首先是這個出現頻率很高的 ApiResponse
,終於要揭曉了~
在 StarBlog.Web/ViewModels/Response
名稱空間下,我建立了三個檔案,分別是:
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
};
}
}
這裡使用運運算元過載,實現了 ApiResponse
到 ApiResponse<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 自帶的一樣妙~
除了這些以 ApiResponse
或 ApiResponse<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:這個包裡不包括過濾器!