上一章的結尾留下了一個問題:同樣是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種不同情況作了分別處理,優化了查找順序,此處就不詳細講解了。
總結一下這個規則:
- 只有在Action返回類型為ObjectResult的時候才會進行“協商”。如果返回類型為JsonResult、ContentResult、ViewResult等特定ActionResult,無論請求是否設置了accept標識,都會被忽略,會固定返回 JSON、String,Html類型的結果。
- 當系統檢測到請求是來自瀏覽器時,會忽略 其Header中Accept 的設置,所以會由服務器端設置的格式決定(未做特殊配置時,系統默認為JSON)。 這是為了在使用不同瀏覽器使用 API 時提供更一致的體驗。系統提供了參數RespectBrowserAcceptHeader,即尊重瀏覽器在請求的Header中關於Accept的設置,默認值為false。將其設置為true的時候,瀏覽器請求中的Accept 標識才會生效。注意這只是使該Accept 標識生效,依然不能由其決定返回格式,會進入“協商”階段。
- 若二者均未設置,采用默認的JSON格式。
- 若二者其中有一個被設置,采用該設置值。
- 若二者均設置且不一致,即二者值不相同且沒有包含關系(有通配符的情況),會判斷系統參數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。