[GraphQL] Apollo ServerのRESTDataSourceとCacheの仕組みについて

Apollo Serverはバックエンドに様々なデータソースをサポートしています。その中にはREST APIも含まれますが、現実的にREST APIをデータソースとした場合、N+1のデータアクセスが頻繁に発生しそうですよね。この課題に対して、Apollo Serverの公式ドキュメントでは以下のように書かれています。

What we’ve found to be far more important when layering GraphQL over REST APIs is having a resource cache that saves data across multiple GraphQL requests, can be shared across multiple GraphQL servers, and has cache management features like expiry and invalidation that leverage standard HTTP cache control headers.

(DeepLによる翻訳)REST API上にGraphQLを重ねる際には、複数のGraphQLリクエストに渡ってデータを保存し、複数のGraphQLサーバーで共有し、標準的なHTTPキャッシュコントロールヘッダを利用して有効期限や無効化などのキャッシュ管理機能を備えたリソースキャッシュを持つことがはるかに重要であることがわかりました。

https://www.apollographql.com/docs/apollo-server/data/data-sources/#what-about-dataloader

つまり、N+1のデータアクセスが起きるのは仕方ないが、なるべくApollo Server側でキャッシュして何度も同じアクセスをすることを減らそうというわけです。DataLoader を利用してN+1をひとまとめにしたバッチリクエストにしたくなりますが、そうするとかえってキャッシュが効きにくくなり逆効果であるということも書かれています。

サンプル実装

では実際に、簡単なサンプル実装で動きを確認してみます。

APIサーバ

データソースとなるREST APIを想定したサーバです。/books/ リソースは Book を返します。BookにはauthorIdが含まれており、authorの詳細は /authors/ リソースから取得します。

package.json

{
  "name": "server",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "fastify": "^3.6.0",
    "ts-node": "^9.0.0",
    "typescript": "^4.0.3"
  },
  "devDependencies": {
    "@types/node": "^14.11.8"
  }
}

index.ts

import fastify from 'fastify'

const server = fastify({
  logger: true
})

const books = [
  {
    title: 'The Awakening',
    authorId: "0",
  },
  {
    title: 'City of Glass',
    authorId: "1",
  },
]

const authors = [
  {
    name: 'Kate Chopin',
  },
  {
    name: 'Paul Auster',
  },
]

server.get('/books', async (req, res) => {
  res.type('application/json').code(200)
  return books
})

server.get('/books/:id', async (req, res) => {
  const id = Number.parseInt(req.params['id'])
  res.type('application/json').code(200)
  return books[id]
})

server.get('/authors/:id', async (req, res) => {
  const id = Number.parseInt(req.params['id'])
  res.type('application/json').code(200)
  return authors[id]
})

server.listen(3000, (err, address) => {
  if (err) {
    console.error(err)
    process.exit(1)
  }
  console.log(`Server listening at ${address}`)
})

ts-nodeでAPIサーバを起動します。

$ yarn ts-node index.ts
{"level":30,"time":1602515320892,"pid":58581,"hostname":"HL00494.local","msg":"Server listening at http://127.0.0.1:3000"}

GraphQLサーバ

スキーマ定義として、Book type の中に Auther type あり、1回のリクエストで取得しようとすると、 /books/ リソースにリクエストしたあと、Book の件数分の回数を /author/ リソースにリクエストすることになります。

データソースとして RESTDataSource を利用します。RESTDataSource は外部のAPIとデータのやり取りをするときに利用するデータソースです。HTTPクライアントとしての機能のほか、Apollo Serverのキャッシュ機構とも統合されています。

package.json

{
  "name": "graphql-server-example",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "apollo-datasource-rest": "^0.9.4",
    "apollo-server": "^2.18.2",
    "apollo-server-cache-redis": "^1.2.2",
    "graphql": "^15.3.0"
  },
  "devDependencies": {
    "@types/graphql": "14.5.0",
    "ts-node": "9.0.0",
    "typescript": "^4.0.3"
  }
}

tsconfig.json ※有効化されている設定のみ抜粋

