项目初始化四大件:命令行参数、配置、日志、测试用例

本课程为精品小课,不标配音频

你好,我是文强。

上节课我们完成了项目的初始化,这节课我们来完成 如何处理命令行参数如何管理配置如何记录日志如何运行测试用例 四个任务。有了这四个基础部分,项目的基础模块就基本成型了。

处理命令行参数

Rust 处理命令行参数推荐使用 clap 这个库。

这里,我想同时跟你聊一下如何更好地使用前面提过的 https://crates.io/ 这个网站,它是 Rust 的公共库的代码仓库。

在我看来, 能不能把 crates.io 用好,决定了你能不能学好Rust 这个语言。 接下来我们就以使用 clap 库来处理命令行参数这个 case 来讲解一下如何用好 crates.io。

比如我们需要使用库 clap,下图是 clap 库的首页。一般在首页都会有关于这个库详细的使用说明,比如使用方法、使用 demo。所以你得重视这个页面,在这个页面可以得到很多信息。另外需要关注右下角两个链接,一个是库的 Rust 文档地址,格式是统一的;另一个一般是源码所在地址,一般是 GitHub 仓库的地址。

这里有个技巧是: 如果在文档中找不到你想要的信息,可以尝试去 GitHub 仓库找,GitHub 仓库一般有更详细的 example 信息。

图片

如下图所示,库的文档首页是统一格式的。它会展示库中 Modules、Macros、Structs、Enums、Traits、Type Aliases 六部分信息,分别会列举库中的 mod、宏、结构体、枚举、Trait、Type Alias 等相关信息。你可以根据需要查看对应部分的内容。

图片

比如我想知道 clap 要怎么用,那就直接看首页的 demo 即可。

图片

接下来来看一下我们的项目是怎么处理命令行参数的。在当前阶段,我们需求是: 能从命令行接收配置文件路径,则代码应该在 src/cmd/src/metadata-service/server.rs 中,代码如下:

use clap::command;
use clap::Parser;

// 定义默认的配置路径,即当命令行没传配置路径时,默认的配置文件路径
pub const DEFAULT_PLACEMENT_CENTER_CONFIG: &str = "config/placement-center.toml";

// 定义接收哪些参数
#[derive(Parser, Debug)]
#[command(author="robustmq-geek", version="0.0.1", about=" RobustMQ: Next generation cloud-native converged high-performance message queue.", long_about = None)]
#[command(next_line_help = true)]
struct ArgsParams {
    #[arg(short, long, default_value_t=String::from(DEFAULT_PLACEMENT_CENTER_CONFIG))]
    conf: String,
}

fn main() {
    // 解析命令行参数
    let args = ArgsParams::parse();
    println!("conf path: {:?}", args.conf);
}

这里,处理命令行参数主要分为两步:

  1. 定义一个结构体 ArgsParams,结构体包含自定义的 conf 属性,即需要接收的配置文件路径。

  2. 通过 ArgsParams::parse() 解析参数。

上面的代码可以通过下面的命令执行得到结果:

cargo run --package cmd --bin placement-center -- --conf=config/placement-center.toml

效果如下所示:

图片

如果要了解更多关于 clap 这个库的语法,可以看它在 crates 的官方文档。

管理静态配置

在管理项目配置文件时,第一件事就是要思考: 要用哪种格式的配置文件。有 Java 背景的同学会习惯用 yaml 或 properties 的配置文件,另外可能可能会用到 json、toml 等配置文件。

那应该用哪种呢?我在一开始写 Rust 的时候也遇到了这个问题。后来我研究后的结果是: 使用 toml 文件

原因很简单,一是因为 toml 格式简单好用,能满足各种配置场景;二是因为大部分开源的 Rust 项目都是使用 toml 格式的配置文件。

那么如何读取和处理 toml 格式的配置文件呢?在 Rust 中,建议通过 toml 这个库,这个库的功能比较简单,你可以先不看下面的代码,去看一下 crates.io 上的内容,看是否知道怎么写。

接下来我们来开发我们的配置文件模块,总共分为三步。

  1. 定义我们元数据服务的配置文件 placement-center.toml,先定义节点 ID 和节点监听的 GRPC 端口两个属性
#![allow(unused)]
fn main() {
node_id = 1 # 节点 ID
grpc_port = 1228 # 节点监听的 GRPC 端口

}
  1. 定义解析配置文件的结构体
#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Debug, Deserialize, Clone, Default)]
pub struct PlacementCenterConfig {
    #[serde(default = "default_node_id")]
    pub node_id: u32,
    #[serde(default = "default_grpc_port")]
    pub grpc_port: usize,
}

pub fn default_node_id() -> u32 {
    1
}

pub fn default_grpc_port() -> usize {
    9982
}

}
  1. 解析配置文件
#![allow(unused)]
fn main() {
static PLACEMENT_CENTER_CONF: OnceLock<PlacementCenterConfig> = OnceLock::new();

pub fn init_placement_center_conf_by_path(config_path: &String) -> &'static PlacementCenterConfig {
    // n.b. static items do not call [`Drop`] on program termination, so if
    // [`DeepThought`] impls Drop, that will not be used for this instance.
    PLACEMENT_CENTER_CONF.get_or_init(|| {
        let content = read_file(config_path);
        let pc_config: PlacementCenterConfig = toml::from_str(&content).unwrap();
        return pc_config;
    })
}

pub fn placement_center_conf() -> &'static PlacementCenterConfig {
    match PLACEMENT_CENTER_CONF.get() {
        Some(config) => {
            return config;
        }
        None => {
            panic!(
                "Placement center configuration is not initialized, check the configuration file."
            );
        }
    }
}

}

上面的代码不复杂,主要有两步:

  1. #[serde(default = "default_node_id")] 的使用,这个语法是定义配置 node_id的默认值,即如果没有配置 node_id 的时候,node_id 的默认值是多少。其中default_node_id是一个函数名,对应上面的 fn default_node_id(), 所以默认值是这个函数的返回值。

  2. 第二个是下面这两行代码,表示读取指定配置文件的内容,并让 toml::from_str 解析配置文件的内容。

#![allow(unused)]
fn main() {
let content = read_file(config_path);
let pc_config: PlacementCenterConfig = toml::from_str(&content).unwrap();

}

理论上 1 和 2 就完成了配置的管理。这里如果每次获取配置都执行 2 来获取配置内容,那每次都要解析文件,就太重了,就得想办法把配置文件缓存到内存里面,比如某个静态变量。

此时,我们是通过OnceLock这个语法来实现的。OnceLock 都是 Rust 标准库中用于实现懒加载的数据结构, 它能够确保一个变量只被初始化一次 也就是我们在其他语言中用到的单例模式

关键代码是:

  1. PLACEMENT_CENTER_CONF.get_or_init:获取或者初始化值。这个函数特殊的地方在于,不管调用多少次,只会初始化一次。

  2. PLACEMENT_CENTER_CONF.get:获取已经初始化后的值。

所以我们在 main 函数初始化配置后,就可以通过placement_center_conf随时获取到配置,代码如下:

fn main() {
    let args = ArgsParams::parse();
    init_placement_center_conf_by_path(&args.conf);
}

执行后效果如下:

图片

接下来我们来看看如何初始化我们的日志模块。到这里你可以在脑子里面想一下这个日志模块应该需要满足什么功能需求?

如何记录日志

通用的日志模块核心是四个需求:

  1. 支持多个不同的日志级别。

  2. 支持多种日志滚动方式,比如按时间滚动、按大小滚动。

  3. 支持自定义日志格式。

  4. 支持根据不同的类型将日志打印到不同的文件。

在 Rust 中,日志文件建议直接用 log4rs 库即可,它满足我们上面的这几点需求。初始化日志模块主要分为三步:

  1. 编写 log4rs.yaml 文件

  2. 初始化日志模块

  3. 记录日志

接下来我们来看一下我们的 log4rs.yaml 文件, 下面重点关注 loggers 模块,这块官方文档写得不太清晰

