解决 Databend 命令行参数加载问题

前言

Iteration 11[1] 从 4/9 开始到 4/22 结束,为期两周。

这个周期非常快乐,我造了一堆轮子来解决 Databend 的命令行使用体验问题:

  • serde-bridge[2]:将一个值在不同的 serde 实现中进行转换
  • serde-env[3]:支持将环境变量解析为嵌套的结构体
  • serfig[4]:基于 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.[5] 中贡献了一个 idea:将 config-rs[6] 与 clap 结合起来,让 clap 能够作为 config-rs 的一个 Source。我为 config-rs 提交了 proposal: Implement serde::Serializer and Source/AsyncSource for Value[7],但是在尝试实现 demo 的时候遇到了无法解决的问题,以至于我开始觉得我们需要新的方法和新的思路。

好,跳出来思考这个问题:

配置加载实际上就是按照顺序从不同的地方加载数据,解析成我们的 Config 结构体并进行合并的过程。所以我们需要:

  • 将环境变量解析为嵌套的结构体

  • 一个统一的数据表示方式

  • 将来自不同的地方的数据进行合并

实现

serde-env

最开始我尝试使用了 envy[8],但是它不支持将环境变量解析为嵌套的结构体,为此我开发了 serde-env[9]:

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[10]:

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[11] 相似,但是更加完整,同时实现了 {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 来彻底解决这些不一致的问题。

总结

快乐的造轮子周期,以至于这周一直在发 #今天用 而不是 #今天学,下个周期还是要多输入一些东西~

引用链接

[1]Iteration 11: https://github.com/users/Xuanwo/projects/2/views/1?filterQuery=iteration%3A%22Iteration+11%22
[2] serde-bridge: https://github.com/Xuanwo/serde-bridge
[3] serde-env: https://github.com/Xuanwo/serde-env
[4] serfig: https://github.com/Xuanwo/serfig
[5] bug: config overwrite when specify --config and any other command line args.: https://github.com/datafuselabs/databend/issues/4362
[6] config-rs: https://github.com/mehcode/config-rs
[7] proposal: Implement serde::Serializer and Source/AsyncSource for Value: https://github.com/mehcode/config-rs/issues/315
[8] envy: https://github.com/softprops/envy
[9] serde-env: https://github.com/Xuanwo/serde-env
[10] serde-bridge: https://github.com/Xuanwo/serde-bridge
[11] serde-value: https://github.com/arcnmx/serde-value

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,907评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,987评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,298评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,586评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,633评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,488评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,275评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,176评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,619评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,819评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,932评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,655评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,265评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,871评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,994评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,095评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,884评论 2 354

推荐阅读更多精彩内容