基於.NetCore開發部落格專案 StarBlog - (24) 統一介面資料返回格式

程式設計實驗室發表於2022-12-20

前言

開發介面,是給客戶端(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 管道模型

最外層,是中介軟體,一個請求進來,經過一個個中介軟體,到最後一箇中介軟體,生成響應,再依次經過一個個中介軟體走出來,得到最終響應。

image

常用的 AspNetCore 專案中介軟體有這些,如下圖所示:

image

最後的 Endpoint 就是最終生成響應的中介軟體。

在本專案中,Program.cs 配置裡的最後一箇中介軟體,就是新增了一個處理 MVC 的 Endpoint

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

這個 Endpoint 的結構又是這樣的:

image

可以看到有很多 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:這個包裡不包括過濾器!

參考資料

系列文章

相關文章