こんにちは、開発本部の楊です。メドレーの社内勉強会「TechLunch」で、前回は、Reactの基本を紹介しましたが、今回はHTTP Cacheで、医療介護求人サイト「ジョブメドレー」のスピード改善ができないか検討した話について、共有しました。
なぜHTTP Cacheについて話すことにしたのか
ジョブメドレーには、医療機関や保育園、介護施設などさまざまな事業所の求人が掲載されています。現在、14万を超える事業部の求人が掲載されており、かつ事業所側で求人原稿を修正することもできるため、求職者側が閲覧するスピードについては、いつも意識して改善に取り組んでいます。
その試行錯誤の中で、HTTP Cacheを使って改善する方法について、実現性など含めて検証することにしました。
今回は、あるページをモデルケースに試してみました。このページは、PCは平均250ms、モバイルは180msになっています。
ユーザが該当のページを見たときに「内容に更新がないときはブラウザ側のキャッシュを利用してスピードを最適化する」「ページが更新されていたら、最新の内容をすぐユーザへ反映する」という要件でスピード改善されることを目指すことにしました。
HTTP Cacheとは?
Google Developersでは以下のように説明されています。
ネットワーク経由で情報を取得するには時間もコストもかかります。レスポンスが大きいと、クライアントとサーバ間のラウンドトリップを何度も繰り返す必要があるため、レスポンスが利用可能となってブラウザで処理できるようになるまで時間がかかります。さらに、ユーザ側ではデータの通信コストが発生します。そのため、前に取得したリソースをキャッシュに保存して再使用できることは、パフォーマンスを最適化する上で非常に重要です。
この機能はほぼ全てのブラウザに対応しており、HTTPヘッダーでCacheControl
、ETAG
、Last-Modified
などを利用して、リソース更新するタイミングなどを細かくコントロール可能です。
RailsでのHTTP Cache
expire_inでキャッシュ
1時間キャッシュしたい場合は、コントローラにコードを一行書けば大丈夫です。
expires_in(1.hour)
これで問題なく1時間キャッシュされるのですが、「ページが編集されたら即時にユーザへ反映する」という要件を満たしてはいません。
ETAGを利用
では「ページが更新されたら最新の内容をすぐユーザへ反映して、更新がない時はブラウザ側のキャッシュを利用してスピードを最適化する」を行いたい場合はどうすればよいでしょうか。
ここではETAG
を利用しようと思います。
ETAG
はページの内容によって、ユニークな文字列を作成して、ブラウザ側でページの更新あるかどうかを判定するものです。
ETAGはどう作成されているか
RailsのデフォルトではHTTP Cacheを有効になっていて、ETAG
を自動的に作成しています。
header['ETag'] = Digest::MD5.hexdigest(body)
上のコードのようなイメージで、レスポンスBodyからETAG
を作成します。
つまり、サーバから返されるHTMLソースの内容が毎回同じであれば、ブラウザは前回キャッシュした内容を読み込むようになります。
サーバレスポンスタイムの短縮
今回の対象ページの機能としてサーバ側では主に二つの部分で時間がかかります。
- DB、Redisなどで画面表示が必要なデータ取得、ロジック処理
- クライアントに返すHTMLのレンダリング
対象ページは「HTMLのレンダリング」する時間が長かったので、それを改善できれば、サーバレスポンスタイムを一気に短くできます。
画面に表示する必要なデータに変換がなければ、HTMLのレンダリングをせずにレスポンスを返す機能がないかをさらに調べました。
fresh_whenを使う
名前の通り、いつ画面更新するかをコントロールするメソッドです。
データベース中の該当データが更新されたら、新しいETAG
を作成するコードは以下のようになります。
fresh_when(etag: [@job_offer, @job_offer.facility])
modelからETAGをどう作成するか
modelのcache_keyからETAGを作ります。
該当ページで使用するjob_offerモデルのcache_keyは"job_offer/5-20071224150000"
のような感じになります。
modelのidとupdated_atの組み合わせでユニークなcache_keyを作成しています。
def cache_key(*timestamp_names) case when new_record? "#{model_name.cache_key}/new" when timestamp_names.any? timestamp = max_updated_column_timestamp(timestamp_names) timestamp = timestamp.utc.to_s(cache_timestamp_format) "#{model_name.cache_key}/#{id}-#{timestamp}" when timestamp = max_updated_column_timestamp timestamp = timestamp.utc.to_s(cache_timestamp_format) "#{model_name.cache_key}/#{id}-#{timestamp}" else "#{model_name.cache_key}/#{id}" end end
自前の作成したクラスからETAGをどう作成するか
modelだけではなく、自前で作成したクラスでデータ管理をしてるところもあります。 そちらでの実現方法も試してみました。
そこでETAG
を作るコードがRailsでどうなっているか追ってみました。
def retrieve_cache_key(key) case when key.respond_to?(:cache_key) then key.cache_key when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) else key.to_param end.to_s end
このように、自作クラスにcache_key
のメソッドを定義したら、その結果からETAGが作成されるようになっています。このメソッドを使って、ユニークな値を返せば大丈夫です。
ここまで調査したことを踏まえて、該当ページをHTTP Cacheで実装をしてみました。以下が該当の疑似コードになります。
class JobOfferBrowsingHistory def cache_key # return job offer ids end end
fresh_whenに渡す
@user_history = JobOfferBrowsingHistory.new fresh_when(etag: [@job_offer, @job_offer.facility, @user_history])
テスト
開発環境で該当ページを2回ロードしました。 1回目は1200ms、キャッシュが効いた2回目は130msになりました。すごく早いです! もちろん、ページ内のデータが更新されたら、最新の内容がページに反映されるようにもなっています。
これで「ページのデータが更新されたら、最新の内容をすぐユーザへ反映する」「更新がなければHTTP Cacheを返す」という状態が実現しました。
課題
- 実装ミスで、更新が必要なのに、更新されない問題が起こりえる
- 例えば、画面に新しいmodelを追加したが、
fresh_when
に渡すのを忘れたとか
- 例えば、画面に新しいmodelを追加したが、
- 問題なく更新されることを保証する仕組みの実装
まとめ
HTTP Cacheを利用して、ジョブメドレーのスピード改善を検討してみた調査過程を紹介しました。
expire_in
、ETAG
の作成方法など色々調べて、最終的にfresh_when
で実現できましたが、運用していく上での課題については、引き続き検討して行きたいと思います。