本文主题:解释模块为什么能互相使用,以及 Maven、Spring 和公共接口分别承担什么职责
适合读者:刚接触 Maven 多模块、Spring 依赖注入和模块化单体架构的 Java 开发者
代码基线:当前学习分支源码快照
上一篇:从零读懂 AI 智能客服后端架构:模块职责与 SSE 聊天链路
下一篇:AI 智能客服为什么不能照搬传统三层架构:CRUD 与 AI 编排分层对比
🐟这里是yurenpai
27届开发者,主要学习 Java 后端与 AI 应用开发。
这里记录真实项目中的代码调用链、Agent/RAG 工程化、问题排查和开发复盘。
个人理念:
模块之间能否协作,不能只看目录位置,必须同时追踪构建依赖和运行时装配。
写在前面
先说结论:两个模块放在同一个仓库里,并不代表它们可以直接互相调用。真正决定模块协作的是三层机制:Maven 负责建立编译依赖,Spring 负责发现并注入运行时 Bean,公共接口负责控制依赖方向、避免业务模块形成循环依赖。
本文会以“聊天模块调用工作流、工作流反向使用聊天能力”为贯穿案例,重点讲清:
- 父子关系、聚合关系和依赖关系有什么区别;
admin为什么能够把多个业务模块装进同一个应用;- 接口放在公共模块中,为什么能避免
chat ↔ aiflow的循环依赖; - 如何用 POM、
import、接口实现和启动模块反向验证一次跨模块调用。
说明:本文以当前项目源码为例,重点解释模块协作方法;不同项目的模块名称可能不同,但分析步骤可以复用。
代码说明:除明确标注为完整源码外,文中的 POM、Java 代码和目录片段均为根据当前源码整理的简化示意;文中的“已核对”表示完成静态源码核对,不等同于启动或接口测试通过。
一、为什么要拆成这么多模块
假设不拆模块,把所有代码都放在一个项目中:
src/main/java/com/tst/pharma/ ├─ 用户管理 ├─ 权限管理 ├─ 聊天 ├─ 知识库 ├─ 工作流 ├─ AI 流程 ├─ Redis ├─ SSE ├─ 短信 ├─ 文件上传 ├─ Excel └─ 定时任务时间长了会出现几个问题:
1. 所有代码混在一起,不知道属于哪个业务; 2. 一个聊天模块也能随便依赖用户、短信、工作流内部实现; 3. 公共工具被复制多份; 4. 修改一个模块容易影响其他模块; 5. Maven 依赖越来越混乱; 6. 新人很难判断代码应该放在哪里; 7. 无法控制模块之间的依赖方向。所以项目进行了两次拆分。
第一次:按大职责拆分
tst-pharma-admin ├─ 应用启动和组装 tst-pharma-common ├─ 公共基础能力 tst-pharma-modules ├─ 具体业务模块 tst-pharma-extend └─ 独立扩展服务第二次:在每个大模块内部继续拆分
例如公共能力拆成:
tst-pharma-common-core tst-pharma-common-web tst-pharma-common-redis tst-pharma-common-sse tst-pharma-common-mybatis tst-pharma-common-satoken业务能力拆成:
tst-pharma-system tst-pharma-chat tst-pharma-workflow tst-pharma-aiflow tst-pharma-generator这样就可以做到:
聊天模块需要 SSE → 只依赖 common-sse 聊天模块需要 Web 能力 → 只依赖 common-web 聊天模块不需要短信 → 就不必直接依赖 common-sms这就是“模块化”的主要意义:
不是为了把目录弄多,而是为了划分职责、复用能力并控制依赖。
二、先区分三个容易混淆的概念
项目中有三种关系,一定要区分。
1. 父子关系
例如子模块的pom.xml中:
<parent><groupId>com.tst.pharma</groupId><artifactId>tst-pharma-main-backend</artifactId><version>${revision}</version></parent>它表示子模块继承父工程的:
版本号 依赖版本管理 Maven 插件 Java 版本 构建配置但是:
继承父 POM,不代表父模块能够直接使用子模块的 Java 类。
2. 聚合关系
根pom.xml中:
<modules><module>tst-pharma-admin</module><module>tst-pharma-common</module><module>tst-pharma-extend</module><module>tst-pharma-modules</module></modules>它表示:
在根目录执行 Maven 构建时 → Maven 会一起构建这些模块但是:
被根 POM 聚合,也不等于模块之间能够互相调用。
例如:
tst-pharma-chat tst-pharma-workflow虽然都被根项目聚合,但如果chat/pom.xml没有依赖workflow,那么chat不能直接随意导入workflow的类。
3. 依赖关系
真正决定一个模块能否使用另一个模块 Java 类的是:
<dependency><groupId>com.tst.pharma</groupId><artifactId>被依赖模块</artifactId></dependency>例如tst-pharma-chat/pom.xml中有:
<dependency><groupId>com.tst.pharma</groupId><artifactId>tst-pharma-common-chat</artifactId></dependency><dependency><groupId>com.tst.pharma</groupId><artifactId>tst-pharma-common-sse</artifactId></dependency>所以聊天模块才能使用:
importcom.tst.pharma.common.chat.domain.dto.request.ChatRequest;importcom.tst.pharma.common.sse.core.SseEmitterManager;可以记成:
聚合关系 └─ 决定一起构建 父子关系 └─ 决定继承统一配置 依赖关系 └─ 决定是否能使用另一个模块的代码小鱼点睛
父子关系统一配置,聚合关系决定一起构建,依赖关系决定能否使用对方的 Java 类。两个目录即使紧挨在一起,也不会自动产生代码依赖。
三、项目真实的模块依赖方向
整个项目可以简化成:
tst-pharma-admin 主应用启动和组装 │ ┌───────────────────┼────────────────────┐ │ │ │ ▼ ▼ ▼ tst-pharma-system tst-pharma-chat tst-pharma-workflow 系统管理业务 AI聊天与知识库 传统业务流程 │ ▼ tst-pharma-aiflow AI流程编排 上述业务模块继续依赖: tst-pharma-common-* 公共基础组件更准确地说,admin直接依赖:
tst-pharma-system tst-pharma-generator tst-pharma-chat tst-pharma-workflow tst-pharma-aiflow因此最终启动admin时,这些业务模块都会进入主应用。
四、admin是怎么把所有业务模块装进来的
文件:
D:\Tools\java\aiwork\tst-pharma-main-backend\tst-pharma-admin\pom.xml它声明了:
admin ├─ 依赖 system ├─ 依赖 generator ├─ 依赖 chat ├─ 依赖 workflow └─ 依赖 aiflow构建主应用时,Maven 会把这些模块的编译产物和依赖一起放进最终应用。
可以将最终运行的应用理解成:
tst-pharma-admin.jar ├─ admin 自己的类 ├─ system 模块的类 ├─ chat 模块的类 ├─ workflow 模块的类 ├─ aiflow 模块的类 ├─ generator 模块的类 └─ 它们所依赖的 common 类因此运行时主要是:
一个 JVM 进程 一个 Spring 容器 一个后端端口 6039而不是:
system 启一个端口 chat 启一个端口 workflow 启一个端口 aiflow 再启一个端口所以当前主体架构属于:
模块化单体应用,不是微服务架构。
五、公共模块是怎么被业务模块使用的
例子一:聊天模块使用 SSE
ChatServiceFacade中:
privatefinalSseEmitterManagersseEmitterManager;SseEmitterManager不在聊天模块,而在:
tst-pharma-common-sse聊天模块的pom.xml声明了:
<dependency><groupId>com.tst.pharma</groupId><artifactId>tst-pharma-common-sse</artifactId></dependency>完整关系是:
tst-pharma-chat │ │ Maven 依赖 ▼ tst-pharma-common-sse │ ├─ SseEmitterManager ├─ SseMessageUtils ├─ SseEventDto └─ SSE 自动配置于是聊天模块才能:
建立 SSE 发送 content 发送 done 发送 error 关闭 SSE例子二:聊天模块使用登录认证
聊天代码中使用:
LoginHelper.getUserId(); StpUtil.getTokenValue();这涉及:
common-satoken common-redis Sa-Token依赖链不一定都由聊天模块直接声明,也可以通过依赖传递获得。
简化理解:
tst-pharma-chat ↓ common-chat / common-sse / common-web ↓ common-satoken ↓ common-redis ↓ Redis所以一个模块不一定需要把所有底层依赖都重新声明一遍。
例子三:系统模块使用 MyBatis
SysConfigMapper:
publicinterfaceSysConfigMapperextendsBaseMapperPlus<SysConfig,SysConfigVo>{}其中BaseMapperPlus位于:
tst-pharma-common-mybatis关系为:
tst-pharma-system │ ▼ tst-pharma-common-mybatis │ ▼ MyBatis-Plus │ ▼ MySQL因此system模块不需要自己再实现一套分页、Mapper 基类和数据库配置。
六、模块之间不仅靠 Maven,还靠 Spring 连接
Maven 解决的是:
编译时能不能看到另一个模块的类Spring 解决的是:
程序启动后,具体使用哪个实现对象启动类:
D:\Tools\java\aiwork\tst-pharma-main-backend\tst-pharma-admin\src\main\java\com\tst\pharma\TstPharmaApplication.java位于根包:
packagecom.tst.pharma;并且有:
@SpringBootApplicationSpring 默认会从启动类所在包向下扫描:
com.tst.pharma ├─ controller ├─ service ├─ config ├─ factory ├─ workflow └─ ...即使类分别位于不同 Maven 模块中,只要最后都被admin引入,并且包名属于:
com.tst.pharma...Spring 就能发现它们。
例如:
chat 模块 └─ ChatController └─ @Controller chat 模块 └─ ChatServiceFacade └─ @Service aiflow 模块 └─ WorkflowStarter └─ Spring Bean common-sse 模块 └─ SSE 自动配置 └─ 注册 SseEmitterManager它们最后都在同一个 Spring 容器中。
七、Spring 依赖注入是怎么跨模块工作的
例如ChatController中:
privatefinalChatServiceFacadechatService;因为类上有:
@RequiredArgsConstructorLombok 会生成类似构造器:
publicChatController(ChatServiceFacadechatService){this.chatService=chatService;}Spring 启动时发现:
ChatController 需要 ChatServiceFacade然后又发现:
@ServicepublicclassChatServiceFacade{}于是完成注入:
Spring 容器 ├─ 创建 ChatServiceFacade ├─ 创建 ChatController └─ 把 ChatServiceFacade 放进 ChatController虽然它们处在不同目录甚至不同 Maven 模块,也不影响运行时注入,只要满足:
1. admin 的 Maven 依赖把两个模块装进来了; 2. Spring 扫描到了对应类; 3. 类注册成了 Bean; 4. 依赖类型能够匹配。小鱼点睛
Maven 只解决“编译时能不能看见接口和类”,真正把接口字段连接到实现对象的是 Spring 容器。只有编译依赖、Bean 扫描和类型匹配同时成立,跨模块注入才会成功。
八、项目中最典型的跨模块设计:common-chat接口桥梁
这一部分很重要,因为它解释了:
chat和aiflow为什么可以互相协作,但没有在 POM 中直接互相依赖。
先看结构:
tst-pharma-common-chat ├─ IChatService ├─ IChatModelService └─ IWorkFlowStarterService它们只是接口和公共协议,不包含具体业务实现。
聊天服务接口
位于:
tst-pharma-common-chat └─ IChatService具体实现位于聊天模块:
@ServicepublicclassChatServiceFacadeimplementsIChatService{}关系:
common-chat └─ 定义 IChatService chat └─ ChatServiceFacade 实现 IChatService工作流启动接口
位于:
tst-pharma-common-chat └─ IWorkFlowStarterService具体实现位于 AI 流程模块:
publicclassWorkflowStarterimplementsIWorkFlowStarterService{}关系:
common-chat └─ 定义 IWorkFlowStarterService aiflow └─ WorkflowStarter 实现 IWorkFlowStarterServicechat 调用 aiflow
ChatServiceFacade中不是直接依赖某个aiflow内部类,而是:
privatefinalIWorkFlowStarterServiceworkFlowStarterService;流程是:
ChatServiceFacade │ │ 只认识公共接口 ▼ IWorkFlowStarterService ▲ │ 由 Spring 在运行时寻找实现 │ WorkflowStarter这样chat模块在编译时不需要直接依赖aiflow。
aiflow 调用 chat
aiflow的WorkflowUtil中使用:
privateIChatServicechatService;privateIChatModelServicechatModelService;流程:
WorkflowUtil │ │ 只依赖公共接口 ▼ IChatService ▲ │ Spring 注入实现 │ ChatServiceFacade所以整体是:
common-chat 公共接口和公共请求对象 ▲ ▲ │ │ chat aiflow 实现聊天接口 实现流程接口这叫:
通过公共契约解耦业务模块。
小鱼点睛
把接口下沉到双方都能依赖的公共模块,目的不只是“集中存放公共类”,而是反转依赖方向:业务模块面向稳定契约协作,避免
chat和aiflow在 POM 中相互咬住。
九、为什么接口要放在common-chat,而不是直接放在chat
如果IChatService放在:
tst-pharma-chat那么aiflow为了调用它,就必须:
aiflow → 依赖 chat同时聊天模块为了启动 AI 工作流,可能又需要:
chat → 依赖 aiflow最后形成:
chat → aiflow ↑ ↓ └───────┘这就是 Maven 循环依赖。
Maven 无法正常处理这样的模块关系。
当前项目把接口放进公共模块后,变成:
chat ──────→ common-chat aiflow ────→ common-chat然后由admin同时引入:
admin ├─ chat └─ aiflow运行时再由 Spring 把实现连接起来。
这个设计可以理解为插座:
common-chat └─ 规定插座形状 chat └─ 提供一种插头 aiflow └─ 提供或使用另一种插头 Spring └─ 启动时把兼容的插头插到插座上十、admin是最终的组装者
这个项目中,真正知道“我要同时加载哪些业务模块”的是:
tst-pharma-admin它的pom.xml相当于组装清单:
主应用需要: ├─ system ├─ generator ├─ chat ├─ workflow └─ aiflow因此可以这样理解:
common └─ 制造公共零件 modules ├─ 制造用户系统 ├─ 制造聊天系统 ├─ 制造业务工作流 └─ 制造 AI 工作流 admin └─ 把所有零件和业务模块装成完整应用十一、数据库 Mapper 又是怎么跨模块加载的
配置位于:
D:\Tools\java\aiwork\tst-pharma-main-backend\tst-pharma-admin\src\main\resources\application.yml其中配置了:
mybatis-plus:mapperPackage:com.tst.pharma.**.mappermapperLocations:classpath*:mapper/**/*Mapper.xmltypeAliasesPackage:com.tst.pharma.**.domainMyBatis 配置类使用:
@MapperScan("${mybatis-plus.mapperPackage}")意思是扫描所有模块中符合下面规则的 Mapper:
com.tst.pharma.任意内容.mapper例如:
system 模块 └─ com.tst.pharma.system.mapper.SysConfigMapper chat 模块 └─ com.tst.pharma.mapper.ChatMessageMapper workflow 模块 └─ 对应的 mapperclasspath*:也表示:
不只查当前模块,还会查依赖 JAR 中的 Mapper XML。
所以启动一个admin,能够把多个业务模块中的 Mapper 全部注册到同一个 MyBatis/Spring 容器。
十二、配置文件如何影响所有模块
主要运行配置集中在:
tst-pharma-admin\src\main\resources\application.yml tst-pharma-admin\src\main\resources\application-dev.yml这里配置:
端口 数据库 Redis MyBatis Sa-Token 多租户 日志 SSE 上传目录虽然配置文件在admin,但 Spring 启动后创建的是一个统一环境。
因此:
system 模块可以使用数据源 chat 模块可以使用数据源 workflow 模块可以使用 Redis common-sse 可以读取 SSE 配置 common-satoken 可以读取认证配置它们不需要各自维护一套application.yml。
可以理解成:
admin application.yml │ ▼ Spring Environment ├─ system 使用 ├─ chat 使用 ├─ workflow 使用 ├─ aiflow 使用 └─ common 组件使用十三、数据库和 Redis 也是模块之间的公共基础设施
当前主要业务模块运行在同一个应用中,并共享:
MySQL Redis 登录状态 租户上下文 事务管理器 SSE 连接管理例如一次聊天请求可能发生:
chat 模块 ├─ 从 chat_model 表查询模型配置 ├─ 从 chat_message 表查询历史消息 ├─ 从 knowledge_info 表查询知识库配置 ├─ 使用 Redis/登录上下文获取认证信息 └─ 使用 SSE 向当前用户发送结果但要注意:
共享同一个数据库,不代表模块应该随意直接修改其他模块的表。
正常情况下应优先:
模块 A → 调用公共接口 → 模块 B 的 Service → 模块 B 的 Mapper → 模块 B 负责自己的表而不是:
模块 A → 直接拿模块 B 的 Mapper → 随意修改模块 B 的表否则模块边界会再次被破坏。
十四、用聊天请求看模块之间如何真正协作
一次:
POST /chat/send背后会经过很多模块。
客户端请求 │ ▼ common-web / common-satoken ├─ Web 请求处理 ├─ 登录认证 └─ 用户上下文 │ ▼ tst-pharma-chat ├─ ChatController ├─ ChatServiceFacade ├─ 场景分类 └─ 模型调用 │ ├──────────────┐ ▼ ▼ common-sse common-chat SSE连接和事件 公共请求对象和接口 │ │ │ ├─────────────┐ │ ▼ ▼ │ aiflow chat实现 │ AI流程启动 模型聊天实现 │ ▼ 浏览器逐段接收回复如果是普通聊天:
ChatController → ChatServiceFacade → ChatModelService → ChatServiceFactory → 模型供应商实现 → LangChain4j → 外部模型接口 → common-sse → 前端如果是 AI 工作流:
ChatController → ChatServiceFacade → IWorkFlowStarterService → aiflow 中的 WorkflowStarter → AI 工作流节点执行 → 需要模型时调用 IChatService → chat 中的 ChatServiceFacade → 模型这就是多个模块之间的真实协作。
十五、extend为什么没有被直接装进admin
根 POM 聚合了:
tst-pharma-extend所以执行根 Maven 构建时,会一起构建:
monitor-admin snailjob-server但是admin/pom.xml没有把它们作为主业务依赖引入。
这说明:
根 POM 聚合它们 ≠ admin 运行时包含它们它们更可能是独立启动的扩展服务:
主后端 └─ tst-pharma-admin 监控服务 └─ tst-pharma-monitor-admin 任务调度服务 └─ tst-pharma-snailjob-server所以再次强调:
聚合 └─ 一起构建 依赖 └─ 编译和运行时使用十六、当前项目的核心模块关系图
可以先保存这张图:
tst-pharma-main-backend │ ├─ tst-pharma-common │ ├─ common-core │ │ └─ 最底层公共对象、异常、工具 │ │ │ ├─ common-redis │ │ └─ 依赖 common-core │ │ │ ├─ common-satoken │ │ └─ 依赖 common-core、common-redis │ │ │ ├─ common-mybatis │ │ └─ 依赖 common-core、common-satoken │ │ │ ├─ common-sse │ │ └─ 依赖 core、redis、satoken、json │ │ │ ├─ common-web │ │ └─ Web 公共能力 │ │ │ └─ common-chat │ ├─ 依赖 core │ ├─ 依赖 sse │ ├─ 依赖 mybatis │ └─ 定义聊天、模型、工作流公共接口 │ ├─ tst-pharma-modules │ ├─ system │ │ └─ 依赖 MyBatis、Web、认证、租户等公共模块 │ │ │ ├─ chat │ │ ├─ 依赖 common-chat │ │ ├─ 依赖 common-sse │ │ ├─ 依赖 common-web │ │ └─ 实现 IChatService、IChatModelService │ │ │ ├─ aiflow │ │ ├─ 依赖 common-chat │ │ ├─ 使用 IChatService │ │ └─ 实现 IWorkFlowStarterService │ │ │ ├─ workflow │ │ └─ 依赖 MyBatis、Web、租户、认证等公共模块 │ │ │ └─ generator │ └─ 依赖 MyBatis、Web、文档、日志等公共模块 │ ├─ tst-pharma-admin │ ├─ 引入 system │ ├─ 引入 chat │ ├─ 引入 workflow │ ├─ 引入 aiflow │ ├─ 引入 generator │ └─ 启动一个完整 Spring Boot 应用 │ └─ tst-pharma-extend ├─ monitor-admin └─ snailjob-server十七、判断“两个模块怎么关联”的固定方法
以后看到两个模块,不要凭目录名猜,可以按下面五步确认。
第一步:看调用模块的pom.xml
例如想知道chat能不能使用common-sse:
打开 tst-pharma-chat/pom.xml 搜索 tst-pharma-common-sse如果有依赖,说明编译时可见。
第二步:看 Javaimport
例如:
importcom.tst.pharma.common.sse.core.SseEmitterManager;说明代码确实使用了该模块的类。
第三步:看字段注入类型
例如:
privatefinalIWorkFlowStarterServiceworkFlowStarterService;说明当前类依赖的是接口。
第四步:查谁实现接口
搜索:
implements IWorkFlowStarterService找到:
aiflow → WorkflowStarter第五步:确认最终启动模块是否同时引入双方
查看:
tst-pharma-admin/pom.xml确认同时依赖:
chat aiflow这样 Spring 运行时才有机会把它们连接起来。
十八、核心结论
结论一
目录相邻 ≠ 有依赖关系真正的编译依赖看:
pom.xml 中的 dependency结论二
根 POM 中有 module ≠ 该模块被装进主应用它可能只是一起构建。
结论三
admin 是主应用组装者它把:
system、chat、workflow、aiflow、generator放进同一个 Spring Boot 应用。
结论四
common 提供公共能力和公共接口 modules 提供具体业务实现结论五
模块之间推荐通过:
公共接口 Spring 依赖注入进行协作,而不是互相直接依赖内部实现。
结论六
当前主体不是微服务,而是:
模块化单体 ├─ 编译时分模块 ├─ 代码职责分模块 └─ 运行时主要在同一个 Spring Boot 进程最典型的真实关系就是:
ChatServiceFacade │ │ 实现 ▼ IChatService(common-chat) ▲ │ 使用 │ WorkflowUtil(aiflow)以及反方向:
WorkflowStarter(aiflow) │ │ 实现 ▼ IWorkFlowStarterService(common-chat) ▲ │ 使用 │ ChatServiceFacade(chat)它们由admin同时组装,再由 Spring 在运行时连接。这个例子基本涵盖了整个项目模块化设计的核心。
总结
本文围绕“解释模块为什么能互相使用,以及 Maven、Spring 和公共接口分别承担什么职责”,主要分析了:
- 父子、聚合和依赖三种 Maven 关系的边界;
- 启动模块如何完成业务模块的最终组装;
- Spring 如何跨 JAR 扫描 Bean 并按接口注入实现;
- 公共接口桥梁如何控制依赖方向并避免循环依赖;
整个过程可以概括为:
根 POM 聚合 → 子模块声明依赖 → Java 使用公共接口 → Spring 扫描实现类 → admin 统一装配 → 运行时完成调用Maven 解决“编译时能不能看见”,Spring 解决“运行时由谁来实现”,公共接口解决“依赖应该朝哪个方向”。三者缺一不可。
当前进度
[OK] 已经完成
- 已完成主要模块 POM 依赖方向的静态核对;
- 已通过接口定义、实现类和启动模块还原聊天模块与工作流模块的协作关系;
[TODO] 后续继续
- 下一篇对比普通 CRUD 分层与 AI 对话编排分层;
- 继续结合真实请求理解模块协作在运行时如何落到方法调用;
小鱼点睛
Maven 解决“编译时能不能看见”,Spring 解决“运行时由谁来实现”,公共接口解决“依赖应该朝哪个方向”。三者缺一不可。
下一篇
下一篇将继续分析“AI 智能客服为什么不能照搬传统三层架构:CRUD 与 AI 编排分层对比”,把本文建立的结构认知继续落到具体代码和对象流上。
这篇文章是我在真实项目学习过程中的阶段性记录。不同项目的命名和目录可能不同,但判断职责边界、依赖方向和数据生命周期的方法可以复用。如果内容中还有遗漏,欢迎一起交流。