rust 第三方库源码阅读——human-panic

341 阅读9分钟

这是一遍阅读 rust 第三方库 human-panic 的源码,这个库是用来美化 panic 信息的

项目地址:github.com/rust-cli/hu…

项目 stars 数:1.7k,代码量四千多行,这种比较小的库是比较入门学习的,可以了解 rust 的一些特性和最佳实践

这是我阅读的第一个 rust 项目,所以会记录一些 rust 的基础知识,正式的源码阅读在下面的源码解读章节中

文档测试

rust 中,文档注释可用于生成文档,同时也可以用于测试:

  • cargo test 运行文档测试
  • cargo doc 自动生成文档:
  • cargo doc --open 自动生成文档并打开浏览器

运行完命令之后,会在 target/doc 目录下生成文档,其中 index.html 是文档的入口

文档测试主要是针对库代码的,不是针对二进制执行文件,所以文档注释的入口默认是 src/lib,在 cargo.toml 中可以修改

比如你想修改为 src/vender.rs,则在 cargo.toml 中添加:

[lib]
path = "src/vender.rs"

文档注释分为:行文档注释块文档注释

行文档注释用 /// 开头,用于为以下的函数、模块、结构体等生成文档,注释的内容应该是对该项的描述,并且会在文档中显示

/// This function adds two numbers and returns the result.
///
/// # Examples
///
/// ```
/// let result = add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

块文档注释用 //! 开头,用于对当前模块、库或文件的整体进行描述,这种注释一般出现在文件或模块的开头,用来描述文件的功能或模块的整体情况。

//! This module provides basic arithmetic functions.

pub mod math {
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }
}

如果你的项目名是 example-project,那么在使用 exmaple-project 作为模块名名时,要以 example_project 为模块名,rust 会自动将 - 替换为 _

如果想要在文档中隐藏某个函数,可以在函数前加上 #[doc(hidden)] 注解

#[doc(hidden)]
pub fn hidden_function() {
    println!("You can't see me!");
}

宏可以实现函数重载,但宏是在编译时展开的,所以宏的参数类型必须是确定的,不能是泛型

宏声明有三种方式:

  1. #[macro_export] - 可被其他 crate 使用
  2. 不带 #[macro_export] - 仅在当前 crate 内使用
  3. 带可见性控制
// 私有宏
macro_rules! private_macro {}

// 模块级可见
#[macro_use]
mod macros {}

// 公开但仅限当前 crate
#[macro_export(local_inner_macros)]
macro_rules! public_crate_only {}

// 完全公开
#[macro_export]
macro_rules! fully_public {}

宏可以使用 $crate 来引用当前 crate,这在跨 crate 使用时很有用

#[macro_export]
macro_rules! setup_panic {
    () => {
        $crate::setup_panic();
    };
}
pub fn setup_panic() {
    println!("uccs");
}

在宏中,$meta 是一个特殊的语法:

  • $ 用来表示这是一个宏变量
  • 它告诉编译器这不是普通的变量名,而是宏系统中的一个占位符
// 在宏中:
macro_rules! setup_panic {
    ($meta:expr) => {{
        $crate::setup_panic(|| $meta); // 是一个无参数闭包
    }};
}

其他常见的类型说明符:

macro_rules! example {
    ($i:ident)    => { /* 标识符 */ };
    ($s:stmt)     => { /* 语句 */ };
    ($e:expr)     => { /* 表达式 */ };
    ($t:ty)       => { /* 类型 */ };
    ($p:pat)      => { /* 模式 */ };
    ($m:meta)     => { /* 元数据 */ };
}

inline(always)

#[inline(always)] 作用是:注解强制函数内联,这会影响最终的 backtrace

这是正常调用

fn add_one(x: i32) -> i32 {
    x + 1
}

fn main() {
    let result = add_one(5); // 这里会有函数调用的开销
}

这是内联后的效果

fn main() {
    let result = 5 + 1; // 编译器直接把函数体放在调用处
}

Cow

Cow"Clone on Write"(写时克隆)的缩写,它是 rust 标准库中一个很巧妙的类型

