はじめに
SENSYN ROBOTICS(センシンロボティクス)の中山です。開発組織全体を見る技術統括という組織でディレクターをやっています。
前回に続いて、性能を担保するための基礎力を向上させたいと思って記事を書いています。今回はWeb API編です。
Web API設計
社内のアプリで時々見かけるのが、一覧画面のUIがWeb APIからすべてのデータを取得して保持し、UI側でページングする、という設計です。これは利用しているUIコンポーネントライブラリで作りやすかったからとか、総件数がそれほどでもないと想定されるからとか、いくつか理由があります。
このWeb APIがすべてのデータを返す設計になっていると、
- サーバ側でCPU、メモリ、I/Oの負荷が高まる
- ネットワークの転送量が増えて
- 応答の完了が遅くなる
- ネットワークのコストが余計に掛かる
- クライアント側でCPU、メモリ、I/Oの負荷が高まる
という問題が出てきます。実際、一覧画面を開いたときに妙に時間が掛かると思ったら、全件取得に時間が掛かっていたから、ということがありました。
良いWeb API設計
ページング
一覧画面でのデータを返すWeb APIであれば、ページングを行えるようにするのが良い設計であると言えると思います。ただ、総件数が少ないと分かっているケースであれば、全件を返す設計の方がむしろ優れている場合もあります(日本の県の名前を取得するWeb API等)。
悪い例
全件取得して、必要なページをUI側で抜き出す。
const plans = apiClient.getAllPlans();
const plansForView = plans
.slice(page * pageSize, (page + 1) * pageSize);
良い例
Web APIにページングの条件を渡して、必要なページだけを取得する。
const plansForView = apiClient.getPlans(page, pageSize);
フィルタ
また、フィルタ機能が必要な場合、Web API側にフィルタ条件を渡して、フィルタされた結果をUI側で取得する設計にするのも重要です。これもUI側で保持して、JavaScriptでフィルタする実装が実際にありました。
悪い例
全件取得してフィルタを行う。
const plans = apiClient.getAllPlans();
const plansForView = plans
.filter(p => p.name === cond.name)
.slice(page * pageSize, (page + 1) * pageSize);
良い例
Web APIにフィルタ条件を渡して、フィルタされた結果を取得する。
const plansForView = apiClient.getPlans(page, pageSize, {name: cond.name});
Web API実装
UI-Web API間と同じことが、Web API内部の実装(Web API-DB間)に関しても言えます。例えば、DBからすべてのデータを読み込んでPHP/TypeScriptでフィルタするような実装では、全く同じ問題が異なるレイヤーで発生します。
悪い例
const plans = dbConnection.query("SELECT * FROM plans");
const plansForView = plans
.filter(p => p.name === cond.name)
.slice(page * pageSize, (page + 1) * pageSize);
良い例
通常の手法
const query ="SELECT * FROM plans WHERE id > ? LIMIT ? OFFSET ?";
const plans = dbConnection.query(query, [pageSize, page * pageSize]);
データ量が大きいときの手法
前回のページの最後の行のidを使って、それ以降を取得する。
const query ="SELECT * FROM plans WHERE id > ? ORDER BY id LIMIT ?";
const plans = dbConnection.query(query, [lastIdOfPrevPage, pageSize]);
データの近くで演算せよ
これを一般化すると、データの近くで演算せよ、ということになります。
データが保存されている場所か、データにできるだけ近い場所で計算することで、latencyを下げられる他、
- CPU
- メモリ
- I/O
- ネットワーク
の消費を抑えることができます。
例: 売上の合計
例えば、RDBMSの表に格納されている売上の合計を取得しようと思ったら SELECT SUM(price * amount) FROM sales とするのが一番速くて効率的です。
これを
- アプリケーションサーバ側で
SELECT price, amount FROM salesして全件を取得しrecords.reduce((acc, {price, amount} => acc + price * amount), 0)で合算する - Web APIで全件のprice, amountを取得してクライアント側で合計する
としたら、非常に性能が悪化します。ページングやフィルタをクライアント側でやるのも同じことで、性能に悪影響を与えることが多いです。
余談
- じゃあ、RDBMSのstored procedureで全部やるのがいいのか、というとメンテナンス性やスケーラビリティの問題があるので、そうとも限りません
- ただ、これに近い主張をするひとはいますし、似たような思想でメンテナンス性・スケーラビリティの問題を解決しようとしたDBMSも存在します(Datomicとか)
まとめ
- Web APIの設計・実装では、UI側で処理するデータ量を減らすように設計すると、性能面で良い結果を生みます
- データの近くで演算することで、性能の悪化を防げます