如何设计一个清晰的Java模块化项目结构
痛点是所有Java开发者都绕不开的坎——你所在项目的根目录下是否也堆着包名如com.company.project.utils、com.company.project.common的类文件里面躺着四十个类、上万行代码谁也不敢动谁也不敢删。今天聊的模块化设计不是你用了Maven或Gradle就算模块化更不是把代码分成几个文件夹自欺欺人。那只是把一锅粥换成了几个碗装粥的粘稠度并未改变。先给模块化下个严格的定义一个高内聚的模块必须做到对外部只暴露不可变的最小契约集而内部的任何变化都不得通过编译错误或者运行时异常告知该模块的使用者。这个门槛很高但达不到这个门槛你的项目结构就只是“看起来很多包”。常见的结构腐化之一是“循环依赖”。在包service里调用repository似乎天经地义但当mapper需要调用domain的某工具类而domain又引用了service的某常量时这种环一旦形成你的代码库就从一个层级结构瞬间退化成网状结构编译期无法发觉直到某次上线线上事故才追悔莫及。那么一个清晰的模块化项目应该何去何从答案不在于包名上多写几个前缀而在于依赖方向的控制。这里引入第一个核心原则依赖必须指向抽象而抽象不能感知具体实现依赖的细节。比如模块A要调用模块B的能力A不应该直接实例化B的类而应面向B定义的接口编程。B通过依赖注入或者服务定位器将实现交给运行时。有人说这不过就是面向接口编程的老调重弹。但我要指出模块化的威力不在于把代码分块而在于把变化隔离在围墙内。假如你的系统里支付模块有微信支付、支付宝、银联三种实现如果三者直接暴露给订单模块订单模块每兼容一种新支付方式就要修改一次逻辑。而恰当的做法是顶层定义PaymentGateway接口支付模块内部是不同实现订单模块只依赖接口。未来的第四种支付方式不过是新增一个类注册进容器在配置处修改一行而已——你隔离的不是代码而是未来的需求波动。接下来我们必须直面一个残酷的现实许多项目采用的是Package-by-Layer按技术层分包策略。Controller、Service、Mapper各建一个顶层包。这种结构在需求变更频繁的业务系统中是毒药。为什么因为一次简单的用户注册功能涉及Controller接收参数、Service业务校验、Mapper数据库操作、Domain用户实体、DTO传输对象、VO展示对象。同一个“用户注册”的需求被肢解到五个包里而团队每改一次业务逻辑都要在这五个包之间如同跳棋般跳转。对比一下Package-by-Feature按功能分包。你将看到user包里有UserControllerUserServiceUserMapper以及该功能专属的DTO。这种做法执行了一致性变更原则凡是因同一原因要修改的代码必须躺在同一张床上。若某个实体类的字段变化牵动的Controller、Service、Mapper在同一包内一眼就能看全改动影响面而不是在IDE里按着CtrlShiftT满工作区搜索。以模块为顶层单位可以借助Java Platform Module System (JPMS)或Maven多模块结构。我倾向于Maven/Gradle作为物理模块边界各自独立构建、独立版本控制而JPMS则可作为第二道防御。一个典型的结构应当如左图所示以职责划分yourcompany-arch架构与版本管理通常只是BOM聚合配置yourcompany-api对外暴露的可复用接口、事件定义、DTOyourcompany-domain核心业务实体、业务规则校验、领域服务yourcompany-persistence仓储实现、数据库映射、ORM配置yourcompany-app应用编排、事务边界、用例编排是业务的导演yourcompany-adapter-webHTTP处理、入参校验、视图模型装配yourcompany-bootstrap启动装配负责组装所有模块注意名称中没有util、common、base。一个名为common的模块通常在五年后会膨胀成一切依赖一切的架构深渊。实用做法是让通用的代码归属到特定的、有上下文的单元里。如果连上下文都想不出那这段代码极可能是过度抽线。宁可让代码复制三次然后抽象也不要为了复用提前抽取一个谁都能用的基座。至于那些确实被多个功能模块依赖的纯函数小工具比如日期计算、字符串格式化要么固化进arch最底层包要么干脆内聚到具体使用的模块内部再通过模块接口暴露。模块与模块之间的访问控制必须依赖编译级别的封印。在Java里就表现为对包可见性和访问修饰符的固执。你应当将每个模块的对外入口限制在一两个类上其余实现类都使用包级别可见性。比如PaymentService是公开的但WxPayCallbackHandler、AliPaySignatureUtil都是默认package-private权限代码在包外永远无法import。一个直接的检测方法——如果新入职的初级程序员无需你的指引就能在模块外new出实现类那么你的模块封装是失败的。下面用一个具体的骨架来示范架构方案。设想项目叫store-order它只负责订单域。顶层模块结构如下store-order ├── store-order-contract // 订单对外API、事件、DTO、枚举无任何spring依赖 ├── store-order-domain // 纯Java模型Order, LineItem, 状态机判定 ├── store-order-application // 应用服务与用例编排引入domain与contract └── store-order-adapter // 适配器持久化、消息、web内部实现storage关键点出现了application是唯一可信赖的编排者。Controller不许直接new OrderDomainService而是调用OrderApplicationService。持久化怎样实现业务完全不知道。模块内类关系呈现为一个清晰的依赖指向web层会依赖application层application不反向依赖web。若某天你需要从HTTP切换为GRPC或者从MyBatis换到Spring Data JPA我们只需要新增一个adapter实现不影响核心演算。因为测试的核心逻辑不依赖于Spring容器和数据库所以跑一次单元测试的速度将快上几个数量级这也是模块化带来的隐性收益。需要警惕的是为了模块化而创造出的伪模块。有些团队把UserController放在user-web、UserRepo放在user-data表面上按实体拆分模块但依赖关系一旦画出来user-web依赖user-dataproject-web依赖project-data而user-data为了查用户的项目列表又反向依赖project-data模块边界形同虚设。这里的本质问题是一个模块的边界并非由它操作哪种实体决定的而是由它的用例Use Case决定的。从数据库表出发设计模块注定飘摇要从用户能完成的目标出发。比如“用户下订单”是一个用例。它横跨用户模块、库存模块、订单模块。如何组织正确的做法是三个模块各自拥有自己的应用服务接口store-order-application作为门面依次调用库存接口预留库存、向订单仓储持久化、通知用户模块扣减积分。整个用例的语义链完整保留在门面类里阅读这段代码就可以看懂整个下单的流程而不是钻进五个包寻找片段。现在的Java项目还有一个隐蔽的炸弹——循环依赖在构造器注入里的粉饰太平。Spring Boot2.6之后默认禁止构造器循环依赖但你仍能通过Lazy偷鸡。在模块级别的结构里如果A模块依赖B模块B又依赖A即使编译过了这也意味着它们本应合并成一个模块。清晰的结构必须能画出一个有向无环图DAG。画不出DAG模块化就破产了。画得出来还要通过ArchUnit之类的测试在每次CI中对依赖规则做自动校验。ArchUnit能监听Package依赖若有人故意在domain层引入springframework测试直接失败。借助ArchUnit固化规则比写十分钟设计文档更有效因为文档会腐烂测试不会。模块演化上有一种值得推荐的节奏先有清晰的运行时边界再有模块的物理边界。很多项目刚开始单体仓储怎么拆都合理但一旦膨胀就拆不动了。比较实用的做法是一开始就建立contract模块对外发布稳定接口。在代码量小的时候哪怕是单一Maven模块也可以通过包名区分。等代码库超过五万行、开发者超过十人就应该重新审视模块的扩散度在依旧可维护的时间窗口切分为多模块。越早引入contract边界成本越低这可能是整篇最值得抄的作业。我们还要考虑全局共享的数据传输对象。常见的恶习是controller层直接暴露JPA实体给前端或是在多个模块间传递同一个Map。前者导致懒加载异常后者造成魂断字符串Key。清晰的架构里模块与模块之间的传参对象专门定义在contract包下且该DTO不依赖任何ORM注解、不包含任何业务方法只做状态展示。这符合防腐败层思想——你的各模块之间只通过免疫的信使对话避免内部领域模型的污染。在具体代码的组织上我建议坚持用例优先、自底向上的放置策略。所谓用例优先是指新写一个功能时先创建用例类名如CompleteOrderUseCase它表达业务事件。然后顺着用例分解它需要什么输入、调用哪些端口interface、输出什么结果。在模块化的语境里你的代码就不该是“Controller新增一个方法然后往下写”的瀑布流而是“一个业务语义能同时被不同的入口HTTP、事件监听、命令行复用”的完整胶囊。最终来看一个清晰的Java模块化项目结构的体验应当是——打开类文件时毫不迟疑修改一个功能所需的文件修改列表在脑内短得惊人。删掉一个模块就像拔掉一个U盘没有残留的隐式引用没有需要手动处理的孤儿类。这种可替代性展示了终极的模块化正确性。模块存在的终极意义正是为了让代价最小的大规模删除成为可能——一个不敢删除任何代码的软件项目无论称作什么架构都已经死了。设计模块化的项目归根结底是在为那个必然到来的、名为“需求骤变”的明天买保险。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →