初めに
こんにちは!
株式会社LabBaseでエンジニアをやらせていただいているヤーノシュです。
本業務では、(主に)Rustのバックエンド開発を担当しており、
社内において共通で適応できるバックエンドの書き方(烏滸がましく言うなら「アーキテクチャ」)
を策定、推進する任命も受けており、日々試行錯誤で効率的に、
かつ気持ち良く(できるだけRustっぽく)普通のウェッブサービスのバックエンド開発を
進められる環境を作ろうと尽力しております。
その中でも、紛れもなく一番重要、かつ難しく感じる要素としては、
DBとの付き合い方だと感じており、本日の記事ではそこらへんの悩みを
少しだけでも皆さんと共有できたらと思います!
(この記事ではRustにも触れていきますが、どちらかというと使用言語があまり関係ない話になります)
問題定義
そもそも我々はなぜDBなんぞを使っているかというと、永続的に、安全に
アプリケーションのデータを保存・取り出しできるシステムが必要だからです。
当たり前ですが、サーバーが再起動した時にユーザーデータが全部飛ぶサービスは、
親ですら長くは使ってくれないでしょう。
特に複数ユーザーの同期アクセス、複数インスタンスからのアクセスなどと言った
期待を各種SQLデータベースが生まれ、今もデフォルトとなっています。
弊社も、古いシステムはMySQL、新規のシステムはpostgresがデフォルトになっていて、
バックエンドで開発する以上、SQLデータベースに確実に触れることになります。
そういったアプリケーション設計だと、バックエンドの役割は、突き詰めるなら、
データベースへ正確1にデータの書き込む、もとい高速にデータを引っ張り出すことに
すぎず、データベースとの連携がシステムの良し悪しの尺度となります2。
となったら、機能面で要求をちゃんと果たしているアプリケーションにおいて
気にすべき性能の指標としては、以下のみとなりますでしょうか?
効率的にデータを書き込み、読み取る
これに頷いている方も多くいらっしゃると思います(同僚の中にもおります)。
ただ、長年開発を続けてきた者は、過去の辛い思いを蘇らせて以下の要素も重視すると思われます:
分かりやすくて、変更や追加しやすい
また、特にRustを使っている方ならこういった考え方が強かったりします:
制約を破るコードが書きにくく、レビューとかで見つけやすい
これらの背景としては、長く努めた方だからこそ、今の仕様が永遠にそのまま
保たれるとは限らない、いずれ必ず変わると理解しており、そうなった時に
たくさんのバグを起こした前科があるからです(もしや自分だけ?)。
得に組込み系からやってきた身としては、物理的な課題を解決しようとしているのではなく、
完全に「人工的」なビジネス課題を解決することが役目のウェッブアプリケーションは
思った以上に複雑に感じており、技術的な課題はそこまで大きくないにしても、
人間とコードの関係性においては大きい課題が満足に解決されず残っていると感じています。
ちなみに、変更への順応力のあり方についてですが。。
特に、昔からのウェッブ開発、Java系の界隈では、大きく仕様が変わっても、 数行しか変えなくて済む設計を最善とする声がありますが、 自分は必ずしもそうだと信じていなくて、大きく仕様が変わった時に、 その変更と関係のある箇所は、できるだけコンパイル時にすぐにエラーに なってほしいと願ったりします。後で見つかる、前の仕様との矛盾は、 できるだけ早い段階で見つけて解決したい、というのもRustっぽい考え方だと思っています。バグ・お問い合わせ対応も担当している身としては、特に以下も気になります:
問題が起きた時に、どの処理が実行され、どのような状態になったかが追いやすい
バックエンドの開発は、結局これらの要求のバランスを決める、という作業でもあり、
開発者としてはずっと、自分が選んだバランスで良かったかどうかの葛藤が付き物です。
解決案の一望
上で挙げているバランスの課題について、色々と考察が重ねられてきており、
解決になると言われる提案もたくさん出されています。
その一部を見ていきたいと思っています。
ドメイン駆動開発・設計
近年人気を集めているやり方で、自分が入社する前からLabBaseでも流行っていました。
個人的には、あまり愛情が沸かない思想で、入社した当初は特に、
何階層もある呼び出しチェインとか、インタフェース(Rustなので実際はtrait)
の使い過ぎで、実際にどんな処理が行われているか非常に見にくく感じていました。
一応、念のために、該当書物3はざっくり読んでいますが、やはり目的を見失っている
感(あるいは、目的が自分と別である感)が強く、LabBaseでも少しずつ消えて行っています。
これでは効率的で、保守しやすいアプリケーションは作れないと判断して、廃止することを決めました。
当時の自分は、ウェッブ経験がそこまで豊富ではなかったのもあって、こういった枠組みはそもそも
要らないのではと強く感じて、以下のスタイルに沿った書き方を、一時的に推奨しました。
あまり設計とか考えずに、そのまま正しいコードを書くだけ
アメリカの強々テック系のユーチューバーも良く提唱するスタイルで、バグはもちろん避けつつ、
あまり構造とか考えないで、早期な抽象化とかをしないで、そのままコードを書くだけの考え方です。
自分の経験でいうと、組み込み系や、実際に「物理的な」問題を解決する時は普通に、それで
一番綺麗なアプリケーションが作れたりします。
ただ、ウェッブアプリケーションとなったら「人間臭い」問題がメインとなり、煩わしい仕様だったり、
メインでバックエンドをやっていない同僚の参戦だったり、すぐに破綻してしまうのも割と早期に、
目に見えてきていて、次はどうするかを考えるようになりました。
この時期の教訓としては、特に
- APIで使う型は厳しく統一して、取り締まる必要があります(しないとフロントの負債にもなります)
-
serdeなどを活用していると、DBから取った型をそのまま変換処理などを挟まずに
レスポンスで返せますが、それだと同じ「概念」の型が何個か、違う項目で併存していて、
フロントでは非常に扱い辛いとのフィードバックをいただきました。ごめんなさい!
-
- 特定の「概念」を取得、扱うコードは、
- できるだけ一ヶ所に集めて
- 一定の書き方に統一しないと簡単に制約が破られたり、バグの温床となります
の二つ+実際にコードに触っていた同僚からのフィードバックを受けて、
現在の標準の書き方を決定しました。
LabBase流(あるいは、ヤーノシュ流)
今新しく追加するコード(一部のプロジェクトを除く)をこちらの書き方に寄せています。
簡単にいうと、
- DB操作を管理するクレートでは「概念」を軸に、モジュールを作ってもらって
- その概念の「標準の形」(
enumなどを使って制約はできるだけ組み込む)と - データベースから取得するのに必要なクエリを、共通化可能な形に提供し、
- それ以外のモジュールからはその概念「の」テーブルに直接触らないようにする
- 1-nのリレーションにおいては基本、別クエリを実行するようにする
- 複数概念を渡る処理は、
usecaseとして概念自体の関数を呼び出して組み立てるようにする - でかいjoinが本当に必要な時だけ、
searchとして、上記の標準型を使いつつ、定義します
上記のDBまわりとは完全に別のところに、APIで扱う型専用のクレートを作っていて、
面倒くさいと思いつつDB型からの変換処理を書いています。
特に、複数の「側面」(企業ユーザー様と個人ユーザー様)を持っているサービスなため、
それぞれの側面で型を分けることで、意図しないデータが意図しないユーザーに渡らないように、
型レベルで防火壁を設けています。
このやり方では、本来なら一つのクエリでできた処理も、場合によっては複数クエリに
渡って実行する実装を(つまり、最速でない実装)をあえて受け入れて、
レスポンス速度が許容範囲から外れる時だけ、個別に効率化を行うことにしています。
そこで明確に妥協していることは、社内でも熱い議論のネタになっており、自分も
不満なところがあるので、最後にどうやって技術敵に解決できるか少しだけ空想を書いてみます。
そもそも、こういった「設計」は、ちゃんと守ってくれる人たちがいる時だけ機能していて、
絶対に守らせるという目的だったら違う手段が必要かもしれません。
ただ、その前には少しだけRustでのDB関連クレートを振り返っていきたいと思います。
RustでDBに触るなら
Rustでは、現状(新しいライブラリはどんどん出ていますが)大きく三つの選択肢があると考えています。
- ORM系
-
dieselは、(コンパイル時の)安全性に全振りした、Rustではもっとも歴史が長いORMです- crates.ioの実装も使ってます
- LabBaseでは、いくつかのプロジェクトで使っていて、型安全は特にかなり好きな部分がありつつ
- 拡張やコードの再利用が難しかったり
- コンパイル時間が遅かったり
- 初心者は圧倒されがちなところがあって、割合が低いです
-
sea-ormは、安全性をある程度犠牲にして、dieselの難易度を下げようとしたORMとなります- 明らかに間違っているクエリでもコンパイルエラーが出ないのでテストは必須
- 弊社では特に、自分が元々OSS貢献した
nested機能を活用して、- 上記で説明している概念ごとのコードの共有を実現できています
- 裏の実装がSQLXなので、最近のバージョンでは、割と快適に生SQLも書けるらしい
-
- 生SQL系
-
sqlxがこの考え方の代表で、特にプロトタイプとかで、早く作りたいケースで選びがち- コンパイル時、DBに接続して、アプリケーション内のクエリを確認してくれているので意外と
sea-ormより安全だったりする - 生SQLなのでクエリの共有、共通化はほとんど不可能なのが大きい課題
- SQLだけ知って入れば良い要素がやっぱり、新入社員観点からうれしい要素
- コンパイル時、DBに接続して、アプリケーション内のクエリを確認してくれているので意外と
-
妄想タイム
効率・制約の遵守・読みやすさのバランスの課題において、たまに、今は空想程度ですが、
今後は取り組むかもしれない仕組みを考えたりしているので、そのうち二つの発想を紹介します。
まず、問題を見つめ直すと、根本的なに解決しないといけない課題でいうと、
DBのスキーマだけでは表現できない制約があり、それを守ってデータを更新・取得しないといけない
そして、DB回りで書いていることが結果的は、その制約を守るための措置にすぎないので、
大前提として、DBとの接続を担うシステムに、その制約伝えることができたら(論理システムくさくなってしまった)、
簡単なコードだけで、必ず制約を守ってくれるシステムが作れるはずです。
例えば
-- 店舗
create table bakeries {
id serial
};
-- パン
create table breads {
id serial
};
-- 店舗が作るパンリレーション
create table bakery_bakes_bread {
bakery_id serial
bread_id serial
};
-- 店舗の受注リレーション
create table orders {
id serial
bakery_id serial
bread_id serial
};
と言ったテーブル設計において、「そのパンを提供しない店舗からは注文できない」4といった
制約がある前提で、それをシステムに何らかの形で伝えて、アプリケーションコード
をその制約を基準にバリデーションできたらいろんな面白いことができてしまいます。
例えば
- 一番簡単:注文を受け付ける時に、必ずそのチェックを入れさせることができる
- もっと面白い:制約が必ず守られていると分かっているので、
ordersテーブルの内容から、bakery_bakes_breadの内容が分かるので(必ず該当する行が入っているはずなので)、場合によっては追加のクエリ・ジョインを省くことができます
そういった論理的な制約の情報があれば、
以下の二つの書き方が考えられます:
完全抽象化晩
これは、論理的な概念の関係性をもとに生成されるコードを使って
let result = db.transaction(|transaction| {
let offers_bread = offers_bread(bread_id, store_id, transaction);
if !offers_bread {
return Err(ServerError::BadRequest);
}
insert_order(bread_id, store_id, transaction);
}).await?;
に対して分析を行い、実は二つのクエリに見える上記のコードを、
一つのクエリで済ませることができることに気づき、
一つのクエリにまとめるシステムを作れるはずです。
-- WITHでもいけますが。。
insert into orders (bread_id, store_id)
select bread_id, store_id from offers_bread where bread_id = .. and store_id = ..;
-- 追加された行数が期待値に合わない場合、ServerError::BadRequestを返す
また、店舗がそのパンを提供しているかどうかのチェックをしないコードを弾くシステムもまた作れます。
ただ、このコードは、解読性でいうとかなり問題があり、実際にどんなSQLが実行されるかが分からないので…
sqlx+
…sqlxでお馴染みの書き方を借りて、開発者が書いたSQLに対して、DBに対するバリデーションに加えて、
上の論理システムのバリデーションも重ねて、不正なクエリを弾くことができます。例えば
insert into orders (bread_id, store_id) VALUES (1, 2);
は、チェックが認められないため、基本コンパイルが通らないようにします。
やっぱ、論理システムを勉強すべきかな…
