Dioxus fullstackのプロダクション運用1年:良い点と苦労した点
One year of Dioxus fullstack in production: the good, the painful (oxidt.com)
要約
Dioxus fullstackを1年間本番運用した経験から、Rustによる開発の利点として「単一言語での開発」と「両サイドでの型共有」が挙げられる一方、ビルド時間の長さが大きな課題であることが語られています。特にCIでのビルド時間が長く、キャッシュやセルフホストランナーなどの対策を試みたものの、根本的な解決には至っていない現状が示されています。今後は、よりサーバーサイドレンダリングに特化したTopcoatフレームワークへの移行を検討しているとのことです。
全文翻訳
Dioxus fullstackのプロダクション運用1年:良い点と苦労した点
Dioxus fullstackを本番運用して1年が経過し、Rustコードは私のビジネスの中で最も退屈な部分となりました。CIが最もエキサイティングな部分ですが、それは良い意味ではありません。リリースビルドがGitHubホストランナーで90分もかかります。
この1年は前職から始まり、自身のプロダクトでも継続されました。4月には、なぜプロダクションレディなマイクロSaaSにRustを選んだのかについて書きました。要約すると、最初は遅いが、後々コストが安くなるということです。この記事はその請求書です。
運用中のもの
SeggWatは、SaaSプロダクト向けのインアプリフィードバックツールです。そのダッシュボードは、Cargoワークスペース内のDioxus 0.7 fullstackアプリです。バックエンドクレートにはサーバー関数、認証クレート、そしてインサイト、フィードバック、アイデア、ボード、ウィジェット、設定をカバーする約10個のUIクレートが含まれています。12個のクレートがdioxusに依存しています。すべてPostgreSQL上に構築されています。
infra.pageも同じスタックで構築されています。
「Fullstack」とは、3つの役割をこなす単一のRustコードベースを意味します。サーバーでレンダリングし、バックエンドとしてサーバー関数を実行し、クライアントをWebAssemblyにコンパイルしてページをインタラクティブにします。
良い点
言語は一つ、すべてがRust
データベースクエリ、ビジネスロジック、サーバー関数、ボタン:すべてRustです。一つのツールチェーン、一つのフォーマッター、一つの習慣、問題が発生したときに確認する場所は一つです。
これは、複数のプロダクトを掛け持ちしているソロファウンダーにとっては些細なことのように聞こえるかもしれませんが、そうではありません。「フロントエンド脳」と「バックエンド脳」をそれぞれ温める必要がありません。ワークスペースを開けば、そのまま作業を続けられます。
両サイドで同じ型
これは最も手放したくない点です。
構造体は一度定義すれば終わりです。データベースレイヤーがそれを返し、サーバー関数がそれを送信し、コンポーネントがそれをレンダリングします。フィールド名を変更すると、cargo checkがUIを含む、壊れたすべての場所のリストを教えてくれます。再生成が必要なOpenAPI仕様も、バックエンドと同期がずれるTypeScriptインターフェースもありません。
これによりコードを速く書けるわけではありません。しかし、深夜に誰かのレビューを待つことなく、恐れることなくリファクタリングできるようになります。
他のプラットフォームへの扉は開かれたまま
Dioxusは、同じコンポーネントモデルからデスクトップやモバイル(iOSを含む)をターゲットにできます。私のプロダクトのどれも現時点でネイティブアプリを必要としていません。しかし、書き直しなしでアプリをリリースできる可能性があると知っていることは、1年間の作業をどこに注ぐかを決める上で重要でした。
プロダクションは退屈
それが肝心な点です。コンパイルが完了すれば、ほとんどの場合、そのまま動作します。「undefined is not a function」のようなバグのクラスはユーザーに到達しないため、実際に発生するバグはロジックバグです。私はそちらに注意を払いたいのです。
4月に述べた「プロダクションでの退屈さ」という議論は、実際のユーザーとの接触でも有効でした。
苦労した点
コンパイル時間
これはRustエバンジェリストがしばしば飛ばしがちな部分です。
ダッシュボードは2回コンパイルされます:サーバー用とブラウザ用のwasm32用です。多数のDioxusクレートとマクロが多いUIが積み重なります。ローカルでは、まともなマシンであれば許容範囲です。しかし、CIは話が別で、ワークフローファイルのコメントは日記のようです:
- WASMビルドだけでも6コアで約22分かかります。 これは単一のリーフクレート、つまりブラウザ用にコンパイルされたダッシュボードです。
- リリースイメージは、GitHubホストの2コアランナーで約90分かかります。 私自身のVPSランナーでは約40分ですが、これは良い方のバージョンです。
- ClippyがOOMキルされました。 7GBのホストランナーで
cargo clippy --workspace --all-targetsを実行したところ、診断情報なしで終了コード143でクラッシュしました。解決策は、10GBのスワップファイルと、ピークメモリを削減するためのCARGO_BUILD_JOBS=1でした。私のリンターにはスワップが必要です。
ある時点で、プッシュごとにイメージをビルドするのをやめました。現在はリリースタグが付いたときか、手動でトリガーしたときにのみビルドします。なぜなら、プッシュごとに90分課金されるのは、お金の無駄遣いだからです。
試したこと
- キャッシュ (
Swatinem/rust-cache)。役立ちますが、落とし穴があります。ワークフロー実行は、自身の参照またはデフォルトブランチからのキャッシュしか復元できず、GitHubは7日間使用されていないキャッシュを削除します。メインブランチでのビルドを停止すると、リリースビルドはコールドスタートを繰り返しました。私の回避策はほぼ滑稽です:依存関係を更新した後、キャッシュを温めるためだけにメインブランチで手動でビルドをトリガーします。 - ビルドとテストを一度に行うことで、テストが世界全体を再度コンパイルするのではなく、コンパイル済みアーティファクトを再利用できるようにします。
moldリンカー。 良いですが、リンクがボトルネックになったことはありませんでした。コンパイルがボトルネックでした。- セルフホストランナーで重いビルドを実行。これは断然最大の成果でした。90分が40分に短縮されました。しかし、ビルドサーバーを自分で管理する必要も出てきました。
これらのすべてが役立ちました。しかし、根本的な計算を変えるものはありませんでした。2つのコンパイルターゲット、大きな依存関係ツリー、そして多数のマクロは、大量のコンパイルを意味します。
「すべてRust」にはアスタリスクが付く
ビジネスロジックはすべてRustです。しかし、エッジの部分はそうではありません。
SeggWatのインサイトページにはチャートが表示されますが、本番運用で信頼できるRustのチャートライブラリを見つけることができませんでした。そのため、チャートはChart.jsを使用し、CDNからロードして、Dioxusがdioxus-use-js経由で呼び出す約340行のJavaScriptでラップしています。スタイリングはTailwindなので、CIはRustが開始する前にBunをインストールし、Tailwind CLIを実行する必要があります。
私はこれで構いません。現実的です。しかし、JavaScriptから逃れるためにDioxusを選んだのであれば、何にサインアップしているのかを知っておくべきです:Rust、いくつかのJSグルー、そしてパイプライン内のJSツールチェーンです。
またやるか?
人々は「何が一番速く出荷するのに役立つか?」と尋ねます。より良い質問は、「人生を嫌うことなく、出荷を継続するのに何が役立つか?」です。
バックエンドに関しては、答えは依然としてRustであり、それに近いものはありません。
フロントエンドについては、少し考えが変わりました。もし本当にアプリのようなもの、つまり重いクライアント状態、オフラインサポート、または実際のモバイルアプリを構築するのであれば、Dioxusを再び選ぶでしょう。しかし、私が構築するもののほとんどはダッシュボードです:フォーム、テーブル、いくつかのチャート、そしていくつかのトグルです。それらのために、私はほとんど使用しないインタラクティビティのために、すべてのリリースで22分間のWASM税を支払っています。
次は何:Topcoat
そこで、私の次のプロジェクトは、tokio-rs orgからの新しいフルスタックフレームワークであるTopcoatを使用することになります。これは7月に発表されました。
Dioxusとは逆の賭けをしています。すべてがサーバーでレンダリングされ、コンポーネントはデータベースに直接クエリできる非同期関数です。インタラクティビティのために、通常の型チェックされたRustで$(...)式を記述します。Topcoatは最初のレンダリングのためにサーバーでそれを評価し、JavaScriptにも変換するため、ブラウザで即座に再実行されます。WASMバンドルも、第二のコンパイルターゲットもありません。
#[component]
async fn faq(cx: &Cx) -> Result<impl View> {
let open = signal(cx, || false);
Ok(view! {
<button @click=$(|_e| open.set(!open.get()))>