Skip to content

选项

依赖注入解决的是服务的创建、依赖传递和生命周期管理。 Options 则把“配置”也变成一种可由 DI 统一管理的强类型对象,避免业务代码自己读配置、自己做转换、自己处理默认值。

在 Spire 中:

  • soulsoft_extensions_options 负责选项对象的注册、命名、覆盖和验证
  • soulsoft_extensions_options_configuration 负责把 IConfiguration 绑定到选项对象

最小示例

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*

class DbOptions {
    // 定义强类型数据库配置。
    public var host: String = ""
    public var port: Int64 = 0
}

main(): Int64 {
    // 创建 DI 服务集合。
    let services = ServiceCollection()

    // 注册默认名称的 DbOptions 配置委托。
    services.configure<DbOptions>({
        options =>
            options.host = "127.0.0.1"
            options.port = 5432
    })

    // 构建容器并解析 IOptions<DbOptions>。
    let provider = services.build()
    let options = provider.getOrThrow<IOptions<DbOptions>>()

    println(options.value.host)
    println(options.value.port)
    return 0
}

上面这段代码体现的是:

  • 注册阶段,把“如何配置 DbOptions”的函数委托登记到容器
  • 解析阶段,通过 IOptions<DbOptions> 取回最终的强类型配置对象

注册选项

configure<TOptions>()

最常用的入口是 ServiceCollection.configure<TOptions>()

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*

class CacheOptions {
    // 定义缓存前缀和过期时间配置。
    public var prefix: String = ""
    public var ttlSeconds: Int64 = 0
}

main(): Int64 {
    let services = ServiceCollection()

    // 注册 CacheOptions 的默认配置。
    services.configure<CacheOptions>({
        options =>
            options.prefix = "spire:"
            options.ttlSeconds = 300
    })

    // 解析默认名称的选项对象。
    let provider = services.build()
    let options = provider.getOrThrow<IOptions<CacheOptions>>()

    println(options.value.prefix)
    println(options.value.ttlSeconds)
    return 0
}

这个 API 会做两件事:

  • 确保选项基础设施已注册
  • 把当前这段配置逻辑以函数委托的形式登记到该类型的默认名称上

这里要特别注意:

  • configure<TOptions>({ ... }) 传入的是一个函数委托
  • 注册阶段只是把这个委托存进容器
  • 不是在调用 configure() 时立刻创建 TOptions 并执行赋值

真正的执行时机是在选项对象首次创建时。 当前实现里,容器会在创建该选项时取出这些已注册的配置委托,并按顺序依次作用到同一个 TOptions 实例上。

为什么不用普通单例

你当然也可以直接这样写:

cangjie
// 直接把某个实例作为单例放进容器。
services.addSingleton(DbOptions())

但这和 Options 不一样。

直接注册实例只有“把一个对象放进容器”这一层语义,而 Options 额外提供了:

  • 默认名称和命名名称模型
  • configureAfter
  • configureAll / configureAfterAll
  • 内置验证流程
  • IStartupValidator
  • 固定的执行顺序

所以 Options 的重点不是“容器里有一个配置对象”,而是“容器里有一套受控的配置生成与校验流程”。

解析选项

IOptions<T>

IOptions<T> 用于解析默认名称的选项实例。

cangjie
// 解析默认名称的选项实例。
let options = provider.getOrThrow<IOptions<AppOptions>>()
println(options.value)

它有两个关键语义:

  • value 是懒加载的,首次访问时才真正创建选项对象
  • 在同一个 ServiceProvider 中,默认选项只会创建一次并被缓存

这意味着你多次解析 IOptions<T>,拿到的是同一份默认配置对象。

IOptionsMonitor<T>

IOptionsMonitor<T> 用于解析命名选项。

cangjie
// 解析命名选项访问器。
let monitor = provider.getOrThrow<IOptionsMonitor<AppOptions>>()
// currentValue 对应默认名称。
let defaultValue = monitor.currentValue
// get("tenant1") 读取指定名称的选项。
let tenant1Value = monitor.get("tenant1")

这里的真实语义是:

  • currentValue 等价于 get("")
  • 默认名称就是空字符串 ""
  • 同名实例会缓存
  • 不同名称之间相互隔离

