www.example.com中的Fluent模型绑定ASP.net

kb5ga3dv  于 2022-11-19  发布在  .NET
关注(0)|答案(1)|浏览(278)

是否有一种方法可以在目标模型之外以流畅的方式定义绑定属性(FromBodyFromQuery等)?类似于FluentValidation与[Required][MaxLength]等属性。
背景故事:
我想使用命令模型作为控制器动作参数:

[HttpPost]
public async Task<ActionResult<int>> Create(UpdateTodoListCommand command)
{
    return await Mediator.Send(command);
}

更重要的是,我希望模型能够从多个源(路径、主体等)绑定:

[HttpPut("{id}")]
public async Task<ActionResult> Update(UpdateTodoListCommand command)
{
  // command.Id is bound from the route, the rest is from the request body
}

这应该是可能的(https://josef.codes/model-bind-multiple-sources-to-a-single-class-in-asp-net-core/https://github.com/ardalis/RouteAndBodyModelBinding),但是需要在命令的属性上绑定属性,这应该避免。

aurhwmvo

aurhwmvo1#

可以使用自定义IModelBinderProvider自定义AspNetCore modelBinding进程而不使用属性。
我将解释一种方法来实现如下的请求结果:

Header: PUT
URL: /TodoList/testid?Title=mytitle&Index=2
BODY: { "Description": "mydesc" }

预期响应正文:

{"Id":"testid","Title":"mytitle","Description":"mydesc","Index":2}

因此,控制器应该将来自路由、查询和主体的所有数据混合为一个模型,然后返回序列化的模型(我们只想检查示例中的自定义绑定结果)。
C# POCO可以是:

public class UpdateTodoListCommand
{
    public string Id { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    // string properties are too easy to bind, so we add an extra property of another type for the demo
    public int Index { get; set; }
}

控制器:

[Route("[controller]")]
public class TodoListController : Controller
{
    [HttpPut("{id}")]
    public IActionResult Update(UpdateTodoListCommand command
    {
        // return the serialized model so we can check all query and route data are merged as expected in the command instance
        return Ok(JsonSerializer.Serialize(command));
    }
}

我们需要一些样板代码来声明关于命令的元数据,并定义哪个属性应该绑定到查询或路由数据。我把它做得非常简单,因为这不是主题的目的:

public class CommandBindingModel
{
    public HashSet<string> FromQuery { get; } = new HashSet<string>();
    public HashSet<string> FromPath { get; } = new HashSet<string>();
}

public class CommandBindingModelStore
{
    private readonly Dictionary<Type, CommandBindingModel> _inner = new ();

    public CommandBindingModel? Get(Type type, bool createIfNotExists)
    {
        if (_inner.TryGetValue(type, out var model))
            return model;
        if (createIfNotExists)
        {
            model = new CommandBindingModel();
            _inner.Add(type, model);
        }
        return model;
    }
}

该存储将包含要与自定义进程绑定的所有命令的元数据快照。
商店的流畅构建器可以像下面这样(我再一次尝试简单化):

public class CommandBindingModelBuilder
{
    public CommandBindingModelStore Store { get; } = new CommandBindingModelStore();

    public CommandBindingModelBuilder Configure<TModel>(Action<Step<TModel>> configure)
    {
        var model = Store.Get(typeof(TModel), true);
        configure(new Step<TModel>(model ?? throw new Exception()));
        return this;
    }

    public class Step<TModel>
    {
        private readonly CommandBindingModel _model;

        public Step(CommandBindingModel model)
        {
            _model = model;
        }

        public Step<TModel> FromQuery<TProperty>(Expression<Func<TModel, TProperty>> property
        {
            if (property.Body is not MemberExpression me)
                throw new NotImplementedException();
            _model.FromQuery.Add(me.Member.Name);
            return this;
        }

        public Step<TModel> FromPath<TProperty>(Expression<Func<TModel, TProperty>> property)
        {
            if (property.Body is not MemberExpression me)
                throw new NotImplementedException();
            _model.FromPath.Add(me.Member.Name);
            return this;
        }
    }
}

现在我们可以创建一个IModelBinderProvider的自定义实现。这个实现负责为我们商店的每个命令向MVC给予一个自定义IModelBinder。我们的命令是复杂类型的,所以我们必须获得一些元数据(从MVC API)来简化属性的绑定:

public class CommandModelBinderProvider : IModelBinderProvider
{
    private readonly CommandBindingModelStore _store;

    public CommandModelBinderProvider(CommandBindingModelStore store)
    {
        _store = store;
    }

    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        var model = _store.Get(context.Metadata.ModelType, false);
        if (model != null)
        {
            var binders = new Dictionary<ModelMetadata, IModelBinder>();
            foreach(var property in model.FromQuery.Concat(model.FromPath))
            {
                var metadata = context.Metadata.GetMetadataForProperty(context.Metadata.ModelType, property);
                var binder = context.CreateBinder(metadata);
                binders.Add(metadata, binder);
            }
            return new CommandModelBinder(model, binders);
        }
        return null;
    }
}

自定义绑定器将读取请求主体(在示例中为JSON,但您可以按任何需要的格式读取和解析):

public class CommandModelBinder : IModelBinder
{
    private readonly CommandBindingModel _commandBindingModel;
    private readonly Dictionary<ModelMetadata, IModelBinder> _binders;

    public CommandModelBinder(CommandBindingModel commandBindingModel, Dictionary<ModelMetadata, IModelBinder> binders)
    {
        _commandBindingModel = commandBindingModel;
        _binders = binders;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var value = await bindingContext.HttpContext.Request.ReadFromJsonAsync(bindingContext.ModelType);
        if (value == null)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }
        bindingContext.Model = value;

        /* CUSTOM BINDING HERE */

        bindingContext.Result = ModelBindingResult.Success(value);
    }
}

如果我们现在执行代码(假设MVC知道自定义提供程序,但在我的示例的这个阶段,这不是真的),只有属性Description会按预期绑定。因此,我们必须从QueryString绑定属性。在MVC绑定理念中,ValueProviders负责从请求中获取原始值:QueryStringValueProvider是QueryString的一个,所以我们可以使用它:

var queryStringValueProvider = new QueryStringValueProvider(BindingSource.Query, bindingContext.HttpContext.Request.Query, CultureInfo.CurrentCulture);
foreach (var fq in _commandBindingModel.FromQuery)
{
    var r = queryStringValueProvider.GetValue(fq);
    bindingContext.ModelState.SetModelValue(fq, r);
    if (r == ValueProviderResult.None) continue;
    /* we have to bind the value to our command */
}

这里很容易使用反射来设置我们命令的属性,但是MVC给予了我们一些工具,所以我认为使用它们更好。而且,我们只是得到一个类型为StringValues的原始值。因此将其转换为预期的属性类型可能会很麻烦(考虑UpdateTodoListCommand的属性Index)。现在可以使用在自定义IModelProvider中创建的绑定:

var m = bindingContext.ModelMetadata.GetMetadataForProperty(bindingContext.ModelType, fq);
using (bindingContext.EnterNestedScope(m, fq, fq, m.PropertyGetter(value)))
{
    bindingContext.Result = ModelBindingResult.Success(r);
    var binder = _binders[m];
    await binder.BindModelAsync(bindingContext);
    var result = bindingContext.Result;
    m.PropertySetter(value, result.Model);
}

现在,在我们的示例中,Title和Index将按预期绑定。Id属性可以与RouteValueProvider绑定:

var routeValueProvider = new RouteValueProvider(BindingSource.Path, bindingContext.ActionContext.RouteData.Values);
foreach (var fp in _commandBindingModel.FromPath)
{
    var r = routeValueProvider.GetValue(fp);
    bindingContext.ModelState.SetModelValue(fp, r);
    if (r == ValueProviderResult.None) continue;
    var m = bindingContext.ModelMetadata.GetMetadataForProperty(bindingContext.ModelType, fp);
    using (bindingContext.EnterNestedScope(m, fp, fp, m.PropertyGetter(value)))
    {
        bindingContext.Result = ModelBindingResult.Success(r);
        var binder = _binders[m];
        await binder.BindModelAsync(bindingContext);
        var result = bindingContext.Result;
        m.PropertySetter(value, result.Model);
    }
}

最后要做的是告诉MVC关于我们自定义的IModelBinderProvider,应该在MvcOptions中完成:

var store = new CommandBindingModelBuilder()
    .Configure<UpdateTodoListCommand>(e => e
        .FromPath(c => c.Id)
        .FromQuery(c => c.Title)
        .FromQuery(c => c.Index)
        )
    .Store;

builder.Services.AddControllers().AddMvcOptions(options =>
{
    options.ModelBinderProviders.Insert(0, new CommandModelBinderProvider(store));
});

这里有一个完整的要点:https://gist.github.com/thomasouvre/e5438816af1a0ad81bddf106432cfa7d
编辑:当然,您可以使用自定义IOperationProcessor来自定义NSwag操作生成,如下所示:

public class NSwagCommandOperationProcessor : IOperationProcessor
{
    private readonly CommandBindingModelStore _store;

    public NSwagCommandOperationProcessor(CommandBindingModelStore store)
    {
        _store = store;
    }

    public bool Process(OperationProcessorContext context)
    {
        ParameterInfo? pinfo = null;
        CommandBindingModel? model = null;

        // check if there is a command parameter in the action
        foreach (var p in context.MethodInfo.GetParameters())
        {
            pinfo = p;
            model = _store.Get(pinfo.ParameterType, false);
            if (model != null) break;
        }

        if (model == null || pinfo == null) return true; // false will exclude the action

        var jsonSchema = JsonSchema.FromType(pinfo.ParameterType); // create a full schema from the command type
        if (jsonSchema.Type != JsonObjectType.Object) return false;
        var bodyParameter = new OpenApiParameter() { IsRequired = true, Kind = OpenApiParameterKind.Body, Schema = jsonSchema, Name = pinfo.Name };
        foreach (var prop in jsonSchema.Properties.Keys.ToList())
        {
            if (model.FromQuery.Contains(prop) || model.FromPath.Contains(prop))
            {
                // then excludes some properties from the schema
                jsonSchema.Properties.Remove(prop);
                continue;
            }
            bodyParameter.Properties.Add(prop, jsonSchema.Properties[prop]);

            // if the property is not excluded, the property should be binded from the body
            // so we have to delete existing parameters generated by NSwag (probably binded as from query)
            var operationParameter = context.OperationDescription.Operation.Parameters.FirstOrDefault(p => p.Name == prop);
            if (operationParameter != null)
                context.OperationDescription.Operation.Parameters.Remove(operationParameter);
        }
        if (bodyParameter.Properties.Count > 0)
            context.OperationDescription.Operation.Parameters.Add(bodyParameter);

        return true;
    }
}

Swagger UI的实际结果:

相关问题