源码小说CodeStory
登录注册
上一章

第十五章 · 开箱起馆:Spring Boot 自动配置与 starter

下一章
字体

主题

版式

12,690 字 · 约 32 分钟

第⼗五章 · 开箱起馆:Spring Boot ⾃动配置与 starter"你什么都没写,我就替你把馆开起来了。"——这是⼀个 starter 在 pom.xml ⾥写下⾃⼰名字时的全部台词。

商旅推⻔进来,只想喝⼝茶、换张⽂牒。可他还没坐下,前台掌柜已经在那⼉了,藏经阁的⻔也开着,差役调度房⾥也站好了⼈。他什么都没安排——驿站套装替他把⼀座馆从⽆到有地起好了。

这就是 Spring Boot starter 给 Spring AI 的承诺:约定优先,⼀⾏依赖起⼀座馆。但越是"开箱即⽤",越值得我们追问⼀句:这座馆到底是谁、在什么条件下、悄悄替我们搭起来的?谁⼜能在它搭好之前把它拦下、换成⾃⼰的⼈?

这⼀章,我们只盯住⼀个样本—— OpenAiChatAutoConfiguration ,把"⾃动配置"这件被⽆数博客⼀句话带过的事,拆到⻣头⾥。

⼀、三层之分:馆⼼ / 装配房 / 套装铺先把⼯程的⻣架⽴稳。Spring AI 的设计⽂档把整个仓库切成三层,这不是组织代码的随意分包,⽽是⼀条机器校验的硬边界( design/02-boot-modularity.adoc:9-

44 ):
馆⼼(core): spring-ai-openai 、 spring-ai-model 、 spring-ai-client-chat ……这些不依赖 Spring Boot,能在裸 Spring Framework、甚⾄纯
main() ⾥ new 出来。

装配房(auto-configurations):依赖 Boot,⽤ @AutoConfiguration ⼲装配的脏活。设计⽂档要求它对底层库"classpath 不在则零影响",所有⾮测试依赖都标 optional 。

套装铺(starters):不含⼀⾏代码,只负责把"某个 autoconfig + 传递依赖"聚成⼀包。

最狠的是第三句:Spring AI 2.0 ⽤ maven-enforcer-plugin 的 bannedDependencies 规则,在编译期机器化地禁⽌"core 依赖 boot"( design/02-boot-

modularity.adoc:37-44 )。也就是说,"馆⼼可裸跑"不是⼀句⼝号,是构建⼯具站岗守着的纪律。这⼀层我们下⼀章会专⻔展开,本章只需记住:autoconfig 是馆⼼与Boot 之间那道"装配房",starter 是把装配房和家具⼀起打包的铺⼦。

⼆、套装铺⾥没有家具:starter ⻓什么样打开 OpenAI 的套装铺 spring-ai-starter-model-openai/pom.xml ,你会发现它名副其实地"空"——通篇只有依赖,没有⼀个 .java :

<!-- starters/spring-ai-starter-model-openai/pom.xml:32-79 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-model-openai</artifactId> <!--装配房 -->
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId> <!--馆⼼ -->
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-model-chat-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
</dependencies>

⼀张清单读下来,套装铺的职责清清楚楚:把 OpenAI 这条线要的"装配房 + 馆⼼ + 前台掌柜的装配房 + 起居注的装配房 + 跑 HTTP 的客户端"⼀并请进来。 它⾃⼰不⼲活,它只负责"把该来的⼈都叫⻬"。

🦴 ⻣灰级细节:starter ⾥为什么找不到 spring-boot-starter ?

很多⼈以为 model starter 会显式依赖那个最基础的 spring-boot-starter ——它才是把 spring-boot-autoconfigure 带进来、让 .imports ⽂件⽣效的关键。但这份 pom ⾥压根没有它,只有 spring-boot-starter-webclient 和 spring-boot-starter-restclient 。

