【译】用 Rust 实现 csv 解析-part4

分隔符,引号和可变长度的记录

在这一节中,我们将暂时抛开 uspop.csv 数据集,而是展示如何读取一些不太“干净”的 CSV 数据。这个 CSV 数据使用 ; 作为分隔符,带有转义的引号 \"(不是"")并且拥有可变长度的记录。下面是一些示例数据,如果你知道 WWE 的话,可以看出其中是一些 WWE 摔跤手及其出生年份的名单:

$ cat strange.csv
"\"Hacksaw\" Jim Duggan";1987
"Bret \"Hit Man\" Hart";1984
# We're not sure when Rafael started, so omit the year.
Rafael Halperin
"\"Big Cat\" Ernie Ladd";1964
"\"Macho Man\" Randy Savage";1985
"Jake \"The Snake\" Roberts";1986

要读取这些 CSV 数据,我们按照下面的思路来:

  • 1.不读取 header,因为没有 header 部分
  • 2.将分隔符由 , 改为 ;
  • 3.将双引号(如 "")的后半部分转义(如\"
  • 4.支持灵活的可变长度记录,因为其中可能会有年份缺省。
  • 5.忽略行首是 # 的行

所有这些(也许有更多其它的)都可以通过配置 ReaderBuilder 来实现,如下方示例:

fn run() -> Result<(), Box<Error>> {
    let mut rdr = csv::ReaderBuilder::new()
        .has_headers(false)
        .delimiter(b';')
        .double_quote(false)
        .escape(Some(b'\\'))
        .flexible(true)
        .comment(Some(b'#'))
        .from_reader(io::stdin());
    for result in rdr.records() {
        let record = result?;
        println!("{:?}", record);
    }
    Ok(())
}

现在重新编译项目并以 strange.csv 作为输入参数来运行它:

$ cargo build
$ ./target/debug/csvtutor < strange.csv
StringRecord(["\"Hacksaw\" Jim Duggan", "1987"])
StringRecord(["Bret \"Hit Man\" Hart", "1984"])
StringRecord(["Rafael Halperin"])
StringRecord(["\"Big Cat\" Ernie Ladd", "1964"])
StringRecord(["\"Macho Man\" Randy Savage", "1985"])
StringRecord(["Jake \"The Snake\" Roberts", "1986"])

你应该多尝试一些其他设置。可能会发生一些有趣的事:

  • 1.如果你删除了 escape 设置,解析时不会报 CSV 错误。相反,记录仍然被正常解析。这是 CSV 解析器的一个特性。即使它得到的数据有一点错误,它仍然能进行一定程度上的解析。因为考虑到实际使用时,CSV 数据可能有一定的错误。这是一个有用的特性。
  • 2.如果删除了 delimiter 设置,解析仍然成功,尽管每个记录只有一个字段。
  • 3.如果删掉 flexible 设置,读取器将会打印前两条记录(因为它们有相同数量的字段),但在第三条记录上返回解析错误,因为它只有一个字段。

这涵盖了你希望在 CSV 读取器上的所有配置项,尽管还有一些其他的配置。如,你可以将记录终止符从换行改为其他字符。(默认情况下,终止符是 CRLF,它将 \r\n\r\n 分别视为单个记录的终止符。)相关详情,可以参考 ReaderBuilder

基于 Serde 读取

我们的库最重要的特性之一是对 Serde 的支持。Serde 是一个自动将数据序列化和反序列化的 Rust 框架。简单地说,通过它,我们不是将记录作为字符串数组来处理,而是特定类型数据的数组。

我们看看 uspop.csv 中的示例数据:

City,State,Population,Latitude,Longitude
Davidsons Landing,AK,,65.2419444,-165.2716667
Kenai,AK,7610,60.5544444,-151.2583333

数据记录中有些字段可以作为字符串处理,如 (City, State),另一些则类似于数值。如,Population 那一列,看起来它包含整形值,同时 LatitudeLongitude 则看起来包含小数。如果我们想要将这些字段转换为“适当”的类型,那么我们需要做大量的工作。下面是一些相关操作的示例:

fn run() -> Result<(), Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    for result in rdr.records() {
        let record = result?;

        let city = &record[0];
        let state = &record[1];
        // 一些记录会丢失 population 的计数,所以如果我们无法解析一个数值,则视 population 列的统计为丢失,而不是返回一个错误。
        let pop: Option<u64> = record[2].parse().ok();
        // 我们太幸运了!所有记录中的 Latitudes 和 longitudes 列都是正常的。因此,如果有一条记录不能解析,则犯规返回错误。
        let latitude: f64 = record[3].parse()?;
        let longitude: f64 = record[4].parse()?;

        println!(
            "city: {:?}, state: {:?}, \
             pop: {:?}, latitude: {:?}, longitude: {:?}",
            city, state, pop, latitude, longitude);
    }
    Ok(())
}

