一、模块之间怎么说话?——三种方案的取舍
上篇讲了 Workforce Hub 选了模块化单体架构,14 个模块跑在同一个进程里。既然所有代码都在一块儿,模块之间要互相通信,第一个直觉当然是直接 import 调方法——简单粗暴,写完就能跑。
但很快就会发现这个路子走不通。
举个例子:考勤模块需要知道某员工今天有没有请假记录,来决定缺勤还是请假。考勤模块可以直接 import 假期模块的 LeaveService 吗?
技术上当然可以。但问题来了——一旦你这么做,考勤模块的编译就依赖假期模块了。以后假期模块改数据结构、改方法签名,考勤模块也得跟着改。更麻烦的是,如果将来想把假期拆成独立服务,考勤模块里散落着一堆对假期模块的直接引用,改都改不完。
我对比了一下能让模块之间通信的三种方案:
直接 import 直接淘汰。MQ 太重——Workforce Hub 目前的规模不需要引入 RabbitMQ 或 Kafka,而且审批→考勤联动需要同步返回结果,异步消息反而麻烦。
SPI 刚好卡在中间:编译时依赖一个接口定义(轻量),运行时注入具体实现(解耦),调用是同步的(满足业务需求)。
二、SPI 怎么用——从接口定义到自动注入
SPI 的全称是 Service Provider Interface,JDK 自带的一套服务发现机制。Spring Boot 在此基础上做了自动注入,用起来更简单。
在 Workforce Hub 里,SPI 模块单独一个包,只放接口定义,不放任何实现代码:
module-spi/src/main/java/com/workforcehub/spi/
├── AttendanceQueryService.java
├── AttendanceRecalculationService.java
├── ImAgentResultCallback.java
├── ImAgentTriggerService.java
├── KnowledgeSearchService.java
├── LeaveQueryService.java
├── OrgQueryService.java
├── WorkflowAgentPreReviewTrigger.java
└── WorkflowAgentResultCallback.java
第一步:在 SPI 模块里定义接口
// 位于 module-spi,不归属任何业务模块
public interface LeaveQueryService {
/**
* 查询某员工在指定时间段内的请假记录
* 考勤模块用这个来判断缺勤/请假
*/
List<LeaveRecord> queryLeaveRecords(String employeeId,
LocalDate start,
LocalDate end);
/**
* 查询某员工的剩余假期天数
*/
int getRemainingDays(String employeeId, String leaveType);
}
第二步:业务模块实现接口
// 位于 module-leave,实现了 SPI 接口
@Service
public class LeaveQueryServiceImpl implements LeaveQueryService {
private final LeaveRecordMapper leaveRecordMapper;
private final LeaveAccountMapper leaveAccountMapper;
@Override
public List<LeaveRecord> queryLeaveRecords(String employeeId,
LocalDate start,
LocalDate end) {
// 用自己的 Mapper 查假期模块的数据库
return leaveRecordMapper.findByEmployeeAndDateRange(
employeeId, start, end);
}
@Override
public int getRemainingDays(String employeeId, String leaveType) {
return leaveAccountMapper.getBalance(employeeId, leaveType);
}
}
第三步:调用方只依赖接口
// 位于 module-attendance,只引用 SPI 接口,不引用 module-leave
@Service
public class AttendanceCalculationService {
// 注入的是 SPI 接口,不是具体实现
private final LeaveQueryService leaveQueryService;
public AttendanceCalculationService(LeaveQueryService leaveQueryService) {
this.leaveQueryService = leaveQueryService;
}
public DailyResult calculate(String employeeId, LocalDate date) {
// 通过 SPI 接口查询请假信息
List<LeaveRecord> leaveRecords =
leaveQueryService.queryLeaveRecords(employeeId, date, date);
if (!leaveRecords.isEmpty()) {
// 今天有请假记录,标记为"请假"而非"缺勤"
return DailyResult.LEAVE;
}
// 否则继续判断是否打卡...
}
}
关键点:考勤模块的代码里,import 不了任何 com.workforcehub.leave 包下的类——编译器级的隔离。它只知道 SPI 接口的存在。
Spring Boot 怎么知道把 LeaveQueryServiceImpl 注入到考勤模块的?因为两个模块都在同一个 Spring 容器里,@Service 注解让 Spring 自动发现了实现类。如果是 split 到不同微服务,把 @Service 换成远程调用代理就行——SPI 接口本身不用改。
三、一个真实案例:请假审批如何触发考勤重算
光讲原理不够,说一个实际跑通的场景。
整个链路的流程是这样的:
员工提交请假申请
↓
审批通过(workflow 模块)
↓
假期余额自动扣除(leave 模块)
↓
触发考勤日结果重算(attendance 模块)
具体到代码层面,审批通过后的处理逻辑大致如下:
// 位于 module-workflow
// 审批通过后的回调处理
@EventListener
public void onApprovalCompleted(ApprovalCompletedEvent event) {
// 第一步:通过 SPI 调用假期模块,扣除假期余额
leaveOperationService.deductLeaveDays(
event.getApplicantId(),
event.getLeaveType(),
event.getDays()
);
// 第二步:通过 SPI 调用考勤模块,触发日结果重算
// 因为请假天数变了,受影响的日子的考勤结果也需要刷新
attendanceRecalculationService.recalculate(
event.getApplicantId(),
event.getStartDate(),
event.getEndDate()
);
}
注意这里 leaveOperationService 和 attendanceRecalculationService 都是 SPI 接口类型,workflow 模块不知道它们的具体实现在哪个包里。
考勤重算里到底做了什么?
最简单的场景:员工张三请了 5 月 8 号的假,申请通过后,考勤模块重算 5 月 8 号的结果。发现这一天本来是"缺勤"(因为没打卡),但现在知道了有请假记录,于是改成"请假"。如果张三当天其实打过卡,那系统需要判断:打卡时间和请假时段是否重叠?重叠了算请假,没重叠算正常出勤。
这个判断逻辑比较复杂,但在考勤模块内部消化就好,不需要让 workflow 或 leave 模块知道。
为什么用 SPI 而不是在这个场景里直接用 @EventListener?
其实审批通过事件是同一个 Spring 容器里广播的,确实可以不用 SPI、直接监听事件。但 SPI 的价值在于:
编译时隔离:所有依赖关系可追溯——你一眼就能看出哪个模块依赖了哪个 SPI 接口,不会出现"A 偷偷引用了 B 的某个 Utils"这种情况
语义清晰:SPI 接口本身就是文档——“考勤模块对外暴露了重算能力”——接口名即契约
为拆分做准备:如果将来 workflow 独立部署,
attendanceRecalculationService.recalculate()这行代码不用改,只需换实现
四、反例:文件中心为什么翻车了
上篇提过,文件中心最初的个人文件和企业文件设计是分开的,但代码写成了强耦合。这里具体展开。
当时的需求很简单:用户上传头像和个人资料附件。我心里的设计是:
PersonalFileService → 处理头像、个人附件
EnterpriseFileService → 处理审批附件、知识库文档
两个服务各自有各自的存储策略——个人文件过期自动清理,企业文件长期保留且有版本管理。
但在写代码的时候,我在 EnterpriseFileService 里处理企业用户的头像上传时,图省事直接调了 PersonalFileService 的 uploadAvatar() 方法。就一行:
// 偷懒的写法——EnterpriseFileService 直接调了 PersonalFileService
this.personalFileService.uploadAvatar(userId, avatarFile);
后来改个人文件的校验逻辑——加了一个文件类型白名单,不包含 PNG 以外的图片格式。结果企业那边的头像上传全炸了,因为企业头像有一部分是 JPG 格式。
我花了半小时 debug 才反应过来——两个从业务上看毫无关系的功能,被一行 import 绑在一起了。
这个翻车经历让我坚定了两个原则:
如果两个域不该有依赖关系,那就真的不能互相 import,哪怕一行代码都不行
SPI 接口要覆盖到模块内部的子域边界——文件中心的个人域和企业域如果也用 SPI 隔离,就不会踩这个坑
五、SPI 的另一层价值:为架构演进留后路
回到架构话题。选模块化单体的同时,我一直在想一个问题:如果将来需要拆微服务,现在的代码能支撑吗?
SPI 的答案是:能。
拆微服务时,最大的痛苦不是写新代码,是改旧代码。如果现有模块之间全是直接 import 调用,拆分时你需要在成百上千处引用里找到所有需要改成远程调用的地方。
有了 SPI 之后,拆分步骤很简单:
拆分前(模块化单体):
module-attendance → 依赖 LeaveQueryService(SPI接口)
→ Spring 自动注入 LeaveQueryServiceImpl
拆分后(微服务):
module-attendance → 依赖 LeaveQueryService(SPI接口,不改)
→ Spring 注入 LeaveQueryServiceHttpClient(新增)
LeaveQueryServiceHttpClient → 通过 HTTP 调用 leave-service
考勤模块的代码一行不动,变的是 Spring 容器里注入的实现类。
这就是所谓"为变更而设计"——你今天做的架构决策,不是用来解决今天的问题的,是用来降低未来变更的成本的。
六、总结
核心收获:
模块通信有三种方式:直接 import(最简单但耦合最紧)、MQ(最解耦但最重)、SPI(居中,适合模块化单体)
SPI 的本质是做接口依赖而非实现依赖——调用方只依赖"你承诺提供什么",不依赖"你怎么实现的"
编译器级别的隔离比"我心里记得"靠谱一万倍——文件中心的翻车就是例子
SPI 是模块化单体最有价值的投资——现在投入的接口设计成本,会在微服务拆分时加倍返还
一个项目当前有 9 个 SPI 接口,覆盖了请假、考勤、组织、Agent、知识库、IM 六个领域
下一步计划:
Agent 模块将通过 SPI 的
WorkflowAgentPreReviewTrigger接入审批预审流程IM 模块将通过 SPI 的
ImAgentTriggerService和ImAgentResultCallback与 Agent 联动
默认评论
Halo系统提供的评论