Rust项目实战:配置文件读取

后端 / 2023-01-04

写在前面

在阅读本文之前,你至少要掌握 Rust 相关基础知识。 包括但不限于 struct trait 异步编程 错误处理等。
通过阅读本文,你将了解。

  • tokio
  • etcd
  • lazy_static
  • serde

这些库的使用。

以及,Rust异步编程 JOSN/YAML 序列化/反序列化 Rust编程范式 可分离设计 等思想。

抓好方向盘,准备发车了!

项目背景

在平时开发中,我们难免使用第三方工具,以及中间件。 例如 数据库 消息队列 各种中间件 等。 平时我们为了方便起见,通常将这些 配置文件 作为变量存储起来。 对于个人小的项目来说,这样做无可厚非。 但是针对企业开发场景下: 我们可能要使用很多的中间件。 例如: mongodb postgresql redis rabbitmq 等各种数据库和中间件。 如果我们将这些配置项,写入到程序中去。是一件比较危险的事情。 因此 如何合理的管理配置文件,是一件非常重要的事情。 那我们该如何管理这些配置文件呢?


在分布式架构中,我们通常有一个叫做 配置中心 的服务来管理我们这些配置文件。 在这里为了方便起见,我采用ETCD 作为 配置中心。

ETCD 是什么? (传送门)

组织项目结构

好的设计,不仅可以提高代码的可读性,同时也提升了程序的鲁棒性。 因此如何如何设计好一个系统是一件尤为重要的事情。

回归到我们的这个问题: 需求是将这些敏感的配置文件,抽离到配置中心去。 然后我们程序加载的时候从配置中心读取,防止配置文件在代码中泄露,从而尽可能的规避风险。

那么这里有两个问题: 选择哪个配置中心? 从哪里读?

大家要知道现有的配置中心,不止ETCD一种,当然如果可以的话,你甚至可以将配置文件写到 web 服务中。 这样在需要配置文件的时候发送http/rpc 请求。然后我们再加载。


这里就是我们的切入点,我们要“照顾”不同的配置中心,那么系统的可扩展性就显得尤为重要。

其实从不同的配置中心中获取配置文件,他们有一个共同的特性。 就是 那么我们该如何让我们的系统适配不同的的配置中心呢?

这里学过OOP的小伙伴举手了,通过多态不就可以解决吗

非常好! 多态是面向对象三大特性之一, 多态:指为不同数据类型的实体提供统一的接口。 在 Rust 中通过 trait 实现。

什么是多态? (传送门)

configurable.rs

#[async_trait]
pub trait Configurable {
    async fn config(&self) -> String;
}

这里我们创建了一个Configurable trait 其中提供了 一个 config 方法,用来读取配置。 因为考虑到一些耗时场景,我们这里使用了 async_trait。 Rust trait 本身是不支持 async 的。我们这里需要添加 cargo 依赖。

async-trait = "0.1.60"

这样我们的trait 方法就成为了异步方法。

实现 ETCDConfig

我们创建一个 结构体 叫做 ETCDConfig 并为其实现 Configurable trait。


pub struct ETCDConfig {
    uri: String,
}

impl ETCDConfig {
    pub fn new<T>(uri: T) -> Self
    where
        T: Into<String>,
    {
        Self { uri: uri.into() }
    }
}

impl Default for ETCDConfig {
    fn default() -> Self {
        ETCDConfig::new(ETCD_URL)
    }
}

#[async_trait]
impl Configurable for ETCDConfig {
    async fn config(&self) -> String {
        let config_key = "/xdai-config/xdai-session.yaml";
        let mut client = Client::connect([&self.uri], None)
            .await
            .expect("连接etcd失败");
        let resp = client.get(config_key, None).await.expect("读取配置失败");
        if let Some(kv) = resp.kvs().first() {
            return kv.value_str().unwrap().into();
        }
        "".into()
    }
}


乍一看 代码好多~
不要慌 我这里将详细解释 为什么这样写,以及这样写的目的。

  • 首先我们创建了结构体,因为考虑到 etcd 地址原因 这里我们添加一个 uri 字段,用来存放 etcd 地址信息。

  • 紧接着我们为该结构体实现了 new 方法。

    • 注意看这里 new 方法是一个泛型方法
    • 这是为了适配不同的数据类型
    • 然后 我们使用了 where 关键字 进行类型限制。
    • 要求 T 必须实现了 Into 这个trait。
    • 也就是说 这里传递进来的T对象必须满足 可转换成String。
  • 接下来我们说这样的好处

    • Rust 中 “” 和 String 是两种完全不同的类型。
    • 但是他们之间可以相互转换。
    • 借助这个特性 我们做此约束。
    • 这样我们不关心传递进来的是什么对象或者字符串,只要他能转换成字符串我就接受。
  • Rust 真是灵活!

    • 我们为该结构体实现了 default 方法。
    • 这样即便不传入任何数据我们也能保证程序正常运行。
  • 最后我们实现了 Configurable

  • 他返回一个String 代表我们从ETCD中读取的配置。

进行单元测试

mod test {
    use crate::etcd_config::{Configurable, ETCDConfig};

