Railsで304を返してブラウザのキャッシュを使ってもらう(しかも簡単に)

あしたのチームで開発をやっています。KIMI です。

エンジニアブログといいつつも技術ネタが少ないので、私からは弊社サービスのコンピテンシークラウドで課題としてあった、画像を返す処理が重いという問題をどのようにして解決したか紹介したいと思います。

304 Not Modified

前提として、次のようなことをRails でやっていました。

  • ユーザー画像をアップロードしてAWS S3に保存
  • 同じグループに属する人だけそのユーザー画像を閲覧することができる

しかし、

  • アクセスがあるたびにS3 から画像を取得して返すので重い

という問題がありました。 コードとしては次のようなものです。

class UsersController < ApplicationController
  authorize_resource

  # 権限チェック通った人だけ画像を返す
  def avatar
    uploader = @user.avatar

    send_data(
      uploader.read,
      type: uploader.content_type,
      disposition: 'inline',
    )
  end
end

これをstale?を使うことによって、

  • 304 Not Modified を返してくれる
  • ついでに Last-modifiedとEtag も @user.updated_at を使って返してくれる

ようになります。あと、 Cache-Control も設定するようにしたものが次のコードです。

class UsersController < ApplicationController
  authorize_resource

  def avatar
   # 実際にはファイル更新時間などを使ってetag, last_modified を設定しないと、ファイル以外の更新でも `@user.updated_at` が変わり etag, last_modified の値も変わってしまう
    return unless stale?(@user, template: false)

    # 明示しておかないとsend_data が `Cache-Control: private` にしてしまう
    # ファイルの更新頻度がわかっているのであれば有効期間を伸ばしてあげればよい
    expires_in 0, public: false, must_revalidate: true

    uploader = @user.avatar

    send_data(
      uploader.read,
      type: uploader.content_type,
      disposition: 'inline',
    )
  end
end

これだけで、ブラウザがキャッシュしてくれるようになるはずです。 確認は request spec を使ってこのようにかけます。

require 'rails_helper'

RSpec.describe UsersController, type: :request do
  let(:user) { create(:user, :with_avatar) }

  before { login_as(user) }

  describe 'GET /users/:id/avatar' do
    it "ETag によってレスポンスが変わること" do
      get avatar_user_path(user)
      expect(response).to have_http_status(:ok)
      expect(response.body).to be_present
      expect(response.content_type).to eq 'image/jpeg'
      expect(response.cache_control).to eq max_age: "0", private: true, must_revalidate: true

      etag = response.etag

      get avatar_user_path(user), params: {}, headers: { "If-None-Match": etag }

      expect(response).to have_http_status(:not_modified)
      expect(response.body).to be_blank
      expect(response.content_type).to be_nil
      expect(response.cache_control).to eq max_age: "0", private: true, must_revalidate: true

      # ETag を変えるためにupdated_at を更新する
      Timecop.freeze(1.second.since) do
        user.touch
      end 

      get avatar_user_path(user), params: {}, headers: { "If-None-Match": etag }

      expect(response).to have_http_status(:ok)
      expect(response.body).to be_present
      expect(response.content_type).to eq 'image/jpeg'
      expect(response.cache_control).to eq max_age: "0", private: true, must_revalidate: true
      expect(response.etag).not_to eq etag
    end 

    it "Last-Modified によってレスポンスが変わること" do
      get avatar_user_path(user)
      expect(response).to have_http_status(:ok)
      expect(response.body).to be_present
      expect(response.content_type).to eq 'image/jpeg'
      expect(response.cache_control).to eq max_age: "0", private: true, must_revalidate: true

      last_modified = response.last_modified
      get avatar_user_path(user), params: {}, headers: { "If-Modified-Since": last_modified.rfc2822 }

      expect(response).to have_http_status(:not_modified)
      expect(response.body).to be_blank
      expect(response.content_type).to be_nil
      expect(response.cache_control).to eq max_age: "0", private: true, must_revalidate: true

      # Last-Modified を変えるためにupdated_at を更新する
      Timecop.freeze(1.second.since) do
        user.touch
      end 

      get avatar_user_path(user), params: {}, headers: { "If-Modified-Since": last_modified.rfc2822 }

      expect(response).to have_http_status(:ok)
      expect(response.body).to be_present
      expect(response.content_type).to eq 'image/jpeg'
      expect(response.cache_control).to eq max_age: "0", private: true, must_revalidate: true
      expect(response.last_modified).not_to eq last_modified
    end
  end
end

積極採用

株式会社あしたのチームでは、「人事評価制度」を一緒につくっていく仲間を募集しています。 ご興味のある方は、こちら!