奥妙在传递依赖:这两个 web 客户端 starter ⾃⼰就依赖了 spring-boot-starter ,于是 spring-boot-autoconfigure 被间接拉了进来。Spring AI 在这⾥偷了个巧——它知道 OpenAI 这条线⽆论如何都要发 HTTP,索性让 HTTP 客户端 starter 顺⼿把 Boot 的⾃动配置基建⼀并带⼊,省掉⼀⾏显式依赖。

代价是:这条传递链是隐式的。哪天有⼈把 webclient/restclient 这两个 starter <exclusion> 掉、换成别的 HTTP 栈, spring-boot-autoconfigure 可能也跟着没了, .imports 静默失效, OpenAiChatModel 这个 Bean 就凭空消失——⽽错误信息只会是冷冰冰的 NoSuchBeanDefinitionException ,不会有任何⼈告诉你"是因为你拆掉了那条隐式链"。便利与可追溯,这⾥做了⼀次取舍。

三、 .imports :装配房的花名册starter 把装配房请进了 classpath,Boot ⼜是怎么知道"装配房⾥有哪⼏位先⽣该上⼯"的?靠的是⼀张花名册—— META-

INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ( auto-configurations/.../model-
openai/.../AutoConfiguration.imports ):

org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfigurationorg.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfigurationorg.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfigurationorg.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfigurationorg.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfigurationorg.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration

⼀个 OpenAI 装配房⾥,六位"先⽣"各管⼀摊:聊天、嵌⼊、图像、语⾳合成、语⾳转写、内容审核。Boot 启动时扫到这张花名册,就把这六个候选类全部"登记在册"。

注意"登记"不等于"上⼯"。花名册只是把它们送进候选名单——真正决定谁上⼯、谁回家,是每个类身上挂的那⼀串条件注解。这正是下⼀节的主⻆。

四、三段式:⼀个 ChatModel Bean 是怎么炼成的现在,我们终于⾛到本章的主菜—— OpenAiChatAutoConfiguration 的全貌。它短得令⼈意外,却把"⾃动配置"的三段式范式演示得淋漓尽致:

// auto-configurations/.../OpenAiChatAutoConfiguration.java:53-92@AutoConfiguration

@EnableConfigurationProperties({ OpenAiCommonProperties.class, OpenAiChatProperties.class })
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.OPENAI,
matchIfMissing = true)
public class OpenAiChatAutoConfiguration {

@Bean@ConditionalOnMissingBean

public OpenAiChatModel openAiChatModel(OpenAiCommonProperties commonProperties, OpenAiChatProperties chatProperties,
ToolCallingManager toolCallingManager, ObjectProvider<ObservationRegistry> observationRegistry,
ObjectProvider<MeterRegistry> meterRegistry,
ObjectProvider<ChatModelObservationConvention> observationConvention,
ObjectProvider<OpenAiHttpClientBuilderCustomizer> httpClientBuilderCustomizers) {
var resolvedProperties = OpenAiAutoConfigurationUtil.resolveCommonProperties(commonProperties, chatProperties);

// ...构造官⽅ SDK的 sync/async client ...

var chatModel = OpenAiChatModel.builder()
.openAiClient(openAIClient)
.openAiClientAsync(openAIClientAsync)
.options(chatProperties.toOptions())
.toolCallingManager(toolCallingManager)
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
.meterRegistry(meterRegistryToUse)
.build();
observationConvention.ifAvailable(chatModel::setObservationConvention);
return chatModel;
}
}

把这块拆成三段,你就拿到了 Spring AI 所有 model autoconfig 的通⽤配⽅。

第⼀段: @AutoConfiguration + @EnableConfigurationProperties —— 绑定⽂牒条款

@AutoConfiguration 把这个类标成"⾃动配置候选"。 @EnableConfigurationProperties({OpenAiCommonProperties, OpenAiChatProperties}) 把⽤户在

application.yml ⾥写的 spring.ai.openai.* (base-url、api-key、model、temperature……)绑进两个强类型属性对象——⼀个放跨模型的公共条款(base-

url/api-key),⼀个放聊天专属条款(temperature/maxTokens)。

⽤译馆的话说:这⼀段是把客官⼿写在⽂牒上的条款,翻译成驿丞看得懂的标准表格。 commonProperties 与 chatProperties 之后会被

