こんにちは、エンジニアの村田です。今回はシンプルさとパフォーマンスを両立した API を作るためにはどうすればよいのかについて述べていきます。
いままで API サーバーを作ってきて、シンプルな API にすればパフォーマンスが犠牲に、パフォーマンスを優先すれば実装が複雑になって保守が大変になるということを経験してきました。具体的には、
1.
の問題を解消するためにシンプルな API にする2.
では N+1 の問題がありパフォーマンスが悪い1.
2.
の問題を解消するためにリソースの Entity はシンプルにしつつ、パラメーターで関連リソースを指定すると関連リソースの Entity を埋め込んで返すようにする4.
だと関連リソースの権限管理やページネーションが複雑になり大変5.
までの問題を解消したいといった感じです。というわけで、これらの問題を解決するためには API をどのように設計するとうまくいくのかについて述べます。
まず結論から述べると次のように設計すると大体の場合でよさそうです。
上記で述べた方針は、Virtual DOM のように最速ではないがシンプルさとパフォーマンスを両立できることを目的としています。以下、そのために具体的にどうすべきかについて述べます。
これは、ページネーションや権限管理、フロントでの Entity 管理などが複雑になりすぎないようにするためです。関連リソースの Entity を含めるようにすると、その関連リソースの権限管理はどうするのか、 ページングは、 N+1 対策は、 と、様々なことを考慮しなければなりません。また、レコードの取得やインスタンスの生成が膨大な数になり、レスポンスが遅くなりがちです。これらを避けるために、関連リソースは Entity ではなく ID で持つようにします。
まず関連リソースの Entity を埋め込む場合。よくあるパターンですが、開発が進むとどんどん複雑になっていきがちです。
{
"id": 1,
"title": "hoge",
"author": {
"id": 5,
"name": "author 5",
"profile": "author 5 profile"
}
}
次に関連リソースへの ID のみ持つ場合。シンプルになりますが、通常このままだと API リクエストや DB アクセスで N+1 が多発して実用に耐えません。(が、今回は後述の実装によりこちらで OK です。)
{
"id": 1,
"title": "hoge",
"authorId": 5
}
これは、「関連リソースへの ID を持つ」でシンプルさを保ったままパフォーマンスを確保するために必要なことです。
まず、API リクエストを即座にサーバーに送信する場合。普通に作るとこのようになりますが、N+1 の問題が起きがちです。そのため、これを避けるために関連リソースの Entity を埋め込んで解決しがちです(その結果どんどん複雑になっていく)。
次に、一定期間(たとえば 10ms)が過ぎるか一定量が溜まるか(例えば 10 リクエスト)までバッファリングしておき、複数のリクエストをまとめてサーバーに送信する場合。このようにすることで、API リクエストの N+1 を大幅に減らせます。
2.
で API リクエストの N+1 は解消されましたが、送信された複数のリクエストをそれぞれ順次評価していたのでは、今度は DB アクセスの際に N+1 が発生してしまいます。リクエストを即座に評価せず遅延評価するのはこれを解消するために必要となります。
まず、各リクエストを順次評価する場合。普通に作るとこのようになりますが、DB への N+1 が発生してしまいます。
次に、リクエストを遅延評価する場合。リクエストの評価を即座に行わずに、どのようなデータを取得すべきかの情報をキューに入れておき、具体的なレスポンスの代わりに、遅延評価をするためのいわゆる Promise を返すようにします。 そして、すべてのリクエストを舐めた後に、Promise を評価します。このときキューからどのようなデータを取得すべきかの情報を取得し、それを元に DB からデータを取得します。後はこのデータを元に各リクエストのレスポンスを生成します。これによって DB への N+1 を回避できます。
これらの設計に基づいて実装するにあたって、API を GraphQL で実装して、クライアントに ApolloClient を用いると比較的容易なので、今回はこれらを用いて実装してみます。具体的には以下を使用します。
Apollo Client については以下が詳しいです。
世のフロントエンドエンジニアにApollo Clientを布教したい - Qiita
GraphQL サーバーの実装を行います。上記でいう 1. リソースの Entity に関連リソースの Entity は埋め込まない
、3. サーバー側で各リクエストを即座に評価しないで遅延評価する
に相当します。
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
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 は 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. クライアントはリクエストを即座に送らず一定期(もしくは一定量)バッファリングしてまとめて送る
に相当します。
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'))
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> {}
上記で実装した 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 相当にしてあります。
かなりの数の 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 })
以上、このように API を設計すれば、シンプルさを保ったままパフォーマンスを高く保つことができます。しかし、API といっても例えば、一般に開放する Public API やリアルタイム性が求められる API などがあり、一概にすべてで有効なものではありません。しかし、SPA やモバイルアプリ・デスクトップアプリと通信する API を実装する際には有効な方法の一つだと考えています。
ハートレイルズは新規事業開発のエキスパートです。詳しくは コーポレートサイト をご覧ください。