ASP.NET Core MVC 混合路由/FromBody 模型绑定和验证

Posted

技术标签:

【中文标题】ASP.NET Core MVC 混合路由/FromBody 模型绑定和验证【英文标题】:ASP.NET Core MVC Mixed Route/FromBody Model Binding & Validation 【发布时间】:2017-08-03 22:19:45 【问题描述】:

我正在使用 ASP.NET Core 1.1 MVC 来构建 JSON API。给定以下模型和动作方法:

public class TestModel

    public int Id  get; set; 

    [Range(100, 999)]
    public int RootId  get; set; 

    [Required, MaxLength(200)]
    public string Name  get; set; 

    public string Description  get; set; 


[HttpPost("/test/rootId/echo/id")]
public IActionResult TestEcho([FromBody] TestModel data)

    return Json(new
    
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    );

我的操作方法参数上的 [FromBody] 导致模型从发布到端点的 JSON 有效负载绑定,但它也阻止了 IdRootId 属性通过路由参数绑定.

我可以将其分解为单独的模型,一个从路由绑定,一个从正文绑定,或者我也可以强制任何客户端发送 idrootId 作为有效负载的一部分,但这两个都是解决方案似乎比我想要的更复杂,并且不允许我将验证逻辑保留在一个地方。有什么方法可以让这种情况正常工作,模型可以正确绑定并且我可以将我的模型和验证逻辑保持在一起?

【问题讨论】:

【参考方案1】:

    安装包HybridModelBinding

    添加到 Statrup:

    services.AddMvc()
        .AddHybridModelBinder();
    

    型号:

    public class Person
    
        public int Id  get; set; 
        public string Name  get; set; 
        public string FavoriteColor  get; set; 
    
    

    控制器:

    [HttpPost]
    [Route("people/id")]
    public IActionResult Post([FromHybrid]Person model)
     
    

    请求:

    curl -X POST -H "Accept: application/json" -H "Content-Type:application/json" -d '
        "id": 999,
        "name": "Bill Boga",
        "favoriteColor": "Blue"
    ' "https://localhost/people/123?name=William%20Boga"
    

    结果:

    
        "Id": 123,
        "Name": "William Boga",
        "FavoriteColor": "Blue"
    
    

    还有其他高级功能。

【讨论】:

确保不要忘记第 4 步,因为它在 Github 上的文档中不是很清楚。很棒的包! 它正在正确绑定属性,但它没有将 ModelState.IsValid 设置为 true。【参考方案2】:

经过研究,我想出了一个创建新模型绑定器+绑定源+属性的解决方案,它结合了 BodyModelBinder 和 ComplexTypeModelBinder 的功能。它首先使用 BodyModelBinder 从 body 中读取,然后 ComplexModelBinder 填充其他字段。代码在这里:

public class BodyAndRouteBindingSource : BindingSource

    public static readonly BindingSource BodyAndRoute = new BodyAndRouteBindingSource(
        "BodyAndRoute",
        "BodyAndRoute",
        true,
        true
        );

    public BodyAndRouteBindingSource(string id, string displayName, bool isGreedy, bool isFromRequest) : base(id, displayName, isGreedy, isFromRequest)
    
    

    public override bool CanAcceptDataFrom(BindingSource bindingSource)
    
        return bindingSource == Body || bindingSource == this;
    