resolveCommonProperties 合并(common 兜底、chat 覆盖),再 chatProperties.toOptions() 成模型选项。
第⼆段: @ConditionalOnProperty(...matchIfMissing=true) —— 选哪位番邦
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, // = "spring.ai.model.chat"
havingValue = SpringAIModels.OPENAI, // = "openai"
matchIfMissing = true)
SpringAIModelProperties.CHAT_MODEL 解析出来是 spring.ai.model.chat ( spring-ai-
model/.../SpringAIModelProperties.java:27 ), SpringAIModels.OPENAI 是字符串 "openai" ( SpringAIModels.java:41 )。

这是设计上最漂亮的⼀笔:同⼀类模型,所有 provider 共⽤同⼀个开关。你 classpath ⾥同时塞了 OpenAI 和 Anthropic 两套 starter,只要spring.ai.model.chat=anthropic ,OpenAI 这条就因条件不满⾜⽽整体不装配,Anthropic 那条上⼯。⼀个属性,在多个互斥的 provider 之间做单选——⼲净、可切换、零代码改动。

✨ 爽点: matchIfMissing = true 的"零配置即⽤"

这四个字是"开箱即⽤"体验的灵魂。它的意思是:这个属性压根没写,也当作匹配。 于是新⼿ demo ⾥只放⼀个 OpenAI starter、 application.yml ⾥只填⼀个api-key,什么 spring.ai.model.chat 都不写,OpenAI 的聊天模型照样起得来。约定替你做了"你⼤概率就是要 OpenAI"这个默认判断。

第三段: @Bean @ConditionalOnMissingBean —— 造模型,且让你能夺权@Bean@ConditionalOnMissingBean

public OpenAiChatModel openAiChatModel(...) { ... }

@ConditionalOnMissingBean 是整套约定优先哲学的"逃⽣⼝":只有当容器⾥还没有同类型 Bean 时,我才造⼀个默认的。 反过来说——只要你⾃⼰ @Bean 了⼀个 OpenAiChatModel (或更宽的 ChatModel ),⾃动配置就识趣地闭嘴,把控制权完整交还给你。约定不是枷锁,是"你不管我才管"。

Bean ⽅法体内,真正的造物逻辑⽤ core 的 OpenAiChatModel.builder() 完成。这⾥要点破⼀处"新旧之变":builder 上挂的是 .openAiClient(...) 和

.openAiClientAsync(...) ——Spring AI 2.0 已经封装了官⽅的 OpenAI Java SDK( com.openai.client.OpenAIClient ),⽽不再是早期那个⼿写的

OpenAiApi 。 装配房的活⼉,从"⾃⼰缝 HTTP 客户端",退化成了"把官⽅ SDK 配好塞进去",这是个值得记⼀笔的演进。

五、 ObjectProvider :软注⼊的艺术第三段⾥最⻅功⼒的,是那⼀串 ObjectProvider<...> 参数。它们对应译馆⾥⼏个"有则⽤、⽆则免"的配套设施:账房( ObservationRegistry 观测)、度量

( MeterRegistry )、⾃定义观测约定( ChatModelObservationConvention )、HTTP 客户端定制器。

为什么不直接注⼊ ObservationRegistry ,⾮要裹⼀层 ObjectProvider ?因为直接注⼊意味着"必须存在",缺了就启动失败。⽽ Spring AI 的 core 类⽴过⼀条规矩:它只依赖 Micrometer 的 ObservationRegistry 接⼝,构造期可以是 NOOP (空实现)。 ObjectProvider 正是把"必须有"软化成"有最好,没有给个安全默认"的⼯具:

.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
getIfUnique(...) :容器⾥有且仅有⼀个 ObservationRegistry 才⽤它,否则退回 ObservationRegistry.NOOP 。于是——你没引⼊ Micrometer,模型照样跑,

埋点变成空操作,缺依赖零影响;你引⼊了 Micrometer 并配了 registry,同⼀⾏代码⾃动把真实账房接上,模型调⽤就被观测包住了。这正是研究笔记⾥那条"core 不依赖 boot、autoconfig 负责把真实 registry 注⼊"的活体证据。