appenders:
  # 定义一个名为stdout的appender,功能是将日志输出到控制台
  stdout:
    kind: console

  # 定义一个名为server的appender,功能是将日志输出到名为server.log的滚动文件
  # 每个文件大小 1gb,文件序号从 0 开始到 50
  # 日志的格式为"{d(%Y-%m-%d %H:%M:%S)} {h({l})} {m}{n}"
  # 日志格式参考这个文档:https://docs.rs/log4rs/1.3.0/log4rs/encode/pattern/index.html
  server:
    kind: rolling_file
    path: "{$path}/server.log"
    encoder:
        pattern: "{d(%Y-%m-%d %H:%M:%S)} {h({l})} {m}{n}"
    policy:
        trigger:
            kind: size
            limit: 1 gb
        roller:
            kind: fixed_window
            pattern: "{$path}/server-{}.log"
            base: 0
            count: 50
  # 参考 server
  requests:
    kind: rolling_file
    path: "{$path}/requests-log.log"
    encoder:
        pattern: "{d(%Y-%m-%d %H:%M:%S)} {h({l})} {m}{n}"
    policy:
        trigger:
            kind: size
            limit: 1 gb
        roller:
            kind: fixed_window
            pattern: "{$path}/requests-log-{}.log"
            base: 0
            count: 50

# 默认清况下,所有的日志都会输出到 stdout和 server 两个 appender
root:
  level: info
  appenders:
    - stdout
    - server

# 这个需要重点注意,可以将不同 lib 或 mod 中的日志输出到不同的文件
loggers:
  # 将 placement_center::server模块的日志会写入到 stdout 和 server 两个 appender
  placement_center::server:
    level: info
    appenders:
      - stdout
      - server
    additive: false
  # 将 placement_center::requests模块的日志会写入到 stdout 和 requests 两个 appender
  placement_center::requests:
    level: info
    appenders:
      - stdout
      - requests
    additive: false

log4rs.yaml 的语法都写在注释里,就不展开了。编写好文件后,就需要初始化配置,来看下面代码:

#![allow(unused)]
fn main() {
pub fn init_placement_center_log() {
    // 1. 获取配置信息
    let conf = placement_center_conf();

    // 2. 检查日志配置 .yaml 文件是否存在
    if !file_exists(&conf.log.log_config) {
        panic!(
            "Logging configuration file {} does not exist",
            conf.log.log_config
        );
    }

    // 3.尝试初始化日志存放目录
    match create_fold(&conf.log.log_path) {
        Ok(()) => {}
        Err(e) => {
            panic!("Failed to initialize log directory {}", conf.log.log_path);
        }
    }

    // 4. 读取日志配置.yaml 文件的内容
    let content = match read_file(&conf.log.log_config) {
        Ok(data) => data,
        Err(e) => {
            panic!("{}", e.to_string());
        }
    };

    // 5. 替换日志文件的存放路径
    let config_content = content.replace("{$path}", &conf.log.log_path);
    println!("{}","log config:");
    println!("{}", config_content);

    // 6. 解析 yaml 格式的配置文件
    let config = match serde_yaml::from_str(&config_content) {
        Ok(data) => data,
        Err(e) => {
            panic!(
                "Failed to parse the contents of the config file {} with error message :{}",
                conf.log.log_config,
                e.to_string()
            );
        }
    };

    // 7. 初始化日志配置
    match log4rs::init_raw_config(config) {
        Ok(_) => {}
        Err(e) => {
            panic!("{}", e.to_string());
        }
    }
}

}

上面代码整体分为七步,如果对 log4rs 学习比较充分的同学,可能会有一个想法,初始化日志配置需要这么复杂吗?用下面的代码不就好了吗?

#![allow(unused)]
fn main() {
log4rs::init_file("log4rs.yml", Default::default()).unwrap();

}

是的,理论上这样是可以的。

但是因为用户修改日志存放目录,是一个常见的需求,并且除了修改日志存放目录外,大部分情况下,用户不需要去修改日志的配置文件内容。

所以希望进一步优化使用体验,即: 希望用户大部分清况下不用去理解 log4rs 的语法,且修改日志存放目录时,不需要修改 log4rs.yaml 中的日志路径

所以为了达到上面的效果,我们在配置文件中加了下面这两行配置:

#![allow(unused)]
fn main() {
[log]
log_config = "./config/log4rs.yaml"
log_path = "./logs"

}

然后再手动读取 log4rs.yaml 的内容,并且在第 5 步 替换了 log4rs.yaml 中的日志目录路径。最后通过log4rs::init_raw_config(config) 完成了日志模块的初始化。

最后在 main 函数中调用init_placement_center_log初始化日志。

fn main() {
    let args = ArgsParams::parse();
    // 初始化配置文件
    init_placement_center_conf_by_path(&args.conf);

    // 初始化日志
    init_placement_center_log();

    let conf = placement_center_conf();
    // 记录日志
    info!("{:?}", conf);
    start_server();
}

