在上一篇爛文中老周已向各位介紹過 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 調用方指定的格式的。方式有二:
- 從路由規則查找名為“format”的關鍵字。就像 MVC 路由規則中的“controller”、"action"關鍵字一樣。如果“format”關鍵字識別出 json,那就返回 JSON 格式的數據;若識別出 xml 就返回 XML 格式的數據。
- 從請求 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 格式時返回的結果:

用 JSON 格式時返回的結果:

自己加個格式
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

前文老周賣了個關子: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");
});

原因並不是 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 類派生就行了。這貨是個抽象類,咱們要做兩件事:
-
在構造函數中向 SupportedMediaTypes 列表中添加受支持的 MIME 類型。你希望它兼容哪些格式,就分別 Add 進去就 OK 了。此例中老周僅希望它支持 text/plain 格式,所以只加這個就可以了。然后還要向 SupportedEncodings 列表添加受支持的字符編碼,現在一般用 UTF-8 就好,減少許多麻煩。
-
實現 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=直播帶貨,無需生產許可,無合格證,無需品控,無售后;無退換貨,商品若有質量問題,請買家自行銷毀
