Skip to content

配置管理

用 ConfigurationBuilder 或 ConfigurationManager 收敛多源配置,统一为 config["section:key"] 一种读法。

适用场景

  • 数据库连接串、API 密钥、端口号等写在代码里,改一次就得重新编译部署。
  • 开发、测试、生产三套环境,每次切换都要改一堆常量。
  • 配置来源分散——JSON 文件用 JsonValue.fromStr()、环境变量用 getVariable()、命令行参数先用 getCommandLine() 获取、再交给 std.argopt 解析——每种来源一套读法。
  • 希望环境变量能自动覆盖 JSON 默认值,不需要手写 if-else 优先级判断。
cangjie
class ChatClient {
    public init() {
        let apiKey = "sk-xxx"
        let model = "gpt-4o"
        // 模型或密钥变了 → 改代码、编译、部署。密钥还会进版本库。
    }
}

配置系统解决的不是"能读多少种来源"。

它真正解决的是:不管多少种来源,读法都只有一种。

核心问题

配置管理把配置数据从代码中抽出来,统一解决三个问题:

  • 值从哪来:JSON 文件、环境变量、命令行参数。
  • 优先级谁高:后注册覆盖先注册。
  • 怎么读:统一为 config["section:key"]

换个写法:

cangjie
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提供 ConfigurationManagerConfigurationBuilderIConfigurationIConfigurationRoot 等核心类型始终需要

快速开始

引入 soulsoft_extensions_configuration 后,三步走通最小路径:创建管理器 → 链式添加源 → 构建并读取

cangjie
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-4oChat:Model = "gpt-4o"
命令行参数--chat:model=gpt-4ochat:model = "gpt-4o"
内存默认值[("chat:model", "gpt-4o")]chat:model = "gpt-4o"

build() 会把可变注册固化下来。读取时从尾向前遍历 Provider 数组,因此后注册的覆盖先注册的。

getSection("db") 只做一件事:在 key 前面拼上 "db:"。所以 section["host"] 等价于 config["db:host"]

两种构建器

有两种入口,区别只在于:添加源之后能不能立刻读值。

cangjie
let builder = ConfigurationBuilder()
builder.addMemory([("chat:model", "gpt-4o")])
builder.addJsonFile("appsettings.json")

// builder 不能读配置——它没有 [] 索引器
let config = builder.build()
println(config["chat:model"])   // 现在才能读
cangjie
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

下面逐一展开用法。

内存配置

键值对直接写在代码里,不做额外转换,适合默认值和单元测试。两种用法:

cangjie
let config = ConfigurationManager()
    .addMemory([
        ("chat:model", "gpt-4o"),
        ("chat:baseUrl", "https://api.openai.com")
    ])
    .build()
cangjie
let config = ConfigurationManager()
    .addMemory()
    .build()

config["chat:model"] = "gpt-4o-mini"

JSON 配置

JSON 源分两种入口:

  • .addJsonString(...):适合嵌入式配置或测试数据。
  • .addJsonFile(...):适合本地配置文件和环境差异化配置。

共同规则:

  • 嵌套对象会展平为 parent:child
  • 数组会展开成 key:0key:1 这类索引键。
  • 非法 JSON 会抛异常。

只有 addJsonFile(...) 额外有一条文件规则:

  • optional: true 时,文件缺失会静默跳过。
  • optional: false 时,文件缺失会直接抛异常。

文件路径解析规则:

  • addJsonFile("appsettings.json") 这种相对路径,是相对当前工作目录解析的。
  • 在最常见的 cjpm run 用法下,当前工作目录通常就是项目根目录。
  • 因此日常项目里,appsettings.json 通常就放在和 cjpm.toml 同级的位置。

最常见的目录结构就是这样:

text
your-project/
├── cjpm.toml
├── appsettings.json
└── src/
    └── main.cj
