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


系列介紹

【五分鍾的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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM