作者:楼智豪 / 后期编辑:张汉东


简介以及背景

在Rust代码的编写过程中,开发者也需要关注错误处理和日志记录的过程,程序能够及时反馈信息,保证程序的正常运行。
本文分两部分,第一部分讲述如何进行错误传递和处理,第二部分讲述应该如何记录日志。

错误处理

以前在使用C进行错误处理时,通常采用的是函数传递错误码的方式,而对于Rust而言这种方式显得有些古老。

首先,Rust当中的错误处理基于两个特性,Result和Error。

  1. pub enum Result<T, E> {
  2. /// Contains the success value
  3. Ok(T),
  4. /// Contains the error value
  5. Err(E),
  6. }

Result是Rust提供的一个枚举类,它里面应当包含,程序成功运行时返回的值T,或者是程序运行失败时返回的错误类型E。如果一个函数,它的返回值是一个Result,那么就表明,它有可能失败并返回一个错误类型,需要我们来处理这个Result。

Rust在标准库中提供了一个trait,sdt::error::Error,目前错误处理都是基于这个trait来进行,一个结构体/枚举如果实现了这个trait,那么我们认为,它就是一个错误类型。

  1. //为自定义的结构体实现Error的trait,该trait要求同时实现Display和Debug
  2. //Error tarit包含多个方法,但通常情况下impl的时候除了source方法其他无需重写
  3. pub trait Error: Debug + Display {
  4. //如果该错误类型中包含了底层的错误Err,那么source方法应该返回Some(err),如果没有返回None。不重写则默认为None
  5. fn source(&self) -> Option<&(dyn Error + 'static)>;
  6. //type_id():该方法被隐藏
  7. fn type_id(&self, _: private::Internal) -> TypeId;
  8. //backtrace():返回发生此错误的堆栈追溯,目前为unstable,默认禁用,且占用大量内存,性能很差
  9. fn backtrace(&self) -> Option<&Backtrace>;
  10. //description():已废弃,改使用Display
  11. fn description(&self) -> &str;
  12. //cause():已废弃,改使用source()
  13. fn cause(&self) -> Option<&dyn Error>;
  14. }

错误传递的背景在于,在开发过程中,可能各个模块自身都定义了一个错误类型,那么当这些模块需要一起使用时,不同错误类型的结构体应该如何转换和处理,如何传递。

方式一:自定义错误类型

  • 自定义错误类型,并且通过From trait进行转换
  • ?来传递错误,自动执行类型转换
  1. impl Error for MyError {
  2. }
  3. /// MyError属于当前自定义的枚举,其中包含了多种错误类型
  4. /// MyError也包含了从下层传递而来的错误类型,统一归纳
  5. #[derive(Debug)]
  6. pub enum MyError {
  7. BadSchema(String, String, String),
  8. IO(io::Error),
  9. Read,
  10. Receive,
  11. Send,
  12. }
  13. //实现Display
  14. impl fmt::Display for MyError {
  15. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  16. match self {
  17. MyError::BadSchema(s1, s2, s3) => {
  18. write!(f, "BadSchema Error:{}, {}, {}", s1, s2, s3)
  19. }
  20. MyError::IO(e) => {
  21. write!(f, "IO Error: {}", e)
  22. }
  23. MyError::Read => {
  24. write!(f, "Read Error")
  25. }
  26. MyError::Receive => {
  27. write!(f, "Receive Error")
  28. }
  29. MyError::Send => {
  30. write!(f, "Send Error")
  31. }
  32. }
  33. }
  34. }

在定义MyError时,其中包括了多种错误类型,有当前模块产生的错误(比如Read, Receive, Send),也有从下层模块传递上来的错误,比如IO(io::Error),针对从下层传递而来的这种错误,我们需要将它归纳到自己的MyError中,统一传递给上层。为了实现这个目的,我们就需要实现From 方法,当我们为一个错误类型的转换实现了From方法,就可以使用?来进行自动转换。如下所示

  1. impl From<io::Error> for MyError {
  2. fn from(err: io::Error) -> MyError {
  3. MyError::IO(err)
  4. }
  5. }
  1. //这两个示例是相同的
  2. fn test_error() -> Result<i32, MyError> {
  3. let s = std::fs::read_to_string("test123.txt")?;
  4. Ok(s)
  5. }
  6. fn test_error2() -> Result<String, MyError> {
  7. let s = match std::fs::read_to_string("test123.txt") {
  8. Ok(s)=>{
  9. s
  10. }
  11. Err(e)=>{
  12. return Err(MyError::from(e));
  13. }
  14. };
  15. Ok(s)
  16. }