cangjie
let config = ConfigurationBuilder()
    .addJsonString(###"{"chat":{"model":"gpt-4o","timeoutSeconds":30}}"###)
    .build()
cangjie
let config = ConfigurationBuilder()
    .addJsonFile("appsettings.json")                         // 通常就是 cjpm.toml 同级的 appsettings.json
    .addJsonFile("appsettings.Production.json", optional: false)  // 文件缺失 → 抛异常
    .build()
cangjie
let config = ConfigurationManager()
    .addJsonString(###"{"servers":["node-a","node-b"]}"###)
    .build()

println(config["servers:0"])  // Some("node-a")
println(config["servers:1"])  // Some("node-b")

环境变量

环境变量源适合容器注入、部署时覆盖和敏感值下发。

核心规则只有两条:

  • 双下划线 __ 会映射成路径分隔符 :
  • 如果传了前缀,加载时会先去掉前缀。前缀匹配同样忽略大小写。
cangjie
let config = ConfigurationBuilder()
    .addEnvVars()
    .build()
cangjie
let config = ConfigurationBuilder()
    .addEnvVars("MYAPP_")
    .build()

双下划线 __ 映射成路径分隔符 :

环境变量读法
MYAPP_Chat__Model=gpt-4oconfig["Chat:Model"]

常见设值方式:

powershell
$env:MYAPP_Chat__Model = "gpt-4o-mini"
cjpm run
json
{
    "terminal.integrated.env.windows": {
        "MYAPP_Chat__Model": "gpt-4o-mini"
    }
}
bash
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>) 收到的那组参数。

最常见的传入方式:

bash
cjpm run -- --chat:model=gpt-4o-mini --chat:timeoutSeconds=60
powershell
.\target\release\bin\main.exe --chat:model=gpt-4o-mini --chat:timeoutSeconds=60
docker
docker run myapp --chat:model=gpt-4o-mini --chat:timeoutSeconds=60

cjpm run -- --chat:model=gpt-4o-mini 这种写法中,-- 之后的内容会原样传给可执行程序,再由 addCmdArgs(args) 解析。

cangjie
let args = ["--chat:model=gpt-4o", "--chat:timeoutSeconds", "30"]

let config = ConfigurationBuilder()
    .addCmdArgs(args)
    .build()

println(config["chat:model"])  // Some("gpt-4o")
cangjie
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

cangjie
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

cangjie
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。这三种写法应该指向同一个配置项,所以系统统一按不区分大小写处理:

cangjie
let config = ConfigurationManager()
    .addMemory([("Chat:Model", "gpt-4o")])
    .build()

println(config["chat:model"])      // 都能读到
println(config["CHAT:MODEL"])      // 同一个值

getValue<T> 类型转换

配置存储的值本质上都是字符串。想要数字、布尔值,就用 getValue<T>()

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

支持的类型:StringRuneBoolInt8 / Int16 / Int32 / Int64UInt8 / UInt16 / UInt32 / UInt64Float16 / Float32 / Float64BigIntDecimal

两种失败情况

  • 键不存在 → 返回 None,不抛异常
  • 值存在但无法转换(如 "abc"Int64)→ 抛异常

getSection 读取子节点

如果要围绕一个子节点反复读取,用 getSection() 比每次写完整路径更清晰:

cangjie
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() 内部只是把当前路径和子键用 : 拼接。即使节点不存在,也会返回一个空的配置节对象;它的 valueNone,不会抛异常。

getChildren 遍历子节点

getChildren() 只取直接子节点,不会递归展开整棵子树:

cangjie
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: None

headers 本身还有子节点(headers:userAgent),所以它的 valueNone,但依然出现在子节点列表中。

读取速查

需求方法
获取字符串值config["key"]
获取并转换类型config.getValue<T>("key")
读取子节点config.getSection("key")
遍历子节点section.getChildren()
检查节是否存在section.exists()
导出所有键值对config.asEnumerable()

绑定到对象

不想一个个 getValue<T>()?用 bind() 把整个配置节映射到对象。核心是 bind("chat", options) 这一行——自动遍历对象的 var 字段并与配置键匹配:

cangjie
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 内存中的数据,不会回写到原始来源

cangjie
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" 写回配置根:

cangjie
let chat = config.getSection("chat")
chat["model"] = "gpt-4.1"
println(config["chat:model"])  // Some("gpt-4.1")

自定义配置源

如果内置源不够用(比如从配置中心拉取),扩展只需两步:继承 ConfigurationProvider,重写 load()——把数据写入父类提供的 data 属性,键必须是扁平格式(app:name,不能嵌套):

cangjie
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) 添加,后注册的覆盖先注册的:

cangjie
let config = ConfigurationBuilder()
    .addMemory([("chat:timeoutSeconds", "15")])
    .add(RemoteSource(ConfigCenterClient()))
    .build()

println(config["chat:timeoutSeconds"])  // Some(30) —— 自定义源后注册,覆盖了 15
cangjie
let manager = ConfigurationManager()
manager.addMemory([("chat:timeoutSeconds", "15")])
manager.add(RemoteSource(ConfigCenterClient()))   // add() 内部调了 provider.load()
println(manager["chat:timeoutSeconds"])  // Some(30) —— 立刻可读

两个关键点:

  • build() 返回自定义 provider
  • load() 把数据写入父类提供的 data,键必须是扁平格式

最佳实践

多源分层,一把读

多种来源读法统一,已在核心问题中展开。这里只强调一个实践要点:

cangjie
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 注册进容器,服务通过构造函数统一获取配置
  • 宿主HostBuilderWebHostBuilder 自动组装默认配置源并注入容器

生产建议

  • addJsonFile 默认 optional: true——文件不存在静默跳过。生产环境建议显式指定 optional: false,避免因文件路径错误导致配置静默缺失。
  • 密码、密钥不要进 JSON 文件。用环境变量注入敏感值,上层通过 addEnvVars() 覆盖。
  • 只在启动阶段组装配置时优先用 ConfigurationBuilder;确实需要运行时重载时再用 ConfigurationManager
  • 多源组合时把默认值放前面,把环境变量和命令行放后面,保证覆盖顺序可预测。

常见问题

该用 ConfigurationBuilder 还是 ConfigurationManager?

需要运行时动态改配置 → ConfigurationManager。只启动时一次性组装 → ConfigurationBuilder

键不存在和值无法转换的区别?

键不存在 → 返回 None,不抛异常。 值无法转换(如 "abc"Int64)→ 抛异常

写入配置会持久化到文件吗?

不会。 写入只改内存。要持久化需要自己实现文件回写。

怎么排查"为什么读不到值"?

导出所有键值对逐项排查:

cangjie
for ((key, value) in config.asEnumerable()) {
    println("${key} = ${value}")
}

常见原因:① 路径写错了 ② 被后面的源覆盖了 ③ 配置源没加载到数据。