Cow<'a, T> 是一个智能指针,它可以包含两种数据:

  • 借用的数据 (&T)
  • 拥有的数据 (T)
fn main() {
    let input = "hello";
    let a = process_string(input);
    println!("{}", a);    // HELLO

    let input = "HELLO";
    let a = process_string(input);
    println!("{}", a);    // HELLO
}
fn process_string<'a>(input: &'a str) -> Cow<'a, str> {
    if input.chars().all(char::is_uppercase) {
        // 如果已经是大写,直接返回借用
        Cow::Borrowed(input)
    } else {
        // 需要修改时,会创建一个并返回一个新的 String
        Cow::Owned(input.to_uppercase())
    }
}

如果不是用 Cow,则需要返回一个 String,这样会导致每次都需要创建一个新的 String,而 Cow 可以避免这种情况

fn main() {
    let input = "hello";
    let a = process_string(input);
    println!("{}", a);    // HELLO

    let input = "HELLO";
    let a = process_string(input);
    println!("{}", a);    // HELLO
}
fn process_string(input: &str) -> String {
    if input.chars().all(char::is_uppercase) {
        input.to_string()
    } else {
        input.to_uppercase()
    }
}

Into

Intorust 标准库中的一个 trait,它的主要作用是提供类型转换的功能

// String 实现了 From<&str>,所以 &str 可以转换为 String
let string: String = "hello".into();  // &str -> String

// 等价于:
let string: String = String::from("hello");

函数的参数如果不使用 Into,则需要传入具体的类型

// 不使用 Into
fn process_string(s: String) {
    println!("{}", s);
}
// 调用时必须传入 String
process_string(String::from("hello")); // 不能直接传 &str

使用 Into 可以传入多种类型

// 使用 Into
fn process_string(s: impl Into<String>) {
    let s = s.into();
    println!("{}", s);
}
// 现在可以传入任何能转换为 String 的类型
process_string("hello");           // &str 可以
process_string(String::from("hello")); // String 也可以

impl Into<String> 是一个泛型语法糖,它等价于 T: Into<String>

fn process_string<T: Into<String>>(s: T) {
    let s = s.into();
    println!("{}", s);
}

通常 Into 搭配 From 使用,这样就可以实现双向转换,实现了 From trait,会自动实现 Into trait

#[derive(Debug)]
struct Person {
    name: String,
}
impl From<&str> for Person {
    fn from(name: &str) -> Self {
        Self {
            name: name.to_string(),
        }
    }
}
fn main() {
    let p: Person = "Alice".into();
    println!("{:?}", p); // Person { name: "Alice" }
}

Into 用于函数参数,From 用于函数返回值

环境变量

常用的 Cargo 环境变量

env!("CARGO_PKG_NAME")       // 包名,来自 name = "your-package-name"
env!("CARGO_PKG_VERSION")    // 版本,来自 version = "0.1.0"
env!("CARGO_PKG_AUTHORS")    // 作者,来自 authors = ["Your Name"]
env!("CARGO_PKG_DESCRIPTION")// 描述,来自 description = "Your description"
env!("CARGO_PKG_HOMEPAGE")   // 主页,来自 homepage = "https://..."

来自的 Cargo.toml 文件中的配置

[package]
name = "my-awesome-app"
version = "0.1.0"
authors = ["Alice <alice@example.com>"]
description = "A really awesome app"
homepage = "https://example.com"

需要注意的是,env! 是编译时宏,在编译时求值,所以只能获取到编译时的环墋变量,不能获取运行时的环境变量

获取运行时环境变量,使用 std::env::var

use std::env;
fn main() {
    let key = "HOME";
    match env::var(key) {
        Ok(val) => println!("{}: {:?}", key, val),
        Err(e) => eprintln!("couldn't interpret {}: {}", key, e),
    }
}

属性标记

属性标记是一种向编译器提供额外信息的元数据

// 内部属性 (作用于包含它的整个模块或项)
#![attribute_name]

// 外部属性 (作用于紧随其后的项)
#[attribute_name]

常见的属性类型:

// 1. 条件编译
#[cfg(target_os = "windows")]
fn windows_only() {
    // 仅在 Windows 系统下编译
}

