解析一个 INI 文件十分钟就能写完。但一套能上线的配置系统,难点从来不在解析格式上。
真正的难题是:配置有几个来源,怎么合并? 命令行、环境变量、配置文件、默认值,同一个 key 可能出现在多处,最后以谁为准?
这篇文章会从零写一个配置加载器,顺便把这套优先级用类型系统表达出来。
先说清楚优先级
一套稳定的配置系统,优先级几乎都是这个顺序(从高到低):
- 命令行参数
--port 9090(临时覆盖,最高)
- 环境变量
APP_PORT=9090(部署环境决定)
- 环境专用文件
config.prod.ini(按环境区分)
- 基础配置文件
config.ini(项目默认)
- 代码内默认值
port: 8080(保底)
为什么环境变量高于配置文件? 因为容器化部署时,同一个镜像要在不同环境跑,只有环境变量能在不改文件的前提下覆盖。这是十二要素应用(12-Factor App)里明确的一条。
优先级一旦定了,就要在代码里显式表达,而不是散落在 if 里。
第一步:配置值的最小表示
先不急着定义强类型结构体。底层先做一个字符串到字符串的扁平表:
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct Config {
values: HashMap<String, String>,
}
impl Config {
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(|s| s.as_str())
}
/// 用另一份配置覆盖自己(后者优先级更高)
pub fn merge_over(&mut self, other: Config) {
for (k, v) in other.values {
self.values.insert(k, v);
}
}
}
为什么底层先用 HashMap<String, String>? 因为不同来源的值形式不一:命令行是 Vec<String>,env 是 str,配置文件是文本。先统一收敛成字符串,再做类型转换,边界最清晰。
merge_over 就是优先级的实现:后合并的覆盖先合并的。 一行代码,但它是整个系统的心脏。
第二步:解析 INI
选 INI 做示例,因为它足够简单又能体现“层级”:
[server]
port = 8080
host = 127.0.0.1
[database]
url = postgres://localhost/app
max_connections = 10
解析器要处理:注释、节(section)、键值对、空白、引号。
pub fn parse_ini(input: &str) -> Result<Config, ConfigError> {
let mut cfg = Config::default();
let mut section = String::new();
for (lineno, raw_line) in input.lines().enumerate() {
let line = raw_line.trim();
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
// 节头 [section]
if line.starts_with('[') {
if !line.ends_with(']') {
return Err(ConfigError::Syntax { lineno: lineno + 1, msg: "未闭合的节头".into() });
}
section = line[1..line.len() - 1].trim().to_string();
continue;
}
// 键值对
let (key, value) = line.split_once('=').ok_or_else(|| ConfigError::Syntax {
lineno: lineno + 1,
msg: "缺少 '='".into(),
})?;
let full_key = if section.is_empty() {
key.trim().to_string()
} else {
format!("{}.{}", section, key.trim())
};
cfg.set(full_key, unquote(value.trim()));
}
Ok(cfg)
}
/// 去掉成对的引号,保留内部内容
fn unquote(s: &str) -> String {
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
节名拼进 key 的方式很关键。 用 server.port 这种扁平 key,而不是嵌套结构,是为了让“合并”这一步保持简单——嵌套结构的深合并(一个 key 在 A 里有、B 里没有)逻辑会复杂得多。
配置系统的扁平化不是偷懒,是让合并语义保持可预测的工程选择。
第三步:解析环境变量
环境变量的 key 风格和配置文件不同:大写、下划线分隔、带前缀。
APP_SERVER_PORT=9090 -> server.port
APP_DATABASE_URL=... -> database.url
需要做一次转换:
pub fn from_env(prefix: &str) -> Config {
let mut cfg = Config::default();
for (key, value) in std::env::vars() {
let Some(rest) = key.strip_prefix(prefix) else { continue };
// APP_SERVER_PORT -> server.port
let normalized = rest.to_lowercase().replacen("__", ".", usize::MAX);
cfg.set(normalized, value);
}
cfg
}
这里有个设计选择:用 __(双下划线)表示层级分隔,单下划线当作名字的一部分。
APP_SERVER__PORT -> server.port
APP_DATABASE__URL -> database.url
APP_MY_SERVICE__PORT -> my_service.port
如果一个下划线就分层,那 my_service 这种名字就没法表达了。 这个 __ 约定在很多框架里都能看到(Spring Boot、.NET 配置),原因是同一个:单下划线太常见,不能当分隔符。
第四步:命令行覆盖
命令行参数最简单,因为格式已经定了:
myapp --port 9090 --database.url=postgres://...
pub fn from_args(args: &[String]) -> Config {
let mut cfg = Config::default();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if let Some(rest) = arg.strip_prefix("--") {
if let Some((k, v)) = rest.split_once('=') {
cfg.set(k.to_string(), v.to_string());
} else if let Some(next) = args.get(i + 1) {
cfg.set(rest.to_string(), next.clone());
i += 1;
}
}
i += 1;
}
cfg
}
注意命令行不强制 key 是已知的。 这一点后面会讲怎么校验。
把它们按优先级串起来
现在到了核心:合并顺序。
pub fn load(args: &[String]) -> Result<Config, ConfigError> {
// 从低到高依次合并
let mut cfg = defaults(); // 5. 代码默认值
cfg.merge_over(parse_ini(&read_file("config.ini")?)?); // 4. 基础文件
let env_name = std::env::var("APP_ENV").unwrap_or_else(|_| "dev".into());
let env_file = format!("config.{env_name}.ini");
if let Ok(content) = std::fs::read_to_string(&env_file) {
cfg.merge_over(parse_ini(&content)?); // 3. 环境专用文件
}
cfg.merge_over(from_env("APP_")); // 2. 环境变量
cfg.merge_over(from_args(args)); // 1. 命令行
Ok(cfg)
}
合并顺序从上往下读,就是优先级的从低到高。 这段代码的价值在于:优先级不是藏在各个 if 里,而是线性地、可读地表达出来了。任何人看一眼就知道谁覆盖谁。
优先级逻辑最好的形态是一串线性调用。一旦它变成嵌套的判断树,就离出 bug 不远了。
第五步:从扁平表构造强类型配置
前面都是字符串。业务代码要的是结构化、有类型、有默认值的配置。
#[derive(Debug)]
pub struct AppConfig {
pub server: ServerConfig,
pub database: DatabaseConfig,
}
#[derive(Debug)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
}
转换时,每个字段的读取都要处理“缺失”和“类型错误”两种情况:
impl AppConfig {
pub fn from_config(cfg: &Config) -> Result<Self, ConfigError> {
Ok(AppConfig {
server: ServerConfig {
host: cfg.get("server.host").unwrap_or("127.0.0.1").to_string(),
port: parse_field(cfg, "server.port", 8080)?,
},
database: DatabaseConfig {
url: cfg.get("database.url")
.ok_or_else(|| ConfigError::Missing("database.url".into()))?
.to_string(),
max_connections: parse_field(cfg, "database.max_connections", 10)?,
},
})
}
}
fn parse_field<T: std::str::FromStr>(
cfg: &Config,
key: &str,
default: T,
) -> Result<T, ConfigError> {
match cfg.get(key) {
None => Ok(default),
Some(raw) => raw.parse().map_err(|_| ConfigError::Invalid {
key: key.to_string(),
value: raw.to_string(),
}),
}
}
parse_field 这个泛型辅助函数把“默认值 + 类型转换 + 错误上下文”三件事一次解决。它的错误信息必须带上 key 和原始值:
配置错误: server.port = "abc" 不是合法的 u16
对比 Invalid digit found in string 这种错误——两者排查效率差一个数量级。
三个必须做对的细节
1. 校验发生在启动时,不是运行到才报错。
pub fn validate(cfg: &AppConfig) -> Result<(), ConfigError> {
if cfg.server.port == 0 {
return Err(ConfigError::InvalidRule("server.port 不能为 0".into()));
}
if !cfg.database.url.starts_with("postgres") && !cfg.database.url.starts_with("mysql") {
return Err(ConfigError::InvalidRule("database.url 协议不支持".into()));
}
Ok(())
}
配置错误应该让进程启动失败,而不是运行三天后在某次请求里才发现端口是空的。这就是所谓 fail-fast。
2. 未知的 key 要有说法。
命令行里用户敲了 --prot 9090(拼错),如果静默忽略,用户会以为生效了。两种策略:
- 严格模式:遇到未知 key 直接报错(开发环境)
- 宽松模式:记录 warning,继续(生产环境,避免环境变量污染导致启动失败)
if strict {
for key in provided_keys(&args) {
if !known_keys.contains(key.as_str()) {
return Err(ConfigError::UnknownKey(key));
}
}
}
3. 敏感值不能进日志。
fn redact(key: &str) -> bool {
let k = key.to_lowercase();
k.contains("password") || k.contains("secret") || k.contains("token") || k.contains("key")
}
打印最终配置时走一遍 redact:
pub fn dump(&self) -> String {
let mut lines: Vec<_> = self.values.iter().collect();
lines.sort_by_key(|(k, _)| k.as_str());
lines
.into_iter()
.map(|(k, v)| {
if redact(k) {
format!("{k} = ****")
} else {
format!("{k} = {v}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
启动时打印完整配置是个好习惯——出问题时第一眼就能看到生效值。但前提是敏感字段必须脱敏。
配置的热更新问题
到这一步,配置是启动时读一次、不可变的。但有些场景需要热更新:调低限流阈值、切换开关。
这里有个重要判断:不是所有配置都该支持热更新。
| 配置类型 |
能否热更新 |
原因 |
| 日志级别 |
✅ |
立即生效无副作用 |
| 限流阈值 |
✅ |
只影响新请求 |
| Feature Flag |
✅ |
设计上就是要动态 |
| 数据库连接串 |
❌ |
改动需要重建连接池 |
| 监听端口 |
❌ |
需要重启服务 |
不可变配置是默认,热更新是例外。 把不该热更新的东西做成动态,只会引入“配置生效了一半”的诡异状态。
真要做,典型实现是 ArcSwap:
use std::sync::Arc;
pub struct DynamicConfig {
inner: arc_swap::ArcSwap<AppConfig>,
}
impl DynamicConfig {
pub fn get(&self) -> Arc<AppConfig> {
self.inner.load().clone()
}
pub fn reload(&self) -> Result<(), ConfigError> {
let new = Arc::new(load_and_validate()?);
self.inner.store(new);
Ok(())
}
}
load().clone() 拿的是 Arc 的拷贝,读路径无锁。热更新的关键在于“整个配置对象原子替换”,而不是逐个字段改——后者会让读到的配置处于不一致的中间状态。
结论
一套配置系统,解析格式只是入口,真正要设计的是这几件事:
优先级用线性的合并顺序显式表达;底层统一扁平化,让合并语义可预测;类型转换必须带 key 和原值的错误上下文;校验在启动时 fail-fast;敏感字段脱敏;热更新只给该热的配置,且用整体替换。
做到这些,配置就从“一堆散落的 env 读取”变成了系统里一块有意义、可验证、可诊断的部分。