3
0

Spring 启动与 AI 摘要请求链路

2026-06-23
2026-07-23

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 容器管理的对象。例如 AiControllerAiApplicationServiceImplPgDocumentResultStoreJdbcTemplate
  • 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"
)

它用于 PgVectorConfigurationPgVectorVectorStorePgDocumentResultStore。只有配置 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 适合第三方类或需要显式构造过程的对象:HikariDataSourceJdbcTemplate 都不能在其源码上加本项目的 @Component,且必须先设置连接参数。

这里构造器(准确说是工厂方法)的参数也会被 Spring 注入:创建 pgVectorJdbcTemplate 前,Spring 先解析出它需要名为 pgVectorDataSourceDataSource,没有这个前置依赖就不会调用方法。

HikariDataSource 是连接池:它复用数据库连接,避免每条 SQL 都建立 TCP/JDBC 连接。JdbcTemplate 在此之上负责执行 SQL、绑定 ? 参数、关闭 Statement/ResultSet 等样板资源;它不是 ORM,不会自动把业务对象永久追踪到数据库。

为什么要 @Qualifier("pgVectorDataSource") / @Qualifier("pgVectorJdbcTemplate")?大型项目常有主业务库、读库、向量库。按类型可能找到多个 DataSourceJdbcTemplate,限定名称是为了明确路由,防止 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 选择它唯一的构造器,并逐个解析参数:

  1. PgVectorProperties:按类型取已绑定的配置 Bean。
  2. JdbcTemplate:因有 @Qualifier,精确取名为 pgVectorJdbcTemplate 的 Bean。
  3. ObjectMapper:按类型取 Spring Boot Jackson 自动配置提供的 Bean。
  4. 三个参数齐备后才反射调用构造器,得到 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;

而非 OpenAiCompatibleAiProviderClientPgDocumentResultStore。这就是依赖倒置:高层业务编排只依赖抽象,低层基础设施实现抽象。

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 池中按次借用的,不要把 JDBC Connection 存为单例字段。
  • 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 内。它协调接口:DocumentResultStoreVectorStoreAiSourceFileLoaderTikaDocumentParserAiProviderClient

完整的成功链路:

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

JdbcTemplateuserIduserFileId 作为绑定参数交给 JDBC,不是字符串拼接,因此避免常见 SQL 注入风险。RowMapper 再把每一行 ResultSet 映射为 StoredDocumentSummary

表对 (user_id, user_file_id) 有唯一约束,正常最多得到一行。摘要非空则 buildStoredSummaryResponse() 直接返回:不读文件、不调用 Tika、不调用模型、不产生模型费用。这就是应用级结果缓存。

缓存并非“永远正确”:文件内容更新后,旧摘要必须失效。项目在重新建索引时通过 clearDocumentResult(userId, userFileId) 删除旧结果。企业系统还应明确文件版本、模型版本、prompt 版本改变时的缓存策略;否则“缓存命中”可能变成“返回过期答案”。

6.4 第二步:缓存未命中时加载可供模型使用的文本

DocumentPromptContext context = loadDocumentContext(userId, fileId, userFileId);

它优先复用向量库中已解析的文本块:

  1. vectorStore.isReady() 且用户/文件 ID 有效时,查该文件是否已有索引摘要。
  2. 有索引时,loadDocumentChunks() 读出已保存的 chunk 文本。
  3. 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”的竞态:两个请求同时发现缓存未命中时,至少不会插入两行。它不能避免两个请求都已经付费调用了一次模型,且后写入者会覆盖前者。若这个成本值得治理,需要增加“同一用户同一文件同一摘要版本”的幂等/请求合并机制或分布式锁;当前代码的最小设计没有做这件事。

标签列表入库时,ObjectMapperList<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.summarizeAiApplicationServiceImpl.summarizePgDocumentResultStore.getSummary / loadDocumentContextAiProviderClient.summarizesaveSummary。同时在 Spring 启动日志中确认真实注入的 AiProviderClientDocumentResultStore 实现。这比背注解更接近企业排障与开发。