[GraphQL] Apollo ServerのCache-Controlについて

GraphQLはエンドポイントが1つだけになるため、従来のHTTPキャッシュが使いにくいという側面があります。Apollo Serverはこれを解決するために、独自のキャッシュ機能を備えています。このキャッシュ機能を使うと、レスポンスの内容に応じて適切なCache-Controlヘッダーが追加されます。この情報に基づいて、CDNやBrowserでキャッシュを持つことが可能になります。

Cache-Controlヘッダーとは

Cache-ControlヘッダーはHTTP/1.1仕様の一部として定義されたもので、リクエストとレスポンスの両方でキャッシュのためのディレクティブ (指示) が格納されています。Apollo Serverが関与するのはレスポンスのディレクティブのみです。Cache-Controlについて詳しく知りたい方はこちらの記事が詳しいです。

https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=ja#cache-control

Apollo Serverが返すCache-Controlヘッダーの仕組み

Apollo ServerではCache-Controlヘッダーの max-agescope(public or private) ディレクティブを設定します。設定は静的な定義と動的な定義が可能で、最小単位ではフィールド単位で設定することができます。Apollo Server はレスポンス全体を組み立てたあと、最小のmax-ageと最小のscopeをレスポンスのCache-Controlヘッダーに設定します。以下に具体例を示します。

静的制御

静的にCache-Contolの値を設定するには、 @cacheControl ディレクティブを使います。これはスキーマ定義にディレクティブとして直接記述することができます。以下のスキーマを例に、Cache-Controlの値がどうなるか見てみます。

type Book @cacheControl(maxAge: 3600) {
  title: String
  author: Author
  reviews: [Review]
  favorite: Boolean @cacheControl(scope: PRIVATE)
}

type Author @cacheControl(maxAge: 3600) {
  name: String
}

type Review @cacheControl(maxAge: 300) {
  rate: Int
}

type Query {
  book(id: ID): Book
  books: [Book]
}

次のQueryでは、 cache-control: max-age=3600, public となります。BookおよびAuthorのmaxAgeが3600であり、privateのフィールドは参照していないためスコープはpublicです。

{
  books{
    title
    author{
      name
    }
  }
}

次のQueryでは、 cache-control: max-age=300, public となります。上のQueryのフィールドに加え、reviewsフィールドを参照しています。ReviewはmaxAgeが300なので、Cache-Controlのmax-ageも300が設定されました。

{
  books{
    title
    author{
      name
    }
    reviews{
      rate
    }
  }
}

次のQueryでは、 cache-control: max-age=300, private となります。上のQueryに加え、スコープがprivateであるfavoriteフィールドを参照しているため、Cache-Controlのスコープはprivateとなります。

{
  books{
    title
    author{
      name
    }
    reviews{
      rate
    }
    favorite
  }
}

このようにレスポンス全体のフィールドで最も最小のmaxAgeとscopeが設定されます。 フィールドはtypeの@cacheControl を継承しますが、type自体に @cacheControl が設定されていない場合は @cacheControl(maxAge: 0) と同じ扱いになることに注意してください。

また、全体にデフォルト設定をすることもできます。

const server = new ApolloServer({
  typeDefs,
  resolvers,
  ...
  cacheControl: {
    defaultMaxAge: 86400 # 全体のデフォルト設定
  }
})

動的制御

リクエストごとに動的にCacheControlの設定を行うこともできます。例えばBookのマスタ更新が毎日0:00なので翌日の0:00までキャッシュさせたいとします。(あまり現実的な例ではないですが。)

スキーマ定義ではmaxAgeを86400(1日)とします。

type Book {
  title: String
  author: Author
  reviews: [Review]
  favorite: Boolean @cacheControl(scope: PRIVATE)
}

type Author @cacheControl(maxAge: 86400) {
  name: String
}

type Review @cacheControl(maxAge: 86400) {
  rate: Int
}

type Query {
  book(id: ID): Book
  books: [Book]
}

リゾルバでは、翌日の0:00 から現在時刻を引いた値をCacheControlのmaxAgeとして動的に設定します。

import 'apollo-cache-control';

const resolvers = {
  Query: {
    books: async (parent, args, { dataSources }, info) => {
      const maxAge = Math.floor((Date.parse('2020-10-15 00:00:00') - Date.now()) / 1000)
      info.cacheControl.setCacheHint({ maxAge })
      return await dataSources.libraryAPI.getBooks()
    },
  }
}

こうすることで、リクエストの度にcache-controlヘッダーのmax-ageディレクティブの値が変わることが確認できます。

{
  books{
    title
  }
}
cache-control: max-age=37472, public
cache-control: max-age=37470, public
...

レスポンス全体をキャッシュする場合

Cache-ControlヘッダーはCDNやBrowserにキャッシュさせる設定でしたが、 apollo-server-plugin-response-cache プラグインを使うことでCache-Controlの値に応じてApollo Serverにレスポンス全体をキャッシュすることも可能です。scopeがprivateになるようなレスポンスは、ユーザごとにキャッシュを管理することも可能です。このプラグインにより、クライアント側でキャッシュが使えない場合でもApollo Server側で強制的にキャッシュを返すことができます。

既にCache-Controlヘッダーの値を返すようになっていれば、設定はプラグインを追加するだけです。

import responseCachePlugin from 'apollo-server-plugin-response-cache'

const server = new ApolloServer({
  typeDefs,
  resolvers,
  ...
  plugins: [responseCachePlugin()], // プラグインを追加
})

実際にリクエストしてGraphQLサーバのログを見てみると、キャッシュがある場合はリゾルバが実行されていないことが分かります。

# 初回アクセス
Request started! Query:
{
  books {
    title
    author {
      name
    }
  }
}
Parsing started!
Validation started!
Call getBooks() { date: '1602653007768' }
Call getAuthor() { date: '1602653007769' }
Call getAuthor() { date: '1602653007769' }

# キャッシュが有効な場合
Request started! Query:
{
  books {
    title
    author {
      name
    }
  }
}

# キャッシュが切れた場合
Request started! Query:
{
  books {
    title
    author {
      name
    }
  }
}
Call getBooks() { date: '1602653020733' }
Call getAuthor() { date: '1602653020734' }
Call getAuthor() { date: '1602653020734' }

おわりに

Apollo Serverでは、リクエストの内容に応じて適切にCache-Controlヘッダーの値を制御できることが分かりました。scopeディレクティブがprivateになってしまうようなQueryはCDNでキャッシュできないので、CDNキャッシュをうまく活用することを考えるとpublicなQueryとprivateのQueryを分けてリクエストすると良いかもしれません。

参考