命名选项

命名选项是 soulsoft_extensions_options 的核心能力之一。 它适合:

  • 多租户
  • 多实例客户端
  • 同一类型需要维护多份独立配置
cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*

class TenantOptions {
    public var endpoint: String = ""
}

main(): Int64 {
    let services = ServiceCollection()

    // 注册默认配置。
    services.configure<TenantOptions>({
        options => options.endpoint = "https://default.example.com"
    })
    // 注册 tenant1 专属配置。
    services.configure<TenantOptions>("tenant1", {
        options => options.endpoint = "https://t1.example.com"
    })
    // 注册 tenant2 专属配置。
    services.configure<TenantOptions>("tenant2", {
        options => options.endpoint = "https://t2.example.com"
    })

    // 通过 IOptionsMonitor 读取不同名称的实例。
    let provider = services.build()
    let monitor = provider.getOrThrow<IOptionsMonitor<TenantOptions>>()

    println(monitor.currentValue.endpoint)
    println(monitor.get("tenant1").endpoint)
    println(monitor.get("tenant2").endpoint)
    return 0
}

这里要特别分清楚:

  • IOptions<T> 只对应默认名称
  • IOptionsMonitor<T> 才能访问指定名称

如果你的场景天然存在“同类型多份配置”,那就应该直接以 IOptionsMonitor<T> 为主接口,而不是后面再补。

执行顺序

选项对象在创建时不是“随便执行几段委托”,而是有固定顺序的。

当前实现的顺序是:

  1. 执行所有 configure
  2. 执行所有 configureAfter
  3. 执行所有 validate

因此理解这套系统时,最重要的一个原则就是:

configure 负责提供值,configureAfter 负责最终覆盖,validate 负责最后把关。

configureAfter

cangjie
class ServerOptions {
    public var port: Int64 = 0
}

let services = ServiceCollection()
// 先注册基础端口。
services.configure<ServerOptions>({
    options => options.port = 8080
})
// 再通过 configureAfter 做最终覆盖。
services.configureAfter<ServerOptions>({
    options => options.port = 9090
})

let provider = services.build()
let options = provider.getOrThrow<IOptions<ServerOptions>>()
println(options.value.port) // 9090

configureAfter 的定位不是“另一个 configure”,而是“在所有 configure 都执行完之后再做统一覆盖”。

configureAllconfigureAfterAll

这两个 API 用于“作用于该类型的全部名称”。

cangjie
class StorageOptions {
    public var region: String = ""
    public var bucket: String = ""
}

let services = ServiceCollection()
// 给 tenant1 设置独立 bucket。
services.configure<StorageOptions>("tenant1", {
    options => options.bucket = "bucket-a"
})
// 给 tenant2 设置独立 bucket。
services.configure<StorageOptions>("tenant2", {
    options => options.bucket = "bucket-b"
})
// 给全部名称统一设置 region。
services.configureAll<StorageOptions>({
    options => options.region = "cn-hangzhou"
})
// 最后再对全部名称统一改写 bucket。
services.configureAfterAll<StorageOptions>({
    options => options.bucket = "normalized-" + options.bucket
})

它们的语义分别是:

  • configureAll:对该类型全部名称执行前置配置
  • configureAfterAll:对该类型全部名称执行后置配置

如果 configureAll 注册在某个命名 configure 之后,它会覆盖前面写入的值。 如果你需要“最后兜底改写”,通常优先考虑 configureAfterAll

链式注册

当一份配置需要多个步骤共同组成时,可以使用 addOptions<TOptions>() 返回的 OptionsBuilder<T> 做链式注册。

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*

class ServiceOptions {
    public var version: Int64 = 0
    public var name: String = ""
}

main(): Int64 {
    let services = ServiceCollection()

    // 通过 OptionsBuilder 链式注册配置和校验。
    services.addOptions<ServiceOptions>()
        .configure {
            options => options.version = 1
        }
        .configure {
            options => options.name = "gateway"
        }
        .configureAfter {
            options => options.version = 2
        }
        .validate("version 必须大于 1") {
            options => options.version > 1
        }

    // 构建后解析最终配置结果。
    let provider = services.build()
    let options = provider.getOrThrow<IOptions<ServiceOptions>>()

    println(options.value.version)
    println(options.value.name)
    return 0
}