// 2. 衍生宏
#[derive(Debug, Clone)]
struct Point {
    x: i32,
    y: i32,
}

// 3. 编译器控制
#[allow(dead_code)]
fn unused_function() {}

// 4. 测试标记
#[test]
fn test_function() {
    assert_eq!(2 + 2, 4);
}

// 5. 文档注释
#[doc = "这是一个函数的文档"]
fn documented_function() {}

属性标记的参数形式:

// 无参数
#[test]

// 单个参数
#[path = "foo.rs"]

// 多个参数
#[allow(unused, deprecated)]

// 键值对
#[cfg(target_os = "windows")]

// 结构化参数
#[deprecated(
    since = "1.0.0",
    note = "请使用新的 API"
)]

序列化

rust 中序列化还是比较简单的,使用 serde 就可以实现

serde_derive 是过程宏 (procedural macros) 库,提供 #[derive(Serialize, Deserialize)] 派生宏

#[derive(Debug, Serialize)]
struct Person {
    name: String,
    age: u8,
}

impl Person {
    fn new(name: String, age: u8) -> Self {
        Self { name, age }
    }
    pub fn serialize(&self) -> Option<String> {
        toml::to_string_pretty(&self).ok()
    }
}

fn main() {
    let person = Person::new("Alice".to_string(), 30);
    println!("{:?}", person.serialize().unwrap());
}

调试

  • cargo run 默认是进入 Debug 模式,可通过 cfg!(debug_assertions) 判断
  • cargo run --release 进入 Release 模式,通过 std::env::var("RUST_BACKTRACE") 获取环境变量
  • RUST_BACKTRACE=1 cargo run 进入 Debug 模式,通过 std::env::var("RUST_BACKTRACE") 获取环境变量
#[non_exhaustive]
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum PanicStyle {
    /// Normal panic
    Debug,
    /// Human-formatted panic
    Human,
}

impl Default for PanicStyle {
    fn default() -> Self {
        if cfg!(debug_assertions) {
            PanicStyle::Debug
        } else {
            match ::std::env::var("RUST_BACKTRACE") {
                Ok(_) => PanicStyle::Debug,
                Err(_) => PanicStyle::Human,
            }
        }
    }
}

模块

pub mod report; 是声明和引入模块,mod 是关键字,report 是模块名,report.rs 是模块文件

use report::{Method, Report}; 是将模块中的具体项引入到当前作用域

pub mod report;
use report::{Method, Report};

源码解读

从这里开始正式进入源码阅读环节,我们先从 README.md 开始

README.md 中,我们看到作者有些 Usage 的例子,我们就从这个例子开始

use human_panic::setup_panic;

fn main() {
   setup_panic!();

   println!("A normal log message");
   panic!("OMG EVERYTHING IS ON FIRE!!!")
}

这个库的入口函数是 setup_panic!,这个是一个宏,我们先找到这个宏的测试用例

通过搜索我们在 tests/single-panic/src/main.rs 中找到了这个宏的测试用例

通过跳转进入 src/lib.rs 文件,我们找到了这个宏的定义

setup_panic!

#[macro_export] 是一个属性标记,表示这个宏可以被其他 crate 使用

macro_rules! 是一个宏定义,setup_panic! 是宏的名称,因为宏可以实现函数重载,所以这个宏可以有多个变体

默认情况下,进入变体 2,也就是无需传递任何参数,作者会调用 metadata! 宏获取默认参数,然后调用 setup_panic 函数

#[macro_export]
macro_rules! setup_panic {
    // 变体 1
    ($meta:expr) => {{
        $crate::setup_panic(|| $meta);
    }};
    // 变体 2
    () => {
        $crate::setup_panic!($crate::metadata!());
    };
}

metadata

在调用 setup_panic 时会传入一个元数据 metadatametadata 是用于描述 crate 基本信息,属性有以下几个

pub struct Metadata {
    name: Cow<'static, str>,
    version: Cow<'static, str>,
    authors: Option<Cow<'static, str>>,
    homepage: Option<Cow<'static, str>>,
    support: Option<Cow<'static, str>>,
}

