シンプルさとパフォーマンスを両立した API 設計と実装の一例

こんにちは、エンジニアの村田です。今回はシンプルさとパフォーマンスを両立した API を作るためにはどうすればよいのかについて述べていきます。

背景

いままで API サーバーを作ってきて、シンプルな API にすればパフォーマンスが犠牲に、パフォーマンスを優先すれば実装が複雑になって保守が大変になるということを経験してきました。具体的には、

  1. 関連リソースの Entity が何重にもネストしていたり、同じリソースに一覧用、詳細用などの Entity があり実装、保守が大変
  2. 1. の問題を解消するためにシンプルな API にする
  3. 2. では N+1 の問題がありパフォーマンスが悪い
  4. 1. 2. の問題を解消するためにリソースの Entity はシンプルにしつつ、パラメーターで関連リソースを指定すると関連リソースの Entity を埋め込んで返すようにする
  5. 4. だと関連リソースの権限管理やページネーションが複雑になり大変
  6. 5. までの問題を解消したい

といった感じです。というわけで、これらの問題を解決するためには API をどのように設計するとうまくいくのかについて述べます。

TL;DR

まず結論から述べると次のように設計すると大体の場合でよさそうです。

  1. リソースの Entity に関連リソースの Entity は埋め込まない
  2. クライアントはリクエストを即座に送らず一定期間(もしくは一定量)バッファリングしてまとめて送る
  3. サーバー側で各リクエストを即座に評価しないで遅延評価する

設計

上記で述べた方針は、Virtual DOM のように最速ではないがシンプルさとパフォーマンスを両立できることを目的としています。以下、そのために具体的にどうすべきかについて述べます。

1. リソースの Entity に関連リソースの Entity は埋め込まない

これは、ページネーションや権限管理、フロントでの Entity 管理などが複雑になりすぎないようにするためです。関連リソースの Entity を含めるようにすると、その関連リソースの権限管理はどうするのか、 ページングは、 N+1 対策は、 と、様々なことを考慮しなければなりません。また、レコードの取得やインスタンスの生成が膨大な数になり、レスポンスが遅くなりがちです。これらを避けるために、関連リソースは Entity ではなく ID で持つようにします。

🆖 関連リソースの Entity をネストする

まず関連リソースの Entity を埋め込む場合。よくあるパターンですが、開発が進むとどんどん複雑になっていきがちです。

{
  "id": 1,
  "title": "hoge",
  "author": {
    "id": 5,
    "name": "author 5",
    "profile": "author 5 profile"
  }
}

🆗 関連リソースへの ID を持つ

次に関連リソースへの ID のみ持つ場合。シンプルになりますが、通常このままだと API リクエストや DB アクセスで N+1 が多発して実用に耐えません。(が、今回は後述の実装によりこちらで OK です。)

{
  "id": 1,
  "title": "hoge",
  "authorId": 5
}

2. クライアントはリクエストを即座に送らず一定期間(もしくは一定量)ごとにバッファリングしてまとめて送る

これは、「関連リソースへの ID を持つ」でシンプルさを保ったままパフォーマンスを確保するために必要なことです。

🆖 API リクエストを即座にサーバーに送る

まず、API リクエストを即座にサーバーに送信する場合。普通に作るとこのようになりますが、N+1 の問題が起きがちです。そのため、これを避けるために関連リソースの Entity を埋め込んで解決しがちです(その結果どんどん複雑になっていく)。

🆗 API リクエストをバッファリングして一定数ごとにサーバーに送る

次に、一定期間(たとえば 10ms)が過ぎるか一定量が溜まるか(例えば 10 リクエスト)までバッファリングしておき、複数のリクエストをまとめてサーバーに送信する場合。このようにすることで、API リクエストの N+1 を大幅に減らせます。

3. サーバー側で各リクエストを即座に評価しないで遅延評価する

2. で API リクエストの N+1 は解消されましたが、送信された複数のリクエストをそれぞれ順次評価していたのでは、今度は DB アクセスの際に N+1 が発生してしまいます。リクエストを即座に評価せず遅延評価するのはこれを解消するために必要となります。

🆖 リクエストを即座に評価する

まず、各リクエストを順次評価する場合。普通に作るとこのようになりますが、DB への N+1 が発生してしまいます。

🆗 リクエストを遅延評価する

次に、リクエストを遅延評価する場合。リクエストの評価を即座に行わずに、どのようなデータを取得すべきかの情報をキューに入れておき、具体的なレスポンスの代わりに、遅延評価をするためのいわゆる Promise を返すようにします。 そして、すべてのリクエストを舐めた後に、Promise を評価します。このときキューからどのようなデータを取得すべきかの情報を取得し、それを元に DB からデータを取得します。後はこのデータを元に各リクエストのレスポンスを生成します。これによって DB への N+1 を回避できます。

