配置管理
配置系统用于把 JSON、环境变量、命令行参数、内存字典等不同来源的数据,统一组织成一套可读取的层级配置。
在 soulsoft_extensions_configuration 中,配置有 4 个最重要的特性:
- 键名不区分大小写
- 用
:表示层级路径 - 可以组合多个配置源
- 后注册的配置源优先级更高
最小示例
如果你只是想“把一段 JSON 读进来并取值”,可以从这个例子开始:
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>()会把字符串结果转换成目标类型
两种创建方式
这个模块提供两种入口:
ConfigurationBuilderConfigurationManager
它们都能注册配置源,但使用方式不一样。
ConfigurationBuilder
ConfigurationBuilder 更适合“先把所有配置源注册好,再一次性构建”。
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 一样直接读写配置
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 上。
读配置
通过索引器读取
最直接的方式是使用索引器:
// 使用内存配置源构造简单配置根。
let config = ConfigurationManager()
.addMemory([
("database:host", "localhost"),
("database:port", "3306")
])
.build()
println(config["database:host"])
println(config["database:port"])返回值类型是 ?String。 如果键不存在,返回 None,不会抛异常。
键名不区分大小写
配置键统一按不区分大小写处理。
let config = ConfigurationManager()
// 键名大小写不同,但会映射到同一个配置项。
.addMemory([("Database:Host", "localhost")])
.build()
println(config["database:host"])
println(config["DATABASE:HOST"])
println(config["Database:Host"])这三次读取拿到的是同一个值。
getValue<T>() 做类型转换
如果你不想自己把字符串再转成 Int64、Bool 等类型,可以直接用 getValue<T>()。
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"))当前实现支持的常见类型包括:
StringRuneBoolInt8到Int64UInt8到UInt64Float16到Float64BigIntDecimal
需要注意两点:
- 键不存在时返回
None - 如果值存在但无法转换成目标类型,会抛出异常
例如,"abc" 不能转换成 Int64。
层级配置与配置节
: 表示层级路径
配置系统使用 : 作为路径分隔符。
let config = ConfigurationManager()
.addMemory([
("logging:level:default", "Info"),
("logging:level:framework", "Warning")
])
.build()
// 通过 : 表示层级路径读取值。
println(config["logging:level:default"])getSection() 获取配置节
当你想围绕某个节点继续读取时,用 getSection() 会更清晰。
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() 遍历子节点
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
}这里会遍历出直接子节点:
hostportcredentials
也就是说,getChildren() 取的是“当前层级的直接孩子”,不是整棵子树的全部叶子节点。
内置配置源
当前内置了 4 类常用配置源:
- 内存:
addMemory() - JSON:
addJsonString()、addJsonStream()、addJsonFile() - 环境变量:
addEnvVars() - 命令行参数:
addCmdArgs()
内存配置
内存配置最适合:
- 测试
- 默认值
- 运行时可修改项
// 用内存配置源保存 app 的默认值。
let config = ConfigurationManager()
.addMemory([
("app:name", "spire"),
("app:port", "8080")
])
.build()也可以不带初始值:
// 也可以创建一个空的内存配置源。
let config = ConfigurationManager()
.addMemory()
.build()JSON 配置
从字符串加载
let config = ConfigurationBuilder()
// 直接从 JSON 字符串加载配置。
.addJsonString(###"
{
"app": {
"name": "spire",
"port": 8080
}
}
"###)
.build()
println(config["app:name"])
println(config["app:port"])从文件加载
let config = ConfigurationBuilder()
// 从 appsettings.json 读取配置。
.addJsonFile("appsettings.json")
.build()addJsonFile() 的 optional 参数默认是 true,这意味着:
- 文件不存在时,会直接跳过
- 如果你传入
optional: false,文件不存在会抛出异常
let config = ConfigurationBuilder()
// optional: false 时,文件缺失会直接报错。
.addJsonFile("appsettings.json", optional: false)
.build()JSON 数组会展开成索引路径
这一点在写文档或调试配置时很有用:
let config = ConfigurationManager()
.addJsonString(###"
{
"servers": [
"node-a",
"node-b"
]
}
"###)
.build()
// JSON 数组会被展开成 servers:0、servers:1 这样的键。
println(config["servers:0"])
println(config["servers:1"])环境变量
加载全部环境变量
let config = ConfigurationBuilder()
// 把当前进程全部环境变量导入配置系统。
.addEnvVars()
.build()加载指定前缀
let config = ConfigurationBuilder()
// 只导入 MYAPP_ 前缀的环境变量。
.addEnvVars("MYAPP_")
.build()当前实现还有一个很重要的规则:
- 环境变量里的双下划线
__会映射成配置路径分隔符:
例如:
- 环境变量
MYAPP_Database__Host=localhost - 对应读取方式
config["Database:Host"]
命令行参数
命令行参数支持几种常见格式:
--key=value--key value/key=value
例如:
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。
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):创建 providerIConfigurationProvider.load():把外部数据加载进 provider
实际实现时,直接继承 ConfigurationProvider 最省事。 它已经提供了默认的 get()、set() 和 getChildKeys()。
最小实现
下面这个例子就是完整的自定义配置源骨架:
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()返回自定义 providerload()把数据写进data
写入 data 时要使用扁平键:
app:nameapp:portlogging:level:default
不要直接保留嵌套对象结构。 getSection()、getChildren() 都是基于这套扁平键工作的。
注册方式
和内置配置源一样,直接 add(source):
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,因为后注册的配置源优先级更高。
可选:补一个扩展方法
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
}
}这样业务代码就能和内置配置源保持一致:
let config = ConfigurationManager()
.addJsonFile("appsettings.json")
// 像内置配置源一样链式追加自定义来源。
.addCustom(CustomConfigClient())
.build()实现边界
源码语义上,有两个点要明确:
build()和ConfigurationManager.add(source)都会立即调用一次provider.load()reload()会再次调用每个 provider 的load()
所以 load() 必须是可重复执行的。
另外,默认 set() 只会改 provider 当前内存里的 data,不会自动回写外部配置源。 如果你需要“写回数据库/配置中心”,要在自定义 provider 里重写 set()。
多配置源组合
配置系统支持把多个来源叠加起来。 当同一个键在多个源里同时出现时,规则很简单:
后注册的配置源覆盖先注册的配置源。
let config = ConfigurationBuilder()
// 先注册基础值。
.addMemory([("app:port", "7000")])
// 再叠加命令行覆盖。
.addCmdArgs(["--app:port=8000"])
// 最后叠加 JSON,因此优先级最高。
.addJsonString(###"{"app":{"port":9000}}"###)
.build()
println(config["app:port"])这里最终读到的是 9000,因为 JSON 是最后注册的。
这一点对实际项目很重要。 如果你希望“基础配置 + 环境覆盖 + 命令行最终覆盖”,注册顺序就要按这个优先级来排。
写入配置
可以写,但要理解“写到哪里”
配置对象支持直接写值:
let config = ConfigurationManager()
.addMemory([("app:name", "spire")])
.build()
// 直接写入当前运行时配置值。
config["app:name"] = "spire-doc"
println(config["app:name"])但这里要特别注意:
当前实现的写入,是写到各 provider 当前持有的内存数据里,不等于持久化回原始外部来源。
这意味着:
- 不会把值真正写回 JSON 文件
- 不会回写环境变量
- 不会回写命令行参数
如果你的目标是“运行时可修改配置”,最稳妥的做法是:
- 使用
addMemory()提供一个内存源 - 把运行时变更理解为内存态覆盖,而不是持久化存储
ConfigurationSection 也可以写
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()。
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() 绑定到对象
当你不想一项项读取配置时,可以把某个配置节绑定到对象上。
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 句话:
- 所有键都按不区分大小写处理
- 层级路径统一用
: - 多配置源组合时,后注册覆盖先注册
- 写入是运行时内存态修改,不等于回写外部源
掌握这 4 点后,再继续看 bind()、reload() 和多源组合,理解会更稳。