回上级页面

配置文件

2023 年 07 月 11 日


问题

在之前的程序中,文本分割符皆以硬编码的方式出现,导致程序灵活性较差。rzeo 项目解析的文本,我倾向为下例所示形式:

以下 Rust 程序
@ hello world #
fn main() {
    println!("Hello world!");
}
@
可在终端打印「Hello world!」。

但是对于其他 rzeo 的用户而言,未必喜欢使用 @# 之类的符号。为了最大程度兼容所有人的偏好,rzeo 使用的文本分割符需以配置文件的方式进行定义,在其运行时方知文本分割符的具体形式。

rzeo 的配置文件采用 YAML 语言撰写,例如:

border: '\n[ \t]*@[ \t\n]*'
code_snippet_neck: '[ \t]*#[ \t]*\n'

假设 rzeo 配置文件为 rzeo.conf,编写一个程序从该文件获取分割符。

读取文件

首先,考虑如何使用 Rust 标准库提供的文件读写功能,读取配置文件 rzeo.conf,并输出其内容,以熟悉文件读写功能的基本用法。

以下代码可读取 rzeo.conf 文件并逐行打印其内容:

use std::fs::File;
use std::io::{BufRead, BufReader};

fn main() -> Result<(), std::io::Error> {
    let f = File::open("rzeo.conf")?;
    let reader = BufReader::new(f);
    for line in reader.lines() {
        println!("{}", line?);
    }
    return Ok(());
}

BufRead 是特性。BufReader 是实现了 BufRead 特性的结构体类型,用于将硬盘中的文件内容读入到内存缓冲区以降低硬盘读取次数。需要注意的是,Fileopen 方法和 BufReaderlines 方法皆返回 Result<T, std::io::Error> 类型,TString 类型——Rust 的又一种字符串类型,相当于 Vec<char> 类型。在此不对 String 给予讲解,可在使用中逐渐熟悉其用法。

以下代码可将 rzeo.conf 文件中的内容写入另一个文件:

use std::fs::File;
use std::io::{BufRead, BufReader, Write};

fn main() -> Result<(), std::io::Error> {
    let f = File::open("rzeo.conf")?;
    let mut g = File::create("foo.txt")?;
    let reader = BufReader::new(f);
    for line in reader.lines() {
        let content = line?;
        g.write_all(content.as_bytes())?;
        g.write_all("\n".as_bytes())?;
    }
    return Ok(());
}

注意,Filewrite_all 方法,其参数类型为 &[u8],即字节数组切片,故而需要使用 &strString 类型的 as_bytes 将字符串转化为字节数组切片。write_all 的返回值是 Result<()> 类型,需使用 unwrap 解包或使用 ? 进行错误传播。

路径

为了保持不同操作系统中文件路径的兼容性,Rust 标准库提供了一种特殊的字符串类型 std::path::Path 以及一些用于处理文件路径的方法。为了程序的可移植性,建议使用 std::path::Path 代替普通的字符串作为文件路径。

以下代码演示了 std::path::Path 的基本用法:

use std::path::Path;
use std::fs::File;
use std::io::{BufRead, BufReader};

fn main() -> Result<(), std::io::Error> {
    let path = Path::new("/tmp/rzeo.conf");
    println!("{:?}", path);
    let f = File::open(path)?;
    let reader = BufReader::new(f);
    for line in reader.lines() {
        println!("{}", line?);
    }
    return Ok(());
}

引入 serde 库

读取 rzeo.conf 文件并不困难,困难的是对其内容的解析。当前的 rzeo.conf 文件中的内容,仅仅用到了 YAML 最为基础的语法——键值对,即便如此,要对其予以解析,免不了要写许多代码。Rust 第三方库 serde 能够实现 Rust 语言的值与特定格式的数据文件的交换,即值的序列化(Serialize)和反序列化(Deserialize)。

serde 只是一个框架,对于特定格式的数据文件,需要引入 serde 的相应实现。下面使用 cargo 构建一个项目,引入 serde 和 serde_yaml 库,实现 Rust 结构体的序列化。

首先,使用 cargo 建立新项目并进入项目目录:

$ cargo new foo
$ cd foo

然后使用 cargo add 命令添加 serde(同时开启 serde 的 derive 特性)和 serde_yaml 库:

$ cargo add -F derive serde
$ cargo add serde_yaml

上述命令可在项目根目录下的 Cargo.toml 文件的 [dependencies] 部分添加以下内容:

serde = { version = "1.0.171", features = ["derive"] }
serde_yaml = "0.9.22"

随着 serde 和 serde_yaml 库的更新,等你看到这份文档时,也动手搭建这个项目时,库的版本号应该是与上述内容不同。

序列化与反序列化

编辑上一节构建的 foo 项目的 src/main.rs 文件,令其内容为

use std::fs::File;