実装

これらの設計に基づいて実装するにあたって、API を GraphQL で実装して、クライアントに ApolloClient を用いると比較的容易なので、今回はこれらを用いて実装してみます。具体的には以下を使用します。

  • Apollo Client
    • GraphQL クライアント
    • クライアントという名前が付いているが、axios のようなものではなく、Redux などの状態管理フレームワークの位置付けに近い
  • apollo-cache-inmemory
    • Apollo Client のサブモジュール。API レスポンスをメモリにキャッシュする
    • Apollo Client の位置付けからすると、Redux でいう Store に相当する
  • apollo-link-batch-http
    • Apollo Client のサブモジュール。リクエストのバッファリングを行い、複数のリクエストをまとめた batch request を生成、サーバーに送信してくれる
    • これ単体でも使用できるので Redux などの場合はこれを直接使用する
  • React
    • アプリケーションの状態から仮想的な DOM を生成し、レンダリングに前回からの差分を計算して用いることで、シンプルさを保ったままパフォーマンスのよい View を実現するための component ベースのライブラリ
  • react-apollo
    • Apollo Client を React から使いやすくするための component ライブラリ
  • graphql-ruby
    • GraphQL サーバーの Ruby 実装。apollo-link-batch-http が生成する batch request をサポートする
  • graphql-batch
    • graphql-ruby で使用する。データの取得部分を遅延評価して最後にまとめて DB から取得するのに使用する

Apollo Client については以下が詳しいです。

世のフロントエンドエンジニアにApollo Clientを布教したい - Qiita

サーバーの実装

GraphQL サーバーの実装を行います。上記でいう 1. リソースの Entity に関連リソースの Entity は埋め込まない3. サーバー側で各リクエストを即座に評価しないで遅延評価する に相当します。

GraphQL 用 Controller と Schema の実装

GraphQL についての詳細な説明は省きますが、GraphQL で API を作る場合には各種 API や型などを Schema というものに定義して、コントローラーからはその Schema を呼び出す形となります。すべての API は POST /graphql といった単一のエンドポイントに集約されます。

class GraphqlController < ApplicationController
  def execute
    result = MySchema.multiplex(graphql_params)
    render json: result
  end

  ...
end

class MySchema < GraphQL::Schema
  mutation Types::MutationType
  query Types::QueryType
  use GraphQL::Batch
end

ユーザーを取得する Query の実装

GraphQL API では、副作用のないもの(REST でいう GET)は Query と呼び、副作用のあるもの(REST でいう POST など)は Mutation と呼びます。今回はユーザーを取得する Query とリソースの Entity を表す型を実装します。

class Types::QueryType < Types::BaseObject
  field :entry, Types::EntryType, null: false do
    argument :id, String, required: true
  end

  field :user, Types::UserType, null: false do
    argument :id, String, required: true
  end

  def entry(id:)
    Loaders::RecordLoader.for(Entry).load(id)
  end

  def user(id:)
    Loaders::RecordLoader.for(User).load(id)
  end
end

class Types::EntryType < Types::BaseObject
  field :id, String, null: false
  field :title, String, null: false
  field :authorId, String, null: false
end

class Types::UserType < Types::BaseObject
  field :id, String, null: false
  field :name, String, null: false
end

遅延評価の Loader の実装

データの取得部分を遅延評価させるための Loader を実装します。 実装した Loader は Loaders::RecordLoader.for(User).load(id) のように使用します。for() に渡す値はキューのキーで load() に渡す値はパラメーターとなります。今回はキューのキーをモデルクラスとし、パラメーターを ID としました。 for() に渡された値は Loader クラスの initializer に渡されます。また、perform() はリクエストが評価されるときに呼ばれます。今回の実装では id の配列が渡されるので、それを元にレコードを取得します。取得したレコードは fulfill(param, value) で登録します。param には load で指定した値(今回は ID)を、 value にはレコードを指定します。

class Loaders::RecordLoader < GraphQL::Batch::Loader
  def initialize(model)
    @model = model
  end

  def perform(ids)
    @model.where(id: ids).each do |record|
      fulfill record.id, record
    end

    ids.each do |id|
      fulfill id, Errors::NotFoundError.new("id = #{id}") unless fulfilled?(id)
    end
  end
end

クライアントの実装

上記で実装した API サーバーのクライアントです。React と ApolloClient を使って実装します。2. クライアントはリクエストを即座に送らず一定期(もしくは一定量)バッファリングしてまとめて送る に相当します。

エントリーポイントの実装と GraphQL クライアントの設定

SPA のエントリーポイントと GraphQL クライアントの設定を行います。

import * as React from 'react'
import { render } from 'react-dom'
import { ApolloProvider } from 'react-apollo';
import AppComponent from './components/AppComponent'

