选项模式
用强类型选项对象收敛一组相关配置,通过
configure/bind注册,通过IOptions<T>或IOptionsMonitor<T>读取并校验。
为什么需要选项模式
直接注入 IConfiguration 的痛点: 把依赖关系和校验规则都藏进字符串 key 里,拼写错误只能在运行时暴露,构造函数也看不出依赖了哪些 key。以下场景尤其典型:
- 业务代码里散落着
config["chat:apiKey"]、config["chat:model"]这类字符串 key。 - 同一类配置需要多套实例,例如同时接入 OpenAI、Claude、内部模型网关。
- 想把"默认值、绑定、校验、启动前失败"收敛到一条配置链里,而不是散在多个服务里各自判断。
- 写扩展库时,希望调用方看到构造函数签名就知道依赖了哪一组配置。
选项模式的解法: 把一组配置收敛成一个类,再通过 IOptions<T> 或 IOptionsMonitor<T> 注入。构造函数直接暴露依赖的配置类型,读取从字符串 key 变成字段访问,编译器可以静态检查。
class ChatClient {
public init(config: IConfiguration) {
let apiKey = config["chat:apiKey"] // 字符串 key,拼写错误编译器不报错
let model = config["chat:model"]
}
}2
3
4
5
6
class ChatOptions {
public var apiKey: String = ""
public var model: String = "gpt-4o" // 默认值写在字段上,缺少配置也有明确初始值
}
class ChatClient {
public init(options: IOptions<ChatOptions>) {
let apiKey = options.value.apiKey // 字段访问,编译器可检查,构造函数一眼看出依赖
let model = options.value.model
}
}2
3
4
5
6
7
8
9
10
11
选项模式的价值就在于把配置结构、默认值和读取入口都收回到类型里。
快速开始
最小路径只有三步:定义选项类 → 注册并配置 → 从容器读取。
configure<T>() 会注册选项相关服务,并登记一条后续创建 T 时要执行的配置回调。
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*
class ChatOptions {
public var apiKey: String = ""
public var model: String = "gpt-4o"
public var baseUrl: String = ""
}
main(): Int64 {
let services = ServiceCollection()
services.configure<ChatOptions>() { options => // 注册选项服务,并追加一条配置回调
options.apiKey = "sk-xxx"
options.model = "gpt-4o"
options.baseUrl = "https://api.openai.com"
}
let provider = services.build()
let options = provider.getOrThrow<IOptions<ChatOptions>>() // 未注册会立即抛异常
println(options.value.model) // 首次访问 .value 时懒创建,之后始终返回同一实例
return 0
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
原本分散的配置值收敛成一个 ChatOptions,再统一从 IOptions<ChatOptions> 读取。
整个最小流程可以拆成下面三步:
| 步骤 | 做什么 | 用的类型 |
|---|---|---|
| 定义选项类 | 用字段描述一组相关配置,并给出默认值 | 普通类 + var 字段 |
| 注册并配置 | 把选项加入 DI,并写入值或绑定配置节 | ServiceCollection + configure<T>() / OptionsBuilder<T> |
| 读取选项 | 从容器解析默认配置或命名配置 | IOptions<T> / IOptionsMonitor<T> |
下面按这个顺序展开。
核心概念
IOptions<T> 与 IOptionsMonitor<T> 的分工
| 接口 | 适合场景 | 行为 |
|---|---|---|
IOptions<T> | 只有一套默认配置 | 首次读取 .value 时创建并缓存默认名称的对象,之后始终返回同一实例 |
IOptionsMonitor<T> | 同一类型有多套命名配置 | 按名称创建并缓存,每个名称各自独立;可通过 .get(name) 按名称读取,或通过 .currentValue 读取默认名称 |
如果你的系统只有一套 ChatOptions,优先用 IOptions<ChatOptions>。只有当同一结构需要多套实例时,再切到 IOptionsMonitor<ChatOptions>。
选项类的约束
- 选项类需要公开的无参构造函数,因为
OptionsFactory会通过反射(ClassTypeInfo.of<TOptions>().construct())创建实例。 - 需要被绑定或配置的字段应使用
var。bind()会跳过不可变字段——它只写入可变成员。 - 默认值应直接写在字段上。即使缺少配置,对象也会有明确初始值,不会出现空字段。
- 选项对象是 per-name 单例,不要在业务代码里修改选项对象的字段,否则同一名称下所有消费者都会受影响。
validateOnStart 的语义
关键行为
services.build() 不会自动触发启动校验!validateOnStart() 只是"把当前选项加入启动校验列表",并非"立刻开始校验"。你需要手动解析 IStartupValidator 并调用 validate() 才会集中执行。
如果运行在 Host 或 WebHost 启动链路中,启动阶段会自动执行这批校验,无需手动调用。
查看实现细节
addOptions<T>(name)会通过tryAddSingleton注册四个核心服务:IOptions<T>、IOptionsMonitor<T>、IOptionsFactory<T>、IOptionsMonitorCache<T>。- 每次调用
configure、configureAfter、validate,都会在 DI 中追加一条对应的配置或校验服务注册(使用addSingleton,非tryAdd)。 OptionsFactory.create(name)的顺序固定为:反射创建实例 → 运行所有configure→ 运行所有configureAfter→ 运行所有validate。校验失败时抛出OptionsValidationException。IOptions<T>由UnnamedOptionsManager实现,首次访问.value时懒创建并缓存默认名称的对象;IOptionsMonitor<T>由OptionsMonitor实现,按名称走缓存。configureAll注册的是ConfigureNamedOptions(name 为None),在OptionsFactory迭代时通过_name.isNone()无视命名,对所有名称的实例统一生效。
- 选项模式不提供热更新能力,当前源码未内置
IOptionsSnapshot<T>或OnChange回调。
相关依赖
选项模式基于依赖注入运行。是否接入配置系统,决定你要引入哪些包。
| 包名 | 作用 | 什么时候需要 |
|---|---|---|
soulsoft_extensions_injection | 提供 ServiceCollection、IServiceProvider 等 DI 基础设施 | 始终需要 |
soulsoft_extensions_options | 提供 IOptions<T>、IOptionsMonitor<T>、addOptions、validateOnStart 等核心能力 | 始终需要 |
soulsoft_extensions_options_configuration | 提供 bind()、bindConfiguration()、configure<T>(IConfiguration) 等绑定能力 | 只有当选项值来自 IConfiguration 时需要 |
常见用法
代码配置与后置覆盖
最直接的用法是 configure。需要覆盖同一组选项、同一名称上更早写入的值时,再用 configureAfter。
services.configure<ChatOptions>() { options =>
options.apiKey = "sk-xxx"
options.model = "gpt-4o"
}2
3
4
services.configure<ChatOptions>() { // 即使写在 configureAfter 下面,仍会先执行
options => options.model = "gpt-4o"
}
services.configureAfter<ChatOptions>() { options => // 同类型、同名称时,后写的值总能覆盖前面 configure 的结果
options.model = "gpt-4o-mini"
}2
3
4
5
6
7
区分这两个入口的原因是:bind() 等框架能力内部也会追加 configure。configureAfter 总在所有 configure 之后执行,但这个“后执行”只对同一个 TOptions、同一个名称生效;configureAfter<ChatOptions>("openai") 不能覆盖 "claude"。
以同一个 TOptions、同一个名称上的多次注册为例,执行顺序如下:
| 注册顺序 | 回调种类 | 执行时机 |
|---|---|---|
| 1 | configure | 第一批,按注册顺序执行 |
| 2 | configureAfter | 第二批,所有 configure 之后执行 |
如果某段配置只是写固定值,用 configure;当需要覆盖同一组选项、同一名称上更早写入的值时,用 configureAfter。
从配置系统绑定
如果值已经在 IConfiguration 里,优先直接绑定整段配置。
import soulsoft_extensions_configuration.*
import soulsoft_extensions_options_configuration.*
let config = ConfigurationManager()
.addMemory([
("chat:apiKey", "sk-xxx"),
("chat:model", "gpt-4o"),
("chat:baseUrl", "https://api.openai.com")
])
.build()
let services = ServiceCollection()
services.configure<ChatOptions>(config.getSection("chat")) // 字段名与配置节的子键匹配时自动绑定2
3
4
5
6
7
8
9
10
11
12
13
services.addOptions<ChatOptions>()
.bind(config.getSection("chat")) // 先按配置节全量绑定
.configure { options => options.baseUrl = "https://proxy.example.com" } // 再通过 configure 覆盖个别字段2
3
services.addSingleton<IConfiguration>(config) // IConfiguration 必须先注册到 DI
services.addOptions<ChatOptions>()
.bindConfiguration("chat") // 解析选项时才从 DI 获取 IConfiguration 并绑定2
3
这三种写法解决的是同一个目标,只是绑定入口和时机不同:
services.configure<T>(section):最短路径,适合直接把固定配置节绑到选项类型。builder.bind(section):适合“先绑定,再补少量覆盖”。bindConfiguration("path"):适合IConfiguration本身也交给 DI 管理的场景;传空字符串""时会绑定整个根配置。
绑定行为
- 配置节不存在时,字段保留默认值,不会抛异常。
- 类型不匹配时,
bind()跳过该字段(如配置值是"abc"但字段是Int64),同样不抛异常。 bind()只写入var字段,let字段自动跳过。
验证与启动校验
validate 负责定义规则,validateOnStart 负责决定这些规则何时集中执行。
class ChatRetryOptions {
public var maxRetries: Int64 = 3
}
// 定义校验规则,此时不执行
services.addOptions<ChatOptions>()
.configure { options =>
options.apiKey = "sk-xxx"
options.model = "gpt-4o"
}
.validate("API Key 不能为空") { options => !options.apiKey.isEmpty() }
services.addOptions<ChatRetryOptions>()
.configure { options =>
options.maxRetries = 1
}
.validate("重试次数至少为 1") { options => options.maxRetries >= 1 }
let provider = services.build()
let chatOpts = provider.getOrThrow<IOptions<ChatOptions>>()
chatOpts.value // 首次访问 .value 时执行校验,失败抛 OptionsValidationException
let retryOpts = provider.getOrThrow<IOptions<ChatRetryOptions>>()
retryOpts.value // 同理,各自在首次访问时校验2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ChatRetryOptions {
public var maxRetries: Int64 = 3
}
// 注册 ChatOptions 并加入启动校验
services.addOptions<ChatOptions>()
.configure { options =>
options.apiKey = "sk-xxx"
options.model = "gpt-4o"
}
.validate("API Key 不能为空") { options => !options.apiKey.isEmpty() }
.validateOnStart()
// 注册 ChatRetryOptions 并加入启动校验
services.addOptions<ChatRetryOptions>()
.configure { options =>
options.maxRetries = 1
}
.validate("重试次数至少为 1") { options => options.maxRetries >= 1 }
.validateOnStart()
let provider = services.build()
let validator = provider.getOrThrow<IStartupValidator>()
validator.validate() // 一次性集中执行 ChatOptions + ChatRetryOptions 的全部校验,失败汇总到 AggregateException2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
差异只在失败时机:validate 在首次读取 .value 时触发;validateOnStart 让你在应用对外服务前集中执行。它更适合数据库连接串、第三方密钥、监听地址这类“启动前必须正确”的配置。
如果既要注册选项服务又要启用启动校验,可以用 addOptionsWithValidateOnStart 一步完成,它等价于 addOptions + validateOnStart:
services.addOptionsWithValidateOnStart<ChatOptions>()
.configure { options =>
options.apiKey = "sk-xxx"
}
.validate("API Key 不能为空") { options => !options.apiKey.isEmpty() }2
3
4
5
多个选项类型调用 validateOnStart() 时,IStartupValidator.validate() 会依次执行全部校验,并把失败汇总到 AggregateException。
命名选项与统一覆盖
同一个选项类需要多套实例时,用名称区分,而不是复制多个结构完全相同的类。
let services = ServiceCollection()
services.configureAll<ChatOptions> { options => options.baseUrl = "https://proxy.example.com" } // 无视命名,对所有名称的实例都生效
services.configure<ChatOptions>("openai") { options => // 只覆盖与公共默认值不同的字段
options.apiKey = "sk-openai-xxx"
options.model = "gpt-4o"
}
services.configure<ChatOptions>("claude") { options =>
options.apiKey = "sk-ant-xxx"
options.model = "claude-sonnet-4-6"
}
let provider = services.build()
let monitor = provider.getOrThrow<IOptionsMonitor<ChatOptions>>()
let openai = monitor.get("openai") // 每个名称各自缓存,首次 get 时懒创建
let claude = monitor.get("claude") // 与 openai 是同一个 ChatOptions 类型的不同实例2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
configureAll 会无视命名,对所有实例统一生效。但它和普通 configure(name) 一样,仍然受注册顺序影响:后注册的委托可以覆盖前面写入的字段。把公共默认值放前面、命名差异放后面时,公共部分才会先写、差异字段再覆盖。
如果需要在所有命名配置之后统一再覆盖一次,用 configureAfterAll:
services.configureAll<ChatOptions> { options => options.baseUrl = "https://proxy.example.com" }
services.configureAfterAll<ChatOptions> { options => options.model = "gpt-4o-mini" } // 在所有 configure 之后,对所有名称做统一覆盖2
最佳实践
设计注册入口:两个重载
推荐把 ChatClient 的注册入口设计成两个重载:一个无参 addChatClient(),一个带 configureOptions 回调的 addChatClient(configureOptions)。这样模块只负责公开选项扩展点和注册服务,不替调用方决定配置来源。
import soulsoft_extensions_configuration.*
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*
class ChatOptions {
public var apiKey: String = ""
public var model: String = "gpt-4o"
}
class ChatClient {
private let _options: ChatOptions
public init(options: IOptions<ChatOptions>) {
_options = options.value
}
}
extend ServiceCollection {
public func addChatClient(): ServiceCollection {
return addChatClient { _ => }
}
public func addChatClient(configureOptions: (ChatOptions) -> Unit): ServiceCollection {
configure<ChatOptions>(configureOptions)
addSingleton<ChatClient, ChatClient>()
return this
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let services = ServiceCollection()
services.addChatClient { options =>
options.apiKey = "sk-xxx"
options.model = "gpt-4o"
}
let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()2
3
4
5
6
7
8
let services = ServiceCollection()
services.addChatClient()
services.configureAfter<ChatOptions>() { options =>
options.model = "gpt-4o-mini" // 对同一组选项、同一名称总在所有 configure 之后执行
}
let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()2
3
4
5
6
7
8
let config = ConfigurationManager()
.addMemory([
("chat:apiKey", "sk-xxx"),
("chat:model", "gpt-4o")
])
.build()
let services = ServiceCollection()
services.addChatClient { options =>
config.getSection("chat").bind(options)
}
let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()2
3
4
5
6
7
8
9
10
11
12
13
14
这样做的价值是:ChatOptions 的结构和消费方式固定在模块内部;调用方只决定“值从哪来”。后续补校验或新增字段时,接入方式不用重写。
关键配置做启动校验
API Key、数据库连接串、监听地址这类“启动前必须正确”的配置,不要等到首次读取 .value 才暴露错误。用 validateOnStart() 把校验收进注册链,在 build() 之后、对外服务之前集中跑一遍。
services.addOptionsWithValidateOnStart<ChatOptions>()
.configure { options =>
options.apiKey = "sk-xxx"
}
.validate("API Key 不能为空") { options => !options.apiKey.isEmpty() }
.validate("Model 必须在支持列表中") { options =>
["gpt-4o", "gpt-4o-mini", "claude-sonnet-4-6"].contains(options.model)
}
let provider = services.build()
let validator = provider.getOrThrow<IStartupValidator>()
validator.validate() // 所有 validateOnStart 注册的校验在此集中执行,失败抛 AggregateException2
3
4
5
6
7
8
9
10
11
12
命名选项的完整用法见常见用法 → 命名选项与统一覆盖。
常见问题
configureAfter 和再写一次 configure 有区别吗?
有。configureAfter 总是在所有 configure 之后执行,即使注册顺序写在前面也是如此。如果只是追加一个不依赖前置结果的固定值,用 configure 即可。
什么情况下调用 addOptions<T> 还不够,必须把选项类扩展成库的公共 API?
当你写的扩展库希望调用方通过 configure<T>() 或 bind() 传入配置时,应该把相关选项类暴露为公共 API。这样调用方不需要查看源码就能知道有哪些可配置字段;反之,如果选项只供库内部使用,就不必暴露。
相关专题
- 依赖注入 — 选项的注册、配置、读取全程依赖 DI 容器
- 配置管理 — 通过
bind(section)或bindConfiguration("path")将IConfiguration映射到选项对象 - HttpClient —
ChatOptions承载上游地址、API Key,在addHttpClient()注册时通过IOptions<T>读取 - 日志记录 — 日志级别、输出目标等配置通过选项模式注入
- 宿主 — 启动阶段自动执行
validateOnStart校验