[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class FromBodyAndRouteAttribute : Attribute, IBindingSourceMetadata

    public BindingSource BindingSource => BodyAndRouteBindingSource.BodyAndRoute;


public class BodyAndRouteModelBinder : IModelBinder

    private readonly IModelBinder _bodyBinder;
    private readonly IModelBinder _complexBinder;

    public BodyAndRouteModelBinder(IModelBinder bodyBinder, IModelBinder complexBinder)
    
        _bodyBinder = bodyBinder;
        _complexBinder = complexBinder;
    

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    
        await _bodyBinder.BindModelAsync(bindingContext);

        if (bindingContext.Result.IsModelSet)
        
            bindingContext.Model = bindingContext.Result.Model;
        

        await _complexBinder.BindModelAsync(bindingContext);
    


public class BodyAndRouteModelBinderProvider : IModelBinderProvider

    private BodyModelBinderProvider _bodyModelBinderProvider;
    private ComplexTypeModelBinderProvider _complexTypeModelBinderProvider;

    public BodyAndRouteModelBinderProvider(BodyModelBinderProvider bodyModelBinderProvider, ComplexTypeModelBinderProvider complexTypeModelBinderProvider)
    
        _bodyModelBinderProvider = bodyModelBinderProvider;
        _complexTypeModelBinderProvider = complexTypeModelBinderProvider;
    

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    
        var bodyBinder = _bodyModelBinderProvider.GetBinder(context);
        var complexBinder = _complexTypeModelBinderProvider.GetBinder(context);

        if (context.BindingInfo.BindingSource != null
            && context.BindingInfo.BindingSource.CanAcceptDataFrom(BodyAndRouteBindingSource.BodyAndRoute))
        
            return new BodyAndRouteModelBinder(bodyBinder, complexBinder);
        
        else
        
            return null;
        
    


public static class BodyAndRouteModelBinderProviderSetup

    public static void InsertBodyAndRouteBinding(this IList<IModelBinderProvider> providers)
    
        var bodyProvider = providers.Single(provider => provider.GetType() == typeof(BodyModelBinderProvider)) as BodyModelBinderProvider;
        var complexProvider = providers.Single(provider => provider.GetType() == typeof(ComplexTypeModelBinderProvider)) as ComplexTypeModelBinderProvider;

        var bodyAndRouteProvider = new BodyAndRouteModelBinderProvider(bodyProvider, complexProvider);

        providers.Insert(0, bodyAndRouteProvider);
    

【讨论】:

这真是太棒了!谢谢你。这应该是框架中开箱即用的标准。 它就像一个魅力!完美解决了我的问题,谢谢!此外,它不仅是从路由和正文绑定,它实际上是从路由/正文/查询字符串/标题绑定。是由 ComplexTypeModelBinderProvider 完成的吗? @zpisgod 我不记得确切但我想是的。它以递归方式工作,在模型从主体 (_bodyBinder.BindModelAsync) 绑定之后,复杂的模型绑定器为模型中定义的每个属性重新运行模型绑定。 我不确定这个解决方案是否能在所有情况下都按预期工作。对于 shure,上述提供者应该是 providers 集合中的最后一个,否则不会调用集合/数组绑定器。我继续使用它,但会观察绑定是如何工作的 值得注意的是,这不适用于record 类型,因为它们是不可变的,并且复杂的模型绑定器将覆盖主体之一的结果。需要一个自定义的专用模型绑定器,它可以使用第二个绑定器创建模型的新副本。【参考方案3】:

您可以删除输入中的 [FromBody] 装饰器并让 MVC 绑定映射属性:

[HttpPost("/test/rootId/echo/id")]
public IActionResult TestEcho(TestModel data)

    return Json(new
    
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    );

更多信息: Model binding in ASP.NET Core MVC

更新

测试

更新 2

@heavyd,你是对的,因为 JSON 数据需要 [FromBody] 属性来绑定你的模型。所以我上面所说的将适用于表单数据,但不适用于 JSON 数据。

作为替代方案,您可以创建一个自定义模型绑定器,该绑定器绑定来自 url 的 IdRootId 属性,同时绑定来自请求正文的其余属性。

public class TestModelBinder : IModelBinder

    private BodyModelBinder defaultBinder;

    public TestModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory) // : base(formatters, readerFactory)
    
        defaultBinder = new BodyModelBinder(formatters, readerFactory);
    

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    
        // callinng the default body binder
        await defaultBinder.BindModelAsync(bindingContext);

        if (bindingContext.Result.IsModelSet)
        
            var data = bindingContext.Result.Model as TestModel;
            if (data != null)
            
                var value = bindingContext.ValueProvider.GetValue("Id").FirstValue;
                int intValue = 0;
                if (int.TryParse(value, out intValue))
                
                    // Override the Id property
                    data.Id = intValue;
                
                value = bindingContext.ValueProvider.GetValue("RootId").FirstValue;
                if (int.TryParse(value, out intValue))
                
                    // Override the RootId property
                    data.RootId = intValue;
                
                bindingContext.Result = ModelBindingResult.Success(data);
            

        

    

创建一个活页夹提供者:

public class TestModelBinderProvider : IModelBinderProvider

    private readonly IList<IInputFormatter> formatters;
    private readonly IHttpRequestStreamReaderFactory readerFactory;

    public TestModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory)
    
        this.formatters = formatters;
        this.readerFactory = readerFactory;
    

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    
        if (context.Metadata.ModelType == typeof(TestModel))
            return new TestModelBinder(formatters, readerFactory);

        return null;
    

并告诉 MVC 使用它:

services.AddMvc()
  .AddMvcOptions(options =>
  
     IHttpRequestStreamReaderFactory readerFactory = services.BuildServiceProvider().GetRequiredService<IHttpRequestStreamReaderFactory>();
     options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(options.InputFormatters, readerFactory));
  );

那么你的控制器有:

[HttpPost("/test/rootId/echo/id")]
public IActionResult TestEcho(TestModel data)
...

测试

您可以将 IdRootId 添加到您的 JSON 中,但它们将被忽略,因为我们在模型绑定器中覆盖它们。

更新 3

以上允许您使用数据模型注释来验证IdRootId。但我认为这可能会使其他查看您的 API 代码的开发人员感到困惑。我建议只简化 API 签名以接受与 [FromBody] 一起使用的不同模型,并将来自 uri 的其他两个属性分开。

[HttpPost("/test/rootId/echo/id")]
public IActionResult TestEcho(int id, int rootId, [FromBody]TestModelNameAndAddress testModelNameAndAddress)

您可以为所有输入编写一个验证器,例如:

// This would return a list of tuples of property and error message.
var errors = validator.Validate(id, rootId, testModelNameAndAddress); 
if (errors.Count() > 0)

    foreach (var error in errors)
    
        ModelState.AddModelError(error.Property, error.Message);
    

【讨论】:

一个很好的答案,它会通过删除属性来绑定基于主体和路由值的属性。我仍然建议使用路由约束,但我希望它们仍然会绑定到模型属性。我得测试一下看看。 @Nkosi,我的测试表明 MVC 从路由中获取了这些。 酷,那我一定是做错了什么。至少您能够确认它有效。 @FrankFajardo,您是否尝试过使用 JSON 正文而不是表单数据?我的测试表明,如果没有 [FromBody],模型绑定器将不会绑定到 JSON 主体,并且您的链接支持该立场。 Binding formatted data from the request body 我也用 JSON body 测试了我的,结果和@heavyd 一样【参考方案4】:

我没有为您的示例尝试过这个,但它应该像这样作为 asp.net 核心支持模型绑定工作。

您可以像这样创建模型。

public class TestModel

    [FromRoute]
    public int Id  get; set; 

    [FromRoute]
    [Range(100, 999)]
    public int RootId  get; set; 

    [FromBody]
    [Required, MaxLength(200)]
    public string Name  get; set; 

    [FromBody]
    public string Description  get; set; 

更新 1:如果流不可倒带,上述方法将不起作用。主要是在您发布 json 数据时。

自定义模型绑定器是一种解决方案,但如果您仍然不想创建那个并且只想使用模型进行管理,那么您可以创建两个模型。

public class TestModel
    
        [FromRoute]
        public int Id  get; set; 

        [FromRoute]
        [Range(100, 999)]
        public int RootId  get; set;         

        [FromBody]
        public ChildModel OtherData  get; set;         
    


    public class ChildModel
                
        [Required, MaxLength(200)]
        public string Name  get; set; 

        public string Description  get; set; 
    

注意:这与应用程序/json 绑定完美配合,因为它的工作方式与其他内容类型有所不同。

【讨论】:

我试过了,还是不行。一方面,每个请求只能有一个 FromBody 属性,并且将 FromBody 放在 class/action 参数上并在 to 属性上使用 FromRoute 也不起作用 @heavyd “将 FromBody 放在 class/action 参数上并在 to 属性上使用 FromRoute” 你为什么要这样做?让您的操作参数无属性。 @Mardoxx ASP.NET Core 不会绑定请求正文中的内容,除非您明确地将 [FromBody] 属性放在操作参数/模型属性上。【参考方案5】:

我最终做的(翻译成你的情况)是:

    型号
public class TestModel

    public int Id  get; set; 

    [Range(100, 999)]
    public int RootId  get; set; 

    [Required, MaxLength(200)]
    public string Name  get; set; 

    public string Description  get; set; 

    控制器
[HttpPost("/test/rootId/echo/id")]
public IActionResult TestEcho(int rootId, int id, TestModel data)

    data.RootId = rootId;
    data.Id = id;
    return Json(new
    
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    );

它可能与控制器方法上的签名不同。它可能看起来不像仅在签名中包含模型那么优雅。但是,这很简单,因为它不需要下载任何外部包,并且只需要对控制器方法进行少量更改(一个额外的行和每个添加的路由参数声明的参数)。

【讨论】:

以上是关于ASP.NET Core MVC 混合路由/FromBody 模型绑定和验证的主要内容,如果未能解决你的问题,请参考以下文章

混合 SPA 和 ASP.NET MVC 路由

从 ASP.NET Core MVC 项目提供 Angular SPA 时,我可以使用 OIDC 混合流吗?

Asp.Net Core MVC - 路由问题

[十] ASP.NET Core MVC 中的路由

ASP.NET Core MVC 中两种路由的简单配置

asp.net core 系列 5 MVC框架路由(上)