Java 三层架构项目中数据实体目录规划与使用建议

一. 背景介绍

1.1 数据实体分类

Java 项目中,常见的数据实体包含以下几种:

(1)PO (Persistent Object):表示与数据库表直接映射的持久化对象,部分设计中命名为 DO(Data Object),常作用于三层中的 dao 层。

(2)BO (Business Object):承载核心业务逻辑的操作对象,可能聚合多个 PO/DTO 的数据和操作,常作用于三层中的 service 层。

(3)DTO (Data Transfer Object):作为传输对象,通常不承载具体业务逻辑,常作用于三层中的 controller 层。

(4)VO (View Object):面向视图层的数据封装对象。

问题一、DTO 和 VO 的区别?

DTO 既用于接收请求参数(Request DTO),也用于返回响应结果(Response DTO),而 VO 仅用于封装视图类响应结果。当前主流采用前后端分离的开发模式,正常情况下,JAVA 项目不应再输出 VO,所以下面介绍不再涉及 VO。

1.2 原有项目目录结构及存在问题

原有 Java 项目采用三层架构开发,在 SpringBoot 项目中采用单一 Module 设计,目录结构如下:

bash 复制代码
src/main/java
└── com
    └── example
        ├── contract # 保存dto
        │   │── in
        │   │   └──UserIn.java
        │   │── out
        │   │   └──UserOut.java
        ├── controller
        ├── service
        │   │── model
        │   │   └──User.java
        ├── dao
        │   │── model
        │   │   └──User.java
        └── Application.java

特点:

(1)DTO 保存在 contract 层下。

(2)BO 和 PO 分别存放于 service 和 dao 层的 model 子文件夹下。

(3)数据实体之间转化代码无约定存放位置,一般保存在 service 层的逻辑代码中。

存在问题:

(1)因历史原因,DTO 被单独提取至 contract 层,BO 和 PO 则保留在 service、dao 层的 model 子文件夹下,设计不对称。

(2)当前 service 层代码包含大量数据实体转换代码(DTO 转 BO\PO 或 PO\BO 转 DTO),该类代码量大、简单、可以不做或少做单元测试,保存在 service 层各业务类中,即影响可读性,又难被复用。

(3)数据实体的存放路径和命名规则过于抽象,缺乏统一标准。当前 service、dao 层的 BO、PO 数据实体统一存于 model 子包下,子包命名类似且较抽象,实体类名有重名可能,如 BO、PO 类名都为 User。

二. 思考及可改进点

2.1 数据实体间的转换代码是写在业务代码中,还是单独创建专门的数据实体转换类?

在实际业务开发中,各类数据实体间转换必不可少,建议创建专门的数据实体转换类及包(包名建议为 converter),统一管理数据实体转换。这能避免 service 层业务代码包含大量简单、重复且无需测试的数据实体转换代码,让 service 代码更简洁。另外,将此做法养成习惯并作为开发约定,有利于复用数据实体转换代码。

2.2 数据实体包(package)、数据实体类及数据转换类如何命名?

建议按类型归纳数据实体包(package,下同),将 service、dao 下的 model 子包及 contract 包分别改为 bo、po 和 dto 包,并将 dto 按数据传入方向分为 in、out 两类子包;各类数据实体按类型添加相应后缀,如 UserPO、UserBO、UserDTOIn 和 UserDTOOut;同理数据转换类,建议以转换目标类为前缀,并添加 converter 后缀,如 UserPOConverter、UserBOConverter、UserDTOInConverter 和 UserDTOOutConverter。

2.3 各类实体及实体转换类存放位置与目录规划?

各类实体有三种存放方式。其一,单独创建 model 层,与 controller、service、dao 层平级,用于存放各类数据实体及其转换类,作为上层公共模块服务于 controller、service 和 dao;其二,将 DTO、BO、PO 分别存于 controller、service 和 dao 层的 model 子包下,并把对应的转换类也放在 model.converter 子包下;其三,是第二种方式的变种,实际开发中 DTO 与 PO 之间转换,可能不需要 BO,这种方式可能导致 controller 引用 dao,dao 又引用 controller,造成两者循环引用,为避免该问题,本方式将转换类单独提至公共层 converter,此公共层与 controller、service 和 dao 平级。

综上所述,方式二存在模块循环引用问题,不建议采用,实际可选方式仅一、三两种,两种方式项目结构如下:

(1)方式一

bash 复制代码
src/main/java
└── com
    └── example
        ├── model
        │   ├── dto
        │   │   ├── in
        │   │   └── out
        │   ├── bo
        │   ├── po
        │   ├── converter
        │   │   ├── dto
        │   │   │   ├── in
        │   │   │   └── out
        │   │   ├── bo
        │   │   └── po
        ├── controller
        ├── service
        ├── dao
        └── Application.java

(2)方式三