这条链的本质仍然是注册:

  • .configure(...) 注册前置配置委托
  • .configureAfter(...) 注册后置配置委托
  • .validate(...) 注册验证委托

除了只接收 TOptions 的版本外,configureconfigureAftervalidate 都还有可访问 IServiceProvider 的重载。 这意味着你可以在配置和验证时读取容器中的其他服务。

验证

validate()

validate() 在选项对象第一次真正创建时执行。 如果校验失败,会抛出 OptionsValidationException

cangjie
class AppOptions {
    public var port: Int64 = 0
}

let services = ServiceCollection()
// 注册一个非法端口值。
services.addOptions<AppOptions>()
    .configure {
        options => options.port = 0
    }
    .validate("port 必须大于 0") {
        options => options.port > 0
    }

let provider = services.build()
let options = provider.getOrThrow<IOptions<AppOptions>>()
// 首次访问 value 时触发校验。
let _ = options.value

这个异常不是在注册时抛,也不是在 build() 时抛,而是在首次访问该选项时抛。

validateOnStart()

如果你不想把问题拖到第一次访问才暴露,就要用 validateOnStart()

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*
import soulsoft_extensions_options.exceptions.*

class AppOptions {
    public var version: Int64 = 0
}

main(): Int64 {
    let services = ServiceCollection()

    // 注册选项,并在启动阶段完成验证。
    services.addOptions<AppOptions>()
        .configure {
            options => options.version = 1
        }
        .validate("version 必须大于 1") {
            options => options.version > 1
        }
        .validateOnStart()

    // 解析统一启动校验器并主动执行。
    let provider = services.build()
    let validator = provider.getOrThrow<IStartupValidator>()

    validator.validate()
    return 0
}

它的作用是把该选项登记到统一启动校验器中。

IStartupValidator.validate() 的行为是:

  • 单个失败时抛 OptionsValidationException
  • 多个失败时抛 AggregateException

如果你只是想快速注册“默认开启启动校验”的选项,也可以用:

cangjie
// 快速创建默认启用启动校验的选项构建器。
services.addOptionsWithValidateOnStart<AppOptions>()

使用边界

1. 选项类型需要能被反射创建

当前实现会通过反射构造 TOptions。 因此类型需要:

  • 继承自 Object
  • 具备可用的无参构造能力

否则在首次创建选项时会抛异常。

2. IOptions<T> 是默认名称,不是“全部名称”

这点很容易误解。

  • IOptions<T> 只代表默认名称
  • IOptionsMonitor<T> 才代表“按名称读取”

所以只要你的配置模型里出现“tenant1 / tenant2 / default”这样的概念,就应该优先用 IOptionsMonitor<T>

3. 选项对象是缓存的

当前实现里:

  • 默认名称通过 IOptions<T> 单例缓存
  • 命名实例通过 IOptionsMonitor<T> 按名称缓存

也就是说,Options 不是“每次 get 都重新跑一遍配置”,而是“首次创建,之后复用”。

4. configureAfter 的意义是覆盖,不是补充

如果某个字段既在 configure 中赋值,又在 configureAfter 中赋值,最终以后者为准。 理解这一点后,很多配置覆盖行为就不会混乱。

引入配置绑定

到这里为止,我们讲的都还是 soulsoft_extensions_options 本体。

但在真实项目里,配置往往不是手写在 configure { ... } 里,而是来自:

  • JSON
  • 环境变量
  • 命令行
  • 内存配置
  • 自定义配置源

这时就应该在 options 之上,再引入 soulsoft_extensions_options_configuration

它不是替代 options,而是建立在 options 之上的绑定扩展:

  • options 负责选项对象的生命周期、命名模型和验证模型
  • options_configuration 负责把 IConfiguration 的值填进这个对象

配置绑定

最常见用法

cangjie
import soulsoft_extensions_configuration.*
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*
import soulsoft_extensions_options_configuration.*

class ServerOptions {
    public var host: String = ""
    public var port: Int64 = 0
    public var enabled: Bool = false
}

