Iteration 11 从 4/9 开始到 4/22 结束,为期两周。
这个周期非常快乐,我造了一堆轮子来解决 Databend 的命令行使用体验问题:
- serde-bridge: 将一个值在不同的 serde 实现中进行转换
- serde-env: 支持将环境变量解析为嵌套的结构体
- serfig: 基于 serde 实现的多层配置系统,支持从环境变量,配置文件,自身等多个地方读取并合并配置
最终实现的效果是 Databend 能够按照指定的顺序依次加载来自配置文件,环境变量和命令行参数中的配置:
pub fn load() -> Result<Self> {
let arg_conf: Self = Config::parse();
let mut builder: serfig::Builder<Self> = serfig::Builder::default();
// Load from config file first.
{
let config_file = if !arg_conf.config_file.is_empty() {
arg_conf.config_file.clone()
} else if let Ok(path) = env::var("CONFIG_FILE") {
path
} else {
"".to_string()
};
builder = builder.collect(from_file(Toml, &config_file));
}
// Then, load from env.
builder = builder.collect(from_env());
// Finally, load from args.
builder = builder.collect(from_self(arg_conf));
Ok(builder.build()?)
}
背景
Databend 经历早期的野蛮生长之后,现在终于有时间可以稍微打磨一下使用体验。首当其冲是繁复而不成体系的配置项,以配置 S3 存储的 Bucket 为例:
通过命令行参数配置:
--bucket=abc
通过环境变量配置:
export S3_STORAGE_BUCKET=abc
通过配置文件配置:
[storage.s3]
bucket = "abc"
出现这种状况的一大原因是 clap 的不良设计导致用户使用中出现的畸形姿势:
clap 的 Parser
不支持结构体,所有 args 都必须平铺,导致用户必须为所有的结构体加上 #[clap(flatten)]
:
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Args)]
#[serde(default)]
pub struct StorageConfig {
// azure storage blob config.
#[clap(flatten)]
pub azure_storage_blob: AzureStorageBlobConfig,
}
更糟糕的是,clap 依赖字段名来唯一区分参数,这就要求整个结构体中不得出现重名的字段。比如下列这样的代码能编译,但是无法正常运行:
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Args)]
#[serde(default)]
pub struct StorageConfig {
// S3 storage backend config.
#[clap(flatten)]
pub s3: S3StorageConfig,
// azure storage blob config.
#[clap(flatten)]
pub azure_storage_blob: AzureStorageBlobConfig,
}
#[derive(Clone, PartialEq, Serialize, Deserialize, Args)]
#[serde(default)]
pub struct S3StorageConfig {
/// <root>
#[clap(long, default_value_t)]
pub root: String,
}
#[derive(Clone, PartialEq, Serialize, Deserialize, Args)]
#[serde(default)]
pub struct AzureStorageBlobConfig {
/// <root>
#[clap(long, default_value_t)]
pub root: String,
}
所以大家开始写这样的代码:
#[derive(Clone, PartialEq, Serialize, Deserialize, Args)]
pub struct MetaConfig {
/// The dir to store persisted meta state for a embedded meta store
#[clap(long, env = META_EMBEDDED_DIR, default_value = "./_meta_embedded")]
pub meta_embedded_dir: String,
#[clap(long, env = META_ADDRESS, default_value = "", help = "MetaStore backend address")]
pub meta_address: String,
#[clap(long, env = META_USERNAME, default_value = "", help = "MetaStore backend user name")]
pub meta_username: String,
#[clap(long, env = META_PASSWORD, default_value = "", help = "MetaStore backend user password")]
pub meta_password: String,
}
Args 与 Env 的关系已经非常混乱了,databend 还需要支持从配置文件中加载。为了保障正确的加载顺序,社区甚至开始写宏来强行再次加载环境变量:
macro_rules! env_helper {
($config:expr, $struct: tt, $field:tt, $field_type: ty, $env:expr) => {
let env_var = std::env::var_os($env)
.unwrap_or($config.$struct.$field.to_string().into())
.into_string()
.expect(format!("cannot convert {} to string", $env).as_str());
$config.$struct.$field = env_var
.parse::<$field_type>()
.expect(format!("cannot convert {} to {}", $env, stringify!($field_type)).as_str());
};
}
impl StorageConfig {
pub fn load_from_env(mut_config: &mut Config) {
env_helper!(mut_config, storage, storage_type, String, STORAGE_TYPE);
env_helper!(mut_config, storage, storage_num_cpus, u64, STORAGE_NUM_CPUS);
// DISK.
env_helper!(
mut_config.storage,
fs,
data_path,
String,
FS_STORAGE_DATA_PATH
);
...
}
}
思考
在动手改进之前,首先考虑最理想的状况是怎样的:
- 正确的加载顺序:同名字段会按照
config
->env
->args
的顺序记载,后者覆盖前者 - 统一的命名体系:同一个字段在不同地方使用统一的命名风格,比如说
storage.s3.bucket
,--storage-s3-bucket
,STORAGE_S3_BUCKET
- 减少冗余代码:尽可能减少维护者需要写的重复代码
社区在 Issue bug: config overwrite when specify --config and any other command line args. 中贡献了一个 idea: 将 config-rs 与 clap 结合起来,让 clap 能够作为 config-rs 的一个 Source。我为 config-rs 提交了 proposal: Implement serde::Serializer and Source/AsyncSource for Value,但是在尝试实现 demo 的时候遇到了无法解决的问题,以至于我开始觉得我们需要新的方法和新的思路。
好,跳出来思考这个问题:
配置加载实际上就是按照顺序从不同的地方加载数据,解析成我们的 Config 结构体并进行合并的过程。所以我们需要:
- 将环境变量解析为嵌套的结构体
- 一个统一的数据表示方式
- 将来自不同的地方的数据进行合并
实现
serde-env
最开始我尝试使用了 envy,但是它不支持将环境变量解析为嵌套的结构体,为此我开发了 serde-env:
use serde::Deserialize;
use serde_env::from_env;
#[derive(Debug, Deserialize)]
struct Cargo {
home: String,
}
#[derive(Debug, Deserialize)]
struct Test {
home: String,
cargo: Cargo,
}
fn main() {
let t: Test = from_env().expect("deserialize from env");
assert!(!t.home.is_empty());
assert!(!t.cargo.home.is_empty());
println!("{:?}", t)
}
思路其实很简单,serde-env 内部将环境变量表示为使用 _
分隔的 tree,于是上述例子中的 Test.cargo.home
实际上就能转化为 CARGO_HOME
。
延续这样的思路,serde-env 还能够支持形如这样的结构体:
#[derive(Debug, Deserialize)]
struct Cargo {
test: String,
}
#[derive(Debug, Deserialize)]
struct Test {
home: String,
cargo: Cargo,
cargo_home: String,
}
有效解决了环境变量转化为结构体的问题。
serde-bridge
为了能够处理配置之间的合并,我开发了 serde-bridge:
use anyhow::Result;
use serde_bridge::{from_value, into_value, FromValue, IntoValue, Value};
fn main() -> Result<()> {
let v = bool::from_value(Value::Bool(true))?;
assert!(v);
let v: bool = from_value(Value::Bool(true))?;
assert!(v);
let v = true.into_value()?;
assert_eq!(v, Value::Bool(true));
let v = into_value(true)?;
assert_eq!(v, Value::Bool(true));
Ok(())
}
它是一个到 serde API one-to-one 的 mapping,跟 serde-value 相似,但是更加完整,同时实现了 {De,S}erialize[r]
等类型。任何 serde 实现都可以基于 serde_bridge::Value
作为中间层来进行转换。
serfig
在上述库的支持下,serfig 通过 serde_bridge::Value
来合并配置并对外暴露 Builder
的接口:
use serde::{Deserialize, Serialize};
use serfig::collectors::{from_env, from_file, from_self};
use serfig::parsers::Toml;
use serfig::Builder;
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
struct TestConfig {
a: String,
b: String,
c: i64,
}
fn main() -> anyhow::Result<()> {
let builder = Builder::default()
.collect(from_env())
.collect(from_file(Toml, "config.toml"))
.collect(from_self(TestConfig::default()));
let t: TestConfig = builder.build()?;
println!("{:?}", t);
Ok(())
}
跟 clap 的整合也非常容易,强大的 serde_bridge::Value
使得我们能够将结构体本身也作为一个数据源 from_self
,以 Databend 为例:
pub fn load() -> Result<Self> {
let arg_conf: Self = Config::parse();
let mut builder: serfig::Builder<Self> = serfig::Builder::default();
// Load from config file first.
{
let config_file = if !arg_conf.config_file.is_empty() {
arg_conf.config_file.clone()
} else if let Ok(path) = env::var("CONFIG_FILE") {
path
} else {
"".to_string()
};
builder = builder.collect(from_file(Toml, &config_file));
}
// Then, load from env.
builder = builder.collect(from_env());
// Finally, load from args.
builder = builder.collect(from_self(arg_conf));
Ok(builder.build()?)
}
我们首先使用 Config::parse()
来加载命令参数,然后在最后使用 from_self(arg_conf)
来覆盖前面获取到的数据。
后续
目前的实现还有不少问题,我们仍未解决 #[clap(flatten)]
导致的各种问题:
pub struct AzblobStorageConfig {
/// Endpoint URL for Azblob
///
/// # TODO(xuanwo)
///
/// Clap doesn't allow us to use endpoint_url directly.
#[clap(long = "storage-azblob-endpoint-url", default_value_t)]
#[serde(rename = "endpoint_url")]
pub azblob_endpoint_url: String,
/// # TODO(xuanwo)
///
/// Clap doesn't allow us to use root directly.
#[clap(long = "storage-azblob-root", default_value_t)]
#[serde(rename = "root")]
pub azblob_root: String,
}
- 相同的字段还是会冲突
- 需要手动指定 clap 的
long
字段
未来可能会想办法自行实现 clap Parser
来彻底解决这些不一致的问题。
总结
快乐的造轮子周期,以至于这周一直在发 #今天用
而不是 #今天学
,下个周期还是要多输入一些东西~