然后给这个结构体实现关联方法

impl Metadata {
    pub fn new(name: impl Into<Cow<'static, str>>, version: impl Into<Cow<'static, str>>) -> Self {
        Self {
            name: name.into(),
            version: version.into(),
            authors: None,
            homepage: None,
            support: None,
        }
    }
    pub fn authors(mut self, value: impl Into<Cow<'static, str>>) -> Self {
        let value = value.into();
        if !value.is_empty() {
            self.authors = value.into();
        }
        self
    }
    pub fn homepage(mut self, value: impl Into<Cow<'static, str>>) -> Self {
        let value = value.into();
        if !value.is_empty() {
            self.homepage = value.into();
        }
        self
    }
    pub fn support(mut self, value: impl Into<Cow<'static, str>>) -> Self {
        let value = value.into();
        if !value.is_empty() {
            self.support = value.into();
        }
        self
    }
}

在宏里面调用方法,需要给那个方法实现宏

#[macro_export]
macro_rules! metadata {
    () => {{
        $crate::Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
            .authors(env!("CARGO_PKG_AUTHORS").replace(":", ", "))
            .homepage(env!("CARGO_PKG_HOMEPAGE"))
    }};
}

setup_panic

setup_panic 函数用模式匹配 PanicStyle,如果是 Debug 模式啥也不干,如果是Human 模式,就进入Human 分支,进行美化 panic

#[doc(hidden)]
pub fn setup_panic(meta: impl Fn() -> Metadata) {
    use std::panic;

    match PanicStyle::default() {
        PanicStyle::Debug => {}
        PanicStyle::Human => {
            let meta = meta();

            panic::set_hook(Box::new(move |info: &PanicInfo<'_>| {
                let file_path = handle_dump(&meta, info);
                print_msg(file_path, &meta)
                    .expect("human-panic: printing error message to console failed");
            }));
        }
    }
}

当程序发生 panic,会调用 panic hook,来打印错误信息,我们通过这些 hook 才实现自定义处理函数

set_hook 就是其中之一

fn main() {
    std::panic::set_hook(Box::new(|info| {
        // 自定义处理逻辑
        println!("这是自定义 panic 处理逻辑");
    }));
    panic!();
}

还有闭包前面的 move 关键字的作用

move 的作用是把闭包捕获的所有外部变量的所有权转移到闭包内部,这里是 meta 变量

PanicStyle

PanicStyle 是一个枚举类型,用于控制 panic 时的输出风格

它上面定义了两个属性标记:

  • #[non_exhaustive] - 表示这个枚举是非穷尽的,即未来可能会添加新的变体,强制用户处理未知情况,通常使用 _ 通配符
  • #[derive(Copy, Clone, PartialEq, Eq)] - 表示这个枚举可以进行复制、克隆、比较

接着为 PanicStyle 实现了 Default trait,这样就可以使用 PanicStyle::default() 来获取默认值

Default trait 的实现中,首先判断是否是 debug 模式,如果是则返回 PanicStyle::Debug,否则判断环境变量 RUST_BACKTRACE 是否存在,如果存在则返回 PanicStyle::Debug,否则返回 PanicStyle::Human

#[non_exhaustive]
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum PanicStyle {
    Debug,
    Human,
}

impl Default for PanicStyle {
    fn default() -> Self {
        if cfg!(debug_assertions) {
            PanicStyle::Debug
        } else {
            match ::std::env::var("RUST_BACKTRACE") {
                Ok(_) => PanicStyle::Debug,
                Err(_) => PanicStyle::Human,
            }
        }
    }
}

handle_dump

handle_dump 第一个代码段

  • #[cfg(feature = "nightly")]#[cfg(not(feature = "nightly"))] 是条件编译,针对不同版本做的兼容,nightly 是一个特性,表示这段代码只在 nightly 版本下编译,否则编译另一段代码
  • 模式匹配可以匹配多个值
    let (x, y) = (1, 2);
    match (x, y) {
        (1, 2) => println!("匹配到 1,2"),
        (1, _) => println!("x 是 1,y 是任意值"),
        (_, 2) => println!("x 是任意值,y 是 2"),
        _ => println!("其他情况"),
    }
    
  • downcast_ref 是一个 trait,用于将 Any 类型的对象转换为具体类型的引用,如果转换失败则返回 None