⽽这个被软注⼊的 registry,最终会在馆⼼深处的埋点调⽤点真正发挥作⽤:

// models/spring-ai-openai/.../OpenAiChatModel.java:212-217

ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
ChatCompletion chatCompletion = this.openAiClient.chat().completions().create(request);

// ...

});

observe(() -> { 真正的 HTTP 调⽤ }) ——观测像⼀层透明的纱,把对番邦的实际请求整个包住。装配房软注⼊的那个 registry,在这⾥决定了这层纱是真账本还是空⽓。

⚠ 暗礁⼀: ObjectProvider 软注⼊ ≠ 所有依赖都软

别被那⼀排 ObjectProvider 迷惑,以为 Bean ⽅法的每个参数都是"缺了也⾏"。看第⼀个⾮ Provider 参数:

ToolCallingManager toolCallingManager, //硬注⼊,不是 ObjectProvider!

toolCallingManager 是裸类型直注——它在容器⾥必须存在,缺了这个 Bean,整个 openAiChatModel ⽅法连参数都凑不⻬,启动直接报错。

那它从哪来?来⾃另⼀个装配房 ToolCallingAutoConfiguration ,它⽤ @Bean @ConditionalOnMissingBean 给你兜底造了⼀个带 NOOP 观测的默认

manager( auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java )。也就是说,"差役调度房"的"缺失给默认"发⽣在另⼀处

autoconfig,⽽不是在这个 Bean ⽅法签名⾥。

这是研究笔记需要被订正的⼀处:笔记把 toolCallingManager 也写成了" ObjectProvider 软注⼊、缺失给 NOOP",但源码⾥它是硬注⼊,默认值由ToolCallingAutoConfiguration 在别处提供。结论⼀样安全(⽤户什么都不配也能跑),但机制不同——⼀个是⽅法签名内的 getIfUnique ,⼀个是跨 autoconfig的 @ConditionalOnMissingBean 兜底 Bean。这种细节,正是"⻣灰级"和"差不多"的分⽔岭。

六、同构:VectorStore / Advisor / ChatClient 都是同⼀张配⽅这套三段式不是 OpenAI 聊天的专利,⽽是全⼯程的统⼀范式。换个⼦系统,换汤不换药:

// auto-configurations/vector-stores/.../PgVectorStoreAutoConfiguration.java:49,54

@ConditionalOnProperty(name = SpringAIVectorStoreTypes.TYPE, havingValue = SpringAIVectorStoreTypes.PGVECTOR,
matchIfMissing = true)

// ...@Bean@ConditionalOnMissingBeanPgVectorStore ...

VectorStore 把第⼆段的开关从 spring.ai.model.chat 换成了 spring.ai.vectorstore.type ,选 provider 的逻辑⼀模⼀样; @Bean

@ConditionalOnMissingBean 让你能换⾃⼰的藏经阁。

ChatClient(前台掌柜)的装配房则示范了更精细的条件编排:

// auto-configurations/.../ChatClientAutoConfiguration.java:67,99

@AutoConfiguration(after = ToolCallingAutoConfiguration.class) //排在差役调度房之后

// ...@Bean@ConditionalOnMissingBean

@ConditionalOnBean(ToolCallingManager.class) //有调度房才装⼯具驿丞
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(...) { ... }
@AutoConfiguration(after = ...) 保证装配顺序——前台掌柜在差役调度房就位之后才上⼯; @ConditionalOnBean(ToolCallingManager.class) 则是"先

有调度房,才配那位负责⼯具回路的驿丞"。注意这⾥也印证了⼀条版本演进:⼯具回路已经从 ChatModel 上移到 ChatClient 的 ToolCallingAdvisor ——所以"装⼯具驿丞"这件事,⾃然落在 ChatClient 的装配房⾥。

⼀张配⽅,横扫 model / vectorstore / advisor / chatclient。读懂了 OpenAI 这⼀个,你就读懂了全部。

