第 46 章:项目结构规范
学习目标
- 掌握企业级分层架构
- 理解 DO / DTO / VO 的职责边界
- 建立统一的命名规范
一、分层架构
| 层 | 职责 | 不该做什么 |
|---|---|---|
| Controller | 参数接收/校验、调 Service、组装响应 | ❌ 写业务逻辑、❌ 直接调 Mapper |
| Service | 业务逻辑、事务控制、编排 | ❌ 处理 HTTP 相关(request/response) |
| Manager | 对第三方的封装、跨 Service 的通用逻辑 | ❌ 包含具体业务规则 |
| Mapper | 单表 CRUD | ❌ 写业务判断 |
Manager 层是很多人忽略的。它的价值:当订单和用户两个 Service 都需要「发短信」时,这个逻辑放哪?放任一个 Service 都会造成 Service 互相依赖。放 Manager 层就干净了。
二、标准包结构
com.taskflow
├── TaskflowApplication.java # 启动类(必须在最外层包)
│
├── common/ # 公共
│ ├── annotation/ # 自定义注解
│ ├── constant/ # 常量
│ ├── enums/ # 枚举
│ ├── exception/ # 异常定义
│ ├── result/ # 统一响应体
│ └── util/ # 工具类
│
├── config/ # 配置类
│ ├── MybatisPlusConfig.java
│ ├── RedisConfig.java
│ ├── SecurityConfig.java
│ └── WebMvcConfig.java
│
├── framework/ # 框架增强
│ ├── aspect/ # 切面
│ ├── filter/ # 过滤器
│ ├── interceptor/ # 拦截器
│ └── handler/ # 全局异常处理器
│
└── modules/ # 业务模块(按业务垂直划分)
├── system/ # 系统管理
│ ├── controller/
│ ├── service/
│ │ └── impl/
│ ├── mapper/
│ ├── entity/ # DO(数据库实体)
│ ├── dto/ # 入参
│ ├── vo/ # 出参
│ └── convert/ # 对象转换
└── order/ # 订单模块
└── ...(同上)为什么按业务模块(modules)划分而不是按技术层? 按技术层划分(
controller/、service/各一个大包)时,改一个功能要在四五个包之间跳来跳去。按业务划分时,一个功能的所有代码都在一个文件夹里,高内聚。项目大了之后拆微服务也容易——直接把一个 module 拎出去。
三、对象模型:DO / DTO / VO
| 类型 | 全称 | 位置 | 用途 |
|---|---|---|---|
| DO / Entity | Data Object | entity/ | 与数据库表一一对应 |
| DTO | Data Transfer Object | dto/ | 接收前端入参 / 跨层传输 |
| VO | View Object | vo/ | 返回给前端的展示对象 |
| BO | Business Object | bo/ | Service 内部的业务模型(可选) |
| Query | - | dto/ | 查询条件封装 |
为什么不能只用一个 Entity 走天下?
java
// ❌ 直接把数据库实体返回给前端
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userMapper.selectById(id);
}四个致命问题:
| 问题 | 说明 |
|---|---|
| 敏感数据泄漏 | password、salt、idCard 全暴露给前端 |
| 数据库结构暴露 | 表结构改了,前端就崩了;也给攻击者提供信息 |
| 无法定制展示 | 前端要 roleName,但表里只有 roleId |
| 入参污染 | 用 Entity 接收入参,前端可以传 id、createTime、deleted 覆盖任意字段 |
④ 是最危险的:如果你用
User接收注册请求,攻击者可以传{"username":"hack","roleId":1}直接给自己设成管理员。
完整示例
java
// ① DO:严格对应数据库表
@Data
@TableName("sys_user")
public class User {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String username;
private String password; // 加密后的
private String salt;
private String phone;
private Integer status;
private Long deptId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableLogic
private Integer deleted;
}
// ② DTO:只包含前端能传的字段
@Data
public class UserCreateDTO {
@NotBlank(message = "用户名不能为空")
@Length(min = 4, max = 20, message = "用户名长度 4-20")
private String username;
@NotBlank(message = "密码不能为空")
@Length(min = 8, message = "密码至少 8 位")
private String password; // 明文,Service 里加密
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
private String phone;
@NotNull(message = "部门不能为空")
private Long deptId;
// ✅ 注意:没有 id、status、createTime、deleted —— 前端传了也没用
}
// ③ VO:只包含前端需要看到的
@Data
public class UserVO {
private Long id;
private String username;
private String phoneMasked; // 138****8888 脱敏
private String statusName; // "正常" 而不是 1
private String deptName; // 关联查出来的部门名
private LocalDateTime createTime;
// ✅ 没有 password、salt
}四、对象转换:用 MapStruct
java
@Mapper(componentModel = "spring") // ① 生成 Spring Bean
public interface UserConvert {
UserConvert INSTANCE = Mappers.getMapper(UserConvert.class);
@Mapping(target = "id", ignore = true) // ② 忽略字段
@Mapping(target = "password", ignore = true)
User toEntity(UserCreateDTO dto);
@Mapping(source = "dept.name", target = "deptName") // ③ 嵌套取值
@Mapping(target = "phoneMasked", expression = "java(MaskUtils.phone(user.getPhone()))")
UserVO toVO(User user);
List<UserVO> toVOList(List<User> users); // ④ 批量转换自动生成
}为什么用 MapStruct 而不是 BeanUtils.copyProperties?
维度 MapStruct BeanUtils 原理 编译期生成 get/set 代码 运行期反射 性能 和手写一样快 慢几十倍 字段名不一致 编译期报错 静默不复制,线上才发现 类型不匹配 编译期报错 静默失败或抛异常
五、命名规范
类命名
| 类型 | 规范 | 示例 |
|---|---|---|
| Controller | XxxController | UserController |
| Service 接口 | XxxService | UserService |
| Service 实现 | XxxServiceImpl | UserServiceImpl |
| Mapper | XxxMapper | UserMapper |
| 实体 | 名词单数 | User(不是 Users) |
| DTO | Xxx动作DTO | UserCreateDTO、UserUpdateDTO |
| VO | XxxVO | UserVO、UserDetailVO |
| 查询 | XxxQuery | UserPageQuery |
| 转换器 | XxxConvert | UserConvert |
| 常量 | XxxConstants | CacheConstants |
| 枚举 | XxxEnum 或直接名词 | OrderStatusEnum |
| 工具类 | XxxUtils | DateUtils |
方法命名
| 前缀 | 语义 | 示例 |
|---|---|---|
get | 单个查询 | getById(Long id) |
list | 列表查询 | listByDeptId(Long deptId) |
page | 分页查询 | page(UserPageQuery query) |
count | 统计 | countByStatus(Integer status) |
save / create | 新增 | save(User user) |
update | 修改 | updateById(User user) |
remove / delete | 删除 | removeById(Long id) |
exists | 判断存在 | existsByUsername(String name) |
batchXxx | 批量 | batchSave(List<User> users) |
get语义要一致:getById找不到时抛异常还是返回 null?团队要统一。推荐:getById返回 null,getByIdOrThrow抛异常。
其他规范
java
// ① 布尔字段不要加 is 前缀(Jackson 序列化会出问题)
private Boolean deleted; // ✅
private Boolean isDeleted; // ❌ 序列化后可能变成 "deleted"
// ② 常量全大写下划线
public static final int MAX_RETRY_TIMES = 3;
// ③ 包名全小写,不用复数
com.taskflow.modules.user // ✅
com.taskflow.modules.Users // ❌
// ④ 避免魔法值
if (status == 1) { } // ❌ 1 是什么?
if (status == UserStatus.NORMAL.getCode()) { } // ✅六、代码规范检查
推荐工具链:
xml
<!-- Checkstyle:代码风格检查 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<failOnViolation>true</failOnViolation> <!-- 违规就构建失败 -->
</configuration>
</plugin>| 工具 | 作用 |
|---|---|
| Alibaba Java Coding Guidelines | IDEA 插件,实时提示违反阿里规约的代码 |
| SonarLint | IDEA 插件,检测 bug 和坏味道 |
| Checkstyle | 构建期强制检查代码风格 |
| SpotBugs | 静态分析找潜在 bug |
| EditorConfig | 统一缩进、换行符(跨 IDE 生效) |
七、本章小结
| 要点 | 关键 |
|---|---|
| 分层 | Controller / Service / Manager / Mapper |
| 包结构 | 按业务模块划分,不按技术层 |
| DO | 对应数据库表 |
| DTO | 接收入参,绝不用 Entity 接参数 |
| VO | 返回前端,绝不返回 Entity |
| 转换 | MapStruct,不用 BeanUtils |
| 命名 | get/list/page/save/update/remove |
| 布尔字段 | 不加 is 前缀 |
动手练习
练习 1:基础题
为「商品」设计完整的对象模型:Product(DO)、ProductCreateDTO、ProductVO、ProductPageQuery,并写出 MapStruct 转换接口。
练习 2:思考题
一个「用户下单」接口需要:校验库存、扣减库存、创建订单、发送通知。这四步分别放在哪一层?为什么?
下一章:第 47 章:多环境配置 →