Doolittle: What concrete evidence do you have that you exist?
Bomb #20: Hmmmm... well... I think, therefore I am.
Doolittle: That’s good. That’s very good. But how do you know that anything else exists?
Bomb #20: My sensory apparatus reveals it to me.
——Dark Star
Rust中有关输入输出的特性围绕着三个trait:Read
、BufRead
、Write
来组织:
- 实现了
Read
的值有读取字节输入的方法。它们被称为 读者(reader) 。 - 实现了
BufRead
的值是 buffered reader(有缓存的读者) 。它们支持Read
的所有方法,加上读取文本的一行的方法,等等。 - 实现了
Write
的值支持字节和UTF-8文本输出。它们被称为 写者(writer) 。
”图18-1”展示了这三个trait以及一些reader和writer类型的示例。
在本章中,我们将解释如何使用这些trait和它们的方法,包括图中出现的reader和writer类型,还有一些其他的和文件、终端、网络交互的方法。
图18-1 Rust的三个主要的I/O trait以及一些实现了它们的类型
Reader 是你的程序可以从中读取字节的值。例如:
- 使用
std::fs::File::open(filename)
打开的文件 - 用于从网络中接收数据的
std::net::TcpStream
- 进程用来读取标准输入的
std::io::stdin()
std::io::Cursor<&[u8]>
和std::io::Cursor<Vec<u8>>
值,它们是从内存中的字节数组或vector中“读取”数据的reader
Writer 是你的程序可以向其中写入字节的值。例如:
- 使用
std::fs::File::create(filename)
打开的文件 - 用于向网络中发送数据的
std::net::TcpStream
- 用于写入到终端的
std::io::stdout()
和std::io::stderr()
Vec<u8>
,它也是一个writer,它的write
方法把数据附加到尾部std::io::Cursor<Vec<u8>>
,类似于上面,但允许你同时读取和写入数据,并可以在vector中定位到不同位置std::io::Cursor<&mut [u8]>
,和std::io::Cursor<Vec<u8>>
很像,除了它不能让缓冲区增长,因为它只是已经存在的字节数组的切片
因为有为reader和writer设计的标准trait(std::io::Read
和std::io::Write
),所以编写可以处理多种输入输出通道的泛型代码是非常普遍的。例如,这里有一个函数拷贝任意reader中的所有字节到任意writer:
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;
}
}
这是Rust的标准库中的std::io::copy()
的实现。因为它是泛型的,你可以使用它从File
中读取数据然后写入到TcpStream
,或者从Stdin
读取,然后写入到内存中的Vec<u8>
,等等。
如果你看不明白这里的错误处理代码,请复习”第7章”。我们将在接下来的内容中一直使用Result
类型,掌握它的工作原理很重要。
这三个std::io
的trait:Read
、BufRead
、Write
,以及Seek
如此常用,以至于有一个只包含这些trait的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
有几个方法用于读取数据。所有这些方法都通过mut
引用获取self参数。
reader.read(&mut buffer)
从数据源读取一些字节,并存储到给定的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
trait,这会赋予你极大的灵活性。但如果你尝试读取一些数据,就会非常难受。因此,Rust提供了几个更高级的便捷方法。它们都有基于.read()
的默认实现。它们都处理了ErrorKind::Interrupted
,因此你不需要再处理。
reader.read_to_end(&mut byte_vec)
读取reader中剩余的所有输入,将读到的数据附加到byte_vec
尾部,byte_vec
是一个Vec<u8>
。返回一个io::Result<uszie>
,表示读取到的字节数。
这个方法读取的数据的大小没有限制,因此不要将它用于不受信任的源。(你可以使用.take()
方法施加限制,如后文所述。)
reader.read_to_string(&mut string)
和上面相同,不过把数据附加到给定的String
。如果流不是有效的UTF-8,它会返回一个ErrorKind::InvalidData
错误。
在一些编程语言中,字节输入和字符输入由不同的类型来处理。如今,UTF-8占据主导地位,Rust承认这一事实标准,并且完全支持UTF-8。其他字符集由开源的encoding
crate提供支持。
reader.read_exact(&mut buf)
读取恰好足以填满给定缓冲区的数据。参数的类型是&mut [u8]
,如果在读取够buf.len()
个字节之前reader的数据就已经耗光,那么会返回一个ErrorKind:: UnexpectedEof
错误。
上面这些是Read
trait的主要方法。除此之外,还有三个以值获取reader
的适配器方法,将它转换为一个迭代器或者一个不同的reader:
reader.bytes()
返回一个输入流的字节的迭代器。item的类型是io::Result<u8>
,因此每一个字节都需要进行错误检查。另外,它会逐字节调用reader.read()
,因此如果reader没有缓存的话会非常低效。
reader.chain(reader2)
返回一个新的reader,首先产生reader
的所有输入,然后产生reader2
的所有输入。
reader.take(n)
返回一个新的reader,从和reader
相同的数据源读取数据,但最多只读取n
个字节。
没有关闭reader的方法。reader和writer通常实现了Drop
,因此它们会自动关闭。
出于性能考虑,reader和writer可以进行 缓存(buffer) ,意思是它们有一块内存(缓冲区)用来存储一些输入或输出数据。这样可以减少系统调用的次数,如”图18-2”所示。在这个例子中,应用调用.read_line()
方法从BufReader
中读取数据,BufReader
从操作系统获取更大块的输入。
图18-2 一个有缓冲的文件reader
这张图并不是按比例的,一个BufReader
的实际大小是几千字节,因此一次系统的read
调用可以提供上百次.read_line()
调用。这么做之所以能提高性能是因为系统调用很慢。(如图所示,操作系统也有一个缓冲区,原因与此相同:系统调用很慢,但从磁盘读取数据更慢。)
有缓冲的reader实现了Read
和另一个trait BufRead
,它添加了下面的方法:
reader.read_line(&mut line)
读取一行文本并将它附加到line
,line
是一个String
。行尾的换行符'\n'
也会包含在line
中。如果输入中有Windows风格的换行符"\r\n"
,这两个字符都会包含进line
。
返回值是一个io::Result<usize>
,代表读取到的字节数,包括行尾的换行符。
如果reader到达输入结尾,line
会保持不变,并返回Ok(0)
。
reader.lines()
返回一个迭代输入中每一行的迭代器。item的类型是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)
,用来直接访问reader的内部缓冲区。更多有关这些方法的信息,可以查阅在线文档。
接下来的两节详细介绍了有缓冲的reader。
这里有一个实现了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(())
}
因为我们想调用.lines()
,所以我们需要一个实现了BufRead
的输入源。在这个例子中,我们调用了io::stdin()
来获取通过管道传入的数据。然而,Rust标准库使用了一个mutex来保护stdin
,我们调用.lock()
来锁住stdin
以让当前的线程独占使用,它返回一个实现了BufRead
的StdinLock
值。在循环的结尾,StdinLock
被丢弃,释放mutex。(如果没有mutex,那么如果两个线程同时从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(())
}
现在我们可以向它传递一个StdinLock
或者一个有缓存的File
:
let stdin = io::stdin();
grep(&target, stdin.lock())?; // ok
let f = File::open(file)?;
grep(&target, BufReader::new(f))?; // ok
注意File
并不是自动缓存的。File
实现了Read
但没有实现BufRead
。然而,很容易为File
或者其他任何无缓存的reader创建一个有缓存的reader。BufReader::new(reader)
可以实现这个功能。(可以使用BufReader::with_capacity(size, reader)
设置缓冲区的大小。)
在大多数语言中,文件都是默认有缓存的。如果你想要无缓存的输入或输出,你必须知道如何关闭缓存。在Rust中,File
和BufReader
是两个单独的库特性,因为有时你可能需要没有缓冲的文件,或者需要缓存文件之外的内容(例如,你可能会想要缓存来自网络的输入)。
包含错误处理和一些参数解析的完整的程序,如下所示:
// grep - 搜索stdin或文件中匹配给定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);
}
}
包括.lines()
在内的几个reader方法返回产生Result
的迭代器。当你第一次尝试将一个文件的每一行收集到一个很大的vector中时,你可能会遇到需要摆脱Result
的问题:
// ok,但不是你想要的
let results: Vec<io::Result<String>> = reader.lines().collect();
// error: 不能将Result的集合转换成Vec<String>
let lines: Vec<String> = reader.lines().collect();
第二次尝试不能编译:哪里出错了?直观的解决方法是编写一个for
循环并为每一个item检查错误:
let mut lines = vec![];
for line_result in reader.lines() {
lines.push(line_result?);
}
不错;但这里如果使用.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>
{
...
}
这个签名需要仔细阅读,但它是一个漂亮的技巧。假设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!(); // 打印空白行
还有一个print!()
宏,它不会在最后加上一个换行符,eprintln!
和eprint!
宏写入到标准错误流。这些函数的格式化代码都和format!
宏一样,见“格式化”。
使用write!()
和writeln!()
宏可以把输出写入一个writer。它们与print!()
和println!()
类似,除了两个不同点:
writeln!(io::stderr(), "error: world not helloable")?;
writeln!(&mut byte_vec, "The greatest common divisor of {:?} is {}", numbers, d)?;
一是write
宏有一个额外的第一个参数:writer。另一个不同是它们返回一个Result
,因此必须进行错误处理。这就是为什么我们在每一行的结尾都使用了?
运算符。
print
宏不返回一个Result
,如果写入失败它们会直接panic。因为它们会写入到终端,写入终端很少会失败。
Write
trait有这些方法:
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()
。
类似于reader,writer也是在丢弃时自动关闭。
类似于BufReader::new(reader)
为任意reader添加缓存,BufWriter::new(writer)
为任意writer添加缓存:
let file = File::create("tmp.txt")?;
let writer = BufWriter::new(file);
为了设置缓冲区的大小,使用BufWriter::with_capacity(size, writer)。
当BufWriter
被丢弃时,它剩余的所有被缓存的数据都会被写入到底层的writer。然而,如果这次写入时出现了错误,这个错误会被 忽略 。(因为这个错误是在BufWriter
的.drop()
方法中发生,没有汇报错误的地方。)为了保证你的应用能够注意到所有的输出错误,可以在drop有缓存的writer之前手动调用.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")?;
方法.append(), .write(), .create_new()
等,被设计用来进行类似这样的链式调用:每一个都返回self
。这种链式方法的设计模式在Rust中太过普遍以至于有一个专门的名字:它被称为 builder(构建器) 。std::process::Command
是另一个例子。更多关于OpenOptions
的细节可以查阅在线文档。
File
被打开后,它的行为就类似于其他的reader和writer。如果需要的话你可以添加一个缓冲区。当你drop一个File
时它会自动关闭。
File
还实现了Seek
trait,它意味着你可以在一个File
中跳来跳去,而不是只能从开始单调地读到尾。Seek
的定义类似如下:
pub trait Seek {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64>;
}
pub enum SeekFrom {
Start(u64),
End(i64),
Current(i64)
}
得益于这个枚举,seek
方法变得很有表达力:使用file.seek(SeekFrom::Start(0))
来定位到开始,使用file.seek(SeekFrom::Current(-8))
来回退一些字节,等等。
在一个文件中定位很慢。不管你是在硬盘还是固态盘(SSD)上,定位都要消耗和读取几M数据一样长的时间。
目前为止,本章主要使用了File
作为示例,但还有很多其他有用的reader和writer类型:
io::stdin()
返回一个标准输入流的reader。它的类型是io::Stdin
。因为它被多个线程共享,所以每一次读取都需要请求并释放mutex。
Stdin
有一个.lock()
方法获取mutex并返回一个io::StdinLock
,这是一个有缓存的reader,它会持有mutex,直到它被丢弃。因此对StdinLock
的单独操作可以避免mutex的开销。我们在“读取行”中展示过使用这个方法的示例代码。
出于技术原因,io::stdin().lock()
不能工作。这个锁持有一个Stdin
值的引用,这意味着Stdin
值必须被存储起来,这样它才能生存的足够久:
let stdin = io::stdin();
let lines = stdin.lock().lines(); // ok
io::stdout(), io::stderr()
返回标准输出和标准错误流的Stdout
和Stderr
writer类型。这两个类型也持有互斥锁和.lock()
方法。
Vec<u8>
实现了Write
。写入到一个Vec<u8>
会把新的数据附加到vector尾部。
然而,String
并没有 实现Write
。为了使用Write
构建一个字符串,首先要写入到一个Vec<u8>
,然后使用String::from_utf8(vec)
来把vector转换为字符串。
Cursor::new(buf)
创建一个Cursor
,它是一个从buf
中读取的有缓存的reader。这也是一个创建从String
读取的reader的方法。参数buf
可以是任何实现了AsRef<[u8]>
的类型,因此你也可以传递一个&[u8], &str, Vec<u8>
。
Cursor
内部的结构非常简单。它只有两个字段:buf
和一个整数,用来表示下一次读取开始的偏移量。初始时为0。
Cursor
实现了Read, BufRead, Seek
。如果buf
的类型是&mut [u8]
或者Vec<u8>
,那么Cursor
还会实现Write
。写入一个Cursor
会覆盖buf
中从当前位置开始的字节。如果你试图越界写入一个&mut [u8]
,结果会是部分写入或者一个io::Error
。使用Cursor越界写入一个Vec<u8>
没有问题,因为它会让vector变长。因此Cursor<&mut [u8]>
和Cursor<Vec<u8>>
实现了std::io::prelude
中全部的4个trait。
std::net::TcpStream
代表一个TCP网络连接。因为TCP允许双向连接,所以它既是reader又是writer。
类型关联函数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的stdin,所以它会退出
child.wait()?;
child.stdin
的类型是Option<std::process:ChildStdin>
;这里我们在创建子进程的时候使用了.stdin(Stdio::piped())
,因此.spawn()
成功后child.stdin
肯定是Some
。否则child.stdin
将是None
。
Command
还有类似的.stdout()
和.stderr()
方法,它们可以用来请求child.stdout
和child.stderr
中的reader。
std::io
模块还提供了很多返回简单reader和writer的函数:
io::sink()
这是一个无操作的writer。所有的写入方法都会返回Ok
,但数据都会被丢弃。
io::empty()
这是一个无操作的reader。所有的读取都会成功,但总是返回输入结束。
io::repeat(byte)
返回一个无限重复给定字节的reader。
有很多基于std::io
框架的开源crate提供额外的特性。
byteorder
crate提供ReadBytesExt
和WriteBytesExt
trait,它们为所有reader和writer添加二进制输入和输出的方法:
use byteorder::{ReadBytesExt, WriteBytesExt, LittleEndian};
let n = reader.read_u32::<LittleEndian>()?;
writer.write_i64::<LittleEndian>(n as i64)?;
flate2
crate提供读取和写入gzip
数据的适配器方法:
use flate2::read::GzDecoder;
let file = File::open("access.log.gz")?;
let mut gzip_reader = GzDecoder::new(file);
serde
crate以及它关联的格式化crate例如serde_json
,实现了序列化和反序列化:它们在Rust结构体和字节流之间来回转换。我们之前在“trait和其他人的类型”中提到过它们一次。现在让我们仔细看看。
假设我们有一些数据,即一个文字冒险游戏的地图,存储在一个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())]);
...
将这个数据转换为JSON并输出只需要一行代码:
serde_json::to_writer(&mut std::io::stdout(), &map)?;
在内部,serde_json::to_writer
使用了serde::Serialize
trait的serialize
方法。这个库给所有它知道如何序列化的类型附加了这个trait,其中包括我们的数据中出现的类型:字符串、字符、元组、vector、HashMap
。
serde
非常灵活。在我们的程序中,输出是JSON数据,因为我们选择了serde_json
序列化器。其他格式例如MessagePack
也是可用的。同样地,你可以把输出送到文件、Vec<u8>
或其他任何writer中。上面的代码通过stdout
打印了数据,内容如下:
{"Debris Room":[["E","Cobble Crawl"],["W","Sloping Canyon"]],"Cobble Crawl": [["W","Debris Room"]]}
serde
还包括派生两个关键trait的支持:
#[derive(Serialize, Deserialize)]
struct Player {
location: String,
items: Vec<String>,
health: u32
}
这个#[derive]
属性会让编译过程稍微变长,因此当你在 Cargo.toml 文件中将serde
列为依赖时需要要求它支持这个特性。这是我们上面的代码用到的依赖:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
更多的细节可以查阅serde
的文档。简单来说,构建系统可以自动为Player
生成serde::Serialize
和serde::Deserialize
,因此序列化一个Player
值非常简单:
serde_json::to_writer(&mut std::io::stdout(), &player)?;
输出看起来是这样的:
{"location":"Cobble Crawl","items":["a wand"],"health":3}
现在我们已经展示了如何使用reader和writer,下面的几节将介绍Rust中处理文件和目录的特性,它们在std::path
和std::fs
模块中。这些特性都涉及到文件名,所以我们将以文件名类型开始。
很不方便的一点是,你的操作系统并不一定强制文件名是有效的Unicode。这里有两个创建文本文件的Linux shell命令。只有第一个是有效的UTF-8文件名:
$ echo "hello world" > ô.txt
$ echo "O brave new world, that has such filenames in't" > $'\xf4'.txt
两条命令都可以运行,因为Linux内核不知道来自Ogg Vorbis的UTF-8。对于内核来说,任何字节(除了null字节和斜杠)组成的字符串都是可接受的文件名。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
用于操作系统可能用到的字符串。我们将再介绍一个用于文件名的std::path::Path
,它纯粹是为了方便。Path
实际上很像OsStr
,但它添加了很多和文件名相关的方法,我们将在下一节中介绍。可以使用Path
表示绝对路径和相对路径。对于路径中每个单独的部分,使用OsStr
。
最后,每个字符串类型都有一个相应的 有所有权的(owning) 类型:String
拥有一个堆上分配的str
,一个std::ffi::OsString
拥有一个堆上分配的OsStr
,一个std::path::PathBuf
拥有一个堆上分配的Path
。”表18-1”列出了每个类型的一些特性。
str | OsStr | Path | |
---|---|---|---|
非固定大小类型,总是以引用传递 | 是 | 是 | 是 |
包含任意Unicode文本 | 是 | 是 | 是 |
通常看起来就像UTF-8 | 是 | 是 | 是 |
可以包含非Unicode数据 | 否 | 是 | 是 |
文本处理方法 | 是 | 否 | 否 |
文件名相关方法 | 否 | 是 | 是 |
对应的有所有权、可增长的、堆上分配的类型 | String |
OsString |
PathBuf |
转换为有所有权的类型 | .to_string() |
.to_os_string() |
.to_path_buf() |
所有这些类型都实现了一个公共的trait: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();
...
}
所有接受path
参数的标准函数和方法都使用了这项技术,因此你可以自由地向它们传递字符串字面量。
Path
提供了下面这些方法:
Path::new(str)
将一个&str
或者&OsStr
转换为&Path
。它不会拷贝字符串,新的&Path
和原本的&str
或&OsStr
指向相同的字节流:
use std::path::Path;
let home_dir = Path::new("/home/fwolfe");
(类似的方法OsStr::new(str)
将&str
转换为&OsStr
。)
path.parent()
返回path
的父目录,如果有的话。返回类型是Option<&Path>
。
它也不会拷贝路径,path
的父目录总是path
的一个子串:
assert_eq!(Path::new("/home/fwolfe/program.txt").parent(),
Some(Path::new("/home/fwolfe")));
path.file_name()
返回path
的最后一个部分,如果有的话。返回类型是Option<&OsStr>
。
在通常的情况下,path
由一个目录、一个斜杠、然后是一个文件名组成,这会返回文件名:
use std::ffi::OsStr;
assert_eq!(Path::new("/home/fwolfe/program.txt").file_name(),
Some(OsStr::new("program.txt")));
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"));
如果path2
是一个绝对路径,这会简单地返回path2
的拷贝,因此这个方法可以用于将任何路径转换为一个绝对路径:
let abs_path = std::env::current_dir()?.join(any_path);
path.components()
返回一个从左到右迭代给定路径的所有部分的迭代器。这个迭代器的item类型是std::path::Component
,它可以代表任何可能出现在文件名中的部分:
pub enum Component<'a> {
Prefix(PrefixComponent<'a>), // 一个驱动器字母或者共享设备(在Windows上)
RootDir, // 根目录,`/`或`\`
CurDir, // `.`特殊目录
ParentDir, // `..`特殊目录
Normal(&'a OsStr) // 普通的文件和目录名
}
例如,Windows路径 \\venice\Music\A Love Supreme\04-Psalm.mp3 由一个Prefix
(表示 \\venice\Music )、后跟一个RootDir
,然后是两个Normal
组件(分别是 A Love Supreme 和 04-Psalm.mp3 )组成。
细节见在线文档。
path.ancestors()
返回一个从path
一直回溯到根目录的迭代器。每一个产生的item都是一个Path
:第一个是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("/")]);
这很像一直调用parent
直到它返回None
。最终的item总是一个根目录或者前缀路径。
这些方法只考虑内存中的字符串。Path
还有一些会查询文件系统的方法:.exists(), .is_file(), .is_dir(), .read_dir(), .canonicalize()
等等。更多内容请查阅在线文档。
有三个将Path
转换为字符串的方法。每一个都允许Path
中可能含有无效的UTF-8:
path.to_str()
将一个Path
转换成字符串,返回一个Option<&str>
。如果path
不是有效的UTF-8,它返回None
:
if let Some(file_str) = path.to_str() {
println!("{}", file_str);
} // ...否则跳过这个文件名
path.to_string_lossy()
这个方法功能基本和上面一样,但它在所有情况下都会返回字符串。如果path
不是有效的UTF-8,这个方法会创建拷贝,然后将每一个无效的字节序列替换为Unicode占位字符:U+FFFD('�')。
返回值类型是std::borrow::Cow<str>
:可能是字符串的借用也可能是有所有权的字符串。为了从这个值得到一个String
,使用它的.to_owned()
方法。(更多有关Cow
的内容,见“Borrow
和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
值。除非特意提及,不然就是io::Result<()>
。
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_permission(path, perm) |
chmod() |
SetFileAttributes() |
(copy()
返回的数字是被拷贝的文件的大小,以字节为单位。有关创建符号链接,见“平台特定特性”。)
如你所见,Rust努力提供可以在Windows、macOS、Linux以及其他Unix系统上工作的可移植函数。
文件系统的完整说明超出了本书的范围,但如果你对这些函数中的某些更感兴趣,你可以在网上轻松地找到有关他们的更多信息。我们将在下一节中展示更多示例。
所有这些函数都是通过调用操作系统的功能来实现。例如std::fs::canonicalize(path)
不只是使用字符串处理来消除给定的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());
}
注意这段代码中?
的两次使用。第一行的检查打开目录的错误。第二行的检查读取下一个条目的错误。
entry
的类型是std::fs::DirEntry
,它有如下方法:
entry.file_name()
文件或目录的名字,是一个OsString
。
entry.path()
和上面相同,但和原本的路径连接在一起,产生一个新的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(())
}
用一个单独的函数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(())
}
到目前为止,我们的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)?;
...
只要我们在Unix系统例如Linux和macOS上编译程序,它就可以工作。
std::os
模块包含很多平台特定的特性,例如symlink
。std::os
在标准库中的实际内容看起来像这样(取得了许可):
//! 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;
#[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())))
}
事实证明symlink
是一种特殊情况。大多数Unix特定的特性并不是单独的函数而是一些扩展的trait,它们为标准库类型添加了一些的方法(我们在“trait和其他人的类型”中介绍过扩展trait)。这里有一个可以一次性启用所有这些扩展的prelude
模块:
use std::os::unix::prelude::*;
例如,在Unix上这会给std::fs::Permissions
添加一个.mode()
方法,它提供Unix上表示权限的底层u32
值的访问。类似的,它还扩展了std::fs::Metadata
,添加了一些访问底层的struct stat
的字段的方法——例如.uid()
返回文件所有者的ID。
总而言之,std::os
中的内容非常基础。更多的平台特定功能通过第三方crate提供,例如winreg
提供了访问Windows注册表的支持。
有关网络编程的教程超出了本书的范围。然而,如果你已经知道一些有关网络编程的知识,那么这一节可以帮助你在Rust中开始网络编程。
底层的网络编程需要使用std::net
模块,它提供了TCP和UDP网络的跨平台支持。使用native_tls
crate来提供SSL/TLS支持。
这些模块提供了通过网络的直观的、阻塞式的输入和输出。你可以通过std::net
用很少的代码编写一个简单的服务器,为每一个连接创建一个线程。例如,这里有一个“echo”服务器:
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: ");
}
一个回声服务器简单地把你发送给它的数据返回。这些代码和你在Java或Python中编写的代码并没有多少不同。(我们将在”下一章”中介绍std::thread::spawn()
)
然而,对于高性能的服务器,你将需要使用异步的输入和输出。”第20章”会介绍Rust对异步编程的支持,并展示编写网络客户端和服务器的完整代码。
更高层的协议由第三方crate支持。例如,reqwest
crate为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()))?;
}
// 读取响应的body并写入到标准输出。
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);
}
}
用于HTTP服务器的actix-web
框架提供了更高层的特性,例如Service
和Transform
trait,它们可以帮助你通过可组合的部分构建一个app。websocket
crate实现了WebSocket协议,等等。Rust是一门年轻的语言,有一个繁荣的开源生态系统。对网络的支持正在快速扩张。