JunoYi 框架实践:Spring Boot 项目为什么拆成 framework、module、server 三层?

JunoYi 框架实践:Spring Boot 项目为什么拆成 framework、module、server 三层?

开篇:一个"越改越怕改"的单体

很多 Spring Boot 项目都有过类似的成长轨迹:最初是一个几十个类的单工程,Controller、Service、Mapper、工具类混在一起也能跑得很快;半年后业务翻倍,包结构开始模糊,工具类里混进了业务判断,公共模块被各处随意依赖;一年后,任何一次"小改动"都要先花半天时间确认影响范围,构建变慢、测试变脆,团队甚至对升级基础依赖产生了恐惧。

这类问题的根源通常不是代码写得差,而是边界没有被显式定义。当所有代码共享同一个编译单元、同一个依赖集合时,编译器不会阻止任何人把业务校验写进工具类,也不会阻止底层代码去 import 上层业务对象。耦合在一次次"顺手一改"中累积,直到系统对变更的响应成本超过业务迭代的收益。

JunoYi(钧逸)相关开源实践给出的解法是把一个 Spring Boot 单体拆成三层:framework(基础框架层)、module(业务模块层)、server(启动服务层),这一思路在技术社区的架构复盘文章《从单体到模块化:我的 Spring Boot 项目为什么拆成 framework、module、server?》中有直接的讨论 1。与此同时,其下游项目 Juno-Yi/task-flow(钧逸研发管理系统)被定位为"面向软件开发团队打造的轻量级任务协作与研发管理平台......支持私有化部署,具备良好的扩展性与二次开发能力" 3,可以说,这套分层正是为"可裁剪、可二次开发、可私有化交付"这类需求服务的。

需要先说明本文的事实边界:

  • 涉及 task-flow 的项目定位、技术栈与目标用户,依据 GitHub 仓库 README 描述与相关技术文章 23;
  • 涉及"framework / module / server 三层"的工程细节,主要依据架构复盘文章 1 与通用的多模块工程实践展开,文中代码均为通用示意,并非从仓库直接摘取,实际包名、模块坐标、构建工具与目录结构以仓库最新内容为准;
  • 由于本次可核实资料未提供各来源的发布时间与热度数据,本文不宣称"近期热点",也不给出 Star 数、性能数字等未经核实的指标。

如果你正被单体工程拖累,或者要基于开源系统做二次开发与私有化交付,接下来的内容会给你三样东西:一套判断代码归属的判据、一套把边界变成硬约束的依赖管理做法,以及一份说清代价的迁移路径。


三层职责:framework、module、server 各管什么

拆分首先要解决的是"什么东西放在哪"。没有判据的分层只是把混乱搬到了多个模块里,甚至更糟------因为跨模块的隐式依赖更难被发现。

framework------基础框架层:与业务无关的一切

framework 层承载的是在任何业务系统里都可能被复用的能力:统一响应体与异常体系、通用工具类、参数校验与注解、分页与数据访问封装、认证鉴权与数据权限这类横切关注点、日志与链路追踪接入、缓存与消息的抽象封装等。

判断一段代码是否属于 framework,有一个很实用的口诀:

这段代码里如果出现"项目、任务、需求、工时"这类业务名词,它就不属于 framework。

framework 的价值在于稳定。它被所有业务模块依赖,任何破坏性改动都会向下传导到全部模块,因此它的变更频率应当是三层中最低的,接口设计要保守,弃用要走兼容期。反过来说,一旦把业务校验塞进 framework,依赖方向就反转了:底层被迫理解上层概念,模块无法被单独复用,其他项目想引入这套基础能力时会被迫连带引入一堆无关业务。

一个典型误用示例(通用示意,非项目实际代码):

java 复制代码
// 反例:业务名词侵入基础框架层
// 该类不应放在 framework,而应下放到 task 业务模块
@Component
public class TaskStatusValidator {

    public boolean canComplete(Task task, User user) {
        // framework 不应该认识 Task、User 这类业务模型
        return task.getAssigneeId().equals(user.getId())
                && !TaskStatus.DONE.equals(task.getStatus());
    }
}

把它改造成通用形态后,才适合留在 framework:

java 复制代码
// 通用示意:framework 只提供与业务无关的校验语义
public interface StateTransitionValidator<T, S> {
    boolean validate(T entity, S targetState, Object operator);
}

业务模块自行实现这个接口,framework 不需要知道"任务"是什么。这就是分层带来的依赖反转收益。

module------业务模块层:一个模块等于一个业务闭环

