【令和新改良式】Rust製Webサーバー開発時のホットリロード
はじめに
RustでWebサーバーを建てるケースも増えてきていると思います。サーバーを開発するときは、コードの変更をホットリロードできると便利です。海外の記事で以下があります。
しかし、この内容は古くなっており、オプションの違いやうまくいかない箇所、その他改善点などがあります。
この記事では、上記記事を参考に2024年12月現在で有効な方法を日本語で解説(翻訳ではない)し、さらに別のコマンドを用いた方法について説明します。
記事で紹介されているリロードの方法
以下のようなサーバーを実行します。
use anyhow::Result; #[tokio::main] async fn main() -> Result<()> { let listener = tokio::net::TcpListener::bind("0.0.0.0:3210").await?; let router = axum::Router::new().route("/", axum::routing::get(|| async { "Hello, axum!\n" })); axum::serve(listener, router).await?; Ok(()) }
なお、dependenciesは以下の通りです。
[dependencies]
anyhow = "1.0.95"
axum = "0.7.9"
tokio = { version = "1.42.0", features = ["full"] }
これを実行するときは、通常以下のようにします。
$ cargo run
このサーバーに対してcurlを実行してみます。これは、 cargo run したターミナルとは別のターミナルで行います。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * Connected to localhost (127.0.0.1) port 3210 (#0) > GET / HTTP/1.1 > Host: localhost:3210 > User-Agent: curl/8.1.2 > Accept: */* > < HTTP/1.1 200 OK < content-type: text/plain; charset=utf-8 < content-length: 13 < date: Sun, 22 Dec 2024 14:47:22 GMT < Hello, axum! * Connection #0 to host localhost left intact
問題なく実行できました。
(1)当然の方法: 手動リロード
このエンドポイントの戻り値を ”Hello, axum 2nd!\n” に変更します。
cargo run したターミナルはそのままに、 curl をもう一度実行してみます。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * Connected to localhost (127.0.0.1) port 3210 (#0) > GET / HTTP/1.1 > Host: localhost:3210 > User-Agent: curl/8.1.2 > Accept: */* > < HTTP/1.1 200 OK < content-type: text/plain; charset=utf-8 < content-length: 13 < date: Sun, 22 Dec 2024 14:55:26 GMT < Hello, axum! * Connection #0 to host localhost left intact
結果は変わりません。Rustは事前コンパイル型言語なので、コードを書き換えたら当然再度コンパイルして実行する必要があります。今回、 cargo run はそのままに curl しているので、メッセージが書き換わりませんでした。
それでは、 cargo run していたターミナルを Ctrl + C で止め、再度 cargo run します。
その後、もうひとつのターミナルで curl すると以下のようになります。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * Connected to localhost (127.0.0.1) port 3210 (#0) > GET / HTTP/1.1 > Host: localhost:3210 > User-Agent: curl/8.1.2 > Accept: */* > < HTTP/1.1 200 OK < content-type: text/plain; charset=utf-8 < content-length: 17 < date: Sun, 22 Dec 2024 14:58:32 GMT < Hello, axum 2nd! * Connection #0 to host localhost left intact
今度はメッセージが書き換わりました。このように、
- コードを書き換え、
cargo runしているものをCtrl + Cで止め、- 再度
cargo runする
ことでリロードを行えます。
課題
この手順は非常にシンプルな方法ですが、コード修正後に毎回手動で実行する必要があります。
(2) 単純なホットリロード: cargo watch を使う
コード変更を検知しコマンドを実行できるツールとして、cargo-watch があります。以下のようにインストールできます。
$ cargo install --locked cargo-watch
cargo-watch を利用することで、コード修正後に自動で cargo run を再実行できます。具体的には、 cargo run の代わりに以下を利用します。
# -x: cargoコマンドを実行。複数可。 $ cargo watch -x run
エンドポイントの戻り値を ”Hello, axum 3rd!\n” に変更し、保存しましょう。すぐ(0.5秒後)に cargo run が始まります。
curl すると次のようになります。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * Connected to localhost (127.0.0.1) port 3210 (#0) > GET / HTTP/1.1 > Host: localhost:3210 > User-Agent: curl/8.1.2 > Accept: */* > < HTTP/1.1 200 OK < content-type: text/plain; charset=utf-8 < content-length: 17 < date: Mon, 23 Dec 2024 01:53:53 GMT < Hello, axum 3rd! * Connection #0 to host localhost left intact
課題
この方法は自動で再起動がかかり便利です。一方、再起動中にリクエストすると、以下のようにエラーになります。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * connect to 127.0.0.1 port 3210 failed: Connection refused * Trying [::1]:3210... * connect to ::1 port 3210 failed: Connection refused * Failed to connect to localhost port 3210 after 6 ms: Couldn't connect to server * Closing connection 0 curl: (7) Failed to connect to localhost port 3210 after 6 ms: Couldn't connect to server
この記事で作成しているサーバーは最低限の機能しかないため、以下の通りビルドは2.4秒程度です。更に起動まであわせて3秒程度で完了します。
しかし、現実のアプリケーションでは、ビルドや起動により多くの時間がかかることが考えられます。手動で curl しているうちは気になりにくいですが、フロントエンドアプリケーションから利用していると思わぬタイミングでエラーハンドリングが起こり動作確認に苦戦する原因になりえます。
(3) サーバーダウン中にもリクエストを拒否しない: systemfd を組み合わせる
ここからは、「ビルドや起動により多くの時間がかかる」ことのシミュレーションとして、10秒間のスリープを挟んでから cargo run するようにしてみます。
# -s: シェルコマンドを実行。複数可。 $ cargo watch -s 'sleep 10s' -s 'cargo run'
このようにしたうえで、コードを変更し保存してみましょう。先ほどと同様のエラーが十数秒出続けることになります。
そこで登場するのが systemfd というコマンドです。以下でインストールできます。
$ cargo install systemfd
systemfd の説明をする前に、実際のコマンド例を示します。
$ systemfd --no-pid -s http::3210 -- cargo watch -s "sleep 10" -s "cargo run"
systemfd は3210ポートをラップしてリクエストを受け付けます。そして後続のコマンドでサーバーが同じポート番号に立ち上がるまで待機し、立ち上がったらそこへリクエストを流します。
さて、まずは上記で cargo run まで到達した状態でリクエストしてみます。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * Connected to localhost (127.0.0.1) port 3210 (#0) > GET / HTTP/1.1 > Host: localhost:3210 > User-Agent: curl/8.1.2 > Accept: */* > ^C
……動きませんでした。
課題
そもそも動きませんでしたので、動かなかった事自体が課題となります。
また、動いたとしても、リクエストの応答はコンパイル等が完了するまで待つ仕組みです。つまり、コンパイル時間の増大に応じて待ち時間が増えることになります。フロントエンドアプリケーションでタイムアウトを設定している場合、それに引っかかる可能性が高くなっていきます。
(4) マーカーファイルを使用し、ビルド後に再起動をかける
新たなアプローチとして、以下のように考えます。
- まず、通常の
cargo watchを使い、チェックやビルドを行います- このステップの最後に、
.triggerファイルを更新します
- このステップの最後に、
.triggerファイルの更新をトリガーに、サーバーを再起動します
以下が具体的な手順です。
# `cargo-watch` は `.gitignore` で指定されているファイルを無視するので、 `.trigger` を追加しておきます。 $ echo ".trigger" > .gitignore # ターミナル(1)で、watchしてビルド等を行います。 $ cargo watch -s "sleep 10" -s "cargo run" -s "touch .trigger" # ターミナル(2)で、 `.trigger` を監視して `cargo run` を行います。 $ cargo watch --no-gitignore -w .trigger -x run
コードを更新すると、ターミナル(1)でビルド等が行われます。ターミナル(2)で( .trigger を通して)ビルドの完了を検知し、サーバーを再起動します。ターミナル(1)ビルド中、サーバーは古いコードで稼働を続けます。
コードの編集時、ターミナル(2)での再起動中だけ以下のように接続が切断されます。
$ curl -v localhost:3210/ * Trying 127.0.0.1:3210... * connect to 127.0.0.1 port 3210 failed: Connection refused * Trying [::1]:3210... * connect to ::1 port 3210 failed: Connection refused * Failed to connect to localhost port 3210 after 5 ms: Couldn't connect to server * Closing connection 0 curl: (7) Failed to connect to localhost port 3210 after 5 ms: Couldn't connect to server
しかし、これはビルド時間に比べると非常に短い時間で済むことがほとんどだと思います。
課題
この方法で十分な場合は多いですが、以下の課題があります。
再起動中に切断される
前述の通り、再起動中は通信が切断されます。これは、 systemfd などを組み合わせることでカバーできます(ただし、動作しない環境もあります)。今回は systemfd がうまく動作しませんでしたので、以降の方法でも同様の課題がありますが、記載を省略します。
cargo watch を2回起動する必要がある
ターミナル(1)(2)が出てきたように、2回 cargo watch をする必要があります。これらは、バックグラウンドプロセスにするか、2つのターミナルを用意する必要があり、起動や停止が手間になります。
最大の課題: cargo-watch がメンテナンスモードである
ここまで cargo-watch を利用する方法を紹介してきましたが、2024年10月に cargo-watch がメンテナンスモードとなることがアナウンスされていました。
https://github.com/watchexec/cargo-watch#maintenance
今後積極的な機能追加は行われず、代替案があれば乗り換えを検討したいところです。
令和新改良式の方法
cargo-watch のREADMEを読むと、代替として bacon および watchexec が提案されています。
(5) bacon
推奨されている bacon を使用してみます。以下でインストールできます。
$ cargo install --locked bacon
bacon は、現在のリポジトリの bacon.toml にjob等の設定を書き、job名を指定して実行します。詳しくはドキュメントを参照してください。
以下が今回利用する設定ファイルになります。
default_job = "build-and-trigger" env.CARGO_TERM_COLOR = "always" [jobs.build-and-run] command = ["bash", "-c", "sleep 10s && cargo build && cargo run"] need_stdout = false allow_warnings = true background = false on_change_strategy = "kill_then_restart" # baconではファイルの内容変更を監視しているようなので、touchではなく適当な内容で更新されるようにしておく [jobs.build-and-trigger] command = ["bash", "-c", "sleep 10s && cargo build && date +%s > .trigger"] need_stdout = false background = false extraneous_args = false [jobs.run-trigger] command = ["cargo", "run"] need_stdout = true allow_warnings = true background = false on_change_strategy = "kill_then_restart" default_watch = false watch = [ ".trigger" ] apply_gitignore = false [keybindings] r = "job:run-trigger"
これを用いて、以下のように実行します。
# ターミナル(1)で、watchしてビルド等を行います。 $ bacon build-and-trigger # ターミナル(2)で、 `.trigger` を監視して `cargo run` を行います。 $ bacon run-trigger
結果は(4)と同様です。また、上記設定ファイルで定義した bacon build-and-run を利用すると、(3)と同様になります。
baconの機能についての補足
設定ファイルの雛形
設定ファイルが存在しない状態で bacon --init を実行すると、設定ファイルを生成します。これにはいくつかのjobが記載されています。これをこのまま使えば十分なケースもあるかと思います。
複数コマンドの実行
bacon のjobは1つの実行可能ファイルを実行するようにしか定義できず、 && などで複数のコマンドを連結できません。今回は代替案として bash -c "${COMMANDS}" のようにしています。
ちなみに、job設定に on_success = "job:foo" と書くこともできます。しかし、これでjobを移動すると「現在実行中のjob」が変わってしまい、元のjobから実行することができません。
たとえば、 cargo check のあとに cargo clippy を行いたいとき、以下のようになるかと思います。
[jobs.check] command = ["cargo", "check"] on_success = "job:clippy" # ...その他の設定... [jobs.clippy] command = ["cargo", "clippy"] # ...その他の設定...
この状態でコードの変更を行うと、check, clippyの順に実行されます。しかし、その後再度コードの変更を行うとclippyしか実行されません。
このような複数タスクを実行する機能は、以下issueで提案されている状態です。
並列job監視
スクリーンショットから勘違いしていましたが、baconはシングルタスクランナーであり、複数のjobを並列に監視する機能はないようです。この機能は以下のissueで議論されています。
なお、このissue中にある複数jobの指定方法は1.xのものであり、現在の2.xでは利用できないようです。
bacon.toml のホットリロードの不具合?
baconの設定ファイルは編集時に自動でリロードされます。しかし、私の環境ではリロード時に bacon を実行しているターミナルが応答しなくなることが多々ありました。編集時には、 bacon を止めてから編集することをおすすめします。
課題
この場合の課題は、 bacon を2回起動する必要があることです。これは cargo-watch のときと変わりありません。
(6) watchexec
もうひとつ、推奨されている watchexec を使ってみます。以下でインストールできます。
$ cargo install --locked watchexec-cli
watchexec は引数に条件やコマンドを指定します。
# ターミナル(1)で、watchしてビルド等を行います。 $ watchexec -w src 'sleep 10s && cargo build && date +%s > .trigger' # ターミナル(2)で、 `.trigger` を監視して `cargo run` を行います。 $ watchexec --no-project-ignore -w .trigger -r -- cargo run
結果は(4)と同様です。
課題
watchexec の場合でも、2回起動が必要です。
まとめ: 個人的なおすすめ
- 素直にコマンドが記述でき、開発が継続している
watchexecを用いた(6)がおすすめです- このくらいの規模感であれば、シェルスクリプトにしておけばよいでしょう
- ひとつの設定ファイルにまとめたい場合は、
baconで(5)を実施しましょう- 紹介は省略しますが、複数のコマンドを連結させる場合は、
cargo-makeなどを併用すると良さそうです
- 紹介は省略しますが、複数のコマンドを連結させる場合は、
- 複数のjobがある場合に対応した選択肢はないので、諦めてターミナルを複数立ち上げましょう
その他の活用ケース
On テスト
ホットリロードとは少し異なりますが、テストケースを足したり、コード修正に伴って特定のテストを更新したりといったときにも、更新後のコードを読み込んで実行してほしいと思います。実行するコマンドの違いはありますが、本記事の内容を参考に実行が可能かと思います。
On Doc
ドキュメント記載時も同様に対応できます。特に bacon はドキュメントに対応した初期設定が生成されます。 bacon --init で生成される設定を確認しましょう。
参考: 環境情報
PC
Rust
$ rustup --version rustup 1.27.1 (54dd3d00f 2024-04-24) info: This is the version for the rustup toolchain manager, not the rustc compiler. info: The currently active `rustc` version is `rustc 1.82.0 (f6e511eec 2024-10-15)` $ rustc --version rustc 1.82.0 (f6e511eec 2024-10-15) $ cargo --version cargo 1.82.0 (8f40fc59f 2024-08-21)
cargo-watch
$ cargo-watch --version cargo-watch 8.1.2
bacon
$ bacon --version bacon 3.6.0
watchexec
$ watchexec --version watchexec 2.2.1 (2024-12-23) +pid1 build-date: 2024-12-23 release: 2.2.1 features: default,pid1
SNSアカウントの活用方針
連絡がつくもの・つかないもの
1日1回以上確認する
- Twitter: @const_MUTE
- タイムラインはほぼ確認できていません
- DMは通知が来るように設定しており、確認しています
数日に一度確認する
- Discord
未定
- Bluesky: @vraisamis.bsky.social
- 文字通りアカウントを「作っただけ」
- Misskey: @vraisamis
- 文字通りアカウントを「作っただけ」
現在確認していない
今後の予定
async-graphql製アプリのセキュリティ対策
概要
上記を読んで、先日つくったGraphQLアプリに対策を施すことができるか確認した。
先日作ったGraphQLアプリ
やったこと
- introspection対策
- field suggestion対策
- 再帰的Fragment対策
- alias対策
やっていないこと
- persisted queryの導入
- 調査時間不足で全体像が分からず断念
- extensionsに実装は存在する
introspection対策、field suggestion対策
この2つはメソッドを呼ぶだけで対策が可能。
SchemaBuilder 型のメソッドに disable_introspection() disable_suggestions() があるので、それらを呼ぶだけ。
例えば、releaseビルドでのみ無効化したいときは、以下のようにする。
let s: SchemaBuilder<_, _, _> = ...; if cfg!(not(debug_assertions)) { s = s.disable_suggestions().disable_introspection(); }
再帰的Fragment対策
元々対策されている模様。
(……が、エラーメッセージが「再帰深度の最大は32です」なので確認したほうがよさそう)
query { user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { ...A } } fragment A on User { name ...B } fragment B on User { ...A }
{ "data": null, "errors": [ { "message": "The recursion depth of the query cannot be greater than `32`", "locations": [ { "line": 12, "column": 20 } ] } ] }
alias対策
aliasはどれだけ多くても許可するようになっている。以下のように10000個のaliasをリクエストしたが普通にresponseが返ってきた。
{ alias0: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } alias1: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } # ... alias9999: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } }
これに対策するには独自のExtensionの実装が必要。以下に記載がある。
この中の parse_query を利用する。
let result = next.run(ctx, query, variables).await?; とすると、resultの中からパース済みのGraphQL queryを取り出せるので、そこからaliasを見つけてカウントしていき、エラーを出すようにする。
今回は以下のように制限した。
- aliasは全部で10個まで
- 各階層(ネストの深さ)ごとに、aliasは合計3つまで
全体コードは以下。
実装のコアは以下。
#[async_trait] impl Extension for RestrictQueryAliasesImpl { async fn parse_query( &self, ctx: &ExtensionContext<'_>, query: &str, variables: &Variables, next: NextParseQuery<'_>, ) -> ServerResult<ExecutableDocument> { let result = next.run(ctx, query, variables).await?; // after parsed let query_selection_set = find_query(&result); let aliases = alias_count_recursive(&query_selection_set); if aliases > self.limit { Err(ServerError::new( format!("エイリアスは全部で{}個より多くできません", self.limit), None, ))?; } let aliases_per_level = alias_count_by_nested_level_recursive(&query_selection_set, 1); let max_aliases_per_level = max_value_or(aliases_per_level, 0); if max_aliases_per_level > self.limit_per_level { Err(ServerError::new( format!( "エイリアスは各階層で{}個より多くできません", self.limit_per_level ), None, ))?; } Ok(result) } }
このextensionをSchemaBuilderで s.extension(RestrictQueryAliases::default()) として作成すればOK。
同じ階層に4つ以上aliasをいれてリクエストをすると以下のようにエラーになる。
{ alias0: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } alias1: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } alias2: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } alias3: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { n0: name n1: name n2: name ownedBoards { b0: id b1: id b2: id owner { id } } } }
{ "data": null, "errors": [ { "message": "エイリアスは各階層で3個より多くできません" } ] }
同じ階層ではaliasを3つ以内に押さえていても、全体で10個以上になるリクエストは以下のようにエラーになる。
{ alias0: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } alias1: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { name } alias3: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") { n0: name n1: name n2: name ownedBoards { b0: id b1: id b2: id owner { id i0: id i1: id } } } }
{ "data": null, "errors": [ { "message": "エイリアスは全部で10個より多くできません" } ] }
async-graphql + sqlx + shakuを使ってGraphQL を試した
- 作ったもの
- Tips
- 気になっていること
RustでGraphQLアプリを構築して、気づいたことや感想まとめ
作ったもの
https://github.com/vraisamis/graphql-rust-sample
- Kanban風のデータを取得できるアプリ
- GraphQLアプリ
- Queryのみ実装済みで、Mutation, Subscriptionは未実装
- QueryではDataLoaderを実装
- 言語: Rust 1.71.0
- ライブラリ(主要)
- サーバー: axum 0.6.19
- GraphQL: async-graphql 6.0.0
- DI: shaku 0.6.1
- SQL: sqlx 0.7.2
- データストア
- Postgres 16.0
- dynamodb on localstack 2.3 (未使用)
- オニオンアーキテクチャ
- DDD(ドメイン駆動設計)
- とはいえ、クエリしか作っていないのでIDくらいしか活用していない
ドメイン
だいたいこんな。未実装箇所もある https://github.com/vraisamis/graphql-rust-sample#domain
レイヤー
だいたいこんな。 https://github.com/vraisamis/graphql-rust-sample#layer
やっていないこと(完了していないこと)
- テストの実装
- Mutation, Subscription
- ドメインの実装
- dynamodb
- CI構築
Tips
Rust: 言語機能
Rustの言語機能関連
Trait alias
何がしたかったか
shaku のモジュール M が Box<dyn UsersQuery> をprovideできるとき、 M: HasProvider<dyn UsersQuery> を満たしている。
M の具体型は実装レイヤで決まるので、実装に関与しないレイヤでは抽象的に扱いたい。
しかしこのとき、 M の指定が大変なことになりやすい。
fn foo<M>(m: &M) where M: HasProvider<dyn UsersQuery>, M: HasProvider<dyn BoardQuery>, M: HasProvider<dyn ColumnsQuery>, M: HasProvider<dyn CardsQuery>, // ... { todo!() }
これを解消するために直感的には type でエイリアスを定義したい。
// これはできない type MyFuncTrait = HasProvider<dyn UsersQuery> + HasProvider<dyn BoardQuery> + HasProvider<dyn ColumnsQuery> + HasProvider<dyn CardsQuery>;
これはできない。
対策
SEE: https://zenn.dev/ttttkkkkk/articles/e213d91ef7b47a
上の記事に全部書いてあるが……以下をやる。
- すべてを前提とするtrait定義
- すべての前提traitを実装している型に対して全称実装
// trait定義 pub trait QueryProvider where Self: HasProvider<dyn UsersQuery> + HasProvider<dyn BoardQuery> + HasProvider<dyn ColumnsQuery> + HasProvider<dyn CardsQuery>, { } // すべての前提traitを実装している型に対して全称実装 impl<T> QueryProvider for T where Self: HasProvider<dyn UsersQuery> + HasProvider<dyn BoardQuery> + HasProvider<dyn ColumnsQuery> + HasProvider<dyn CardsQuery>, { }
ちなみに、この用途だと FooQuery が増えるたびに2箇所に追記していくことになる。マクロで解決できないかと思ったが、構文エラーになって駄目だった。
// これはできない macro_rules! queries { () => { HasProvider<dyn UsersQuery> + HasProvider<dyn BoardQuery> + HasProvider<dyn ColumnsQuery> + HasProvider<dyn CardsQuery> }; } pub trait QueryProvider where Self: queries!(), { } impl<T> QueryProvider for T where Self: queries!(), { }
pub(crate) とか re-exportとか
Rustのstruct, enum定義は縦に長くなりがちである。これは主に以下のような理由による。
- メンバの定義とメソッド定義を別に書く必要があるため、構文要素が多くなる
- ジェネリクスを持つ(
Foo<T>)場合、Tが実装しているtraitによって実装するメソッドを増やしたりするともっと多くなる
- ジェネリクスを持つ(
- traitを実装する記述を書くほど長くなる
- メソッド本体よりも周りの
impl FooTrait for Foo where ...がいっぱいになる
- メソッド本体よりも周りの
- テストを同じファイルに書く習慣がある
また、DDDでは以下のようにしたくなる。
- ユーザー情報に関連するモデルを複数作りたい
UserUserNameEmail
- 同じサブドメインに関連するモデルがわかりやすい構造で外部(上位レイヤ)から参照したい
use domain_kanban::user::{User, UserName, Email}
これをそのまま実現しようとすると、1ファイルに大量の定義や実装を書くことになる。対策として、複数ファイルに分ける方法を利用できる。
具体的には、以下のようにuser/email.rs, user/user_name.rs を作成する。
src/ |-- lib.rs |-- user/ | |-- email.rs | `-- user_name.rs `-- user.rs
そして、以下のようにする。
// -------- user/email.rs -------- // `pub(crate)` で(直接)参照可能範囲を制限する pub(crate) struct Email { // ... } impl Email { // ... } // -------- user/user_name.rs -------- pub(crate) struct UserName { // ... } impl Email { // ... } // -------- user.rs -------- // mod にはpubをつけない( `domain_kanban::user::email::Email` というふうには参照させない) mod email; mod user_name; // pub use (=re-export) で `domain_kanban::{Email, UserName}` で参照できるようにする pub use email::Email; pub use user_name::UserName; pub struct User { // ... } // ...
こうすることで、ファイルが長くなるのを抑えつつ、外部への見せ方を変える事ができる。 ただし、こうするとファイル数が多くなるので、やり過ぎは禁物。
Vec<Result<T, E>> to Result<Vec, E>
SEE: https://qnighy.hatenablog.com/entry/2017/06/14/220000
Vec<T> を map していると、軽率に Vec<Result<T, E>> になる。
これだと扱いにくいのだが、 collect でどうにかできる。
let vec_of_results = vec![Some(1), Some(2)]; // collectは複数の型に変換できるので、要求する型を束縛する変数側に指定する // `collect::<Result<Vec<_>, _>()` でもいいが、個人的にはこちらのほうが書きやすい let result_of_vec: Result<Vec<_>, _> = vec_of_results.into_iter().collect(); // `?` が使える文脈なら、それを使って処理を続ける let v = result_of_vec?; // `?` を使うなら、collect側に書いたほうが1行で完結できる let v = vec_of_results.into_iter().collect::<Result<Vec<_>, _>>()?;
Vec<Future> to Vec
dataloader実装(後述)をやっていても、複数回クエリを回したいときもある。
let result_futures: Vec<impl Future<Output=Foo>> = keys .into_iter() // some_queryがFutureを返す .map(|k| some_query(/* ... */)) .collect();
このあとどうやって処理するかというと、futures-util join_all を使う。
let result_vec: Vec<Foo> = join_all(result_futures).await;
Rust: (Virtual) Workspaceの活用
オニオンアーキテクチャにおけるレイヤー分けを見据えて、リポジトリ全体はVirtual Workspaceにした。
ワークスペースのメンバcrateは、 crates/ ディレクトリ配下に置いた。rootディレクトリがゴチャつかないようにするためで、好みの問題かもしれない。
メンバcrateの指定は Cargo.toml で以下のようにした。
[workspace] members = [ 'crates/*' ]
bin crateが crates/main だけで他がlib crateであれば、 cargo run で問題なくmainが起動できた。
Workspace Dependencies
https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table
Workspaceでこれが一番便利だったかもしれない。
/Cargo.toml の workspace.dependencies 配下にいつも通り依存関係を書く。
各 /crates/*/Cargo.toml の dependencies で、 package-name.workspace = true とすると、そのcrateを利用できる。
例:
# -------- on /Cargo.toml -------- [workspace.dependencies] anyhow = "1.0.72" # -------- on /crates/main/Cargo.toml -------- [dependencies] anyhow.workspace = true # -------- on /crates/domain-util/Cargo.toml -------- [dependencies] anyhow.workspace = true
実際に定義したものを列挙してコメントしておく。説明の都合sourceとは掲載順が違うことがある。
# -------- on /Cargo.toml --------
[workspace.dependencies]
# anyhow, thiserror: エラー処理はどの層でも行うので共通で入れた
# ちゃんとエラー処理するほうに倒すと、anyhowは減っていく可能性もある
anyhow = "1.0.72"
thiserror = "1.0.47"
# tracing, tracing-subscriber: ロギング。
# ログに使っているcrate versionが揃うように。
# tracing-subscriberはもしかしたらmainだけでいいかもしれない。ちゃんとログ実装したら除去できるかも。
# 各層から依存するlog crateでtracingを使っていることを隠蔽しようかとも思ったけど、
# マクロの構文が物によって違いそうなのでやめた。
tracing = "0.1.37"
tracing-subscriber = "0.3.17"
# async-trait: infrastructure層で外部リソースを使用することが必至なため、だいたいasyncにする。
# I/Fはquery層などに置くことになるので、trait methodとしてasyncにする必要がある。
async-trait = "0.1.72"
# futures-util, itertools: 一般的なutility
futures-util = "0.3.28"
itertools = "0.11.0"
# shaku: DI。I/F側と実装側でバージョン統一するため。
shaku = "0.6.1"
# sqlx: これはinfrastructure層でしか使わない……と見せかけて、migrateとかscriptとかで使っている(後述)
# ここではバージョンのみ定義していて、featuresは使う側のcrateで決めている。
sqlx = "0.7.2"
# ulid: これはdomain-utilで吸収しているのでここではいらないかも……
ulid = "1.0.1"
# 以下各レイヤの定義。利用側でpathを毎回書かなくてよくなる。
# 依存されないcrateはここには列挙していない
presentation-axum = { path = "./crates/presentation-axum" }
presentation-graphql = { path = "./crates/presentation-graphql" }
infrastructure-rdb = { path = "./crates/infrastructure-rdb" }
query-resolver = { path = "./crates/query-resolver" }
domain-kanban = { path = "./crates/domain-kanban" }
domain-util = { path = "./crates/domain-util" }
# tokio: main関数を使う場所で使っている。mainアプリケーションでは下層のspawnerにも使っている。
[workspace.dependencies.tokio]
version = "1.29.1"
features = ["macros", "rt-multi-thread"]
- 便利ポイント
- シンプルに同じバージョンや同じfeaturesを共有できるので便利
- featuresは利用側で追加できる。共通のものだけ置いておくのが便利
- レイヤー間の依存関係について、個別にpathで書くのがちょっとめんどくさかった。これが解消されるので便利。
- イマイチポイント
cargo-addとかのツールのサポート(後述)
Workspace: cargo-(add|outdated)
それぞれ依存crate管理に関わるツール。ツール単体に気になるところはなく、普通に便利だった。
workspace dependencies を書くうえではコツがいりそうなので、ポイントに絞って書く。
cargo-add
端的に言うとworkspaceはサポートしていない。
issueはここにある: https://github.com/rust-lang/cargo/issues/10608
workspace dependenciesには自分で書くとして、そのヒントとなる情報は --dry-run オプションで得るのがちょっと便利だった。
# --package オプションの指定がめんどくさいので、移動しておく。
$ cd crates/main
$ cargo add frunk --dry-run
Updating crates.io index
Adding frunk v0.4.2 to dependencies.
Features:
+ frunk_proc_macros
+ proc-macros
+ std
+ validated
- serde
warning: aborting add due to dry run
この結果を見ると以下がわかる。Webで調べる場合との比較も書いておく。
- 最新版は0.4.2っぽい
- crates.io をみればわかるが、GitHubを見てもわからないことが多い
- releasesを使ってリリースしているところもあればそうでないところもある。なんなら途中から切り替わったところもある
/Cargo.tomlに書いてないこともある。Virtual Workspaceを使っていてmulti crateそれぞれpublishされている場合とか- デフォルトブランチの内容がリリースの最新とは限らない。大体のリポジトリには、リリースバージョンごとにタグをつけてあるけど、「大体」
- crates.io はREADMEの幅が狭いので、説明文が読みにくいことがある。 feature flagsはREADMEにあることが多いので、読みにくいと困る
- crates.io をみればわかるが、GitHubを見てもわからないことが多い
- default featuresは
frunc_proc_macros, proc-macros, std, validatedで、optional featuresはserdeらしい- これもどの
Cargo.tomlをみたらいいかわかりにくい。みてもわからないことも…… - そもそもRust全体として、外部から指定するfeatureなのか内部のためにあるfeatureなのかがわからないという話がある。ここは変わらない
- たとえばこのfrunkなら、実際は
validated, proc-macros, serdeが指定するもので、残りは依存されるものでおそらく外部から指定するものではない - ……が、実は
serdeはCargo.tomlに載ってない。READMEにだけある
- たとえばこのfrunkなら、実際は
- これもどの
cargo-outdated
workspace supportはあるっぽい。
w, --workspace- なしだと workspace dependenciesをチェックする。というより、rootの
Cargo.tomlをみるみたい - ありだと、workspace memberのdependenciesをチェックする
- なしだと workspace dependenciesをチェックする。というより、rootの
R, --root-deps-only- 直接依存しているcrateのみを表示するようになる
- デフォルトだと依存しているものの依存先も再帰的に辿っていくので、更新対象を確認する用途だとほぼ必須っぽい
上記を整理すると、以下のような使い分けになる。
- workspace dependenciesを確認するとき:
cargo outdated -Rw - 各crateのdependenciesを確認するとき:
cargo outdated -R
Workspace: スクリプト
進むうちに、いくつかスクリプトが欲しくなった。
- Rustコード系
- シェルスクリプト系
- 開発用の
cargo ...などのコマンド。オプションが多くて思い出すのがめんどくさくなってきた
- 開発用の
それぞれ検討した方法は以下の通り。1番目に記載したものが採用したもの。
- Rustコード系
- シェルスクリプト系
- READMEに書く
- 特に説明することはない……READMEに1行説明とともに列挙するだけ
cargo-makeにまとめるcargo-makeにまとめるとcargo make migrateみたくすることができる- CIとかを見据えると、1箇所で管理できて名前付きにできるこの方法のほうがリッチだけど、
Makefile.tomlを書くのがやや面倒 - 多機能ゆえに機能を把握していないと触りにくい(性格の問題)
- READMEに書く
Workspace: default members
前述のように、bin crateを追加したことで実行時引数が増えた。
- before:
cargo run - after:
cargo --project main run
ちょっと増えたぐらいだから別にいいんだけど、 default-members に指定することで引数不要にできるのでそうした。
[workspace] members = [ 'crates/*' ] default-members = [ 'crates/main' ]
cargo-watch
GraphQLサーバーのコードを更新するたびに再ビルドさせて実行させるのに、 cargo-watch が便利だった。
cargo watch -x run で実行できる。
watchはデフォルトでcheckをするので、xで実行内容を指定するdefault-membersでないものは-packageなどを指定する必要があるが、x 'check --package scripts'のようにquoteが必要- GitHub を見たら、
cargo watch -- cargo check --package scriptsのようにもできるらしい
cargo-expand
マクロをそれなりに使うときは、 cargo-expand を使うとコンパイルエラー調査が便利である。
今回だと、async-trait, tokio, async-graphql, shaku, sqlxあたりがマクロをよく使う。
cargo-expand はデフォルトだと全部を展開しようとする。itemの指定ができるので、以下のように使うと便利。
# 該当のcrate rootまで移動しておく cd crates/infrastructure-rdb # mod単位でexpand cargo expand query::card
- 本当にstruct単位でexpandしようとすると、implが表示されないなど情報不足なことがある
- かといってmoduleだと多いなーという場合は確認用にsubmoduleに切り出すといいかもしれない
- ただ、期待しすぎない方がいい。普通にコードを読むより多くの知識を要求されることが多いので、ある程度「わからんものはわからん」という気持ちでいないとつらくなる
- 見ても分からなければ強い人に聞いて、また強くなったら帰ってこよう
Rust utilityライブラリ
あまり書くことはないけど使ったもの。
itertools
VecをgroupingしてHashMapにするなどのときに便利だった。 でもびっくりするぐらいメソッドがあるので、iterator関連のほしいメソッドはググるのが賢明。
futures-util
future関連のメソッドが追加されていて便利。
どうしてもiteratorの要素ごとにFutureが生成されてしまったので、 join_all を使った。
GraphQL
GraphiQL UI
GraphiQLというGraphQLのUIがあり、 async-graphql でもすぐ利用できるようになっている。
cargo watch -x run で起動し、8000ポートにマップした。
GraphQLの要素が補完され、ドキュメントも出るのでクエリ組み立てに便利。
watch でサーバーが再起動しても、UIは残るので使いやすかった。
サーバー再起動時にはschemaを取り直さないので、 Refresh GraphQL schema ボタンを押すことで補完されるようになる。
dataloaderと相互参照
dataloaderを実装した(というより、schemaのresolverを分けた)ことで、相互参照が実現できた。
{ usersAll { id name # userがオーナーのボードを参照 ownedBoards { id title # ボードのオーナーを参照 owner { id name } } } boardsAll { id title # ボードのオーナーを参照 owner { id name } } }
schemaのresolverを分けたりしなくても相互参照はできる。 しかし、dataloaderの実装に任せることで、実装をシンプルに保つことができた。
async-graphql
GraphQL実装はRust製のasync-graphqlを使った。一通りの機能があり、ドキュメントもあるのであまり困らず使えた。
Schema生成
ここにある通りに書いた。
実装上の考慮事項として、Schema型にはdataやextentionsなどのデータを紐づけているが、これはスキーマ生成だけを考えると不要である。そのため、以下のように考慮しておく(と楽)。
- schema生成時はdataやextentionsを紐付けない
- アプリとschema生成で同じbuildを通るように共通化
共通化にあたって、SchemaやBuilderの型引数にquery, mutation, subscriptionがあって煩雑になったので、 type キーワードでエイリアスを作って対策した。
// 定義 type SchemaType = Schema<Query, EmptyMutation, EmptySubscription>; type SchemaBuilderType = SchemaBuilder<Query, EmptyMutation, EmptySubscription>; // 利用 fn schema_builder() -> SchemaBuilderType { Schema::build(Query, EmptyMutation, EmptySubscription) } fn schema() -> SchemaType { schema_builder().finish() } fn schema_with<F>(f: F) -> SchemaType where F: FnOnce(SchemaBuilderType) -> SchemaBuilderType, { f(schema_builder()).finish() }
また、rust-scriptだと補完が効かなくて作りにくかったのでscripts crateにまとめた。
dataloader実装
dataloaderの実装方法自体はドキュメントにコードで記載があり、それをなぞった。以下が私にとってのポイントだった。
- Trait実装として提供するので、同じキーのdataloaderを複数個同じ型に実装できない
- たとえば
Id<User>をキーにユーザー一覧をとりたいときとボード一覧をとりたいときとか - →未確認だが、
UserIdForBoardのようなstructにラップして利用すれば良さそう?もしくはdataloaderを分ける?- そもそもdataloaderを複数にするべきかどうかがわかっていない。一つでいいような気がする。
- たとえば
- dataloaderはadapter層で、実行するクエリはquery層で定義している。そのため、IDの型があわない。せっかく
keys: &[K]でもらっても変換するのでVecにしている。- →仕方ないと思って諦めた
- ちなみに、返却時も再変換が必要
- 戻り値が
HashMap<K, V>であるので、この形に変換が必要- →あきらめてquery層をこちらに寄せてHashMapを返すようにした
Data 取得
async-graphqlは context から参照できるデータを持つ。このデータは格納時に Any にして、取り出すときにダウンキャストしている。
let modules: &Modules = context.data()?; // or let modules = context.data::<&Modules>()?;
そのままでも使えるが、型注釈が必須な点がやや面倒である。
個人的には前者のほうが読みやすいが、続けてメソッドを呼び出したい場合などでは後者の書き方になり、このとき型注釈を忘れるとエラーになってしまったり、 ::<> のように装飾が多かったりとあまり好きではない。
以下のようにcontextを拡張するメソッドをつくって、型注釈を不要にした。メソッド名から何が返るのかもわかりやすく、実装コストに対して効果的な印象。
// ContextにDataLoader, Modulesを取得するメソッドを作成する // dataはAnyで型消去しているけど、このプロジェクト内ではSchemaを作っている箇所で必ず設定しているはずなので気にしないことにする pub trait ContextExt { fn data_loader(&self) -> Result<&DataLoader<Modules>, GqlError>; fn modules(&self) -> Result<&Modules, GqlError> { Ok(self.data_loader()?.loader()) } } impl<'ctx> ContextExt for Context<'ctx> { fn data_loader(&self) -> Result<&DataLoader<Modules>, GqlError> { self.data() } }
sqlx
マクロ
sqlxはマクロで「コンパイル時にSQL結果の型をチェック」できる。
これの利用に向けてはちょっとコツがいる。
- コンパイル時に参照できる場所にテーブルが存在するDBを立てる必要がある
- dockerを使って立てるのが楽
- docker内にテーブルを用意するのは、sqlxのmigrate機能等を使うといい
- offlineモードというのもあるが、試していない
- dockerを使って立てるのが楽
- 環境変数で参照するDBを指定する必要がある
- コマンドが長くなるので、スクリプトにまとめた方がいい
- (
query!()マクロの場合)コンパイル時に型チェック=コンパイルしないと型が決まらない- rust-analyzerが補完してくれない
- クエリを書いたあと、後述処理を書くまえにコードを保存(ないしは再コンパイル)するといい
- コンパイル時にSQLでエラーになったとき、エラー箇所がわからない
感想としては、「すごいが、コンパイル時チェックなしの関数でもいいかな……」という感じだった。理由は以下。
- クエリがstatic文字列でなくてはいけない
- 動的に
where句のパターンを書き換えたいときにつらい - 似たような or 同じクエリを使い回せない
query_file!()マクロがあれば完全に同じクエリならいける
- 動的に
- (前述)クエリ結果を束縛した状態でsaveしないとrust-analyzerが補完してくれない
- 安全性は(テストをちゃんと書くなら)関数で書いてもあまり変わらない
poolとtransaction
sqlxの(postgres)クエリを実行するとき、 PgPool か Transaction<'a, Postgres> が必要になる。それぞれの使い方は以下の通り。
pool: &PgPoolの場合.execute(&pool)とする
mut transaction: Transaction<'a, Postgres>の場合.execute(&mut transaction)とする
transaction: &mut Transaction<'a, Postgres>の場合.execute(&mut **transaction)とする- 特にこれを忘れやすい
- 関数にクエリをまとめていると引数に登場しがち
特にC言語の経験からすると3番目は謎が深いが、とりあえず覚えることにする。
derefして &Connection に、更にderefして '&mut を取っている感じで認識している。
migrate
sqlxがCLIを提供していて、それを通してmigrateした。
migrateファイルの作成も適用もCLIで問題なく行えた。
shaku: Providerのエラー型をasync-graphqlに合わせる
RustのDIライブラリ。async-graphqlのcontextと組み合わせて、以下のようにしてクエリを取得したい。
let user_query: Box<dyn UsersQuery> = context.modules()?.query().provide()?;
これは工夫しなくても実現できる……が、エラー型がちょっと扱いにくい。最終的には async_graphql::Error 型にしたいが、 shaku::HasProvider::provide() が返すエラーは Box<dyn std::error::Error> であり async_graphql::Error 型に変換できない。
そこで、この変換だけをラップするためのメソッドをtraitで拡張して作成した。
// shakuのErrorをasync-graphqlにあわせる pub trait HasProviderGql<I: ?Sized>: HasProvider<I> { fn provide_gql_result(&self) -> Result<Box<I>, GqlError>; } impl<T, I: ?Sized> HasProviderGql<I> for T where T: HasProvider<I> + ?Sized, { fn provide_gql_result(&self) -> Result<Box<I>, GqlError> { self.provide().map_err(|e| { GqlError::new(format!( "providing <{}> failed: {}", type_name::<I>(), e )) }) } }
これを使うと、GraphQL クエリメソッド内でも ? でエラー型を変換できる。
開発体験: ChatGPT
使い込んでいないので3.5での結果。
ChatGPTにやらせたこと(1): ドメインモデル図を描かせる
ありきたりなkanbanアプリのドメインモデル図だったので、ChatGPTに書かせた。
Kanbanアプリケーションをドメイン駆動設計で作成します。ドメインモデル図をmermaid形式で示してください。
指示としては以上で、それ以降は「集約を小さくしてください」「操作を追加してください」などを追加指示した。
結果として取得できたコードは以下で、いくつか間違っている箇所がある。手動で直せばいい範囲なので、ChatGPTに再修正させず手で直した。
classDiagram
class User {
<<Entity>>
+userId: int
+name: string
+email: string
+boards: Board[]
}
class Board {
<<Aggregate>>
+boardId: int
+title: string
+owner: User
+members: User[]
+columns: Column[]
+addColumn(title: string): Column
+removeColumn(columnId: int): void
}
class Column {
<<Aggregate>>
+columnId: int
+title: string
+tasks: Task[]
%% --------間違った結果の箇所: addColumnではなくaddTaskっぽい。--------
+addColumn(taskTitle: string, taskDescription: string): Task
+removeTask(taskId: int): void
}
class Task {
<<Entity>>
+taskId: int
+title: string
+description: string
+status: string
}
User --> Board : owns
User --> Board : member of
Board --> Column : has
Column --> Task : contains
ChatGPTにやらせたこと(2): レイヤー構造を描かせる
同じ理屈で、オニオンアーキテクチャのレイヤー構造を図にしてもらった。
mermaidの図の書き方を思い出すより早くできた。
ChatGPTにやらせたこと(3): 典型的な docker-compose.yml を書かせる
RDBを構築したくなったタイミングでChatGPTに docker-compose.yml も書かせてみた。
今回は典型的な(後で使うつもりのlocalstackと)Postgresを立てるだけだったので、ほとんどそのまま出力できた。
注意点は以下。
- localstackは古いバージョンでのみ利用できる環境変数があったので除去した
- ポート番号はそのままで割り当てられるので、それを
60000から振るように追加指示した
ChatGPTにやらせたこと(4): RDBテーブル定義を作らせる
RDBのテーブル定義を作るのが面倒だったので、吐き出させた。
何を入力にしたかというと、GraphQL Schemaを入力にした。
以下graphqlのデータをPosgresで構築します。テーブル定義のDDLを示してください。
追加注文したのは以下の通り。
- 各
IdはVARCHAR型にしてください - テーブル間の関連を別テーブルに切り分けてください
- これは2回くらい注文した。
- テーブル名をsnake_caseに変更してください
- テーブル名を複数形にしてください
ChatGPTにやらせたこと(5): Rustコードをサンプルデータ投入SQLに変換させる
infrastructure層の実装が終わって、RDBと接続して確認したかったが、データがない。
仮実装の段階でRustコードでデータをメモリ上に作る実装があったので、これを入力にINSERT文を吐かせてみたら、思ったより精度良く吐かせることができた。
以下Rustコードで作成するのと同等のデータをPostgreSQL DBに用意するINSERT文を示してください。 [Rustコード...] なお、テーブル定義は以下のとおりです。 [テーブル定義...]
特に、配列参照を使ってIDがおなじになるようにしていた箇所が全体的に正しく解決されていたので驚いた。
気になっていること
frunkで変換が楽になる?
レイヤーの関係で変換が必要な presentation_graphql::scalar::Id<T> と domain_util::Identifier<T> とか、クエリの返却値とgraphql responseとか、細かい変換をいくつも書いた。
frunkという関数型プログラミング系のライブラリがあって、この中のGenericという機能がこの辺りの変換をサポートしてくれていそう。活用したら楽になりそう。
infrastructureのテスト
互いのテストが影響しないようにするために、インメモリDBを使いたいが、sqlxでそのあたりうまくできるのかを調査する必要がある。
認証とか
まだアプリケーション内での認証処理とかについて調べていないので確認する必要がある。
coc-metals がなくなってもなんとかcocで生きている
経緯
しばらく前から、Scalaを書く機会が訪れた。NeoVimのLanguage Clientプラグインとしてはcocを使っているので、Scalaでもcoc-metalsを入れて使うことにした。
https://github.com/scalameta/coc-metals
2022年3月に coc-metalsの開発終了が宣言された 。
なんとかしなきゃなと思いつつも、ひとまず現在のプロジェクトでは問題なく動いていたので、そのまま使い続けていた。
2022年10月、現在のプロジェクトのScalaバージョンアップに伴って、ついにいくつかの機能が動かなくなってしまった。具体的には補完と定義ジャンプあたりができなくなった。
試行錯誤
補完と定義ジャンプくらいは動かないと開発しづらいので、これらをできるように色々試した。
試行錯誤の方向性
coc-metals のREADMEでは「 nvim-metals でNeoVim組み込みのLanguage Clientを変わりに使おう」と書いてある。しかし、他のcocプラグインなどもあったり一度慣れた操作を全て捨てなくてはいけなかったりで、これを理由に乗り換えるのは現実的ではないなと思った。
よって、「coc上でmetalsを使う方法」を模索することにした。
公式の推奨は手動インストール(だが、なるべく使いたくない……)
とりあえずcocとmetalsの公式を見てみた。coc向けのmetalsの導入方法はどちらも手動でのインストールを推奨していた。
しかし、手動でのインストールは腰が引けた。私の開発環境は複数あって、そのうち特定の環境でしかScalaを利用しないし、ビルドツールの導入から必要なようだったので単純にめんどくさいなと思った。これまで coc-metals にmetalsの管理を任せていたので、余計に手間だと感じたのかもしれない。
いったんmetalsをそのまま使える方向を模索しようと思った。
coc-metals が参照するmetalsのバージョンアップ
正直今までREADMEに従ってただ導入していただけだったので、なぜ動かなくなったかの調査から始めた。metals起動時にバージョンに関する警告が出ており、ここに 0.11.2 を利用していると書かれていた。調査時点でリリースされているのは 0.11.9 まであったが、 coc-metals の最新版が参照しているのは 0.11.2 であるようだ。そこでこのバージョンを上げれば直るのではと考えた。
coc-metals のREDMEに設定項目について記載されており、 metals.serverVersion に 0.11.9 などを入れれば良さそうだ。
設定してみたところ、metalsのダウンロードができなくなり、起動に失敗するようになってしまった。
coc-metals は metals-languageclient の 0.4.2 を利用している。metalsのダウンロードも metals-languageclient の機能だ。そして metals-languageclient@0.4.2 では、metalsをダウンロードするScalaのバージョンは 2.12 に固定されていた。
metalsのリリースノートを見ると、metalsの 0.11.3 からビルドするScalaのバージョンが 2.13 になったっぽい。なので org.scalameta:metals_2.12:0.11.9 をダウンロード出来ずに失敗してしまうらしい。
coc-metals の依存する metals-languageclient のバージョンアップ
それならということで、 coc-metals の依存する metals-languageclient のバージョンアップをやってみることにした。いわゆるモンキーパッチだ。
coc-metals はこれまで :CocInstall でインストールしていた。これはnpmから取得しているので、これにパッチを当てるのは無理だ。cocのドキュメントを見ると、gitでのインストール方法が書いてあった(ただし、依存ライブラリをビルドする関係でディスクスペースを多く使うので非推奨らしい)。
そこで、以下のようにとりあえず coc-metals をcloneしてNeoVimで読み込めるようにした( yarn は適当に準備)。
# 適当なディレクトリ
cd /path/to/directory
git clone https://github.com/scalameta/coc-metals.git
cd coc-metals
yarn install
# NeoVimの設定ファイルにて
:set runtimepath^=/path/to/directory/coc-metals
# 読み込めると以下コマンドの結果に `coc-metals` が増える
:echo map(CocAction('extensionStats'), {k, v -> v['id']})
インストールは出来たので、 yarn upgrade --latest metals-languageclient にて更新した。その結果、 yarn build が通らなくなってしまった。インターフェースが色々変わったようだ……
metals-languageclient の最新は 0.5.17 だったので、バージョン飛ばし過ぎかなと思って 0.5.1 (0.5系の最初)まで戻したのだけど、ビルドできない状況は変わらなかった。これまでコントリビュートしてこれなかった身としてはエラーをすぐに解決することは無理そうだった。ついでに、これに関するマイグレートイシューが立っていて、すでに試されていることであると知った。
metalsを手動インストールして使う
ここまできて coc-metals を使い続けるのは無理だなと判断した。そのため、metalsを手動で入れて利用する方向にかじを切った。metalsの手動インストール方法については、ドキュメントに載っていたため、これを参考にビルドしていった。
Coursierをインストール
私の環境にはJavaは導入済みだったので、あまりそれについては考えずに、未インストールのCoursierからインストールした。
上記Coursierのドキュメントに従って、
brew install coursier/formulas/coursier
とした。すでに sbt 等は sbtenv などで用意していたため、 cs setup は行わなかった。
metalsのビルド
前掲のmetalsのドキュメントに従って、以下のようにした。オプションは吟味していない。
cs bootstrap \
--java-opt -Xss4m \
--java-opt -Xms100m \
--java-opt -Dmetals.client=coc.nvim \
org.scalameta:metals_2.13:0.11.9 \
-r bintray:scalacenter/releases \
-r sonatype:snapshots \
-o ~/bin/metals-vim -f
変更点は以下の通り。
coursierコマンドを適宜インストールして使うのではなく、前節でbrew installしたものを使う- これによりCoursierは
csで起動する
- これによりCoursierは
- 出力先を
/usr/local/binではなく、~/binにする
-------- (2023/07/20 追記) --------
metals.client はnvim-lsp以外にも設定可能な値があるようだった。以下を参考に coc.nvim にした。設定値が変わる模様。
github.com
パスを通す
前節で ~/bin へ配置したが、普段自分でビルドしないので、ここにはパスが通っていなかった。私はzshなので、 ~/.zprofile に以下を追記した。
[ -d ~/bin ] && PATH="$HOME/bin:$PATH"
cocと連携させる
coc-nvim のwikiに設定があるので、そのまま coc-settings.json へコピペした。
"languageserver": {
"metals": {
"command": "metals-vim",
"rootPatterns": ["build.sbt"],
"filetypes": ["scala", "sbt"]
}
}
動作確認
ここまでの設定でNeoVimでScalaファイルを開き動作確認をした。
- できたこと
- ファイル内のdocumentHover
- できなかったこと
- ファイルをまたぐdocumentHover
- 定義ジャンプ
- 補完
coc-metalsにあったmetals.build-importなどのコマンド
感触としては、動いてはいるけど十分ではない、という感じだ。とはいえ、スタートラインには立ててはいるようだ(ちなみに、この段階だと「この先試行錯誤してもだめ」な可能性はあって、戦々恐々としながら進めていった……)。
まともに動くようにする
追加の動作確認
動いている気はするものの裏付けがなかったので、これを確認した。coc-nvim のwikiにDebug language serverという節があった。これを参考に試していった。
まず、 :CocList services を見てみたところ、 languageserver.metals は [running] となっていた。問題なく起動は出来ているようだ。
次に、metalsのログを見てみた。 :CocCommand workspace.showOutput から、 languageserver.metals を選ぶ……が、ほとんどログが出ていなかった。タイミングによっては(ログがないからか)選ぶことすらできない。
ドキュメントを見直すと、 "trace.server": "verbose" を追加するとLSP通信の内容が全部出るようだったので、これを追加してやり直したところ、詳細なログを得ることができた。
ファイル内のdocumentHoverは思ったとおりうまくいっているらしい。
(ファイル内のdocumentHover)
[Trace - 1:50:37] Sending request 'textDocument/hover - (4)'.
Params: {
"textDocument": {
"uri": "(省略)"
},
"position": {
"line": 10,
"character": 25
}
}
[Trace - 1:50:38] Received response 'textDocument/hover - (4)' in 485ms.
Result: {
"contents": {
"kind": "markdown",
"value": "(省略)"
}
}
ファイルをまたぐdocumentHoverは思ったとおり何も返ってきていなかった。
(ファイルをまたぐdocumentHover)
[Trace - 1:52:43] Sending request 'textDocument/hover - (5)'.
Params: {
"textDocument": {
"uri": "(省略)"
},
"position": {
"line": 14,
"character": 23
}
}
[Trace - 1:52:43] Received response 'textDocument/hover - (5)' in 14ms.
No result returned.
ところで、ここまでで出たログを眺めていて、 coc-metals を使っていたときには出ていたプロジェクトのビルドのログがないことに気がついた。 coc-metals はログをechoする仕組みだったのに対して、cocから直接languageserverを使った場合はechoされない。このことは知っていたので、ビルドのログが無いことに疑問を持たなかったのだ。
metalsが有効になった後に build-import をかける
ビルドするために何を行えばいいかはおよそ見当がついていて、metalsの調子悪いときに :CocCommand の中から実行していた build-import をすれば良さそうだろうと考えていた。しかし、今までは coc-metals が :CocCommand に追加してくれていたので、 coc-metals を使わなくなった今どうやって実行すれば良いのかが分からなかった。
とりあえず coc-metals のコードからなんとなく command などで検索してみると、起動時にコマンド登録しているっぽいコード が見つかった。このコマンド群の定義は metals-languageclient の ServerCommands に定義されていた。
実際に叩いているコマンドの文字列はわかった。次はコマンドの実行方法だ。先の metals-languageclient のところにserver commandsと書いてある。関係がありそうなものをcocのhelpから探していると、 :call CocAction("runCommand", "name") とすることでコマンドが実行できるらしい。
試しに :call CocAction("runCommand", "build-import") を実行したところ、30秒ぐらい制御を取られ、何かが実行された。その後、documentHoverの情報を改めて確認すると以下の変化があった。
フイル内のdocumentHoverは元々出ていたが、 range が追加された。
(ファイル内のdocumentHover)
[Trace - 2:07:21] Sending request 'textDocument/hover - (11)'.
Params: {
"textDocument": {
"uri": "(省略)"
},
"position": {
"line": 10,
"character": 20
}
}
[Trace - 2:07:21] Received response 'textDocument/hover - (11)' in 99ms.
Result: {
"contents": {
"kind": "markdown",
"value": "(省略)"
},
"range": {
"start": {
"line": 10,
"character": 20
},
"end": {
"line": 10,
"character": 29
}
}
}
また、ファイルをまたぐdocumentHoverの情報も以下のように出るようになった。
(ファイルをまたぐdocumentHover)
[Trace - 2:09:35] Sending request 'textDocument/hover - (12)'.
Params: {
"textDocument": {
"uri": "(省略)"
},
"position": {
"line": 14,
"character": 21
}
}
[Trace - 2:09:35] Received response 'textDocument/hover - (12)' in 22ms.
Result: {
"contents": {
"kind": "markdown",
"value": "(省略)"
},
"range": {
"start": {
"line": 14,
"character": 21
},
"end": {
"line": 14,
"character": 24
}
}
}
これで一旦の目的を果たした。しかし、使いにくい点があるのでもう少し改善したいと思う。
cocのコマンドを非同期に実行する
直前のコマンド( :call CocAction(...) )は、完了するまで制御を返さない。 build-import はプロジェクトサイズによっては結構時間がかかるので、長い時間NeoVimが操作を受け付けなくなる。これは非常に使いにくい。
この解は簡単で、 :call CocActionAsync(...) という非同期版を使えば良い。この2つの関数は、アクションの実行結果の扱いの違いがある。
- 同期版: 戻り値で返す(例:
:echo CocAction("commands")などを叩いてみると結果が表示される) - 非同期版: 引数にcallbackを与えて扱う(
call CocAction("commands", {err, result -> ...})みたくするらしいが試してない……ちなみに非同期なのでechoしても当然何も表示されない)
cocのコマンドにmetalsのコマンドを追加する
:call CocActionAsync("runCommand", "build-import") を打てばいいことはわかったが、毎回打つにはつらすぎる。
NeoVimなので、必要なカスタマイズは自分ですればいい。キーバインドやコマンドを定義するのが普通だが、今まで :CocCommand から呼び出していたので、それと同じ操作感にしたい。
cocのhelpを見ると、 coc#add_command() という関数があった。これを使うと CocCommand で呼び出せるコマンドを追加できるようだ。これを使って coc#add_command("metals.build-import", ":call CocActionAsync('runCommand', 'metals.build-import')") とすることで、 :CocCommand の一覧に表示されるようになった。
うまくいくことがわかったので、サーバーコマンドとして一括で登録する define_metals_commands というlua関数を書いた。
local add_coc_command = function(name, command, description) vim.fn["coc#add_command"](name, command, description) end local define_metals_commands = function() local commands = { "ammonite-start", "ammonite-stop", "analyze-stacktrace", "bsp-switch", "build-connect", "build-disconnect", "build-import", "build-restart", "compile-cancel", "compile-cascade", "compile-clean", "copy-worksheet-output", "debug-adapter-start", "doctor-run", "file-decode", "list-build-targets", "generate-bsp-config", "goto", "goto-position", "goto-super-method", "new-scala-file", "new-java-file", "new-scala-project", "reset-choice", "scala-cli-start", "scala-cli-stop", "sources-scan", "super-method-hierarchy", } for _, v in pairs(commands) do local name = "metals." .. v local action = ":call CocActionAsync('runCommand', '" .. v .. "')" add_coc_command(name, action) end end
これで一通りのコマンドを追加することが可能だ。私は全部使っているわけではないが、一旦全部登録したままにしている。コマンド名に metals. とつけているので、他のものと競合することはないはずだ。
metalsが実行可能なときだけcocに登録する
ここまでで一応使える状況にはなったが、別環境のことを考慮していなかった。 metals-vim がPATHにない状態でScalaファイルを開くとcocがエラーを出す。
前述のように coc-settings.json に書いていると、条件分岐を行う方法がない。ドキュメントを眺めると coc#config() という関数でも coc-settings.json と同等のことが行えるらしい。これなら、コードからの呼び出しなので分岐は自由だ。
coc-settings.json をやめてluaで書く
coc#config() を使っていこうと思ったのだが、 coc-settings.json との併用は非推奨らしい。
仕方がないので、全てをluaで置き換えた。文法は大きく違わないので、以下に注意してluaのtableに置き換え、登録していった。
- keyの扱い
- keyとvalueの区切り文字
- 配列の記法
- 配列の先頭INDEX
ちなみにこのあたりの書き方はいつもluaのマニュアルの(非公式)日本語訳を見ている。
ただし、1箇所読んだだけだとわかりにくかったり、そもそもどこに書いてあるのか目次からわからなかったりする。
たとえば、前述のテーブルの記法の件は「3.4.9 - テーブルコンストラクタ」にある。変数や型の節ではない。3章までよめば文法はわかるので、NeoVimでluaを使うならここまで全部読んでしまってもいいかもしれない。文法以外の関数(たとえば for でテーブルを走査するときによく使う pairs() )などは、ググった方が早い。ググっても載ってない細かい仕様が必要な場合だけドキュメントに戻ってくるので十分だ。
NeoVim側のluaのAPIについてはこのドキュメントを見ている。あとは coc-sumneko-lua の補完に頼っている。更に情報がほしいときだけ、 :h nvim_echo() などで api.txt を読んでいる。関数の存在を和訳ドキュメントで見て、細かいオプションはhelpを見るのが丁度いいかなと思う。
luaに置き換えると、例えば先に示したmetalsの設定の場合は以下のようになる。
local languageserver_config = {} languageserver_config.metals = { ["command"] = "metals-vim", ["rootPatterns"] = { "build.sbt" }, ["filetypes"] = { "scala", "sbt" }, } -- 実際に呼んでいるのは coc#config("languageserver", languageserver_config) My.PutCocConfig("languageserver", languageserver_config)
これなら分岐でもなんでも仕込み放題だ。ついでにコメントも書ける。
metalsの登録を分岐する
前節のような感じで置き換えが出来たので、 metals-vim の実行ファイルがある時だけ上記の部分のコードを追加したい。
まずは実行可能ファイルがあるかのチェックをする。これはNeoVimの関数を利用するのが楽っぽいので、以下関数を定義した。ちなみに My というのは、変数や自作関数を作ったときに置いておくグローバル変数として自分で定義したものだ。
local function has_executable(name) return vim.fn.executable(name) == 1 end My.has_executable = has_executable
これを使って、
if My.has_executable("metals-vim") then -- 前述のコード end
とした。これでいい感じだ。
ここまででやっと使える域に達したと感じることができた。
できていないこと
とりあえず動くようになったが、出来ていないこともありそのうちなんとかしたい気持ちはあるので書き残しておく。
metals起動時まで CocCommand への登録を遅らせる
上記の方法だと、metalsがないときには CocCommand へ登録されないが、あるときにはcoc起動時に登録される。今まで coc-metals ではサービス起動時(≒Scalaファイルを開いたとき)に登録されていたので、そちらに近づけたいと思った。
調べた限りでは、サービスステータスの変更に伴ってCallbackが呼ばれるような仕組みが見当たらず、断念した。
以下が似た投稿で、 user CocNvimInit イベントを使っていたが、このイベントはcocの起動時に呼ばれるため、coc起動後にサービスが立ち上がった場合(私は普段そうなることが多い)に対応できなかった。
まぁ、コマンドが一覧に出るだけで他のコマンドを叩きたいときはたいてい絞り込んで使うため、あまり邪魔にはなっていないのでよしとした。
metals開始時に、 build-import コマンドを実行する
これも上記と同様の理由でトリガーできないため断念した。一旦手動で叩く手間を軽減したので、しばらくは様子見することにした。
metalsを自動でインストールする
頑張れば書ける……が、インストールする頻度が低いし、設定ファイルにインストールスクリプトが載っていればとりあえずはOKということにした。
以下が面倒だった。
- インストール先
- インストール方法
coursierなのかcsなのか、そもそもあるのかどうか- ないとパッケージマネージャーでいれるかそれ以外か、使い捨てかどうか……など
metalsの準備ができたかどうかわからない
metals(に限らないけどLanguageServer)は起動にちょっと時間がかかる。私の環境では1〜2分くらい。
その間にcocの機能でdocumentHoverすると Hover Not Found になってしまう。
終わったかどうかのチェックは CocAction workspace.showOutput で languageserver.metals を目視するか、「そろそろ大丈夫だろう」という勘でやっている。目視の方は、別のウィンドウが開かれるので鬱陶しいし、勘は間違っていることが度々ある。
lightlineを使っているので、ここに表示したいが、このあたりを調べきってない。今は基本勘で、ダメそうだったら showOutput 出しながら目視するくらいにしている。
build-import しても別のファイルを開くとdocumentHoverできなかったりする
要するに完璧に動くわけじゃないのだけれど、症状を言語化できていない。今動く範囲でコーディングが可能なので放置している。
コマンドリストを手動で作っている
このあたりからどうでもいい話になってくる。
CocCommand に登録するコマンドの一覧を、何かと連携して作っているわけではなく、参考リポジトリからコピペして作っている。これだとコマンドが増えたときに気づけない。
ただ、そんなに新しいコマンドが必要なこともなさそうだし、追加するにしても配列に1行足す程度なので手間じゃない。そういうわけで放置することにした。
コマンド名にvimがついている
本当に軽微な話だけど、 coc#add_command("metals.build-import", ...) としているのに、コマンド名は vim.metals.build-import となる。
見た目が気になるだけだし直し方が分からないので放置している。
感想
なんとか開発できるくらいにはなった
全部完璧にできたわけじゃないが、コードを読んだり書いたりはできるようになった。また気が向いたら調整すると思うけど、一段落できて安心した。
フリーライディングと向き合った
一番精神的に大きな話。
coc-metals の開発終了は少し前から知っていたけれど、その時はまだ動いていたのもあって、メンテナには手を挙げなかった(挙げられなかった)。今回、実際動かなくなってから色々調べたりパッチを当てようとしたりして、最終的にメンテナに手を挙げることができなかった。
metals-languageclient についての知識不足でコードの修正方法が分からなかったのもある。英語のReading以外がとても苦手というのもある。だが自分の中で一番大きかったのは「Scalaを専門で書いているわけではないので時間を割くのが難しい」と思ってしまった点だ。
そう思ったときに「やっぱり自分もフリーライディングしてるし望んでるんだな」と気づいた。誰かがメンテしてくれる安定した無料のソフトウェアを使いたいだけだった。
これは結構へこんだし、しばらく向き合い方に悩んだが、今私にできる貢献としてはこういうブログを書いて同様に困る人を減らすことくらいだろうということに落ち着いた(あとはシンプルにお金を払うとかだろうか……これも追って検討したい)。英語がもうちょっと書けたら、cocにサービスステータス反応でアクションを仕込む方法を聞くか要望上げるかできるんだけど……
LSPはサーバー起動コマンドを設定するだけで使える……とはいかない
どこかで「LSPはプロトコルが整理されているので、クライアントではサーバーの参照方法だけ設定に書けば使えるしプラグイン入れるのは無駄!」みたいな論を見た気がするのだけれど(掘り起こせなかった……)、少なくとも今回はこうはならなかった。
今回で言えば、以下のことをしなければいけなかった。ただしmetals以外でも同様かどうかは調べてない。
- サーバーのインストール管理
- サーバーコマンドを簡単に呼び出せるようにする
- 特定のタイミングでサーバーコマンドを呼び出す
luaでの設定が書きやすい
今まではほとんどの設定をvimscriptで書いていたが、今回初めて大掛かりな設定をluaで書いたところ、なんとなく書きやすいなと感じた。以下のようなあたりが理由だと思う。
- トリッキーさがない
- 関数の引数は
a:としないといけないとか augroup ENDとか- コマンドなのか関数なのかとか
- 大きなハッシュテーブル等を作るときに、行末
\が不要
- 関数の引数は
- 構文ノイズが少ない
letしなくても変数が書き換えられるcallがなくても関数を呼び出せる
- NeoVimのAPIが使いやすい(少なくとも私が使った範囲では)
- NeoVimのAPIがluaでできないことを補完してくれている
まぁ、そもそもvimscriptはvimコマンドの集合だし、そりゃプログラミング言語で書いたほうが書きやすくて当然である。
気持ちとしては全部luaにしてもいいなぁと思っているが、ちょっとずつ移行していけばいいかなと思った。
おわりに
私のneovim config はこちらだ。
開発するためにエディタを整えているのか、エディタを整えるために開発しているのか分からなくならない程度にこれからも頑張っていきたい。