ASP.NET Core 2.2 : 二十一. 內容協商與自定義IActionResult和格式化類


上一章的結尾留下了一個問題:同樣是ObjectResult,在執行的時候又是如何被轉換成string和JSON兩種格式的呢?

本章來解答這個問題,這里涉及到一個名詞:“內容協商”。除了這個,本章將通過兩個例子來介紹如何自定義IActionResult和格式化類。(ASP.NET Core 系列目錄)

一、內容協商

依然以返回Book類型的Action為例,看看它是怎么被轉換為JSON類型的。

public Book GetModel()
{
    return new Book() { Code = "1001", Name = "ASP" };
}

這個Action執行后被封裝為ObjectResult,接下來就是這個ObjectResult的執行過程。

ObjectResult的代碼如下:

public class ObjectResult : ActionResult, IStatusCodeActionResult
{
     //部分代碼略
     public override Task ExecuteResultAsync(ActionContext context)
     {
          var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<ObjectResult>>();
          return executor.ExecuteAsync(context, this);
     }
}

它是如何被執行的呢?首先會通過依賴注入獲取ObjectResult對應的執行者,獲取到的是ObjectResultExecutor,然后調用ObjectResultExecutor的ExecuteAsync方法。代碼如下:

public class ObjectResultExecutor : IActionResultExecutor<ObjectResult>
{
     //部分代碼略
     public virtual Task ExecuteAsync(ActionContext context, ObjectResult result)
     {
          //部分代碼略
          var formatterContext = new OutputFormatterWriteContext(
               context.HttpContext,
               WriterFactory,
               objectType,
               result.Value);

            var selectedFormatter = FormatterSelector.SelectFormatter(
                formatterContext,
                (IList<IOutputFormatter>)result.Formatters ?? Array.Empty<IOutputFormatter>(),
                result.ContentTypes);
            if (selectedFormatter == null)
            {
                // No formatter supports this.
                Logger.NoFormatter(formatterContext);
                context.HttpContext.Response.StatusCode = StatusCodes.Status406NotAcceptable;
                return Task.CompletedTask;
            }

            result.OnFormatting(context);
            return selectedFormatter.WriteAsync(formatterContext);
     }
}

核心代碼就是FormatterSelector.SelectFormatter()方法,它的作用是選擇一個合適的Formatter。Formatter顧名思義就是一個用於格式化數據的類。系統默認提供了4種Formatter,如下圖 1

圖 1

它們都實現了IOutputFormatter接口,繼承關系如下圖 2:

 

圖 2

IOutputFormatter代碼如下:

public interface IOutputFormatter
{
    bool CanWriteResult(OutputFormatterCanWriteContext context);
    Task WriteAsync(OutputFormatterWriteContext context);
}

又是非常熟悉的方式,就像在眾多XXXResultExecutor中篩選一個合適的Action的執行者一樣,首先將它們按照一定的順序排列,然后開始遍歷,逐一執行它們的CanXXX方法,若其中一個的執行結果為true,則它就會被選出來。例如StringOutputFormatter的代碼如下:

public class StringOutputFormatter : TextOutputFormatter
{
    public StringOutputFormatter()
    {
        SupportedEncodings.Add(Encoding.UTF8);
        SupportedEncodings.Add(Encoding.Unicode);
        SupportedMediaTypes.Add("text/plain");
    }

    public override bool CanWriteResult(OutputFormatterCanWriteContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.ObjectType == typeof(string) || context.Object is string)
        {
            return base.CanWriteResult(context);
        }

         return false;
    }
    //省略部分代碼
}

從StringOutputFormatter的CanWriteResult方法中可以知道它能處理的是string類型的數據。它的構造方法中標識它可以處理的字符集為UTF8和Unicode。對應的數據格式標記為“text/plain”。同樣查看HttpNoContentOutputFormatter和HttpNoContentOutputFormatter對應的是返回值為void或者task的,StreamOutputFormatter對應的是Stream類型的。

