一、概述
PUT
和 PATCH
方法用于更新现有资源。 它们之间的区别是,PUT 会替换整个资源,而 PATCH 仅指定更改。
在 ASP.NET Core Web API 中,由于 C# 是一种静态语言(dynamic
在此不表),当我们定义了一个类型用于接收 HTTP Patch 请求参数的时候,在 Action
中无法直接从实例中得知客户端提供了哪些参数。
比如定义一个输入模型和数据库实体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class PersonInput { public string? Name { get; set; }
public int? Age { get; set; }
public string? Gender { get; set; } }
public class PersonEntity { public string Name { get; set; }
public int Age { get; set; }
public string Gender { get; set; } }
|
再定义一个以 FromForm
形式接收参数的 Action:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| [HttpPatch] [Route("patch")] public ActionResult Patch([FromForm] PersonInput input) { var config = new MapperConfiguration(cfg => { cfg.CreateMap<PersonInput, PersonEntity>()); }); var mapper = config.CreateMapper();
var entity = new PersonEntity { Name = "姓名", Age = 18, Gender = "我可能会被改变", };
mapper.Map(input, entity);
return Ok(); }
|
1 2
| curl --location --request PATCH 'http://localhost:5094/test/patch' \ --form 'Name="foo"'
|
如果客户端只提供了 Name
而没有其他参数,从 HttpContext.Request.Form.Keys
可以得知这一点。如果不使用 AutoMapper,那么就需要使用丑陋的判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| [HttpPatch] [Route("patch")] public ActionResult Patch([FromForm] PersonInput input) { var keys = _httpContextAccessor.HttpContext.Request.Form.Keys.Select(m => m.ToLower()); var entity = new PersonEntity { Name = "姓名", Age = 18, Gender = "我可能会被改变", };
if (keys.Contains("name")) { entity.Name = input.Name!; } if (keys.Contains("age")) { entity.Age = input.Age!.Value; } return Ok(); }
|
本文提供一种方式来简化这个步骤。
定义一个名为 PatchInput
的类:
1 2 3 4 5
| public abstract class PatchInput { [BindNever] public ICollection<string>? PatchKeys { get; set; } }
|
PatchKeys
属性不由客户端提供,不参与默认绑定。
PersonInput
继承自 PatchInput:
1 2 3 4 5 6 7 8
| public class PersonInput : PatchInput { public string? Name { get; set; }
public int? Age { get; set; }
public string? Gender { get; set; } }
|
三、定义 ModelBinderFactory 和 ModelBinder
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class PatchModelBinder : IModelBinder { private readonly IModelBinder _internalModelBinder;
public PatchModelBinder(IModelBinder internalModelBinder) { _internalModelBinder = internalModelBinder; }
public async Task BindModelAsync(ModelBindingContext bindingContext) { await _internalModelBinder.BindModelAsync(bindingContext); if (bindingContext.Model is PatchInput model) { model.PatchKeys = bindingContext.HttpContext.Request.Form.Keys; } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public class PatchModelBinderFactory : IModelBinderFactory { private ModelBinderFactory _modelBinderFactory;
public PatchModelBinderFactory( IModelMetadataProvider metadataProvider, IOptions<MvcOptions> options, IServiceProvider serviceProvider) { _modelBinderFactory = new ModelBinderFactory(metadataProvider, options, serviceProvider); }
public IModelBinder CreateBinder(ModelBinderFactoryContext context) { var modelBinder = _modelBinderFactory.CreateBinder(context); if (typeof(PatchInput).IsAssignableFrom(context.Metadata.ModelType) && modelBinder.GetType().ToString().EndsWith("ComplexObjectModelBinder")) { modelBinder = new PatchModelBinder(modelBinder); } return modelBinder; } }
|
四、在 ASP.NET Core 项目中替换 ModelBinderFactory
1 2 3 4
| var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPatchMapper();
|
AddPatchMapper
是一个简单的扩展方法:
1 2 3 4 5 6 7 8
| public static class PatchMapperExtensions { public static IServiceCollection AddPatchMapper(this IServiceCollection services) { services.Replace(ServiceDescriptor.Singleton<IModelBinderFactory, PatchModelBinderFactory>()); return services; } }
|
到目前为止,在 Action 中已经能获取到请求的 Key 了。
1 2 3 4 5 6 7
| [HttpPatch] [Route("patch")] public ActionResult Patch([FromForm] PersonInput input) { return Ok(); }
|
PatchKeys
的作用是利用 AutoMapper。
五、扩展 AutoMapper
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public static class AutoMapperExtensions { public static IMappingExpression<TSource, TDestination> CreateMapWithPath<TSource, TDestination>(this IMapperConfigurationExpression cfg) where TSource : PatchInput { return cfg.CreateMap<TSource, TDestination>().ApplyPatchKeysCondition<TSource, TDestination>(); }
private static IMappingExpression<TSource, TDestination> ApplyPatchKeysCondition<TSource, TDestination>( this IMappingExpression<TSource, TDestination> mappingExpression) where TSource : PatchInput { mappingExpression.ForAllMembers(opts => { opts.Condition((src, dest, srcMember, destMember, context) => { return src.PatchKeys == null || src.PatchKeys.Contains(opts.DestinationMember.Name.ToLower()); }); }); return mappingExpression; } }
|
六、模型映射
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| [HttpPatch] [Route("patch")] public ActionResult Patch([FromForm] PersonInput input) {
var config = new MapperConfiguration(cfg => { cfg.CreateMapWithPath<PersonInput, PersonEntity>(); }); var mapper = config.CreateMapper();
var entity = new PersonEntity { Name = "姓名", Age = 18, Gender = "如果客户端没有提供本参数,那我的值不会被改变" }; mapper.Map(input, entity);
return Ok(); }
|
七、测试
1 2
| curl --location --request PATCH 'http://localhost:5094/test/patch' \ --form 'Name="foo"'
|
或
1 2 3
| curl --location --request PATCH 'http://localhost:5094/test/patch' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'Name=foo'
|
下一步
尝试 INotifypropertyChanged
和 Fody
的 PropertyChanged
来获取 Keys。
源码
Tubumu.PatchMapper
参考资料
GraphQL.NET
如何在 ASP.NET Core Web API 中处理 JSON Patch 请求