⚠ 暗礁⼆: matchIfMissing 的默认魔法,对排错不友好

约定优先的甜,在排错时会反噬成苦。设想:你想⽤ Anthropic,却把开关误写成 spring.ai.model.chat=anthropci (拼错了)。因为 matchIfMissing=true 是按"属性值是否等于 openai"判断,你这个拼错的值既不等于 openai 也不等于 anthropic ——结果两边都不装配,你得到⼀个空容器和⼀句NoSuchBeanDefinitionException ,却完全看不出"是因为⼀个拼写错误"。

更隐蔽的是"该装的没装": @ConditionalOnMissingBean 让⾃动配置在你不知情时悄悄退让。某个测试⾥别⼈ @MockBean 了⼀个 ChatModel ,真实的openAiChatModel 就静默缺席,⾏为诡异却⽆任何报错。

还有⼀处需要明确点破的事实订正:研究笔记称 OpenAiChatAutoConfiguration 类上有 @ConditionalOnClass 守 classpath。但翻遍这个类

( OpenAiChatAutoConfiguration.java:53-57 ),类级别只有 @AutoConfiguration 、 @EnableConfigurationProperties 、 @ConditionalOnProperty 三个

注解,并没有 @ConditionalOnClass 。守 classpath 这件事,在这条线上主要靠 starter 的依赖闭合性与"底层库不在则零影响"的⼯程约定,⽽⾮这个类⾃⼰的

@ConditionalOnClass ——对⽐ ChatClientAutoConfiguration 明明⽩⽩挂着 @ConditionalOnClass(ChatClient.class) ( :68 ),就更显出 OpenAiChat

这条的"裸"。

这就是条件装配的双⾯性:它把"装不装、谁来装"这件事从你眼前藏了起来。 顺⻛时是魔法,逆⻛时是⿊箱。理解每⼀条 @Conditional* 的真实判据,是和这套⿊箱和解的唯⼀办法——这也是为什么这⼀章我们要逐个注解地抠。

🎯 三句带⾛

1. 三段式范式:model autoconfig = @AutoConfiguration + @EnableConfigurationProperties 绑属性 →
@ConditionalOnProperty(spring.ai.model.chat=<provider>, matchIfMissing=true) 选 provider → @Bean @ConditionalOnMissingBean ⽤

core Builder 造 Model,VectorStore/Advisor/ChatClient 同构。

2. 软硬之分:观测/度量/约定等配套⽤ ObjectProvider.getIfUnique(NOOP) 软注⼊、缺则零影响;但 ToolCallingManager 是硬注⼊,其默认值由独⽴的ToolCallingAutoConfiguration 以 @ConditionalOnMissingBean 兜底——别误以为 Bean ⽅法每个参数都可缺。

3. starter 不含代码:只聚合 "autoconfig + 馆⼼ core + 传递依赖",靠 AutoConfiguration.imports 花名册让 Boot 登记候选; @ConditionalOnMissingBean 是逃⽣⼝——你⾃⼰ @Bean 任何同类型 Bean,即可整体覆盖默认装配。

承上启下我们⽤⼀整章,把"开箱起馆"这件被⼀句话糊弄过去的事,拆成了花名册、三段条件、软硬注⼊的明细账。但你或许已经隐隐察觉:三段式之所以成⽴,根⼦在那道"馆⼼ /装配房 / 套装铺"的三层切割—— @ConditionalOnMissingBean 能让你夺权,前提是馆⼼本就能脱离 Boot 独⽴成活; ObjectProvider 能软注⼊ NOOP,前提是core 只认 Micrometer 接⼝⽽不认容器。

是时候把镜头从单个装配房拉远,俯瞰整座译馆的总图了。馆⼼、账房、驿⻢、公⽂——这四样东⻄如何分层、为何这样分、Spring AI ⽤什么哲学把它们焊在⼀起⼜彼此解耦?下⼀章「第⼗六章 · 馆⼼·账房·驿⻢·公⽂:三层架构与设计哲学总览」,我们爬到译馆的最⾼处,看清这套架构真正的承重墙。