在日常后端开发中,我们经常会遇到这些问题:
- 前端没有传某些字段,后端 VO 能接收吗?
- VO 和 Entity 字段不一致,能否直接 copy?
- 为什么有时候字段会变成 null?
- BeanUtils 到底是怎么工作的?
- JSON 是怎么变成 Java 对象的?
这些问题看似零散,其实背后是一个完整链路:
JSON → VO(反序列化)→ DTO/Entity(对象拷贝)
本文将从 原理 + 实战 两个层面,帮大家打通这条链路。
一、整体流程
在 Spring Boot 中,一个请求的完整流程如下:
text
前端 JSON
↓
HTTP 请求 Body
↓
Spring MVC
↓
HttpMessageConverter
↓
Jackson(反序列化)
↓
VO 对象
↓
BeanUtils / MapStruct
↓
Entity(数据库对象)
👉 核心就两个动作:
- 反序列化(JSON → VO)
- 对象拷贝(VO → Entity)
二、JSON → VO:为什么字段可以不传?
1️⃣ 示例
前端请求
json
{
"name": "张三"
}
后端 VO
java
@Data
public class UserVO {
private String name;
private Integer age;
}
最终结果
java
UserVO {
name = "张三",
age = null
}
完全正常 ✅
2️⃣ 底层原理(Jackson 在干什么)
Spring 默认使用:
java
MappingJackson2HttpMessageConverter
而真正做转换的是 Jackson
Jackson 的核心逻辑:
text
1. 创建对象(反射)
2. 遍历 JSON 字段
3. 找同名属性
4. 调 setter 赋值
关键点:
| 情况 | 行为 |
|---|---|
| JSON有字段 | 赋值 |
| JSON没有字段 | 忽略 |
| VO多字段 | 保持默认值 |
3️⃣ 为什么设计成这样?
兼容性(API演进)
后端新增字段,老前端不传也不会报错
前后端解耦
前端无需完全同步后端结构
支持部分更新(Patch)
只传需要修改的字段
4️⃣ 注意事项(坑点)
(1)基本类型问题
java
private int age; // ❌
前端不传:
java
age = 0 // 无法区分"没传"还是"就是0"
✅ 推荐:
java
private Integer age;
(2)校验注解
java
@NotNull
private Integer age;
前端不传:直接报错(400)
(3)JSON 多字段
json
{
"name": "张三",
"xxx": "未知字段"
}
默认:忽略。可配置为报错:
yaml
fail-on-unknown-properties: true
三、VO ↔ Entity:对象拷贝的本质
1️⃣ 示例
VO
java
public class AgentVO {
private String name;
private String address;
private String displayName; // VO特有
}
Entity
java
public class AgentEntity {
private String name;
private String address;
}
2️⃣ VO → Entity
java
BeanUtils.copyProperties(vo, entity);
结果:
java
entity.name = vo.name;
entity.address = vo.address;
displayName 被忽略 ✅
3️⃣ Entity → VO
java
BeanUtils.copyProperties(entity, vo);
结果:
java
vo.displayName = null;
4️⃣ 原理
和 Jackson 一样:
按"字段名 + 类型"匹配,有就拷贝,没有就跳过
四、最危险的坑:null 覆盖问题
示例
java
vo.name = null;
entity.name = "原值";
BeanUtils.copyProperties(vo, entity);
结果:
java
entity.name = null ❌
正确做法
忽略 null 字段:
java
BeanUtils.copyProperties(vo, entity, getNullPropertyNames(vo));
工具方法:
java
public static String[] getNullPropertyNames(Object source) {
BeanWrapper src = new BeanWrapperImpl(source);
return Arrays.stream(src.getPropertyDescriptors())
.map(PropertyDescriptor::getName)
.filter(name -> src.getPropertyValue(name) == null)
.toArray(String[]::new);
}
五、反序列化 vs 对象拷贝
| 场景 | 本质 |
|---|---|
| JSON → VO | Jackson 按字段填充 |
| VO → Entity | BeanUtils 按字段拷贝 |
本质完全一致:
只处理"匹配的字段"
六、实践中的常用方式
1. 分层设计
| 层 | 对象 |
|---|---|
| Controller | VO |
| Service | DTO |
| DAO | Entity |
2. 所有字段用包装类型
java
Integer / Long / Boolean
3. 更新接口必须防 null 覆盖
java
if (vo.getName() != null) {
entity.setName(vo.getName());
}
4. 明确三种状态
| 值 | 含义 |
|---|---|
| null | 前端没传 |
| 有值 | 前端传了 |
| 默认值 | 系统赋值 |
总之,Jackson & BeanUtils 的核心思想是 "按已有数据填充对象,而不是校验对象完整性" ,也就是说, 字段可以不传,对象可以不完全一致,系统只会处理"匹配上的部分",但我们必须控制 null 和业务语义。