注意在示例一当中?的作用,它等效于示例二中的match,意思是

  • 该函数的返回值是一个Result<T,Error>,需要进行处理。
  • 如果该函数运行正确,那么返回T,上述的示例中返回String
  • 如果该函数运行失败,返回了一个错误类型Error,这里返回了io::Error, 并且因为我们实现了From方法,io::Error被自动转换成了MyError::IO(io::Error),然后程序在此处直接return,不再继续往下走。
    注意From的类型转换是通过?来隐式调用的,如果不使用?而是直接return一个错误,它是不会自动进行类型转换的。

方式二 : 使用trait Object传递错误

  • 不定义自己的类型,而直接使用 Box<dyn Error> 来统一错误类型。
  • ?来传递错误,自动把Error转换成 Box<dyn Error>
  1. fn test_error() -> Result<i32, Box<dyn Error>> {
  2. let s = std::fs::read_to_string("test123.txt")?;
  3. let n = s.trim().parse::<i32>()?;
  4. Ok(n)
  5. }

在上面这个示例中,可以看到,我们返回了一个Box,他是一个trait Object,意思是泛指一个具有Error trait的结构体/枚举类型。我们之所以可以这么写,其实是因为Rust本身为Box实现了From方法,所以可以实现自动转换。

上述代码中,第一行和第二行分别返回了io:Error和ParseIntError,都可以被转换成Box。这种方式的好处在于开发者不用再一个个地去定义错误类型,编写From方法,坏处在于丢失了具体的错误类型的信息,如果要对于不同的错误类型进行不同处理,就会遇到麻烦。

虽然Rust提供的downcast方法可以将Box重新还原成具体的结构体,但是e.downcast::<MyError>();这种形式的调用也还是需要预先知道结构体类型,所以使用起来还是有困难。

对比

方式 优点 缺点
自定义错误类型 可以统一错误类型,方便上层用户对不同的错误类型采取不同的措施 需要进行各式的类型转换,较为繁琐
Box Error可以直接透传,不需要在乎具体的类型 丢失了结构体类型信息,但是也可以通过downcast把trait object转换回具体的结构体

结论:综合以上两种方式的优缺点以及各方给出的意见,得出结论如下

  • 如果是编写一个库,那么最好采取方式一,因为我们需要给上层用户传递具体的错误类型,来方便他们进行处理。
  • 如果是编写一个完整的应用程序,所有错误都在自身内部进行处理了,不需要传递给其他人,那么可以考虑采取方式二

其他:第三方库

anyhow :专门为错误处理设计的第三方库

  1. use anyhow::Result;
  2. fn get_cluster_info() -> Result<ClusterMap> {
  3. //从std::io::Error转换成了anyhow::Error
  4. let config = std::fs::read_to_string("cluster.json")?;
  5. let map: ClusterMap = serde_json::from_str(&config)?;
  6. Ok(map)
  7. }
  1. match root_cause.downcast_ref::<DataStoreError>() {
  2. //从anyhow::Error转换成自定义的DataStoreError
  3. Some(DataStoreError::Censored) => Ok(),
  4. None => Err(error),
  5. }

anyhow这个库可以把用户自定义的,所有实现了std::Error trait的结构体,统一转换成它定义的anyhow::Error。这样用户在传递错误的过程中就使用的是统一的一个结构体,不用自定义各种各样的错误。

论用法,其实anyhow和第二种trait Object方法是类似的,但是有几点不同

  • anyhow::Error 的错误是Send, Sync'static
  • anyhow::Error 保证backtrace方法可用,即便你的底层Error没有提供backtrace
  • anyhow::Error是一个机器字长,而Box<dyn Error>是两个机器字长

thiserror :提供便捷的派生宏的第三方库

