做后端接口联调的时候前端同事突然丢过来一个问题“你们接口返回的字段名怎么跟文档不一致文档里写的是 UserId实际返回的却是 userId。”我一看还真是——所有属性名都从大驼峰变成了小驼峰。更尴尬的是Swagger 文档里明明显示的是 UserName、EmailAddress一请求真实接口却变成了 userName、emailAddress。前后端各执一词最后排查了半天才发现问题出在 Asp.Net Core 3.1 的一个默认行为上。这个坑在 Asp.Net Core 3.0 之后特别常见因为框架把默认的 JSON 序列化器从 Newtonsoft.Json 换成了 System.Text.Json而 System.Text.Json 在 Web 场景下默认会启用小驼峰命名策略。换句话说不是代码写错了也不是配置丢了是框架替你把字段名“美化”了一遍。这篇文章我会从根因讲起把几种解决办法、踩过的坑和排查思路一次说清楚适合正在做 Asp.Net Core 3.1 Web Api 开发或者正被前后端字段命名不一致折腾的朋友参考。1. 问题现象与根源解析为什么字段名集体“驼峰化”1.1 先看一个最典型的现象模拟一个非常简单的场景。项目里有一个用户信息模型定义如下public class UserInfo { public int UserId { get; set; } public string UserName { get; set; } public string EmailAddress { get; set; } }控制器里返回一个列表[HttpGet] public IActionResult GetUsers() { var users new ListUserInfo { new UserInfo { UserId 1, UserName zhangsan, EmailAddress zhangsanexample.com } }; return Ok(users); }按理说返回的 JSON 应该是[ { UserId: 1, UserName: zhangsan, EmailAddress: zhangsanexample.com } ]但实际拿到的是[ { userId: 1, userName: zhangsan, emailAddress: zhangsanexample.com } ]这种“集体变小写开头”的现象就是典型的小驼峰命名策略生效了。前端拿着后端给的接口文档对字段发现怎么都对不上因为文档大多是基于 C# 模型生成的展示的是大驼峰属性名而真实接口却用了小驼峰。1.2 根因就藏在序列化器的默认选项里Asp.Net Core 3.0 开始默认的 JSON 序列化器换成了 System.Text.Json。这套序列化器在 Web 场景下初始化的时候会把 JsonSerializerOptions 设置成符合 Web 惯例的模式其中最关键的一项就是PropertyNamingPolicy JsonNamingPolicy.CamelCase含义很简单序列化器在输出对象属性时会把属性名从 C# 里的大驼峰转换成小驼峰再写到 JSON 字符串里。这个转换只影响输出时的字符串C# 类本身的属性名、数据库字段、内部逻辑都不受影响。我习惯用一个类比来解释就像快递包裹本身没变只是快递员在外面重新贴了一张写着小写字母的标签。前端拿到的 JSON 就是这张“标签”而 C# 类里的属性是“包裹内容”。前端看到的字段名变了但类型、值、结构都没变。理解了这一点再去看解决办法就很清晰了——要么关掉这个默认策略要么针对特定字段做覆盖要么干脆把序列化器换回 Newtonsoft.Json。2. 解决方案一全局配置让返回字段保持 C# 属性原名2.1 在 Startup.ConfigureServices 里加一行配置最推荐、也最常见的做法是在项目启动时全局配置。打开 Startup.cs在 ConfigureServices 方法里加上 AddJsonOptions 的配置public void ConfigureServices(IServiceCollection services) { services.AddControllers() .AddJsonOptions(options { // 让序列化器不再转换属性名大小写保持 C# 属性原样输出 options.JsonSerializerOptions.PropertyNamingPolicy null; }); }就这一处配置整个项目的所有 Api 返回字段都会恢复成 C# 属性原本的名字。UserId 就是 UserIdUserName 就是 UserName不会再被偷偷改成小驼峰。这里有个细节值得注意如果你的项目使用的是 AddControllersWithViews 或者 AddMvc那么需要把 AddJsonOptions 挂在对应的方法链上而不是另外单独写一个 services.AddJsonOptions()。Asp.Net Core 3.1 中 AddJsonOptions 是针对 MVC 系列服务的扩展方法脱离了 AddControllers / AddMvc 链路去调用没有意义。2.2 为什么设置成 null 就能生效默认情况下不写这行配置JsonSerializerOptions 会使用 JsonSerializerDefaults.Web 的规则里面把 PropertyNamingPolicy 设置成了 CamelCase。把 PropertyNamingPolicy 设置为 null等于明确告诉序列化器“不要做任何命名策略转换属性名原样输出。”从这个角度也能延伸出一个判断方法如果你希望所有接口统一保持 C# 属性原名全局配置就是最高性价比的方案一处改动全站生效。但这也意味着它会有全局影响——如果前端已经按小驼峰的字段名联调了很长时间全局改成大驼峰后前端必须同步调整这一点在下文第 5 部分我会详细讲。还有一个很容易忽略的点很多团队用 Swagger 做接口文档而 Swagger 生成的示例模型字段名是根据 C# 类原始定义来的它不会主动模拟序列化后的命名策略。所以设置 null 之后Swagger 文档和实际返回结果的字段会保持一致前后端对文档的信任度反而更高了。这一点我在好几个项目里都深有体会。3. 解决方案二局部控制命名格式的三种技巧3.1 在控制器里手动指定 JsonSerializerOptions全局配置有时候并不是最优解。比如某个对外接口只有这一个接口对接方要求字段保持大驼峰其余接口维持小驼峰不变。这时候如果在全局把命名策略改成 null反而会影响已经联调好的其他接口。更稳妥的做法是局部处理。Asp.Net Core 的 Controller 提供了 Json() 方法的重载可以让你临时传入一组 JsonSerializerOptions[HttpGet(user/detail)] public IActionResult GetUserDetail() { var user new UserInfo { UserId 1, UserName zhangsan, EmailAddress zhangsanexample.com }; var options new JsonSerializerOptions { PropertyNamingPolicy null }; return Json(user, options); }这样只会影响当前这个 Action 的输出格式其他接口该是小驼峰还是小驼峰。使用的时候注意引入命名空间 using System.Text.Json如果你只是设置 PropertyNamingPolicy就不需要额外配置其他选项其他选项会用默认值。这种写法适合那种“全局不动、单点覆盖”的场景。3.2 用 [JsonPropertyName] 对个别字段做定向命名有时候前端不是一个字段全都要大驼峰而是只有个别字段因为历史原因或者第三方接口定义必须用某种固定格式。比如某个对外的开放接口要求字段名是下划线风格其他字段保持默认。这种情况直接在 DTO 属性上加特性最省事using System.Text.Json.Serialization; public class UserInfo { [JsonPropertyName(user_id)] public int UserId { get; set; } [JsonPropertyName(user_name)] public string UserName { get; set; } [JsonPropertyName(email_address)] public string EmailAddress { get; set; } }JsonPropertyName 的优先级高于全局 PropertyNamingPolicy。哪怕你设置了 PropertyNamingPolicy CamelCase甚至设置为 null只要属性上挂了 JsonPropertyName序列化的时候就会严格按这个特性指定的名字输出。反序列化时也一样前端传 user_name 也能正确映射到 UserName 属性上。这个特性在对接第三方接口时特别有用。比如有次对接一个老系统的接口对方要求所有字段必须是全小写下划线格式而我们的模型定义又不想为了对接去改动内部命名规范最后就是在专门用于对接的 DTO 上逐个加特性干净利落地解决问题。3.3 自定义 JsonNamingPolicy把字段转成全大写或蛇形风格如果全局要的不是大驼峰也不是小驼峰而是某种特定格式比如前端团队约定所有字段必须全大写或者要对接的第三方接口要求 snake_case那可以写一个自定义命名策略类继承 JsonNamingPolicy 并重写 ConvertName 方法。以输出下划线风格为例比如属性名 UserName 输出为 user_nameOrderID 理想情况下输出为 order_id。我提供一个实际项目里用过的简单实现using System.Text; public class SnakeCaseNamingPolicy : JsonNamingPolicy { public override string ConvertName(string name) { if (string.IsNullOrEmpty(name)) { return name; } var builder new StringBuilder(); for (var i 0; i name.Length; i) { var c name[i]; if (char.IsUpper(c)) { if (i 0 char.IsLower(name[i - 1])) { builder.Append(_); } builder.Append(char.ToLowerInvariant(c)); } else { builder.Append(c); } } return builder.ToString(); } }注册使用services.AddControllers() .AddJsonOptions(options { options.JsonSerializerOptions.PropertyNamingPolicy new SnakeCaseNamingPolicy(); });这个方案适合整个项目统一走一种特殊命名风格的情况。需要注意上面这个实现对于连续大写缩写比如 APIResult可能输出成 api_result也可能输出成 apiresult取决于中间连续大写字符之间是否要加下划线。实际项目中需要根据你的命名习惯调整算法比如遇到 APIResult 这种缩写词到底期望 api_result 还是 apiresult团队成员最好提前约定好。4. 解决方案三切回 Newtonsoft.Json兼容老项目迁移4.1 三步完成从 System.Text.Json 到 Newtonsoft.Json 的切换有些老项目是从 Asp.Net Core 2.x 升级上来的代码里大量使用了 Newtonsoft.Json 的特性比如 JsonProperty、JsonConverter、NullValueHandling 等。升级到 3.1 之后默认序列化器变成 System.Text.Json很多 Newtonsoft 的配置就失效了改起来工作量太大。这种情况下直接把序列化器切回 Newtonsoft.Json 才是性价比最高的做法。切换步骤一共三步第一步通过 NuGet 安装包Microsoft.AspNetCore.Mvc.NewtonsoftJson注意版本要用 3.1.x对应 Asp.Net Core 3.1 框架。第二步在 Startup.ConfigureServices 中启用public void ConfigureServices(IServiceCollection services) { services.AddControllers() .AddNewtonsoftJson(); }第三步可选如果想让返回字段保持 C# 属性原样也就是大驼峰可以在 AddNewtonsoftJson 的配置项中显式指定契约解析器services.AddControllers() .AddNewtonsoftJson(options { options.SerializerSettings.ContractResolver new DefaultContractResolver(); });Newtonsoft.Json 默认的 ContractResolver 本身就不会转换属性名大小写所以这一步不加也是保持大驼峰。如果你希望切回 Newtonsoft 后字段依然被转成小驼峰也就是兼容原来 System.Text.Json 默认行为那就用 CamelCasePropertyNamesContractResolveroptions.SerializerSettings.ContractResolver new CamelCasePropertyNamesContractResolver();4.2 两种序列化器到底怎么选很多人在这个交叉路口犹豫不决。我给一个相对务实的判断标准新项目优先用 System.Text.Json除非你遇到了它难以满足的序列化需求老项目、重度依赖 Newtonsoft 特征的项目切回 Newtonsoft.Json 成本更低。下面这张对比表可以辅助决策对比维度System.Text.JsonNewtonsoft.Json性能官方宣传更高实际差异在高频大对象场景才明显略慢但大多数业务场景感知不强默认命名策略Web 模式下默认小驼峰默认保持属性原名配置入口AddJsonOptions / JsonSerializerOptionsAddNewtonsoftJson / JsonSerializerSettings特性支持JsonPropertyName、JsonIgnore、JsonConverter 等JsonProperty、JsonConverter、NullValueHandling、ReferenceLoopHandling 等更丰富反序列化宽容度较严格对 JSON 格式不规范的情况容忍度低更宽容能处理不少不规范格式适用场景新项目、性能敏感场景、标准 JSON 交互老项目迁移、复杂序列化控制、第三方非标格式对接有一点必须提醒AddJsonOptions 和 AddNewtonsoftJson 是两套互斥的配置入口。如果同时配置实际生效的是后注册的那个前面配的 JsonSerializerOptions 将不再起作用。我见过不止一次有人配了 AddJsonOptions 又装包调用了 AddNewtonsoftJson结果改了半天命名策略没反应就是因为 Newtonsoft 注册在最后System.Text.Json 的配置被覆盖了。所以在项目里最好明确二选一别两套混用。5. 常见问题与排查技巧实录5.1 配置写了但没生效最可能的原因是什么这个问题我在社区里被问过很多次也是日常排查看得最多的场景。总结下来配置没生效的常见原因有这么几类第一项目里同时引入了 Newtonsoft.Json 的包并且某个地方调用了 AddNewtonsoftJson导致 System.Text.Json 的配置被覆盖。检查方法是搜索整个项目里有没有 AddNewtonsoftJson 调用如果有你需要在它的 SerializerSettings 里重新指定命名策略而不是在 AddJsonOptions 里改。第二AddJsonOptions 挂错了位置。在 3.1 中要挂在 AddControllers、AddControllersWithViews 或 AddMvc 的方法链上。我看到过有人单独写 services.AddJsonOptions() 而不是链式调用这种写法在 3.1 里不会生效。第三返回类型不是对象而是字符串或者字节流。如果 Action 返回的是 string框架会直接原样输出字符串内容根本不会进入 JSON 序列化器。碰到字段大小写问题先确认这个接口真的走了 JSON 序列化流程。第四DTO 的属性上挂了 JsonPropertyName 或 Newtonsoft 的 JsonProperty 特性。特性的优先级高于全局策略所以你改全局配置对这类字段无效需要针对特性去调整。第五改完代码没重启或发布没更新。这个虽然听起来基础但实际排查时我确实遇到过因为本地开发环境进程没重启导致反复验证“配置没生效”的情况。确认一下进程真的加载了最新代码再去折腾配置。5.2 字典 Key、DataTable、匿名对象会受到命名策略影响吗这个问题的答案比较微妙。先说字典Dictionarystring, object 的 Key 在我的实测中不会经过 PropertyNamingPolicy 处理你 Key 写什么就返回什么。也就是说如果一个字典里放的是 {UserId: 1}哪怕全局配置是小驼峰返回的也还是 UserId不会被转换成 userId。这一点跟属性字段的处理逻辑不一样容易让人误以为配置失效。再说匿名对象new { UserName zhangsan, Age 20 } 这种方式返回的字段名实际上是通过反射拿真实类型属性名所以匿名类型的属性同样会被命名策略转换UserName 会变成 userName。这里不要被“匿名”两个字误导它在编译后是有真实类型的序列化器照常处理。最后是 DataTableSystem.Text.Json 对 DataTable 的序列化支持有限而且不会按命名策略转换列名。如果你在 DataTable 场景中发现列名没变小驼峰不是配置问题是序列化器对 DataTable 的处理逻辑本来就不走属性命名规则。需要转换列名的要么先把 DataTable 转成 ListDictionarystring, object要么手动改列名。5.3 后端字段名改了前端不兼容怎么办如果项目还在开发前期前后端没有正式联调过直接用文中最开始的全局配置把字段统一恢复成大驼峰前端照着 C# 模型写就行这个问题就能从根上避免。但如果前后端已经联调了相当长时间前端代码里到处是 user.userId 这种写法后端这时候才把字段改成大驼峰那就是给自己找麻烦。我的建议分两种情况处理如果你希望前端改动量最小那就全局保持小驼峰后端不用做任何配置默认行为就是小驼峰前端纯受益。如果你一定要切换成大驼峰那必须由后端牵头把旧的字段名和新字段名整理成映射表提前同步给前端在测试环境完整跑一遍流程再上线。千万别在联调过程中突然改全局命名策略这个操作看着只改一行代码实际影响的是所有接口的返回结构。还有一种折中的场景只有个别接口的个别字段对不上那就不要动全局策略直接在对应 DTO 上用 JsonPropertyName 做定向兼容既能满足前端期望又不影响其他接口。这种方法我在多团队协作时用得最多踩踏风险最小。5.4 问题排查速查表现象可能原因处理方式所有字段都被转成小驼峰System.Text.Json 默认 CamelCase 策略全局设置 PropertyNamingPolicy null全局配置写了但没变化项目实际使用了 Newtonsoft.Json在 AddNewtonsoftJson 的 SerializerSettings 中配置 ContractResolver个别字段名始终是下划线/自定义名DTO 上挂了 JsonPropertyName 特性调整或移除特性或者接受该命名字典 Key 没变小驼峰字典 Key 不走命名策略需要手动转换 KeyDataTable 列名没变DataTable 序列化逻辑不走属性命名策略手动处理列名或转换成对象列表Swagger 文档显示大驼峰实际返回小驼峰Swagger 模型基于 C# 类型定义属于正常现象按实际返回为准或全局统一策略老项目 Newtonsoft 配置全部失效3.x 默认序列化器更换所致安装 Newtonsoft 包并调用 AddNewtonsoftJson 切回最后再分享一个实际操作经验命名策略这种配置最好在新建 Web Api 项目的第一天就定下来明确字段命名规范是统一大驼峰还是统一小驼峰写进团队的接口文档约定里。我在实际项目里吃过这个亏——接口写了几十个之后才来统一字段风格前后端一起改光联调就多花了两天。还有个小习惯很实用交付接口前用 Postman 或者 curl 真实请求一次直接看返回的实际 JSON别只盯着 Swagger 看。Swagger 的示例模型和序列化后的真实结果不一定完全一致眼看为实这能帮你省掉很多不必要的沟通成本。