#[derive(serde::Serialize)]
struct Foo<'a> {
    id: u32,
    data: &'a str
}

fn main() -> Result<(), std::io::Error> {
    let foo = Foo { id: 1, data: "Hello world!" };
    let f = File::create("foo.yml")?;
    serde_yaml::to_writer(f, &foo)?;
    return Ok(());
}

执行以下命令,编译并运行程序:

$ cargo run

但是上述的 main.rs 中存在错误,导致 Rust 编译器报错。错误的原因是 File::createsert_yaml::to_writer 返回的 Result<T, E> 类型不一致,导致无法给出 main 函数的返回值类型的正确定义。对于上述代码快速而脏的修复是

serde_yaml::to_writer(f, &foo).unwrap();

即放弃 serde_yaml::to_write 的错误进行传播。

上述程序通过编译,运行结果是在当前目录创建 foo.yml 文件,其内容为

id: 1
data: Hello world!

以下代码实现了 YAML 文件 foo.yml 的反序列化:

use std::fs::File;

#[derive(Debug, serde::Deserialize)]
struct Foo {
    id: u32,
    data: String
}

fn main() -> Result<(), std::io::Error> {
    let f = File::open("foo.yml")?;
    let foo: Foo = serde_yaml::from_reader(f).unwrap();
    println!("{:?}", foo);
    return Ok(());
}

foo.yml 中的内容被转换为 Rust 结构体类型 Foo 的实例 foo。需要注意的是,上述 Foodata 域,其类型不再是 &str,而是 String,原因 serde_yaml::from_reader 方法并不占有数据,导致存储反序列化结果的结构体实例中的引用无效。

分割符

现在,定义一个结构体类型 Separator,用于存储从 rzeo.conf 中获取的分割符:

#[derive(Debug, serde::Deserialize)]
struct Separator {
    border: String,
    code_snippet_neck: String
}

使用以下代码便可解析 rzeo.conf 相应的信息并将解析结果作为 Separator 类型的值:

fn main() -> Result<(), std::io::Error>  {
    let f = std::fs::File::open("rzeo.conf")?;
    let foo: Separator = serde_yaml::from_reader(f).unwrap();
    println!("{:?}", foo);
    return Ok(()); 
}

至此,本章开头提出的问题便得以解决。

C 版本

有一些采用 C 语言编写的库也能够实现对 YAML 文件的解析,例如能够支持 C 语言结构体的 YAML 序列化和反序列化的库 libcyaml,在 Ubuntu 系统可使用以下命令安装该库:

$ sudo apt install libyaml-dev libcyaml-dev

以下是反序列化 rzeo.conf 文件的 C 程序:

#include <stdlib.h>
#include <stdio.h>
#include <cyaml/cyaml.h>

typedef struct {
        char *border;
        char *code_snippet_neck;
} Foo;

/* 构造结构体类型 Foo 与 rzeo.conf 之间的联系 */
static const cyaml_schema_field_t top_mapping[] = {
        CYAML_FIELD_STRING_PTR(
                "border", CYAML_FLAG_POINTER, Foo, border, 0, CYAML_UNLIMITED),
        CYAML_FIELD_STRING_PTR(
                "code_snippet_neck", CYAML_FLAG_POINTER, Foo,
                code_snippet_neck, 0, CYAML_UNLIMITED),
        CYAML_FIELD_END
};
static const cyaml_schema_value_t top = {
        CYAML_VALUE_MAPPING(CYAML_FLAG_POINTER, Foo, top_mapping)
};
/* 解析器设置 */
static const cyaml_config_t config = {
        .log_fn = cyaml_log,
        .mem_fn = cyaml_mem,
        .log_level = CYAML_LOG_WARNING
};
/* 反序列化 */
int main(void) {
        Foo *foo;
        cyaml_err_t err = cyaml_load_file("rzeo.conf", &config, &top,
                                          (cyaml_data_t **)&foo, NULL);
        if (err != CYAML_OK) {
                fprintf(stderr, "ERROR: %s\n", cyaml_strerror(err));
        return EXIT_FAILURE;
        }
        printf("border: %s\n", foo->border);
        printf("code_snippet_neck: %s\n", foo->code_snippet_neck);
        cyaml_free(&config, &top, foo, 0);
        return 0;
}

使用以下命令编译上述程序:

$ gcc -o foo foo.c $(pkg-config --cflags --libs libcyaml)

运行程序:

$ ./foo
border: \n[ \t]*@[ \t\n]*
code_snippet_neck: [ \t]*#[ \t]*\n

小结

Rust 第三方库对 YAML 序列化和反序列化的支持优于 C 的第三方库。必须要承认,C 语言在文本处理方面,若想变得更为优雅,最好的办法是先基于它实现一门小巧的动态语言(例如 Lua 语言),由后者负责处理文本。