🐘

【Rails】stale? で同じリクエストでリソースが未更新のときは304を返す

2024/05/04に公開

概要

この記事では Rails の stale? メソッドを使って、前回リクエストと同じでリソースが更新されていない場合に status code 304 Not Modifiedを返す方法について解説します。

また、前提知識である以下についても解説していきます。

  • status code 304ってなに?
  • ETag ってなに?
  • If-None-Match ってなに?

stale? を使うことで、不要な処理を行うことなく、アプリケーションのパフォーマンスを向上させることができます。

先にまとめ

今回書いた内容をまとめると以下です。

  • status code 304 ... クライアントが持っているキャッシュが最新であることを示す。
  • ETag ... 「リソースのバージョン」を一意に識別するために使用される識別子。
  • If-None-Match ... リクエストヘッダーの項目の1つで、以前に取得した ETag を送信する。
  • stale? メソッド ... リクエストで送られてきたリソースの ETag が現在のリソースの ETag と一致するかどうかを確認するメソッド。

最終的なコード

class Api::V1::ArticlesController < ApplicationController
  def index
    last_updated_at = Article.maximum(:updated_at)
    condition = params[:condition]
    etag = "#{last_updated_at}|#{condition}"
    return unless stale?(etag: etag)

    articles = Article.where(title: condition)
    render json: { data: articles }
  end
end

status code 304 とは

status code 304 Not Modified は、クライアントが持っているキャッシュが最新であることを示すために使用されます。

この status code によって、サーバーがリソース(HTML, JSON, 画像など)本体を送信することなく、クライアントにキャッシュの再利用を許可します。

ETag と If-None-Match について

ETag

ETag(エンティティタグ)は、「リソースのバージョン」を一意に識別するために使用される識別子です。
試しに Postman を使って、以下のコードの API にリクエストしてみます。

class Api::V1::ArticlesController < ApplicationController
  def index
    articles = Article.all
    render json: { data: articles }
  end
end

エンドポイントはこちらです。

GET /api/v1/articles

すると、レスポンスヘッダーに ETag という項目が存在し、ハッシュ値入っている事がわかります。

このハッシュ値がリソースのバージョンを表しています。

If-None-Match

If-None-Match はリクエストヘッダーの項目の1つです。
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/If-None-Match
クライアント側はこのヘッダーを使用して、以前に取得した ETag を送信します。
サーバー側はそれを現在の ETag と比較して、リソースが変更されたかどうかを判定し、変更されていれば新しいリソースを提供し、されていなければ status code 304 Not Modified だけを返すことで、不要な処理(前回のリソースを取得するなど)を避けることができます。

Rails で ETag のバージョンを判定する方法

さて、ここからが本題で、Railsでは、stale? メソッドを使ってリクエストで送られてきたリソースの ETag が現在のリソースの ETag と一致するかどうかを確認できます。

例えば以下のコードの場合、リクエストヘッダーの If-None-Match で送られてきた ETag と、現在のリソースの ETag("a")を比較します。

class Api::V1::ArticlesController < ApplicationController
  def index
    puts "1"
    return unless stale?(etag: "a")
    puts "2"

    articles = Article.all
    render json: { data: articles }
  end
end

もしも違った場合(If-None-Match で送られてきた ETag が古い場合)、
return unless stale?("a") 以降の処理が行われるため、2が出力されます。

では、Postman を使って、今度は If-None-Match に ETag を指定してリクエストしてみます。

ログを確認すると、現在のリソースと違うため、2 が出力され、それ以降の処理も行われています。

Started GET "/api/v1/articles" for 172.18.0.1 at 2024-05-04 04:22:21 +0000
Processing by Api::V1::ArticlesController#index as */*
1
2
  Article Load (3.1ms)  SELECT `articles`.* FROM `articles`
  ↳ app/controllers/api/v1/articles_controller.rb:10:in `index'
Completed 200 OK in 96ms (Views: 73.2ms | ActiveRecord: 5.2ms | Allocations: 4370)

さらに再度レスポンスヘッダーを確認すると、ETag に新しいハッシュ値が入っています。
これが現在のリソースのハッシュ値になります。

次にこのハッシュ値を If-None-Match に入れてリクエストしてみます。

すると、 リクエストされた ETag が最新と判定され、2 が出力されず、それ以降の処理が行われずに、304 の status code が返ることが分かります。

Started GET "/api/v1/articles" for 172.18.0.1 at 2024-05-04 04:31:25 +0000
Processing by Api::V1::ArticlesController#index as */*
1
Completed 304 Not Modified in 3ms (ActiveRecord: 0.0ms | Allocations: 168)

前回リクエストと、リソースの更新状況から判定する

上記を踏まえて、以下のコードを見てみます。

class Api::V1::ArticlesController < ApplicationController
  def index
    last_updated_at = Article.maximum(:updated_at)
    condition = params[:condition]
    etag = "#{last_updated_at}|#{condition}"
    return unless stale?(etag: etag)

    articles = Article.where(title: condition)
    render json: { data: articles }
  end
end

こちらのコードでは ETag を、「全 Article の最終更新日時と、クエリパラメータの condition」 から生成しています。

これで、Article が更新されるか、クエリパラメータの condition が変更されるかしない限りは、サーバは 304を返すことになり、2回目以降のリクエストでは無駄な処理を回避することができます。

具体的には、Article が更新されずに、以下のリクエストのときはずっと304が返ります。

/api/v1/articles?condition=condition1

ちなみに stale? メソッドには last_modified を引数に取ることができ、
リソースの更新日時を直接指定することもできます。

class Api::V1::ArticlesController < ApplicationController
  def index
    last_updated_at = Article.maximum(:updated_at)
    condition = params[:condition]
    return unless stale?(etag: condition, last_modified: last_updated_at)

    articles = Article.all
    render json: { data: articles }
  end
end

Discussion