Rust
Rust
介绍
Rust 是一门系统编程语言,专注于安全,尤其是并发安全,支持函数式和命令式以及泛型等编程范式的多范式语言。Rust 在语法上和 C++类似,但是设计者想要在保证性能的同时提供更好的内存安全。——百度百科
rust 掀起了一股 RIIR (Rewrite it in Rust) 的热潮。
Awesome Alternatives in Rust | A curated list of command-line utilities written in Rust 收录了一些 rust 优秀应用。主要是 linux cli 工具。
为什么推荐
优点
- 高性能(系统级语言)
- 安全,生命周期与所有权机制
- 开发社区激进,更新频繁,讨论环境良好(tg: @rust_zh)
- 统一的代码格式、文档、测试、打包流程
- 唯一指定顶级包管理器:cargo
- 易于打包
- 静态检查给力,能过 能跑
缺点
- 限制条件多,难以通过编译
- 学习曲线陡峭
- 开发周期长
- GUI 库有待进步
- 我的其他个人暴论
如何学习
官网有详细的 QA 与你所需要的一切。资料方面,rust 的学习资料非常多,列举几个我读过的:external - book
- 我在学习初期,先读资料,然后尝试用 Rust 去解 leetcode 上的[1]题目,看题解以后去文档进一步搜关键字和用法。
- 中后期就完全在做项目了,遇到不会的就去 Telegram 群问。
其他资料:
开发环境
安装 rust
rust 的安装与配置并不难。在 windows 上可以使用官方脚本一行安装 rustup 及 rust。linux 也可以选择用包管理器,详见 Archwiki;但是最为推荐的还是 rustup,毕竟写 rust 会经常换工具链。
我目前日常使用 nightly,仅在必要的时候使用 stable。修改全局默认工具链为 rustup default nightly,在项目内更改当前项目默认工具链为 rustup override set nightly。
开发
然后我使用 vscode 作为 IDE。安装插件:
rust-analyzer,开发必备- (optional)
Rust Feature Toggler,方便切换 features - (optional)
(已改名为cratesDependi[2]),更好管理依赖版本
还有一些能够优化开发体验的选项:
- 使用 clippy 作为 check 指令。
- 安装额外的 cargo 组件
切换 vscode算了,预发布经常出 bug。rust-analyzer插件为预发布版本- 设置 cargo fmt(我常用的)
语言基础
循环
Rust 的 for 循环需要跟可迭代对象,例如:
for i in 0..100 {} // i in [0,99]
for i in 0..=100.step_by(2) {} // i in { 0,2,4,6...,98,100 }而类似 C++ 的 do while 循环可以写成:
loop {
// do something
if condition { break; }
}loop 循环还可以 break 出一个值。
输出
dbg!() 宏可以在 stderr 中输出调试信息,会消耗所有权。dbg! 返回值就是输入。dbg! 在 release 下也会输出。
ln 代表结束空行。常用的就 print(ln)! eprint(ln)!,没了。print 系列宏不消耗所有权。print! 底层是 write!。
输入
输入需要使用标准库中的 std::io(或者其他非标准库),输入是各行的字符串,需要手动处理。
use std::io;
let mut s = String::new();
io::stdin().read_line(&mut s).expect("failed to read");
let num: i32 = s.trim().parse().unwrap(); // 转换类型过程基础数据结构
这里可能会有一些帮助。
- 栈:Vec. 使用
push()&pop()管理栈。 - (双端)队列:
std::collections::VecDeque - 优先队列(堆):
std::collections::BinaryHeap- Why not use d-ary heap inside rather than binary heap,因此工程实践中可以不用 BinaryHeap。
- 字典 / Object / map(键值对):
std::collections::HashMap - 链表:
std::collections::LinkedList,但是其功能在所有权机制下被削弱了(例如,无法删除一个 iter 的值 (safe))。rust 并不推荐使用链表,如果确实需要完整链表,可以自己写去 https://crates.io 多翻翻。对于链表的实现,在 Rust 中有多种方式,比如:(摘自)- 使用 Box 实现(由于 Box 本身的限制,基本只能实现单向链表);
- 使用 Rc + RefCell 实现(由于 RefCell 的限制,迭代器无法很好的实现);
- 使用 Unsafe 实现;
不得不说手写数据结构确实是一个学习 rust 的好方式,自己写一遍,什么 *Cell 什么 Weak 全部都能吃透。
container
monad
此处特指 Option 与 Result 两种。后面的方法可以不记,实际写到再查(IDE 下拉列表看函数签名)。
- 取出值就是
unwrap(),有几个变体。注意会消耗所有权。 - 对内部映射,
map()&map_err() - 后面继续接 monad:
and_then() - 取出值的引用,可变就
as_mut(),不可变就as_ref()。- 字符串特殊一点,
Option<String>转Option<&str>需要as_deref()。
- 字符串特殊一点,
- 进阶一点,
Option<Option<T>>可以flatten(),Option<Result<T, E>>可以transpose(),等等。可以读手册。
字符串
Rust 的字符串所包含的问题实际上很多,此处只是冰山一角。