module 层是业务逻辑的所在地。切分方式应当按业务域,而不是按技术角色。把 Controller 层、Service 层、Mapper 层各自拆成一个模块是常见的错误,它只会把同一业务域的代码分散到多个模块,反而增加跨模块跳转。

以 task-flow 明确提到的能力域为例(任务分配、进度跟踪、协作沟通 3),典型的做法是按"任务、项目/进度、协作消息、成员与权限"这类业务边界来组织模块,具体模块名以仓库实际结构为准。每个业务模块应当自带三样东西:

  1. 对外接口:供其他模块调用的服务契约或事件定义,尽量以接口而非实现类暴露;
  2. 内部实现:Controller、Service、数据访问、领域对象等,对外部不可见;
  3. 自有数据模型:该业务域的实体、DTO、查询对象,避免公共实体大杂烩。

模块之间的调用是直接依赖还是通过接口/事件解耦,需要结合项目实际做法判断 1,但在工程上有两个基本取舍:

  • 直接依赖:简单直观,适合调用关系稳定、方向清晰的场景;缺点是模块间形成编译期耦合,演进时容易牵连。
  • 接口或事件解耦:新增模块不修改既有模块,扩展性好;代价是调试链路变长、事务边界与最终一致性要额外处理。

轻量级系统通常不必上完整的事件驱动架构,把"跨模块调用走接口、跨模块通知走轻量事件"作为约定,已足够覆盖大多数二次开发需求。

server------启动服务层:唯一的装配与出口

server 层是整个系统的装配中心与运行出口,通常包含:启动类、配置文件与环境配置、依赖聚合(把需要的 module 组合进来)、安全与中间件装配、打包与部署脚本。

关键原则是:server 尽量不写业务代码。它只回答"这个部署形态需要哪些模块、如何装配、如何启动"。这样做的直接收益是:更换部署形态(单机、多实例、裁剪部署给不同客户)时,只动 server,不碰业务模块与基础框架。

如果 server 开始堆积业务判断,三层结构就退化为"两个有意义的层加一个垃圾桶",分层的价值会迅速流失。

三层内容判定表

层 放什么 不放什么 变更频率
framework 通用工具、统一响应与异常、通用注解、数据访问封装、认证鉴权与数据权限等横切能力 任何含业务名词的模型、校验、流程 低,需保证向后兼容
module 业务域的接口、实现、数据模型、业务规则 跨业务域的通用工具、启动装配逻辑 中,随业务迭代
server 启动类、配置装配、依赖聚合、打包部署 业务逻辑、业务数据模型 低,随部署形态变化

再看一个正向归属示例(通用示意):

java 复制代码
// 通用示意:这段代码包含"任务"语义,应放在 task 业务模块,而非 framework
@RestController
@RequestMapping("/tasks")
public class TaskController {

    private final TaskAssignmentService assignmentService;

    public TaskController(TaskAssignmentService assignmentService) {
        this.assignmentService = assignmentService;
    }

    @PostMapping("/{taskId}/assign")
    public Result<Void> assign(@PathVariable Long taskId,
                               @RequestBody AssignCommand command) {
        assignmentService.assign(taskId, command.getAssigneeId());
        return Result.success();
    }
}

其中 Result 来自 framework,TaskController 与 TaskAssignmentService 属于 task 模块,server 只需引入该模块即可获得这组接口。


依赖管理:拆分最容易翻车的地方

分层能否长期成立,取决于依赖规则是否被强制执行。口头约定在半年后必然失效,真正的边界必须由构建工具和架构规则守护。

单向依赖原则:server → module → framework

依赖方向应当是单向的:

  • server 依赖若干 module 与 framework;
  • module 依赖 framework,可按约定依赖其他 module 的对外接口;
  • framework 不依赖任何 module 或 server。

为什么不能反向?一旦 framework 依赖某个业务模块,基础层就与特定业务绑死,其他模块无法在不引入无关业务的前提下复用基础能力,"framework 可独立升级"的承诺也随之瓦解。

跨 module 调用的处理方式,常见有两种:一是把公共契约抽取到独立的 api/接口模块,实现模块依赖接口模块而非彼此;二是通过事件机制发布通知,由订阅方自行响应。前者适合强语义调用,后者适合通知类、可异步的场景。取舍标准是:调用方是否必须拿到结果。必须拿到结果就走接口,不需要结果就考虑事件。

版本与依赖收敛

模块一多,最大的隐患是版本漂移:三四十个模块各自声明 Spring 版本、Jackson 版本、工具库版本,最终打包时以某种不确定的方式取值,运行期出现各种"本地正常、服务器报错"。

