Skip to content
第 46 / 250 章后端⏱ 10 分钟阅读

第 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 / EntityData Objectentity/与数据库表一一对应
DTOData Transfer Objectdto/接收前端入参 / 跨层传输
VOView Objectvo/返回给前端的展示对象
BOBusiness Objectbo/Service 内部的业务模型(可选)
Query-dto/查询条件封装

为什么不能只用一个 Entity 走天下?

java
// ❌ 直接把数据库实体返回给前端
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
    return userMapper.selectById(id);
}

四个致命问题

问题说明
敏感数据泄漏passwordsaltidCard 全暴露给前端
数据库结构暴露表结构改了,前端就崩了;也给攻击者提供信息
无法定制展示前端要 roleName,但表里只有 roleId
入参污染用 Entity 接收入参,前端可以传 idcreateTimedeleted 覆盖任意字段

④ 是最危险的:如果你用 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?

维度MapStructBeanUtils
原理编译期生成 get/set 代码运行期反射
性能和手写一样快慢几十倍
字段名不一致编译期报错静默不复制,线上才发现
类型不匹配编译期报错静默失败或抛异常

五、命名规范

类命名

类型规范示例
ControllerXxxControllerUserController
Service 接口XxxServiceUserService
Service 实现XxxServiceImplUserServiceImpl
MapperXxxMapperUserMapper
实体名词单数User(不是 Users
DTOXxx动作DTOUserCreateDTOUserUpdateDTO
VOXxxVOUserVOUserDetailVO
查询XxxQueryUserPageQuery
转换器XxxConvertUserConvert
常量XxxConstantsCacheConstants
枚举XxxEnum 或直接名词OrderStatusEnum
工具类XxxUtilsDateUtils

方法命名

前缀语义示例
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 GuidelinesIDEA 插件,实时提示违反阿里规约的代码
SonarLintIDEA 插件,检测 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)、ProductCreateDTOProductVOProductPageQuery,并写出 MapStruct 转换接口。

练习 2:思考题

一个「用户下单」接口需要:校验库存、扣减库存、创建订单、发送通知。这四步分别放在哪一层?为什么?


下一章第 47 章:多环境配置

本站基于 VitePress 构建 · 由 Codebook 团队维护