これは5年間 PHP (主に Laravel) で Web 系の API を書いてきたエンジニアが、
転職をきっかけに Rust で API を実装することになって感じた PHP とのギャップ についての話です。
- Web API という文脈は同じ
- PHP (Laravel) はそれなりに書いてきた
- Rust は完全に初心者からスタート
そんな立場から見えた「言語やフレームワークの思想の違い」を共有できればと思います。
最初に
なにゆえ PHP から Rust ?
これは長らく動的型付け言語 のみ で生きてきて、
そろそろ静的型付け言語も触りたい!と思ったのがきっかけです。
その中でも Rust にしたのはなんでしょうね。ロマンです。
さて。
これまでの5年間は、主に Laravel を使って以下のような思想で実装していました。
- Eloquent ORM を使った API 実装
- Laravel 推奨のテーブル設計
- strict モードを使用し、出来うる限り型安全性を担保
- 可読性・保守性・変更容易性を優先した実装方針
(多少のパフォーマンスより、読みやすさを取る)
Laravel は、
- Web アプリケーションを書くための前提が揃っている
- 「普通に書けば普通に動く」
- 運用まで含めた開発体験がかなり洗練されている
という印象が強いフレームワークです。
一方、今の会社では Rust を使った API 開発が行われており、
入社後すぐに Rust で API を書くことになりました。
当然、最初にぶつかった壁は言うまでもありませんね。
コンパイルが通らない......;;
型が "設計を言語化するもの" になっている
Rust を触り始めてまず感じたのは、
型が単なる注釈ではなく、設計そのものを表現する役割を持っている という点でした。
PHP でも型と例外はそれなりに意識していた
前提として、PHP 時代も「動けばいい」で型を蔑ろにしたり、
テキトーに実装していたつもりはありません。
/**
* nullable にする場合
*/
public function getUser(int $id): ?User
{
return User::find($id);
}
/**
* ここでスローさせる場合
* @throws ModelNotFoundException
*/
public function getUserOrFail(int $id): User
{
return User::findOrFail($id);
}
- 引数型・return 型は必ず定義
- 予期する exception はできるだけ PHPDoc に記載
- できるだけ strict mode での開発
ただし、一見すると PHP にしてはかなり堅そうに見える実装でも、
- 実行時までエラーに気づけない可能性がある
- チーム全員が同じ粒度で型を書いてくれる保証はない
(strict mode にしない、型を書かない、mixed を多用するなど)
といった不安は、どうしても残ります。
(lint ルールをガチガチに設定しておくとある程度マシになるかもですが...)
Rust では厳格さを強制される
Rust の場合、同じような処理は次のようになります。
#[derive(DerivePartialModel, FromQueryResult)]
#[sea_orm(entity = "users::Entity")]
pub struct User {
pub id: UserId,
pub name: String,
}
impl User {
pub async fn get(id: UserId, db: &DbConn) -> Result<Option<Self>, DbErr> {
users::Entity::find()
.filter(users::Column::Id.eq(id))
.into_partial_model()
.one(db)
.await
}
}
これを見たとき、まず頭に浮かんだのは
設計書として見るなら、こっちの方が分かりやすいかもしれん
その理由としては、
- try catch のように「どこでハンドリングされるか分からない」構造ではない
- Result 型によって、呼び出し側にハンドリングを強制できる
- どんなエラーが返りうるかを return 型から読み取れる
あたりが大きいです。
今まで try catch 信者でしたが
(というより、それしか知らなかっただけ)、
Rust の Result 型は個人的にかなり使い勝手が良く感じました。
- 型を書く行為そのものが設計整理になる
- 書きながら「このケース考えてなかったな」と気づける
- 後から仕様を読み解くコストが減る
- rust-analyzer が優秀で、コンパイル時に大抵のエラーは潰せる
というメリットがありそうだと感じています。
Laravel では
「とりあえずあとでハンドリングを考える」
という進め方をすることもありましたが、
Rust では 書くのと同時にハンドリングを確定させていく 感覚があります。
型と一緒に設計を組み立てていく感じで、
頭の中がかなり整理しやすいと感じました。
ORM の “柔軟性” は… さびしいか?
一方で、Rust で API を書いていて 明確に気に食わなかったポイント もあります。
それが ORM、特に論理削除の扱い です。
Laravel ORM の論理削除はスーパーフレンドリー
Laravel の ORM である Eloquent では、
論理削除はほぼ前提機能として標準装備されています。
class User extends Model
{
use SoftDeletes;
}
$users = User::all(); // 論理削除は自動的に除外される
$users = User::onlyTrashed()->all(); // 論理削除されたレコードのみ
$users = User::withTrashed()->all(); // 論理削除されたレコードも含む
- 取得系は常に安全側 (有効データのみ) に倒れる
- 削除済みデータを扱うには明示的な操作が必要
- うっかり事故が起きにくい
これは単なる便利機能ではなく、
運用を見据えた設計思想 だと思っています。
SeaORM で論理削除を扱ったときの罠
Rust + SeaORM では、論理削除は完全に自前対応になります。
let users = users::Entity::find()
.filter(users::Column::DeletedAt.is_null())
.all(&db)
.await?;
これを見た瞬間、率直に思いました。
論理削除運用、ヤバそう
- 条件を付け忘れてもコンパイルは通る
- レビューでも見落としやすい
- 実行時エラーにもならない
Rust は型システムで多くのミスを防げる一方で、
ORM における「有効データの扱い」は人間の注意力に委ねられている 印象があります。
"思想の違い" では片付けづらい不親切さ
ここは正直、
「Rust はこういう思想だから」で片付けるのは難しいと感じました。
- ORM として論理削除のサポートが薄い
- 安全側に倒すためのガードレールが用意されていない
- チーム運用を考えると、かなり怖い
正直、Web アプリケーションとして実装するなら
必須レベルの機能では? と思っていた分、厳しさを感じました。
実際、チーム内でもこの問題について議論しましたし、
「ないなら自前で作るしかないか……」と考えたこともあります。
現状では、すべてのクエリビルド時に
手動で論理削除フラグの filter を書いています。つらい。
Laravel 時代には深く考えずに済んでいた設計判断が、
Rust では必要になってしまいました。
PHP と Rust では "どこで守ってくれるか" が違う
ここまで振り返ってみて感じたのは、
- PHP + Laravel
- フレームワークが多くを肩代わりしてくれる
- Web 業界で長年使われてきただけあり、開発体験が良い
- Rust
- 言語と型が強力なガードレールになる
- その分、周辺ツールはまだまだ素朴で成長段階にある
(とはいえ現時点でも企業で本運用できてるくらいには整っている)
という違いです。
Rust は「厳しい言語」と言われがちですが、
少なくとも型に関しては 納得感のある厳しさ だと感じました。
一方で、Laravel の開発体験に慣れているほど、
それなりの覚悟は必要です。
(コンパイル時間も割と辛い)
まとめ
PHP から Rust に移行して感じたギャップをまとめると、
-
型は設計をそのまま表現できる強力な道具
- 書くこと自体が仕様整理になる
- 型が厳格でコンパイルも厳しいのでバグ率が減る
-
ORM、とくに論理削除運用は要注意
- SeaORM は柔軟だが不親切
- Laravel の「当たり前」は存在しない
Rust で API を書くこと自体は、とても楽しいです。
ただし、Laravel 的な感覚のまま Web 開発を進めると、
思わぬところで足元をすくわれるかもしれません。