解决办法是把版本收敛到唯一入口:

xml 复制代码
<!-- 通用示意:父 POM 聚合骨架,坐标为占位符,非任何项目实际坐标 -->
<project>
    <groupId>your-project</groupId>
    <artifactId>your-project-parent</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>framework</module>
        <module>module-task</module>
        <module>module-project</module>
        <module>server</module>
    </modules>

    <dependencyManagement>
        <dependencies>
            <!-- 统一技术栈版本,子模块只声明 groupId/artifactId -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

要点有三条:子模块不写版本号,版本只在父 POM 管理;通过 BOM 或 dependencyManagement 统一第三方依赖;JDK 与 Spring Boot 版本同样只有唯一声明处。task-flow 采用的技术栈为 Vue3 + SpringBoot3 23,这意味着 Java 基线为 17 及以上,具体小版本以仓库构建文件为准。

把边界变成机器可查的规则

即使有了单向依赖原则,仍需要工具在构建期拦截违规引用。通用生态里常见的做法是使用 Maven Enforcer 约束依赖收敛与版本一致性,或使用 ArchUnit 这类架构测试工具断言包之间的依赖规则。这些是通用工程建议,JunoYi 或 task-flow 是否实际使用同类机制,需以仓库内容为准,本文不作断言。

示例(通用示意,非项目实际代码):

java 复制代码
// 通用示意:用架构测试固化分层规则
@AnalyzeClasses(packages = "your.project", importOptions = ImportOption.DoNotIncludeTests.class)
class LayerRulesTest {

    @ArchTest
    static final ArchRule framework_should_not_depend_on_module =
        noClasses()
            .that().resideInAPackage("..framework..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..module..");

    @ArchTest
    static final ArchRule module_should_not_depend_on_server =
        noClasses()
            .that().resideInAPackage("..module..")
            .should().dependOnClassesThat()
            .resideInAPackage("..server..");
}

这类规则的价值在于:新人随手 import 错层时,构建立刻失败,而不是在半年后被发现。分层的收益很大程度上来自这种"机器守门"的确定性。

常见翻车现场

现象 成因 后果 处理思路
framework 反向依赖 module 图省事,把业务校验写进工具类 基础层被业务污染,无法复用与升级 把业务逻辑下放 module,framework 只留抽象接口
module 之间循环依赖 双向调用未抽公共契约 无法单独编译、无法裁剪 抽取 api 模块或引入事件通知,把双向变单向
server 堆积业务逻辑 赶工期,直接在启动模块写功能 分层名存实亡,部署形态无法裁剪 将功能下沉到对应 module,server 只保留装配
版本漂移 各模块自行声明依赖版本 打包结果不确定、环境差异问题 父 POM + BOM 统一管理,构建期禁止子模块写版本

循环依赖一旦出现,最直接的信号是构建工具报出模块间无法解析或类初始化期的相互引用。拆解思路是先找出"谁真正需要谁的结果",把被双方共同依赖的契约抽到更低的层次,让调用变成单向。


从单体到三层:拆分的实施路径

已经运行的单体不能"推倒重来",大爆炸式拆分往往带来漫长的回归周期。更稳妥的做法是分阶段迁移,每一步都保持系统可运行、可测试。

先抽 framework,再切 module,最后瘦身 server

阶段一:现状盘点。 列出所有包与类,按"业务相关 / 业务无关"打标,识别工具类里的业务逻辑、公共实体里的业务字段,画出当前的依赖草图。产出物是一张归属清单与问题清单。

阶段二:抽取 framework。 先把真正业务无关的工具、异常、响应封装、通用配置迁入独立模块,让单体依赖它。这一阶段不改业务语义,风险最低,收益是立刻获得一个可复用的基础层。验收项:编译通过、既有测试全绿、依赖方向正确。

阶段三:切分 module。 按业务域逐个迁移,每次只动一个域,先迁依赖最少的,最后迁依赖最复杂的。迁移过程中如果发现跨域调用,就地决定走接口还是事件,不要留待"以后再说"。

阶段四:server 收口。 把启动、配置、依赖聚合、打包脚本集中到 server,删除原单体工程中的残留装配代码,验证打包产物可以独立启动。

每一步的验收标准

拆分不能以"编译通过"为唯一标准。建议每阶段至少满足:

  1. 可运行:应用能正常启动,核心接口手工冒烟通过;
  2. 可回归:既有自动化测试全部通过,新增边界有测试覆盖;
  3. 依赖方向正确:生成依赖图或运行架构规则检查,确认无反向依赖与循环依赖;
  4. 构建指标不劣化:记录拆分前后的构建耗时、打包体积、启动时间,避免拆分反而拖慢交付。