JsonOutputFormatter沒有重寫CanWriteResult方法,采用的是OutputFormatter的CanWriteResult方法,代碼如下:

public abstract class OutputFormatter : IOutputFormatter, IApiResponseTypeMetadataProvider
{
   //部分代碼略
    protected virtual bool CanWriteType(Type type)
    {
        return true;
    }

    /// <inheritdoc />
    public virtual bool CanWriteResult(OutputFormatterCanWriteContext context)
    {
        if (SupportedMediaTypes.Count == 0)
        {
            var message = Resources.FormatFormatter_NoMediaTypes(
                GetType().FullName,
                nameof(SupportedMediaTypes));

             throw new InvalidOperationException(message);
        }

         if (!CanWriteType(context.ObjectType))
        {
            return false;
        }

        if (!context.ContentType.HasValue)
        {
            context.ContentType = new StringSegment(SupportedMediaTypes[0]);
            return true;
        }
        else
        {
            var parsedContentType = new MediaType(context.ContentType);

            for (var i = 0; i < SupportedMediaTypes.Count; i++)
            {
                var supportedMediaType = new MediaType(SupportedMediaTypes[i]);
                if (supportedMediaType.HasWildcard)
                {
                    if (context.ContentTypeIsServerDefined
                        && parsedContentType.IsSubsetOf(supportedMediaType))
                    {
                        return true;
                    }
                }
                else
                {
                    if (supportedMediaType.IsSubsetOf(parsedContentType))
                    {
                        context.ContentType = new StringSegment(SupportedMediaTypes[i]);
                        return true;
                    }
                }
            }
        }

         return false;
    }
}

通過代碼可以看出它主要是利用SupportedMediaTypes和context.ContentType做一系列的判斷,它們分別來自客戶端和服務端:

SupportedMediaTypes:它是客戶端在請求的時候給出的,標識客戶端期望服務端按照什么樣的格式返回請求結果。

context.ContentType:它來自ObjectResult.ContentTypes,是由服務端在Action執行后給出的。

二者的值都是類似“application/json”、“text/plain”這樣的格式,當然也有可能為空,即客戶端或服務端未對請求做數據格式的設定。通過上面的代碼可以知道,如果這兩個值均未做設置或者只有一方做了設置並且設置為JSON時,這個CanWriteResult方法的返回值都是true。所以這樣的情況下除了前三種Formatter對應的特定類型外的ObjectResult都會交由JsonOutputFormatter處理。這也就是為什么同樣是ObjectResult,但string類型的Action返回結果是String類型,而Book類型的Action返回的結果是JSON類型。這個JsonOutputFormatter有點像當其他的Formatter無法處理時用來“保底”的。

那么SupportedMediaTypes和context.ContentType這兩個值又是在什么時候被設置的呢? 在講請求的模型參數綁定的時候,可以通過在請求Request的Header中添加“content-type: application/json”這樣的標識來說明請求中包含的數據的格式是JSON類型的。同樣,在請求的時候也可以添加“accept:xxx”這樣的標識,來表明期望服務端對本次請求返回的數據的格式。例如期望是JSON格式“accept:application/json”,文本格式“accept: text/plain”等。這個值就是SupportedMediaTypes。

在服務端,也可以對返回的數據格式做設置,例如下面的代碼:

 [Produces("application/json")]
 public Book GetModel()
 {
     return new Book() { Code = "1001", Name = "ASP" };
 }

通過這個ProducesAttribute設置的值最終就會被賦值給ObjectResult.ContentTypes,最終傳遞給context.ContentType。ProducesAttribute實際是一個IResultFilter,代碼如下:

