Skip to content

选项模式

用强类型选项对象收敛一组相关配置,通过 configure / bind 注册,通过 IOptions<T>IOptionsMonitor<T> 读取并校验。

为什么需要选项模式

直接注入 IConfiguration 的痛点: 把依赖关系和校验规则都藏进字符串 key 里,拼写错误只能在运行时暴露,构造函数也看不出依赖了哪些 key。以下场景尤其典型:

  • 业务代码里散落着 config["chat:apiKey"]config["chat:model"] 这类字符串 key。
  • 同一类配置需要多套实例,例如同时接入 OpenAI、Claude、内部模型网关。
  • 想把"默认值、绑定、校验、启动前失败"收敛到一条配置链里,而不是散在多个服务里各自判断。
  • 写扩展库时,希望调用方看到构造函数签名就知道依赖了哪一组配置。

选项模式的解法: 把一组配置收敛成一个类,再通过 IOptions<T>IOptionsMonitor<T> 注入。构造函数直接暴露依赖的配置类型,读取从字符串 key 变成字段访问,编译器可以静态检查。

cangjie
class ChatClient {
    public init(config: IConfiguration) {
        let apiKey = config["chat:apiKey"]    // 字符串 key,拼写错误编译器不报错
        let model = config["chat:model"]
    }
}
cangjie
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
    }
}

选项模式的价值就在于把配置结构、默认值和读取入口都收回到类型里。

快速开始

最小路径只有三步:定义选项类 → 注册并配置 → 从容器读取

configure<T>() 会注册选项相关服务,并登记一条后续创建 T 时要执行的配置回调。

cangjie
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
}

原本分散的配置值收敛成一个 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())创建实例。
  • 需要被绑定或配置的字段应使用 varbind() 会跳过不可变字段——它只写入可变成员。
  • 默认值应直接写在字段上。即使缺少配置,对象也会有明确初始值,不会出现空字段。
  • 选项对象是 per-name 单例,不要在业务代码里修改选项对象的字段,否则同一名称下所有消费者都会受影响。

validateOnStart 的语义

关键行为

services.build() 不会自动触发启动校验!validateOnStart() 只是"把当前选项加入启动校验列表",并非"立刻开始校验"。你需要手动解析 IStartupValidator 并调用 validate() 才会集中执行。

如果运行在 HostWebHost 启动链路中,启动阶段会自动执行这批校验,无需手动调用。

查看实现细节
  • addOptions<T>(name) 会通过 tryAddSingleton 注册四个核心服务:IOptions<T>IOptionsMonitor<T>IOptionsFactory<T>IOptionsMonitorCache<T>
  • 每次调用 configureconfigureAftervalidate,都会在 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提供 ServiceCollectionIServiceProvider 等 DI 基础设施始终需要
soulsoft_extensions_options提供 IOptions<T>IOptionsMonitor<T>addOptionsvalidateOnStart 等核心能力始终需要
soulsoft_extensions_options_configuration提供 bind()bindConfiguration()configure<T>(IConfiguration) 等绑定能力只有当选项值来自 IConfiguration 时需要

常见用法

代码配置与后置覆盖

最直接的用法是 configure。需要覆盖同一组选项、同一名称上更早写入的值时,再用 configureAfter

cangjie
services.configure<ChatOptions>() { options =>
    options.apiKey = "sk-xxx"
    options.model = "gpt-4o"
}
cangjie
services.configure<ChatOptions>() {           // 即使写在 configureAfter 下面,仍会先执行
    options => options.model = "gpt-4o"
}

services.configureAfter<ChatOptions>() { options => // 同类型、同名称时,后写的值总能覆盖前面 configure 的结果
    options.model = "gpt-4o-mini"
}

区分这两个入口的原因是:bind() 等框架能力内部也会追加 configureconfigureAfter 总在所有 configure 之后执行,但这个“后执行”只对同一个 TOptions、同一个名称生效;configureAfter<ChatOptions>("openai") 不能覆盖 "claude"

以同一个 TOptions、同一个名称上的多次注册为例,执行顺序如下:

注册顺序回调种类执行时机
1configure第一批,按注册顺序执行
2configureAfter第二批,所有 configure 之后执行

如果某段配置只是写固定值,用 configure;当需要覆盖同一组选项、同一名称上更早写入的值时,用 configureAfter

从配置系统绑定

如果值已经在 IConfiguration 里,优先直接绑定整段配置。

cangjie
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"))  // 字段名与配置节的子键匹配时自动绑定
cangjie
services.addOptions<ChatOptions>()
    .bind(config.getSection("chat"))         // 先按配置节全量绑定
    .configure { options => options.baseUrl = "https://proxy.example.com" } // 再通过 configure 覆盖个别字段
cangjie
services.addSingleton<IConfiguration>(config) // IConfiguration 必须先注册到 DI
services.addOptions<ChatOptions>()
    .bindConfiguration("chat")               // 解析选项时才从 DI 获取 IConfiguration 并绑定