拆分的代价要说清楚

分层不是免费的午餐。代价至少包括:构建结构复杂度上升,多模块聚合、依赖管理需要专门维护;跨模块联调成本增加,调试时需要在多个模块间跳转;新人理解门槛提高,需要先理解分层约定才能动手;对小项目而言,模块数量带来的管理开销可能超过收益。

因此需要明确什么时候不该拆:项目规模很小(比如几十个类)、业务边界尚未稳定、长期只有一人维护、处于快速验证的原型期。在这些场景下,先把包结构按业务域划分清楚,比过早引入多模块更划算。

用 task-flow 的功能域做一次推演可以看出收益的形状:假设要新增一个"工时记录"业务域,在三层结构下,framework 只需确认是否已有通用支撑(通常零改动),新增 module 实现该域的接口与数据模型,server 引入该模块即可上线。反过来,如果不分层,新功能往往要同时修改工具类、公共实体、配置与多个既有服务,回归范围难以预估。以上为基于分层原则的推演,具体实现方式以项目实际为准。


在 task-flow 上验证:分层如何支撑可扩展性与二次开发

task-flow 是什么

根据仓库公开描述,钧逸研发管理系统(Juno-Yi/task-flow)是面向软件开发团队的轻量级任务协作与研发管理平台,聚焦任务分配、进度跟踪与协作沟通中的效率问题,支持私有化部署,并强调良好的扩展性与二次开发能力,适用于中小型研发团队、外包团队与技术创业团队构建自己的协作体系 3。其技术栈为 Vue3 + SpringBoot3,属于企业级 Web 研发项目与任务协作系统的实现 2。项目同时出现在 Gitee 的 JunoYi 组织页面下 4,并在 OSS Compass 的社区信息仓库中存在社区属性登记文件 5,后者属于开源社区度量与治理范畴,与架构本身无直接关系。

在这个定位下,framework / module / server 的分层不是为了炫技,而是直接服务于两类核心诉求:私有化部署时的低侵入定制 ,以及二次开发时的可控改动范围。下面用四类典型场景说明。

场景一:新增一个业务功能

假设团队要新增"工时记录"能力(推演示例,非仓库现有功能)。在三层结构下,改动分布大致是:framework 不动,或仅在确有通用需求时新增抽象;新建工时模块,包含接口、实现与数据模型;server 引入新模块并补充必要配置。核心收益是framework 零改动,意味着基础层可以持续平滑升级,新功能不会阻塞安全补丁与依赖升级。

场景二:替换或裁剪一个业务模块

私有化交付中常见需求是"客户不要某个功能"。由于 server 负责依赖聚合,裁剪可以表现为:在 server 层不引入对应 module,或提供不同的聚合配置。前提是模块之间遵守前述依赖约定------如果模块间是网状的随意调用,裁剪就会失败。这也是为什么模块间应尽量通过接口与事件通信、而不是互相直接依赖实现类。

场景三:私有化部署与定制

客户环境差异通常集中在配置、鉴权方式、数据权限策略、外部系统对接几个方面。在三层结构中,通用的认证鉴权与数据权限扩展点落在 framework,环境相关的装配与适配落在 server,具体业务规则留在 module。这样客户定制主要发生在 server 层的装配与实现替换,而不是散落在各业务模块内部,升级主干代码时的合并成本显著降低。

需要说明的是,同主题生态中存在关于若依(RuoYi)数据权限 @DataScope 自定义传参的讨论,但本次可核实资料无法确认该方案与 task-flow 存在直接技术关联,因此不作为本文论据,仅提示读者在做数据权限定制时注意"通用能力与业务规则的归属边界"这一共性问题。

场景四:前后端协作边界

后端按业务域切分后,前端按同样的业务域组织 views、api、store 是自然的对应方式:任务域、项目/进度域、协作域各自成组,新增业务域时前后端可以并行开发、独立演进。需要强调的是,task-flow 前端仓库的实际目录结构与构建部署方式(是否提供容器化部署、一键脚本等)在本次资料中未获得可核实细节,建议以仓库与部署文档为准,本文不作具体断言 23。

二次开发场景改动矩阵

二开场景 主要改动层 次要改动层 侵入性
新增业务功能 module(新建) server(引入) 低,framework 通常零改动
裁剪/替换业务模块 server(调整聚合) module(实现替换) 低到中
鉴权、数据权限定制 framework 扩展点实现 server 装配 中,需遵循扩展点契约
部署形态调整 server 无 低
前端新增业务域 对应业务域前端代码 接口契约 低,前后端可并行

