在 .NET WebApi 中组合使用 [FromRoute] 和 [FromBody] 将参数读取到同一个参数类中
背景
假设有一个接口设计如下:
# 发表评论
curl --location --request POST 'http://localhost:3100/articles/:article_id/comments' \
--header 'Content-Type: application/json' \
--data-raw '{
"body": "评论内容"
}'
可以看到,这个接口有两个参数:
- article_id: path参数,评论的文章id
- body: body参数,评论内容
我们都知道,在 .NET WebApi 中,article_id 参数应该用 [FromRoute] 来接收;body 参数应该用 [FromBody] 来接收。
但是,[FromRoute] 和 [FromBody] 默认情况下是不能同时使用的,会导致接收不到 [FromRoute] 参数,如下:
/// <summary>
/// 发表评论
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[AcceptVerbs("POST")]
[Route("articles/{article_id:long}/comments")]
public async Task CreateNewComment([FromBody] NewCommentApiModel model)
{
// 能接收到 model.Body, 但 model.ArticleId 为 default
}
public class NewCommentApiModel
{
/// <summary>
/// 文章Id
/// </summary>
[FromRoute(Name = "article_id")]
public long ArticleId { get; set; }
/// <summary>
/// 评论内容
/// </summary>
[Required(AllowEmptyStrings = false, ErrorMessage = "评论内容不能为空。")]
[MaxLength(140, ErrorMessage = "评论内容不能超过140字。")]
public string Body { get; set; }
}
那么,怎么办呢?在 action 上写两个参数实在太丑了……
方案一
我们可以实现一个自定义的 ModelBinder,例如:
public class ApiModelBinder : IModelBinder
{
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
var type = bindingContext.ModelType;
var model = Activator.CreateInstance(type);
// 先读取 request.Body
var body = bindingContext.HttpContext.Request.Body;
var memory = new MemoryStream();
await body.CopyToAsync(memory);
if (memory.Length != 0)
{
memory.Position = 0;
using var reader = new StreamReader(memory, Encoding.UTF8);
var text = reader.ReadToEnd();
model = JsonConvert.DeserializeObject(text, type);
}
// 然后补充其他 request.Body 中不存在的参数
var provider = bindingContext.ValueProvider;
var props = type.GetProperties();
foreach (var prop in props)
{
if (!prop.CanWrite)
continue;
var result = provider.GetValue(prop.Name);
if (result != default)
{
var value = result.FirstValue.Convert2TypedObject(prop.PropertyType);
prop.SetValue(model, value);
}
}
bindingContext.Result = ModelBindingResult.Success(model);
}
}
然后,通过这个自定义的 ModelBinder,我们就可以在读取 [FromBody] 参数并反序列化完毕之后,再从 [FromRoute] 或者 [FromQuery],甚至 [FromForm] 或者 [FromHeader] 中读取其他参数,并通过反射的方式补充到参数类中。
最终 action 和参数类的代码与上面的背景中的写法大致相同,只需要在 参数类 上添加一个 ModelBinderAttribute
:
[ModelBinder(typeof(ApiModelBinder))]
public class NewCommentApiModel
{
// ...
}
方案二 (推荐)
从上面 方案一 的代码中可以看出,我们通过自定义的方式重新定义了 参数绑定 方式,但难免有考虑不周的的场景。所以如果能使用 .NET WebApi 中自带的方式实现 [FromRoute] 和 [FromBody] 的共用,将是更好的方案。
首先,我们可以在 Startup
类中添加一个配置来禁用掉框架默认的 “自动推断参数绑定来源” 功能
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
// 写法一
services.Configure<ApiBehaviorOptions>(options => options.SuppressInferBindingSourcesForParameters = true);
// 写法二
services.AddMvcCore().ConfigureApiBehaviorOptions(options =>
{
options.SuppressInferBindingSourcesForParameters = true;
});
}
}
然后稍微变更一下接口的写法,如下:
[ApiController]
public class ArticleCommentsController : Controller
{
/// <summary>
/// 发表评论
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[AcceptVerbs("POST")]
[Route("articles/{article_id:long}/comments")]
public async Task CreateNewComment(NewCommentApiModel model)
{
_logger.Trace(model.ArticleId);
_logger.Trace(model.CommentText.Body);
}
}
public class NewCommentApiModel
{
/// <summary>
/// 文章Id
/// </summary>
[FromRoute(Name = "article_id")]
public long ArticleId { get; set; }
/// <summary>
/// 评论内容
/// </summary>
[FromBody]
public NewCommentText CommentText { get; set; }
/// <summary>
/// 评论内容
/// </summary>
public class NewCommentText
{
/// <summary>
/// 评论内容
/// </summary>
[Required(AllowEmptyStrings = false, ErrorMessage = "评论内容不能为空。")]
[MaxLength(140, ErrorMessage = "评论内容不能超过140字。")]
public string Body { get; set; }
}
}
需要注意:
- Controller 类需要添加标记 Attribute [ApiController]
- Action 方法声明的参数上不能添加 [FromBody] 或 [FromRoute], 需要添加到参数类的指定字段上
- 因为默认情况下 [FromBody] 或 [FromRoute] 不能同时生效,则需要将 [FromBody] 的参数单独声明一个 class
- [FromBody] 参数类中的参数校验规则依然有效