【ASP.NET Core】設定 Web API 響應資料的格式——FormatFilter特性篇

東邪獨孤發表於2022-02-13

在上一篇爛文中老周已向各位介紹過 Produces 特性的使用,本文老周將介紹另一個特性類:FormatFilterAttribute。

這個特性算得上是篩選器的馬甲,除了從 Attribute 類派生外,還實現了 IFilterFactory 介面。之所以說它是個馬甲,是因為 IFilterFactory 介面要求型別實現 CreateInstance 方法來產生篩選器的物件例項。也就是說,FormatFilterAttribute 類並沒有真正做篩選的程式碼,而是建立一個 FormatFilter 類的例項。


這廝是怎麼工作的

這個特性類可以應用在類(控制器)和方法(控制器中的 Action)上,它允許 API 的呼叫方主動選擇返回資料的格式。這是什麼騷操作呢?

如果你以前(我說的是以前,因為現在很多都只支援JSON格式)做過像微博開放平臺的 API 呼叫,可能還記得在 URL 上通過引數來選擇返回 XML 還是 JSON。比如這樣:

http://what.com/api/getlist?t=xml
http://what.com/api/getlist?t=json

當然了,前提是你寫的 API 支援被指定的格式,要是呼叫者指定了 jpg,而你編寫的 API 不支援是會報錯的。格式名稱是如何讓 ASP.NET Core 識別出要返回的 Content-Type 的呢?別急,往下看就知道了。

先說說 FormatFilter 特性是如何獲取到 API 呼叫方指定的格式的。方式有二:

  1. 從路由規則查詢名為“format”的關鍵字。就像 MVC 路由規則中的“controller”、"action"關鍵字一樣。如果“format”關鍵字識別出 json,那就返回 JSON 格式的資料;若識別出 xml 就返回 XML 格式的資料。
  2. 從請求 URL 的查詢字串中找到名為“format”的欄位,若它的值為 json 表示返回 JSON 格式的資料;若為 xml 就返回 XML 格式的資料。若為其他值,你得自定義實現。

最好通過路由規則的方式來處理,一則此法比較靈活,二則不必佔用 URL 查詢字串,免得把 URL 弄得太長。

剛剛老周說路由規則可以用“format”關鍵字來識別格式,要想知道為什麼,我們們可以看看 FormatFilter 類的原始碼(FormatFilter 特性只是個殼,沒啥好看)。

    public virtual string? GetFormat(ActionContext context)
    {
        if (context.RouteData.Values.TryGetValue("format", out var obj))
        {
            // null and string.Empty are equivalent for route values.
            var routeValue = Convert.ToString(obj, CultureInfo.InvariantCulture);
            return string.IsNullOrEmpty(routeValue) ? null : routeValue;
        }

        var query = context.HttpContext.Request.Query["format"];
        if (query.Count > 0)
        {
            return query.ToString();
        }

        return null;
    }

它先是從 RouteData 字典中找一找有沒有與“format”對應的值,如果有,就返回;如果沒有,再去找 URL 查詢字串中是否存在“format”欄位。

如你所見,在 FormatFilter 類中,這個 GetFormat 方法是宣告為 virtual 的,說白了,你可以自定義你的查詢方法,可能你找的不是名為“format”的關鍵字,而是叫“type”。你只要從 FormatFilter 類派生,然後覆寫 GetFormat 方法。最後把你自己寫的新 FormatFilter 註冊到 MVC 選項的 Filters 列表中即可。


動手一試

此處用的測試資料類為 Book。

    public class Book
    {
        /// <summary>
        /// 編號
        /// </summary>
        public uint ID { get; set; }
        /// <summary>
        /// 書名
        /// </summary>
        public string Title { get; set; }
        /// <summary>
        /// 作者
        /// </summary>
        public string Author { get; set; }
        /// <summary>
        /// 發行時間
        /// </summary>
        public DateTime PublishTime { get; set; }
    }

我們假設 Book 物件表示一本圖書的基本資訊。

然後,我們們弄個控制器。

    [Route("api/bkstore")]
    [ApiController, FormatFilter]
    public class BooksController : ControllerBase
    {
        [HttpGet("list/{format?}")]
        public IEnumerable<Book> ListBooks() => new Book[]
        {
            new() {ID=5112, Title="C語言從入門到割腕", Author="老周", PublishTime = new(2011,10,12)},
            new() {ID=72543, Title="下水道里的英雄", Author="老周", PublishTime= new(2021,4,17)},
            new() {ID=28565, Title="領飯盒時代", Author="老張", PublishTime= new(2022,5,1)},
            new() {ID=80251, Title="錢多腦傻的城裡人", Author="光頭強", PublishTime= new(2017,6,8)}
        };
    }