前面有提到,一个自定义的MyError结构体,需要实现很多内容,Error trait,Display,Debug以及各种From函数,手动编写可能较为麻烦,而thiserror这个库则提供了过程宏来简化这个过程

  1. use thiserror::Error;
  2. #[derive(Error, Debug)]
  3. pub enum DataStoreError {
  4. #[error("data store disconnected")]
  5. Disconnect(#[from] io::Error),
  6. #[error("the data for key `{0}` is not available")]
  7. Redaction(String),
  8. #[error("invalid header (expected {expected:?}, found {found:?})")]
  9. InvalidHeader {
  10. expected: String,
  11. found: String,
  12. },
  13. #[error("unknown data store error")]
  14. Unknown,
  15. #[error("Utf data store error")]
  16. Utf{
  17. #[from]
  18. source: Utf8Error,
  19. backtrace: Backtrace
  20. },
  21. #[error(transparent)]
  22. Other(#[from] anyhow::Error)
  23. }
  • 在我们自定义的结构体前加上#[derive(Error)],就可以自动impl Error
  • #[error("invalid header (expected {expected:?}, found {found:?})")]这条语句代表如何实现Display,后面的字符串就代表Display会输出的字符,同时支持格式化参数,比如这条语句里的expected就是代表结构体里面的元素。如果是元组则可以通过.0或者.1的方式来表示元素
  • #[from]表示会自动实现From方法,将对应的子错误进行转换
    • #[from]有两种写法,第一种就是Disconnect(#[from] io::Error)这样,自动将io::Error转换成DataStoreError::Disconnect,简单的结构体嵌套
    • 第二种写法是Utf { #[from] source: Utf8Error, backtrace: Backtrace }这种,这种格式有且只能有两个字段,sourcebacktrace,不能有其他字段。它会自动将Utf8Error转换成DtaStoreError::Utf,并且自动捕获原错误中的backtrace方法
  • #[source]表示将这个结构体字段的值作为source方法的返回值,如果字段本身的名称就是source的话就不用加#[source]而会自动应用。而backtrace方法则会自动寻找结构体里类型为std::backtrace::Backtrace的字段来作为返回值。

    1. #[derive(Error, Debug)]
    2. pub struct MyError {
    3. msg: String,
    4. #[source] // optional if field name is `source`
    5. source: anyhow::Error,
    6. backtrace: Backtrace, // automatically detected
    7. }
  • #[error(transparent)]表示将源错误的source方法和Display方法不加修改直接应用到DataStoreError::Other

日志记录

log库

  1. error!(target: "yak_events", "Commencing yak shaving for {:?}", yak);
  2. // target默认为当前crate的名称
  3. warn!( "hello world");
  4. info!( "hello world");
  5. debug!( "hello world");
  6. trace!( "hello world");
  • 记录当前crate的名字、文件名路径、行号、文本信息

日志门面库

通过定义统一的接口,使用统一的日志记录方式,可以在多个日志框架中灵活切换,可以让开发者不必关心底层的日志系统。如果你是Rust库的开发者,自然不期望自己的框架绑定某个具体日志库,而是只使用log门面日志库,由使用者自行决定日志库。

Rust中的错误传递和日志记录 - 图1

  1. struct SimpleLogger {};
  2. impl log::Log for SimpleLogger {};
  3. log::set_logger(SimpleLogger);

使用方式:调用set_logger方法绑定底层的日志系统,然后用户只需调用error!、log!这几个宏,其余的如何写入日志的问题则交给系统自己去做。

开源库如何记录日志

下面列出了一些开源库使用了什么日志工具,以及它们是如何记录日志的。

可以得到结论,绝大部分开源库都在使用log这个日志门面库,而且日志记录的方式,通常是直接写入字符串信息,以及调用Error的Display方法进行写入。

  • ivanceras / diwata —用于PostgreSQL的数据库管理工具 : 使用log库

    1. debug!("ERROR: {} ({})", msg, status);
  • habitat—由Chef创建的用于构建,部署和管理应用程序的工具:使用log库

    1. match server::run(args) {
    2. Err(err) => {
    3. error!("Launcher exiting with 1 due to err: {}", err);
    4. process::exit(1);
    5. }
    6. Ok(code) => {
    7. let level = if code == 0 { Level::Info } else { Level::Error };
    8. log!(level, "Launcher exiting with code {}", code);
    9. process::exit(code);
    10. }
    11. }
  • kytan —高性能对等VPN :使用log库

    1. warn!("Invalid message {:?} from {}", msg, addr);
  • Servo —原型Web浏览器引擎 : 使用log库和gstreamer库

    1. gst_element_error!(src, CoreError::Failed, ["Failed to get memory"]);
    2. // 引用C动态库,采取错误码方式传递u32
  • wezterm — GPU加速的跨平台终端仿真器和多路复用器 :使用log库

    1. log::error!("not an ioerror in stream_decode: {:?}", err);
  • nicohman / eidolon —适用于linux和macosx的无Steam和drm的游戏注册表和启动器:使用log库

    1. error!("Could not remove game. Error: {}", res.err().unwrap());
  • Mio - Mio是一个用于Rust的,快速的底层I/O库:使用log库

    1. if let Err(err) = syscall!(close(self.kq)) {
    2. error!("error closing kqueue: {}", err);
    3. }
  • Alacritty —跨平台,GPU增强的终端仿真器:使用log库

    1. if let Err(err) = run(window_event_loop, config, options) {
    2. error!("Alacritty encountered an unrecoverable error:\n\n\t{}\n", err);
    3. std::process::exit(1);
    4. }
  • 最后,关于Error的Display方法具体应当输出什么内容,这里可以参考std::io::Error的内容(这里的io::Error并不是一个trait,而是一个实现了std::error::Error的trait的具体类型,是一个结构体)

    1. impl ErrorKind {
    2. pub(crate) fn as_str(&self) -> &'static str {
    3. match *self {
    4. ErrorKind::NotFound => "entity not found",
    5. ErrorKind::PermissionDenied => "permission denied",
    6. ErrorKind::ConnectionRefused => "connection refused",
    7. ErrorKind::ConnectionReset => "connection reset",
    8. ErrorKind::ConnectionAborted => "connection aborted",
    9. ErrorKind::NotConnected => "not connected",
    10. ErrorKind::AddrInUse => "address in use",
    11. ErrorKind::AddrNotAvailable => "address not available",
    12. ErrorKind::BrokenPipe => "broken pipe",
    13. ErrorKind::AlreadyExists => "entity already exists",
    14. ErrorKind::WouldBlock => "operation would block",
    15. ErrorKind::InvalidInput => "invalid input parameter",
    16. ErrorKind::InvalidData => "invalid data",
    17. ErrorKind::TimedOut => "timed out",
    18. ErrorKind::WriteZero => "write zero",
    19. ErrorKind::Interrupted => "operation interrupted",
    20. ErrorKind::Other => "other os error",
    21. ErrorKind::UnexpectedEof => "unexpected end of file",
    22. }
    23. }
    24. }

slog库:结构化日志

这里还要提到一个库,slog,意为structured log,结构化日志。前面提到的日志都是非结构化日志,直接记录一段话,没有具体的格式。如果程序的日志数量比较小,那么非结构化日志是可以满足要求的,如果日志的数量很大,那么非结构化的日志就会带来诸多问题,就比如,格式多种多样,难以进行查询和解析。

何为结构化日志,就是具有明确具体结构的日志记录形式,最主要的是具有key-value的键值对的形式,典型的是使用json来记录日志,一个json条目就是一条日记,每个字段就是一个键值对。

  1. debug!(log, "Here is message"; key1 => value1, key2 => value2);
  1. //传统的非结构化日志
  2. DEBUG 2018-02-05 02:00:45.541 [file:src/main.rs][line:43] CPU OVerload in location 100,ThreadId is 123456,MemoryUsage is 0,ThreadId is 234567,MemoryUsage is 0
  3. //结构化日志
  4. {
  5. "Timestamp": "2018-02-05 02:00:45.541",
  6. "Severity": "Debug",
  7. "File": "src/main.rs",
  8. "Line": "43",
  9. "Message": "Memory overflow",
  10. "Info": {
  11. "ThreadId": "123456",
  12. "MemoryUsage": "0",
  13. "ThreadId": "234567",
  14. "MemoryUsage": "0",
  15. }
  16. }

日志是维测能力的一个重要方面,也是调试的重要工具。 传统上非结构化的字符串,很难进行后续的二次分析,日志的相关处理也很麻烦。目前结构化日志日趋流行,使用结构化日志,使日志文件具有机器可读性和更高级的功能,以易于解析的结构化格式编写日志文件。这意味着日志分析工具可以轻松地获取结构化日志数据,这也使处理和查询日志更容易,并且分析日志更快,针对特定的条目进行过滤和跟踪分析。

非结构化的日志查询,往往就是搜索关键字,速度慢,准确性差,容易查询出其他不相关的内容,效率低下。而目前的许多json分析工具,支持使用sql语言对条目进行查询;Google Cloud的提供的结构化日志的服务还内置了日志解析工具,提供图形化界面解析日志,定义了日志查询语言来进行查询。

最后,结构化日志可以帮助降低日志的存储成本,因为大多数存储系统上,结构化的键值数据比非结构化的字符串有更高的压缩率。


作者介绍:

楼智豪

任职于华为技术有限公司嵌入式软件能力中心,本文章仅代表作者个人观点,不代表公司意见。