18th
pixivポップボードのキャッシュの仕組みとFacebookのUIの話
こんにちは。JavaScript Advent Calendar 2011 オレ標準コース18日目の@ykskです。
先日pixivにポップボードという通知機能がリリースされました。自分がお気に入りユーザーに追加されたり、投稿したイラストがブックマークされたりした時にヘッダーに未読件数などのお知らせを表示します。僕は直接機能を実装していたわけではないのですが、リリース直後に起こった負荷の問題でJSを書きました。今日はその話をします。主にUIの話です! え!
リリース直後、定期的に未読数の更新をAjaxで行っていた部分の負荷が急激に上がりました。ページロード時にHTMLに未読数を埋め込んだあと、2分ごとに未読数取得APIへリクエストするという処理です。
ポップボードはヘッダーに出るため、ほぼ全てのページでこの処理が入ります。複数のタブでpixivを開いて見ていた場合、その分だけリクエストも増える……そのことが原因じゃないかという話になった時、まじかよと思って自分のブラウザを確認すると複数タブで開いて見ていました。ははーん……。
具体的な対応は後述しますが、結果的には負荷の問題は解決出来ました。それよりも気付かされたのはサイトの設計の問題です。僕はこの現象をUI上のバグだと思いました。同じお知らせ機能のあるFacebookとpixivの違いを考えた時に浮かぶFacebookを複数タブで開いて見ているか?という疑問。Facebookはサイト設計にあたって複数タブを開かせないことに最適化しているのではないか。そして現状pixivは複数タブを開いた方が便利な設計になっていること、負荷になるほど一般的な方法だと思われること……。説が正しいかは別にして、思い付いてからFacebookへの見方が変わりました。チャットもアクティビティも1つのタブで全て見れるし、遷移も速い(history.pushState)。フィードに全てが流れる。不便さを感じさせないことはユーザー体験だけでなく、同じ機能を実装しようと思った時に負荷の面でもメリットになっているいい例なのではないかと思いました。逆に局所的に先進性を取り入れた結果、歪みが生じるのは避けられないことなのかもしれません。
以上UIの話でした。取り上げたUIの問題については現在製作中の国際版で解決する予定です。Railsで作っています。興味のある方一緒に作りましょう!
明日は@aerithさんがinstagramのお話をしてくれるようです。楽しみですね! 以下は実装の話になります。
さて具体的な解決方法です。結論から言うと、JS側ではタブ間でキャッシュを共有して、いくつタブを開いていてもリクエスト数は一定になるようにして負荷を減らしました。
# ざっくりとしか説明していません。実際のコードはpixiv.jsで見ることが出来ます。今のところ難読化していません:)
キャッシュにはHTML5のlocalStorageを使います。localStorageがすばらしいのはブラウザ全体で値を共有出来ることです。ページをリロードする必要なく、タブ1で値を書き換えたらその瞬間にタブ2で更新された値にアクセス出来ます。この仕組みがあればキャッシュを全体で共有出来ます。
- localStorageの値を監視
- 値の期限が過ぎていたらAPIから値を再取得して、結果をlocalStorageに突っ込む
- 値の期限が残っていたらlocalStorageの値をそのまま使う
これにより最初に期限が切れたタブだけがリクエストして、他のタブはその結果を使い回します。
対策した後の処理の流れです。まず、ページロード時にHTMLに入っている未読数をlocalStorageに突っ込みます。このキャッシュは2分間有効。これによって2分以内に新しいタブが開かれ続ければ常にキャッシュが生きるのでAPIリクエストは全く発生しなくなります。キャッシュの期限はsetIntervalで10秒ごとに確認します。前述の通り、古くなっていればそのタブが値の更新を担当します。まだ期限が残っていれば、値が新しくなっていないかの確認を行います。
タブを開きっぱなしにして何も操作をしていない場合を想定して、リクエストの度に間隔を4分→8分と延ばすようにしました。新しいタブが開かれると間隔はリセットされます。
タブを開いたタイミングによっては1つのタブでリクエストしている間に別のタブからさらにリクエストが飛ぶ可能性があるので、その対策も必要です。今回はjQueryのDeferredオブジェクトを使って解決しました。localStorageにロード中のフラグを立てて、フラグが立っている間に発生したリクエストにはDeferredオブジェクトを返しておきます。最初のリクエストの結果が返ればlocalStorageの期限が更新されるはずです。待っているタブではキャッシュを1秒おきに確認して、更新されたらDeferred.resolveで更新されたキャッシュの値を流してあげるようにしました。
localStorageでいくつか注意する点として、1つはlocalStorageにはCookieのように有効期限が設定出来ません。今回はこんなこともあろうかと整備してあった、localStorageの値自体に期限を設定出来るライブラリを使ってさくっと実装出来ました。抜き出すと、
function storageParse(d) {
var data, expires;
try {
if (!isNaN(d)) {
return d;
}
d = JSON.parse(d);
return $.type(d) == 'object' ?
pixiv.storage.parseExpire(d) :
d;
}
catch (e) {
return d;
}
}
function storageAddExpire(data, expires) {
expires = +new Date + (expires || 0);
return {data: data, expires: expires};
}
function storageParseExpire(data) {
var expires = data.expires;
return expires || expires === 0 ?
(+new Date > expires ? undefined : data.data) :
data;
}
function storageLocalStorage(name, value, expires) {
expires && (value = pixiv.storage.addExpire(value, expires));
return storageStorage('local', name, value);
}
function storageStorage(type, name, value) {
var storage = w[type + 'Storage'], ret;
if (!storage || !name || !$.support.json) return;
switch (value) {
case null:
storage.removeItem(name);
break;
case undefined:
return pixiv.storage.parse(storage[name]);
default:
try {
$.browser.touchDevice && storage.removeItem(name);
storage.setItem(name, $.type(value) == 'object' ? JSON.stringify(value) : value);
}
catch (e) {
pixiv.log(e);
}
}
}
// 使う時はこれだけでOK
pixiv.storage.localStorage('name', value, 1000 * 60 * 60);
このような感じ。オプションを与えるだけでいいようにしておくと便利だと思います。もう1つはlocalStorageの値はサービスのログイン状態とは無関係ということです。ユーザーがログアウトした後も固有の情報がlocalStorageに残り続けてアクセス出来てしまうのは問題です。今回は未読数しかキャッシュしない上にキャッシュする時間も短時間なので利用しましたが、この制限でだいぶ利用範囲が狭まります。。実装可能か検討する際には考慮しておきましょう。最後にlocalStorageはIE 8未満では使えません。pixivではIE 8未満はサポートしない方針なので、APIリクエストによる未読数更新はlocalStorageをサポートしないブラウザでは動かないようにしました(ページをリロードすれば更新される)。
最終的にどうにかなったものの、ダメだった場合は機能自体が成り立たない可能性がありました。非同期な機能が増えると事前の予測が難しくなっていくので、問題が起こった例は共有していくといいと思います。これらの処理が必要になるサービスは滅多にないとは思いますが、何かの折りに参考になればうれしいです。
