【ASP.NET Core】设置 Web API 响应数据的格式——FormatFilter特性篇
在上一篇烂文中老周已向各位介绍过 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=直播带货,无需生产许可,无合格证,无需品控,无售后;无退换货,商品若有质量问题,请买家自行销毁