RealWorld 業務 Rust
- 実際に Rust 1.0 の頃から業務で Rust を使ってコードを保守してきてハマった落とし穴についての
知見恨み言です - 気が向いたら追加します
開発環境編
ビルドマシンを買ってもらえ
- ノートパソコンのCPUとメモリでは限界がある
- CPU 二桁コアのマシンを何人かで共有して使え
- VSCode の Remote SSH でがんばれ
- vim でもいいぞ
ストレージは可能な限りデカくしろ
- target はブラックホール
- 10GB 超はあたりまえ、中には 100GB 超も
- sccache、 cargo cache 、 cargo sweep などを駆使してがんばれ
- docker も使うので大容量ストレージだけが正義だ
sccache 使用例
$ cat ~/.cargo/config.toml
[build]
rustc-wrapper = "/home/***/.cargo/bin/sccache"
cargo cache
で target/...
の使ってないキャッシュを消す例
$ cargo cache --gc --fsck --autoclean-expensive
mold を入れろ
- 一番ビルド時間とメモリを食うのは ld でのリンク(シングルスレッドしか使わないため)
- 並列リンクできる mold は絶対使っとけ
- ビルドでメモリを使うのはリンク時のみ
- 実はメモリは16GBもあれば十分かもしれない
使用例
$ cat ~/.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "/usr/bin/clang"
rustflags = ["-Clink-arg=-fuse-ld=/usr/local/bin/mold"]
ビルド時間を調べろ
-
cargo build --timings
で計測しろ - 謎の features のせいでビルド時間が律速してることが多々ある
- 計測しろ
rust-toolchain.toml をバージョン指定で配置しろ
- rust-toolchain.toml は必ず設置しろ
- 使いたい機能があっても nightly は論外
[toolchain]
channel = "1.72.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
docker でビルドできるようにしとけ
- 開発メンバーの開発環境はてんでバラバラだ
- windows: 10, 11, wsl1, wsl2
- mac: x64, m1, m2
- ubuntu: buster, bullseye, bookworm
- debian, archlinux, NixOS, asahilinux, amazonlinux1, 2, chromebook
- くらい統一されてない
- こうなると docker しか信用できない
- docker のバージョンも厳密に統一しろ
- docker engine バージョン違いで挙動は変わる
glibc のバージョンを揃えるために実行環境と同じ docker コンテナでビルドしろ
- cargo-zigbuild なら glibc のバージョンが固定できるというは幻想で、できる場合もある、のが正しい
- aws lambda を使うなら amazonlinux2 の中でビルドできるようにしとけ
CI で cargo fmt
をチェックしろ
- 常識
- オレオレフォーマットを主張するやつは相手にするな
- ci に
cargo fmt && git diff --exit-code
とかすればよい
CI で cargo clippy --tests --examples -- -Dclippy::all
しろ
- とりあえず全部つけとけ
- パラノイアになれ
- 長いものにまかれろ
- ひとつの warning も許すな
docs.rs にしがみつけ、ソースも読め、github の issue も読め
- 大抵のことは docs.rs を読めばわかる
- でも何をやってるのかわからなくなるので docs.rs にある source リンクを押してソースを読むことになる
- でも何が起きてるのかわからなくて
cargo new hogecrate-sandbox
で local に playground を作って試すことになる - でも実は docs.rs に書いてあったりする
- docs.rs だけでなく github の issue も何度でも検索しろ
crate の semver は信じるな
- rust において信じられるのは semver ではない、作者への信用だけだ
- 0.x はすべて破壊的変更を含んでいると思え
- 1.x になっても非互換な変更を入れてくるやつはいる、aws-sdk-rust とか
バージョンはパッチバージョンまで固定しろ
- パッチバージョンを上げただけでバグる crate は存在する
- aws-sdk-rust とか
rust-analyzer は頼りにならない
- 小規模コードならともかく、コードが大きくなるにつれて動かなくなる
-
features
を認識しなかったりバージョン違いなどで、いずれ動かなくなる - 複数回のコード定義ジャンプしないと理解できないようなコードを書くな
- 「n回のコピペ(切り貼り)で移植可能なコード」のnが小さいほど可読性、可植性が高い
- 誰でも読めてどこにでもコピペできる愚直で平易なコードが長く生き残るコードだ
- https://play.rust-lang.org/ にコピペして実行できるサイズのコードがちょうどよいサイズだ
コンパイルエラーは一番上のエラーから順番に直せ
-
cargo clippy --tests --examples 2>&1 | head -n 40
だけを信じろ - 下の方のエラーは上のエラーが引き起こしているので読むだけ無駄である
gdb は頼りにならない
- どうせマルチスレッド、マルチ非同期タスクのコードのデバッグにはなんの役にもたたない
-
println
とlog::debug
だけが唯一の信頼できるデバッグ情報だ - テストを書いて print debug で二分探索するのが最も早いデバッグ手段だ
ハイゼンバグは実在する
- print すると再現しないバグは存在する
-
println!
はスレッド間で同期を取る
-
musl はあてにならない
- RealWorld 業務 Rust では libssl や libsqlite3 などの共有ライブラリに頼ることになるので musl は使えないと思え
- ↑2つは bundled があるのでフルビルドでなんとかなるが RealWorld では他にも共有ライブラリに頼ることになるのでいずれ musl は使えなくなる
本番環境でも常に RUST_BACKTRACE=1
で実行しろ、
- release にも debug 情報は残せ
-
しろ
[profile.release] debug = 1
- error-chain も failure も thiserror も信用できない
- 結局信じられるのは stack trace の関数名と行番号だけだ
- backtrace が有効なら
.expect("ここで落ちた")
を頑張る必要はない- 安心して
.unwrap()
してくれ
- 安心して
コーディング編
- RealWorld 業務 Rust は個人のライブラリ開発ではない
- この文書は社内のみの業務アプリケーションコードを複数人で書くコツである
alias は使うな
-
use hoge::A as HogeA;
とかするな - 愚直に
hoge::A
とフルパスでタイプしろ - お前は読めても他の人間は読めない
-
type Result<T> = Result<T, MyError>
みたいな std の型名を上書きするのは論外 - お前のことやぞ
use anyhow::Result;
-
use std::error::Error;
とuse std::io::Error;
を見分けられる人間だけが石を投げなさい-
use std::time::Duration;
とuse chrono::Duration;
もあるぞ -
use thiserror::Error:
とuse anyhow::Error:
もあるぞ - 等々
-
- お前は読めても他の人間は読めない
- trait alias も同様
- お前は読めても他の人間は読めない
- 部分コピペで動かなくなるコードは作るな
ファイル先頭で use
は使うな
-
use std::sync::mpsc::channel;
とかするな-
channel
とかの一般名刺が突然出てきてもわからなくなる
-
-
use hoge::Error;
とかするな-
std::error::Error
と区別がつかなくなるから - 部分コピペで動かなくなるコードは作るな
-
- お前は読めても他の人間は読めない
-
std::rc::Rc<std::cell::RefCell<T>>
とかBox<dyn std::future::Future<Output=T>+ Send + Sync + 'static>
とかをノーミスでタイプできるようにしろ- タイピング練習を欠かすな
- でも
use std::rc::Rc;
とかuse std::sync::Arc;
とかならゆるしちゃうかも- メジャーなライブラリでの名前の衝突がないので
- でも
use tokio::sync::Mutex;
とかが突然生えてきたりするので自衛のために愚直にstd::sync::Mutex<T>
と書いてしまおう - 関数内の先頭なら許す
- ファイル先頭と違ってスコープが狭いので
- コピペ可植性が高いので
- でもモジュールの先頭とかには書かないでくれ
- コードを書くときは楽ができるかもしれないが、コードを保守する側としては大変困る
- github で PR を作ってもファイル先頭
use
はコンフリクトの主要な発生源になり大変面倒 -
use std::{thread::sleep, *}
みたいなワイルドカード import は言語道断である - Smithay/smithay のこのコードを見て発狂しないやつだけが石を投げなさい
use hoge::prelude::*
は使うな
- ファイル先頭に限らず
std
以外のprelude
は使うべきでない - どのシンボルが import されているのか、そのライブラリに詳しいお前以外は予想できない
- お前は読めても他の人間は読めない
複雑なライフタイム変数を持つ参照は使うな
- 脳死で
Arc<Mutex<T>>
してClone + Send + Sync + 'static
しろ - 業務で生体参照ライフタイムソルバするのは不毛
- メモリ効率とか速度とか気にするな
- 顧客へのデプロイ速度がすべてだ
- 実行効率の最適化は問題が起きてからやれ
- 非同期rustを書いていると
String
のclone
が頻発する(&strがライフタイム的にできないので)が、気にせずclone
しろ-
Arc<String>
を使う最適化は後から考えろ
-
オレオレ trait は使うな
- trait でオレオレ DSL を作ろうとするな
- お前は読めても他の人間は読めない
- rust-analyzer で定義に飛んで trait だったときの絶望感を味わえ
- 誰にも読めない完璧な抽象化コードよりも、誰でも読めてどこにでもコピペできる愚直で平易なコードが長く生き残るコードだ
マクロは使うな
- お前にしか読めないコードよりも誰でも読めるコピペコードのほうがマシだ
- 修正箇所が O(n) の置換コピペで済むならコピペコードのほうがマシだ
- 誰にも読めない完璧な抽象化コードよりも、誰でも読めてどこにでもコピペできる愚直で平易なコードが長く生き残るコードだ
io をモックできるテストを書け
- io を伴うテストにはすべからく再現性がない(flaky である)
e2e テストを書け
- aws-sdk-rust すら実行時の挙動に破壊的変更が入る
- aws lambda を aws_lambda_runtime で実行する場合 amazon linux で実行することになるが、 glibc のバージョン違いとかでローカルテストが通っても lambda の中では実行時リンクエラーで動作しなかったりする
- e2e test だけが唯一信用できる
huga()?
みたいな (the question mark operator) をそのまま使うな
- エラーを見てもどこで何がおきたかわからん
-
use anyhow::Context;
してhoge.huga(param).context(format!("huga {param:?} で落ちた"))?
を書きまくれ - backtrace も有効化しろ
Builder Pattern はクソ
- Rust の Builder Pattern は crate で API を公開するときのメジャーバージョンの互換性のために使われている
- 非公開内製 crate なら Builder Pattern で setter を生やすよりも Paramater struct を引数にとる new method だけで十分
- お前のことやぞ aws-sdk-rust
- rusoto は良かった、本当に…
- init pattern が好き
println するな log::debug しろ
- log を使っておけばテストやデバッグでも潰しが効く
- tracing にも対応できるぞ
- とりあえず main 関数には脳死で env_logger 入れとけ
- テストにも脳死で
env_logger::builder().is_test(true).try_init().ok()
って書いとけ - これが業務 rust の "おまじない" だ
エラーの型は Result<Result<T, CustomError>, anyhow::Error>
でFA
- Rustのエラー処理はResultのネストが正解
- エラーには分類(回復)可能なものとそうでないもの(panic相当)がある
- 回復不能なものを別にanyhowとしてくくりだすことで
?
を使いつつ柔軟なエラー処理が書ける -
#[tokio::main] async fn main() -> Result<(), anyhow::Error>{ let o = match foo().await? { Ok(o) => o, Err(CustomError::A) => { todo!() } _ => { todo!() } } }
- 単にpanicさせるのではなくエラーレポートを書きたいなどのときに、パニックハンドラのようなlow-levelの処理に頼らなくても良くなるので便利
let _ = hoge()
による _
束縛は使うな _hoge
みたいに名前をつけろ
-
_
で束縛した変数は実は束縛されず、その場で drop される - これは実は変数束縛ではなくパターンマッチング
- 変数スコープを抜けるときに drop されれる他の変数とは処理が異なる
- ややこしいから unused variable warning を避ける目的なら
_hoge
のように名前をつけろ - 公式ドキュメントにも RFCS にも "明示的には" 載ってない挙動です
- Ignoring an Entire Value with _
- wildcard-pattern
- [Rust] _(underscore) Does Not Bind
- Rustにおけるirrefutable patternを使ったイディオム
- destructors
async fn
はあてにならない
-
async fn
の返り値の future が持つ参照のライフタイムは記述できない - clippy が
warning: this function can be simplified using the async fn syntax
とか言ってくるが#[allow(clippy::manual_async_fn)]
で黙らせろ
async fn が使えないので sqlx のクエリ関数の例
#[allow(clippy::manual_async_fn)]
fn run_query<'a, 'c, A>(conn: A) -> impl Future<Output = Result<(), BoxDynError>> + Send + 'a
where
A: Acquire<'c, Database = Postgres> + Send + 'a,
{
crate 名は常に hoge_huga
を使え hoge-huga
は使うな
- ややこしい
-
serde-json
かserde_json
か間違えたことがないものだけが石を投げなさい
長いものに巻かれろ
- 一番使われている crate がいちばんいい crate だ
- 謎の crate を自作するな公開するな