配置管理
用 ConfigurationBuilder 或 ConfigurationManager 收敛多源配置,统一为
config["section:key"]一种读法。
适用场景
- 数据库连接串、API 密钥、端口号等写在代码里,改一次就得重新编译部署。
- 开发、测试、生产三套环境,每次切换都要改一堆常量。
- 配置来源分散——JSON 文件用
JsonValue.fromStr()、环境变量用getVariable()、命令行参数先用getCommandLine()获取、再交给std.argopt解析——每种来源一套读法。 - 希望环境变量能自动覆盖 JSON 默认值,不需要手写 if-else 优先级判断。
class ChatClient {
public init() {
let apiKey = "sk-xxx"
let model = "gpt-4o"
// 模型或密钥变了 → 改代码、编译、部署。密钥还会进版本库。
}
}配置系统解决的不是"能读多少种来源"。
它真正解决的是:不管多少种来源,读法都只有一种。
核心问题
配置管理把配置数据从代码中抽出来,统一解决三个问题:
- 值从哪来:JSON 文件、环境变量、命令行参数。
- 优先级谁高:后注册覆盖先注册。
- 怎么读:统一为
config["section:key"]。
换个写法:
import soulsoft_extensions_configuration.*
main(): Int64 {
let config = ConfigurationManager()
.addJsonFile("appsettings.json") // 基础配置
.addEnvVars("MYAPP_") // 环境变量覆盖
.build()
let apiKey = config["chat:apiKey"]
let model = config["chat:model"]
return 0
}代码不需要关心值最初来自 JSON、环境变量还是命令行。换环境时,只需要换配置源,不需要改业务代码。
相关依赖
| 包名 | 作用 | 什么时候需要 |
|---|---|---|
soulsoft_extensions_configuration | 提供 ConfigurationManager、ConfigurationBuilder、IConfiguration、IConfigurationRoot 等核心类型 | 始终需要 |
快速开始
引入 soulsoft_extensions_configuration 后,三步走通最小路径:创建管理器 → 链式添加源 → 构建并读取。
import soulsoft_extensions_configuration.*
main(): Int64 {
let config = ConfigurationManager()
.addMemory([
("chat:apiKey", "sk-xxx"),
("chat:model", "gpt-4o")
])
.build()
println(config["chat:apiKey"])
println(config["chat:model"])
return 0
}运行输出:
Some(sk-xxx)
Some(gpt-4o)信息
本页所有仓颉示例已通过 cjpm build 验证并运行通过。
整个流程只有三个角色:
| 步骤 | 做什么 | 用的类型 |
|---|---|---|
| 创建管理器 | 准备一个空配置容器 | ConfigurationManager() |
| 添加源 | 往容器里放入数据 | addMemory() / addJsonFile() / ... |
| 构建并读取 | 固化后统一取值 | build() → IConfigurationRoot |
下面按这个顺序展开。
核心概念
注册源 → 构建 → 读取
配置系统分三个阶段:
这张图只表达一条主线:先通过 add* 链式添加配置源,再用 build() 固化,最后统一从 IConfigurationRoot 读取。
不同来源翻译为统一 section:key 格式:
| 来源 | 原始写法 | 统一格式 |
|---|---|---|
| JSON 文件 | {"chat": {"model": "gpt-4o"}} | chat:model = "gpt-4o" |
| 环境变量 | MYAPP_Chat__Model=gpt-4o | Chat:Model = "gpt-4o" |
| 命令行参数 | --chat:model=gpt-4o | chat:model = "gpt-4o" |
| 内存默认值 | [("chat:model", "gpt-4o")] | chat:model = "gpt-4o" |
build() 会把可变注册固化下来。读取时从尾向前遍历 Provider 数组,因此后注册的覆盖先注册的。
getSection("db") 只做一件事:在 key 前面拼上 "db:"。所以 section["host"] 等价于 config["db:host"]。
两种构建器
有两种入口,区别只在于:添加源之后能不能立刻读值。
let builder = ConfigurationBuilder()
builder.addMemory([("chat:model", "gpt-4o")])
builder.addJsonFile("appsettings.json")
// builder 不能读配置——它没有 [] 索引器
let config = builder.build()
println(config["chat:model"]) // 现在才能读let manager = ConfigurationManager()
manager.addMemory([("chat:model", "gpt-4o")])
println(manager["chat:model"]) // 不用 build(),直接就能读
manager.addJsonString(###"{"chat":{"baseUrl":"https://api.openai.com"}}"###)
println(manager["chat:baseUrl"]) // 加一个源,立刻能读新数据ConfigurationBuilder 是一个纯粹的构建工具。它只负责收集配置源,自身不能读配置。必须调 build() 拿到 IConfigurationRoot 之后才能读值,适合启动阶段一次性组装。
ConfigurationManager 同时是构建器和配置根,不需要 build() 就能直接读写。build() 返回的还是它自己,只是为了和 ConfigurationBuilder 保持接口一致。需要运行时动态调整配置时用它。
常见用法
注册配置源
可以把配置源先分成两类:
- 内置源:内存、JSON、环境变量、命令行。
- 自定义源:通过
.add(source)接入任何外部来源。
先看一行速览:
| 来源 | 注册方式 | 最常见用途 |
|---|---|---|
| 内存 | .addMemory(...) | 默认值、测试数据 |
| JSON | .addJsonString(...) / .addJsonFile(...) | 本地配置、环境差异化配置 |
| 环境变量 | .addEnvVars(...) | 容器注入、敏感值覆盖 |
| 命令行 | .addCmdArgs(args) | 临时覆盖、运维调试 |
| 链式 | .addConfiguration(...) | 复用已有配置(如日志节)。第二个参数 shouldDisposeConfiguration 为保留参数,默认 false |
| 自定义 | .add(source) | 配置中心、数据库、Redis |
下面逐一展开用法。
内存配置
键值对直接写在代码里,不做额外转换,适合默认值和单元测试。两种用法:
let config = ConfigurationManager()
.addMemory([
("chat:model", "gpt-4o"),
("chat:baseUrl", "https://api.openai.com")
])
.build()let config = ConfigurationManager()
.addMemory()
.build()
config["chat:model"] = "gpt-4o-mini"JSON 配置
JSON 源分两种入口:
.addJsonString(...):适合嵌入式配置或测试数据。.addJsonFile(...):适合本地配置文件和环境差异化配置。
共同规则:
- 嵌套对象会展平为
parent:child。 - 数组会展开成
key:0、key:1这类索引键。 - 非法 JSON 会抛异常。
只有 addJsonFile(...) 额外有一条文件规则:
optional: true时,文件缺失会静默跳过。optional: false时,文件缺失会直接抛异常。
文件路径解析规则:
addJsonFile("appsettings.json")这种相对路径,是相对当前工作目录解析的。- 在最常见的
cjpm run用法下,当前工作目录通常就是项目根目录。 - 因此日常项目里,
appsettings.json通常就放在和cjpm.toml同级的位置。
最常见的目录结构就是这样:
your-project/
├── cjpm.toml
├── appsettings.json
└── src/
└── main.cjlet config = ConfigurationBuilder()
.addJsonString(###"{"chat":{"model":"gpt-4o","timeoutSeconds":30}}"###)
.build()let config = ConfigurationBuilder()
.addJsonFile("appsettings.json") // 通常就是 cjpm.toml 同级的 appsettings.json
.addJsonFile("appsettings.Production.json", optional: false) // 文件缺失 → 抛异常
.build()let config = ConfigurationManager()
.addJsonString(###"{"servers":["node-a","node-b"]}"###)
.build()
println(config["servers:0"]) // Some("node-a")
println(config["servers:1"]) // Some("node-b")环境变量
环境变量源适合容器注入、部署时覆盖和敏感值下发。
核心规则只有两条:
- 双下划线
__会映射成路径分隔符:。 - 如果传了前缀,加载时会先去掉前缀。前缀匹配同样忽略大小写。
let config = ConfigurationBuilder()
.addEnvVars()
.build()let config = ConfigurationBuilder()
.addEnvVars("MYAPP_")
.build()双下划线 __ 映射成路径分隔符 ::
| 环境变量 | 读法 |
|---|---|
MYAPP_Chat__Model=gpt-4o | config["Chat:Model"] |
常见设值方式:
$env:MYAPP_Chat__Model = "gpt-4o-mini"
cjpm run{
"terminal.integrated.env.windows": {
"MYAPP_Chat__Model": "gpt-4o-mini"
}
}docker run -e MYAPP_Chat__Model=gpt-4o-mini myapp如果你在 VS Code 终端里运行 cjpm run,.vscode/settings.json 中的 terminal.integrated.env.windows 会直接进入当前终端进程;addEnvVars("MYAPP_") 读取到的就是这批值。
命令行参数
命令行源适合临时覆盖和运维调试。
核心规则:
- 支持
--key=value、--key value、/key=value三种格式。 - 单短横线参数(如
-h)需要通过switchMappings显式映射到完整 key。 - 双短横线参数(如
--host)本身就可以直接解析;只有想把它重定向到别的配置键时,才需要switchMappings。 - 传给可执行程序时,
cjpm run需要用--或--run-args转交参数。
这里的 args,通常就是 main(args: Array<String>) 收到的那组参数。
最常见的传入方式:
cjpm run -- --chat:model=gpt-4o-mini --chat:timeoutSeconds=60.\target\release\bin\main.exe --chat:model=gpt-4o-mini --chat:timeoutSeconds=60docker run myapp --chat:model=gpt-4o-mini --chat:timeoutSeconds=60cjpm run -- --chat:model=gpt-4o-mini 这种写法中,-- 之后的内容会原样传给可执行程序,再由 addCmdArgs(args) 解析。
let args = ["--chat:model=gpt-4o", "--chat:timeoutSeconds", "30"]
let config = ConfigurationBuilder()
.addCmdArgs(args)
.build()
println(config["chat:model"]) // Some("gpt-4o")let switchMappings = HashMap<String, String>()
switchMappings["-m"] = "chat:model"
let config = ConfigurationBuilder()
.addCmdArgs(["-m", "gpt-4o-mini"], switchMappings: switchMappings)
.build()
println(config["chat:model"]) // Some("gpt-4o-mini")多源组合与优先级
多个配置源叠加时,后注册的覆盖先注册的。下面这段里,命令行参数最后注册,所以会覆盖前面所有源的 chat:model:
let config = ConfigurationBuilder()
.addMemory([("chat:model", "gpt-4o")]) // ① 默认值,优先级最低
.addJsonFile("appsettings.json") // ② 文件覆盖
.addEnvVars("MYAPP_") // ③ 环境变量再覆盖
.addCmdArgs(["--chat:model=gpt-4o-mini"]) // ④ 命令行最终覆盖
.build()
println(config["chat:model"]) // gpt-4o-mini推荐注册顺序是:代码默认值 → JSON 文件 → 环境变量 → 命令行参数,把用户可控的放在后面、程序默认的放在前面。
自定义配置源
如果内置源不够用,可以直接接入自定义来源,例如配置中心、数据库或 Redis。
统一要求只有两条:
- 继承
ConfigurationProvider,在load()里把结果写进data。 - 通过
.add(source)注册,继续复用同一套覆盖顺序和读取方式。
读取配置
索引器读取
这是最直接的方式。返回 ?String,键不存在时返回 None:
let config = ConfigurationManager()
.addMemory([
("chat:model", "gpt-4o"),
("chat:baseUrl", "https://api.openai.com")
])
.build()
println(config["chat:model"]) // Some("gpt-4o")
println(config["chat:apiKey"]) // None(键不存在,不抛异常)键名不区分大小写
JSON 里可能写 Chat,环境变量里是 CHAT,命令行里又是 chat。这三种写法应该指向同一个配置项,所以系统统一按不区分大小写处理:
let config = ConfigurationManager()
.addMemory([("Chat:Model", "gpt-4o")])
.build()
println(config["chat:model"]) // 都能读到
println(config["CHAT:MODEL"]) // 同一个值getValue<T> 类型转换
配置存储的值本质上都是字符串。想要数字、布尔值,就用 getValue<T>():
let config = ConfigurationManager()
.addMemory([
("chat:timeoutSeconds", "30"),
("chat:stream", "true")
])
.build()
let timeout = config.getValue<Int64>("chat:timeoutSeconds") // Some(30)
let stream = config.getValue<Bool>("chat:stream") // Some(true)支持的类型:String、Rune、Bool、Int8 / Int16 / Int32 / Int64、UInt8 / UInt16 / UInt32 / UInt64、Float16 / Float32 / Float64、BigInt、Decimal。
两种失败情况
- 键不存在 → 返回
None,不抛异常 - 值存在但无法转换(如
"abc"转Int64)→ 抛异常
getSection 读取子节点
如果要围绕一个子节点反复读取,用 getSection() 比每次写完整路径更清晰:
let config = ConfigurationManager()
.addMemory([
("chat:model", "gpt-4o"),
("chat:baseUrl", "https://api.openai.com")
])
.build()
let chat = config.getSection("chat")
println(chat["model"]) // Some("gpt-4o")
println(chat["baseUrl"]) // Some("https://api.openai.com")getSection() 内部只是把当前路径和子键用 : 拼接。即使节点不存在,也会返回一个空的配置节对象;它的 value 是 None,不会抛异常。
getChildren 遍历子节点
getChildren() 只取直接子节点,不会递归展开整棵子树:
let config = ConfigurationManager()
.addMemory([
("chat:model", "gpt-4o"),
("chat:timeoutSeconds", "30"),
("chat:headers:userAgent", "spire-doc")
])
.build()
let chat = config.getSection("chat")
for (child in chat.getChildren()) {
println("${child.key}: ${child.value}")
}输出:
model: Some(gpt-4o)
timeoutSeconds: Some(30)
headers: Noneheaders 本身还有子节点(headers:userAgent),所以它的 value 是 None,但依然出现在子节点列表中。
读取速查
| 需求 | 方法 |
|---|---|
| 获取字符串值 | config["key"] |
| 获取并转换类型 | config.getValue<T>("key") |
| 读取子节点 | config.getSection("key") |
| 遍历子节点 | section.getChildren() |
| 检查节是否存在 | section.exists() |
| 导出所有键值对 | config.asEnumerable() |
绑定到对象
不想一个个 getValue<T>()?用 bind() 把整个配置节映射到对象。核心是 bind("chat", options) 这一行——自动遍历对象的 var 字段并与配置键匹配:
class ChatOptions {
public var apiKey: String = ""
public var model: String = ""
public var stream: Bool = false
}
main(): Int64 {
let config = ConfigurationManager()
.addMemory([
("chat:apiKey", "sk-xxx"),
("chat:model", "gpt-4o"),
("chat:stream", "true")
])
.build()
let options = ChatOptions()
config.bind("chat", options)
println(options.apiKey) // sk-xxx
println(options.model) // gpt-4o
println(options.stream) // true
return 0
}bind() 的工作方式:通过 ClassTypeInfo.of() 在运行时获取对象的实际类型 → 遍历所有实例变量 → 跳过 let 字段 → 去配置中找同名键 → 做类型转换 → 写入。
| 情况 | 行为 |
|---|---|
var 字段 | 正常赋值 |
let 字段 | 跳过,保持原值 |
| 配置中无对应键 | 跳过,保持原默认值 |
| 目标节不存在 | 不抛异常 |
| 值无法转换 | 跳过该字段,保持原值,不抛异常 |
bind() 通过运行时类型信息(ClassTypeInfo.of(instance))获取实例的实际类型,遍历实例直接声明的 var 字段完成绑定。父类中定义的 var 字段默认不会被绑定——getBindableVariables() 默认只返回当前类直接声明的变量。如需绑定继承链上的字段,让类实现 IConfigurationBinding 接口,此时 bind() 会通过反射获取所有继承变量并逐个绑定。
写入配置
可以写,但要理解写到哪了——写的是各 provider 内存中的数据,不会回写到原始来源:
let config = ConfigurationManager()
.addMemory([("chat:model", "gpt-4o")])
.build()
config["chat:model"] = "gpt-4o-mini"
println(config["chat:model"]) // Some("gpt-4o-mini")不回写原始来源
写入只影响内存中的 provider 数据,不会写回 JSON 文件、修改环境变量或改写命令行参数。如果需要运行时可变配置,用 addMemory() 创建一个内存源。
getSection() 返回的节也能写,底层把 section["key"] 拼接成 "section:key" 写回配置根:
let chat = config.getSection("chat")
chat["model"] = "gpt-4.1"
println(config["chat:model"]) // Some("gpt-4.1")自定义配置源
如果内置源不够用(比如从配置中心拉取),扩展只需两步:继承 ConfigurationProvider,重写 load()——把数据写入父类提供的 data 属性,键必须是扁平格式(app:name,不能嵌套):
import soulsoft_extensions_configuration.*
import std.collection.*
class ConfigCenterClient {
public func fetchAll(): Collection<(String, String)> {
[("chat:model", "gpt-4o"), ("chat:timeoutSeconds", "30")]
}
}
class RemoteProvider <: ConfigurationProvider {
private let client: ConfigCenterClient
init(client: ConfigCenterClient) { this.client = client }
public override func load() {
for ((key, value) in client.fetchAll()) {
data[key] = value // data 由父类提供
}
}
}
class RemoteSource <: IConfigurationSource {
private let client: ConfigCenterClient
init(client: ConfigCenterClient) { this.client = client }
public func build(_: IConfigurationBuilder): IConfigurationProvider {
RemoteProvider(client)
}
}注册方式和内置源一样——用 add(source) 添加,后注册的覆盖先注册的:
let config = ConfigurationBuilder()
.addMemory([("chat:timeoutSeconds", "15")])
.add(RemoteSource(ConfigCenterClient()))
.build()
println(config["chat:timeoutSeconds"]) // Some(30) —— 自定义源后注册,覆盖了 15let manager = ConfigurationManager()
manager.addMemory([("chat:timeoutSeconds", "15")])
manager.add(RemoteSource(ConfigCenterClient())) // add() 内部调了 provider.load()
println(manager["chat:timeoutSeconds"]) // Some(30) —— 立刻可读两个关键点:
build()返回自定义 providerload()把数据写入父类提供的data,键必须是扁平格式
最佳实践
多源分层,一把读
多种来源读法统一,已在核心问题中展开。这里只强调一个实践要点:
let config = ConfigurationManager()
.addJsonFile("appsettings.json")
.addEnvVars("MYAPP_")
.addCmdArgs(args)
.build()
// 不管值最初从哪来的,读法都一样
let apiKey = config["chat:apiKey"]
let model = config["chat:model"]
let timeout = config["chat:timeoutSeconds"]新增配置源只需再加一个 .addXxx()。
该用 ConfigurationBuilder 还是 ConfigurationManager
- 只启动时一次性组装、构建后只读 →
ConfigurationBuilder。构建即锁定,避免误操作。 - 需要运行时动态改配置、收到信号后重新加载 →
ConfigurationManager。支持reload()且加了源立刻可读。
敏感信息不进文件
密码、API 密钥、连接串中的凭据,不要写进 appsettings.json(会被提交到版本库)。用环境变量或命令行参数注入,让 JSON 文件只放非敏感的默认值。
bind 优于逐个 getValue
当配置节的结构和类字段一一对应时,用 bind("chat", options) 这类一次性映射,不要逐个 getValue<T>()。少写代码,少写错键名。
相关专题
- 选项模式 — 用
IOptions<T>替代config["key"],字符串 key 变成编译期可检查的字段访问 - 日志记录 — 日志系统通过
addConfiguration()读取配置中的级别和过滤规则 - 依赖注入 — 将
IConfiguration注册进容器,服务通过构造函数统一获取配置 - 宿主 —
HostBuilder和WebHostBuilder自动组装默认配置源并注入容器
生产建议
addJsonFile默认optional: true——文件不存在静默跳过。生产环境建议显式指定optional: false,避免因文件路径错误导致配置静默缺失。- 密码、密钥不要进 JSON 文件。用环境变量注入敏感值,上层通过
addEnvVars()覆盖。 - 只在启动阶段组装配置时优先用
ConfigurationBuilder;确实需要运行时重载时再用ConfigurationManager。 - 多源组合时把默认值放前面,把环境变量和命令行放后面,保证覆盖顺序可预测。
常见问题
该用 ConfigurationBuilder 还是 ConfigurationManager?
需要运行时动态改配置 → ConfigurationManager。只启动时一次性组装 → ConfigurationBuilder。
键不存在和值无法转换的区别?
键不存在 → 返回 None,不抛异常。 值无法转换(如 "abc" 转 Int64)→ 抛异常。
写入配置会持久化到文件吗?
不会。 写入只改内存。要持久化需要自己实现文件回写。
怎么排查"为什么读不到值"?
导出所有键值对逐项排查:
for ((key, value) in config.asEnumerable()) {
println("${key} = ${value}")
}常见原因:① 路径写错了 ② 被后面的源覆盖了 ③ 配置源没加载到数据。