这里的问题是我们需要手动解析每一个字段的数据,这些算是劳动密集型和重复型的工作。而 Serde 可以让这个过程自动化。例如,我们可以将每一条记录反序列化为元组类型:(String, String, Option<u64>, f64, f64)

// 这里引入了一些类型别名,这样我们可以便利地使用我们的记录类型。
type Record = (String, String, Option<u64>, f64, f64);

fn run() -> Result<(), Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    // 这里不再是一个 `records` 方法的迭代器,而是 `deserialize` 方法的迭代器。
    for result in rdr.deserialize() {
        // 我们必须告诉 Serde 我们想要的反序列化后的目标类型。
        let record: Record = result?;
        println!("{:?}", record);
    }
    Ok(())
}

运行代码,可以看到形如下面的输出:

$ cargo build
$ ./target/debug/csvtutor < uspop.csv
("Davidsons Landing", "AK", None, 65.2419444, -165.2716667)
("Kenai", "AK", Some(7610), 60.5544444, -151.2583333)
("Oakman", "AL", None, 33.7133333, -87.3886111)
# ... and much more

以这种方式使用 Serde 有个缺点,那就是必须按顺序精确匹配记录中的每一个字段。如果 CSV 数据中有头记录,这可能是一个问题,因为你可能倾向于将每个值存在一个命名了的字段中,而不是一个数值编号的字段中。实现这一点的一种方法是将记录反序列化为 HashMapBTreeMap。特别地,下一个例子所展示的,与之前示例有所不同,那就是使用类型别名的定义和使用关键字 use 从标准库中导入的 HashMap:

use std::collections::HashMap;

// 这里使用类型别名,以便于我们引用记录的类型。
type Record = HashMap<String, String>;

fn run() -> Result<(), Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    for result in rdr.deserialize() {
        let record: Record = result?;
        println!("{:?}", record);
    }
    Ok(())
}

运行这个程序后显示的结果和之前类似,不同的是每条记录会以 map 的方式打印:

$ cargo build
$ ./target/debug/csvtutor < uspop.csv
{"City": "Davidsons Landing", "Latitude": "65.2419444", "State": "AK", "Population": "", "Longitude": "-165.2716667"}
{"City": "Kenai", "Population": "7610", "State": "AK", "Longitude": "-151.2583333", "Latitude": "60.5544444"}
{"State": "AL", "City": "Oakman", "Longitude": "-87.3886111", "Population": "", "Latitude": "33.7133333"}

如果你需要读取带有头记录的 CSV 数据,但确切的结构需要运行时才确定,那么下面这个方法更有效。然而,在我们的例子中,我们已知 uspop.csv 中的数据结构。特别地,使用 HashMap 方法,当我们将每条记录反序列化为 (String, String, Option<u64>, f64, f64) 时,我们丢失了前面示例中每个字段的类型说明。能否有一种方法来识别字段对应的头名称,并分配每个字段一个确定的类型呢?答案是肯定的,但是首先我们要引入一个名为 serde_derive 的 crate。你可以通过将它添加到你的依赖声明 Cargo.toml 文件的[dependencies] 中:

serde = "1"
serde_derive = "1"