import { ApolloClient } from 'apollo-client'
import { BatchHttpwLink } from 'apollo-link-batch-http'
import { InMemoryCache } from 'apollo-cache-inmemory'

const link = new BatchHttpLink({ uri: '/graphql', batchMax: 50 })
const cache = new InMemoryCache()
const client = new ApolloClient({ link, cache })

const RootComponent = (
  <ApolloProvider client={ client }>
    <AppComponent />
  </ApolloProvider>
)

render(RootComponent, document.getElementById('app'))

GraphQL クエリと Query component の実装

component の実装と ReactApollo によるデータの取得部分の実装を行います。 GraphQL API のクエリを実装して、そこから Query component を生成します。この Query component は、レンダリングされる際に GraphQL クエリをサーバーに送信し(キャッシュがあればクエリは送信せずにキャッシュを返す)、レスポンスが返ってきたら子 component に prop として渡すという便利なものです。

import * as React from 'react'
import gql from 'graphql-tag'
import { Query } from "react-apollo";

const GetEntry = gql`
  query GetEntry($id: String!) {
    entry(id: $id) {
      id
      title
      authorId
    }
  }
`
class GetEntryQuery extends Query<any, any> {}

const GetUser = gql`
  query GetUser($id: String!) {
    user(id: $id) {
      id
      name
    }
  }
`
class GetUserQuery extends Query<any, any> {}

View component の実装

上記で実装した Query component を使って View component を実装します。Query component が子 component に渡す loading prop が true になるのでインジケーターの component を返して、レスポンスが返ってきたら loading prop が false になりかつ data prop にレスポンスが返ってくるので、それを元に View component を生成します。 キャッシュ機構があるとはいえ、レンダリングされる度に GraphQL クエリをサーバーに送信するので重そうですが、ミドルウェアである BatchHttpLink モジュールを使うことによって、バッファリングされます。

const EntryComponent = (props: { id: string }) => (
  <div>
    <GetEntryQuery query={ GetEntry } variables={ { id: props.id } } >
      {({ loading, data, error }) => {
        if (loading) return <div>loading...</div>
        if (error) return <div>{ error.toString() }</div>
        if (!data) return <div>nothing</div>
        return (
          <div>
            <p>#{ data.entry.id } { data.entry.title }</p>
            <div>
              <p>Author</p>
              <GetUserQuery query={ GetUser } variables={ { id: data.entry.authorId } } >
                {({ loading, data, error }) => {
                  if (loading) return <div>loading...</div>
                  if (error) return <div>{ error.toString() }</div>
                  if (!data) return <div>owner is nothing</div>
                  return (
                    <div>
                        <p>#{ data.user.id } { data.user.name }</p>
                    </div>
                  )
                }}
              </GetUserQuery>
            </div>
          </div>
        )
      }}
    </GetEntryQuery>
  </div>
)

const AppComponent = () => (
  <div>
    <EntryComponent id="1" />
    <EntryComponent id="2" />
    <EntryComponent id="3" />
  </div>
)

export default AppComponent

デモ

上記のように実装を行い、サンプルアプリを作成してみました。通信の様子を分かりやすくするために、developer tool で通信速度を 3G 相当にしてあります。

測定条件

  • 通信速度(Throttring): Fast 3G(帯域: 1.5MB/s、レイテンシ: 550ms ほど)
  • API リクエスト数: 112 リクエスト
  • バッチリクエスト数上限: 50 リクエスト

測定結果

  • HTTP リクエスト数: 4 リクエスト
  • DB クエリ発行数: 6 回
  • ページロード時間: 3.82 秒

かなりの数の API を呼び出しているのにもかかわらず、HTTP リクエスト、DB クエリ発行数ともに少なく高速に動いています。

バッファリングも遅延評価もない場合

ちなみに、以下のように apollo-link-batch-http の代りに apollo-link-http を使って愚直に都度リクエストをサーバーに送信し評価するようにすると、非常に遅くなります。

import { ApolloClient } from 'apollo-client'
- import { BatchHttpLink } from 'apollo-link-batch-http'
+ import { HttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'

- const link = new BatchHttpLink({ uri: '/graphql', batchMax: 50 })
+ const link = new HttpLink({ uri: '/graphql' })
const cache = new InMemoryCache()
const client = new ApolloClient({ link, cache })

測定結果

  • HTTP リクエスト数: 112 リクエスト
  • DB クエリ発行数: 112 回
  • ページロード時間: 13.09 秒

総括

以上、このように API を設計すれば、シンプルさを保ったままパフォーマンスを高く保つことができます。しかし、API といっても例えば、一般に開放する Public API やリアルタイム性が求められる API などがあり、一概にすべてで有効なものではありません。しかし、SPA やモバイルアプリ・デスクトップアプリと通信する API を実装する際には有効な方法の一つだと考えています。