public class ProducesAttribute : Attribute, IResultFilter, IOrderedFilter, IApiResponseMetadataProvider
    {
        //部分代碼省略
        public virtual void OnResultExecuting(ResultExecutingContext context)
        {
            //部分代碼省略
           SetContentTypes(objectResult.ContentTypes);
        }

        public void SetContentTypes(MediaTypeCollection contentTypes)
        {
            contentTypes.Clear();
            foreach (var contentType in ContentTypes)
            {
                contentTypes.Add(contentType);
            }
        }

        private MediaTypeCollection GetContentTypes(string firstArg, string[] args)
        {
            var completeArgs = new List<string>();
            completeArgs.Add(firstArg);
            completeArgs.AddRange(args);
            var contentTypes = new MediaTypeCollection();
            foreach (var arg in completeArgs)
            {
                var contentType = new MediaType(arg);
                if (contentType.HasWildcard)
                {
                    throw new InvalidOperationException(                     Resources.FormatMatchAllContentTypeIsNotAllowed(arg));
                }

                contentTypes.Add(arg);
            }

            return contentTypes;
        }
    }

在執行OnResultExecuting的時候,會將設置的“application/json”賦值給ObjectResult.ContentTypes。所以請求最終返回結果的數據格式是由二者“協商”決定的。下面回到Formatter的篩選方法FormatterSelector.SelectFormatter(),這個方法寫在DefaultOutputFormatterSelector.cs中。精簡后的代碼如下:

public class DefaultOutputFormatterSelector : OutputFormatterSelector
{
    //部分代碼略
    public override IOutputFormatter SelectFormatter(OutputFormatterCanWriteContext context, IList<IOutputFormatter> formatters, MediaTypeCollection contentTypes)
    {
        //部分代碼略
        var request = context.HttpContext.Request;
        var acceptableMediaTypes = GetAcceptableMediaTypes(request);
        var selectFormatterWithoutRegardingAcceptHeader = false;
        IOutputFormatter selectedFormatter = null;
        if (acceptableMediaTypes.Count == 0)
        {
            //客戶端未設置Accept標頭的情況
            selectFormatterWithoutRegardingAcceptHeader = true;
        }
        else
        {
            if (contentTypes.Count == 0)
            {
                //服務端未指定數據格式的情況
                selectedFormatter = SelectFormatterUsingSortedAcceptHeaders(
                    context,
                    formatters,
                    acceptableMediaTypes);
            }
            else
            {
                //客戶端和服務端均指定了數據格式的情況
                selectedFormatter = SelectFormatterUsingSortedAcceptHeadersAndContentTypes(
                    context,
                    formatters,
                    acceptableMediaTypes,
                    contentTypes);
            }

            if (selectedFormatter == null)
            {
                //如果未找到合適的,由系統參數ReturnHttpNotAcceptable決定直接返回錯誤
                //還是忽略客戶端的Accept設置再篩選一次
                if (!_returnHttpNotAcceptable)
                {
                    selectFormatterWithoutRegardingAcceptHeader = true;
                }
            }
        }

        if (selectFormatterWithoutRegardingAcceptHeader)
        {
            //Accept標頭未設置或者被忽略的情況
            if (contentTypes.Count == 0)
            {
                //服務端也未指定數據格式的情況
                selectedFormatter = SelectFormatterNotUsingContentType(
                    context,
                    formatters);
            }
            else
            {
                //服務端指定數據格式的情況
                selectedFormatter = SelectFormatterUsingAnyAcceptableContentType(
                    context,
                    formatters,
                    contentTypes);
            }
        }

        if (selectedFormatter == null)
        {
            // No formatter supports this.
            _logger.NoFormatter(context);
            return null;
        }

        _logger.FormatterSelected(selectedFormatter, context);
        return selectedFormatter;
    }

    // 4種情況對應的4個方法略
    // SelectFormatterNotUsingContentType
    // SelectFormatterUsingSortedAcceptHeaders
    // SelectFormatterUsingAnyAcceptableContentType
    // SelectFormatterUsingSortedAcceptHeadersAndContentTypes
}