将这些 crate 添加到我们的项目后,我们现在可以定义记录的结构体。然后,我们让 Serde 自动实现从 CSV 记录填充数据到结构体实例中的粘合代码。下一个示例将展示具体操作。注意不要忽视 extern crate 行!

extern crate csv;
extern crate serde;
// 这可以让我们可以编写 `#[derive(Deserialize)]` 声明。
 #[macro_use]
extern crate serde_derive;

use std::error::Error;
use std::io;
use std::process;

// 我们无需 derive `Debug`(它无需 Serde),但对于所有类型来说,把它加上是一个不错的习惯。
//
// 注意结构体中的字段名不是按照 CSV 数据中的顺序!
 #[derive(Debug, Deserialize)]
 #[serde(rename_all = "PascalCase")]
struct Record {
    latitude: f64,
    longitude: f64,
    population: Option<u64>,
    city: String,
    state: String,
}

fn run() -> Result<(), Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    for result in rdr.deserialize() {
        let record: Record = result?;
        println!("{:?}", record);
        // 如果你不喜欢所有的内容缩卷到一行,可以试试下面这个打印:
        // println!("{:#?}", record);
    }
    Ok(())
}

fn main() {
    if let Err(err) = run() {
        println!("{}", err);
        process::exit(1);
    }
}

编译并运行这个程序,可以看到跟之前类似的输出:

$ cargo build
$ ./target/debug/csvtutor < uspop.csv
Record { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
Record { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }
Record { latitude: 33.7133333, longitude: -87.3886111, population: None, city: "Oakman", state: "AL" }

同样,我们根本不需要改变 run 函数,我们仍然使用 deserialize 迭代器遍历记录,这是我们在本节开始时用到的迭代器。在这个例子中唯一变化的是 新定义的Record 类型和两个新的 extern crate 语句。我们的记录的类型现在是我们定义的自定义类型,而不是类型别名,因此,Serde 默认情况下不知道如何对它反序列化。但是有一个特殊的编译器插件 serde_derive,它可以在编译时读取你声明的结构体,并生成代码,将 CSV 记录反序列化为 Record 值。若要查看没有自动派生会发生什么,请将 #[derive(Debug, Deserialize)] 改为 #[derive(Debug)]

在这个例子中,还有一点需要注意,那就是使用 #[serde(rename_all = "PascalCase")]。这个指令将帮助 Serde 把你的结构体的字段映射到 CSV 数据的头部名称。如果你还记得,我们的头记录如下:

City,State,Population,Latitude,Longitude

注意,每个名称首字母是大写的,但结构体中的字段没有。#[serde(rename_all = "PascalCase")] 注解指令通过解析 PascalCase 中的每个字段来解决该问题,其中字段的第一个字母是大写的。如果我们没有告诉 Serde 关于名称重映射的信息,那么程序将会退出,并报异常:

$ ./target/debug/csvtutor < uspop.csv
CSV deserialize error: record 1 (line: 2, byte: 41): missing field `latitude`

我们本可以通过其他方法解决这个问题。如,我们可以使用首大写字母的标识符作为字段名:

#[derive(Debug, Deserialize)]
struct Record {
    Latitude: f64,
    Longitude: f64,
    Population: Option<u64>,
    City: String,
    State: String,
}

然而,这违反了 Rust 的命名风格。(事实上,Rust 编译器甚至会给你警告,你的命名不符合约定!)

解决这个问题的另一个方法是要求 Serde 单独重命名每个字段。当字段到头部的映射不一致时,这个方法很有用:

#[derive(Debug, Deserialize)]
struct Record {
    #[serde(rename = "Latitude")]
    latitude: f64,
    #[serde(rename = "Longitude")]
    longitude: f64,
    #[serde(rename = "Population")]
    population: Option<u64>,
    #[serde(rename = "City")]
    city: String,
    #[serde(rename = "State")]
    state: String,
}

To read more about renaming fields and about other Serde directives, please consult the Serde documentation on attributes.

要阅读更多关于重命名字段和 Serde 的指令信息,可以参考 Serde 属性文档

持之以恒!
posted @ 2020-11-14 15:45  suhanyujie  阅读(178)  评论(0编辑  收藏  举报