最主要的就是 &str 和 String 两种了,前者没有所有权,后者有。
- Rust 字符串默认支持分行。使用 \ 可以使多行字符串不换行。
- Raw String:
r#"\something"# - 字符串转换:
to_owned()orto_string()converts&str->String(造了一个所有权)。也可以用into(),更简单,但是更不直观。 - 字符串连接
字符串修改
在 Rust 语言中,字符串采用 utf-8 编码,字符长度不一,因此 Rust 不提供下标查找字符串的方法。这让字符串的修改需要一点点的技巧。
- 转换为
Vec<char>后修改 C++程序员认为这种方式非常亲切。之后若有需要,还可将Vec<char>重新转换为字符串。注意,Rust 中的char为 4 字节,转为 Vec 后,可进行 O(1) 查找。let s1:String = String::from("Hello我是绝对值_x"); let mut a : Vec<char> = s1.chars().collect(); a[5] = '你'; let s2 = a.iter().collect::<String>(); assert_eq!(s2,"Hello你是绝对值_x"); - replace_range 函数请注意,若替换范围不在 utf-8 字符的分割点上将会导致程序抛出 panic,因此不适用于变字节数的未知字符串的替换。
let mut s1:String = String::from("Hello我是绝对值_x"); s1.replace_range(5..=7,"你"); assert_eq!(s1,"Hello你是绝对值_x"); - as_bytes_mut 方法(unsafe)该方法异常繁琐,同样也不适用于变字节数的未知字符串的替换,但是若替换范围不在 utf-8 字符的分割点上并不会触发 panic. 例如,将第 6 行代码改为
let mut s1:String = String::from("Hello我是绝对值_x"); unsafe { let s1_bytes: &mut [u8] = s1.as_bytes_mut(); let s2_bytes: &[u8] = "你".as_bytes(); for i in 0..3{ s1_bytes[i + 5] = s2_bytes[i] } } assert_eq!(s1,"Hello你是绝对值_x");s1_bytes[i + 6] = s2_bytes[i]的运行结果:Hello�你��绝对值_x
其他字符串
std::path::{Path, PathBuf}是路径字符串。Path没有所有权,PathBuf有所有权。url::Urlurl 是 url 字符串。
语法糖
问号
问号用于提前返回错误。do_something_that_might_fail()? 等价于
match do_something_that_might_fail() {
Ok(v) => v,
Err(e) => return Err(e),
}问号不能在正常签名的闭包中使用,例如 for_each,map 等的参数。可以用 try_for_each,try_map 等(如果有的话)。
或者等他娘的 try_blocks 稳定。
impl Trait
匿名泛型,可以让你少写点东西。
fn print(a: impl IntoIterator<Item = impl fmt::Display>) {
a.into_iter().for_each(|s| println!("{}", s));
}
// equals to:
fn print<T>(a: T)
where
T: IntoIterator,
T::Item: fmt::Display,
{
a.into_iter().for_each(|s| println!("{}", s));
}并发
如果你对 async/await 模型没有明确概念,可以看看这篇文章入门。
rust 提供 async/await 模型和线程模型。
Future
每个 async 函数返回的都是一个 Future<Output = ...>。Rust 的 Future 不像其他语言那样创建即执行,而是需要通过 poll 执行并推进。
- 手写 Future 要注意,如果返回
Poll::Pending,必需要在前面调一次 wake。 - 手动
impl Future for Xxx比较复杂,要手写状态机,因此如果不是写底层库,一般就impl Xxx { async fn call() },虽然调用时不能直接.await而需要.call().await,但是能够极大降低心智负担。
Send/Sync
关于 Send/Sync 可以看这里 或者 external articles 5.。
- 另一个理解是:Send:对象的 &mut 和析构能在别的线程访问;Sync:对象的 & 能在别的线程访问 ——包布丁
关于 Wrappers,看这里即可(我想大家应该都看得懂):
| Struct | Trait |
|---|---|
Box<T> | Send(T) -> Send, Sync(T) -> Sync |
Arc<T> | (Send + Sync)(T) -> (Send + Sync) |
Mutex<T> | Send(T) -> (Send + Sync) |
Rc | !Send + !Sync |
Cell<T>, RefCell<T> | Send(T) -> Send, !Sync |
RwLock<T> | (Send + Sync)(T) -> (Send + Sync), Send(T) -> Send |
此处暂不考虑 allocator.
将这些类型列在一起,可以发现,标准库没有任何包装可以将 !Send 转为 Send。(貌似有一个 crate send_wrapper 可以做到)
tokio
说到并发,目前广泛使用的异步运行时是 tokio。一般 features = ["macros", "rt", "rt-multi-thread"] 是必加的。
关于 tokio 可以看入门秘籍 13 章。
- 立即执行 Future 需要用
spawn。否则只会在 await 时执行。 - 计算密集型任务请用
spawn_blocking,性能提升巨大。spawn_blocking 的默认最大线程数也是很高的(约 512),必要时也可以调小 blocking 池的大小,将任务更合理地分配给 physical thread。- 也可以换用 rayon。
tokio::fs比std::fs要慢很多(10 倍以上),如果你没有高并发 IO 需求请尽可能用 std::fs。
简单批处理
在实际并发中经常碰到需要等待一批 Future 结束并获取返回值的情况。join! 不能 join 任意数量;tokio 有一个 JoinSet,但返回值是乱序的,并且 api 设计也不够易用。所以我们如何获取顺序的并行 Future 返回值呢?
答:用 futures / futures_util crate 的 futures::stream::FuturesUnOrdered。具体使用方法可以参考用例。一般就是将每一个 task spawn,然后将 handle collect 到 FuturesUnOrdered 里再 while let Some(x) = container.next().await 即可。
mod
rust 的 mod 确实会让初学者摸不着头脑。建议先搜几篇文章看看,例如Rust 模块和文件 - [译],也可以问 AI。多写几次就完全掌握了。
- 每一个
.rs文件、mod块 和 带有mod.rs的文件夹 都是模块。 lib.rs(如果不是 lib target 则为main.rs)是顶层模块(crate),其他模块层级即为文件目录层级。- 整个模块结构是一颗树。
- 初始时,只有
lib.rs在模块树内,其他文件都在树外。 - 我们需要使用
mod xxx将模块添加到模块树内。只有在模块树内的模块才会参与编译。 - 添加到模块树后,在某个模块使用另一个模块的定义需要用
use xxx。这里的 xxx 可以是模块树中的“绝对路径”(也就是从顶层模块开始查找,crate::sub1::xxx),也可以是“相对路径”(从当前模块开始查找,super::sub2::xxx)。
- 初始时,只有
其他
- 可以显式调用
std::mem::drop()释放值,不过一般使用代码块,让变量自动销毁,会更加清晰。更多详细解释 - 不知道结构体多大?rust-analyzer 有选项能直接看,将光标放在结构体上,(vscode 中 Ctrl + Shift + P)选择 view memory layout 即可。
语言进阶
trait
trait 可谓是 rust 核心,不是 OOP 胜似 OOP(?),rust 学习的一大难点也是掌握 trait 的用法。
trait 在一些方面有点像其他语言的 interface,但脱离了继承的限制,可以随时创建,随时 implement,trait 之间不需要有联系。
trait 可以为现有的结构附加方法,这是多数强类型语言所不具备的。
trait 简化了泛型的实现。(点名 C++ (<20))
trait 可以“继承”,指 impl 其他 trait 后才能 impl 这个 trait。最经典的就是
Eq需要PartialEq。trait 本身也是一个类型,可以
impl trait for trait:pub trait Process { fn f(&self); } pub trait Takable { fn take(&self); } impl<T> Takable for T where T: Process, { fn take(&self) { self.f(); } }- 但是从其他模块调用 take 时需要
use <mod_name>::Takable。
- 但是从其他模块调用 take 时需要
TAIT:Trait alias.
AFIT:Async Functions in Traits,Rust 1.75 实现了这个语法糖,可以直接在 trait 里使用 async 函数,在 1.75 之前需要用 async-traits crate。
- 但是这并不意味着对于 rust >= 1.75 就可以直接去掉 async-traits,因为 AFIT 的 trait 不能用于创建 dyn object。AFIT 解糖后的结果是
-> impl Future<...>,其返回值大小未知,违反了 dyn object 需要 Object safe 的原则。而 async-traits 宏的输出是Pin<Box<dyn Future>>,这是大小已知的。
- 但是这并不意味着对于 rust >= 1.75 就可以直接去掉 async-traits,因为 AFIT 的 trait 不能用于创建 dyn object。AFIT 解糖后的结果是
dyn object
一个特殊的对象是 dyn object,表示实现了某个 trait 的任意对象。开销比特定类型对象大一点,但是非常好用。
一般要求 Sized,所以要么 dyn object + Sized,要么 Box<dyn object>。如果在 struct 内用,经常还需要加生命周期。虽然要求这么多,但是真正用到才发现好用。泛型写一大串不如直接 dyn 秒了。
- dyn 在写异步时非常好用。异步经常需要跟
Pin<Box<T>>打交道,而我们不能直接从Pin<Box<T>>拿到 T,假设变换过程中随便加一个中间结构(例如std::iter::Map),然后就会变成Map<Pin<Box<T>>>,如果又要 awaitable 又会转成Pin<Box<Map<Pin<Box<T>>>>>,套来套去泛型根本没法处理。
宏
宏是很好用的东西,分为过程宏和声明宏。声明宏简单,常见用来写不定参数的函数/减少重复代码。过程宏就是纯 Token 处理,非常复杂,可以写装饰器。
学习宏,直接去看如何学习中提到的小宏书。讲的很好。
调试宏可以用 cargo-expand,不需要通过编译就能展开宏。注意!cargo-expand 不保证展开后的运行结果与原代码一致!
- 不加
#[macro_export]的话,定义的宏仅在当前 mod 可用。加了#[macro_export]并且当前模块存在于模块树中,则这个宏归属于整个 crate,crate 内使用可以不加模块树前缀。 - 在其他宏里调非卫生宏不能直接用,需要加
$crate显式指定路径。 - 可以定义同名宏重载系统宏,但是注意不能在同名宏里调用被重载的系统宏,否则递归。example
- A Note On Working With Cfg
- 有几个标签类型可以被重解释(
ident,tt等),非常强大。 - 匹配写不出来就上
$(tt)*,啥都能匹配。但是由于 tt 太强,需要注意边界条件,否则把所有 token 全吃了。
生命周期
待续
杂谈
嵌入外部资源
有时候我们要将外部文件/可执行程序嵌入代码二进制中。rust-embed 比较麻烦而且文档不太行,我决定用我自己的方式,带有 zstd 压缩(因为 zstd 解压快,可以尽可能减小运行时开销)。
引入一个 build-dependencies:
[build-dependencies]
zstd = "0.13"然后写 build.rs,编译时将这些文件压缩为 zstd 包:
use std::{env, fs::File, io::Write, path::Path};
use zstd::stream::write::Encoder;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=dwarfs-0.12.4.exe");
println!("cargo:rerun-if-changed=winfsp-x64-2.1.25156.dll");
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("dwarfs.exe.zst");
compress_to(include_bytes!("dwarfs-0.12.4.exe"), dest_path);
let dest_path = Path::new(&out_dir).join("winfsp-x64.dll.zst");
compress_to(include_bytes!("winfsp-x64-2.1.25156.dll"), dest_path);
}
fn compress_to(input: &[u8], output: impl AsRef<Path>) {
let f = File::create(output).unwrap();
let mut encoder = Encoder::new(f, 19).unwrap();
encoder.write_all(input).unwrap();
encoder.finish().unwrap();
}在程序中使用宏引入(因为 include_bytes! 必须接受 literal,所以不能用 fn 传入 path):
/// decompress the prebuilt zst file and write to a temp file.
macro_rules! write_prebuilt_zstd {
($zst_filename:expr, $output_path:expr) => {{
let compressed_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/", $zst_filename));
let file = std::fs::File::create(&$output_path).expect("create temp file failed");
let mut decoder = zstd::stream::Decoder::new(std::io::Cursor::new(compressed_bytes))
.expect("zstd decoder create failed");
let mut writer = std::io::BufWriter::new(file);
std::io::copy(&mut decoder, &mut writer).map(|_| $output_path)
}};
}
write_prebuilt_zstd!("dwarfs.exe.zst", my_path)?;这样就可以把嵌入进二进制里的内容解出来了。
当然也有现成的库 include_assets 可以做到这一点。不过这个库基本不再维护,且 pull request 区也有指示其 bug,建议谨慎使用。
你不该用 Rust 做...
有些东西就是大坑,劝你别往坑里跳。
- 音频:虽然有万能解码器 Symphonia,但是编码器这块缺的可太多了,除了 wav 这种简单格式有 pure rust 的 hound,大部分编码器都还只有 bindings。
- 视频:就连音频都还是那个鸟样,还想要 pure rust 的视频编解码库?洗洗睡吧,老实滚回去用 ffmpeg。
- 加密:加密算法太多了!而且依赖于加密领域的专业知识。
Cargo
rust 唯一官方指定包管理器:cargo,而且在一众语言包管理中是顶级的。
- cargo 的 dep 版本中,
xxx = 1.2.1指的其实是>= 1.2.1, <2.0.0,与 npm 中的^1.2.1一致。(ref)
cargo envs
常用 cargo 指令
太常用的就不说了。
cargo clippy --fix --all-targets --all-features --allow-staged --allow-dirty:用于自动修复 clippy 问题的终极命令。cargo tree -i xxx:查询某个依赖的路径,弄清引入它的罪魁祸首。
我的配置
创建 ~/.cargo/config.toml,参考我的配置。
然后由于现在大家都用 CI release,因此 [profile.release] 要写在项目里而不能写全局。
一些解释:
- Book: Optimizing Build Performance
- checksum-freshness:正常情况下 cargo 按 mtime 进行编译缓存,也就是即使不修改文件内容,只要按了保存,就要重新编译此 package 及所有依赖其的项。使用 checksum-freshness 可以根据文件 hash 进行编译缓存,也就是疯狂按保存而不修改内容是不会触发缓存失效的。
fmt
在 rustfmt.toml 里写代码的格式化选项。我一般会开这些:
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
merge_derives = true
unstable_features = true
wrap_comments = true懒的话也可以直接抄前辈的。
构建
cargo build 在全局获取包与依赖的源码,并编译到 target 里。rust 的包构建体积膨胀非常厉害,而且同一份源码的编译产物可能不同[3],因此没有全局缓存,还是需要为每个仓库都编出中间产物。
不过可以试试 sccache 全局缓存。
扩展
cargo 扩展跟 git 扩展很像,只要是名为 cargo-xxx 的可执行文件都能视作 cargo 扩展。这些扩展与 cargo 一起组成了整个优秀的 rust 工具链生态。以下列举一些常用的 cargo 扩展应用。
| 名字 | 简介 |
|---|---|
| miri | rustup +nightly component add miri,用于更严格的测试,检测内存泄漏与不安全,死锁等 |
| cargo-binstall | 安装 binary,减少从源码编译 |
| cargo-bloat | Find out what takes most of the space in your executable. |
| cargo-expand | 展开宏 |
| cargo-msrv | Find the minimum supported Rust version (MSRV) for your project |
| cargo-wizard | 提供编译模板以配置为最大性能、快速编译时间或最小二进制大小。感觉一般,对于新手还行,老手不都有自己的模板吗。 |
| flamegraph | benchmark 火焰图 |
| cargo-bisect-rustc | 二分查找哪个 rustc nightly 版本引入了错误 |
| cargo-shear / cargo-machete | Remove unused Rust dependencies |
| cargo-nextest | 好用的 test 工具,有超时失败,log 筛选等特性 |
| cargo-audit | 查依赖漏洞 |
| cargo-hakari | 加速构建的黑科技 |
| cargo-selector | TUI 快速选择运行目标 |
| cargo-sweep | 部分清理编译产物 |
| cargo-depgraph | 看依赖关系图。这一个工具的依赖有点多,不能直接出图,感觉不太好用。 |
| cargo-semver-checks | 检查 API 是否遵循 semver 规范 |
三方库评价
多看热门项目用的库,是发现好用的库的好方法。还有一个方法是水群。
热门
有一些库几乎成为业界标准,必需掌握。
| 库名 | 简介 |
|---|---|
| anyhow | 一般用于 bin target 的错误处理,把所有 error 统一起来,可以偷懒,有需要也可以 downcast 到具体错误类型。 |
| thiserror / snafu | 一般用于 lib target 的错误处理,需要让下游使用者区分错误类型并区别处理。thiserror 比较简单,可以想象成一个快速 from other error 的容器。snafu 更复杂也更全能,完成此工作的同时也提供了易用的 context、whatever、ensure 等方便的控制。 |
| arc_swap | 高性能的读多写少并发容器 |
| tokio | 异步运行时 |
| serde | 序列化与反序列化 |
| reqwest[4] | 高层次的 Http Client |
| clap / palc | 命令行工具,后者是为了减小二进制体积而使用的 |
| tempfile | 创建自动销毁的临时文件/文件夹 |
| rayon | 易于使用的线程级并发库,针对 CPU 负载任务 |
| indicatif | progress bar |
| colored / simply-colored | 命令行颜色输出,后者更适合用于 no_std |
| rand / smallrand | 随机数,后者更适合用于 no_std |
| parking_lot | 一个解锁分配更公平的、没有 poison 的互斥锁 |
| enum_dispatch | 如果一个 tagged enum 的每个 variant 都实现了某个 trait,那么此 enum 本身可以直接实现这个 trait。(trait 不可携带 type) |
| walkdir | 递归访问文件系统 |
推荐
另外一些库则是我用过然后觉得好用。
| 库名 | 简介 |
|---|---|
| memchr | 字符串查找 |
| assert2 / pretty_assertions | 全兼容的好看的 assert |
| tap | 函数式工具,在链式中途拿取引用操作而不影响返回值 |
| enum-tools / strum_macros | 提供 enum 的常用方法,最常用的就是字符串互转了 |
| pollster | 小而美,专注于 在同步环境运行异步函数 一件事,打破同步与异步间隔,强烈推荐 |
| expect-test | 自动更新 test 中 assert_eq 的期望值 |
| const-hex | Vec<u8> -> hex str |
| constime | 计算编译期值,用一个非常简单易用的宏 |
| inquire | 用户命令行交互 |
| samply | profiler (support flamegraph, tutorial video) |
| binrw | 二进制内容的序列化 / 反序列化 src |
| terminal-menu | 一个 TUI 终端选择器,小巧又好用 |
| arcstr | Arc<str> 的高性能替代,节命神器。注意在多线程下引用计数的性能有问题。 |
| ustr | 全局去重的 &'static str 池。如果你的拷贝次数远大于构造,且不同 str 的构造次数有限,可以使用它,无需关心各种生命周期,clone 起来飞快。 |
| flexi_logger | 如果你想在 log crate 下使用一些高级收集特性,可以尝试它,用着非常舒适,而且我想要的功能,rolling、compress、batch 等都是开箱即用。这是一个我如何使用它的样例。 |
这里还有一个常用库的列表可以参考。
拉黑
当然,也有一些避雷条目一生黑(不仅限于 lib):
| 库名 | 吐槽 |
|---|---|
| teloxide | Telegram bot 库,但是没有文档,只有一点最简单的 example;遇到各种问题没有解决方法;API 经常 break 并且设计得很丑 |
| rusqlite | 绑定了 openssl!不要用它,要玩 sqlite 请左转 sqlx |
| listeners | 有严重的性能问题 ref |
| crossbeam-channel | 该暴露的方法不暴露,该设计的 trait 不设计 trait,该实现的功能没有实现,性能还不如 crossfire 一根毛(ref) |
| pingora | issue 爱理不理,trait 设计糟糕,大公司开源但不是真正意义上的开源 |
| tracing 系 | 性能不如 fasttrace 一根,tracing-appender 代码写得一坨狗屎,众望所归的 feat pr 都喂到嘴边了就是不合 |
| xq | 跟 jq cli 不兼容;纯纯傻逼玩具,性能垃圾,打一个 1G json,jq 和 jaq 峰值内存都用不了 6G,xq 吃了 20G 都打不出来 |
| tauri_specta | 2.0.0-rc.21 的指令是错的,cargo add 会版本冲突;有些第三方可以 serde 的类型没有实现 derive(specta::Type),例如 chrono:😗;很多第三方 error 类型也没有实现 serde/Type,没法用。建议只用 ts-rs 做 struct 结构生成,事件还是用 tauri 那一套。 |
| tauri-plugin-http | 这是它的 issue 区,低级问题太多了,不知道一个 client 为什么能做得这么屎。还容易遇到权限问题。 |
三方库心得
clap
一般我都用 features = ["derive"],使用更方便,但是文档更难找,因为文档默认用的是动态添加成员。clap 就是得多用才能熟练,可以多去网上找找例子。
clap derive 一般都会将 Cli 实例设为 static LazyLock,可以免去到处传参之苦。带来的问题是写测试变得更加困难,因为不同的测试可能有不同的初始参数,而测试是并发的,没法表达不同的 Cli 状态(而且 LazyLock 的话就是只读了)。所以如果 rust 有一个好用的 context 实践的话就好了。
我们可能对命令行有更多自定义的验证,这时候最好 impl Cli 添加自定义的 fn validate(&self),并且在 parse 后调用。不要用 clap 自带的 value_parser,那个是一坨大便;或者可以使用某个 serde_inline_default 宏,但是代码这里不好给出,可以私聊我。
- clap 默认不允许
-开头的 value,如果需要,用户可以用xxx=-xxx,开发者可以考虑 allow_hyphen_values. - clap 的 Vec 默认允许 0 个元素。如果要求用户必须给出内容,可以使用
#[clap(num_args = 1..)]。
如果你对构建后产物有极致要求,可以用 palc,功能比 clap 弱一些,但是可以节省几百 KB 的产物大小。
once_cell
创建 Lazy 或 OnceCell 的 static 变量。在 rustc 1.80.0 以前这是 unstable,但是现已 stabilized(std::sync::LazyLock),所以 once_cell 已经没人用了。
错误处理
rust 界流传着 bin 用 anyhow,lib 用 thiserror 的谚语。它们两个的目的是完全相反的。一个是细化,一个是归一。
- anyhow 可以将所有错误归为一类往外抛,并且还有额外信息(context)支持。
- anyhow 比较“重”,会增大你的二进制大小。如果你不需要用它的一些额外特性(例如 context),也可以
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;。
- anyhow 比较“重”,会增大你的二进制大小。如果你不需要用它的一些额外特性(例如 context),也可以
- thiserror 比较轻量,用来细分自定义的 error 类型,可以自动 derive From another error。
- 不能在两个错误类型中同时 from 同一个 Error。如果确实需要,可能要手动再分 Enum 作为 suberror。
而 snafu 是一个很有野心的挑战者,它可以同时适应类似 anyhow 抛模糊错误和 thiserror 抛精确错误的场景。但是 snafu 也是有缺点的:
- snafu 虽然通过 proc macro 生成
xxxSnafu来简化错误创建,但是本质上还是手动挡。大量的 context 会让代码复杂度变高,并且写 context 的时候如果不注意可能会造成性能问题(就像.context(xxxSnafu { path: p.to_path_buf() })这样,即使不进错误处理的分支也会多进行一次字符串拷贝)。- snafu 对标 anyhow 的部分,也就是 Whatever,并没有想象中的那么好用。即使对于自己创建的 Snafu,每个 Whatever 也都需要 .whatever_context 来抛出异常(如果写闭包的话代码又更长了)。
我的观点大概是,anyhow 对于 binary target 基本是必备,anyhow 的方便是其他库不能替代的。而对于 lib target,thiserror 当然没有问题,如果你的库不需要细分其他 Error 可以用 thiserror 自动档,如果有更细致的要求就用 snafu 手动挡。没有必要专门去用 snafu 重写当前的错误处理方案。
日志
说日志的话总共也就两套方案,一种是传统的 log 方案,另一种是 trace 方案。trace 方案基本上算是给网关和 server 用的,像我这种写垃圾小玩具的肯定是接触不到了。
log 方案的好处就是 log crate 非常统一,而且用起来跟其他语言很像,比较简单。但是 log 只提供底层 API,而如何展示就有很多种选择了。对于一般的小玩具我更喜欢使用 pretty-env-logger,其实只负责打印,没有用到其他功能。而对于更大一点的玩具,需要更多功能的,flexi_logger 会更舒适一些。
trace 方案最常见最泛用的就是 tracing 了,跟 tokio 一样,大企业都在用。但是我不太喜欢(tracing 的一些生态),详见前面的拉黑。
channel
crossbeam-channel 被我拉黑了,大家可以选择 crossfire。它的 crates 页 还有一些跟其他 channel 库(kanel, flume)比的 benchmark。
serde
除了直接 derive 外,serde 一般用得多的技巧还有:
#[serde(rename = "xx")]和#[serde(rename_all = "kebab-case")],自定义序列化的名称与格式。更多宏可以看doc Field attributes。- 对于需要在缺失时使用 empty 的容器对象,
#[serde(default)]是个不错的选择。 - 如果有的结构需要手写 parser,可以顺带实现 serialize trait,代码不会太多。
- serde 提供了 remote derive,也就是为第三方 crate 里的 struct derive serde。但是我没用过,看起来不太好用的样子。
一些 serde 插件:
- serde_valid_derive:使用宏对某些字段进行 validate,避免写大段函数。这玩意还是比较早期的阶段,不够好用,但是出发点是好的,开发者修 issue 也修得也很快。
rayon
rayon 现在已经几乎统治了 rust CPU 负载型的并发。使用 rayon 可以非常方便地写出多线程程序,榨干你的 CPU,并且无需引用任何异步运行时。
rayon 的基础示例可以读 doc 或让 AI 给 example,不再赘述。
rayon 的生态也不错,一个常用的是 indicatif (features = ["rayon"]),它可以让 rayon 并发处理时显示易于阅读的进度条,这在一般耗时较长的 CPU 负载场景下是非常好用的。
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
let process_pb = ProgressBar::new(files.len() as u64);
process_pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")
.expect("Internal Error: Failed to set progress bar style")
.progress_chars("#>-"),
);
files
.into_par_iter()
.progress_with(process_pb.clone())
.for_each(|entry| {...});
process_pb.finish_with_message("Processing complete!");sqlx
如果你写 SQL 比较熟练,不需 ORM,那么 sqlx 就非常适合你。尤其是在当前 Rust 还没有任何特别好用的 ORM 的环境下,sqlx 更是一个不差的选择。
说到 sqlx 就不得不提,它是强制类型的,因此在编译时就需要获取数据库表信息,例如 sqlite 情况下用户需要为其提供一个模板 sqlite。但是(假设用户没有装 sqlite cli)创建一个 sqlite 本身就需要 sqlx,就遇到了鸡/蛋问题。而且修改 schema.sql 也有可能忘记重新构建模板 sqlite。这时候就要用一个 build.rs 在 schema 初始化或改变时自动更新模板 sqlite。这个 build.rs 我写在了这里。
tauri
由于我只写 web based GUI,因此 tauri 成了我的唯一选择。网上有很多喷 tauri 的,我用得不爽也会喷,但又不是不能用,实现我的需求还是没问题的。
- 一开始就不要对 tauri 抱有太大期待,当成一个 IPC 框架用就行了。
- tauri 的很多插件比较狗屎,上面 拉黑 写了一些。很多插件是为了在 js 里操作只有 rust 能拿到的资源,这里建议不要用它们,自己手写 rust 然后暴露 command 给前端调用,这样比较可控。
- 用 tauri 插件有权限问题,很烦,有时候调试半天结果发现是没给权限。如果自己写 rust 代码就没有这些问题。tauri 这些权限并不会让系统更加安全,只会加重开发者的心理负担。
- 而且 js/ts 调某些资源还是比较危险的,先不说 nullable 语言和类型隐式转换一坨大便,光是前端响应式什么时候会重复执行,什么时候没法执行,就够开发者喝一壶了。
- 如果 vibe coding 小子那就更应该多写 rust,少写 js/ts,这都是血的教训。
- 当然有些插件也是可以用的,tauri_plugin_window_state、tauri-plugin-single-instance 这些都比较简单,没啥问题。像这两个插件,写 tauri APP 基本算是必须引入的,可以提升关键的用户体验。
- 错误处理一般是自己写 thiserror,还必须实现 Serialize,这个没法 derive(很多 from 的错误都没法 derive Serialize),只能自己写个:
impl Serialize for Error { fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> where S: Serializer, { serializer.serialize_str(self.to_string().as_ref()) } }
打包
说到打包就不得不提万恶的 openssl,我已经喷了无数次,无数次,无数次[5]…。很多库会提供 rustls feature 来绕过 openssl,例如 reqwest;但是也有库根本不提供,例如 rusqlite。所以 openssl 的问题还是得去解决。
最小化二进制
一般这样够用了。我虽然敏感,但没有 no-std 那么极端。
[profile.release]
strip = true
opt-level = "z"
lto = true
panic = "abort"或者也可以看看 cargo-wizard。
交叉编译
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl我也写过 rust release CI,深知交叉编译在 link 阶段很容易出问题。解法有两个,一个是用工具链对应的链接器,还有一个就是 cargo-zigbuild,蛮好用的。不过注意,windows 和 macos 不能用 cargo-zigbuild;然后这玩意也经常出问题,要做好心理预期,我也骂了很多次。
release
说到 release,我首推我自己写的 rust simple release,优势是配置非常简单 + hack openssl,可以专注代码而无需折腾 CI。(结果折腾 CI 的变成我了,天天跟 cargo-zigbuild 打)
再说到我之前用的 taiki-e/upload-rust-binary-action,我也用了很久,说句实话还行,但是它内部用的 cross docker container,如果有除了 rust 的其他依赖,或者遇到傻逼 openssl 的问题就没辙了。
cross action:使用 docker 容器进行 build,但是不提供压缩产物。
发布
将包发布到 crates.io 上也是极其方便的。直接 cargo publish 即可。
不过我不建议使用 CI 进行 publish。具体原因
测试
assert 有 assert!() 和 debug_assert!() 之分,前者在 release 下仍然会进行 assert,而后者不会。
assert2 是一个全兼容 assert 的更好看的第三方库,是 pretty_assertions 进化版。
关于测试看这一篇就够了。小总结/补充:
#[cfg(test)]指非 test 情况下忽略代码,不会被编译。后面一般接mod test{ use super::*; ...}。#[test]后接函数,名称随意,就是真正的测试函数。- 如果 target 是 bin,则写在 doc 中的测试不会被运行。
cargo test默认不打印 stdout 输出,想打印需要cargo test -- --show-output。- 所有的 test 是并行测试的,如果你的程序里有用到 global mutable static 变量/系统外部依赖,请务必注意不同测试之间是否会互相影响。
- 测试中常用的库:
- serial_test:让指定的测试串行运行。
benchmark
cargo bench 是 rust 自带的 benchmark,但是还在 nightly 阶段。学习可以参考这篇文章,讲的不错。
criterion 是 rust 界最知名的第三方 benchmark 库,它可以在 stable 下使用,并且有更多的功能,例如其默认自带 3s 的 warming up,随机抽样和稳定的耗时,比 cargo bench 好用。不过也有些缺点,例如不能写在文件内部做 unit bench。一个 criterion 的例子:
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use sha2::{Digest, Sha256};
fn bench_md5(c: &mut Criterion) {
let data = b"hello world";
c.bench_function("md5", |b| {
b.iter(|| {
black_box(md5::compute(data));
})
});
}
fn bench_sha256(c: &mut Criterion) {...}
criterion_group!(benches, bench_md5, bench_sha256);
criterion_main!(benches);allocator
rust 的默认 allocator 并不好用(ref);只需要 3 行代码更换一个 global allocator 即可获得免费的性能提升。
之前比较广泛使用的 allocator 是 jemalloc,但是现在它已经似了。目前最热的是 mimalloc,可以看看它的性能测试,建议使用。
用户界面
GUI
GUI 是 rust 日经问题了。
一些 GUI 框架:
- tauri:electron 的竞品,据说很灵车(许多群友都说过了)。
- 2022 年我试了一下,连 example 都跑不过。
- 2024 尝试,还不错。主要是前端工具链是 GUI 界最顶级的那一批,爆杀各类原生 UI。
- 得物商家客服从 Electron 迁移到 Tauri 的技术实践
- tauri-bug-reproducer,T 黑头子(
- egui:原生 GUI,有大项目。
- 20250123 我用了一次 egui 0.3.0,太灵车了,建议别用。
- 窗口 API,还有布局等等都很差,很多地方还要自己拿 size 算(还有缩放坑),太原始,前端一个 flex 全搞定了。而且 API 变化太大,AI 没法输出有效信息,这也是比较致命的。
- slint:嗯写 DSL
- dioxus:也是嗯写 DSL。release 0.5.0 时火了一把
- native-windows-gui:非跨平台
- winio:
莓软的愚人节玩笑 - sciter:web 界面渲染
这里有一些 issue/star 数对比。Are we GUI Yet?是更多 GUI 框架简介。
我早期尝试过一下 iced,用不明白,不用了。反正自从我开始写前端后我就不再写其他非前端 GUI 了,实在是太 naive 太难受了。
他们之中有哪个能达到 electron 80% 的可用程度,称为可用。
——阿卡琳
TUI
比起 GUI,rust 重心还是在 CLI 和 TUI 上。
ratatui
一个广泛使用的 TUI 框架,教程还不错。
我读源码花了挺久时间。如果只想快速上手,建议狠狠抄这个 example。
用着发现个 bug,顺带 pr 了几行[6]。
然后 tui-realm 是基于其做的一个高层 TUI 框架,或许可以一试。
嵌入向量数据库
我需要 rust 侧的轻量嵌入式向量数据库解决方案,要求是 10,000,000 个向量内查最近邻。(每个向量还有附带额外信息)
简单看了一下。我不希望将所有数据先加载到内存,最好的方案应该是 DiskANN,不过这玩意目前还没有 rust 的实现。
- sqlite-vec:sqlite 跨平台扩展,但是现在并不支持最近邻算法。
r18n
去 luoxu 随便一搜,发现 i18n 模型是个自古以来的难题。
我也找了一些看,包括 rust-i18n, r18, i18n-embed, fluent-rs,最后还是感觉 rust-i18n 文档清晰,模型简单,比较适合我的项目。
奇技淫巧
nightly on stable
古语云:async 就像海洋,只有打开 nightly 的人才能到达彼岸。——Sherlock Holo
Rust nightly 有许多好用的东西,然而 nightly 工具链只有日期,没有版本,导致想要下载一个特定版本 nightly 工具链还需要去了解 rust release 流程,十分不友好。fuk
但是有一扇窗为 nightly 打开,那就是 RUSTC_BOOTSTRAP=1。只要开了这个环境变量,就可以不需要重装任何工具链,纵享丝滑 unstable featureref。
external
books:
- Rust 语言圣经:圣经,文风上乘,质量高。
- tour of rust:交互授课式,基础入门。
- rust by example:注重例子。
- 小宏书:专门介绍 rust macro
- Rust Atomics and Locks:底层并发原理入门
- Rust 入门秘籍:一本既简洁又深入的书,非常值得一看(特别是 tokio 相关章节)。
- Rust 编程第一课 - 陈天:比较进阶的书,写得很好,着重讲述了难点和实战
- Rust magic patterns:针对某些狭小的知识点的深入分析
articles:
- Rust Learning Smart Pointers
- Rust 中的闭包递归与 Y 组合子
- 随机 Rust Quiz - dtolnay / Rust Quiz - boxyuwu:想成为语言律师吗?
- 为什么 Rust 需要 Pin, Unpin ?(中文翻译)
- 如何理解 rust 中的 Sync、Send?
- Rust 的 Pin 与 Unpin
- static, const, let 声明变量有什么区别?
- An introduction to advanced Rust traits and generics
- [翻译] async: 什么是 blocking
- Rust Runtime 设计与实现-科普篇 及后续系列文章
- Using Rust Macros for Custom VTables:如何创建一个 runtime object
- 金枪鱼之夜:基于完成的 Rust 异步:compio 项目及其经验
- 用 Rust 搞科研的两年
- The missing parts in Cargo
- Fast Rust Builds
- 幽灵索引类型与匿名结构体
- Rust 中常见的有关生命周期的误解
- Inside Rust's std and parking_lot mutexes - who wins?
- Making a const version of Rust's array::from_fn - How hard can it be?,图码文并茂
由于 rust 的 I/O 较为麻烦,leetcode (比起 洛谷 等)能免去 I/O 之苦。 ↩︎
哪怕同一个编译器同一个包 rust 的编译是有副作用的,比如 env 宏 build script 乃至 proc macro,都是能任意副作用的 (ref) ↩︎
为避免傻逼 openssl 造成的影响,一般建议起手reqwest 从 0.13 起已经将 default ssl 后端切到了 rustls,不需要再手动搞了,好事 ↩︎reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "http2", "charset", "system-proxy"] }。openssl 不仅编译的工具链垃圾,性能也垃圾,打不过 pure rust 的 rustls,打不过手写的 simd。这种垃圾还有什么存在的必要吗? ↩︎
review 还挺严格的,但是 member 说话又好听 ↩︎
