isyumi_netブログ

isyumi_netがプログラミングのこととかを書くブログ

書き込み系のREST APIは、URLとBodyに無いデータを書き込まないようにしよう

REST APIは、要求を受理するか判定する部分と、要求に従ってデータを返信したりDBに書き込んだりする部分に分かれる。前者の受理判定部分はあらゆる情報を使っていい。しかし、後者の処理部分はリクエストに明記されたもの以外を使ってはいけない。使っていいのはURLとBodyだけだ。

例えば/myprofileというAPIがあったとしよう。このサーバーはCookieを使ってアクセスした人を特定できる。そこで /myprofile ではCookieの情報をもとにその人のプロフィール情報を返してあげていた。これはよくない設計だ。このように直してほしい。まずURLは/profiles/:user_id (:user_idは変数)に変更しよう。そして、メソッドの冒頭にCookieと:user_idを比較して一致しなければエラーを出してほしい。

void onMyProfile(HttpRequest req) {
// これで /users/:user_id の:user_idの部分が取れる
var userID = req.uri.pathSegments[1];
if (!isUserIDMatch(req.cookies, userID)) {
req.response.statusCode = HttpStatus.BAD_REQUEST;
return;
}

req.response
..statusCode = HttpStatus.OK
..write(getProfile(userID));
}

 前者と後者の違いは、前者はサーバーに忖度を求めているのに対し後者はサーバーにしてほしいことを明確に指定している。後者の考え方でシステム全体を統一することで設計の不明瞭さがなくなる。どのAPIも挙動が予想しやすく、変更をかけやすく、機能追加したい人がどうしたらいいかわかりやすい。もう一つの違いは、前者は処理が始まってから複数の成功パターンに分岐していくのに対して、後者は一種類の成功パターン以外はすべてエラーになるということだ。後者のほうがコードと仕様がきれいになる。

他に例を挙げると、掲示板の書き込みAPIはPostのBodyにMessageとUserIDを取ろう。Messageだけを受け取りUserIDをCookieから割り出してはいけない。

いい例
{
"message": "こんにちは",
"user_id": "user1"
}
悪い例
{
"message": "こんにちは"
}

 また、1年以上前から会員登録してくれていた人向けに特別サービスをするときのAPIについても考えよう。二種類考えられる。

案1

  • サーバー側で会員登録履歴を確認する
  • 1年以上前からの会員にサービス価格を適用する
  • それ以外の会員に通常価格を適用する

案2

  • 特別サービス対象者か確認できるGet APIを用意する
  • 購入APIではRequest Bodyで自分が対象者かどうか明記させる
  • 非対象者が対象者として購入APIをコールしたらエラーにする
  • 対象者が非対称者として購入APIをコールしたらエラーにする
  • それ以外は申告通りに適用する

この場合も、面倒だが案2を選ぼう。

全体を集計した結果のキャッシュ

 

blog.isyumi.net

 前回、元データとキャッシュが1:1で対応するときのコツを書いた。今回は全体を集計した結果のキャッシュの話をする。難易度が1段上がる。

まずどのようなキャッシュが該当するか考えよう。月ごとの売上を合計して画面に表示するプログラムがあったとしよう。このようなSQLになる。

select month , sum(price) as month_sum from sales group by month;

売上件数が多ければ結構な時間がかかってしまう。そこで、別テーブルに集計しておき、次回はそこからデータをとってくることにした。

create table cache_month_sales as select month , sum(price) as month_sum from sales group by month;

この場合、salesテーブルのどの行がキャッシュテーブルのどの行に対応するか明確に決まっていない。このキャッシュは元データ全体に対するキャッシュだ。

このキャッシュの難しさは以下の二点だ

  1. 元データのどこが変更されたらキャッシュのどこを更新しないといけないか一概にいえなくなる
  2. 別の処理がキャッシュに依存している

1は、先述の例は簡単すぎたが、もっと複雑な集計をすることもある。例えば各月のランクもキャッシュしていたなら、salesテーブルを1行変えただけで順位の入れ代わりを計算することになる。

2は、元データからすぐに集計結果を出せるわけではないので、キャッシュが存在しなければ元データからデータを取り出そうという実装ができない。

