Viddy v1.0.0 リリース: Go から Rust への移行
はじめに
この記事では、私が開発している TUI(Text-based User Interfaces) ツール Viddy の v1.0.0 のリリースにあたって、 Go から Rust で再実装した際の経験や感想について書きたいと思います。Viddy はモダンな watch
コマンドとして当初は Go で実装されていましたが、今回、Rust による再実装に挑戦しました。この記事が、同じように Rust での開発に興味がある人の参考になると嬉しいです。
Viddy について
Viddy は、Unix 系 OS の watch
コマンドのモダンな代替ツールとして開発しました。 watch
コマンド + α の主要な機能として、以下があります。後述するデモを見てもらえるとわかりやすいと思います。
- Pager 機能: コマンドの実行結果をスクロールして確認できる。
- Time machine モード: コマンドの過去の実行結果を遡って確認できる。
- Vim のようなキーバインド
実は当初は Rust での実装を目指していました。
しかし、技術的な壁にぶつかり、一旦リリースを優先するために慣れていた Go で実装したという過去があります。
今回その雪辱を果たせたので感慨深いです。
Viddy のデモ
リプレイスのモチベーション
決して Go 言語自体に不満があったわけではありません。
当時は PoC 的に実装したため、見返してみると改善したい箇所が多く、バグや機能拡張の障害になっていました。そこで、フルスクラッチで作り直したいという気持ちが強まっていました。
また、Rust に対する興味があり、Rust の学習を進める中で何らかのプロジェクトとして手を動かしたいと思っていました。書籍による勉強はしていたのですが、やはり実際に手を動かさないと、言語の特徴や習得感が得られず苦しんでいました。
リプレイスで得られた知見
最適な実装にこだわらず、まずはリリースする
再実装において、最も重視したのはリリース可能な状態にすることでした。
最適な実装にこだわりすぎず、メモリ利用量の最適化や記述の簡潔さは後回しにして、とにかくまずはリリースを目指しました。
決して誇れるようなことではありませんが、このスタンスのおかげで慣れない言語でのリプレイスでもなんとか挫折せずに開発ができたかと思います。
具体的には、現時点では、所有権について深く考えず clone を多用して実装していたりします。最適化の余地はたくさんありますので伸び代しかありませんw
また、メソッドチェーンを使えばより美しく書ける部分も多くあったかなぁと思います。メソッドチェーンを使って、 if
や for
によるループを削減して宣言的に書けそうだなぁと思うのですが、自分の Rust の語彙が貧弱で、また調べるのを怠って、現時点では愚直に実装してしまっている部分も多いです。
一旦リリースしたらこの辺のモヤモヤを解消するために所有権の見直しと最適化、リファクタリングなどを行なっていければと考えています。
もし、コードを見てより良い書き方などがありましたら気軽に issue, PR を作成して教えていただけると嬉しいです!
Rust でリプレイスして感じた良い点と悪い点
Rust に置き換える中で、Go と比較して感じた良い点と悪い点をまとめてみます。
あくまで感想ですし Rust 初心者ですので間違えているところもあるかもしれません。
もし誤解している点があればコメントいただけるとありがたいです!
👍 Propagating error
Rust では Propagating error を利用することで、エラーが起こった場合に早期リターンするというコードが簡潔に記述できます。
Go だと error を返却しうる関数は以下のように定義されます。
func run() error {
// cool code
}
そしてこれを呼び出す側は以下のようにしてエラーをハンドリングします。
例えば、エラーが発生していればさらに呼び出し元にエラーを伝播させるため早期リターンするような形です。
func caller() error {
err := run()
if err != nil { // エラーである(nilじゃない)ならばエラーを伝播させる
return err
}
fmt.Println("Success")
return nil
}
Rust であればエラーを返却しうる関数は Result 型を利用して以下のように記述できます。
use anyhow::Result;
fn run() -> Result<()> {
// cool code
}
そして 呼び出し側でこのエラーを伝播するために早期リターンさせたい場合は ?
を利用して以下のように簡潔に書くことができます。
fn caller() -> Result<()> {
run()?;
println!("Success");
return Ok(())
}
最初は出てきた時は困惑したのですが、慣れてくると非常に簡潔に書けて便利なので良いなと思っております。
👍 Option 型
Go では nullable な値を表現するためにポインタ型にすることがよくあります。
しかし、これは安全ではありません。
よく nil
の要素にアクセスしようとしてしまいランタイムエラーがでるということがありました。
しかし Rust では Option 型があるため安全に扱えます。
例えば以下のような形です。
fn main() {
// Option型の変数を定義
let age: Option<u32> = Some(33);
// matchを使ってOption型を扱う
match age {
Some(value) => println!("ユーザーの年齢は {} 歳です。", value),
None => println!("年齢が設定されていません。"),
}
// if let を使って簡潔に扱う
if let Some(value) = age {
println!("if let を使って、ユーザーの年齢は {} 歳です。", value);
} else {
println!("if let を使っても、年齢が設定されていません。");
}
// 設定されていない場合は 20 歳とする
let age = age.unwrap_or(20);
}
最後の例のように Option 型にはさまざまな便利なメソッドが生えています。
これらを利用することによって、if
や match
などを利用しなくても条件分岐のロジックを書くことができます。使い所によりますが、簡潔に記述できるようになっているのが良いなと思いました。
👍 綺麗にかけると嬉しい
パターンマッチやメソッドチェーンや先述の仕組みを利用して綺麗に短く書けると嬉しいです。
プログラミングのパズル的な楽しさを思い出させてくれるような気がします。
例えば、以下は Viddy で実際に動いているコードの一部です。
コマンド実行間隔としてフラグで渡ってきた文字列をパースして Duration
を返すための関数です。
humantime
というクレートを利用することで、 1s
、 5m
などの形式の時間間隔指定をパースすることができますが、もしこの形式のパースに失敗したら、数値(秒数)が指定されているものと仮定してパースしています。
// https://github.com/sachaos/viddy/blob/4dd222edf739a672d4ca4bdd33036f524856722c/src/cli.rs#L96-L105
fn parse_duration_from_str(s: &str) -> Result<Duration> {
match humantime::parse_duration(s) {
Ok(d) => Ok(Duration::from_std(d)?),
Err(_) => {
// If the input is only a number, we assume it's in seconds
let n = s.parse::<f64>()?;
Ok(Duration::milliseconds((n * 1000.0) as i64))
}
}
}
このように match
を利用して宣言的に書けるような部分が出てくると嬉しいなと思います。
ただ、後述しますがこれはまだ短く、より宣言的に記述することもできます。
👍 ランタイムエラーになることは少ない
先ほどの Option
型などによって、コンパイル時に一定の安全性が確認されるため、開発していてランタイムエラーになることが少なかったなぁと思います。
コンパイラが通ればほとんど問題なく動くというのは、かなりありがたいなぁと感じました。
👍 コンパイラが親切
例えば、先ほどの時間間隔を指定する文字列をパースする関数の引数を &str
→ str
に変えてみます。
fn parse_duration_from_str(s: str /* Before: &str */) -> Result<Duration> {
match humantime::parse_duration(s) {
Ok(d) => Ok(Duration::from_std(d)?),
Err(_) => {
// If the input is only a number, we assume it's in seconds
let n = s.parse::<f64>()?;
Ok(Duration::milliseconds((n * 1000.0) as i64))
}
}
}
これをコンパイルすると以下のようなエラーが発生します。
error[E0308]: mismatched types
--> src/cli.rs:97:37
|
97 | match humantime::parse_duration(s) {
| ------------------------- ^ expected `&str`, found `str`
| |
| arguments to this function are incorrect
|
note: function defined here
--> /Users/tsakao/.cargo/registry/src/index.crates.io-6f17d22bba15001f/humantime-2.1.0/src/duration.rs:230:8
|
230 | pub fn parse_duration(s: &str) -> Result<Duration, Error> {
| ^^^^^^^^^^^^^^
help: consider borrowing here
|
97 | match humantime::parse_duration(&s) {
| +
エラーメッセージを読むとわかるように、この例では humantime::parse_duration
の引数の s
を &s
に変更するといいかもしれないよと提案してくれます。
このように、コンパイラのエラーメッセージが非常に丁寧なのが素晴らしいと感じました。
🤔 もっと綺麗に書けそうだなというストレス
ここからはちょっと微妙だと感じた点の紹介を行います。
綺麗に書けると嬉しいという点と表裏一体なのですが、表現力が高く書き方が多数考えられるため、もっと綺麗に書けるのではないか?というストレスを感じることがありました。
Go であれば、ある程度割り切って愚直に書いていたのですが、それによってコードの書き方ではなく、ビジネスロジックそのものに集中できる感覚があり、個人的には良い点であると感じていました。
しかし Rust ではより綺麗に書ける可能性がある分、より良い書き方を求めてしまい、そちらに少し余計な頭のリソースを使ってしまう感覚がありました。
例えば、先ほども例に挙げた parse_duration_from_str
関数は GitHub Copilot に聞くと以下のように短くできると言われました。
fn parse_duration_from_str(s: &str) -> Result<Duration> {
humantime::parse_duration(s)
.map(Duration::from_std)
.or_else(|_| s.parse::<f64>().map(|secs| Duration::milliseconds((secs * 1000.0) as i64)))
}
match
式も消えて綺麗になっていますね。かっこいいです。
このように綺麗にしようとするとどこまでも綺麗に書けるため、 Rust の語彙が十分身についていない初心者の私はもっと綺麗に書けるんじゃないかなぁと迷い、ストレスを感じることがありました。
また、どこまで綺麗に書くかは人によって好みが分かれるかもしれません。
なので、どこまでかっこいいコードを書くかに関してもちょっと迷ってしまいそうだなぁと感じました。
しかし、これは慣れ・チーム全体の練度の問題かもしれません。もっと精進したいものです。
🤔 標準ライブラリが Go に比べると少ない
後のトピックでも言及するのですが、標準ライブラリが Go に比べると少ないと感じました。
Go であれば標準ライブラリがあってそれを利用すれば間違いないというものも多くあったのですが、Rust では標準ライブラリは用意されていないものもあり、サードパーティを使うということが多かったです。
サードパーティのライブラリを使うのは若干リスクがあるため、避けたいなぁという気持ちがあったのですが、Rust はそういうものなのだろうと思って割り切りました。
これは Rust と Go でユースケースが異なっているからなのかなぁと感じました。
これは非常にざっくりとした印象ですが、 Go は Web 系〜ミドルウェアがカバー範囲、 Rust はより広く、Web 系 〜 ミドルウェア、 低レイヤー、システムプログラミング、組み込みのような領域がカバー範囲なのかなぁと思っています。
そのため全ての領域をカバーするような標準ライブラリを作ろうと思うと、かなりコストがかかってしまうのかなぁと思っております。
また Rust はコンパイラが本当に素晴らしいと思っているので、そちらにリソースが集中されているのかなぁと感じました。
🤔 わからない、難しい
やはり、難しいとは思いました。もっと勉強しなければなりません。
Viddy では利用しているけど自分では消化できていないところを挙げておきます。
- 並行プログラミング、非同期ランタイム
- Dependency Injection のやり方
- マクロの魔法感
また、言語仕様がリッチなため、知らないことを知らない領域が多くあるんだろうなぁと思っています。
これからメンテナンスしつつ色々試して勉強していきたいと思います。
数字で見る Rust と Go
提供している機能も完全に同じというわけではないし比較するのはやや無理があるのですが、参考までにソースコードの行数、ビルド時間、依存しているライブラリの数を比較してみます。
なるべく機能的差分を少なくするため SQLite を利用する機能が含まれていない Viddy の RC 版 (v1.0.0-rc.1
) で計測しました。
Go は Viddy の最新の Go 実装リリースの v0.4.0
で計測しました。
ソースコード行数
後述しますが Rust 実装では Ratatui という TUI 開発のためのクレートのテンプレートを利用したため、テンプレート由来のコードが多くあります。それに加えて、機能も増えている部分があるので、行数が多くなってしまったのではないかと思います。
一般には Go に比べて Rust の方が短いコードでの表現力が高いように感じました。
行数 | |
---|---|
Go | 1987 |
Rust | 4622 |
Go
❯ tokei
===============================================================================
Language Files Lines Code Comments Blanks
===============================================================================
Go 8 1987 1579 43 365
Makefile 1 23 18 0 5
-------------------------------------------------------------------------------
(省略)
===============================================================================
Total 10 2148 1597 139 412
Rust
❯ tokei
===============================================================================
Language Files Lines Code Comments Blanks
===============================================================================
(省略)
-------------------------------------------------------------------------------
Rust 30 4622 4069 30 523
|- Markdown 2 81 0 48 33
(Total) 4703 4069 78 556
===============================================================================
Total 34 4827 4132 124 571
===============================================================================
ビルド時間の比較
Rust 側は機能追加もされている + コード数も多いので、正しい比較ではないですが、それを加味しても Rust のビルドは Go に比べると遅いと言えそうです。
ただし、前述しましたが Rust のコンパイラは非常に優秀で、コンパイル時の型チェックで動作保証がされることも多く、どのように修正すれば良いかもわかりやすく表示してくれるため、これはしょうがないものなのかなと思います。
Go | Rust | |
---|---|---|
初回ビルド | 10.362 | 52.461s |
差分なしビルド | 0.674s | 0.506s |
コードの変更後のビルド | 1.302s | 6.766s |
Go
# go clean -cache 実行後
❯ time go build -ldflags="-s -w" -trimpath
go build -ldflags="-s -w" -trimpath 40.23s user 11.83s system 502% cpu 10.362 total
# 2回目以降
❯ time go build -ldflags="-s -w" -trimpath
go build -ldflags="-s -w" -trimpath 0.54s user 0.83s system 203% cpu 0.674 total
# main.go 変更時
❯ time go build -ldflags="-s -w" -trimpath
go build -ldflags="-s -w" -trimpath 1.07s user 0.95s system 155% cpu 1.302 total
Rust
# cargo clean 実行後
❯ time cargo build --release
...(省略)
Finished `release` profile [optimized] target(s) in 52.36s
cargo build --release 627.85s user 45.07s system 1282% cpu 52.461 total
# 2回目以降
❯ time cargo build --release
Finished `release` profile [optimized] target(s) in 0.40s
cargo build --release 0.21s user 0.23s system 87% cpu 0.506 total
# main.rs 変更時
❯ time cargo build --release
Compiling viddy v1.0.0-rc.0
Finished `release` profile [optimized] target(s) in 6.67s
cargo build --release 41.01s user 1.13s system 622% cpu 6.766 total
依存している標準でないライブラリ数の比較
Go では標準ライブラリで済むことも多いため、なるべく使わないようにしていました。
しかし前述した通り Rust では標準ライブラリ(クレート)は Go に比べて少なく外部クレートに依存しているものが多いようでした。
実際、 Viddy が直接依存しているライブラリ数を見てみると以下のようになりました。
依存しているライブラリ数 | |
---|---|
Go | 13 |
Rust | 38 |
例えば Go であれば標準でサポートされていた JSON のシリアライズやデシリアライズも Rust ではサードパーティ製の serde
, serde_json
などを利用することになります。
また非同期処理のランタイムもさまざまな選択肢があり、自分で選定して導入することになります。
もちろんデファクトスタンダードと言えるようなライブラリはあるようですが、サードパーティへの依存が多いのはメンテナンスコストが上がるんじゃないかなぁという不安がありました。
ですが、Rust では頭を切り替えて外部クレートに依存するのもそこまで厭わないというスタンスの方が良さそうです。
その他のトピック
Ratatui template が便利
今回 Rust で TUI アプリケーションを作るために Ratatui というクレートを利用しました。
Ratatui はテンプレートを準備してくれていて、これが非常に便利だったので紹介します。
TUI アプリケーションは GUI アプリケーションと同様、イベント駆動のような形になります。あるキーが押されたらイベントが発火して何らかの動作が起こるというような形です。
Ratatui は TUI のブロックをターミナルに描画する機能を提供しているので、Ratatui 自体にイベントをハンドリングする処理などはありません。
なので自分でイベントを受け取りハンドリングするような機構を作る必要があります。
テンプレートではそのような仕組みが最初から用意されているので、すぐにアプリケーションを作ることが可能です。
さらに GitHub Actions による CI, CD や、キーマッピングやスタイルの設定をファイルから読み込んでカスタマイズできるようにしてくれたり、という TUI アプリケーションを作る上で必要になってくる仕組みを最初から用意してくれています。
Rust で TUI アプリケーションを作成するときは利用の検討をしてみると良いのかなと思います。
RC 版のテストをコミュニティ、 Reddit で呼びかけ
Viddy v1.0.0 が Rust で再実装したバージョンになるということを、コミュニティに周知するため、GitHub Issue や Reddit でも告知しました。
すると、ありがたいことに、さまざまなフィードバックやバグレポートが寄せられ、中には自ら問題を見つけて PR を送ってくださる方もいました。もし、こうしたコミュニティの協力がなければ、バグが多いままリリースしてしまっていたかもしれません。
この経験から、OSS 開発の楽しさを改めて実感できました。モチベーションも高まりましたし、本当に感謝しています。
Viddy の新たな機能
以前より Viddy ユーザーの方々から「コマンド実行結果の履歴を保存して、後から確認できる機能が欲しい」という声が寄せられていました。そこで今回、SQLite に実行結果を保存し、コマンド終了後も再度立ち上げて結果を確認できる「ルックバック機能」を実装しました。この機能により、例えばコマンドの実行結果の変更履歴を他の人と簡単に共有することが可能になります。
ちなみに、Viddy という名前自体が映画にちなんでいますが、今後も映画ネタを積極的に取り入れていきたいと思います。この「ルックバック」という機能の名前も、その一環として非常に気に入っています。映画『ルックバック』最高でした。
ルックバック機能のデモ
アイコンについて
今 Viddy は Gopher のアイコンを使っているのですが、実装言語が Rust に変わってしまったのでやや誤解を招いてしまいそうだなーと思っています。ただ、このアイコンは最高なのでこのままにしておきたいと思っていますw
「Viddy well gopher, viddy well」というフレーズもちょっと意味深になったかもしれません。
まとめ
今回、Viddy を Go から Rust にリプレイスするという挑戦を通じて、言語の違いやそれぞれの特徴を深く学ぶことができたかなぁと思います。Rust のパターンマッチや Propagating error 、Option 型といった機能、優秀なコンパイラは、より安全で簡潔なコードを書く上で非常に有用であると感じました。一方で、コードの美しさにこだわりすぎるあまり、表現力の高さが逆にストレスになることもありました。また、Go に比べてビルドが遅かったり、標準ライブラリが少ないという点も再実装によって実感を伴って認識することができました。
慣れない Rust でのリプレイス作業でしたが、リリースを最優先にし、まずは動くものを作るという姿勢が結果的にリプレイスを前に進めることができましたし、コミュニティの力を借りて RC 版のテストや改善を進めることができたことは、大きなモチベーションになりました。
今後も Rust での Viddy の開発・メンテナンスを続けて自分の Rust 力を向上させたいと思います。この記事が、Rust に挑戦したいと思っている方々にとって少しでも参考になれば幸いです。最後に、もし Viddy のコードを見て改善の余地があると感じた方は、ぜひ Issue や PR をお寄せください!
Discussion