这三种写法解决的是同一个目标,只是绑定入口和时机不同:

  • services.configure<T>(section):最短路径,适合直接把固定配置节绑到选项类型。
  • builder.bind(section):适合“先绑定,再补少量覆盖”。
  • bindConfiguration("path"):适合 IConfiguration 本身也交给 DI 管理的场景;传空字符串 "" 时会绑定整个根配置。

绑定行为

  • 配置节不存在时,字段保留默认值,不会抛异常。
  • 类型不匹配时,bind() 跳过该字段(如配置值是 "abc" 但字段是 Int64),同样不抛异常。
  • bind() 只写入 var 字段,let 字段自动跳过。

验证与启动校验

validate 负责定义规则,validateOnStart 负责决定这些规则何时集中执行。

cangjie
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  // 同理,各自在首次访问时校验
cangjie
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 的全部校验,失败汇总到 AggregateException

差异只在失败时机:validate 在首次读取 .value 时触发;validateOnStart 让你在应用对外服务前集中执行。它更适合数据库连接串、第三方密钥、监听地址这类“启动前必须正确”的配置。

如果既要注册选项服务又要启用启动校验,可以用 addOptionsWithValidateOnStart 一步完成,它等价于 addOptions + validateOnStart

cangjie
services.addOptionsWithValidateOnStart<ChatOptions>()
    .configure { options =>
        options.apiKey = "sk-xxx"
    }
    .validate("API Key 不能为空") { options => !options.apiKey.isEmpty() }

多个选项类型调用 validateOnStart() 时,IStartupValidator.validate() 会依次执行全部校验,并把失败汇总到 AggregateException

命名选项与统一覆盖

同一个选项类需要多套实例时,用名称区分,而不是复制多个结构完全相同的类。

cangjie
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 类型的不同实例

configureAll 会无视命名,对所有实例统一生效。但它和普通 configure(name) 一样,仍然受注册顺序影响:后注册的委托可以覆盖前面写入的字段。把公共默认值放前面、命名差异放后面时,公共部分才会先写、差异字段再覆盖。

如果需要在所有命名配置之后统一再覆盖一次,用 configureAfterAll

cangjie
services.configureAll<ChatOptions> { options => options.baseUrl = "https://proxy.example.com" }
services.configureAfterAll<ChatOptions> { options => options.model = "gpt-4o-mini" }  // 在所有 configure 之后,对所有名称做统一覆盖

最佳实践

设计注册入口:两个重载

推荐把 ChatClient 的注册入口设计成两个重载:一个无参 addChatClient(),一个带 configureOptions 回调的 addChatClient(configureOptions)。这样模块只负责公开选项扩展点和注册服务,不替调用方决定配置来源。

cangjie
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
    }
}
cangjie
let services = ServiceCollection()
services.addChatClient { options =>
    options.apiKey = "sk-xxx"
    options.model = "gpt-4o"
}

let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()
cangjie
let services = ServiceCollection()
services.addChatClient()
services.configureAfter<ChatOptions>() { options =>
    options.model = "gpt-4o-mini"            // 对同一组选项、同一名称总在所有 configure 之后执行
}

let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()
cangjie
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>()

这样做的价值是:ChatOptions 的结构和消费方式固定在模块内部;调用方只决定“值从哪来”。后续补校验或新增字段时,接入方式不用重写。

关键配置做启动校验

API Key、数据库连接串、监听地址这类“启动前必须正确”的配置,不要等到首次读取 .value 才暴露错误。用 validateOnStart() 把校验收进注册链,在 build() 之后、对外服务之前集中跑一遍。

cangjie
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 注册的校验在此集中执行,失败抛 AggregateException

命名选项的完整用法见常见用法 → 命名选项与统一覆盖

常见问题

configureAfter 和再写一次 configure 有区别吗?

有。configureAfter 总是在所有 configure 之后执行,即使注册顺序写在前面也是如此。如果只是追加一个不依赖前置结果的固定值,用 configure 即可。

什么情况下调用 addOptions<T> 还不够,必须把选项类扩展成库的公共 API?

当你写的扩展库希望调用方通过 configure<T>()bind() 传入配置时,应该把相关选项类暴露为公共 API。这样调用方不需要查看源码就能知道有哪些可配置字段;反之,如果选项只供库内部使用,就不必暴露。

相关专题

  • 依赖注入 — 选项的注册、配置、读取全程依赖 DI 容器
  • 配置管理 — 通过 bind(section)bindConfiguration("path")IConfiguration 映射到选项对象
  • HttpClientChatOptions 承载上游地址、API Key,在 addHttpClient() 注册时通过 IOptions<T> 读取
  • 日志记录 — 日志级别、输出目标等配置通过选项模式注入
  • 宿主 — 启动阶段自动执行 validateOnStart 校验