Skip to content

配置管理

配置系统用于把 JSON、环境变量、命令行参数、内存字典等不同来源的数据,统一组织成一套可读取的层级配置。

soulsoft_extensions_configuration 中,配置有 4 个最重要的特性:

  • 键名不区分大小写
  • : 表示层级路径
  • 可以组合多个配置源
  • 后注册的配置源优先级更高

最小示例

如果你只是想“把一段 JSON 读进来并取值”,可以从这个例子开始:

cangjie
import soulsoft_extensions_configuration.*

main(): Int64 {
    // 通过 ConfigurationManager 读取一段 JSON 配置。
    let config = ConfigurationManager()
        .addJsonString(###"
        {
            "database": {
                "host": "localhost",
                "port": 3306
            }
        }
        "###)
        .build()

    // 用路径读取配置值,并按目标类型转换端口。
    println(config["database:host"])
    println(config.getValue<Int64>("database:port"))
    return 0
}

这个例子里有两个关键点:

  • database:host 这样的路径表示层级配置
  • getValue<Int64>() 会把字符串结果转换成目标类型

两种创建方式

这个模块提供两种入口:

  • ConfigurationBuilder
  • ConfigurationManager

它们都能注册配置源,但使用方式不一样。

ConfigurationBuilder

ConfigurationBuilder 更适合“先把所有配置源注册好,再一次性构建”。

cangjie
import soulsoft_extensions_configuration.*

main(): Int64 {
    // 先注册内存配置,再叠加 JSON 配置。
    let root = ConfigurationBuilder()
        .addMemory([("app:name", "spire")])
        .addJsonString(###"{"app":{"port":8080}}"###)
        .build()

    println(root["app:name"])
    println(root["app:port"])
    return 0
}

可以把它理解成“配置根的构建器”。

ConfigurationManager

ConfigurationManager 同时具备两种身份:

  • 它可以像 builder 一样继续 add...
  • 它本身又可以像 root 一样直接读写配置
cangjie
import soulsoft_extensions_configuration.*

main(): Int64 {
    // ConfigurationManager 可以先创建,再逐步添加配置源。
    let manager = ConfigurationManager()
    manager.addMemory([("app:name", "spire")])

    // build() 后依然返回同一个 manager 实例。
    let root = manager.build()
    println(root["app:name"])

    // 运行时继续写入配置值。
    manager["app:port"] = "8080"
    println(root["app:port"])
    return 0
}

这里 build() 返回的就是 manager 自己,所以后面对 manager 的修改也会反映到 root 上。

读配置

通过索引器读取

最直接的方式是使用索引器:

cangjie
// 使用内存配置源构造简单配置根。
let config = ConfigurationManager()
    .addMemory([
        ("database:host", "localhost"),
        ("database:port", "3306")
    ])
    .build()

println(config["database:host"])
println(config["database:port"])

返回值类型是 ?String。 如果键不存在,返回 None,不会抛异常。

键名不区分大小写

配置键统一按不区分大小写处理。

cangjie
let config = ConfigurationManager()
    // 键名大小写不同,但会映射到同一个配置项。
    .addMemory([("Database:Host", "localhost")])
    .build()

println(config["database:host"])
println(config["DATABASE:HOST"])
println(config["Database:Host"])

这三次读取拿到的是同一个值。

getValue<T>() 做类型转换

如果你不想自己把字符串再转成 Int64Bool 等类型,可以直接用 getValue<T>()

cangjie
let config = ConfigurationManager()
    .addMemory([
        ("app:port", "8080"),
        ("app:debug", "true"),
        ("app:name", "spire")
    ])
    .build()

// 按目标类型直接读取配置值。
println(config.getValue<Int64>("app:port"))
println(config.getValue<Bool>("app:debug"))
println(config.getValue<String>("app:name"))

当前实现支持的常见类型包括:

  • String
  • Rune
  • Bool
  • Int8Int64
  • UInt8UInt64
  • Float16Float64
  • BigInt
  • Decimal

需要注意两点:

  • 键不存在时返回 None
  • 如果值存在但无法转换成目标类型,会抛出异常

例如,"abc" 不能转换成 Int64

层级配置与配置节

: 表示层级路径

配置系统使用 : 作为路径分隔符。

cangjie
let config = ConfigurationManager()
    .addMemory([
        ("logging:level:default", "Info"),
        ("logging:level:framework", "Warning")
    ])
    .build()

// 通过 : 表示层级路径读取值。
println(config["logging:level:default"])

getSection() 获取配置节

当你想围绕某个节点继续读取时,用 getSection() 会更清晰。

cangjie
let config = ConfigurationManager()
    .addMemory([
        ("database:host", "localhost"),
        ("database:port", "3306")
    ])
    .build()

// 先取 database 节,再读取相对键。
let database = config.getSection("database")
println(database["host"])
println(database["port"])

如果节不存在,getSection() 仍然会返回一个配置节对象,只是它下面的值会是 None

getChildren() 遍历子节点

cangjie
let config = ConfigurationManager()
    .addMemory([
        ("database:host", "localhost"),
        ("database:port", "3306"),
        ("database:credentials:user", "root")
    ])
    .build()

let database = config.getSection("database")
// 遍历 database 下面的直接子节点。
for (child in database.getChildren()) {
    "${child.key}: ${child.value}" |> println
}

这里会遍历出直接子节点:

  • host
  • port
  • credentials

也就是说,getChildren() 取的是“当前层级的直接孩子”,不是整棵子树的全部叶子节点。

内置配置源

当前内置了 4 类常用配置源:

  • 内存:addMemory()
  • JSON:addJsonString()addJsonStream()addJsonFile()
  • 环境变量:addEnvVars()
  • 命令行参数:addCmdArgs()

内存配置

内存配置最适合:

  • 测试
  • 默认值
  • 运行时可修改项
cangjie
// 用内存配置源保存 app 的默认值。
let config = ConfigurationManager()
    .addMemory([
        ("app:name", "spire"),
        ("app:port", "8080")
    ])
    .build()

也可以不带初始值:

cangjie
// 也可以创建一个空的内存配置源。
let config = ConfigurationManager()
    .addMemory()
    .build()

JSON 配置

从字符串加载

cangjie
let config = ConfigurationBuilder()
    // 直接从 JSON 字符串加载配置。
    .addJsonString(###"
    {
        "app": {
            "name": "spire",
            "port": 8080
        }
    }
    "###)
    .build()

println(config["app:name"])
println(config["app:port"])

从文件加载

cangjie
let config = ConfigurationBuilder()
    // 从 appsettings.json 读取配置。
    .addJsonFile("appsettings.json")
    .build()

addJsonFile()optional 参数默认是 true,这意味着:

  • 文件不存在时,会直接跳过
  • 如果你传入 optional: false,文件不存在会抛出异常
cangjie
let config = ConfigurationBuilder()
    // optional: false 时,文件缺失会直接报错。
    .addJsonFile("appsettings.json", optional: false)
    .build()

JSON 数组会展开成索引路径

这一点在写文档或调试配置时很有用:

cangjie
let config = ConfigurationManager()
    .addJsonString(###"
    {
        "servers": [
            "node-a",
            "node-b"
        ]
    }
    "###)
    .build()

// JSON 数组会被展开成 servers:0、servers:1 这样的键。
println(config["servers:0"])
println(config["servers:1"])

环境变量

加载全部环境变量

cangjie
let config = ConfigurationBuilder()
    // 把当前进程全部环境变量导入配置系统。
    .addEnvVars()
    .build()

加载指定前缀

cangjie
let config = ConfigurationBuilder()
    // 只导入 MYAPP_ 前缀的环境变量。
    .addEnvVars("MYAPP_")
    .build()

当前实现还有一个很重要的规则:

  • 环境变量里的双下划线 __ 会映射成配置路径分隔符 :

例如:

  • 环境变量 MYAPP_Database__Host=localhost
  • 对应读取方式 config["Database:Host"]

命令行参数

命令行参数支持几种常见格式:

  • --key=value
  • --key value
  • /key=value

例如:

cangjie
let args = [
    "--server:host=localhost",
    "--server:port", "8080",
    "/server:debug=true"
]

let config = ConfigurationBuilder()
    // 把命令行参数解析为配置项。
    .addCmdArgs(args)
    .build()

println(config["server:host"])
println(config["server:port"])
println(config["server:debug"])

关于短开关

如果你使用短开关或自定义映射,建议显式传入 switchMappings

cangjie
import std.collection.*

// 定义短开关到完整配置路径的映射。
let switchMappings = HashMap<String, String>()
switchMappings["--host"] = "server:host"
switchMappings["--port"] = "server:port"

let config = ConfigurationBuilder()
    // 用 switchMappings 解释短开关。
    .addCmdArgs(
        ["--host", "localhost", "--port=8080"],
        switchMappings: switchMappings
    )
    .build()

println(config["server:host"])
println(config["server:port"])

这里有两个容易踩坑的点:

  • switchMappings 里的键必须以 --- 开头
  • switchMappings 的键按不区分大小写处理,重复会抛异常

如果没有映射,短开关写法不要当成主要用法来依赖。 对文档和业务代码来说,优先推荐 --key=value--key value

自定义配置源

扩展点只有两个:

  • IConfigurationSource.build(builder):创建 provider
  • IConfigurationProvider.load():把外部数据加载进 provider

实际实现时,直接继承 ConfigurationProvider 最省事。 它已经提供了默认的 get()set()getChildKeys()

最小实现

下面这个例子就是完整的自定义配置源骨架:

cangjie
import soulsoft_extensions_configuration.*
import std.collection.*

class CustomConfigClient {
    public func fetchAll(): Collection<(String, String)> {
        // 模拟从外部配置中心取回扁平键值对。
        [
            ("app:name", "spire"),
            ("app:port", "8080"),
            ("logging:level:default", "Info")
        ]
    }
}

class CustomConfigurationProvider <: ConfigurationProvider {
    private let _client: CustomConfigClient

    init(client: CustomConfigClient) {
        _client = client
    }

    public override func load() {
        // 把外部来源的键值装载进 provider 的 data。
        for ((key, value) in _client.fetchAll()) {
            data[key] = value
        }
    }
}

class CustomConfigurationSource <: IConfigurationSource {
    private let _client: CustomConfigClient

    init(client: CustomConfigClient) {
        _client = client
    }

    public func build(_: IConfigurationBuilder): IConfigurationProvider {
        // 构建时返回自定义 provider。
        return CustomConfigurationProvider(_client)
    }
}

这里真正关键的只有两点:

  • build() 返回自定义 provider
  • load() 把数据写进 data

写入 data 时要使用扁平键:

  • app:name
  • app:port
  • logging:level:default

不要直接保留嵌套对象结构。 getSection()getChildren() 都是基于这套扁平键工作的。

注册方式

和内置配置源一样,直接 add(source)

cangjie
let client = CustomConfigClient()

let config = ConfigurationBuilder()
    // 先放一个内存默认值。
    .addMemory([("app:port", "7000")])
    // 再叠加自定义配置源。
    .add(CustomConfigurationSource(client))
    .build()

println(config["app:name"])
println(config["app:port"])

这里 app:port 最终会是 8080,因为后注册的配置源优先级更高。

可选:补一个扩展方法

cangjie
extend ConfigurationBuilder {
    public func addCustom(client: CustomConfigClient): ConfigurationBuilder {
        // 把自定义配置源接入 ConfigurationBuilder。
        add(CustomConfigurationSource(client))
        return this
    }
}

extend ConfigurationManager {
    public func addCustom(client: CustomConfigClient): ConfigurationManager {
        // 把自定义配置源接入 ConfigurationManager。
        add(CustomConfigurationSource(client))
        return this
    }
}

这样业务代码就能和内置配置源保持一致:

cangjie
let config = ConfigurationManager()
    .addJsonFile("appsettings.json")
    // 像内置配置源一样链式追加自定义来源。
    .addCustom(CustomConfigClient())
    .build()

实现边界

源码语义上,有两个点要明确:

  • build()ConfigurationManager.add(source) 都会立即调用一次 provider.load()
  • reload() 会再次调用每个 provider 的 load()

所以 load() 必须是可重复执行的。

另外,默认 set() 只会改 provider 当前内存里的 data,不会自动回写外部配置源。 如果你需要“写回数据库/配置中心”,要在自定义 provider 里重写 set()

多配置源组合

配置系统支持把多个来源叠加起来。 当同一个键在多个源里同时出现时,规则很简单:

后注册的配置源覆盖先注册的配置源。

cangjie
let config = ConfigurationBuilder()
    // 先注册基础值。
    .addMemory([("app:port", "7000")])
    // 再叠加命令行覆盖。
    .addCmdArgs(["--app:port=8000"])
    // 最后叠加 JSON,因此优先级最高。
    .addJsonString(###"{"app":{"port":9000}}"###)
    .build()

println(config["app:port"])

这里最终读到的是 9000,因为 JSON 是最后注册的。

这一点对实际项目很重要。 如果你希望“基础配置 + 环境覆盖 + 命令行最终覆盖”,注册顺序就要按这个优先级来排。

写入配置

可以写,但要理解“写到哪里”

配置对象支持直接写值:

cangjie
let config = ConfigurationManager()
    .addMemory([("app:name", "spire")])
    .build()

// 直接写入当前运行时配置值。
config["app:name"] = "spire-doc"
println(config["app:name"])

但这里要特别注意:

当前实现的写入,是写到各 provider 当前持有的内存数据里,不等于持久化回原始外部来源。

这意味着:

  • 不会把值真正写回 JSON 文件
  • 不会回写环境变量
  • 不会回写命令行参数

如果你的目标是“运行时可修改配置”,最稳妥的做法是:

  • 使用 addMemory() 提供一个内存源
  • 把运行时变更理解为内存态覆盖,而不是持久化存储

ConfigurationSection 也可以写

cangjie
let root = ConfigurationManager()
    .addMemory([("app:name", "spire"), ("app:version", "1.0")])
    .build()

// 对配置节写值,底层会写回 app:name。
let app = root.getSection("app")
app["name"] = "spire-doc"

println(root["app:name"])
println(app["name"])

reload() 的真实语义

reload() 会让所有 provider 重新执行 load()

cangjie
let root = ConfigurationBuilder()
    .addMemory([("key", "original")])
    .build()

// 先在运行时改写当前键值。
root["key"] = "modified"
println(root["key"])

// reload() 会让 provider 重新装载原始数据。
root.reload()
println(root["key"])

在这个例子里,reload() 之后原来的键会恢复成 original

但还要注意一个实现细节:

reload() 会恢复 provider 自己原本持有的键值,但不会主动清掉你后续写入的“额外新键”。

因此,reload() 更准确的理解是:

  • 重新加载各配置源自己的原始数据
  • 不是“完全回到最初状态”

bind() 绑定到对象

当你不想一项项读取配置时,可以把某个配置节绑定到对象上。

cangjie
import soulsoft_extensions_configuration.*

class AppSettings {
    public var name: String = ""
    public var port: Int64 = 0
    public var debug: Bool = false
}

main(): Int64 {
    let root = ConfigurationManager()
        .addMemory([
            ("app:name", "spire"),
            ("app:port", "8080"),
            ("app:debug", "true")
        ])
        .build()

    // 先创建目标对象,再把 app 节绑定进去。
    let settings = AppSettings()
    root.bind("app", settings)

    println(settings.name)
    println(settings.port)
    println(settings.debug)
    return 0
}

当前 bind() 的行为边界需要明确:

  • 只会绑定当前配置节下的同名字段
  • 只会给 var 字段赋值
  • let 字段会被跳过
  • 缺失字段保持对象原来的默认值
  • 目标节不存在时,不会抛异常

也就是说,bind() 是一个很实用的轻量绑定能力,但不要把它理解成复杂对象图的深层递归绑定器。

常见建议

什么时候用 ConfigurationBuilder

适合:

  • 启动阶段一次性组装所有配置源
  • 构建后主要做读取

什么时候用 ConfigurationManager

适合:

  • 需要边添加配置源边使用
  • 想在运行时继续改配置值
  • 希望 build() 后继续沿用同一个对象

什么时候优先用 addMemory()

适合:

  • 单元测试
  • 默认配置
  • 运行时临时覆盖
  • 不要求持久化

命令行参数怎么写最稳妥

优先推荐:

  • --key=value
  • --key value

尽量不要把短开关当默认写法,除非你已经明确配置了 switchMappings

小结

如果你刚开始用这套配置系统,建议先记住下面 4 句话:

  1. 所有键都按不区分大小写处理
  2. 层级路径统一用 :
  3. 多配置源组合时,后注册覆盖先注册
  4. 写入是运行时内存态修改,不等于回写外部源

掌握这 4 点后,再继续看 bind()reload() 和多源组合,理解会更稳。