targetをES2016以上にする必要があります。

{
  "compilerOptions": {
    /* Basic Options */
    "target": "ES2019",                       /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */

    /* Strict Type-Checking Options */
    "strict": false,                           /* Enable all strict type-checking options. */

    /* Module Resolution Options */
    "esModuleInterop": true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */

    /* Advanced Options */
    "skipLibCheck": true,                     /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true  /* Disallow inconsistently-cased references to the same file. */
  }
}

index.ts

import { ApolloServer, gql } from 'apollo-server'
import { RESTDataSource } from 'apollo-datasource-rest'

const typeDefs = gql`
  type Book {
    title: String
    author: Author
  }

  type Author {
    name: String
  }

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

class LibraryAPI extends RESTDataSource {
  constructor() {
    super()
    this.baseURL = 'http://localhost:3000'
  }

  async getBooks() {
    const data = await this.get('/books')
    return data
  }

  async getBook(id: string) {
    const data = await this.get(`/books/${id}`)
    return data
  }

  async getAuthor(id: string) {
    const data = await this.get(`/authors/${id}`)
    return data
  }
}

const resolvers = {
  Query: {
    book: async (parent, args, { dataSources }) => {
      return dataSources.libraryAPI.getBook(args['id'])
    },
    books: async (parent, args, { dataSources }) => {
      return await dataSources.libraryAPI.getBooks()
    },
  },
  Book: {
    author: async (parent, args, { dataSources }) => {
      return dataSources.libraryAPI.getAuthor(parent['authorId'])
    }
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => {
    return {
      libraryAPI: new LibraryAPI()
    }
  }
})

server.listen({port: process.env.PORT || 4000}).then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`)
})

ts-nodeでGraphQLサーバを起動します。

$ yarn ts-node index.ts
🚀  Server ready at http://localhost:4000/

GraphiQLからQueryをリクエストすると、データが取得できることが確認できます。

{
  books{
    title
    author {
      name
    }
  }
}
{
  "data": {
    "books": [
      {
        "title": "The Awakening",
        "author": {
          "name": "Kate Chopin"
        }
      },
      {
        "title": "City of Glass",
        "author": {
          "name": "Paul Auster"
        }
      }
    ]
  }
}

APIサーバのリクエストログを確認すると、以下のようになります。今はキャッシュが効いていないので、GraphQLサーバのリクエストのたびに、N+1回のリクエストが発生します。