では、このようなキャッシュを作るうえで気をつけたいことを挙げる。まず、元データに更新がかかったらキャッシュを全部作り直すか部分的に更新するか決めよう。先の例であれば、部分的に更新するSQLはこうだ。

insert into sales values( ? , ? );
update cache_month_sales set month_sum + ? where month = ?;

全部一気に更新するならこうだ。

drop table if exists cache_month_sales;

create table cache_month_sales as select month , sum(price) as month_sum from sales group by month;

これらは主に読み込みと書き込みの頻度を元に決める。書き込みが頻繁なら部分アップデートをしなければいけないがたまにしか書き込まれないなら全体を洗いがえてもいい。部分を更新するほうを選ぶときは、それなりに覚悟を持って。次に、どちらを選ぶにせよ全部一気に更新する方法を用意しよう。最後に、前回も話したが全部一気に更新する間違えにくいUIを用意しておこう。あなたが一番あせってるときに間違えないUIだ。

補足

PostgreSQLを使っている場合、Materialized Viewというそのまんまの機能があるのでそっちを使おう。

正規化したTableとキャッシュ用のテーブルの区別がつくように、キャッシュ用のテーブルにはすべてcache_やmareriarized_などのPrefixをつけよう。

全部一気に更新するときにdrop tableしてから create tableしたが、truncateからのinsert selectのほうがいいかも。

create tableしたら alter add indexをしたほうがいい場合があるので忘れないように。

元データとキャッシュデータが1:1で対応する時のコツ

元データとキャッシュデータが1:1で対応するとは、キャッシュ層に元データの一部を置いてある状態である。この場合、キャッシュのデータは元データのどの部分かはっきりしている。例えば、47都道府県の市町村リストがあり、その中で茨城と島根のデータをローカルにキャッシュしてあるような状況を想像してほしい。このようなキャッシュは、元データが変更されたらキャッシュデータの何を更新しないといけないかわかりやすい。比較的難易度が低いキャッシュと言える。

では、どのようにキャッシュを使おうか考えよう。

まずキャッシュがあってもなくても同じ動きになるようにしよう。get()メソッドの中で

String get(String id) {
if (cache.hasCache(id)) {
return cache.get(id);
} else {
return httpHandler.get(id);
}
}
 

 のように自動的にキャッシュを使うかどうか判断してくれるとありがたい。こうしておけばいざという時はキャッシュを全部消せば良いので心配が減る。ただ、このライブラリが自動でキャッシュを使うということがライブラリ利用者側伝わりづらくトラブルの元になりかねない。意味もなくuseCacheをtrueにさせるという一手間をかけさせると「あ、キャッシュするんだな」と伝わって良い。たとえfalseという選択肢がなかったとしても。

class Api {
Api({bool useCache: true}) {
if (!useCache) {
throw "そんな選択肢はない!";
}
}
}

void main() {
var api = new API(useCache: true);
}

 

 次に、元データ管理層からキャッシュの更新か破棄を指示できるようにしておこう。キャッシュ層が自分の管理するサーバー内にあるならそれようのプロトコルを作っておこう。WebシステムならURLの末尾にタイムスタンプをつけておくとCDNと併用できて手軽だ。

var modificateAt = <?php print $modificateAt; ?>;
$.ajax("/resources.json?" + modificateAt);

最後に、キャッシュを手動で破棄する方法を用意しよう。管理画面にデカデカとキャッシュクリアボタンを用意しておくと良い。いついかなる理由でキャッシュをクリアしなければいけないか未知なので必ず設けよう。だいたいキャッシュをクリアしたい時は超絶焦っている時だ。「SSH接続してこのファイルを削除ー」とかはやめよう。

 

キャッシュを設計する時に考えること

キャッシュは難しい。そこで安全な設計ができるように考えるべきことをまとめた。

まず、キャッシュにはどのようなものがあるか。

  • 前回のHTTPリクエストの結果をローカルに保存しておき、次回はそこから結果を返す
  • HDDに書き込んだデータをメモリにも保存しておき次回はそこから読み込む
  • RDBで時間がかかるクエリを高頻度で実行する時、あらかじめ別テーブルに集計結果を入れておく
  • 数学のアルゴリズムを書く際に、CPU負荷の高い平方根を先に済ませて結果を連想配列に入れておく