DefaultOutputFormatterSelector根據客戶端和服務端關於返回數據格式的設置的4種不同情況作了分別處理,優化了查找順序,此處就不詳細講解了。

總結一下這個規則:

  1. 只有在Action返回類型為ObjectResult的時候才會進行“協商”。如果返回類型為JsonResult、ContentResult、ViewResult等特定ActionResult,無論請求是否設置了accept標識,都會被忽略,會固定返回 JSON、String,Html類型的結果。
  2. 當系統檢測到請求是來自瀏覽器時,會忽略 其Header中Accept 的設置,所以會由服務器端設置的格式決定(未做特殊配置時,系統默認為JSON)。 這是為了在使用不同瀏覽器使用 API 時提供更一致的體驗。系統提供了參數RespectBrowserAcceptHeader,即尊重瀏覽器在請求的Header中關於Accept的設置,默認值為false。將其設置為true的時候,瀏覽器請求中的Accept 標識才會生效。注意這只是使該Accept 標識生效,依然不能由其決定返回格式,會進入“協商”階段。
  3. 若二者均未設置,采用默認的JSON格式。
  4. 若二者其中有一個被設置,采用該設置值。
  5. 若二者均設置且不一致,即二者值不相同且沒有包含關系(有通配符的情況),會判斷系統參數ReturnHttpNotAcceptable(返回不可接受,默認值為false),若ReturnHttpNotAcceptable值為false,則忽略客戶端的Accept設置,按照無Accept設置的情況再次篩選一次Formatter。如果該值為true,則直接返回狀態406。

涉及的兩個系統參數RespectBrowserAcceptHeader和ReturnHttpNotAcceptable的設置方法是在 Startup.cs 中通過如下代碼設置:

 services.AddMvc(
    options =>
    {
        options.RespectBrowserAcceptHeader = true;
        options.ReturnHttpNotAcceptable = true;
    }
 )

  最終,通過上述方法找到了合適的Formatter,接着就是通過該Formatter的WriteAsync方法將請求結果格式化后寫入HttpContext.Response中。JsonOutputFormatter重寫了OutputFormatter的WriteResponseBodyAsync方法(WriteAsync方法會調用WriteResponseBodyAsync方法),代碼如下:

public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
    if (context == null)
    {
        throw new ArgumentNullException(nameof(context));
    }

    if (selectedEncoding == null)
    {
        throw new ArgumentNullException(nameof(selectedEncoding));
    }

    var response = context.HttpContext.Response;
    using (var writer = context.WriterFactory(response.Body, selectedEncoding))
    {
        WriteObject(writer, context.Object);
        // Perf: call FlushAsync to call WriteAsync on the stream with any content left in the TextWriter's
        // buffers. This is better than just letting dispose handle it (which would result in a synchronous
        // write).
        await writer.FlushAsync();
    }
}

這個方法的功能就是將結果數據轉換為JSON並寫入HttpContext.Response. Body中。至此,請求結果就按照JSON的格式返回給客戶端了。

在實際項目中,如果上述的幾種格式均不能滿足需求,比如某種數據經常需要通過特殊的格式傳輸,想自定義一種格式,該如何實現呢?通過本節的介紹,可以想到兩種方式,即自定義一種IActionResult或者自定義一種IOutputFormatter。

二、自定義IActionResult

舉個簡單的例子,以第一節的第3個例子為例,該例通過 “return new JsonResult(new Book() { Code = "1001", Name = "ASP" })”返回了一個JsonResult。

返回的JSON值為:

{"code":"1001","name":"ASP"}

假如對於Book這種類型,希望用特殊的格式返回,例如這樣的格式:

Book Code:[1001]|Book Name:<ASP>

可以通過自定義一個類似JsonResult的類來實現。代碼如下:

public class BookResult : ActionResult
{
        public BookResult(Book content)
        {
            Content = content;
        }
        public Book Content { get; set; }
        public string ContentType { get; set; }
        public int? StatusCode { get; set; }