main(): Int64 {
    // 构造一份配置根。
    let root = ConfigurationManager()
        .addJsonString(###"
        {
            "server": {
                "host": "127.0.0.1",
                "port": 8080,
                "enabled": true
            }
        }
        "###)
        .build()

    let services = ServiceCollection()
    // 把 server 配置节绑定到 ServerOptions。
    services.configure<ServerOptions>(root.getSection("server"))

    // 解析绑定后的选项对象。
    let provider = services.build()
    let options = provider.getOrThrow<IOptions<ServerOptions>>()

    println(options.value.host)
    println(options.value.port)
    println(options.value.enabled)
    return 0
}

这时:

  • ConfigurationManager 负责读取配置
  • services.configure<TOptions>(section) 负责把配置节绑定到选项类型
  • IOptions<T> 负责统一访问结果

它支持什么绑定

当前实现已经覆盖的常见场景包括:

  • String
  • Bool
  • Int8Int64
  • UInt8UInt64
  • Float16Float64
  • ?String
  • ?Bool
  • ?Int64
  • ?Float64
  • Array<String>
  • 数值数组,如 Array<Int64>Array<UInt32>Array<Float64>
  • 嵌套类对象

嵌套对象

cangjie
class DatabaseOptions {
    public var host: String = ""
    public var port: Int64 = 0
}

class AppOptions {
    public var name: String = ""
    public var database: DatabaseOptions = DatabaseOptions()
}

let root = ConfigurationManager()
    .addMemory([
        ("name", "spire"),
        ("database:host", "localhost"),
        ("database:port", "3306")
    ])
    .build()

let services = ServiceCollection()
// 把整棵配置根绑定到 AppOptions。
services.configure<AppOptions>(root)

这里 database 会自动绑定 database:* 这一层配置。

数组

cangjie
class ClusterOptions {
    public var nodes: Array<String> = []
    public var ports: Array<Int64> = []
}

let root = ConfigurationManager()
    .addMemory([
        ("nodes:0", "node-a"),
        ("nodes:1", "node-b"),
        ("ports:0", "8080"),
        ("ports:1", "9090")
    ])
    .build()

let services = ServiceCollection()
// 按数组下标键把配置绑定到数组字段。
services.configure<ClusterOptions>(root)

数组依赖 field:0field:1 这样的子键。

可空字段

?T 字段不会被跳过。 当配置存在且能解析时,会绑定成 Some(value);否则保持默认值。

cangjie
class FeatureOptions {
    public var name: String = ""
    public var enabled: ?Bool = None
}

let root = ConfigurationManager()
    .addMemory([
        ("name", "search"),
        ("enabled", "true")
    ])
    .build()

let services = ServiceCollection()
// 可空字段在配置存在时会绑定为 Some(value)。
services.configure<FeatureOptions>(root)

绑定边界

1. 只绑定 var

配置绑定只会写入可变字段:

  • var 会绑定
  • let 会跳过并保留原值

2. 解析失败时保留默认值

当前实现的策略是:

  • 成功解析就写入
  • 解析失败就不写入
  • 键不存在也不写入

因此字段会保留类里的默认值,而不是直接抛出绑定异常。

3. 命名绑定只对同名实例生效

cangjie
// tenant1 和 tenant2 会分别绑定到不同名称的选项实例。
services.configure<AppOptions>("tenant1", config1)
services.configure<AppOptions>("tenant2", config2)

这两份绑定彼此隔离。 如果你读取一个未注册的名称,会创建一个默认实例,但不会套用其他名称上的绑定结果。

小结

理解这套系统时,建议按下面这个顺序记:

  1. 先把 soulsoft_extensions_options 看成 DI 的配置扩展
  2. 先掌握 IOptions<T>IOptionsMonitor<T>OptionsBuilder<T>
  3. 再理解命名选项、后置覆盖和验证
  4. 最后再引入 soulsoft_extensions_options_configuration,把 IConfiguration 绑定进来

如果只记一句话,可以记这一句:

soulsoft_extensions_options 负责把配置变成可由 DI 管理的强类型对象,soulsoft_extensions_options_configuration 负责把外部配置源填充进这个对象。