第18章 输入与输出
# 第18章 输入与输出
杜利特尔:你有什么确凿的证据证明你存在呢? 炸弹20号:嗯…… 这个嘛…… 我思故我在。 杜利特尔:说得好,非常好。但你怎么知道其他事物是存在的呢? 炸弹20号:我的感官系统告诉我的。 ——《暗星》
Rust标准库的输入输出功能围绕Read
、BufRead
和Write
这三个特性展开:
- 实现
Read
的类型,具备面向字节的输入方法,这类值被称为读取器(reader)。 - 实现
BufRead
的是缓冲读取器,它们不仅支持Read
的所有方法,还提供了读取文本行等方法。 - 实现
Write
的类型支持面向字节和UTF-8文本的输出,这类值被称为写入器(writer)。
图18-1展示了这三个特性,以及一些读取器和写入器类型的示例。
在本章中,我们将解释如何使用这些特性及其方法,介绍图中所示的读取器和写入器类型,并展示与文件、终端和网络进行交互的其他方式。
图18-1. Rust的三个主要I/O特性及实现它们的部分类型
# 读取器和写入器
读取器是程序可以从中读取字节的对象,例如:
- 使用
std::fs::File::open(filename)
打开的文件。 std::net::TcpStreams
,用于通过网络接收数据。std::io::stdin()
,用于从进程的标准输入流读取数据。std::io::Cursor<&[u8]>
和std::io::Cursor<Vec<u8>>
,这些读取器从已经存在于内存中的字节数组或向量中 “读取” 数据。
写入器是程序可以向其中写入字节的对象,例如:
- 使用
std::fs::File::create(filename)
打开的文件。 std::net::TcpStreams
,用于通过网络发送数据。std::io::stdout()
和std::io::stderr()
,用于写入终端。Vec<u8>
,其写入方法会将数据追加到向量中。std::io::Cursor<Vec<u8>>
,它与Vec<u8>
类似,但既可以读取数据,也可以写入数据,还能在向量内的不同位置进行定位。std::io::Cursor<&mut [u8]>
,它与std::io::Cursor<Vec<u8>>
非常相似,不过它无法扩展缓冲区,因为它只是现有字节数组的一个切片。
由于读取器和写入器都有标准特性(std::io::Read
和std::io::Write
),编写适用于各种输入或输出通道的泛型代码非常常见。例如,下面这个函数可以将任何读取器中的所有字节复制到任何写入器中:
use std::io::{self, Read, Write, ErrorKind};
const DEFAULT_BUF_SIZE: usize = 8 * 1024;
pub fn copy<R: ?Sized, W: ?Sized>(reader: &mut R, writer: &mut W) -> io::Result<u64>
where
R: Read,
W: Write {
let mut buf = [0; DEFAULT_BUF_SIZE];
let mut written = 0;
loop {
let len = match reader.read(&mut buf) {
Ok(0) => return Ok(written),
Ok(len) => len,
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
writer.write_all(&buf[..len])?;
written += len as u64;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这是Rust标准库中std::io::copy()
的实现。由于它是泛型函数,你可以用它将数据从File
复制到TcpStream
,从标准输入Stdin
复制到内存中的Vec<u8>
等等。
如果这里的错误处理代码不太容易理解,可以回顾第7章。在接下来的内容中,我们会经常使用Result
类型,掌握它的工作原理非常重要。
std::io
的三个特性Read
、BufRead
和Write
,以及Seek
,使用非常频繁,因此有一个prelude
模块专门包含这些特性:
use std::io::prelude::*;
在本章中你会多次看到这个导入语句。我们还习惯导入std::io
模块本身:
use std::io::{self, Read, Write, ErrorKind};
这里的self
关键字将io
声明为std::io
模块的别名。这样,std::io::Result
和std::io::Error
可以更简洁地写成io::Result
和io::Error
等等。
# 读取器
std::io::Read
有几个用于读取数据的方法,所有这些方法都通过可变引用获取读取器本身。
reader.read(&mut buffer)
:从数据源读取一些字节,并将它们存储到给定的缓冲区中。buffer
参数的类型是&mut [u8]
,这个方法最多读取buffer.len()
个字节。返回类型是io::Result<u64>
,它是Result<u64, io::Error>
的类型别名。读取成功时,u64
值表示读取的字节数,这个数字可能等于或小于buffer.len()
,即使还有更多数据,这取决于数据源。Ok(0)
表示没有更多输入可读。读取失败时,.read()
返回Err(err)
,其中err
是一个io::Error
值。io::Error
类型的值可以打印输出,方便查看;对于程序来说,它有一个.kind()
方法,返回一个io::ErrorKind
类型的错误代码。这个枚举的成员名称如PermissionDenied
(权限被拒绝)和ConnectionReset
(连接重置)等。大多数错误表示严重问题,不能忽视,但有一种错误需要特殊处理。io::ErrorKind::Interrupted
对应于Unix错误代码EINTR
,表示读取操作被信号中断。除非程序设计为能巧妙处理信号,否则应该重试读取操作。上一节中copy()
函数的代码展示了这种处理方式的示例。可以看出,.read()
方法非常底层,甚至继承了底层操作系统的一些特性。如果你要为一种新的数据源类型实现Read
特性,这会给你很大的发挥空间。但如果你只是想读取一些数据,这个方法会有点麻烦。因此,Rust提供了几个更高级的便捷方法,它们都基于.read()
方法给出了默认实现,并且都处理了ErrorKind::Interrupted
错误,所以你无需自己处理。reader.read_to_end(&mut byte_vec)
:从这个读取器读取所有剩余输入,并将其追加到byte_vec
中,byte_vec
是一个Vec<u8>
。返回一个io::Result<usize>
,表示读取的字节数。这个方法对堆积到向量中的数据量没有限制,所以不要在不可信的数据源上使用它。(你可以使用下一项中描述的.take()
方法来限制读取量。)reader.read_to_string(&mut string)
:与read_to_end
类似,但将数据追加到给定的String
中。如果流中的数据不是有效的UTF-8编码,这个方法会返回一个ErrorKind::InvalidData
错误。在一些编程语言中,字节输入和字符输入由不同类型处理。如今,UTF-8占据主导地位,Rust认可这一事实标准,并在各处都支持UTF-8。其他字符集可以通过开源的encoding
库来支持。reader.read_exact(&mut buf)
:读取刚好足够的数据来填满给定的缓冲区,参数类型是&[u8]
。如果在读取buf.len()
个字节之前读取器就没有数据了,这个方法会返回一个ErrorKind::UnexpectedEof
错误。
这些是Read
特性的主要方法。此外,还有三个适配器方法,它们按值获取读取器,并将其转换为迭代器或不同类型的读取器:
reader.bytes()
:返回一个遍历输入流字节的迭代器,迭代器的元素类型是io::Result<u8>
,所以每次读取字节时都需要进行错误检查。此外,这个方法每次读取一个字节时都会调用reader.read()
,如果读取器没有缓冲,效率会非常低。reader.chain(reader2)
:返回一个新的读取器,它先产生reader
的所有输入,然后是reader2
的所有输入。reader.take(n)
:返回一个新的读取器,它与reader
从相同的数据源读取数据,但最多只读取n
个字节。
没有用于关闭读取器的方法。读取器和写入器通常实现了Drop
特性,这样它们会自动关闭。
# 缓冲读取器
为了提高效率,读取器和写入器可以进行缓冲处理,这意味着它们有一块内存(缓冲区),用于在内存中保存一些输入或输出数据。如图18-2所示,这样可以减少系统调用。在这个例子中,应用程序通过调用read_line()
方法从BufReader
读取数据,而BufReader
则从操作系统以更大的数据块获取输入。
该图并非按比例绘制。实际上,BufReader
缓冲区的默认大小是几千字节,因此一次系统读取操作可以满足数百次read_line()
调用。这很重要,因为系统调用速度较慢。(如图所示,操作系统也有一个缓冲区,原因相同:系统调用速度慢,但从磁盘读取数据更慢。)
图18-2. 缓冲文件读取器
缓冲读取器同时实现了Read
和另一个特性BufRead
,BufRead
添加了以下方法:
reader.read_line(&mut line)
:读取一行文本并将其追加到line
中,line
是一个String
。行尾的换行符'\n'
会包含在line
中。如果输入的是Windows风格的行尾"\r\n"
,这两个字符都会包含在line
中。返回值是io::Result<usize>
,表示读取的字节数,包括行尾字符(如果有的话)。如果读取器已到达输入末尾,line
不会被修改,且返回Ok(0)
。reader.lines()
:返回一个遍历输入行的迭代器,迭代器元素类型是io::Result<String>
,字符串中不包含换行符。如果输入的是Windows风格的行尾"\r\n"
,这两个字符都会被去除。对于文本输入,这个方法几乎总是你想要的。接下来的两个部分会展示一些使用示例。reader.read_until(stop_byte, &mut byte_vec)
、reader.split(stop_byte)
:这两个方法与read_line()
和lines()
类似,但它们是面向字节的,生成的是Vec<u8>
而不是String
。你可以选择分隔字节stop_byte
。
BufRead
还提供了一对底层方法.fill_buf()
和.consume(n)
,用于直接访问读取器的内部缓冲区。有关这些方法的更多信息,请查看在线文档。
接下来的两个部分将更详细地介绍缓冲读取器。
# 读取行
下面是一个实现Unix系统中grep
工具功能的函数。它在多行文本中(通常是通过管道从另一个命令传入)搜索给定的字符串:
use std::io;
use std::io::prelude::*;
fn grep(target: &str) -> io::Result<()> {
let stdin = io::stdin();
for line_result in stdin.lock().lines() {
let line = line_result?;
if line.contains(target) {
println!("{}", line);
}
}
Ok(())
}
2
3
4
5
6
7
8
9
10
11
12
13
因为我们要调用.lines()
,所以需要一个实现了BufRead
的输入源。在这个例子中,我们调用io::stdin()
来获取通过管道传入的数据。然而,Rust标准库使用互斥锁(mutex)来保护stdin
。我们调用.lock()
来锁定stdin
,以供当前线程独占使用,它返回一个实现了BufRead
的StdinLock
值。循环结束时,StdinLock
会被丢弃,释放互斥锁。(如果没有互斥锁,两个线程同时尝试从stdin
读取数据会导致未定义行为。C语言也有同样的问题,并且解决方式相同:所有C标准输入输出函数在幕后都会获取一个锁。唯一的区别是在Rust中,锁是API的一部分。)
函数的其余部分很简单:它调用.lines()
并遍历生成的迭代器。由于这个迭代器生成的是Result
值,我们使用?
操作符来检查错误。
假设我们想让grep
程序更进一步,增加对搜索磁盘文件的支持。我们可以将这个函数泛型化:
fn grep<R>(target: &str, reader: R) -> io::Result<()>
where
R: BufRead
{
for line_result in reader.lines() {
let line = line_result?;
if line.contains(target) {
println!("{}", line);
}
}
Ok(())
}
2
3
4
5
6
7
8
9
10
11
12
现在我们既可以传入StdinLock
,也可以传入缓冲的File
:
let stdin = io::stdin();
grep(&target, stdin.lock())?; // 可行
let f = File::open(file)?;
grep(&target, BufReader::new(f))?; // 也可行
2
3
4
注意,File
不会自动进行缓冲。File
实现了Read
,但没有实现BufRead
。不过,为File
或任何其他未缓冲的读取器创建一个缓冲读取器很容易,BufReader::new(reader)
就可以做到。(如果要设置缓冲区的大小,可以使用BufReader::with_capacity(size, reader)
。)
在大多数语言中,文件默认是缓冲的。如果你想要无缓冲的输入或输出,必须想办法关闭缓冲。在Rust中,File
和BufReader
是两个独立的库特性,因为有时你可能需要无缓冲的文件,有时又可能需要不基于文件的缓冲(例如,你可能希望对网络输入进行缓冲)。
下面是完整的程序,包括错误处理和一些简单的参数解析:
// grep - Search stdin or some files for lines matching a given string.
use std::error::Error;
use std::io::{self, BufReader};
use std::io::prelude::*;
use std::fs::File;
use std::path::PathBuf;
fn grep<R>(target: &str, reader: R) -> io::Result<()>
where
R: BufRead
{
for line_result in reader.lines() {
let line = line_result?;
if line.contains(target) {
println!("{}", line);
}
}
Ok(())
}
fn grep_main() -> Result<(), Box<dyn Error>> {
// 获取命令行参数。第一个参数是要搜索的字符串;其余的是文件名。
let mut args = std::env::args().skip(1);
let target = match args.next() {
Some(s) => s,
None => Err("usage: grep PATTERN FILE...")?,
};
let files: Vec<PathBuf> = args.map(PathBuf::from).collect();
if files.is_empty() {
let stdin = io::stdin();
grep(&target, stdin.lock())?;
} else {
for file in files {
let f = File::open(file)?;
grep(&target, BufReader::new(f))?;
}
}
Ok(())
}
fn main() {
let result = grep_main();
if let Err(err) = result {
eprintln!("{}", err);
std::process::exit(1);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 收集行
包括.lines()
在内的几个读取器方法,返回的迭代器会生成Result
值。当你第一次想要将文件的所有行收集到一个大向量中时,会遇到如何处理这些Result
值的问题:
// 可行,但不是我们想要的结果
let results: Vec<io::Result<String>> = reader.lines().collect();
// 错误: 无法将Result集合转换为Vec<String>
let lines: Vec<String> = reader.lines().collect();
2
3
4
第二次尝试无法编译通过:因为错误该如何处理呢?直接的解决方法是编写一个for
循环,并检查每个项是否有错误:
let mut lines = vec![];
for line_result in reader.lines() {
lines.push(line_result?);
}
2
3
4
这样写还不错,但如果能在这里使用.collect()
就更好了,实际上我们是可以做到的。我们只需要知道要指定什么类型:
let lines = reader.lines().collect::<io::Result<Vec<String>>>()?;
这是如何实现的呢?标准库中包含了针对Result
的FromIterator
实现(在在线文档中很容易被忽略),这使得上述操作成为可能:
impl<T, E, C> FromIterator<Result<T, E>> for Result<C, E>
where
C: FromIterator<T>
{
...
}
2
3
4
5
6
这需要仔细理解,但它是个很巧妙的方法。假设C
是任何集合类型,比如Vec
或HashSet
。只要我们已经知道如何从T
值的迭代器构建一个C
,那么我们就可以从生成Result<T, E>
值的迭代器构建一个Result<C, E>
。我们只需要从迭代器中获取值,并从Ok
结果构建集合,但如果遇到Err
,则停止并返回该错误。
换句话说,io::Result<Vec<String>>
是一个集合类型,所以.collect()
方法可以创建并填充该类型的值。
# 写入器
如我们所见,输入操作主要通过方法来完成。而输出操作则略有不同。
在本书中,我们一直在使用println!()
来生成纯文本输出:
println!("Hello, world!");
println!("The greatest common divisor of {:?} is {}", numbers, d);
println!(); // 打印一个空行
2
3
还有一个print!()
宏,它不会在末尾添加换行符,另外eprintln!
和eprint!
宏用于写入标准错误流。所有这些宏的格式化代码与“格式化值”中描述的format!
宏的格式化代码相同。
要将输出发送到写入器,可使用write!()
和writeln!()
宏。它们与print!()
和println!()
类似,但有两个区别:
writeln!(io::stderr(), "error: world not helloable")?;
writeln!(&mut byte_vec, "The greatest common divisor of {:?} is {}", numbers, d)?;
2
一个区别是,写入宏都多了一个第一个参数,即写入器。另一个区别是,它们返回一个Result
,因此必须处理错误。这就是为什么我们在每行的末尾使用了?
操作符。
打印宏不会返回Result
;如果写入失败,它们会直接引发恐慌(panic)。由于它们写入的是终端,这种情况很少见。
Write
特性有以下这些方法:
writer.write(&buf)
:将切片buf
中的一些字节写入底层流。它返回一个io::Result<usize>
。成功时,返回写入的字节数,这个数字可能小于buf.len()
,具体取决于流的情况。与Reader::read()
一样,这是一个底层方法,你应避免直接使用。writer.write_all(&buf)
:写入切片buf
中的所有字节。返回Result<()>
。writer.flush()
:将任何缓冲的数据刷新到底层流。返回Result<()>
。
注意,虽然println!
和eprintln!
宏会自动刷新标准输出和标准错误流,但print!
和eprint!
宏不会。在使用它们时,你可能需要手动调用flush()
。
与读取器一样,写入器在被丢弃时会自动关闭。
就像BufReader::new(reader)
为任何读取器添加缓冲区一样,BufWriter::new(writer)
为任何写入器添加缓冲区:
let file = File::create("tmp.txt")?;
let writer = BufWriter::new(file);
2
如果要设置缓冲区的大小,可以使用BufWriter::with_capacity(size, writer)
。
当BufWriter
被丢弃时,所有剩余的缓冲数据都会被写入底层写入器。然而,如果在写入过程中发生错误,该错误将被忽略。(由于这发生在BufWriter
的.drop()
方法内部,没有合适的地方来报告错误。)为确保你的应用程序能注意到所有输出错误,在丢弃缓冲写入器之前,应手动调用.flush()
。
# 文件
我们已经见过两种打开文件的方式:
File::open(filename)
:打开一个现有文件用于读取。它返回一个io::Result<File>
,如果文件不存在则会报错。File::create(filename)
:创建一个新文件用于写入。如果具有给定文件名的文件已存在,则会将其截断。
注意,File
类型位于文件系统模块std::fs
中,而不是std::io
中。
当这两种方式都不满足需求时,你可以使用OpenOptions
来指定所需的精确行为:
use std::fs::OpenOptions;
let log = OpenOptions::new()
.append(true) // 如果文件存在,追加到文件末尾
.open("server.log")?;
let file = OpenOptions::new()
.write(true)
.create_new(true) // 如果文件已存在则失败
.open("new_file.txt")?;
2
3
4
5
6
7
8
9
10
.append()
、.write()
、.create_new()
等方法设计为可以像这样链式调用:每个方法都返回self
。这种方法链式调用的设计模式在Rust中很常见,它有一个名字:构建器(builder)模式。std::process::Command
是另一个例子。有关OpenOptions
的更多详细信息,请查看在线文档。
一旦文件被打开,它的行为就和其他读取器或写入器一样。如果需要,你可以为其添加缓冲区。当你丢弃File
时,它会自动关闭。
# 定位
File
还实现了Seek
特性,这意味着你可以在文件中随意跳转,而不必从头到尾一次性读取或写入。Seek
的定义如下:
pub trait Seek {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64>;
}
pub enum SeekFrom {
Start(u64),
End(i64),
Current(i64)
}
2
3
4
5
6
7
8
9
借助这个枚举,seek
方法的表达能力很强:使用file.seek(SeekFrom::Start(0))
可以将文件指针移到开头,使用file.seek(SeekFrom::Current(-8))
可以向后移动几个字节,依此类推。
在文件中定位操作速度较慢。无论你使用的是硬盘还是固态硬盘(SSD),一次定位操作所花费的时间与读取几兆字节数据的时间差不多。
# 其他读取器和写入器类型
到目前为止,本章一直以File
为例进行讲解,但还有许多其他有用的读取器和写入器类型:
io::stdin()
:返回标准输入流的读取器,其类型为io::Stdin
。由于它由所有线程共享,每次读取都需要获取和释放一个互斥锁。Stdin
有一个.lock()
方法,用于获取互斥锁并返回一个io::StdinLock
,这是一个缓冲读取器,它会持有互斥锁直到被丢弃。因此,对StdinLock
的单个操作可以避免互斥锁带来的开销。我们在“读取行”中展示了使用此方法的示例代码。由于技术原因,io::stdin().lock()
不能直接使用。锁持有对Stdin
值的引用,这意味着Stdin
值必须存储在某个地方,以确保其生命周期足够长:
let stdin = io::stdin();
let lines = stdin.lock().lines(); // 可行
2
io::stdout()
、io::stderr()
:返回标准输出和标准错误流的Stdout
和Stderr
写入器类型。它们也有互斥锁和.lock()
方法。Vec<u8>
:实现了Write
。向Vec<u8>
写入数据会将新数据追加到向量中。(然而,String
没有实现Write
。要使用Write
构建字符串,首先写入Vec<u8>
,然后使用String::from_utf8(vec)
将向量转换为字符串。)Cursor::new(buf)
:创建一个Cursor
,这是一个从buf
读取数据的缓冲读取器。这是创建一个从String
读取数据的读取器的方式。参数buf
可以是任何实现了AsRef<[u8]>
的类型,所以你也可以传入&[u8]
、&str
或Vec<u8>
。Cursor
的内部结构很简单。它只有两个字段:buf
本身和一个整数,表示下一次读取将在buf
中的偏移量。初始位置为0。Cursor
实现了Read
、BufRead
和Seek
。如果buf
的类型是&mut [u8]
或Vec<u8>
,那么Cursor
还实现了Write
。向Cursor
写入数据会从当前位置开始覆盖buf
中的字节。如果你尝试在&mut [u8]
的末尾之后写入数据,可能会得到部分写入结果或一个io::Error
错误。不过,使用Cursor
在Vec<u8>
的末尾之后写入数据是没问题的:它会扩展向量。因此,Cursor<&mut [u8]>
和Cursor<Vec<u8>>
实现了std::io::prelude
中的所有四个特性。std::net::TcpStream
:表示一个TCP网络连接。由于TCP支持双向通信,它既是读取器也是写入器。类型关联函数TcpStream::connect(("hostname", PORT))
尝试连接到服务器,并返回一个io::Result<TcpStream>
。std::process::Command
:支持生成一个子进程,并将数据通过管道传输到其标准输入,如下所示:
use std::process::{Command, Stdio};
let mut child =
Command::new("grep")
.arg("-e")
.arg("a.*e.*i.*o.*u")
.stdin(Stdio::piped())
.spawn()?;
let mut to_child = child.stdin.take().unwrap();
for word in my_words {
writeln!(to_child, "{}", word)?;
}
drop(to_child); // 关闭grep的标准输入,使其退出
child.wait()?;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
child.stdin
的类型是Option<std::process::ChildStdin>
;在这里,我们在设置子进程时使用了.stdin(Stdio::piped())
,所以当.spawn()
成功时,child.stdin
肯定是有值的。如果我们没有这样做,child.stdin
将为None
。Command
还有类似的方法.stdout()
和.stderr()
,可用于在child.stdout
和child.stderr
中请求读取器。
std::io
模块还提供了一些返回简单读取器和写入器的函数:
io::sink()
:这是一个空操作写入器。所有写入方法都返回Ok
,但数据会被直接丢弃。io::empty()
:这是一个空操作读取器。读取总是成功,但返回输入结束标志。io::repeat(byte)
:返回一个读取器,它会不断重复给定的字节。
# 二进制数据、压缩和序列化
许多开源库都基于std::io
框架提供额外的功能。
byteorder
库提供了ReadBytesExt
和WriteBytesExt
特性,为所有读取器和写入器添加了用于二进制输入和输出的方法:
use byteorder::{ReadBytesExt, WriteBytesExt, LittleEndian};
let n = reader.read_u32::<LittleEndian>()?;
writer.write_i64::<LittleEndian>(n as i64)?;
2
3
4
flate2
库提供了用于读取和写入gzip压缩数据的适配方法:
use flate2::read::GzDecoder;
let file = File::open("access.log.gz")?;
let mut gzip_reader = GzDecoder::new(file);
2
3
4
serde
库及其相关的格式库(如serde_json
)实现了序列化和反序列化功能:它们在Rust结构体和字节之间进行转换。我们之前在 “特性与他人的类型” 中提到过这个库。现在我们可以更深入地了解一下。
假设我们有一些数据,比如一个文本冒险游戏的地图,存储在HashMap
中:
type RoomId = String; // 每个房间都有一个唯一的名称
type RoomExits = Vec<(char, RoomId)>; // ...以及一个出口列表
type RoomMap = HashMap<RoomId, RoomExits>; // 房间名称和出口,很简单
// 创建一个简单的地图
let mut map = RoomMap::new();
map.insert("Cobble Crawl".to_string(),
vec![('W', "Debris Room".to_string())]);
map.insert("Debris Room".to_string(),
vec![('E', "Cobble Crawl".to_string()),
('W', "Sloping Canyon".to_string())]);
...
2
3
4
5
6
7
8
9
10
11
12
将这些数据转换为JSON格式进行输出只需要一行代码:
serde_json::to_writer(&mut std::io::stdout(), &map)?;
在内部,serde_json::to_writer
使用了serde::Serialize
特性的serialize
方法。该库为所有它知道如何序列化的类型附加了这个特性,这包括我们数据中出现的所有类型:字符串、字符、元组、向量和HashMap
。
serde
非常灵活。在这个程序中,输出是JSON数据,因为我们选择了serde_json
序列化器。其他格式,如MessagePack,也同样可用。同样,你可以将这个输出发送到文件、Vec<u8>
或任何其他写入器。前面的代码将数据打印到标准输出。输出如下:
{"Debris Room":[["E","Cobble Crawl"],["W","Sloping Canyon"]],"Cobble Crawl":[["W","Debris Room"]]}
serde
还支持派生两个关键的serde
特性:
#[derive(Serialize, Deserialize)]
struct Player {
location: String,
items: Vec<String>,
health: u32
}
2
3
4
5
6
这个#[derive]
属性可能会使编译时间稍长一些,所以当你在Cargo.toml
文件中将serde
列为依赖项时,需要显式请求它支持这个功能。以下是我们在前面代码中使用的依赖配置:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
2
3
更多详细信息请查看serde
的文档。简而言之,构建系统会为Player
自动生成serde::Serialize
和serde::Deserialize
的实现,这样序列化一个Player
值就很简单:
serde_json::to_writer(&mut std::io::stdout(), &player)?;
输出如下:
{"location":"Cobble Crawl","items":["a wand"],"health":3}
# 文件和目录
既然我们已经介绍了如何使用读取器和写入器,接下来的几个部分将介绍Rust中用于处理文件和目录的功能,这些功能位于std::path
和std::fs
模块中。所有这些功能都涉及到文件名的处理,所以我们先从文件名类型开始介绍。
# OsStr和Path
不方便的是,操作系统并不强制要求文件名是有效的Unicode。下面是两个在Linux shell中创建文本文件的命令。只有第一个命令使用了有效的UTF-8文件名:
$ echo "hello world" > ô.txt
$ echo "O brave new world, that has such filenames in't" > $'\xf4'.txt
2
这两个命令都能顺利执行,因为Linux内核无法区分UTF-8和Ogg Vorbis格式。在内核看来,任何字节串(不包括空字节和斜杠)都是可接受的文件名。在Windows上情况类似:几乎任何16位 “宽字符” 字符串都是可接受的文件名,即使这些字符串不是有效的UTF-16。
操作系统处理的其他字符串,如命令行参数和环境变量,也是如此。
Rust字符串始终是有效的Unicode。实际上,文件名大多是Unicode,但Rust必须处理极少数不是Unicode的情况。这就是为什么Rust有std::ffi::OsStr
和OsString
类型。
OsStr
是一种字符串类型,是UTF-8的超集。它的作用是能够表示当前系统上的所有文件名、命令行参数和环境变量,无论它们是否是有效的Unicode。在Unix系统上,OsStr
可以保存任何字节序列。在Windows上,OsStr
使用一种UTF-8的扩展形式存储,这种扩展形式可以编码任何16位值序列,包括不匹配的代理项对。
所以我们有两种字符串类型:str
用于表示实际的Unicode字符串;OsStr
用于表示操作系统可能提供的任何内容(不管是不是合法的Unicode)。我们再介绍一种:std::path::Path
,用于表示文件名。这纯粹是为了方便。Path
与OsStr
完全一样,但它添加了许多与文件名相关的便捷方法,我们将在下一节介绍这些方法。绝对路径和相对路径都使用Path
。对于路径的单个组件,则使用OsStr
。
最后,对于每种字符串类型,都有一个对应的拥有所有权的类型:String
拥有一个在堆上分配的str
;std::ffi::OsString
拥有一个在堆上分配的OsStr
;std::path::PathBuf
拥有一个在堆上分配的Path
。表18-1概述了每种类型的一些特性。
表18-1. 文件名类型
str | OsStr | Path | |
---|---|---|---|
未 Sized 类型,总是通过引用传递 | 是 | 是 | 是 |
可以包含任何Unicode文本 | 是 | 是 | 是 |
通常看起来就像UTF-8 | 是 | 是 | 是 |
可以包含非Unicode数据 | 否 | 是 | 是 |
文本处理方法 | 是 | 否 | 否 |
与文件名相关的方法 | 否 | 否 | 是 |
拥有所有权、可增长、在堆上分配的等效类型 | String | OsString | PathBuf |
转换为拥有所有权的类型 | .to_string() | .to_os_string() | .to_path_buf() |
这三种类型都实现了一个公共特性AsRef<Path>
,所以我们可以轻松声明一个泛型函数,接受 “任何文件名类型” 作为参数。这使用了我们在 “AsRef和AsMut” 中展示的技术:
use std::path::Path;
use std::io;
fn swizzle_file<P>(path_arg: P) -> io::Result<()>
where
P: AsRef<Path>
{
let path = path_arg.as_ref();
...
}
2
3
4
5
6
7
8
9
10
所有接受路径参数的标准函数和方法都使用了这种技术,所以你可以随意将字符串字面量传递给它们中的任何一个。
# Path和PathBuf方法
Path
提供了以下方法,以及其他一些方法:
Path::new(str)
:将&str
或&OsStr
转换为&Path
。这不会复制字符串。新的&Path
指向与原始&str
或&OsStr
相同的字节:
use std::path::Path;
let home_dir = Path::new("/home/fwolfe");
2
3
(类似的方法OsStr::new(str)
将&str
转换为&OsStr
。)
path.parent()
:返回路径的父目录(如果有的话)。返回类型是Option<&Path>
。这不会复制路径。路径的父目录始终是路径的子串:
assert_eq!(Path::new("/home/fwolfe/program.txt").parent(),
Some(Path::new("/home/fwolfe")));
2
path.file_name()
:返回路径的最后一个组件(如果有的话)。返回类型是Option<&OsStr>
。在通常情况下,路径由目录、斜杠和文件名组成,这个方法会返回文件名:
use std::ffi::OsStr;
assert_eq!(Path::new("/home/fwolfe/program.txt").file_name(),
Some(OsStr::new("program.txt")));
2
3
4
path.is_absolute()
、path.is_relative()
:这些方法用于判断文件路径是绝对路径(如Unix路径/usr/bin/advent
或Windows路径C:\Program Files
)还是相对路径(如src/main.rs
)。path1.join(path2)
:连接两个路径,返回一个新的PathBuf
:
let path1 = Path::new("/usr/share/dict");
assert_eq!(path1.join("words"),
Path::new("/usr/share/dict/words"));
2
3
如果path2
是绝对路径,这个方法只会返回path2
的副本,所以这个方法可以用于将任何路径转换为绝对路径:
let abs_path = std::env::current_dir()?.join(any_path);
path.components()
:返回一个遍历给定路径组件的迭代器,从左到右。这个迭代器的元素类型是std::path::Component
,这是一个枚举,它可以表示文件名中可能出现的所有不同部分:
pub enum Component<'a> {
Prefix(PrefixComponent<'a>), // 在Windows上,代表驱动器盘符或共享名
RootDir, // 根目录,Unix上是`/`,Windows上是`\`
CurDir, // 特殊目录`.`
ParentDir, // 特殊目录`..`
Normal(&'a OsStr) // 普通的文件和目录名
}
2
3
4
5
6
7
例如,Windows路径\\venice\Music\A Love Supreme\04-Psalm.mp3
由一个代表\\venice\Music
的Prefix
、一个RootDir
,然后是两个代表A Love Supreme
和04-Psalm.mp3
的Normal
组件组成。更多详细信息,请查看在线文档。
path.ancestors()
:返回一个从路径向上遍历到根目录的迭代器。生成的每个元素都是一个Path
:首先是路径本身,然后是它的父目录,再然后是它的祖父目录,依此类推:
let file = Path::new("/home/jimb/calendars/calendar-18x18.pdf");
assert_eq!(file.ancestors().collect::<Vec<_>>(),
vec![Path::new("/home/jimb/calendars/calendar-18x18.pdf"),
Path::new("/home/jimb/calendars"),
Path::new("/home/jimb"),
Path::new("/home"),
Path::new("/")]);
2
3
4
5
6
7
这与反复调用parent
直到返回None
非常相似。最后一个元素始终是根路径或前缀路径。
这些方法在内存中的字符串上操作。路径还有一些用于查询文件系统的方法,如.exists()
、.is_file()
、.is_dir()
、.read_dir()
、.canonicalize()
等等。更多信息请查看在线文档。
有三种将Path
转换为字符串的方法。每种方法都考虑到了路径中可能存在无效UTF-8的情况:
path.to_str()
:将Path
转换为字符串,返回Option<&str>
。如果路径不是有效的UTF-8,这个方法返回None
:
if let Some(file_str) = path.to_str() {
println!("{}", file_str);
} // ...否则跳过这个名字奇怪的文件
2
3
path.to_string_lossy()
:这个方法基本与to_str
相同,但在所有情况下都能返回某种字符串。如果路径不是有效的UTF-8,这些方法会进行复制,用Unicode替换字符U+FFFD
(�
)替换每个无效的字节序列。返回类型是std::borrow::Cow<str>
:一种要么是借用的,要么是拥有所有权的字符串。要从这个值获取String
,可以使用它的.to_owned()
方法。(关于Cow
的更多信息,请参见 “借用和ToOwned的应用:不起眼的Cow”。)path.display()
:这个方法用于打印路径:
println!("Download found. You put it in: {}", dir_path.display());
这个方法返回的值不是字符串,但它实现了std::fmt::Display
,所以可以与format!()
、println!()
等宏一起使用。如果路径不是有效的UTF-8,输出中可能会包含�
字符。
# 文件系统访问函数
表18-2展示了std::fs
中的一些函数,以及它们在Unix和Windows上大致等效的命令。所有这些函数都返回io::Result
值。除非另有说明,它们的返回类型是Result<()>
。
表18-2. 文件系统访问函数概述
Rust函数 | Unix | Windows |
---|---|---|
create_dir(path) | mkdir() | CreateDirectory() |
create_dir_all(path) | 类似mkdir -p | 类似mkdir |
remove_dir(path) | rmdir() | RemoveDirectory() |
remove_dir_all(path) | 类似rm -r | 类似rmdir /s |
remove_file(path) | unlink() | DeleteFile() |
copy(src_path, dest_path) -> Result<u64> | 类似cp -p | CopyFileEx() |
rename(src_path, dest_path) | rename() | MoveFileEx() |
hard_link(src_path, dest_path) | link() | CreateHardLink() |
canonicalize(path) -> Result<PathBuf> | realpath() | GetFinalPathNameByHandle() |
metadata(path) -> Result<Metadata> | stat() | GetFileInformationByHandle() |
symlink_metadata(path) -> Result<Metadata> | lstat() | GetFileInformationByHandle() |
read_dir(path) -> Result<ReadDir> | opendir() | FindFirstFile() |
read_link(path) -> Result<PathBuf> | readlink() | FSCTL_GET_REPARSE_POINT |
set_permissions(path, perm) | chmod() | SetFileAttributes() |
(copy()
返回的数字是复制文件的大小,以字节为单位。关于创建符号链接,请参见 “特定平台的特性”。)
如你所见,Rust致力于提供可移植的函数,这些函数在Windows、macOS、Linux和其他Unix系统上都能按预期工作。
关于文件系统的完整教程超出了本书的范围,但如果你对这些函数中的任何一个感到好奇,可以很容易在网上找到更多相关信息。我们将在下一节展示一些示例。
所有这些函数都是通过调用操作系统的功能来实现的。例如,std::fs::canonicalize(path)
不仅仅是通过字符串处理来消除给定路径中的.
和..
。它会使用当前工作目录来解析相对路径,并且会跟踪符号链接。如果路径不存在,这是一个错误。
std::fs::metadata(path)
和std::fs::symlink_metadata(path)
返回的Metadata
类型包含文件类型、大小、权限和时间戳等信息。一如既往,详细信息请查阅文档。
为了方便起见,Path
类型内置了其中一些函数作为方法:例如,path.metadata()
与std::fs::metadata(path)
是一样的。
# 读取目录
要列出目录的内容,可以使用std::fs::read_dir
,或者等效地使用Path
的.read_dir()
方法:
for entry_result in path.read_dir()? {
let entry = entry_result?;
println!("{}", entry.file_name().to_string_lossy());
}
2
3
4
注意这段代码中两次使用了?
。第一行用于检查打开目录时是否出错。第二行用于检查读取下一个目录项时是否出错。
entry
的类型是std::fs::DirEntry
,它是一个结构体,只有几个方法:
entry.file_name()
:文件或目录的名称,类型为OsString
。entry.path()
:与file_name()
返回的内容相同,但会将原始路径与之拼接,生成一个新的PathBuf
。如果我们列出的目录是/home/jimb
,而entry.file_name()
是.emacs
,那么entry.path()
将返回PathBuf::from("/home/jimb/.emacs")
。entry.file_type()
:返回一个io::Result<FileType>
。FileType
有.is_file()
、.is_dir()
和.is_symlink()
方法。entry.metadata()
:获取关于此目录项的其余元数据。
在读取目录时,特殊目录.
和..
不会列出。
下面是一个更具体的示例。以下代码将磁盘上的一个目录树递归地复制到另一个位置:
use std::fs;
use std::io;
use std::path::Path;
/// 将现有的目录`src`复制到目标路径`dst`。
fn copy_dir_to(src: &Path, dst: &Path) -> io::Result<()> {
if!dst.is_dir() {
fs::create_dir(dst)?;
}
for entry_result in src.read_dir()? {
let entry = entry_result?;
let file_type = entry.file_type()?;
copy_to(&entry.path(), &file_type, &dst.join(entry.file_name()))?;
}
Ok(())
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
一个单独的函数copy_to
用于复制单个目录项:
/// 将`src`处的内容复制到目标路径`dst`。
fn copy_to(src: &Path, src_type: &fs::FileType, dst: &Path) -> io::Result<()> {
if src_type.is_file() {
fs::copy(src, dst)?;
} else if src_type.is_dir() {
copy_dir_to(src, dst)?;
} else {
return Err(io::Error::new(io::ErrorKind::Other,
format!("don't know how to copy: {}",
src.display())));
}
Ok(())
}
2
3
4
5
6
7
8
9
10
11
12
13
# 特定平台的特性
到目前为止,我们的copy_to
函数可以复制文件和目录。假设我们还想在Unix系统上支持符号链接。目前没有一种可移植的方法能创建在Unix和Windows上都能工作的符号链接,但标准库提供了一个特定于Unix的symlink
函数:
use std::os::unix::fs::symlink;
有了这个函数,我们的工作就简单了。我们只需要在copy_to
函数的if
表达式中添加一个分支:
...
} else if src_type.is_symlink() {
let target = src.read_link()?;
symlink(target, dst)?;
...
2
3
4
5
只要我们只针对Unix系统(如Linux和macOS)编译程序,这段代码就能正常工作。
std::os
模块包含各种特定于平台的特性,比如symlink
。标准库中std::os
的实际内容大致如下(这里做了一些简化):
//! 特定于操作系统的功能。
#[cfg(unix)]
pub mod unix;
#[cfg(windows)]
pub mod windows;
#[cfg(target_os == "ios")]
pub mod ios;
#[cfg(target_os == "linux")]
pub mod linux;
#[cfg(target_os == "macos")]
pub mod macos;
2
3
4
5
6
7
8
9
10
11
#[cfg]
属性表示条件编译:这些模块中的每一个都只存在于某些平台上。这就是为什么我们使用std::os::unix
的修改后的程序只能在Unix系统上成功编译:在其他平台上,std::os::unix
并不存在。
如果我们希望代码在所有平台上都能编译,并且在Unix系统上支持符号链接,那么我们也必须在程序中使用#[cfg]
。在这种情况下,最简单的方法是在Unix系统上导入symlink
,而在其他系统上定义我们自己的symlink
占位函数:
#[cfg(unix)]
use std::os::unix::fs::symlink;
/// 为不提供`symlink`函数的平台定义的占位实现。
#[cfg(not(unix))]
fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(src: P, _dst: Q) -> std::io::Result<()> {
Err(io::Error::new(io::ErrorKind::Other,
format!("can't copy symbolic link: {}",
src.as_ref().display())))
}
2
3
4
5
6
7
8
9
10
事实证明,symlink
有点特殊。大多数特定于Unix的特性并不是独立的函数,而是扩展特性,这些扩展特性为标准库类型添加了新方法。(我们在 “特性与他人的类型” 中介绍过扩展特性。)有一个prelude
模块可以用来一次性启用所有这些扩展:
use std::os::unix::prelude::*;
例如,在Unix系统上,这会为std::fs::Permissions
添加一个.mode()
方法,用于访问Unix上表示权限的底层u32
值。类似地,它为std::fs::Metadata
扩展了访问底层struct stat
值字段的访问器,比如.uid()
,用于获取文件所有者的用户ID。
总的来说,std::os
中的内容相当基础。通过第三方库可以获得更多特定于平台的功能,比如用于访问Windows注册表的winreg
库。
# 网络编程
网络编程的教程远远超出了本书的范围。不过,如果你已经对网络编程有一些了解,本节内容将帮助你在Rust中开始进行网络编程。
对于底层网络代码,可以从std::net
模块入手,它为TCP和UDP网络提供跨平台支持。可以使用native_tls
库来支持SSL/TLS。
这些模块提供了通过网络进行简单的阻塞式输入和输出的基础组件。你可以用几行代码编写一个简单的服务器,使用std::net
并为每个连接生成一个线程。例如,下面是一个 “回显” 服务器:
use std::net::TcpListener;
use std::io;
use std::thread::spawn;
/// 永远接受连接,为每个连接生成一个线程。
fn echo_main(addr: &str) -> io::Result<()> {
let listener = TcpListener::bind(addr)?;
println!("listening on {}", addr);
loop {
// 等待客户端连接。
let (mut stream, addr) = listener.accept()?;
println!("connection received from {}", addr);
// 生成一个线程来处理这个客户端。
let mut write_stream = stream.try_clone()?;
spawn(move || {
// 将从`stream`接收到的所有内容回显回去。
io::copy(&mut stream, &mut write_stream)
.expect("error in client thread: ");
println!("connection closed");
});
}
}
fn main() {
echo_main("127.0.0.1:17007").expect("error: ");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
回显服务器只是简单地将你发送给它的所有内容原样返回。这种代码与你在Java或Python中编写的代码并没有太大区别。(我们将在下一章介绍std::thread::spawn()
。)
然而,对于高性能服务器,你需要使用异步输入和输出。第20章介绍了Rust对异步编程的支持,并展示了网络客户端和服务器的完整代码。
更高层的协议由第三方库提供支持。例如,reqwest
库为HTTP客户端提供了出色的API。下面是一个完整的命令行程序,它可以获取任何具有http:
或https:
URL的文档,并将其输出到终端。这段代码使用的是reqwest = "0.11"
,并启用了其"blocking"
特性。reqwest
也提供了异步接口。
use std::error::Error;
use std::io;
fn http_get_main(url: &str) -> Result<(), Box<dyn Error>> {
// 发送HTTP请求并获取响应。
let mut response = reqwest::blocking::get(url)?;
if!response.status().is_success() {
Err(format!("{}", response.status()))?;
}
// 读取响应体并将其写入标准输出。
let stdout = io::stdout();
io::copy(&mut response, &mut stdout.lock())?;
Ok(())
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
eprintln!("usage: http-get URL");
return;
}
if let Err(err) = http_get_main(&args[1]) {
eprintln!("error: {}", err);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
用于HTTP服务器的actix-web
框架提供了一些高级特性,比如Service
和Transform
特性,这些特性帮助你通过可插拔的组件来组合应用程序。websocket
库实现了WebSocket协议。诸如此类。Rust是一门年轻的语言,拥有活跃的开源生态系统。对网络编程的支持正在迅速扩展。