问题:定义
private String aBcc;,返回 JSON 却变成了"abcc",更奇怪的是加上@JsonProperty后竟然同时出现了abcc和aBcc两个字段。
本文将深入剖析这一现象背后的技术原理(JavaBean 规范、Lombok、Jackson),并给出多种可靠解决方案,帮你彻底告别此类问题。
一、问题重现
假设我们有一个 Spring Boot 项目,定义了一个响应类:
java
kotlin
import lombok.Data;
@Data
public class MyResponse {
private String aBcc; // 注意:首字母小写,第二个字母大写
// 其他字段...
}
在 Controller 中返回该对象:
java
java
@RestController
public class TestController {
@GetMapping("/test")
public MyResponse test() {
MyResponse resp = new MyResponse();
resp.setABcc("hello"); // Lombok 生成的 setter 方法名为 setABcc()
return resp;
}
}
前端收到的 JSON 却是:
json
json
{
"abcc": "hello"
}
我们期望的是 aBcc,但实际得到了 abcc。为了强制指定,我们在字段上添加 @JsonProperty("aBcc"):
java
kotlin
@Data
public class MyResponse {
@JsonProperty("aBcc")
private String aBcc;
}
结果返回的 JSON 变成了:
json
json
{
"abcc": "hello",
"aBcc": "hello"
}
竟然同时出现了两个属性!这是怎么回事?
二、原因深度分析
要理解这个问题,需要捋清三个关键角色的行为:JavaBean 规范 、Lombok 、Jackson。
1. JavaBean 规范对属性命名的规定
JavaBean 规范定义:属性名是通过 getter/setter 方法名推导出来的。规则是:
- 去掉方法名的
get或set前缀。 - 将剩余部分的首字母改为小写。
- 如果剩余部分的前两个字母都是大写,则保持不变(特殊情况)。
例如:
getName()→ 去掉get→Name→ 首字母小写 →name✅getURL()→ 去掉get→URL→ 前两个字母都是大写,保持不变 →URL✅getaBcc()→ 去掉get→aBcc→ 首字母已经是小写,保持不变 →aBcc✅getABcc()→ 去掉get→ABcc→ 前两个字母AB都是大写,保持不变 →ABcc(这不符合常规认知)
但大多数 JSON 库(包括 Jackson)在实现时,对上述规则做了更符合直觉 的处理:如果去掉前缀后首字母大写,但第二个字母小写,则会将首字母小写。然而对于 ABcc,它们会直接转为小写?实际上 Jackson 内部的命名转换逻辑比较复杂,但最终结果通常是 abcc。
关键点 :规范的 getter 方法名对于字段 aBcc 应该是 getaBcc()(注意第二个字母是小写 a),而不是 getABcc()。
2. Lombok 生成的 getter/setter
当我们在类上使用 @Data 注解时,Lombok 会为所有字段生成标准的 getter/setter。它的生成规则是:
- 对于字段名,直接首字母大写并拼接
get前缀。 - 对于
aBcc,首字母大写后变为ABcc,再拼接get得到getABcc()。
Lombok 不会 去判断是否应该保持第二个字母的大小写,它只是机械地首字母大写。因此生成的 setter 是 setABcc()。
这不符合 JavaBean 规范(规范要求是 getaBcc())。
3. Jackson 的序列化过程
Spring Boot 默认使用 Jackson 作为 JSON 序列化工具。Jackson 在序列化一个对象时,会扫描该对象的所有"可访问"属性。它主要通过两种方式:
- Getter 方法 :Jackson 会查找所有以
get开头的方法,并依据 JavaBean 规范推导出属性名。 - 字段(Field) :如果字段是 public 或者有
@JsonProperty注解,Jackson 也会直接使用字段名作为属性名。
对于我们的场景:
- Lombok 生成了
getABcc()方法。 - Jackson 看到
getABcc(),去掉get得到ABcc。按照其内部命名策略(通常是PropertyNamingStrategy),它会将ABcc转换为abcc(因为 Jackson 认为这是驼峰命名,会将连续大写字母转为小写,但具体实现细节不展开)。最终 JSON 键名为abcc。
这就是最初出现 abcc 的原因。
4. 引入 @JsonProperty 后为什么会出现双属性?
当我们在字段上添加 @JsonProperty("aBcc") 时:
- 该注解让私有字段变得可序列化 ,Jackson 会直接使用注解指定的名称
aBcc输出该字段的值。 - 同时,因为
getABcc()方法依然存在且是 public 的,Jackson 仍然会将其作为一个属性源,并按照默认规则生成键名abcc。 - 两个来源都指向同一个值,最终 JSON 中便出现了两个同名异键的属性。
注意 :如果 Lombok 能够将字段上的 @JsonProperty 复制到它生成的 getter 方法上,那么 Jackson 在处理 getter 时也会使用 aBcc,两个来源会合并为一个,不会重复。但 Lombok 默认只复制有限的注解 (如 @NonNull),并不包括 @JsonProperty。除非我们主动配置 lombok.config 让 Lombok 复制该注解。
三、解决方案
根据项目需求,我们可以选择以下方案之一,均能彻底解决问题。
方案一:手动编写符合规范的 Getter(最直接,推荐)
放弃 Lombok 为该字段生成的 getter/setter,手动编写符合 JavaBean 规范的 getaBcc() 和 setaBcc()。
java
typescript
@Data
public class MyResponse {
private String aBcc;
// 手动编写,注意方法名是 getaBcc(小写 a)
public String getaBcc() {
return aBcc;
}
public void setaBcc(String aBcc) {
this.aBcc = aBcc;
}
}
此时 Lombok 检测到已有手动编写的 getter/setter,便不会再自动生成。Jackson 看到 getaBcc(),去掉 get 后为 aBcc,首字母已小写,保持不变,JSON 输出为 aBcc。完美。
优点 :简单明确,无需额外配置,完全遵循 JavaBean 规范。
缺点:需要手动编写,略显繁琐(但仅针对少数特殊命名字段)。
方案二:修改字段命名(根本解决)
如果条件允许,直接修改字段名,使其符合常见的驼峰命名(第二个字母小写)或干脆改为其他命名。
java
kotlin
@Data
public class MyResponse {
private String abcc; // 全部小写,或者 bcc
}
此时 Lombok 生成 getAbcc(),Jackson 推导出 abcc,两者一致。
优点 :从根源上杜绝此类问题,符合编码规范,一劳永逸。
缺点:如果字段名已在前后端约定好,修改成本较高。
方案三:配置 Lombok 复制 @JsonProperty 注解(保留 Lombok 自动化)
如果你希望继续使用 Lombok 自动生成所有 getter/setter,并且想用 @JsonProperty 显式指定序列化名称,那么可以配置 Lombok 让其将字段上的注解复制到生成的方法上。
在项目根目录(src/main 同级)或 src/main/java 目录下创建 lombok.config 文件,内容如下:
properties
kotlin
# 让 Lombok 将 @JsonProperty 复制到生成的 getter/setter 方法上
lombok.copyableAnnotations += com.fasterxml.jackson.annotation.JsonProperty
然后在字段上添加 @JsonProperty("aBcc"):
java
kotlin
@Data
public class MyResponse {
@JsonProperty("aBcc")
private String aBcc;
}
此时 Lombok 生成的 getABcc() 方法上也会带上 @JsonProperty("aBcc")。Jackson 在处理方法时,会使用注解指定的名称 aBcc,而字段上的注解同样指定 aBcc,两者合并,最终 JSON 中只出现一个 aBcc 属性。
优点 :完全保留 Lombok 的简洁性,无需手写代码。
缺点:需要额外配置文件,且确保构建工具能识别(Maven/Gradle 均支持)。
方案四:全局配置 Jackson 的命名策略(不推荐)
如果你希望统一所有字段的命名规则,可以配置 Jackson 使用 PropertyNamingStrategy,例如 SNAKE_CASE 或 KEBAB_CASE。但对于单一字段的特殊需求,这种全局配置灵活性不足,且会使所有字段都改变命名,通常不适用于本问题。
四、各方案对比与选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 方案一(手动 getter) | 符合规范,无额外配置,立即生效 | 需要手动编写少量代码 | 字段命名已定,且仅少数字段有此问题 |
| 方案二(改字段名) | 最根本、最规范,彻底避免问题 | 可能涉及前后端接口变更 | 项目初期或前后端可协商修改时 |
| 方案三(Lombok 配置) | 保持 Lombok 简洁,自动化程度高 | 需要配置文件,且需确保所有开发环境一致 | 大量使用 @JsonProperty 且希望保持代码简洁 |
| 方案四(全局命名策略) | 统一全局命名风格 | 无法针对个别字段精细控制 | 仅当整个项目需要统一命名风格时使用 |
我的推荐 :如果字段数量少且命名已固定,方案一 最为稳妥;如果还未上线或可以调整,方案二 是长远之选;如果你的项目对代码简洁度要求极高且愿意增加配置,方案三也是一个优雅的选择。
五、总结
这个问题本质上是由 Lombok 对不规范字段名的机械处理 与 Jackson 对 JavaBean 规范的严格遵循 之间的冲突引起的。关键点在于:
- 字段
aBcc的正确 getter 应为getaBcc()。 - Lombok 生成了
getABcc(),导致 Jackson 推导出abcc。 - 添加
@JsonProperty时未配置 Lombok 复制注解,导致字段和 getter 被视为两个独立属性源,产生双属性。
通过理解这背后的机制,我们可以根据项目实际情况灵活选择解决方案,彻底避免此类"字段名变形"的问题。希望本文能帮助你扫清这一常见但令人困惑的障碍,让 Spring Boot 开发更加顺畅。
如果你有任何疑问或更好的实践,欢迎在评论区交流讨论!😊
(完)