#[cfg(feature = "nightly")]
let message = panic_info.message().map(|m| format!("{}", m));

#[cfg(not(feature = "nightly"))]
let message = match (
    panic_info.payload().downcast_ref::<&str>(),
    panic_info.payload().downcast_ref::<String>(),
) {
    (Some(s), _) => Some((*s).to_owned()),
    (_, Some(s)) => Some(s.to_owned()),
    (None, None) => None,
};

再来看 handle_dump 的第二个代码段

panic_info.location() 返回一个 Option,如果 Some 则返回文件名和行号,否则返回 None

  • location.file() 获取发生 panic 的文件名
  • location.line() 获取发生 panic 的行号

然后格式化后添加到 expl 字符串中

match panic_info.location() {
    Some(location) => expl.push_str(&format!(
        "Panic occurred in file '{}' at line {}\n",
        location.file(),
        location.line()
    )),
    None => expl.push_str("Panic location unknown.\n"),
}

第三个代码段是输出 backtrace,通过调用 Report::new 创建一个 Report 实例,然后调用 persist 方法持久化

let report = Report::new(&meta.name, &meta.version, Method::Panic, expl, cause);
if let Ok(f) = report.persist() {
    Some(f)
} else {
    use std::io::Write as _;
    let stderr = std::io::stderr();
    let mut stderr = stderr.lock();

    let _ = writeln!(
        stderr,
        "{}",
        report
            .serialize()
            .expect("only doing toml compatible types")
    );
    None
}

Report

Report 用于 panic 信息的序列化和持久化,或打印给用户

Report 包含下面这些属性

pub struct Report {
    name: String,
    operating_system: String,
    crate_version: String,
    explanation: String,
    cause: String,
    method: Method,
    backtrace: String,
}

提供三个关联方法:

  • new - 创建一个新的 Report 实例
  • serialize - 序列化 Report 实例
  • persist - 持久化 Report 实例
impl Report {
    pub fn new(
        name: &str,
        version: &str,
        method: Method,
        explanation: String,
        cause: String,
    ) -> Self {
        let operating_system = os_info::get().to_string();
        let backtrace = render_backtrace();

        Self {
            crate_version: version.into(),
            name: name.into(),
            operating_system,
            method,
            explanation,
            cause,
            backtrace,
        }
    }

    pub fn serialize(&self) -> Option<String> {
        toml::to_string_pretty(&self).ok()
    }

    pub fn persist(&self) -> Result<PathBuf, Box<dyn Error + 'static>> {
        let uuid = Uuid::new_v4().hyphenated().to_string();
        let tmp_dir = env::temp_dir();
        let file_name = format!("report-{}.toml", &uuid);
        let file_path = Path::new(&tmp_dir).join(file_name);
        let toml = self.serialize().expect("only using toml-compatible types");
        std::fs::write(&file_path, toml.as_bytes())?;
        Ok(file_path)
    }
}

print_msg

print_msg 用于打印 panic 信息,如果开启了 color 特性,则打印红色,否则不打印颜色

#[cfg(feature = "color")]#[cfg(not(feature = "color"))] 是条件编译,针对不同特性做的兼容

featureCargo.toml 中的一个配置,如果用户启用了 color 特性,则会去 Cargo.toml 中查找 features 配置

[features]
default = ["color"]
nightly = []
color = ["dep:anstyle", "dep:anstream"]

其中方式:

// 默认使用 color feature
cargo build

// 禁用 color feature
cargo build --no-default-features

// 显式启用 color feature
cargo build --features color

写入信息交给 write_msg 函数

#[cfg(feature = "color")]
pub fn print_msg<P: AsRef<Path>>(file_path: Option<P>, meta: &Metadata) -> IoResult<()> {
    use std::io::Write as _;

    let stderr = anstream::stderr();
    let mut stderr = stderr.lock();

    write!(stderr, "{}", anstyle::AnsiColor::Red.render_fg())?;
    write_msg(&mut stderr, file_path, meta)?;
    write!(stderr, "{}", anstyle::Reset.render())?;

    Ok(())
}

