如何:来自多个源的参数绑定
How to: Parameter binding from multiple sources
目前我正在尝试创建一个基于 asp.net 核心 2.0 的网络 api,我想创建一个嵌套路由。在放置请求的情况下,它在路由中发送一部分信息,在 body.
中发送另一部分信息
要求
希望 url 调用的是
https://localhost/api/v1/master/42/details
如果我们想在我们的 master 42 下面创建一个新的详细信息,我希望在 body 中发送详细信息的数据,而 ID主人从路线中走出来
curl -X POST --header 'Content-Type: application/json' \
--header 'Accept: application/json' \
-d '{ \
"name": "My detail name", \
"description": "Just some kind of information" \
}' 'https://localhost/api/v1/master/42/details'
请求的输出响应将是
{
"name": "My detail name",
"description": "Just some kind of information",
"masterId": 42,
"id": 47
}
和响应中的位置 url header 喜欢
{
"location": "https://localhost/api/v1/master/42/details/47
}
到目前为止完成的工作
为了让它工作,我创建了这个控制器:
[Produces("application/json")]
[Route("api/v1/master/{masterId:int}/details")]
public class MasterController : Controller
{
[HttpPost]
[Produces(typeof(DetailsResponse))]
public async Task<IActionResult> Post([FromBody, FromRoute]DetailCreateRequest request)
{
if(!ModelState.IsValid)
return BadRequest(ModelState);
var response = await Do.Process(request);
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
}
使用这些 classes:
public class DetailCreateRequest
{
public int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
public class DetailResponse
{
public int Id { get; set; }
public int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
问题
到目前为止,大部分内容都按预期工作。唯一真正不起作用的是将路由中的 MasterId 合并到来自 body.
的 DetailCreateRequest
第一次尝试:在参数上使用两个属性
我试图通过这个动作调用将这两件事结合起来:
public async Task<IActionResult> Post([FromBody, FromRoute]DetailCreateRequest request)
但是传入的 object 只有 MasterId
为零。如果我更改这两个属性的顺序,那么只会采用路由中的 id 并且 body 中的所有值都将被忽略(因此似乎是第一个属性获胜)。
第二次尝试:在动作中使用两个不同的参数
我尝试的另一种方法是此操作调用:
public async Task<IActionResult> Post([FromRoute]int masterId, [FromBody]DetailCreateRequest request)
在第一个位置这看起来没问题,因为现在我在控制器操作中有两个值。但我对这种方法的最大问题是模型验证。正如您在上面的代码中看到的,我检查了 ModelState.IsValid
,它是通过 FluentValidation
的一些检查填充的,但是这些检查无法真正完成,因为 object 没有建立起来由于缺少主 ID 而正确。
(Not-working) 想法:使用合并参数创建自己的属性
试图实现这样的东西:
public async Task<IActionResult> Post([FromMultiple(Merge.FromBody, Merge.FromRoute)]DetailCreateRequest request)
如果我们已经有了这样的东西,那就太好了。属性中参数的顺序将给出合并(以及可能的覆盖)发生的顺序。
我已经开始实施此属性并为所需的 IValueProvider
和 IValueProviderFactory
创建框架。但这似乎是相当多的工作。特别是找到所有漂亮的细节,使它与我正在使用的 asp.net 核心和其他库的整个管道无缝地工作(比如通过 swashbuckle 招摇过市)。
所以我的问题是,asp.net 核心是否已经存在某种机制来实现这样的合并,或者 body 是否知道已经存在的解决方案或关于如何实现这样的野兽。
目前的解决方案:自定义 ModelBinder
从 I look into how to create a custom model binder 得到答案并提出了这个实现:
public class MergeBodyAndValueProviderBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
throw new ArgumentNullException(nameof(bindingContext));
var body = bindingContext.HttpContext.Request.Body;
var type = bindingContext.ModelMetadata.ModelType;
var instance = TryCreateInstanceFromBody(body, type, out bool instanceChanged);
var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
var setters = type.GetProperties(bindingFlags).Where(property => property.CanWrite);
foreach (var setter in setters)
{
var result = bindingContext.ValueProvider.GetValue(setter.Name);
if (result != ValueProviderResult.None)
{
try
{
var value = Convert.ChangeType(result.FirstValue, setter.PropertyType);
setter.SetMethod.Invoke(instance, new[] { value });
instanceChanged = true;
}
catch
{ }
}
}
if (instanceChanged)
bindingContext.Result = ModelBindingResult.Success(instance);
return Task.CompletedTask;
}
private static object TryCreateInstanceFromBody(Stream body, Type type, out bool instanceChanged)
{
try
{
using (var reader = new StreamReader(body, Encoding.UTF8, false, 1024, true))
{
var data = reader.ReadToEnd();
var instance = JsonConvert.DeserializeObject(data, type);
instanceChanged = true;
return instance;
}
}
catch
{
instanceChanged = false;
return Activator.CreateInstance(type);
}
}
}
它尝试将 body 反序列化为所需的 object 类型,然后尝试应用来自可用值提供程序的更多值。为了让这个模型活页夹工作,我必须用 ModelBinderAttribute 装饰目标 class 并将 MasterId 设为内部,这样 swagger 就不会宣布它并且 JsonConvert 不会反序列化它:
[ModelBinder(BinderType = typeof(MergeBodyAndValueProviderBinder))]
public class DetailCreateRequest
{
internal int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
在我的控制器中,动作方法参数仍然包含 [FromBody]
标志,因为 swagger 使用它来宣布如何调用该方法,但它永远不会被调用,因为我的模型绑定器具有更高的优先级。
public async Task<IActionResult> Post([FromBody]DetailCreateRequest request)
所以它并不完美,但到目前为止效果很好。
这看起来是个正确的选择:
[HttpPost]
[Produces(typeof(DetailsResponse))]
public async Task<IActionResult> Post([FromRoute]int masterId, [FromBody]DetailCreateRequest request) {
//...
}
但是,如果您在域模型验证方面遇到一些问题,请创建不带主 ID 的自定义 Dto 对象。
否则,您可以使用自定义模型绑定器,然后使用来自操作和绑定上下文的参数。
我不确定这是否适用于 Asp.Net-Core 2.0,但我们在 3.1 中使用以下内容来拥有一个从多个位置获取其属性的请求对象:
// Annotate the action parameter with all relevant attributes
public async Task<IActionResult> Post([FromBody][FromRoute][FromQuery]DetailCreateRequest request) { ... }
// Annotate each property separately, so the binder(s) don't overwrite
public class DetailCreateRequest
{
[FromRoute]
public int MasterId { get; set; }
[FromBody]
public string Name { get; set; }
[FromQuery]
public string Description { get; set; }
}
目前我正在尝试创建一个基于 asp.net 核心 2.0 的网络 api,我想创建一个嵌套路由。在放置请求的情况下,它在路由中发送一部分信息,在 body.
中发送另一部分信息要求
希望 url 调用的是
https://localhost/api/v1/master/42/details
如果我们想在我们的 master 42 下面创建一个新的详细信息,我希望在 body 中发送详细信息的数据,而 ID主人从路线中走出来
curl -X POST --header 'Content-Type: application/json' \
--header 'Accept: application/json' \
-d '{ \
"name": "My detail name", \
"description": "Just some kind of information" \
}' 'https://localhost/api/v1/master/42/details'
请求的输出响应将是
{
"name": "My detail name",
"description": "Just some kind of information",
"masterId": 42,
"id": 47
}
和响应中的位置 url header 喜欢
{
"location": "https://localhost/api/v1/master/42/details/47
}
到目前为止完成的工作
为了让它工作,我创建了这个控制器:
[Produces("application/json")]
[Route("api/v1/master/{masterId:int}/details")]
public class MasterController : Controller
{
[HttpPost]
[Produces(typeof(DetailsResponse))]
public async Task<IActionResult> Post([FromBody, FromRoute]DetailCreateRequest request)
{
if(!ModelState.IsValid)
return BadRequest(ModelState);
var response = await Do.Process(request);
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
}
使用这些 classes:
public class DetailCreateRequest
{
public int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
public class DetailResponse
{
public int Id { get; set; }
public int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
问题
到目前为止,大部分内容都按预期工作。唯一真正不起作用的是将路由中的 MasterId 合并到来自 body.
的DetailCreateRequest
第一次尝试:在参数上使用两个属性
我试图通过这个动作调用将这两件事结合起来:
public async Task<IActionResult> Post([FromBody, FromRoute]DetailCreateRequest request)
但是传入的 object 只有 MasterId
为零。如果我更改这两个属性的顺序,那么只会采用路由中的 id 并且 body 中的所有值都将被忽略(因此似乎是第一个属性获胜)。
第二次尝试:在动作中使用两个不同的参数
我尝试的另一种方法是此操作调用:
public async Task<IActionResult> Post([FromRoute]int masterId, [FromBody]DetailCreateRequest request)
在第一个位置这看起来没问题,因为现在我在控制器操作中有两个值。但我对这种方法的最大问题是模型验证。正如您在上面的代码中看到的,我检查了 ModelState.IsValid
,它是通过 FluentValidation
的一些检查填充的,但是这些检查无法真正完成,因为 object 没有建立起来由于缺少主 ID 而正确。
(Not-working) 想法:使用合并参数创建自己的属性
试图实现这样的东西:
public async Task<IActionResult> Post([FromMultiple(Merge.FromBody, Merge.FromRoute)]DetailCreateRequest request)
如果我们已经有了这样的东西,那就太好了。属性中参数的顺序将给出合并(以及可能的覆盖)发生的顺序。
我已经开始实施此属性并为所需的 IValueProvider
和 IValueProviderFactory
创建框架。但这似乎是相当多的工作。特别是找到所有漂亮的细节,使它与我正在使用的 asp.net 核心和其他库的整个管道无缝地工作(比如通过 swashbuckle 招摇过市)。
所以我的问题是,asp.net 核心是否已经存在某种机制来实现这样的合并,或者 body 是否知道已经存在的解决方案或关于如何实现这样的野兽。
目前的解决方案:自定义 ModelBinder
从
public class MergeBodyAndValueProviderBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
throw new ArgumentNullException(nameof(bindingContext));
var body = bindingContext.HttpContext.Request.Body;
var type = bindingContext.ModelMetadata.ModelType;
var instance = TryCreateInstanceFromBody(body, type, out bool instanceChanged);
var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
var setters = type.GetProperties(bindingFlags).Where(property => property.CanWrite);
foreach (var setter in setters)
{
var result = bindingContext.ValueProvider.GetValue(setter.Name);
if (result != ValueProviderResult.None)
{
try
{
var value = Convert.ChangeType(result.FirstValue, setter.PropertyType);
setter.SetMethod.Invoke(instance, new[] { value });
instanceChanged = true;
}
catch
{ }
}
}
if (instanceChanged)
bindingContext.Result = ModelBindingResult.Success(instance);
return Task.CompletedTask;
}
private static object TryCreateInstanceFromBody(Stream body, Type type, out bool instanceChanged)
{
try
{
using (var reader = new StreamReader(body, Encoding.UTF8, false, 1024, true))
{
var data = reader.ReadToEnd();
var instance = JsonConvert.DeserializeObject(data, type);
instanceChanged = true;
return instance;
}
}
catch
{
instanceChanged = false;
return Activator.CreateInstance(type);
}
}
}
它尝试将 body 反序列化为所需的 object 类型,然后尝试应用来自可用值提供程序的更多值。为了让这个模型活页夹工作,我必须用 ModelBinderAttribute 装饰目标 class 并将 MasterId 设为内部,这样 swagger 就不会宣布它并且 JsonConvert 不会反序列化它:
[ModelBinder(BinderType = typeof(MergeBodyAndValueProviderBinder))]
public class DetailCreateRequest
{
internal int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
在我的控制器中,动作方法参数仍然包含 [FromBody]
标志,因为 swagger 使用它来宣布如何调用该方法,但它永远不会被调用,因为我的模型绑定器具有更高的优先级。
public async Task<IActionResult> Post([FromBody]DetailCreateRequest request)
所以它并不完美,但到目前为止效果很好。
这看起来是个正确的选择:
[HttpPost]
[Produces(typeof(DetailsResponse))]
public async Task<IActionResult> Post([FromRoute]int masterId, [FromBody]DetailCreateRequest request) {
//...
}
但是,如果您在域模型验证方面遇到一些问题,请创建不带主 ID 的自定义 Dto 对象。 否则,您可以使用自定义模型绑定器,然后使用来自操作和绑定上下文的参数。
我不确定这是否适用于 Asp.Net-Core 2.0,但我们在 3.1 中使用以下内容来拥有一个从多个位置获取其属性的请求对象:
// Annotate the action parameter with all relevant attributes
public async Task<IActionResult> Post([FromBody][FromRoute][FromQuery]DetailCreateRequest request) { ... }
// Annotate each property separately, so the binder(s) don't overwrite
public class DetailCreateRequest
{
[FromRoute]
public int MasterId { get; set; }
[FromBody]
public string Name { get; set; }
[FromQuery]
public string Description { get; set; }
}