Books 控制器應用了 FormatFilter 特性,使得在整個控制器內的操作方法均支援通過 format 關鍵字來選擇資料格式。呼叫的 URL 格式如下:

http://localhost/api/bkstore/list/json
http://localhost/api/bkstore/list/xml

“{format?}”中有個問號,表示這個路由引數是可選的,即可以省略。如果省略,ASP.NET Core 應用程式就會從已經註冊的格式列表中查詢匹配的第一個項作為預設格式。例如,MVC 格式列表中註冊了json、xml、audio/wav 等格式,當 {format} 引數省略後,預設會選擇 json。

在 Program.cs 檔案中補上其他程式碼,在註冊 API 控制器功能時,要呼叫 AddXmlSerializerFormatters 方法,這樣才支援返回 XML 格式的資料。

var builder = WebApplication.CreateBuilder(args);
// 新增XML格式的支援需要呼叫 AddXmlSerializerFormatters 方法
builder.Services.AddControllers().AddXmlSerializerFormatters();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
//================================================================
var app = builder.Build();
//================================================================
app.UseSwagger();
app.UseSwaggerUI(o => 
{
    o.RoutePrefix = "";
    o.SwaggerEndpoint("swagger/v1/swagger.json", "swg");
});

app.MapControllers();

app.Run();

上面程式碼中,呼叫了 UseSwaggerUI 等方法,使專案支援 Web API 的測試,這個地方老周修改了一些預設配置。

app.UseSwaggerUI(o => 
{
    o.RoutePrefix = "";
    o.SwaggerEndpoint("swagger/v1/swagger.json", "swg");
});

RoutePrefix 屬性設定訪問 Swagger 頁面的路徑,預設要到 /swagger 下,我把它改為空字串,表示在根路徑就能訪問,主要是為了測試方便。直接訪問 http://localhost:xxx/ 就 OK。由於預設的字首 /swagger 被去掉了,所以,獲取描述 API 的 JSON 文件的獲取路徑要手動設定回預設的路徑 /swagger/v1/swagger.json,否則執行後會找不到 API 資訊。

由於 Swagger UI 的測試頁不能將 {format?} 識別為可選引數,所以在呼叫時要顯式加上 xxx/json 或 xxx/xml。

http://localhost:5228/api/bkstore/list/json
http://localhost:5228/api/bkstore/list/xml

用 XML 格式時返回的結果:

img

用 JSON 格式時返回的結果:

img


自己加個格式

json、xml 是 ASP.NET Core 自動註冊的格式名稱,我們也可以自己加一些格式。

builder.Services.AddControllers()
    .AddXmlSerializerFormatters()
    .AddFormatterMappings(mappings =>
    {
        mappings.SetMediaTypeMappingForFormat("txtj", "text/json");
    });

在呼叫完 AddControllers、AddXmlSerializerFormatters 後,順勢呼叫 AddFormatterMappings 方法新增格式對映。通過 SetMediaTypeMappingForFormat 方法把名為 txtj 的格式與 text/json 關聯。這麼一來,想讓 API 返回 Content-Type 為 text/json 的資料,只需要這樣訪問就行:

http://localhost:5228/api/bkstore/list/txtj

img

前文老周賣了個關子:ASP.NET Core 程式是如何識別出格式對應的 MIME ?這個 SetMediaTypeMappingForFormat 方法的呼叫就是答案。它維護了一個 Key/Value 集合(理解為一個字典吧),key 是格式的名稱(這個可以自定義),如 xml、json,jpg 等,然後會有唯一的 MIME 與之對應。像 json --> application/json,xml --> application/xml、abc --> image/png 這樣。

但是,若新增 txt --> text/plain 的對映,就會失敗。

builder.Services.AddControllers().AddXmlSerializerFormatters()
    .AddFormatterMappings(mappings =>
    {
        mappings.SetMediaTypeMappingForFormat("txt", "text/plain");
    });

img

