写在前面
在阅读本文之前,你至少要掌握 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 即可。
- 为 Config 实现 From
单元测试
这里我实现一个 本地配置类。
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/",
},
}