#[cfg(not(feature = "color"))]
pub fn print_msg<P: AsRef<Path>>(file_path: Option<P>, meta: &Metadata) -> IoResult<()> {
    let stderr = std::io::stderr();
    let mut stderr = stderr.lock();

    write_msg(&mut stderr, file_path, meta)?;

    Ok(())
}

write_msg

write_msg 用于写入 panic,在结构结构体时 .. 表示忽略剩余字段

fn write_msg<P: AsRef<Path>>(
    buffer: &mut impl std::io::Write,
    file_path: Option<P>,
    meta: &Metadata,
) -> IoResult<()> {
    let Metadata {
        name,
        authors,
        homepage,
        support,
        ..
    } = meta;

    writeln!(buffer, "Well, this is embarrassing.\n")?;
    writeln!(
        buffer,
        "{name} had a problem and crashed. To help us diagnose the \
     problem you can send us a crash report.\n"
    )?;
    writeln!(
        buffer,
        "We have generated a report file at \"{}\". Submit an \
     issue or email with the subject of \"{} Crash Report\" and include the \
     report as an attachment.\n",
        match file_path {
            Some(fp) => format!("{}", fp.as_ref().display()),
            None => "<Failed to store file to disk>".to_owned(),
        },
        name
    )?;

    if let Some(homepage) = homepage {
        writeln!(buffer, "- Homepage: {homepage}")?;
    }
    if let Some(authors) = authors {
        writeln!(buffer, "- Authors: {authors}")?;
    }
    if let Some(support) = support {
        writeln!(buffer, "\nTo submit the crash report:\n\n{support}")?;
    }
    writeln!(
        buffer,
        "\nWe take privacy seriously, and do not perform any \
     automated error collection. In order to improve the software, we rely on \
     people to submit reports.\n"
    )?;
    writeln!(buffer, "Thank you kindly!")?;

    Ok(())
}

源码到此基本就结束了

测试

snapboxrust 测试工具库

snapbox = { version = "0.6.4", features = ["cmd", "dir"] }

features = ["cmd", "dir"]:表示启用两个可选功能:

  • cmd:启用命令执行相关功能
  • dir:启用目录和文件操作相关功能
// 创建一个临时目录
let root = snapbox::dir::DirRoot::mutable_temp().unwrap();
// ...
// 测试结束时显式清理临时目录
root.close().unwrap();

#[cfg(unix] 表示只在 unix 系统下编译,#[cfg(not(unix))] 表示在非 unix 系统下编译

因为 UnixWindows 系统处理临时文件的方式不同,Unix 系统依赖 TMPDIR 环境变量,Windows 系统有其他机制来处理临时目录

#[cfg(unix)]
let envs = [("TMPDIR", root_path)];
#[cfg(not(unix))]
let envs: [(&str, &str); 0] = [];

cargo_bin!("single-panic-test") 用于获取 cargo 二进制文件的路径,它会在 Cargo 的构建目录(通常是 target/debug/target/release/)中查找名为 "single-panic-test" 的可执行文件

snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("single-panic-test"))

snapbox::str! 是一个宏,用于定义期望的输出格式,r#"..."#rust 的原始字符串语法,可以包含多行和特殊字符,[..] 是通配符,表示这部分内容可以变化

snapbox::str![[r#"
"name" = "single-panic-test"    // 测试名称
"operating_system" = "[..]"     // [..] 表示匹配任何操作系统
"crate_version" = "0.1.0"       // crate 版本
"explanation" = """             // panic 发生的位置说明
Panic occurred in file 'tests/single-panic/src/main.rs' at line [..]
"""
"cause" = "OMG EVERYTHING IS ON FIRE!!!"    // panic 的具体消息
"method" = "Panic"                          // panic 的方法
"backtrace" = """                           // 调用栈信息
...
"""

"#]

总结

rust 语言看的第一个库,学到的东西有很多,除了 rust 基本语法外,所有的都是新知识,每遇到一个都要问去搜索一下