bash 复制代码
src/main/java
└── com
    └── example
        ├── converter
        │   ├── dto
        │   │   ├── in
        │   │   └── out
        │   ├── bo
        │   ├── po
        ├── controller
        │   ├── dto
        ├── service
        │   ├── bo
        ├── dao
        │   ├── po
        └── Application.java

上述两种方式各有利弊。方式一的优点是数据实体与数据实体转换类统一存于同一包(model)下,便于集中管理;该包作为 controller、service、dao 的上层公共模块,不存在循环依赖问题,缺点是数据实体与具体业务或数据剥离,且 dao 层与常规及原目录结构不符,存在认知和迁移成本。方式三,与方式一相反,整体设计与常规及原目录结构一致,仅添加了 converter 层,改动较小,但仍可能存在循环依赖问题,如:service 依赖于 converter,converter 依赖于 service。从综合考虑,方式一利远大于弊,更为合理。

2.4 什么样的数据实体适合置于 PO 下?

对外 IO 相关操作的数据实体都适合放在 PO 下,如 http 请求、redis、MQ 等操作涉及的数据实体。

2.5 自动和人工生成的同类实体类如何存放?

自动生成的代码不应被人为改动,否则后续可能存在不一致的问题。建议将自动生成和人为创建的同类代码分别保存在不同的目录,目录名分别为:generator 和 custom。例如,mybatis-generator 生成的 PO 实体,建议保存在 po.db.{dbname}.generator 目录下;人为创建的与该 db 相关的 PO,保存在 po.db.{dbname}.custom 目录下,mapper.xml 和 Mapper 类同理。

三. 推荐项目结构及命名规范

综上所述,推荐项目结构及命名规范如下:

bash 复制代码
src/main/java
└── com
    └── example
        ├── model
        │   │── dto
        │   │   ├── in
        │   │   │   └── UserDTOIn.java
        │   │   │── out
        │   │   │   └── UserDTOOut.java
        │   ├── bo
        │   │   └── UserBO.java
        │   ├── po
        │   │   ├── db
        │   │   │   ├── {dbname}
        │   │   │   │   ├── generator
        │   │   │   │   │   └── UserPO.java
        │   │   │   │   ├── custom
        │   │   │   │   │   └── User{XXX}PO.java
        │   │   ├── http
        │   │   ├── redis
        │   │   ├── kafka
        │   ├── converter
        │   │   ├── dto
        │   │   │   ├── in
        │   │   │   │   └── UserDTOInConverter.java        
        │   │   │   │── out
        │   │   │   │   └── UserDTOOutConverter.java        
        │   │   ├── bo
        │   │   │   └── UserBOConverter.java
        │   │   │── po
        │   │   │   └── UserPOConverter.java
        ├── controller
        ├── service
        ├── dao
        └── Application.java

四. 使用建议

(1)保持各层数据实体的纯洁性,通过明确的目录和命名规则实现各层间逻辑解耦。

(2)需严格层级边界,禁止跨层暴露,如将 PO 直接暴露到 controller 层作为 DTO/VO 使用或将 DTO/VO 作为 PO 直接用于 dao 层相关操作。该规范用于减少各数据实体间的耦合度,有利于项目后期维护及长远发展。

(3)正常数据实体流转过程应为:DTO --> BO【可选】 --> PO --> BO【可选】--> VO/DTO。

(4)建议将实体转换类中的转换方法名统一命名为"map",因为它与 Java 8 Lambda 中的 map 方法功能类似。示例代码:

java 复制代码
public class UserPoConverter {

    public static UserPO map(UserBO useBo) {
        // 在 IDEA 中,建议使用 GenerateO2O 插件自动生成。
    }
}

(5)不建议使用 MapStruct、ModelMapper、BeanUtils 等工具实现数据实体间转换,这类工具通常基于属性名匹配,代码重构时可能导致字段映射关系丢失,容易出错,不推荐使用。

五. 推荐工具

5.1 Lombok

建议使用 Lombok 样板代码库简化数据实体类定义时的通用代码,如 get、set 及构造函数等。

5.2 GenerateO2O

在 IDEA 中,推荐使用 GenerateO2O 等工具自动生成数据实体之间的转换代码。

效果如下:

使用方法: 以 Windows 下为例,使用 Alt + Insert 快捷键,打开 GenerateO2O,单击执行即可。

存在不足: 可能会生成冗余的 get/set 方法,删除即可。

六. 效果

6.1 service 代码更少更专注于业务逻辑

将 service 层中原有的数据实体转换代码迁移至 model/converter 下,service 层代码更少,更专注于业务逻辑的实现。

6.2 统一项目结构及开发规范,减轻设计负担,减少重复代码,提高复用能力

使用上述项目结构及开发规范后,研发人员养成将实体类及转换类写在 model 及 model.converter 包下的习惯,并调用对应的 Converter 类,既能提高代码复用能力,又能使项目结构清晰明了。

七. 参考文档

(1)阿里巴巴 Java 开发手册