    #[tokio::test]
    pub async fn test_read() {
        let config = ETCDConfig::default();
        let cfg = config.config().await;
        println!("{}", cfg);
    }
}

实现 Config 配置类

#[derive(Debug, Serialize, Deserialize)]
pub struct Mongo {
    dsn: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Redis {
    dsn: String,
    password: Option<String>,
    db: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Postgresql {
    dsn: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct RabbitMQ {
    dsn: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    pub mongo: Mongo,
    pub redis: Redis,
    pub postgresql: Postgresql,
    pub lapin: RabbitMQ,
}

这里就是一些简单的 结构体定义。 包含了 MongoDB Redis Postgresql Rabbitmq 这四个常用组件。

这里需要说明下结构体上面的 derive 宏。

  • Debug 调试结构体参数时使用
  • Serialize serde库 用于序列化结构体
  • Deserialize serde库 用于反序列化结构体

这里说下 serde 是 Rust 一个 非常优秀的 序列化/反序列化库。

serde_yaml = "0.9.16"
serde = {version = "1.0.152",features=["derive"]}

实现 read 方法


impl Config {
    pub async fn read(cfg: impl Configurable) -> Result<Config, Error> {
        let buf = cfg.config().await;
        // TODO: 反序列化
        // TODO: 生成 config 实例
        return Ok(buf.into());
    }
}

这里我们 为 config 实现了一个 read 方法。注意这里的 read 方法并没有 &self。 也就是说我们需要通过 Config::read() 方式进行调用。

cfg 参数 我们做了个 静态转发。 要求这里的 cfg 必须实现 Configurable trait 这样 就能满足了我们多态的需求。
对于调用者而言:只需要实现 Configurable trait 无序关系read 内部实现。
对于read 方法而言: 只需要 调用实现了 config 的方法 无需关心这个对象是谁。

这真是一种非常巧妙的设计!!

然后我们函数返回类型是一个 Result 类型。 Result 类型是 Rust 内置的类型。他是一个枚举类型,函数签名如下。

pub enum Result<T, E> {
    /// Contains the success value
    #[lang = "Ok"]
    #[stable(feature = "rust1", since = "1.0.0")]
    Ok(#[stable(feature = "rust1", since = "1.0.0")] T),

    /// Contains the error value
    #[lang = "Err"]
    #[stable(feature = "rust1", since = "1.0.0")]
    Err(#[stable(feature = "rust1", since = "1.0.0")] E),
}

千万不要被这一大串吓到。 其实他只是声明了两个枚举而已。 Ok 代表成功 Err 代表失败。

这里不得不提,Rust错误设计真的巧妙而又优雅。

比某些 if err != nil 语言要好太多。

hhh~ 又扯远了

回到我们的代码,函数起签名就是如果代码没有出现错误那么返回一个Ok 其中包含我们的 Config,否则返回 Error 包含错误信息。

注意看! 这里的 into方法

我们前面知道 config 函数签名返回的是一个 String 类型 他是怎么变成 Config 类型的呢?

我们接着往下看代码

impl From<String> for Config {
    fn from(e: String) -> Self {
        serde_yaml::from_str(e.as_str()).expect("序列化失败")
    }
}

唔~ 原来如此。 不禁感叹 Rust 优美的语言哲学。 通过 From<String> trait 我们就实现了 从 String 到Config类型的转换。
然后我们通过 serde_yaml 成功将 字符串 反序列化为 我们的Config 对象。


然后我们来简单梳理下:

  • 通过 Config::read 来读取配置项
  • cfg 参数从何而来?
    • 实现了 Configurable trait 所有对象
    • 这里可以是 本地文件、网络配置、配置中心、数据库
    • 无需关心内部实现。只需要调用 config 方法 读取我们想要的配置文件。
  • 如何反序列化对象?
    • 通过 serde derive 宏标记 要序列化的 字段
    • serde_yaml::from_str() 来讲文本反序列化成对象
  • 如何将文本转换成对象?
    • 为 Config 实现 From trait 即可。

单元测试

这里我实现一个 本地配置类。

struct LocalConfig;

#[async_trait]
impl Configurable for LocalConfig{
    async fn config(&self)->String{
        fs::read_to_string("./cfg.yaml").unwrap()
    }
}

#[cfg(test)]
mod test{

    #[tokio::test]
    pub async fn test() {
        enum PropType {
            Local,
            ETCD,
        }

        let mode = PropType::Local;

        let cfg = {
            match mode {
                PropType::Local => Config::read(LocalConfig::default()).await.unwrap(),
                PropType::ETCD => Config::read(ETCDConfig::default()).await.unwrap(),
            }
        };
        dbg!(&cfg);
    }
}


最后通过一个枚举类,来选择不同的配置文件。

输出结果如下:

[src/config.rs:92] &cfg = Config {
    mongo: Mongo {
        dsn: "mongo",
    },
    redis: Redis {
        dsn: "1123213213213",
        password: Some(
            "passwd",
        ),
        db: 1,
    },
    postgresql: Postgresql {
        dsn: "host=321313lll user=postgres password=passwd dbname=xdai port=5432 sslmode=disable TimeZone=Asia/Shanghai",
    },
    lapin: RabbitMQ {
        dsn: "amqp://2424214214142141/",
    },
}