当完成初始化后,就可以通过 info!、debug!、warn!、error! 等方法记录日志了,并将 placement_center::requests 模块的日志写入到 request.log 和 stdout,再将placement_center::server 模块的日志写入到 server.log 和 stdout,其他日志默认全部写入 server.log 和 stdout。

到这里我们就完成了日志模块的初始化。接下来我们来看一下如何写测试用例。

运行测试用例

接下来我们以读取静态配置的流程为 case,写一个测试用例验证我们读取静态配置的代码是没问题的。

写测试用例的时候, 一般会把测试用例和代码写在一起。比如我们要测试common/base/src/config/placement_center.rs 中的代码,则可以把下面的测试用例放在这个文件中。

测试文件用例如下:

#![allow(unused)]
fn main() {
mod tests {
    use crate::config::placement_center::{
        init_placement_center_conf_by_path, placement_center_conf,
    };

    #[test]
    fn config_init_test() {
        let path = format!(
            "{}/../../../config/placement-center.toml",
            env!("CARGO_MANIFEST_DIR")
        );
        init_placement_center_conf_by_path(&path);
        let config = placement_center_conf();
        assert_eq!(config.node_id, 1);
        assert_eq!(config.grpc_port, 1228);
    }
}

}

这里我写了一个 config_init_test 方法来验证 init 日志是否正常。主要依赖 assert_eq 来判断读取的数据是否符合预期。

基本所有测试用例都是这个逻辑: 初始化某个数据,然后判断数据是否符合预期。我们可以通过 cargo test --package common-base 来测试这个模块中的测试用例。

最后分享一个运行测试用例的技巧。

我们通常会运行 Server 来提供服务,一般都需要测试我们提供的服务是否正常,我们通常会写测试用例验证服务接口的进出参是否正常。此时,如果运行 Cargo 就会遇到一个问题,如果 Server 没启动,那么 Cargo Test 执行就会失败。

此时,可以通过一个 shell 脚本封装 Cargo Test 来测试,脚本内容伪代码如下:

start server
cargo test --package common-base
stop server

这里给一个我们封装好的 shell 示例,给你参考,比较简单,就不展开讲了。

#!/bin/sh
start_placement_center(){
    nohup cargo run --package cmd --bin $placement_center_process_name -- --conf=tests/config/$placement_center_process_name.toml >/dev/null 2>&1 &
    sleep 3
    while ! ps aux | grep -v grep | grep "$placement_center_process_name" > /dev/null; do
        echo "Process $placement_center_process_name has not started yet, wait 1s...."
        sleep 1
    done
    echo "Process $placement_center_process_name starts successfully and starts running the test case"
}

stop_placement_center(){
    pc_no=`ps aux | grep -v grep | grep "$placement_center_process_name" | awk '{print $2}'`
    echo "placement center num: $pc_no"
    kill $pc_no
    sleep 3

    while ps aux | grep -v grep | grep "$placement_center_process_name" > /dev/null; do
        echo "”Process $placement_center_process_name stopped successfully"
        sleep 1
    done
}

# 1. 启动placement center
start_placement_center

# 2. Run Cargo Test
cargo test

# 3. stop server
if [ $? -ne 0 ]; then
    echo "Test case failed to run"
    stop_placement_center
    exit 1
else
    echo "Test case runs successfully"
    stop_placement_center
fi

总结

tips:每节课的代码都能在项目 https://github.com/robustmq/robustmq-geek 中找到源码,有兴趣的同学可以下载源码来看。

这节课我们完成了命令行参数、静态配置、日志模块、测试用例的开发。

  • 命令行参数推荐使用 clap 库。

  • 配置文件建议用 toml 格式文件,通过 toml 库配合 OnceLock 来实现配置文件的单例加载。

  • 日志模块通过 log4rs 来初始化即可。

  • 测试用例建议和代码写在同一个文件,如果需要依赖外部系统完成测试用例,建议在 Cargo Test 上配合 shell 来完成对应的工作。

思考题

这里是本节课推荐的相关 issue 的任务列表,请点击查看 《Good First Issue》,任务列表会不间断地更新。欢迎给我的项目 https://github.com/robustmq/robustmq 点个 Star 啊!