【5min+】美化API,包裝AspNetCore的返回結果

系列介紹

【五分鐘的dotnet】是一個利用您的碎片化時間來學習和豐富.net知識的博文系列。它所包含了.net體系中可能會涉及到的方方面面,好比C#的小細節,AspnetCore,微服務中的.net知識等等。html

經過本篇文章您將Get:前端

  • 將API返回的數據自動包裝爲所須要的格式
  • 理解AspNetCoreAction返回結果的一系列處理過程

本文的演示代碼請點擊:Github Linkgit

時長爲大約有十分鐘,內容豐富,建議先投幣再上車觀看😜github

正文

當咱們在使用AspNet Core編寫控制器的時候,常常會將一個Action的返回結果類型定義爲IActionResult,相似於下面的代碼:json

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

當咱們運行起來,經過POSTMan等工具進行調用該API時就會返回My String這樣的結果。後端

可是有的時候,您會發現,忽然我忘記將返回類型聲明爲IActionResult,而是像普通定義方法同樣定義Action,就相似下面的代碼:api

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

再次運行,返回結果依舊是同樣的。前端框架

那麼咱們到底該使用怎樣的返回類型呢?Controller裏面都有OK()NotFound()Redirect()等方法,這些方法的做用是什麼呢? 這些問題都將在下面的內容中獲得答案。服務器

合理的定義API返回格式

先回到本文的主題,談一談數據返回格式。若是您使用的是WebAPI,那麼該問題對您來講可能更爲重要。由於咱們開發出來的API每每是面向的客戶端,而客戶端一般是由另外的開發人員使用前端框架來開發(好比Vue,Angular,React三巨頭)。app

因此開發的時候須要先後兩端的人員都遵循某些規則,否則遊戲可能就玩不下去了。而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

相關文章
相關標籤/搜索