このようなものが挙げられるだろう。

本題に入る前にいろいろなタイプのキャッシュを分類をしたい。キャッシュは二種類に分けられる。一つは元データとキャッシュデータが一対一で対応するものだ。キャッシュデータが元データのどの部分をキャッシュしているか明確に答えらえるものとも言える。例えば1万ファイルの中で数ファイルをメモリにも持っているようなキャッシュだ。これは、キャッシュしたファイルがHDD上のどのファイルかはっきりしている。もう一つは、元データの集計結果に対するキャッシュだ。例えば、1年間の売り上げが全てDBに入っているとしよう。その総売上金額をメモリにキャッシュしているとする。そのキャッシュデータは売り上げデータ全体に対するキャッシュだ。逆に言えば元データのどこか個別の場所のキャッシュではない。この違いによってキャッシュ設計で考えるべきことが変わってくるのできちんと区別しよう。

次にキャッシュの難しさについて考えたい。なぜキャッシュは難しいのか。そもそも真実は一箇所に管理していた方が都合がいい。同じデータを複数の場所に保存しているとデータに更新をかけたい時どこを書き換えればいいかわからないくなる。また、元データでなくキャッシュの計算式が変わる場合もある。税込みの売上データをキャッシュしていたらある日消費税率が変わったりする。その時キャッシュデータをどう更新しなければいけないのかが難しい。このように、キャッシュはデータやシステムに変更があったら正確にキャッシュを更新しなければ不整合が起こることが難しい。

では、どのような心構えで挑めばいいだろうか。まず、不整合とは何かはっきりさせたい。一つは元データが更新されたのにキャッシュデータが更新されていない状態だ。次に、キャッシュの計算式を変えた時は、キャッシュを更新するまで全てのキャッシュが不整合な状態になる。さらに、今キャッシュされているデータが整合しているか自信がない時も不整合と同じである。例えばDBの一箇所を手動でUpdateしたが、このUpdateはキャッシュとの間に不整合を起こすかわからなかったとしよう。心配になってる時点で不整合が起きていると一緒だ。

そこで、何が正になるデータかはっきりさせよう。HDDのデータをメモリにも持つならHDDが正だ。RDBで正規化したテーブルとパフォーマンスのために結合結果を保存してあるテーブルがあれば正規化したテーブルの方が正だ。正のデータはキャッシュのデータに依存してはいけない。存在を知っていてもいけない。キャッシュ管理層は正のデータを取りに行っていいが、反対に正のデータ管理層はキャッシュデータに触らなければ整合性を維持できない状態になってはいけない。また、キャッシュデータは知っているけど正のデータは知らない情報があってもいけない(ただし、キャッシュの作成時刻のようなメタデータはOK)。このようにレイヤーを意識して依存関係が複雑にならないようにするといい。

次に、どんな時にキャッシュのどの部分を更新するかきちんと洗い出そう。決定事項はReadme.mdなどにきちんと書き出して管理しておくことをお勧めする。この部分は特に「ソースコードが正解」という事態は避けた方がいい。

そして、不整合はいつか起きるものだと思っておこう。起こらないようにするより大事なことだ。

では、ここからいいキャッシュのためどんな作戦を立てればいいかを考えよう。

まず、元データとキャッシュが1対1で対応するようなキャッシュではここに気をつけたい

  • キャッシュはあってもなくても同じ動きになるようにしたい
  • あった方が速いがなくてもいいという状態が最善
  • 元データ管理層がキャッシュのクリアを指示できるように
  • キャッシュがおかしくなったらとりあえずキャッシュをクリアすればいいようにしておきたい
  • キャッシュを簡単に削除できるように

逆に、全体に対するキャッシュの場合は

  • キャッシュを簡単い作り直せるように

を気をつけよう。

具体例は次回以降に話したい。

バッチ処理には必ずドライラン機能をつけよう

バッチ処理の負債化は早い。理由はよくわからない。想像するに、普段ほとんど意識しないからどんな機能があるか忘れる、外部のシステムとつながっているので手動テストすらし辛い、処理が長い、などの理由がありそうだ。

