一、从考勤到Agent平台——模块是怎么长出来的
Workforce Hub 最早只有一个模块:员工考勤。
就是上学期期末做的那个打卡平台,一个 Spring Boot 工程,几张表,CRUD 搞定。说实话,那时候根本不需要考虑什么架构——总共没几个类,全塞一个包都跑得好好的。
变化是从这学期开始的。
老师说可以优化成毕设,我就开始往上加东西。先是加了假期管理(请假总得有个地儿查余额吧),然后又加了审批流程(请假不能自己批自己吧),再然后加了即时消息(审批结果总得通知一下别人吧)。到这里其实还好,都是围绕考勤一条业务线在扩展。
真正让架构"绷不住"的是 Agent 和文件系统这两个模块。
Agent 模块要调用工作流引擎、要查知识库、要发消息——它天然就和好几个模块都有关系。文件系统更麻烦,我一开始心里想的是"个人文件和企业文件分开,用不同的存储策略",代码写出来却是强耦合——改企业文件的上传逻辑,个人文件那边也会受影响。
做文件系统那段时间说实话挺沮丧的。明明设计阶段画图的时候分得清清楚楚,一写代码就不自觉地把它们揉在一起了。后来专门花了一周重构,才把文件系统的个人域和企业域真正拆开。
也就是从那个时候开始,我才真正理解了什么叫"模块边界"——不是你画了线就算分开了,是改 A 模块的时候 B 模块真的不受影响,才算分开了。
二、为什么不是微服务?——一个实用主义者的选择
聊架构选型的时候,我被问过最多的问题就是:为什么不做微服务?
说实话,我也纠结过。现在网上到处都在讲微服务,Spring Cloud、Docker、Kubernetes 这些名词天天在技术社区刷屏。如果你说你用单体架构,总有一种"是不是我不会微服务"的心虚感。
但我最后还是选了模块化单体。不是因为我不会微服务,是因为以项目目前的功能规模,做微服务没有明显的性能收益,反而会带来几个实实在在的问题:
开发周期会明显变长。 微服务不是把代码拆开就完了——你要搞定服务注册发现、远程调用、分布式事务、统一配置中心、链路追踪……这些东西每一个都是时间。一个学期的时间本来就紧,再把这些基础设施全搭一遍,业务代码都不用写了。
运维复杂度。 微服务意味着每个服务独立部署、独立扩容。对于我这个一个人开发的项目来说,光是本地调试就要同时起好几个服务,内存先吃没了。更别说 Docker Compose 编排、日志聚合这些配套设施。
当前规模不需要。 Workforce Hub 目前是单企业部署的,用户量级和并发量都没到单体扛不住的程度。在性能瓶颈没有出现之前,拆微服务属于过早优化——花了巨大的成本,解决了一个你暂时没有的问题。
模块化单体和微服务不是"高级"和"低级"的区别,是在不同约束条件下做的不同选择。你手里有一把锤子,你要钉的是一颗钉子——这时候给你一个液压打桩机,不是更好,是更麻烦。
三、模块化单体怎么玩——SPI 解耦与清晰的模块边界
选了单体不代表不用设计架构。恰恰相反,正因为所有代码都在一个进程里跑,模块之间更容易"不小心黏在一起",架构约束反而要更严格。
3.1 目录级别的物理隔离
每个业务模块有自己独立的包和依赖:
server/src/main/java/com/workforcehub/
├── module-auth/ # 认证模块
├── module-org/ # 组织人员模块
├── module-attendance/ # 考勤模块
├── module-leave/ # 假期模块
├── module-workflow/ # 审批流程模块
├── module-im/ # 即时消息模块
├── module-doc/ # 文档管理模块
├── module-kb/ # 知识库模块
├── module-agent/ # Agent 平台模块
├── module-file/ # 文件中心模块
├── module-notify/ # 通知模块
├── module-hr/ # 员工生命周期模块
├── module-admin/ # 后台管理模块
├── module-connector/ # 外部连接器模块
├── module-common/ # 公共模块
└── module-spi/ # SPI 接口定义
关键设计:业务模块之间不互相 import。比如 module-leave 不会直接依赖 module-attendance 的代码——如果请假需要联动考勤,通过 SPI 接口来通信。
3.2 SPI:让模块通信但不依赖
SPI(Service Provider Interface)是整个架构里我最满意的一个设计决策。它的作用说白了就是:模块之间可以互相调用,但编译时互不依赖。
具体怎么做的:
// 文件位置:module-spi/src/main/java/.../spi/LeaveQueryService.java
// 这是一个接口,定义在 SPI 模块里,不归属任何业务模块
public interface LeaveQueryService {
/**
* 查询某员工在某段时间内的假期使用情况
*/
List<LeaveRecord> queryLeaveRecords(String employeeId,
LocalDate start,
LocalDate end);
}
module-attendance 需要知道请假信息来重算考勤结果,但它不需要知道 module-leave 里的任何实现细节——它只依赖 SPI 接口。module-leave 提供这个接口的实现:
// 文件位置:module-leave/src/main/java/.../spi/LeaveQueryServiceImpl.java
@Service
public class LeaveQueryServiceImpl implements LeaveQueryService {
@Override
public List<LeaveRecord> queryLeaveRecords(...) {
// module-leave 内部的数据库查询逻辑
}
}
然后 Spring Boot 自动发现实现类,注入到考勤模块里。考勤模块的代码里只出现 LeaveQueryService 这个接口,不会出现任何 module-leave 的类名。
这样做的好处:如果将来真的要把假期模块拆成一个独立微服务,我只需要把 LeaveQueryServiceImpl 从本地实现换成远程调用实现,考勤模块的代码一行都不用改。
3.3 目前完成的模块
所有模块都遵循同一个包结构规范:
module-xxx/
├── dto/ # 数据传输对象
├── repository/ # 数据访问接口
├── mapper/ # MyBatis 映射文件
├── controller/ # REST 接口
├── service/ # 业务逻辑
├── spi/ # SPI 接口实现(如果需要)
└── domain/ # 领域实体
到目前为止,认证、组织、文件中心、审批流程、假期、考勤这六个模块已经完成了可运行的闭环。Agent、知识库、即时消息等模块还在开发中。
四、踩过的坑——文件中心的强依赖教训
前面提了一嘴,这里展开讲。
文件中心(module-file)的设计初衷是把个人文件和企业文件分开管理:
个人文件:员工上传的头像、个人资料附件,存在个人空间,用完即删
企业文件:审批附件、知识库文档、公司公告,存在企业空间,长期保留,有版本管理
画图的时候,我画了两个服务:PersonalFileService 和 EnterpriseFileService,各自负责各自的存储策略。看起来很干净。
写代码的时候就出问题了——为了"方便",我在 EnterpriseFileService 里直接调了 PersonalFileService 的方法来处理企业用户的头像上传。“就一行代码的事儿”,我当时这么想。
结果后面改个人文件的上传流程(加了文件类型校验),企业那边的头像上传直接挂了。调试了半天才发现,两个八竿子打不着的功能被一行 import 绑在了一起。
教训:模块解耦不能靠"心里记住",得靠编译器帮你把关。如果两个模块不应该有依赖关系,那就真的不能让它们互相 import。后来我把企业头像上传的逻辑完全重写,不再复用个人文件的服务,两个模块彻底独立。
这也让我更坚定了一开始就设计 SPI 层这个决策——如果文件中心也用 SPI 来隔离个人和企业两个域,就不会犯这个错误。
五、总结
核心收获:
架构选型的第一原则不是"选最先进的",是"选最合适的"——以当前项目的规模,模块化单体是性价比最高的方案
选了单体不代表不做设计——目录隔离和 SPI 解耦是两个关键的架构约束,能防止模块暗中耦合
模块边界不是你画了就算——只有"改 A 模块不影响 B 模块"才算真正解耦。编译器级别的隔离比人脑记住靠谱
如果将来业务体量真的超出了单体的承载能力,拆微服务的成本是可控的——因为 SPI 已经把接口定义好了
下一步计划:
完成 Agent 模块与知识库模块的 SPI 对接
完善文件中心的企业空间版本管理与去重
补 Docker Compose 多服务编排,为可能的拆分做准备
我个人对架构的理解:没有最好的架构,只有合适的架构。当前最适合这个项目的就是模块化单体。如果后续迭代中功能持续膨胀、出现了单体架构扛不住的场景,那我也做好了拆成微服务的准备。架构不是信仰,是工具——哪个好用用哪个。
默认评论
Halo系统提供的评论