Rustを使ってCLI(Rust)とVSCode拡張(TS+Wasm)を同時にモノレポでリリースしてみました
Rustを使ってCLIとVSCode拡張を同時に作ってみたよという記事です!
ここで言う「同時に作った」とは「CLIとVSCode拡張に共通するコアの処理をRustで実装し、CLIはコア処理以外もRustで実装する一方、vscode拡張はTSベースで実装してコア処理はWasmに変換して呼び出す実装にした」という意味です。
それをモノレポでやってみて、けっこう色々いい感じだった&勉強になったので記録を残しておいてみます👶
なお、ツールのアイデアをくれたtaisaさん、Wasmを使うアイデアをくれたyokoishiさん本当にありがとうございました!
作ったもの
MySQLのINSERTクエリをテーブルのような見た目に、つまりカラム名と各行の値がタテ方向に並んで見えるようフォーマットするというものです。
自分の勤め先ではテストデータを大量のINSERTクエリで表現する場面がしばしばあり、地味ながらけっこう役に立っています!
「おっ割といいかも?」と思った方はぜひ使ってみて下さい!せっかく作ったのでリンクを貼っておきますネ👶
- CLI:https://github.com/canalun/insertfmt
- VSCode拡張:https://marketplace.visualstudio.com/items?itemName=canalun.insertfmt
やっていておもしろかったこと
ここからは下記4つの「やっていておもしろかったこと」それぞれについて、実際に何をどうやったか説明していく時間です。
- Wasmを活用した、CLIとVSCode拡張のモノレポ構築
- 外部ライブラリに対するパッチ適用(Rust)
- 関数型での実装
- 「このOSSはがんばってやっているよ!」という気持ちの伝達
おもしろ1: Wasmを活用した、CLIとVSCode拡張のモノレポ構築
冒頭にも書いたように、今回は「CLIとVSCode拡張に共通するコアの処理をRustで実装し、CLIはコア処理以外もRustで実装する一方、vscode拡張はTSベースで実装してコア処理はWasmに変換して呼び出す実装にする」ということを、モノレポでやっています👶
この構成にした理由
- RustやWasmを使ってみたかった
(書いたことや使ったことがなかった) - せっかく作るのでCLIもVSCode拡張も配布したかった
(VSCode拡張は通常TS/JSなんかで実装されがちなので、Rustで作ったツールを流用するにはWasmがちょうどよいと思った)
(CLIとエディタ拡張はそれぞれ一長一短であり、両方でリリースするのはOSS的な強みを増やせると思った。前者はCI/CDに組み込めたり、他ツールと組み合わせたりしやすい。後者は日常的にサクッと使いやすい) - 同じツールをTypeScriptで実装したことがあったが、Rust/Wasmで速くなるか興味があった
(この点についてはまた下記リンク先などふまえて、記事を書いてみたい)
モノレポふかぼり1: 全体のディレクトリ構成
少し詳しく見ていきます。まずは全体のディレクトリ構成から。
.
├── cli
│ └── main.rs ######### CLI本体。coreにある処理をライブラリとして使用する
├── core
│ └── core.rs ######### フォーマット処理が実装されたライブラリ
├── Cargo.toml ######### CLI本体やライブラリのディレクトリを指定。dependencyも管理(後述)
├── Makefile ######### Wasmへのコンパイルコマンドはmakeで実装
├── .vscode ######### VSCode拡張のデバッグ構成(デバッグに必要な各種設定)の置き場(後述)
└── vscode_extension ##### VSCode拡張のディレクトリ。yeomanで初期化
├── node_modules
├── insertfmt_core ##### Wasmのコンパイル先はここが指定されている
├── src
│ └── extension.ts ## VSCode拡張の本体
├── package.json ##### VSCode拡張としての情報(名前、バージョンなど)が入っている
└── webpack.config.js ## Wasm含めてバンドルできるような設定をしている
モノレポふかぼり2: CLIのディレクトリ構成
ルートディレクトリのcli
とcore
に注目してみます。
これは"the book"(公式)や"Command Line Applications in Rust"(公式)を参考にしつつ、標準的なCLIの構成を「コア処理以外の部分」と「コア処理」で分ける形にアレンジしました。
わざわざこうした理由として、コアはVSCode拡張と共通の部分になるのでCLIとは異なる概念で整理しておきたかったのと、気が向いたらコア処理だけライブラリとしてリリースできるようにしておこうというところがあります。
結果的にCargo.toml
はこうなりました👶
[package]
name = "insertfmt"
// licenseなどは略 //
[[bin]]
name = "insertfmt"
path = "./cli/main.rs"
[lib]
name = "insertfmt_core"
path = "./core/core.rs"
crate-type = ["cdylib", "rlib"]
// 以下、dependencyなどは略 //
モノレポふかぼり3: Rust CLIとTypeScript VSCode拡張の同居
上記Rustプロジェクトにそのままvscode_extension
ディレクトリが配置されていますが、「Rust製のCLIのディレクトリに、TypeScript製のVSCode拡張をどうやって同居させよう?」という問いに、自分の思いから下記2点が制約として課された結果です👶
- コアの処理がCLIとVSCode拡張とで共通である以上はいたずらにレポジトリを分けたくはない
- コア処理(≒ライブラリ)もCLIもVSCode拡張も並列の概念として捉えたかったので、各ディレクトリ合計3つを同階層に配置したい
というわけでcore
とcli
がある階層に、そのままvscode_extension
を並べました。
とても素直にRustプロジェクトのディレクトリでみんな大好きnpx yo code
を実行しTypeScriptのプロジェクトを作りましたが、うまくいきました。
(※このyeomanというscaffoldingツールはVSCode拡張開発でとても重宝します。いちおうリンクを貼っておきます)
一方で下記2点は少しハマりどころでした。これらについてもメモを残しておきます。
- VSCode拡張のための設定ファイルの置き場
- Wasmの呼び出しとバンドル
モノレポハマりどころ1: VSCode拡張のための設定ファイルの置き場
VSCode拡張のための設定ファイルは、個人的には大きく2種類に分けられると考えています。
- メタ情報(拡張の名前やバージョン情報などのこと。もっといい名前があるのかも?)
- デバッグ構成(拡張をローカルで動かしたりテストしたりするための設定)
一般的なVSCode拡張レポジトリの構成であれば(少なくとも前述のyeomanを用いて作ったレポジトリであれば)、1の「メタ情報」はルート直下のpackage.json
に、2の「デバッグ構成」はルート直下の.vscode
ディレクトリのlaunch.json
とtasks.json
に、それぞれ記述されます。
.
├── .vscode
│ ├── launch.json # ローカルでの拡張の実行に関する設定
│ └── tasks.json # VSCodeのtask(パレットで実行できるタスク)の設定(launch.jsonに参照されている)
├── package.json # VSCode拡張のメタ情報など
(他にも色々なファイルがあるけど省略)
このうち注意が必要なのは.vscode
ディレクトリです。
考えてみれば当たり前なのですが、特別な設定("Multi-root Workspaces"なるものとか?このあたりは深掘っていないので分かりません!)をしない限り、VSCodeは作業中に開いているディレクトリのルートに.vscode
があればそれを使い、なければ使いません。
そのため、今回のようにルートディレクトリの一階層下にVSCode拡張の諸々が配置されるような布陣の場合、.vscode
ディレクトリをルートに移動させてあげるのが良さそうです(もちろん.vscode
がバッティングするなど色々なケースがあるのでしょうが、そういう時は先ほど言及した「特別な設定」を深堀りするのが良いのかもしれません)。
.
├── cli
├── core
├── Cargo.toml
├── Makefile
├── .vscode # ここに移動する
└── vscode_extension
├── (.vscode) # ここだとダメ
├── package.json
(省略)
そのときlaunch.json
やtasks.json
の内容で、パスが関わるものを更新し忘れないことが肝要です👶
モノレポハマりどころ2: Wasmの呼び出しとバンドル
Wasmの呼び出しとバンドルはとても苦労したのですが、試行錯誤の末、とても素直な形に落ち着いたような気がします。
前提として、Wasmの生成はwasm-packで--target bundler
を指定したうえでVSCode拡張のディレクトリに向けてやっています。なお、このtarget
をnodejs
にしてハマるなどしていました😭
gen-wasm-for-extension:
rm -rf ./vscode_extension/insertfmt_core
wasm-pack build --target bundler --out-dir ./vscode_extension/insertfmt_core --release
そのうえで、拡張からはdynamic importで呼び出しています。
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand(
'insertfmt.fmtInsertQueries',
() => {
// いろいろやって……
// Wasmを使うぞ!
import('../insertfmt_core').then((module) => {
const result = module.format_insert_queries_wasm(text) // textにはフォーマットしたいデータが入っている
// 以下省略
})
// 以下省略
})
}
そして今回はyeomanでwebpackを使う設定にしていたたため、webpack側でもWasmをどう扱うか設定しておきます。具体的には下記を設定するだけで済みました。
experiments: {
asyncWebAssembly: true,
}
このあたりはwebpackのバージョンによって異なってくるのか、色々な情報がネット上に溢れています。今回は試行錯誤の末にこうなったというだけなので、これだけでうまくいかないこともきっとあるのだと思います。
なお、今回のwebpackのバージョンはwebpack@^5.75.0
です。関連モジュールのバージョンも気になる方はレポジトリを見て頂ければと思います👶
おもしろ2: 外部ライブラリに対するパッチ適用(Rust)
ここではRustだと外部ライブラリにパッチを当てるのがめちゃ簡単だよという話をします!
MySQLクエリのフォーマットを実装するうえで、Rust製SQLパーサーのsqlparserを用いました。色々なdialectに対応しているスゴいパーサーです。本当に感謝しかありません。
一方で、このパーサーはパースした結果から実際のクエリを復元すると、もともとクエリに含まれていたエスケープ用バックスラッシュがエスケープされてしまいます。
-- パース前
INSERT INTO `table` ('id', 'text') VALUES (1, 'they say \"Hello\".');
-- パースして、その結果をクエリにもどした後
INSERT INTO `table` ('id', 'text') VALUES (1, 'they say "Hello".');
普通はあまり困らないのだろうし、むしろこの方が便利だからこう実装されているのだと思います。実際コードを見ると、意図的にそうしていることが伝わってきます。
ただ、フォーマッターとしては勝手に内容が変わるとマズい(特に、改行が\n
として入っているなどのケースで影響が顕著になることもあった)ので、どうにかして外部ライブラリに手を入れようと決めました。
パーサーのコードを読んで修正をするのはそれなりに大変だったのですが、修正したバージョンのライブラリを使うのは本当に簡単でびっくりしました。
というのも、Rustはある外部ライブラリを使う際にその実際のソースコードの参照先を下記から選べるのです!(選択肢はもっとあるのかもしれないので調べてみて下さい👶)
- crates.io(デフォルト。Rust公式のライブラリレジストリ)
- gitリポジトリ(公開されていれば何でもOK。プライベートリポジトリでもできるのかは不明)
- プロジェクト内のディレクトリ(プロジェクト内でなくてもいいのかは不明)
そしてこの指定の仕方がめちゃくちゃ単純明快です。こんな感じにCargo.toml
へ書くだけです。
// ここまで略 //
[dependencies]
regex = "1.8.1"
sqlparser = "0.33.0"
itertools = "0.10.5"
wasm-bindgen = "0.2.84"
// これを書き足すだけ!!!
[patch.crates-io]
sqlparser = { git = 'https://github.com/canalun/sqlparser-rs', branch = 'feat/add_non_escape_mode' }
// 以下略 //
上記は今回の実際のCargo.toml
です。どうですかこの「"sqlparser"についてはcanalun/sqlparser-rsっていうフォークのこのブランチを使っているんだねえ」とひと目で分からせる記述!
もちろん別のレポジトリにしたくないという人は、同じプロジェクトのディレクトリにライブラリをまるごと入れて、そこに修正をあててもOKです。
ただ、別のレポジトリにしておくことの良さとして、そのレポジトリで実装した変更をそのまま本家へのPRにしやすいということがあるかと思います👶ウレシー!!(今回も後日PRを出してみようと思います)
なお、the bookでも「パッチを作ってPRにしてそれが本家に取り込まれて」という流れを意識した形で、どうCargo.toml
を書き換えていけばよいかが紹介されています。嬉しいねえ。
おもしろ3: 関数型での実装
関数型プログラミングに前から興味があったので、今回の実装もそれに寄せています。
return get_char_length_matrix(columns, values)
.iter()
.fold(
vec![vec![0 as usize; 0]; columns.len()],
|mut transposed_matrix, char_length_of_row| {
char_length_of_row
.iter()
.enumerate()
.for_each(|(column_index, char_length)| {
transposed_matrix[column_index].push(*char_length)
});
return transposed_matrix;
},
)
.iter()
.map(|char_length_of_column| {
return char_length_of_column
.into_iter()
.max()
.unwrap_or(&(0 as usize))
.clone();
})
.collect::<Vec<usize>>();
Rustではmap()
やfilter()
やfold()
など関数型プログラミングでおなじみの道具立てが何も考えずに使えます(組み込み関数としてサポートされている)。
また、the bookでもけっこうな量のページを「関数型でどう書くか」という話題に割いており、関数型の良さもどんどん取り込んでいこうという意欲が伺えます。やったね!
余談ですが、手続き的な発想でなららくらく実装できる処理も、いざ関数型でやってみろと言われると難しいことが(自分は)多いです。「えっなにそのfoldr()
の使い方は!?」とかあるわけです。
そんな相談をHaskellコミュニティで少ししたのですが、皆さんとても優しく色々と教えてくれました。Haskellのコミュニティは質問やそれへの回答が頻繁にやり取りされているイメージがあり、本当にすごいです。皆さんもぜひ覗いてみてください👶(下記ページの「その1 公式Slackチームの開放」にslackへのリンクがあります)
おもしろ4: 「このOSSはがんばってやっているよ!」という気持ちの伝達
『OSSは「ちゃんとやっていますよ!」感を出しておかないと誰も使ってくれない』という話を耳にし、たしかにそれもそうかと思ったので下記のことをやってみました。
- CI/CDを作ってみる(そんなうまくいっていないけど)
- depandabotを入れてみる
- テストはエッジケース含めて、とりあえず書けるものは書く
- mainブランチを保護して「issueからのPRからのmerge」を1人でやる
- READMEをちゃんと書く
- リリースノートをちょっとだけ作ってみる(大したものではない)
個人的な話ですが、こういう作業ってやり始めたくらいでは「くぅ〜大変だなあ」と思うのですが、やっているうちに楽しくなるものです。
最後に、嬉しかったこと
小さなものでもOSSを作るのはやっぱり楽しいし、また、自分がいる会社では実際に使ってくれる人がいたので嬉しいです!
また今回は特に、全く書いたことも読んだこともなくずっと憧れていたRustで、Wasm含めてツールを作りきれたのが本当に大きな満足感につながりました🎉
所有権や借用に始まり、エラーハンドリングやトレイトなどRustっぽいことをたくさん知れたのがすごく有意義だったなと思います。
また、Rust製の外部ライブラリにパッチ適用&PR作成ができたのも、めちゃくちゃ嬉しかったです!🎉
せっかくなので、このあとは英語でツールを紹介する記事を書いたり、redditやstackoverflowで多量のINSERTクエリの扱いに困っている人がいないか探してみたりしてみようと思います✌
(go製のsqlランダムデータ生成ツールも前に作ったので、興味があればぜひ見てみてください!何かの役に立てば嬉しいです👶)
Discussion