バッチ処理は簡単に動作を再現できることが重要だ。もちろん、開発環境は用意してあるだろう。そこで本番に近しい結果を得ることはできるかもしれない。しかし、今動いている本番サーバーがどんな動きをするか簡単に確認出来てほしい。

例えば、一日に一回1週間ログインしていない人にメールを送るとする。そうであれば、今バッチ処理が実行されたら誰にどんなメールが送られるか確認できるようにしてほしい。出力形式はWebの画面でもCSVでもなんでもいい。このようにテスタブルにすることで微修正を重ねやすくなりソースの腐敗が防がれる。

これが実現可能であるということこそ、これまでこのブログで支持してきた副作用を抑えるプログラミングスタイルのメリットの一つである。

 

blog.isyumi.net

例えば先述のメールのバッチ処理の場合

  1. DBにQueryを発行する
  2. 1件ずつFetchし、メール送信対象者ならメールを送る

というバッチ処理ではドライラン機能をつけるのが難しい。しかし

  1. DBにQueryを発行する
  2. 1件ずつFetchし、メール送信対象者なら「メールを送る人配列」に追加していく
  3. まとめてメールを送る

というように書いておけば最後の3番をIF文で切り替えて「メールを送る人配列」をCSVに出力するだけで簡単にドライランが実装できる。

 

追記

本当に配列に入れちゃうとパフォーマンスとかヤバそうな場合あるので、その言語のイベントストリーム機能を調べよう。例えばGoならchannelの受信先を挿げ替えるだけで同じことが実現できる。

複数の言語で開発する時は定数を一元管理できるようにしよう

一つのシステムを複数の言語で開発することがある。例えばサーバーをJavaで開発しクライアントをTypeScriptで開発する、などだ。こんな時、定数の一覧をYAMLなどでファイル化しよう。そして、YAMLから各言語の定数ファイルを自動生成するものも作ろう。すると、サーバーとクライアントのやりとりでtypoによるエラーがなくなって便利だ。例えばプロフィール入力画面があったとする。そこで入力する性別がman/womanとmale/femaleのどちらにしようか、また大文字か小文字か間違えないようにしたい。こんなYAMLを作ろう。

MALE: MALE
FEMALE: FEMALE

そこから生成したGoとTypeScriptはこんな感じだ。

const (
MALE = "MALE"
FEMALE = "FEMALE"
)

 

const  MALE = "MALE",
FEMALE = "FEMALE";

 

TSXからこんな感じに使える

return <select>
<option value={MALE}>{MALE}</option>
<option value={FEMALE}>{FEMALE}</option>
</select>

 サーバーサイドはこんな風に描ける

func PutGender(userid , gender string) {
if gender != MALE && gender != FEMALE {
panic(gender)
}

db.put(userid , gender)

}

このようにサーバークライアント通じて安全にプログラミングができて便利だ。

Swaggerなど関連する便利なツールもあるので利用していきたい。

 

VからCにメッセージを送る時は最小限のデータにしよう

いろんなMVCがあると思う。特定の実装に依存する話じゃない。

  • RoR的なサーバー・クライアントモデル
  • ReactやAngularを使ってクライアントサイドMVCを構築する
  • CLI

など、色々ある。大雑把に話の前提を揃えよう。Mは様々なデータを管理している。VはMを加工して画面に表示する。Vでクライアントからの入力を受け付けてCがそれを処理する。VはMからデータを取り出すことができる。今日の問題提起だが、なぜかVからCに送るデータが肥大化しがちと言うことである。例えば、

  • 画面がありそこに価格が書いてある
  • それを押すとサーバーで課金システムが動く

というシステムだとしよう。ここでクリックされた時にサーバーに送られるデータは商品IDだけであるべきだ。価格は省いて送信するべきだ。Cは自分でMから商品の料金を参照するべきだ。

なぜか。一つにセキュリティの問題だ。そして変更に弱くなる。しかし、もう一つ大きな理由としてメンタルモデルをあげたい。「システムの様々な場所に点在するデータは、最終的にMを正にする」と思い込んでいる。その中で、CがVから必要ないデータを受け取っていたら、「これ何かに使うのかな?」と言う気がしてくる。