diff --git a/src/source/_posts/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net.md b/src/source/_posts/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net.md new file mode 100644 index 0000000..3b94d9c --- /dev/null +++ b/src/source/_posts/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net.md @@ -0,0 +1,248 @@ +--- +title: How to keep payload process in ASP.NET Core consistent with ASP.NET +date: 2024-09-06 22:15:59 +tags: ASP.NET Core +--- + +# 问题背景 + +最近主要做的一项工作是需要将 XXX Api 中 Engagements 相关的业务接口从 ASP.NET 框架迁移至 ASP.NET Core 中。得益于团队前期已经把基础依赖部分的实现代码都已经迁到到 netstandard2.0,所以 Controller 级别的接口迁移也就不需要很多工作量,基本就是修改一下路由定义和对应的返回值类型就差不多了。再把所有相关的 Controller 都迁移完之后按照团队的测试策略来讲的话,也就可以部署到 QA 环境进行测试。在第一轮的测试过程中,QA人员反馈测试相对比较顺利,没有发现什么明显的 bug。本以为能安心接着修改 Engagements 对应的测试,但是却突然收到了其他团队的反馈,说有一个接口出问题,错误日志如下图所示: + +![Error](/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error01.png) + +# 问题复现 + +通过和前后端代码反复对比确认,这一块的代码从 ASP.NET 迁移到 ASP.NET Core 中并没有做任何改动,理论上不应该出现这种问题,为了让这个问题能在本地复现,我在本地分别写了两个不同框架的接口: + +```C# +# ASP.NET Core +public class XXXServicesController : ControllerBase +{ + [HttpPut("fee/xxx/{engagementId}/services")] + public IActionResult Save([FromRoute]long engagementId, [FromBody] BatchUpdateEngagementTypeOfServiceRequest request) + { + return Ok(request); + } +} + +# ASP.NET +public class EngagementServicesController : ApiController +{ + [HttpPut] + [Route("fee/xxx/{engagementId}/services")] + public HttpResponseMessage Save(long engagementId, BatchUpdateEngagementTypeOfServiceRequest request) + { + return Request.CreateResponse(request); + } +} +``` + +Payload 参数如下所示: + +```C# +{ + "servicesToCreate":[ + { + "service":{ + "name":"12312", + "engagementUri":"https://test/xxx/20069", + "countryTypeOfServiceUri":"https://test/services/1798", + "status":"Active", + "needPreApproval":false, + "abc":"N/A" + }, + "fee":{ + "countryAbbreviation":"LT", + "interofficeFeeAmount":null, + "adbcdefeaade":null, + "useForProgressBill":false + } + } + ], + "servicesToUpdate":[ + + ], + "idsToBeDeleted":[ + + ] +} +``` + +发现在 payload 相同的情况下,传到 ASP.NET 中就可以正常解析,但是传到 APS.NET Core 中时就会导致转化 request 对象为 null,通过对比 payload 格式和定义的 DTO 差异,发现 actionRequiredLeadTime 后端实际定义的类型为 int? 类型,但是前端传递的值却是 N/A ,显然是类型不匹配导致的问题。为了让日志里面的错误更加具体,我在 ASP.NET Core 中将序列化异常的回调事件注册上: + +```C# +services.AddControllers().AddNewtonsoftJson(options => +{ + options.SerializerSettings.Error += (sender, args) => + { + var errorCtx = args.ErrorContext.Error; + LogManager.GetLogger(nameof(Program)).Log(LogLevel.Error, errorCtx, errorCtx.Message); + args.ErrorContext.Handled = false; + }; +}); +``` + +添加上述配置后再进行测试,ASP.NET Core 中就会有一个这种错误信息了: + +![Error](/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error02.png) + +# 追根溯源 + +通过本地复现的方式,可以得到一个这样的结论:在 payload 穿的参数类型和后端定义的参数类型不一致的情况下,ASP.NET 不会报错,会以默认值填充;但是在 ASP.NET Core 中就直接爆参数类型异常。为了从代码代码角度来分析这个问题,有必要先了解一下这两种框架对于处理 payload 的差异性。 + +![Filter Pipeline](/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/filterpipeline.png) + +![Controller Execute](/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/controllerexecute.png) + + +通过上图我们可以看到,无论是 ASP.NET Core 还是 ASP.NET ,对于 payload 转后端对象的逻辑都是由 Model Binding 这一层来实现的,所以就需要这里面的不同实现,通过查看源码可以找出有这一段逻辑。 + +# 解决方案 + +## 方案一:CustomModelBinder + +```C# +public class BatchUpdateEngagementTypeOfServiceRequestBinder : IModelBinder +{ + private readonly ILogger logger; + public BatchUpdateEngagementTypeOfServiceRequestBinder(ILogger logger) + { + this.logger = logger; + } + + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + bindingContext.HttpContext.Request.EnableBuffering(); + using var reader = new StreamReader(bindingContext.ActionContext.HttpContext.Request.Body); + var body = await reader.ReadToEndAsync(); + + try + { + var model = JsonConvert.DeserializeObject(body, + new CustomNullableIntJsonConverter()); + bindingContext.Result = ModelBindingResult.Success(model); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + } + finally + { + bindingContext.ActionContext.HttpContext.Request.Body.Seek(0, SeekOrigin.Begin); + } + } +} + +internal class CustomNullableIntJsonConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, int? value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString()); + } + + public override int? ReadJson(JsonReader reader, Type objectType, int? existingValue, bool hasExistingValue, + JsonSerializer serializer) + { + return reader.Value == null ? null : int.TryParse(reader.Value.ToString(), out var val) ? val : default(int?); + } +} +``` + +```C# +public class CustomRequestEntityBinderProvider : IModelBinderProvider +{ + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return context.Metadata.ModelType == typeof(BatchUpdateEngagementTypeOfServiceRequest) + ? new BinderTypeModelBinder(typeof(BatchUpdateEngagementTypeOfServiceRequestBinder)) + : null; + } +} +``` + +```C# +services.AddControllers(options => +{ + options.OutputFormatters.RemoveType(); + options.OutputFormatters.RemoveType(); + options.ModelBinderProviders.Insert(0, new CustomRequestEntityBinderProvider()); +}).AddNewtonsoftJson(options => +{ + options.SerializerSettings.Error += (sender, args) => + { + var errorCtx = args.ErrorContext.Error; + LogManager.GetLogger(nameof(Program)).Log(LogLevel.Error, errorCtx, $"can not process request body with incorrect type:{errorCtx.Message}"); + args.ErrorContext.Handled = false; + }; +}); +``` + +## 方案二:CustomValueTypeJsonConverter + +```C# +# ValueTypeJsonConverter.cs +public class ValueTypeJsonConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + writer.WriteValue(value); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, + JsonSerializer serializer) + { + var val = reader.Value; + if (val == null) + { + return Activator.CreateInstance(objectType); + } + + try + { + var typeDescriptor = TypeDescriptor.GetConverter(objectType); + return typeDescriptor.ConvertFromString(val.ToString()); + } + catch (Exception e) + { + if (e is ArgumentException) + { + return Activator.CreateInstance(objectType); + } + + throw; + } + } + + public override bool CanConvert(Type objectType) => objectType.IsValueType; +} +``` + +```C# +services.AddControllers(options => +{ + options.OutputFormatters.RemoveType(); + options.OutputFormatters.RemoveType(); +}).AddNewtonsoftJson(options => +{ + options.SerializerSettings.Converters.Add(new ValueTypeJsonConverter()); + options.SerializerSettings.Error += (sender, args) => + { + var errorCtx = args.ErrorContext.Error; + LogManager.GetLogger(nameof(Program)).Log(LogLevel.Error, errorCtx, $"can not process request body with incorrect type:{errorCtx.Message}"); + args.ErrorContext.Handled = true; + }; +}); +``` + +# 总结 + +> 略 \ No newline at end of file diff --git a/src/source/_posts/tech-session-redux.md b/src/source/_posts/tech-session-redux.md index 2604b9f..b6e59bd 100644 --- a/src/source/_posts/tech-session-redux.md +++ b/src/source/_posts/tech-session-redux.md @@ -21,7 +21,6 @@ React 的推出大大提高了前端程序员的开发效率。其组件化的 在谈论 Redux 的技术架构前,我们需要先了解一些周边知识,方便我们对 Redux 的核心思想有更深一步的理解。 - ## 设计思想 - 响应式编程:Rx-Programming diff --git a/src/source/images/controllerexecute.png:Zone.Identifier b/src/source/images/controllerexecute.png:Zone.Identifier new file mode 100644 index 0000000..59fa16d --- /dev/null +++ b/src/source/images/controllerexecute.png:Zone.Identifier @@ -0,0 +1,4 @@ +[ZoneTransfer] +ZoneId=3 +ReferrerUrl=https://www.notion.so/hippiezhou/ece51bbe748d4b04ba7c7b7d6c82013c?v=9b6e2b7898844f5483214bf34ace4f56&p=bd97363e8b904f73a733bedf7c6bbfa4&pm=c +HostUrl=https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F79644b17-082c-4681-9b5e-5b599806ab47%2FUntitled.png?table=block&id=4d411325-3443-4553-8760-c3fd239ebbed&spaceId=47325eef-3bbf-41df-bbd4-9eedf849533b&width=2000&userId=02ceccb3-d0a1-4040-97d7-709e6fc321c8&cache=v2 diff --git a/src/source/images/filterpipeline.png:Zone.Identifier b/src/source/images/filterpipeline.png:Zone.Identifier new file mode 100644 index 0000000..6acc751 --- /dev/null +++ b/src/source/images/filterpipeline.png:Zone.Identifier @@ -0,0 +1,4 @@ +[ZoneTransfer] +ZoneId=3 +ReferrerUrl=https://www.notion.so/hippiezhou/ece51bbe748d4b04ba7c7b7d6c82013c?v=9b6e2b7898844f5483214bf34ace4f56&p=bd97363e8b904f73a733bedf7c6bbfa4&pm=c +HostUrl=https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Fee1703b4-2e92-4cc0-a4ad-c4b4855bbe1b%2FUntitled.png?table=block&id=fbcc1b1a-7884-4065-bab4-55503e32bc36&spaceId=47325eef-3bbf-41df-bbd4-9eedf849533b&width=2000&userId=02ceccb3-d0a1-4040-97d7-709e6fc321c8&cache=v2 diff --git a/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/controllerexecute.png b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/controllerexecute.png new file mode 100644 index 0000000..c9eae66 Binary files /dev/null and b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/controllerexecute.png differ diff --git a/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error01.png b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error01.png new file mode 100644 index 0000000..4afc5da Binary files /dev/null and b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error01.png differ diff --git a/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error02.png b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error02.png new file mode 100644 index 0000000..2b73712 Binary files /dev/null and b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/error02.png differ diff --git a/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/filterpipeline.png b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/filterpipeline.png new file mode 100644 index 0000000..3bdbd9c Binary files /dev/null and b/src/source/images/how-to-keep-payload-process-in-asp-net-core-consistent-with-asp-net/filterpipeline.png differ