这张表真正想表达的是评估开源项目可扩展性的一个实用视角:看某个需求的改动范围是否被限制在少数模块内。如果新增一个小功能需要跨五六个模块大改,说明边界设计存在问题;如果主要落在一个模块加上少量装配,二次开发成本就是可控的。


落地清单与适用边界

拆分前自检清单

在动手拆分前,建议逐条对照:

  1. 是否明确了单向依赖方向:server → module → framework?
  2. 是否用"业务名词判定法"清理了 framework 中的业务逻辑?
  3. 模块是否按业务域切分,而非按 Controller/Service/Mapper 切分?
  4. 每个模块是否有明确的对外接口与内部实现边界?
  5. 跨模块调用是否走接口或事件,是否存在双向依赖?
  6. 版本是否收敛到父 POM 或 BOM,子模块是否禁止自行声明版本?
  7. 是否有构建期或架构测试守卫依赖规则?
  8. 测试是否按模块归属,能否对单个模块独立测试?
  9. server 是否只保留装配与启动职责,业务逻辑是否已下沉?
  10. 是否为二次开发准备了文档:模块清单、扩展点说明、私有化部署指引?

什么情况下不必拆

小规模项目、原型验证期、单人维护、业务边界频繁变化的场景,通常不需要完整的三层多模块结构。此时更务实的做法是:先在单工程内按业务域组织包结构,约定依赖方向,把工具类与业务逻辑分开,等到出现真实的复用、裁剪或多人协作痛点时,再按前述路径拆分。架构演进应当服务于当前的约束,而不是服务于形式上的完整。

后续可延伸的工程议题

三层落地之后,还有几个方向值得持续投入:模块化后的测试策略与契约测试、CI 构建矩阵与增量构建优化、面向二次开发者的文档与示例工程维护。这些议题的共同点是:让边界不仅存在于代码中,也存在于流程与文档中,分层的价值才能在长期协作中沉淀下来。

回到最初的问题:为什么要拆成 framework、module、server 三层?答案不是"分层更好看",而是把"什么能变、什么不能变、谁依赖谁"从口头约定变成工程约束。对 task-flow 这类需要私有化部署与二次开发的轻量级研发协作系统而言,这套结构直接对应着"基础能力可升级、业务能力可裁剪、部署形态可定制"三个现实诉求 3。拆分有代价,但当系统的变更成本开始超过业务迭代速度时,这笔投资通常值得。

参考资料

1 从单体到模块化:我的 Spring Boot 项目为什么拆成 framework、module、server?,CSDN,https://blog.csdn.net/m0_74899094/article/details/165242377

2 企业Web开发:基于 Vue3 + SpringBoot3 的研发项目与任务协作系统设计与实现,CSDN,https://blog.csdn.net/m0_74899094/article/details/165588756

3 Juno-Yi/task-flow:钧逸研发管理系统(轻量级任务协作与研发管理平台),GitHub,https://github.com/Juno-Yi/task-flow

4 JunoYi 组织项目页,Gitee,https://gitee.com/organizations/juno-yi/projects

5 communities/JunoYi.yml · OSS Compass 开源指南针 / compass-projects-information,Gitee,https://gitee.com/oss-compass/compass-projects-information/blob/20260803101238-qiangqiang554-community-property/communities/JunoYi.yml

(说明:以上来源的发布日期在本次采集资料中未提供,文中未作时间性断言;文中代码与目录结构均为通用工程示意,具体实现以对应仓库最新内容为准。)

相关推荐
汉堡大王95271 小时前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
mudtools1 小时前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
Wang's Blog1 小时前
Java 项目实战: 外卖平台优化-从库Slave配置与主从复制验证
java·开发语言
知守观1 小时前
ThreadLocal + 异步线程导致用户数据串号:一次跨请求数据泄漏的完整复盘
后端
前端冒菜师1 小时前
我为什么做了 Iris,又为什么停下了它
后端·ai编程
子一!!1 小时前
集成Spring家族的Spring论坛实战==一阶段
java·后端·spring
Ticnix1 小时前
你的 Agent 聊到第 20 轮就"失忆"?你管理的是历史,高手管理的是上下文
后端·python·agent
不合格的程序员1 小时前
Agent Memory架构设计与实现
后端·ai编程
何中应1 小时前
Maven 执行控制台中文乱码问题
java·maven·intellij-idea