この記事は Rust Advent Calendar 2023 25日目の記事です。
お久しぶりです。DIGGLEのhirataです。
今回はDIGGLEとは関係ないですが、個人的にRustの利用を促進すべくRustでのWebアプリケーションの開発経験について書きたいと思います。
所感としては一人で開発するならRustを使うべし、と思える経験でした。やはりあちこちで言われている事ではありますが、静的型とライフタイムチェックによりコンパイルを通ってしまえばほぼバグが無く動くという安心感がとても心地よいものでした。コンパイラが代わりにテストをしてくれているような心持ちです。 また、Rustで作成したサーバはとてもメモリ消費量が小さく、Railsアプリであれば下手をすると1GB程まで行ってしまいますが、数十MBで十分動くというのも個人にとってありがたいものでした。 ちなみにとても安定して動作しており、今までバグやアップデート以外の再起動はありません。
参考までに私は元組み込み系のエンジニアでして、その影響で未だにメモリを沢山使うことに罪の意識を覚えます。 そして現在の仕事のメインはRailsでメモリ潤沢な大富豪バックエンドの開発をしています。その影響で随所にRailsとの比較が出てくるかなと思います。
どうしてRustだったのか。そしてどんなWebアプリケーションか。
それまでRustでいくつか小さなプログラムを書いたことはありましたがWebアプリケーションを書くとどんな感じなのだろうかという好奇心が全てでした。
おかげで少々苦労することにはなりましたが。
プロジェクトとしては個人での副業で、開発者は一人だったのでインフラも含めて全て自由に決めることができました。運用費はできるだけ安く (できればタダ) にしたかったので、Railsだとリソース的に厳しかろうということでRustを選んだ一面もあります。 用途はとある小規模なジムで使う来客管理システムで、会員登録やその事前登録、お客様の入退場、入場料等を管理できるものになっています。 一人で開発するならSPAじゃないほうが開発は速かろうということで、マルチページでのアプケーションとしました。 使用しているクレート (Rustでは公開・共有されているライブラリの事をこう呼びます) は開発開始当初は大まかに以下の通りでした。
- actix-web (Webフレームワーク)
- tera (HTML Templateエンジン)
- diesel 1.x系 (ORM)
後は必要に応じて追加していくことになります。
まずは見切り発車での開発。
とりあえずactix-webはRustではその当時(2年ほど前)一番メジャーで高速そうなクレートだったので採用しました。確かどこかのベンチマークサイトで一番になっていたことも有ったはずです。他のも大体人気順に決めた感じでした。 あまり知識がない状態だとこんなものです。
実際に使ってみるとactix-webやteraは特に問題なく使えたのですが、dieselはなかなか難解でした。JOINの無い状態だと簡単に使えるのですがJOINした場合の型が以下のように大変なことに。。。 これはJOIN有りでSELECTした結果を引数として受け取る関数です。とても自分で書けるものではなかったので、エラーメッセージからコピペしました。
fn admittance_search_query<'a>(params: &CustomerAdmittanceSearchParams) -> diesel::query_builder::BoxedSelectStatement<'a, (schema::customer_admittances::SqlType, schema::staffs::SqlType, schema::customers::SqlType), diesel::query_source::joins::JoinOn<diesel::query_source::joins::Join<diesel::query_source::joins::JoinOn<diesel::query_source::joins::Join<schema::customer_admittances::table, schema::staffs::table, diesel::query_source::joins::Inner>, diesel::expression::operators::Eq<diesel::expression::nullable::Nullable<schema::customer_admittances::columns::processed_by_id>, diesel::expression::nullable::Nullable<schema::staffs::columns::id>>>, schema::customers::table, diesel::query_source::joins::Inner>, diesel::expression::operators::Eq<diesel::expression::nullable::Nullable<schema::customer_admittances::columns::customer_id>, diesel::expression::nullable::Nullable<schema::customers::columns::id>>>, diesel::pg::Pg>
RDBを使っている以上、JOINが無いことは考えられないと思いますのでこれは避けて通れなさそうです。当時はver1.x系しか無くてそれを使っていたのですが、今はver2になっているのでもしかしたら現在は解決されているかもしれません。もしくはもっと良い書き方があるよ!という方は教えていただけると助かります。
初期段階で一番苦労したのは、Railsのように1から10までレールが引かれている訳では無いので、開発の基盤を安定させるところでした。例えば以下の事は自分で決める必要がありました。
- モジュールをどのような構造で構成するか。MVCにするか、レイヤードアーキテクチャにするか、はたまたオリジナルにするか。そこまで複雑にならない予定だったのでRailsで慣れているMVC構成としました。
- 構成したモジュール間の初期化処理をどのように繋げるか。簡単なようですがついついエレガントさを求めてしまい、意外に時間を取られます。そして最終的には悩んだ割にあんまりエレガントじゃないという。。。
- エラーログはどう出力するか。どのライブラリを使うのか。エラーレベルはどう定義するのか。最終的にはenv_loggerとlogというクレートを使いました。
- 各種設定や開発環境、本番環境の切り分けは環境変数で行うのか、設定ファイルを作るのか。コンテナで動かすつもりだったので環境変数で全て設定するようにしています。
- JavaScript側の構成はどうするのか?JavaScriptのライブラリを使いたかったので一応webpackも入れました。
このように事ある毎に自分でレールを敷く必要があるので、このやり方でいいのかという自問との戦いでした。下手に悩むくらいなら実は酒でも飲みながら進めた方が捗ったかもしれないですね。
開発を終え保守のステージ。
無事リリースし以降数回の修正を重ねることになりますが、幾度かの修正を重ねる中、HTMLテンプレートのチェックをコンパイル時に行ってくれないteraだと、実際に動かしてみるまで些細なミスが発見できないのが苦痛になってきました。 修正後にサーバを起動しページをレンダリングしようとした段階で、結構な頻度でテンプレート内の式でエラーになってしまいます。
ということで、コンパイル時にテンプレートについても静的チェックを行ってくれるクレートを探していたところ発見しました。
これがとても良く私の用途にマッチしてくれて、おかげでテンプレート内の式の書き間違いに苦しむ事はなくなりました。一度コンパイルが通ればページレンダリングの段階でエラーになることはありません。
このようにしてHTML出力に関してはほぼ動的にエラーが発生することはなくなり、コンパイル時に気づくことができるようになりました。 ただまだHTML上でシステムにリクエストを行うような下記の部分はサーバを起動して動的にチェックする必要があります。
- aタグやその他に書かれているシステムのURL
- formタグの中身のinput
これらをコードで生成するライブラリを作ることができればJavaScirpt以外は全てコンパイル時にチェックされることになり動的なエラーを撲滅できそうです。 実現できれば後はJavaScriptをTypeScript化すれば全てに型が付いてコンパイラがチェックしてくれるWebアプリケーション開発環境のできあがりです。夢が膨らみます。暇が無くてできてませんが。
またコードを書いている中でどうしても、formを受け取るための構造体と、ORMで使うための構造体に差が出てきてしまうので最初は手書きでメンバをコピーするコードを書いていました。 これが苦痛でたまらない上に、ミスに気づきにくかったのですが、構造体のコピーをしてくれるようなクレートが見つからなかったため、自分でマクロを作りました。
https://crates.io/crates/clone_into_derive
これでかなりコードが簡略化できたのと、どうしてもコピーするメンバを書き忘れることがあったのですがそれが無くなって保守性があがりました。
こんなコードが有った場合、直接コピーできないのでどうしても冗長に書く必要があります。わかりやすくするために簡略化しています。
// formから変更内容を受け取る用の構造体 pub struct UpdateCustomer { pub second_name: String, pub first_name: String, ... pub blood_type: i16, pub memo: String, pub monthly_path_limit: Option<NaiveDate>, } // ORM用の構造体定義 pub struct Customer { pub id: i64, pub assigned_id: Option<i32>, pub email: String, pub sex: i16, pub birthday: NaiveDate, pub post_number: String, pub prefecture: String, pub municipalitie: String, pub address: String, pub tel: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub blood_type: i16, pub membered_at: Option<NaiveDateTime>, pub memo: String, pub second_name: String, pub first_name: String, pub second_name_furigana: String, } pub fn change_it(con: &Connection, customer_id: i64, update_customer: UpdateCustomer) { let customer = customer::find(con, customer_id); customer.second_name = update_customer.second_name.clone(); customer.first_name = update_customer.first_name.clone(); ... customer.blood_type = update_customer.blood_type; customer.update(con); }
こうなります (変更部分だけ)。違いはUpdateCustomer
に#[derive(CloneInto)]
というマクロを使用するための記述が追加されたのと、そのマクロupdate_customer_clone_into!
をchjange_it
内でメンバのコピーに使っていることです。かなり短くなりました。
// formから変更内容を受け取る用の構造体 #[derive(CloneInto)] pub struct UpdateCustomer { pub second_name: String, pub first_name: String, ... pub blood_type: i16, pub memo: String, pub monthly_path_limit: Option<NaiveDate>, } pub fn change_it(con: &Connection, customer_id: i64, update_customer: UpdateCustomer) { let customer = customer::find(con, customer_id); update_customer_clone_into!(param.customer, customer); customer.update(con); }
よし!かなり使えるライブラリだ!、と思ってクレートとして公開したのですが、全然アクセスがありません。私の想定では人気のクレートになってRust界の有名人になる筈だったのですが。
是非使っていただければ。
あとがき。
という訳で、全てでは無いですがHTMLテンプレートの中までコンパイラが型チェック等をしてくれる状態になりました。 一人で開発しているとやることが多いので、動作確認の一部とはいえ肩代わりしてくれる存在が心強いです。
実装面での感想ですが、Rustでは俗に言う継承(inheritance)が無く、代わりに委譲(composition)が推奨されているのでRubyのような純粋オブジェクト指向の世界から来ると戸惑いを覚えます。 共通部分だけベースクラスとして定義して、それを継承するということは結構やってしまいがちだと思いますが、一切使えません。代わりに委譲を使いましょう。
また安心してマクロが使えるというのは特筆すべきことかなと思います。マクロとはコードを生成するコードの事で、Rustではコンパイル時にマクロからコードに展開します。 マクロはデバッグも厄介で時にバグの温床になるのですが、Rustでは生成されたコードが問題ないかどうかを型の整合性の面と変数のライフタイムからコンパイラがチェックしてくれるので、コンパイルが通ればほぼ大丈夫という安心感があります。 私の書いたマクロは単にコピー元の変数の構造体定義にあるメンバをコピー先として指定された変数の同名のメンバに代入するコードを生成するというものですが、このチェックのおかげで問題があればコンパイラが検出してくれます。なので私のクレートではコピー先の型は指定しなくても良いようにしました。 このようにマクロとコンパイラでの静的チェックはとても相性が良さそうに思います。
そしてRustはいい!コンパイルが通れば動くという安心感に加えて、クレート=ライブラリも比較的充実しており、あまり開発時に困ることもなさそうです。 私はCommon Lispも好きなのですが、欲しいライブラリが無いことがままあって悲しい思いをすることが多いです。。。
ただ、マクロが必要になることが結構ありそうなこと、ライフタイムがあること、そもそも型の概念が難しい等々で、やはりRustを扱う人には高い技術が求められそうです。 個人的にはそれに伴うリターンはあると思うので、高い技術力をもったメンバーを集めるのであれば、Rustでの開発も選択肢に入れてもいいのではないかと考えます。
以上RustでSPAでは無いアプリケーションを作ったときのお話でした。 今どきはHTMLを直接出力するWebアプリを作る機会は少ないのかもしれませんが、何かの参考にしていただければ。
We're hiring!
DIGGLE では共にプロダクトを開発してくれるエンジニアを大募集中です。
少しでも興味があれば、ぜひ下記採用サイトからエントリーください。 カジュアル面談も実施しているので、気軽なお気持ちでご応募いただければと思います!