一张表一个 ext 字段,输出时却要"平铺"成普通 JSON 属性?不用改实体、不用写死字段,一个注解全搞定!
一、当前痛点
在日常业务开发中,我们经常会遇到这样一个场景:
数据库设计时,预留一个 ext 或 extra 字段,类型是 JSON 字符串,用来存储一些可扩展的属性。
比如用户表有个 ext,里面可能放性别、地址、爱好等不确定的字段;商品表也有个 ext,里面可能放颜色、尺寸、产地......
-
存的时候没问题:直接把一个 Map 转成 JSON 字符串塞进去。
-
但取出来给前端的时候,前端希望得到的是平铺的 JSON 对象,而不是嵌套的 { ext: {...} }。
比如后端想要输出:
{
"id": 1,
"age": 17,
"sex": "男",
"address": "山东省"
}
而不是:
{
"id": 1,
"age": 17,
"ext": {
"sex": "男",
"address": "山东省"
}
}
传统做法是什么?在实体类里把 sex、address 全部定义成独立属性,然后手动从 ext 里 get/set。
问题是:扩展属性随时可能增加,难道每次加一个属性就要改一次实体、改一次 VO、改一次转换逻辑?太痛苦了!
有没有一种方法:不在实体里增加字段,却能让扩展字段在序列化时像普通属性一样被平铺展示?
答案是:有!Jackson 的 @JsonAnySetter 和 @JsonAnyGetter 就是为此而生的。
二、解决方案
2.1 核心思路
我们依然在实体中保留一个 Map<String, Object> dynamicProps 用于存储所有扩展属性。
然后通过两个注解告诉 Jackson:
-
反序列化时:遇到实体中没有定义的属性,统统丢进这个 Map(@JsonAnySetter)。
-
序列化时:把这个 Map 里的所有键值对,直接"拉平"到 JSON 对象的顶层(@JsonAnyGetter)。
这样,前端看到的 JSON 和实体里写死的普通属性一模一样,而我们的实体类无需为扩展字段添加任何具体成员变量。
2.2 代码实现
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import java.util.HashMap;
import java.util.Map;
public class Student {
private Long id;
private Integer age;
// 动态扩展属性容器
private Map<String, Object> dynamicProps = new HashMap<>();
// --- 普通属性的 getter/setter ---
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
// --- 动态属性操作 ---
@JsonAnySetter
public void setDynamicProp(String key, Object value) {
dynamicProps.put(key, value);
}
@JsonAnyGetter
public Map<String, Object> getDynamicProps() {
return dynamicProps;
}
// 便捷取值方法
@SuppressWarnings("unchecked")
public <T> T get(String key) {
return (T) dynamicProps.get(key);
}
}
2.3 测试效果
@RestController
@RequestMapping("/student")
public class StudentController {
// GET:返回一个包含动态属性的 Student 对象
@GetMapping("/info")
public ResultObject getStudent() {
Student stu = new Student();
stu.setId(1L);
stu.setAge(17);
stu.setExt("name", "张三");
stu.setExt("sex", "男");
stu.setExt("address", "山东省");
return Result.success(stu);
}
// 输出:{"code": 200, "data": { "id": 1, "age": 17, "name": "张三", "sex": "男", "address": "山东省" }}
// POST:接收前端 JSON,自动将未知属性映射到 dynamicProps
@PostMapping("/save")
public ResultObject saveStudent(@RequestBody Student student) {
String name = student.get("name");
String sex = student.get("sex");
String grade = student.get("grade"); // 假设前端还传了 grade
return Result.success("name=" + name + ", sex=" + sex + ", grade=" + grade);
}
// 输出:{"id": 2, "age": 18, "name": "李四", "sex": "女", "grade": "高三" }
}
完美! 没有任何额外实体字段,扩展属性却被自然地平铺到了 JSON 中。
三、进阶技巧与注意事项
3.1 避免与普通字段冲突
如果动态属性中的 key 和实体里已有的普通字段同名(比如 id),Jackson会覆盖普通属性。这点要特别注意。
3.2 处理 null 值
Jackson 默认会忽略值为 null 的属性。如果你希望 null 的动态属性也被输出,可以配置:
mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
3.3 反序列化时限制动态属性
如果想只允许某些 key 进入动态 Map,可以在 @JsonAnySetter 方法中加入校验逻辑:
@JsonAnySetter
public void setDynamicProp(String key, Object value) {
if (allowedKeys.contains(key)) {
dynamicProps.put(key, value);
}
}
3.4 与数据库 JSON 字段配合
数据库中 ext 字段是 JSON 字符串,从 DB 读取后,可以用 Jackson 先反序列化成 Map,再通过 @JsonAnySetter 填充到实体中。也可以直接让 MyBatis 等 ORM 框架支持将 JSON 列映射为 Map 类型。
四、总结
对于需要预留扩展字段的业务场景,使用 Jackson 的 @JsonAnySetter / @JsonAnyGetter 将 JSON 内容拉平到实体输出,是一个非常优雅且实用的方案。
-
存:将扩展属性以 Map 形式存入 JSON 字段。
-
用:通过注解动态塞入/取出。
-
展:序列化时自动平铺,前端无感知。
从此告别"加一个扩展属性就要改一堆代码"的噩梦。