        public override async Task ExecuteResultAsync(ActionContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

             var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<BookResult>>();
            await executor.ExecuteAsync(context, this);
        }
    }

定義了一個名為BookResult的類,為了方便繼承了ActionResult。由於是為了處理Book類型,在構造函數中添加了Book類型的參數,並將該參數賦值給屬性Content。重寫ExecuteResultAsync方法,對應JsonResultExecutor,還需要自定義一個BookResultExecutor。代碼如下:

public class BookResultExecutor : IActionResultExecutor<BookResult>
{
    private const string DefaultContentType = "text/plain; charset=utf-8";
    private readonly IHttpResponseStreamWriterFactory _httpResponseStreamWriterFactory;

    public BookResultExecutor(IHttpResponseStreamWriterFactory httpResponseStreamWriterFactory)
    {
        _httpResponseStreamWriterFactory = httpResponseStreamWriterFactory;
    }

    private static string FormatToString(Book book)
    {
        return string.Format("Book Code:[{0}]|Book Name:<{1}>", book.Code, book.Name);
    }

     /// <inheritdoc />
    public virtual async Task ExecuteAsync(ActionContext context, BookResult result)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (result == null)
        {
            throw new ArgumentNullException(nameof(result));
        }

        var response = context.HttpContext.Response;
        string resolvedContentType;
        Encoding resolvedContentTypeEncoding;
     ResponseContentTypeHelper.ResolveContentTypeAndEncoding(
            result.ContentType,
            response.ContentType,
            DefaultContentType,
            out resolvedContentType,
            out resolvedContentTypeEncoding);
        response.ContentType = resolvedContentType;

        if (result.StatusCode != null)
        {
            response.StatusCode = result.StatusCode.Value;
        }

        string content = FormatToString(result.Content);
        if (result.Content != null)
        {
            response.ContentLength = resolvedContentTypeEncoding.GetByteCount(content);
            using (var textWriter = _httpResponseStreamWriterFactory.CreateWriter(response.Body, resolvedContentTypeEncoding))
            {
                await textWriter.WriteAsync(content);
                await textWriter.FlushAsync();
            }
        }
    }
}

這里定義了默認的ContentType 類型,采用了文本格式,即"text/plain; charset=utf-8",這會在請求結果的Header中出現。為了特殊說明這個格式,也可以自定義一個特殊類型,例如"text/book; charset=utf-8",這需要項目中提前約定好。定義了一個FormatToString方法用於將Book類型的數據格式化。最終將格式化的數據寫入Response.Body中。

這個BookResultExecutor定義之后,需要在依賴注入中(Startup文件中的ConfigureServices方法)注冊:

public void ConfigureServices(IServiceCollection services)
{
    //省略部分代碼
    services.TryAddSingleton<IActionResultExecutor<BookResult>, BookResultExecutor>();
}

至此,這個自定義的BookResult就可以被使用了,例如下面代碼所示的Action:

public BookResult GetBookResult()
{
    return new BookResult(new Book() { Code = "1001", Name = "ASP" });
}

用Fiddler訪問這個Action測試一下,返回結果如下:

Book Code:[1001]|Book Name:<ASP>

Header值:

Content-Length: 32
Content-Type: text/book; charset=utf-8

這是自定義了Content-Type的結果。

三、 自定義格式化類

對於上一節的例子,也可以對照JsonOutputFormatter來自定義一個格式化類來實現。將新定義一個名為BookOutputFormatter的類,也如同JsonOutputFormatter一樣繼承TextOutputFormatter。代碼如下:

public class BookOutputFormatter : TextOutputFormatter
    {
        public BookOutputFormatter()
        {
            SupportedEncodings.Add(Encoding.UTF8);
            SupportedEncodings.Add(Encoding.Unicode);
            SupportedMediaTypes.Add("text/book");
        }

        public override bool CanWriteResult(OutputFormatterCanWriteContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            if (context.ObjectType == typeof(Book) || context.Object is Book)
            {
                return base.CanWriteResult(context);
            }

             return false;
        }

         private static string FormatToString(Book book)
        {
            return string.Format("Book Code:[{0}]|Book Name:<{1}>",book.Code,book.Name);
        }

        public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

             if (selectedEncoding == null)
            {
                throw new ArgumentNullException(nameof(selectedEncoding));
            }

            var valueAsString = FormatToString(context.Object as Book);
            if (string.IsNullOrEmpty(valueAsString))
            {
                await Task.CompletedTask;
            }

            var response = context.HttpContext.Response;
            await response.WriteAsync(valueAsString, selectedEncoding);
        }
    }

首先在構造函數中定義它所支持的字符集和Content-type類型。重寫CanWriteResult方法,這是用於確定它是否能處理對應的請求返回結果。可以在此方法中做多種判斷,最終返回bool類型的結果。本例比較簡單,僅是判斷返回的結果是否為Book類型。同樣定義了FormatToString方法用於請求結果的格式化。最后重寫WriteResponseBodyAsync方法,將格式化后的結果寫入Response.Body中。

BookOutputFormatter定義之后也需要注冊到系統中去,例如如下代碼:

 services.AddMvc(
    options =>
    {
        options.OutputFormatters.Insert(0,new BookOutputFormatter());
    }
 )

這里采用了Insert方法,也就是將其插入了OutputFormatters集合的第一個。所以在篩選OutputFormatters的時候,它也是第一個。此時的OutputFormatters如下圖 3

 

圖 3

通過Fiddler測試一下,以第一節返回Book類型的第4個例子為例:

public Book GetModel()
{
    return new Book() { Code = "1001", Name = "ASP" };
}

當設定accept: text/book或者未設定accept的時候,采用了自定義的BookOutputFormatter,返回結果為:

Book Code:[1001]|Book Name:<ASP>

Content-Type值是:Content-Type: text/book; charset=utf-8。

當設定accept: application/json的時候,返回JSON,值為:

{"code":"1001","name":"ASP"}

Content-Type值是:Content-Type: application/json; charset=utf-8。

這是由於BookOutputFormatter類型排在了JsonOutputFormatter的前面,所以對於Book類型會首先采用BookOutputFormatter,當客戶端通過Accept方式要求返回結果為JSON的時候,才采用了JSON類型。測試一下服務端的要求。將這個Action添加Produces設置,代碼如下:

 [Produces("application/json")]
 public Book GetModel()
 {
     return new Book() { Code = "1001", Name = "ASP" };
 }

此時無論設定accept: text/book或者未設定accept的情況,都會按照JSON的方式返回結果。這也驗證了第二節關於服務端和客戶端“協商”的規則。

四、添加XML類型支持

第三、四節通過自定義的方式實現了特殊格式的處理,在項目中常見的格式還有XML,這在ASP.NET Core中沒有做默認支持。如果需要XML格式的支持,可以通過NuGet添加相應的包。

在NuGet中搜索並安裝Microsoft.AspNetCore.Mvc.Formatters.Xml,如下圖 4

 

圖 4

不需要像BookOutputFormatter那樣都注冊方式,系統提供了注冊方法:

services.AddMvc().AddXmlSerializerFormatters();

或者

services.AddMvc().AddXmlDataContractSerializerFormatters();

分別對應了兩種格式化程序:

System.Xml.Serialization.XmlSerializer;
System.Runtime.Serialization.DataContractSerializer;

二者的區別就不在這里描述了。注冊之后,就可以通過在請求的Header中通過設置“accept: application/xml”來獲取XML類型的結果了。訪問上一節的返回結果類型為Book的例子,返回的結果如下:

<Book xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <Code>1001</Code>
  <Name>ASP</Name>
</Book>

Content-Type值是:Content-Type: application/xml; charset=utf-8。


免責聲明!

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



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