Spring 启动与 AI 摘要请求链路
Spring 原理笔记:从 AI 微服务看 Bean 启动与一次摘要请求
目标:看懂本项目启动时 Spring 到底做了什么,以及一次
POST /api/v1/ai/files/summary如何从 HTTP 请求走到 PostgreSQL / 大模型再返回。本文基于networkdisk-business/networkdisk-ai的真实代码。
5. 项目启动阶段:Bean 扫描与自动装配全流程
5.0 先建立三个最重要的概念
Spring IoC 容器可以先理解为一个“对象工厂 + 对象目录”。以前我们自己 new AiApplicationServiceImpl(...),还要手工准备它依赖的九个对象;Spring 则负责:发现哪些对象要创建、按规则创建、把依赖塞进去、保存并管理它们的生命周期。
- Bean:交给 Spring 容器管理的对象。例如
AiController、AiApplicationServiceImpl、PgDocumentResultStore、JdbcTemplate。 - BeanDefinition(Bean 定义):Bean 的“说明书”,记录类名、作用域、构造方式、条件、依赖关系等。它不是对象本身。
- ApplicationContext:日常所说的 Spring 容器。它在
BeanFactory(创建 Bean 的核心能力)上增加了配置、事件、国际化、资源加载等能力。
一个必须分清的事实:扫描、注册定义、实例化是三件不同的事。
读取配置 / 扫描 classpath
↓
得到候选类,按条件注册 BeanDefinition(尚未 new 对象)
↓
refresh 阶段实例化非 lazy 的单例 Bean,并完成注入与初始化
↓
Web Server 就绪,开始接收 HTTP 请求
5.1 Spring Boot 是从哪里开始的
AI 服务的启动类(通常带 @SpringBootApplication)启动后,Spring Boot 会创建 ApplicationContext 并调用 refresh()。@SpringBootApplication 是三个常用能力的组合:
@SpringBootConfiguration // 本质上也是 @Configuration:声明这是配置入口
@EnableAutoConfiguration // 导入 Spring Boot 与依赖库提供的自动配置
@ComponentScan // 扫描启动类所在包及其子包
本项目中 com.disk.ai 下的 Controller、Service、基础设施实现会在组件扫描范围内。与此同时,Spring Boot 会从依赖中的自动配置清单导入配置,例如 Web MVC、Jackson、校验、JDBC 等。因此 ObjectMapper 并不是本项目手写的 @Component,而是 Jackson 自动配置按条件提供的 Bean。
企业项目排查启动问题时,第一步不是猜“Spring 为什么没注入”,而是先问:该类是否在扫描范围内?对应 BeanDefinition 有没有被注册?
5.2 第一步:扫描候选组件,并按条件注册 BeanDefinition
@Component、@Service、@Controller、@RestController 都是组件标记;后面三个本质上是带语义的 @Component。扫描器读取 class 元数据,不需要先实例化每个类。
本项目主要有两类来源:
| 来源 | 项目例子 | Spring 做什么 |
|---|---|---|
| 组件扫描 | @RestController、@Service、@Component |
扫描到类后注册其 BeanDefinition |
| Java 配置 | PgVectorConfiguration 的 @Bean 方法 |
注册“调用这个工厂方法得到 Bean”的 BeanDefinition |
条件装配的准确时机与作用
条件注解并不是“在扫描包之前过滤所有类”。更准确地说:Spring 找到候选组件或配置类后,会在解析、注册 BeanDefinition 的阶段评估条件;条件不成立,该定义不会进入容器,后续自然没有对象可注入。
项目的关键条件如下:
@ConditionalOnProperty(
name = "com.disk.ai.pgvector.enabled",
havingValue = "true"
)
它用于 PgVectorConfiguration、PgVectorVectorStore、PgDocumentResultStore。只有配置 com.disk.ai.pgvector.enabled=true,才会注册 pgvector 的数据源、JdbcTemplate、向量库和摘要/标签结果库。关闭开关时,这一整套真实实现不会加载。
@ConditionalOnMissingBean(AiProviderClient.class)
它用于 MockAiProviderClient。语义是“当前上下文中没有 AiProviderClient Bean 时才注册我”。本项目同时有:
@ConditionalOnProperty(name = "com.disk.ai.provider.type", havingValue = "openai-compatible")
class OpenAiCompatibleAiProviderClient implements AiProviderClient { ... }
当 provider.type=openai-compatible 时,真实客户端先满足条件并注册;Mock 因为容器已有接口实现而不注册。没有真实实现时,Mock 成为兜底,服务仍可启动、联调也不会因缺模型密钥直接失败。
这是一种很实用的企业设计:业务层依赖稳定接口,环境用配置选择基础设施实现。
还要注意一个边界:如果最终同时存在两个 AiProviderClient,按类型注入会产生歧义,启动报 NoUniqueBeanDefinitionException。解决办法是让条件互斥、加 @Primary 指定默认实现,或在注入点加 @Qualifier 指名要哪个;不能靠“Spring 随机选一个”。
如何确认条件为什么没生效
启动时增加 --debug,Spring Boot 会输出 Condition Evaluation Report,说明某自动配置或条件 Bean 是 matched 还是 did not match。这比盲目加 @Autowired、反复重启更可靠。也要检查 profile:最终生效的可能是 application-prod.yml,而不是你正在看的默认 application.yml。
5.3 第二步:绑定配置属性
PgVectorProperties 把 yml 的 com.disk.ai.pgvector 绑定成 Java 对象,例如 URL、用户名、密码、连接池大小、向量维度、init-schema。
com:
disk:
ai:
pgvector:
enabled: true
init-schema: true
dimension: 768
配置属性的价值不是“少写 environment.getProperty”,而是把分散字符串变成有类型、可校验、可注入的配置对象。企业代码通常还应配合 @Validated 和 @NotBlank / @Min 等约束,让缺 URL、非法维度在启动期失败,而非在线上第一次请求才失败。
配置优先级也很重要:命令行参数、环境变量、外部配置、profile 配置都可能覆盖 application.yml。所以“代码明明写 enabled=true,却没有创建 Bean”常常是部署环境覆盖了属性。
5.4 第三步:@Configuration + @Bean 创建底层基础设施
PgVectorConfiguration:
@Configuration
@ConditionalOnProperty(name = "com.disk.ai.pgvector.enabled", havingValue = "true")
public class PgVectorConfiguration {
@Bean(name = "pgVectorDataSource")
public DataSource pgVectorDataSource(PgVectorProperties properties) { ... }
@Bean(name = "pgVectorJdbcTemplate")
public JdbcTemplate pgVectorJdbcTemplate(
@Qualifier("pgVectorDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
@Bean 适合第三方类或需要显式构造过程的对象:HikariDataSource、JdbcTemplate 都不能在其源码上加本项目的 @Component,且必须先设置连接参数。
这里构造器(准确说是工厂方法)的参数也会被 Spring 注入:创建 pgVectorJdbcTemplate 前,Spring 先解析出它需要名为 pgVectorDataSource 的 DataSource,没有这个前置依赖就不会调用方法。
HikariDataSource 是连接池:它复用数据库连接,避免每条 SQL 都建立 TCP/JDBC 连接。JdbcTemplate 在此之上负责执行 SQL、绑定 ? 参数、关闭 Statement/ResultSet 等样板资源;它不是 ORM,不会自动把业务对象永久追踪到数据库。
为什么要 @Qualifier("pgVectorDataSource") / @Qualifier("pgVectorJdbcTemplate")?大型项目常有主业务库、读库、向量库。按类型可能找到多个 DataSource 或 JdbcTemplate,限定名称是为了明确路由,防止 AI SQL 误落到主库。
5.5 第四步:创建 PgDocumentResultStore,看懂构造器注入
PgDocumentResultStore 是组件扫描得到的 BeanDefinition,条件满足后创建:
@Component
@ConditionalOnProperty(name = "com.disk.ai.pgvector.enabled", havingValue = "true")
public class PgDocumentResultStore implements DocumentResultStore, InitializingBean {
public PgDocumentResultStore(
PgVectorProperties properties,
@Qualifier("pgVectorJdbcTemplate") JdbcTemplate jdbcTemplate,
ObjectMapper objectMapper) { ... }
}
Spring 选择它唯一的构造器,并逐个解析参数:
PgVectorProperties:按类型取已绑定的配置 Bean。JdbcTemplate:因有@Qualifier,精确取名为pgVectorJdbcTemplate的 Bean。ObjectMapper:按类型取 Spring Boot Jackson 自动配置提供的 Bean。- 三个参数齐备后才反射调用构造器,得到
PgDocumentResultStore实例。
这叫构造器注入。它优于字段注入:依赖不可变(可用 final)、对象创建即完整、单元测试可以直接 new 并传入 fake/mock 依赖,循环依赖也更早暴露。
Bean 生命周期:初始化钩子究竟何时执行
一个典型单例 Bean 的简化生命周期:
实例化(调用构造器)
-> 依赖注入 / 属性填充
-> BeanPostProcessor 前置处理
-> @PostConstruct
-> InitializingBean.afterPropertiesSet()
-> 自定义 initMethod
-> BeanPostProcessor 后置处理(可能返回 AOP 代理)
-> 可被其他 Bean 正常使用
PgDocumentResultStore 实现 InitializingBean,所以注入完成后 Spring 调用 afterPropertiesSet()。当 init-schema=true 时,方法执行 initializeSchema(),创建 ai_document_result 表和索引。它保证表在请求到来前准备好。
实践建议:项目现有写法可用;新业务代码更常用 @PostConstruct,以避免业务类直接依赖 Spring 接口。两者都不应用于耗时很长、可失败重试的大任务,否则会拖慢甚至阻断启动;这类任务应使用迁移工具(如 Flyway/Liquibase)或明确的初始化作业。
5.6 第五步:接口实现、兜底实现与依赖倒置
业务层依赖的是:
private final AiProviderClient aiProviderClient;
private final DocumentResultStore documentResultStore;
而非 OpenAiCompatibleAiProviderClient 或 PgDocumentResultStore。这就是依赖倒置:高层业务编排只依赖抽象,低层基础设施实现抽象。
AiApplicationServiceImpl
│ 依赖接口
├── AiProviderClient ← OpenAiCompatibleAiProviderClient / MockAiProviderClient
└── DocumentResultStore ← PgDocumentResultStore / DisabledDocumentResultStore
当 pgvector 关闭,DisabledDocumentResultStore 由 @ConditionalOnMissingBean(DocumentResultStore.class) 兜底注册。业务层仍然能注入一个 DocumentResultStore,但它的 isReady() 返回不可用,业务逻辑自然跳过缓存。这比在服务中到处写 if (pgvectorEnabled) 更集中、更可测试。
5.7 第六步:创建业务服务与 Controller
AiApplicationServiceImpl 标记为 @Service,使用 Lombok @RequiredArgsConstructor。Lombok 在编译期生成包含全部 final 字段的构造器;运行时 Spring 只看到一个普通构造器,并自动注入其中依赖。
它的依赖包括模型客户端、模型/索引配置、源文件加载器、Tika 解析器、分块器、嵌入客户端、向量库、结果库。Spring 不会机械地“严格从底到顶创建全部 Bean”;默认非懒加载单例会在容器 refresh 的预实例化阶段创建,具体顺序由依赖图决定:谁需要谁,谁就会先被创建。不要依赖偶然的初始化顺序。
接着 AiController 被发现:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/ai")
public class AiController {
private final AiApplicationService aiApplicationService;
}
Spring 注入 AiApplicationServiceImpl 后,Spring MVC 还会读取 @RequestMapping、@PostMapping 等注解,建立请求映射。@RestController = @Controller + @ResponseBody:返回对象默认由消息转换器序列化为 JSON。
**启动完成不等于所有对象一定都已完全创建。**默认单例通常已预实例化;但 @Lazy Bean、prototype Bean、某些框架按需创建的对象会在首次使用时创建。这个区别解释了某些错误为何“启动没报错、首次调用才报”。
5.8 容器、单例与线程安全:企业开发必须知道的边界
- Spring 默认 scope 是 singleton:一个 ApplicationContext 中每个 Bean 名称通常只有一个实例,不是 JVM 全局单例,也不是每个请求一个实例。
- Controller/Service 单例会并发处理很多 HTTP 请求,因此不要把
userId、本次请求的文件内容等可变状态放在成员变量。请求数据必须放局部变量、方法参数或请求作用域对象中。 JdbcTemplate可安全复用;连接是从 DataSource 池中按次借用的,不要把 JDBCConnection存为单例字段。- AOP(如
@Transactional、权限、日志)常给 Bean 包上一层代理。调用方拿到的可能不是原对象,而是代理;代理在方法调用前后织入事务等横切逻辑。 - 若某个 Bean 有两个实现且没有
@Qualifier/@Primary,启动失败是好事:它避免了生产环境不可预测地选错实现。
5.9 启动阶段一张总图
application.yml / 环境变量 / profile
│
▼
SpringBootApplication → ApplicationContext.refresh()
│
├─ 组件扫描:Controller / Service / Component
├─ 解析 @Configuration 与 @Bean
├─ 条件评估:pgvector、provider 类型、MissingBean
├─ 注册 BeanDefinition
│
▼
PgVectorProperties → pgVectorDataSource → pgVectorJdbcTemplate
│ │
└──────────→ PgDocumentResultStore ─┐
OpenAI/Mock AiProviderClient ────────────────────────┤
VectorStore / Tika / Loader / EmbeddingClient ───────┤
▼
AiApplicationServiceImpl
▼
AiController
▼
Spring MVC 注册 /api/v1/ai/** 映射
6. 运行阶段:一次“生成文档摘要”请求的完整调用链路
6.0 入口与真实路径
请求为:
POST /api/v1/ai/files/summary
Content-Type: application/json
{
"fileId": "前端加密后的文件 ID",
"filename": "合同.pdf",
"prompt": ""
}
注意:项目真实映射是 /files/summary,不是 /summary。网关环境中还会先经过网关的 Path=/api/v1/ai/** 路由,转发给 networkdisk-ai;直连服务时则直接由 8087(以实际配置为准)接收。
6.1 Web 容器、DispatcherServlet 与参数绑定
请求进入内置 Tomcat 后,Spring MVC 的前端控制器 DispatcherServlet 接管:
HTTP 请求
→ Filter(鉴权、日志、跨域等,具体以项目配置为准)
→ DispatcherServlet
→ HandlerMapping 找到 AiController.summarize
→ HandlerAdapter 调用方法
→ HttpMessageConverter 将 JSON 转为 DocumentSummaryParamVO
→ Bean Validation 校验 @Valid
Controller 方法:
@PostMapping("/files/summary")
public Result<DocumentSummaryVO> summarize(
@Valid @RequestBody DocumentSummaryParamVO request) { ... }
@RequestBody:让 Jackson 从 HTTP body 的 JSON 反序列化请求对象。@Valid:触发DocumentSummaryParamVO上的 Jakarta Validation 约束;不合法请求在进入业务服务前就被拒绝,通常由全局异常处理转为统一错误响应。- 鉴权后的
UserIdUtil.get()从当前请求上下文取用户 ID,不能信任前端传来的 userId,否则用户可伪造身份访问别人的文件。 IdUtil.decrypt(request.getFileId())将对外加密 ID 转成内部userFileId。Controller 负责协议转换和身份补全,不负责摘要业务规则。
Controller 构造内部的 AiDocumentSummaryRequest,调用接口:
AiSummaryData data = aiApplicationService.summarize(summaryRequest);
6.2 应用服务的角色:编排,而不是堆 SQL 或 HTTP
AiApplicationServiceImpl.summarize() 是本场景的用例编排点。它不应知道 HTTP JSON 的细节,也不应把 PostgreSQL SQL 或 OpenAI HTTP 协议散落在 Controller 内。它协调接口:DocumentResultStore、VectorStore、AiSourceFileLoader、TikaDocumentParser、AiProviderClient。
完整的成功链路:
Controller
→ AiApplicationServiceImpl.summarize
→ 默认摘要缓存查询(DocumentResultStore)
├─ 命中:直接返回
└─ 未命中:加载文本 → 调模型 → 保存默认摘要 → 返回
→ Controller 转 DocumentSummaryVO
→ Jackson 序列化 Result<DocumentSummaryVO> 为 JSON
6.3 第一步:缓存资格判断与查询
代码的资格判断:
private boolean canUseStoredSummary(AiDocumentSummaryRequest request) {
return documentResultStore.isReady()
&& request.getUserId() != null
&& request.getUserFileId() != null
&& StringUtils.isBlank(request.getPrompt());
}
只有“结果库可用 + 能定位用户和文件 + 没有自定义 prompt”才允许复用摘要。原因是缓存 key 实际上是 (user_id, user_file_id),并没有包含 prompt;若把“只提取风险项”的自定义摘要保存进去,下一次默认摘要会读到错误语义。
满足资格时调用:
StoredDocumentSummary stored = documentResultStore.getSummary(userId, userFileId);
pgvector 开启时,动态分派到 PgDocumentResultStore.getSummary(),通过 pgVectorJdbcTemplate 执行参数化查询:
select filename, summary_text, summary_model, summary_mocked
from ai_document_result
where user_id = ? and user_file_id = ? and summary_text is not null
JdbcTemplate 把 userId、userFileId 作为绑定参数交给 JDBC,不是字符串拼接,因此避免常见 SQL 注入风险。RowMapper 再把每一行 ResultSet 映射为 StoredDocumentSummary。
表对 (user_id, user_file_id) 有唯一约束,正常最多得到一行。摘要非空则 buildStoredSummaryResponse() 直接返回:不读文件、不调用 Tika、不调用模型、不产生模型费用。这就是应用级结果缓存。
缓存并非“永远正确”:文件内容更新后,旧摘要必须失效。项目在重新建索引时通过 clearDocumentResult(userId, userFileId) 删除旧结果。企业系统还应明确文件版本、模型版本、prompt 版本改变时的缓存策略;否则“缓存命中”可能变成“返回过期答案”。
6.4 第二步:缓存未命中时加载可供模型使用的文本
DocumentPromptContext context = loadDocumentContext(userId, fileId, userFileId);
它优先复用向量库中已解析的文本块:
vectorStore.isReady()且用户/文件 ID 有效时,查该文件是否已有索引摘要。- 有索引时,
loadDocumentChunks()读出已保存的 chunk 文本。 mergeWithLimit()合并文本并按模型允许的最大长度截断,非空则直接作为上下文。
没有索引或向量库不可用时,执行兜底路径:
AiSourceFileLoader.load(...) → 从文件系统/对象存储读取原文件
TikaDocumentParser.parse(...) → PDF、Word、PPT、TXT 等提取纯文本
limitContent(...) → 限制最大字符数
为什么需要截断?模型的上下文窗口按 token 而不是 Java 字符计费,超限会失败或成本过高。字符上限是保守保护措施,不等于精确 token 计算;生产场景要结合具体模型的 token 限额、提示词预留、语言差异进行规划。
之后 enrichFilename() 会在请求缺文件名时,用真实文件名补齐。它影响模型提示词和返回展示,不改变身份授权的来源。
6.5 第三步:通过接口调用真实模型或 Mock
AiSummaryData response = aiProviderClient.summarize(request, context.content());
调用方只见到 AiProviderClient:
OpenAiCompatibleAiProviderClient生效时,它按 OpenAI 兼容协议将摘要提示词和文档文本发往配置的模型 API,解析模型响应,形成AiSummaryData。- 没有真实实现时,
MockAiProviderClient返回可预测的模拟数据,用于本地开发和接口联调。
这里体现多态:同一行调用,在运行时因容器注入的实现不同而执行不同代码。它不是“业务层不知道底层所以没有风险”;真实客户端仍必须处理超时、限流、非 2xx 响应、空响应、敏感信息与成本控制。只是这些基础设施细节被封装在实现类中,业务流程保持稳定。
企业实践中,模型调用是外部网络 I/O:必须配置连接/读取超时、记录可追踪的请求 ID(不能记录完整敏感正文)、区分可重试与不可重试错误,并避免在数据库长事务中等待模型响应。模型“成功返回 HTTP 200”也不等于内容可用,仍要校验结果非空、结构符合预期。
6.6 第四步:结果落库与并发语义
if (canPersistSummary(request, response)) {
documentResultStore.saveSummary(...);
}
只有默认摘要且摘要内容非空时才保存。PgDocumentResultStore.saveSummary() 使用 PostgreSQL UPSERT:
insert into ai_document_result (...)
values (...)
on conflict (user_id, user_file_id)
do update set summary_text = excluded.summary_text, ...
ON CONFLICT 依赖唯一约束,避免“先 select 再 insert”的竞态:两个请求同时发现缓存未命中时,至少不会插入两行。它不能避免两个请求都已经付费调用了一次模型,且后写入者会覆盖前者。若这个成本值得治理,需要增加“同一用户同一文件同一摘要版本”的幂等/请求合并机制或分布式锁;当前代码的最小设计没有做这件事。
标签列表入库时,ObjectMapper 把 List<String> 写为 JSON,再以 jsonb 存储;读取时反序列化回列表。不要手写 JSON 拼接,转义字符和空值会很快造成数据错误。
6.7 第五步:返回 JSON
服务层返回 AiSummaryData。Controller 把它转换为只面向前端的 DocumentSummaryVO,然后:
return Result.success(response);
DispatcherServlet 选择 Jackson 消息转换器,把 Result<DocumentSummaryVO> 序列化成 JSON,Tomcat 写回 HTTP 响应。Data 与 VO 分层的意义是:内部数据结构、用户 ID、基础设施字段不必直接暴露给 API;接口演进也不会强迫业务层跟着改。
6.8 一次请求的时序图
Browser/Gateway AiController AiApplicationService ResultStore/PG File/Tika AI Provider
│ POST /files/summary │ │ │ │ │
│────────────────────>│ │ │ │ │
│ │ 参数绑定、校验、取用户 │ │ │ │
│ │───────────────────>│ │ │ │
│ │ │ getSummary │ │ │
│ │ │─────────────────────>│ │ │
│ │ │<─────────────────────│ │ │
│ │ │ 缓存命中? │ │ │
│ │ │──是:直接返回─────────│ │ │
│ │ │──否:loadDocumentContext │ │
│ │ │───────────────────────────────────────>│ │
│ │ │<───────────────────────────────────────│ │
│ │ │ summarize(文本) ───────────────────────────────────────>│
│ │ │<──────────────────────────────────────────────────────│
│ │ │ saveSummary │ │ │
│ │ │─────────────────────>│ │ │
│ │<───────────────────│ │ │ │
│<────────────────────│ Result<VO> JSON │ │ │ │
6.9 按故障现象定位到哪一层
| 现象 | 优先检查 |
|---|---|
启动报“找不到 AiProviderClient” |
provider.type、实现类条件、包扫描、Condition Evaluation Report |
| 启动报多个同类型 Bean | 是否同时注册真实/Mock 实现;用条件互斥、@Primary 或 @Qualifier |
| 摘要接口 404 | 实际路径是否为 /api/v1/ai/files/summary;网关路由和服务前缀 |
| 请求 400 | @Valid 约束、JSON 字段名、fileId 是否缺失 |
| 每次都调用模型 | pgvector 是否开启、DocumentResultStore.isReady()、是否传了自定义 prompt、文件是否刚重建索引 |
| 数据库表不存在 | init-schema、数据库权限、启动时 afterPropertiesSet() 异常日志 |
| 摘要属于别的用户/文件 | 必查 UserIdUtil.get() 与 fileId 解密/授权链,不能仅依赖前端参数 |
6.10 看完本章应能做什么
你应能清晰区分:条件装配决定“Bean 定义是否存在”;构造器注入决定“实例需要什么”;初始化回调负责“对象就绪前的动作”;Controller 负责 HTTP 协议转换;应用服务负责编排;基础设施实现负责 SQL、文件解析和外部模型协议。
下一步学习时,建议直接打断点跟一次请求:AiController.summarize → AiApplicationServiceImpl.summarize → PgDocumentResultStore.getSummary / loadDocumentContext → AiProviderClient.summarize → saveSummary。同时在 Spring 启动日志中确认真实注入的 AiProviderClient 和 DocumentResultStore 实现。这比背注解更接近企业排障与开发。