试试Jackson:把JSON拉平成普通属性
一张表一个 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 字段。
用:通过注解动态塞入/取出。
展:序列化时自动平铺,前端无感知。
从此告别“加一个扩展属性就要改一堆代码”的噩梦。