{"level":30,"time":1602549094025,"pid":91221,"hostname":"HL00494.local","reqId":1,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64276},"msg":"incoming request"}
{"level":30,"time":1602549094036,"pid":91221,"hostname":"HL00494.local","reqId":2,"req":{"method":"GET","url":"/authors/1","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64277},"msg":"incoming request"}
{"level":30,"time":1602549094036,"pid":91221,"hostname":"HL00494.local","reqId":3,"req":{"method":"GET","url":"/authors/0","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64278},"msg":"incoming request"}

キャッシュ機能を追加する

RESTDataSource はAPIレスポンスの Cache-Control ヘッダーに基づきレスポンスをキャッシュします。

APIサーバ側の設定で、レスポンスに Cache-Control ヘッダーを追加すると、30秒間はAPIサーバにリクエストせず、GraphQLサーバのローカルキャッシュを使うことが確認できます。

APIサーバのindex.ts

server.get('/authors/:id', async (req, res) => {
  const id = Number.parseInt(req.params['id'])
  res.type('application/json').code(200)
  res.header('Cache-Control', 'public, max-age=30') // Cache-Controlを追加
  return authors[id]
})

APIサーバのアクセスログ

# 1回目のリクエスト
{"level":30,"time":1602549558552,"pid":96233,"hostname":"HL00494.local","reqId":1,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64425},"msg":"incoming request"}
{"level":30,"time":1602549558564,"pid":96233,"hostname":"HL00494.local","reqId":2,"req":{"method":"GET","url":"/authors/0","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64426},"msg":"incoming request"}
{"level":30,"time":1602549558564,"pid":96233,"hostname":"HL00494.local","reqId":3,"req":{"method":"GET","url":"/authors/1","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64427},"msg":"incoming request"}

# 2回目のリクエスト
{"level":30,"time":1602549564983,"pid":96233,"hostname":"HL00494.local","reqId":4,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64430},"msg":"incoming request"}

また、APIサーバ側のレスポンスにCache-Control ヘッダーがない場合や、GraphQLサーバ側で時間をコントロールしたい場合は、RESTDataSource側でキャッシュ時間を設定することもできます。

GraphQLサーバのindex.ts

async getAuthor(id: string) {
    const data = await this.get(`/authors/${id}`, null, {
      cacheOptions: { // cacheOptionsを追加
        ttl: 30
      }
    })
    return data
  }

APIサーバのアクセスログ

# 1回目のリクエスト
{"level":30,"time":1602550106761,"pid":2441,"hostname":"HL00494.local","reqId":1,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64624},"msg":"incoming request"}
{"level":30,"time":1602550106778,"pid":2441,"hostname":"HL00494.local","reqId":2,"req":{"method":"GET","url":"/authors/1","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64625},"msg":"incoming request"}
{"level":30,"time":1602550106779,"pid":2441,"hostname":"HL00494.local","reqId":3,"req":{"method":"GET","url":"/authors/0","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64626},"msg":"incoming request"}

# 2回目のリクエスト
{"level":30,"time":1602550111170,"pid":2441,"hostname":"HL00494.local","reqId":4,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":64630},"msg":"incoming request"}

キャッシュのデータストアを設定する

デフォルトではApollo ServerはIn-Memoryをキャッシュのデータストアとして利用しますが、GraphQLサーバが複数台ある場合はキャッシュがそれぞれのサーバ内にあるので有効に利用できません。そのため本番環境ではKVSを利用することが推奨されます。公式にサポートしているデータストアとしてはmemcachedやRedisがあります。

今回はRedisをデータストアとして設定してみました。

docker-compose.yml

version: '3'
services:
  redis:
    image: redis:latest
    ports:
      - 6379:6379

dockerでredisサーバを起動します。

$ docker-compose up -d
$ docker-compose ps
Name                           Command               State           Ports
------------------------------------------------------------------------------------------------
graphql-server-example_redis_1   docker-entrypoint.sh redis ...   Up      0.0.0.0:6379->6379/tcp

Apollo Serverにキャッシュストレージの設定を追加します。

GraphQLサーバのindex.ts

import { RedisCache } from 'apollo-server-cache-redis'

const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => {
    return {
      libraryAPI: new LibraryAPI()
    }
  },
  cache: new RedisCache({ // cache設定を追加
    host: 'localhost',
    port: '6379'
  }),
})

そしてGraphQLサーバを、ポート4000と4001の2台起動します。最初にポート4000のGraphQLサーバでQueryをリクエストしたあと、ポート4001の方からもQueryをリクエストすると、4001の方はキャッシュからデータを取り出していることが分かります。

APIサーバのアクセスログ

# ポート4000のGraphQLサーバからのリクエスト
{"level":30,"time":1602552767596,"pid":29538,"hostname":"HL00494.local","reqId":1,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":49518},"msg":"incoming request"}
{"level":30,"time":1602552767611,"pid":29538,"hostname":"HL00494.local","reqId":2,"req":{"method":"GET","url":"/authors/1","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":49519},"msg":"incoming request"}
{"level":30,"time":1602552767611,"pid":29538,"hostname":"HL00494.local","reqId":3,"req":{"method":"GET","url":"/authors/0","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":49520},"msg":"incoming request"}

# ポート4001のGraphQLサーバからのリクエスト
{"level":30,"time":1602552776545,"pid":29538,"hostname":"HL00494.local","reqId":4,"req":{"method":"GET","url":"/books","hostname":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":49527},"msg":"incoming request"}

おわりに

キャッシュをうまく利用することで、既存のAPIサーバに必要以上に負荷をかけることなくをGraphQLサーバに取り込むことができそう、ということが確認できました。

参考