在 .NET WebApi 中组合使用 [FromRoute] 和 [FromBody] 将参数读取到同一个参数类中

momo314相同方式共享非商业用途署名转载



背景

假设有一个接口设计如下:

# 发表评论
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] 参数类中的参数校验规则依然有效
✎﹏ 本文来自于 momo314和他们家的猫,文章原创,转载请注明作者并保留原文链接。