第十章 · Runtime 组装(yudao-server 启动与装配)
前面九章把积木一块块拆开看了,这一章把它们按真实启动顺序拼回去。回答三个问题:①启动类怎么找到散布在各 module 的 Bean(
scanBasePackages的魔法);②Filter 链/拦截器按什么顺序串起来;③某个 module 不开会怎样(DefaultController的 404 兜底)。读完你就拥有了”理解整个工程如何跑起来”的那张总装图。
1. 模块概述
yudao-server 是唯一可运行的 Spring Boot 应用模块(其余全是被它引用的库)。它只包含两个类 + 配置文件,真正的逻辑全部来自 yudao-module-* 与 yudao-framework。它负责”把散落的自动配置与业务 Bean 统一收口成一台能启动的应用“。
graph TD
S["YudaoServerApplication"] -->|scanBasePackages| M["cn.iocoder.yudao.module.*"]
S -->|scanBasePackages| FW["cn.iocoder.yudao.framework.* 的 AutoConfiguration"]
M -->|组件扫描| BIZ["各 module-biz 的 Controller/Service"]
FW -->|自动配置| STARTERS["redis/tenant/security/mybatis/web"]
S -->|依赖| POMROOT["根 pom: 仅 system+infra 启用"]
DC["DefaultController"] -->|未启用 module 的 404 兜底| COMMON["CommonResult.error NOT_IMPLEMENTED"]
🔑 关键认知:
yudao-server自己没有”业务”,它是装配器。装配的依据是根pom.xml里<modules>启用了哪些 module——启用则 Bean 能被扫到、接口可用;未启用则DefaultController对它的路径返回”已禁用”提示。
2. 命名体系与易混淆对象对比
2.1 命名规律拆解(装配层级)
| 前缀/命名 | 对象 | 含义 |
|---|---|---|
YudaoServer |
Application |
启动主类 |
Default |
Controller |
未启用 module 的兜底路由 |
XxxAutoConfiguration |
各 Starter 配置类 | 条件化的自动装配(第一章) |
test |
(端点) | 调试用”回显请求”接口 |
命名规律总结:XxxAutoConfiguration 是”框架层约定成俗的装配名”;DefaultController 的”Default”直白表达”兜底默认”。启动与装配体系不搞花活,全是最直白的命名。
2.2 易混淆对象对比:scanBasePackages 装配 vs @AutoConfiguration 装配
| 对比维度 | 组件扫描(scanBasePackages) |
自动配置(@AutoConfiguration) |
|---|---|---|
| 机制 | Spring 扫描 @Component/@Service/@Controller |
Spring Boot 从 META-INF/spring/...AutoConfiguration.imports 加载 |
| 触发 | 启动类指定包 | classpath 存在 + @ConditionalOnXxx 满足 |
| 覆盖对象 | 业务 Bean(module 的 Controller/Service/mapper 无注解配置) | 框架基础设施(RedisTemplate、SecurityFilterChain 等) |
| 一句话区分 | 是”业务 Bean 的招兵买马”;是”框架件的有条件装配”。 |
再对比 DefaultController 的各种 Xxx404(bpm404/mp404/mall404/erp404/...)——它们结构完全一致(同一 @RequestMapping + 返回 NOT_IMPLEMENTED 错误),差异只在路径与提示文案,是”多模块禁用的模板化兜底”。
3. API Signatures
装配与兜底的完整结构:
graph LR
subgraph server["启动"]
APP["YudaoServerApplication.main"] --> RUN["SpringApplication.run"]
RUN --> SCAN["scanBasePackages
${base-package}.server + .module"]
end
subgraph regard["兜底"]
DC["DefaultController"] -->|bpm 路径| NOT["NOT_IMPLEMENTED"]
DC -->|产品/交易/营销| MALL["商城模块"]
DC -->|test PermitAll| ECHO["回显请求"]
end
SCAN --> DC
1 | // —— 启动主类 —— |
关键点:
scanBasePackages里用${yudao.info.base-package}占位符,值来自application.yaml的yudao.info.base-package: cn.iocoder.yudao。所以扫描的是cn.iocoder.yudao.server与cn.iocoder.yudao.module两个包。@SuppressWarnings("SpringComponentScan")是因为 IDEA 无法解析${yudao.info.base-package}这种占位符形式的包名,会误报”找不到组件”。这是”配置驱动包名”的可读性代价。DefaultController的兜底其实不止”404 提示”,/test还是调试利器——@PermitAll免登录,一次把 query/header/body 全部回显到日志。
4. 数据结构深度解析
4.a 结构体存在的理由:DefaultController 的一串 404 方法
为什么需要这样一个”专唱反调”的 Controller?因为根
pom.xml只启用了 system 与 infra 两个 module。若用户引用了 bpm/mall 等未启用模块的前端或脚本,请求/admin-api/bpm/**会直接命中 Spring 默认的 404(纯转发错误页),用户无从知道”这个模块是没引入,还是我配置错了”。DefaultController把每种未启用模块的路径都映射到一个**带说明文案的CommonResult.error**,让报错自解释。
4.b 数据:NOT_IMPLEMENTED 错误码(与第二章错误码体系的衔接)
1 | // GlobalErrorCodeConstants 中的全局错误码之一(code 在全局段 0~999) |
DefaultController 每个方法都复用同一个 NOT_IMPLEMENTED.getCode(),只是 msg 各自带上模块名与”如何开启”的文档链接。(详见 第二章 的错误码三段式设计。)
4.c 字段三层分析表(NOT_IMPLEMENTED 用法)
| “字段” | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
getCode() 复用 501 |
全局统一”未实现”语义,不做多码 | 每个模块一个码,消费端难统一处理 | 各模块自定义码,语义更细但更散(成本高) |
msg 带模块名 + 文档链接 |
让前端/运维一眼知道”哪个模块没开、怎么开” | 只返回”501”,人无从排查 | 返回 404 原样,信息为零 |
4.d 生命周期状态图(一次”请求未启用模块接口”的流转)
stateDiagram-v2
[*] --> 命中: 请求 /admin-api/bpm/user
命中 --> 匹配: DefaultController.bpm404 命中 @RequestMapping
匹配 --> 响应: 返回 CommonResult.error(501, 模块已禁用+文档)
响应 --> 前端展示: 前端按 code!=0 走错误提示
响应 --> [*]
命中 --> 未匹配: URL 不属于任何禁用模块也非业务路径
未匹配 --> 真404: Spring 默认 404
5. 函数逐行精讲
5.a 场景卡片
**类:
DefaultController**(源码:20-95)
- 调用时机:请求打到某个未启用 module 的路径。
- 典型调用者:浏览器/前端直接访问
admin-api/{未启用模块}/**。- 前置条件:目标 module 未加入根 pom 的
<modules>。- 目的:把”模块不存在”翻译成带说明的友好错误,而不是裸 404。
5.b 逐行注释式精讲(取 mall404 这段最典型的)
1 |
|
1 |
|
🔍 一个 module 可能对应多个路径前缀:Mall 一个模块被拆成 product/trade/promotion 三个大脑皮层端点,正因为它是平台里最大的业务模块。
DefaultController把这三个前缀**放进同一个@RequestMapping**,一个方法兜底一个模块。
5.c 场景卡片:test
**函数:
test**(源码:83-93)
- 调用时机:开发期想确认”请求进来了没、带了什么”。
- 典型调用者:curl / 浏览器直接
GET /test。- 前置条件:
@PermitAll免登录,无需 token。- 目的:把 query/header/body 全打日志,快速验证网关转发或参数透传是否符合预期。
1 |
|
6. 关键机制剖析:装配顺序与 Filter 链
6.1 从”包路径”到”可运行”的装配顺序
启动时做了”从粗到细”五件事:
flowchart LR
A[根 pom 决定启用哪些 module] --> B[SpringApplication 扫描 yudao.module]
B --> C[各 Starter 的 @AutoConfiguration 依条件装配框架件]
C --> D[YudaoWebSecurityConfigurerAdapter 拼 SecurityFilterChain]
D --> E[WebMvc/Filter/拦截器按顺序注册]
@AutoConfiguration由@ConditionalOnXxx决定装不装,见 第一章;YudaoWebSecurityConfigurerAdapter@AutoConfigureOrder(-1)最先装,定义 Security FilterChain,见 第四章。
6.2 Filter 顺序(Web 层的执行次序)
多种 Filter/拦截器按责任分工排成一条链,顺序大体如下:
| 顺序 | 组件 | 关键作用 | 出处 |
|---|---|---|---|
| 1 | TenantContextWebFilter |
解析租户写入上下文 | 第六章 |
| 2 | (web 的 api 相关 Filter) | ApiAccessLogFilter 等记录请求 |
第三章 |
| 3 | TenantSecurityWebFilter |
校验租户越权/禁用 | 第六章 |
| 4 | TokenAuthenticationFilter |
校验 token 注入登录态 | 第四章 |
| 5 | 请求进入 Controller | 业务执行 + @PreAuthorize |
第八章 |
🔑 顺序的隐藏语义:
ApiAccessLogFilter必须在TokenAuthenticationFilter之前,因为它靠 request 属性读取 userId(第四章 的分析)——请求结束时能拿到”这个请求由谁发起”。租户 Filter 最早,因为后续所有查询/缓存都要先有租户。
6.3 为什么能”一个 module 不开就只少接口、不影响启动”
根 pom 的 <modules> 直接决定是否把该 module 编译进 jar 并引入 classpath。没引入 → 该 module 的 @AutoConfiguration 与 @Component 自然不装配 → 对应接口不存在 → 由 DefaultController 兜底给 501。这套”模块即开关“设计让平台能按需裁剪业务领域而不破坏启动。
7. 设计决策分析
- 为什么用
scanBasePackages的${...}占位符而不是写死包名? 让包名可配置化——不同部署可改yudao.info.base-package整体搬家。代价是 IDEA 静态扫描会误报组件未找到(故@SuppressWarnings),是”灵活性换可读性”的取舍。 - 为什么未启用模块要”兜底返回 501”而非让 Spring 真 404? 因为”禁用”是主动状态(用户有意从 pom 去掉),不是”路径写错”的偶然 404。主动状态值得一个带指引的错误,让修改者少走弯路;真 404 对生产者无语义。
- 为什么
mall一个模块映射三个路径前缀? 商城领域拆成 商品/交易/营销 三中心,RequestMapping支持数组因此一个方法可覆盖。这既是”模块粒度”的现实,也是DefaultController用数组@RequestMapping保持简洁的原因。
8. 学习检查点
📝 本章小结
yudao-server是装配器:scanBasePackages扫cn.iocoder.yudao.module与.server两个包,把散布的 Bean 收拢。- 根 pom 的
<modules>是模块开关,决定哪些业务接口存在。 DefaultController为未启用模块返回NOT_IMPLEMENTED(501)+说明文案,替代裸 404。- Filter 顺序:租户 → 访问日志 → Token 认证 → Controller,顺序背后有 request 属性传递的依赖。
/test免登录回显 query/header/body,是调试转发的利器。
🤔 思考题
scanBasePackages里@SuppressWarnings("SpringComponentScan")到底在压制什么?如果不写会怎样?(提示:IDEA 静态分析、${...}占位符)参考答案
IDEA 等 IDE 的 Spring 适配器会静态解析包名扫描组件;当包名是
${yudao.info.base-package}这种运行时占位符时,IDE 解析不了,就会高亮”组件扫描找不到类”。压制该告警避免误报噪音。运行时 Spring 会读到占位符真实值正常扫描,所以这只是”障眼 IDE”。为什么
ApiAccessLogFilter必须排在TokenAuthenticationFilter之前?把顺序反过来会出什么问题?(提示:request 属性、userId 读取时机)参考答案
TokenAuthenticationFilter通过setLoginUser把 userId 写进 request 属性;而ApiAccessLogFilter在请求结束时从 request 属性读 userId 记日志。若TokenAuthenticationFilter在前面先执行,认证信息已就位,访问日志能正常读到操作者;反过来(日志 Filter 先执行并在请求结束后才轮到认证),日志读取时 userId 还没写入,记不到操作者。故必须”认证先执行、日志后收尾”。若你新加一个独立业务模块
yudao-module-xxx,想让它在未加入根 pom 时也返回友好提示,需要做什么?(提示:DefaultController的集中式 vs 模块内兜底)参考答案
在
DefaultController加一个@RequestMapping("/admin-api/xxx/**")的新方法,返回NOT_IMPLEMENTED+[xxx 模块 - 已禁用][参考 ... 开启]即可。这种”兜底集中在 server 模块”的做法简单直接——无需在尚未引入的 module 里放兜底类(该 module 若真引入,其真实 Controller 会优先于/**通配命中)。代价是 server 会随着模块增多累积兜底方法,属可接受的集中式管理。