1. 为什么后端需要分层数据模型?
第一次接触企业级Java项目时,我盯着满屏的UserDTO、UserCommand、UserEntity、UserPO陷入了深深的困惑——这不都是用户数据吗?为什么非要拆成这么多类?直到参与了一个老项目的重构,才真正理解了分层设计的价值。
那个老系统里,User类同时承担了接口传输、业务逻辑和数据库存储三种职责。当产品经理要求修改手机号加密规则时,我们不得不检查所有用到User类的代码;当需要给前端增加一个计算字段时,数据库查询也被迫跟着修改;当MySQL要分库时,业务代码里到处都是@Table注解需要调整。这种"牵一发而动全身"的维护噩梦,正是缺乏分层设计导致的典型问题。
2. 四层架构与对应数据模型
2.1 接口层(Interface):协议模型
在电商系统的用户注册接口中,我们定义了这样的DTO:
java复制public class UserRegisterDTO {
@NotBlank
private String username;
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$")
private String password;
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate birthday;
// 前端需要的计算字段
public int getAge() {
return Period.between(birthday, LocalDate.now()).getYears();
}
}
关键特征:
- 包含输入校验注解(如@NotBlank)
- 字段命名和结构贴合前端需求
- 可能有JSON序列化配置
- 会随接口版本迭代频繁变更
经验:DTO应该保持"贫血模型",不要包含业务逻辑。曾经有个项目在DTO里加了密码加密逻辑,结果移动端和Web端需要不同的加密方式时,不得不重写所有DTO。
2.2 应用层(Application):用例模型
继续以用户注册为例,对应的Command对象:
java复制public class UserRegisterCommand {
private Username username; // 值对象
private EncryptedPassword password;
private UserType userType = UserType.NORMAL;
private RegistrationSource source;
public void encryptPassword(PasswordEncoder encoder) {
this.password = encoder.encode(this.password);
}
}
与DTO的区别:
- 使用值对象(如Username)强化业务语义
- 包含用例特有的字段(如注册来源)
- 可能有简单的流程控制方法
- 生命周期通常仅限于单个事务
实际项目中,我们曾因为把优惠券核销规则放在Command里吃了大亏。当同样的核销逻辑需要在不同场景复用时,不得不进行痛苦的重构。
2.3 领域层(Domain):业务模型
真正的业务核心,以电商用户为例:
java复制public class User {
private UserId id;
private Username username;
private Password password;
private AccountStatus status;
private List<Role> roles;
public void changePassword(Password old, Password new) {
if (!this.password.matches(old)) {
throw new BusinessException("旧密码错误");
}
this.password = new;
}
public boolean hasPermission(Permission permission) {
return roles.stream().anyMatch(r -> r.hasPermission(permission));
}
}
关键设计原则:
- 使用值对象包装基本类型(如UserId代替Long)
- 封装业务规则和行为(如密码修改逻辑)
- 不依赖任何框架注解
- 可以通过纯Java单元测试验证
曾经有个支付系统把JPA注解放在Entity里,结果领域逻辑测试不得不启动Spring容器,测试时间从2分钟暴涨到15分钟。
2.4 基础设施层(Infrastructure):持久化模型
对应的用户PO类可能长这样:
java复制@Entity
@Table(name = "t_user", indexes = {
@Index(name = "idx_username", columnList = "username", unique = true)
})
public class UserPO {
@Id
@GeneratedValue(strategy = SEQUENCE)
private Long id;
@Column(name = "username", length = 64)
private String username;
@Column(name = "pwd_hash")
private String passwordHash;
@Type(type = "json")
@Column(columnDefinition = "json")
private List<String> roles;
}
典型特征:
- 完全贴合数据库表结构
- 包含各种ORM注解
- 可能有非范式化的冗余字段
- 字段类型和命名以存储效率优先
在分库分表改造时,正是因为有清晰的PO层,我们才能在不影响业务代码的情况下,通过修改PO注解和Repository实现来完成数据路由。
3. 层间数据流转实践
3.1 转换器设计模式
推荐使用MapStruct实现类型安全转换:
java复制@Mapper(componentModel = "spring")
public interface UserConverter {
UserRegisterCommand toCommand(UserRegisterDTO dto);
@Mapping(target = "password", ignore = true)
UserVO toVO(User user);
User toDomain(UserPO po);
UserPO toPO(User user);
}
实际项目中的经验:
- 为每个聚合根建立独立的Converter
- 对于复杂转换,可以组合多个Converter
- 使用@AfterMapping处理特殊字段
- 禁用自动映射,显式声明所有字段
曾经因为自动映射导致密码字段意外泄露,现在团队强制要求所有Converter必须显式声明@Mapping。
3.2 典型数据流转路径
用户注册场景的完整流程:
java复制@PostMapping("/register")
public UserVO register(@Valid @RequestBody UserRegisterDTO dto) {
// DTO -> Command
UserRegisterCommand command = userConverter.toCommand(dto);
// 应用服务编排
User user = userService.register(command);
// Entity -> VO
return userConverter.toVO(user);
}
// 在应用服务中
public User register(UserRegisterCommand command) {
command.encryptPassword(passwordEncoder);
User user = new User(command);
userRepository.save(user); // 内部会转换为PO
eventPublisher.publish(new UserRegisteredEvent(user));
return user;
}
关键原则:
- Controller只处理DTO转换和HTTP响应
- Service负责流程编排
- 领域对象不直接暴露给外层
- 每个层都使用自己理解的数据模型
4. 常见问题与解决方案
4.1 性能优化技巧
-
DTO字段裁剪:使用@JsonView控制不同接口返回的字段
java复制public class UserViews { public interface Simple {} public interface Detail extends Simple {} } @JsonView(UserViews.Detail.class) private String sensitiveField; -
批量转换优化:为List转换添加特殊方法
java复制@Mapper public interface UserConverter { List<UserVO> toVOs(List<User> users); } -
懒加载处理:在Converter中注入Service处理关联对象
java复制@AfterMapping default void fillExtraInfo(User user, @MappingTarget UserVO vo) { vo.setStatistics(userService.getStatistics(user.getId())); }
4.2 典型错误案例
错误1:跨层泄漏
java复制// 错误!Controller直接使用Entity
@GetMapping("/{id}")
public User get(@PathVariable Long id) {
return userRepository.findById(id);
}
修正方案:
java复制@GetMapping("/{id}")
public UserVO get(@PathVariable Long id) {
User user = userService.getById(id);
return userConverter.toVO(user);
}
错误2:混合注解
java复制// 错误!领域对象包含JPA注解
@Entity
public class User {
@Id
private Long id;
@Column
private String username;
}
修正方案:
java复制// Domain层
public class User {
private UserId id;
private Username username;
}
// Infrastructure层
@Entity
public class UserPO {
@Id
private Long id;
private String username;
}
5. 演进式架构实践
在项目初期,可以适当简化分层:
- MVP阶段:DTO直接转换为PO,省略中间层
- 业务复杂后:引入Command处理用例逻辑
- 领域模型成熟:提取纯领域对象
- 微服务拆分:强化DTO版本控制
我们团队的经验法则是:当修改某个功能需要同时改动超过3个层的代码时,就说明需要加强分层设计了。
6. 工具链推荐
-
代码生成:
- MapStruct(类型安全转换)
- Lombok(减少样板代码)
-
静态检查:
- ArchUnit(架构约束测试)
java复制@ArchTest static final ArchRule no_jpa_in_domain = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat() .areAnnotatedWith(Entity.class); -
文档化:
- Spring REST Docs(接口文档)
- Structurizr(架构图)
7. 团队协作规范
-
命名约定:
- 接口层:XxxDTO/XxxVO
- 应用层:XxxCommand/XxxQuery
- 领域层:Xxx(纯业务名称)
- 基础设施层:XxxPO/XxxRecord
-
目录结构:
code复制src/ ├── main/ │ ├── java/ │ │ ├── application/ │ │ ├── domain/ │ │ ├── infrastructure/ │ │ └── interfaces/ │ └── resources/ └── test/ ├── java/ └── resources/ -
Code Review要点:
- 检查对象是否出现在正确的层
- 验证转换器是否完整处理所有字段
- 确认领域对象不依赖框架
经过三个项目的实践验证,这套规范使我们的代码变更影响范围减少了60%,新成员上手时间缩短了40%。特别是在进行数据库迁移时,只需要重写PO层和Repository实现,业务代码完全不受影响。