原因並不是 ASP.NET Core 不允許你這樣做,而是格式不匹配。還記得老周在上一篇水文中說過嗎,text/plain 預設由 StringOutputFormatter 類來處理的,只支援返回值為 string 型別的方法。而我們們上例中的 ListBooks 方法是返回一個 Book 物件的列表的,型別上不匹配。

所以,如果你想對映 txt --> text/plain 上,需要自定義一個 Formatter,讓其將 Book 列表變為字串。這個大夥可以自己試試(這個最好不要太自定義了,否則有陣列有類,比較難搞,可以考慮在 Book 類中重寫 ToString 方法,可能好弄些),老周接下來用另一個例子來說明一下,因為這個例子不返回陣列,只返回單個例項,可以用反射來掃描所有公共屬性,然後連線成字串。當然了,這種做法侷限性大,也沒辦法通用於所有型別,僅作演示。

先定義我們們需要的資料類,這裡命名為 Goods,表示一件商品(因為老周是開雜貨店的,所以用 Goods 類)。

    public class Goods
    {
        /// <summary>
        /// 商品ID
        /// </summary>
        public uint ID { get; set; }
        /// <summary>
        /// 商品標題
        /// </summary>
        public string Name { get; set; } = "none";
        /// <summary>
        /// 單價
        /// </summary>
        public decimal Price { get; set; }
        /// <summary>
        /// 備註
        /// </summary>
        public string Remark { get; set; } = string.Empty;
    }

接著,實現自定義的 Formatter 類,這裡我們們所需的功能是將物件的公共屬性拼接為字串返回給客戶端。故我們們不需要完全自己去實現 IOutputFormatter 介面,直接從 TextOutputFormatter 類派生就行了。這貨是個抽象類,我們們要做兩件事:

  1. 在建構函式中向 SupportedMediaTypes 列表中新增受支援的 MIME 型別。你希望它相容哪些格式,就分別 Add 進去就 OK 了。此例中老周僅希望它支援 text/plain 格式,所以只加這個就可以了。然後還要向 SupportedEncodings 列表新增受支援的字元編碼,現在一般用 UTF-8 就好,減少許多麻煩。

  2. 實現 WriteResponseBodyAsync 方法,將待處理物件轉化為字串,並回寫到響應流中。

    public class MyOutputFormatter : TextOutputFormatter
    {
        public MyOutputFormatter()
        {
            /*
             * 下面這兩行必不能少
             */
            // 新增所支援的 MIME 型別
            SupportedMediaTypes.Add("text/plain");
            // 新增支援的字元編碼
            SupportedEncodings.Add(Encoding.UTF8);
        }


        public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
        {
            // 獲取被處理的物件例項
            object obj = context.Object;
            // 獲取物件的 Type
            Type objtype = context.ObjectType;
            if (obj is null || objtype is null)
            {
                return;
            }
            // 找出公共屬性
            var props = objtype.GetProperties(BindingFlags.Public | BindingFlags.Instance);
            StringBuilder strbf = new();
            // 逐個讀取出來
            foreach (var p in props)
            {
                strbf.Append($"{p.Name}=");
                object val = p.GetValue(obj);
                if (!(val is null))
                {
                    strbf.Append(val);
                }
                strbf.AppendLine();
            }
            // 寫響應內容
            await context.HttpContext.Response.WriteAsync(strbf.ToString());
        }
    }

在 Program.cs 檔案中,呼叫 AddControllers 方法,把剛剛定義的 Formatter 例項新增到 OutputFormatters 列表中。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(opt =>
{
    opt.OutputFormatters.Add(new MyOutputFormatter());
})
    .AddXmlSerializerFormatters()
    .AddFormatterMappings(mappings =>
    {
        mappings.SetMediaTypeMappingForFormat("txt", "text/plain");
    });
……

最後,我們們回過頭來向控制器類新增一個操作方法。

        [HttpGet("buy/{format?}")]
        public Goods BuySomething() => new Goods
        {
            ID = 93257,
            Name = "恐龍皮做的女士揹包",
            Price = 58888.03M,
            Remark = "直播帶貨,無需生產許可,無合格證,無需品控,無售後;無退換貨,商品若有質量問題,請買家自行銷燬"
        };

然後執行測試一下(訪問 http://localhost:xxxx/api/bkstore/buy/txt)。返回結果:

ID=93257
Name=恐龍皮做的女士揹包
Price=58888.03
Remark=直播帶貨,無需生產許可,無合格證,無需品控,無售後;無退換貨,商品若有質量問題,請買家自行銷燬

相關文章