GraphQLロゴGraphQL

REST APIをGraphQLでラップする

5/5/2016著者Steven Luscher

フロントエンドのWeb開発者やモバイル開発者から、同じ願望を何度も耳にします。彼らはRelayやGraphQLのような新技術が提供する開発効率の向上を熱望していますが、既存のREST APIには長年の蓄積があります。移行のメリットを明確に示すデータがないため、GraphQLインフラストラクチャへの追加投資を正当化するのが難しいと感じています。

この記事では、JavaScriptのみを使用して、既存のREST APIの上にGraphQLエンドポイントを迅速かつ低コストで構築できる方法の概要を説明します。このブログ投稿の作成中に、バックエンド開発者が被害を受けることはありません。

クライアントサイドRESTラッパー#

既存のREST APIへの呼び出しをラップするGraphQLスキーマ(データの宇宙を記述する型システム)を作成します。このスキーマは、すべてクライアント側でGraphQLクエリを受信して解決します。このアーキテクチャには、本質的なパフォーマンス上の欠陥がありますが、実装が迅速で、サーバー側の変更は必要ありません。

/people/エンドポイントを通じて、Personモデルとその関連する友達を参照できるREST APIがあるとします。

A REST API that exposes an index of people

人々とその属性(first_nameemailなど)と、友情を通して他の個人との関連性をモデル化するGraphQLスキーマを構築します。

インストール#

まず、スキーマ構築ツールが必要です。

npm install --save graphql

GraphQLスキーマの構築#

最終的には、クエリを解決するために使用できるGraphQLSchemaをエクスポートしたいと考えています。

import { GraphQLSchema } from "graphql"
export default new GraphQLSchema({
query: QueryType,
})

すべてのGraphQLスキーマの根本には、その定義を私たちが提供し、ここではQueryTypeとして指定したqueryという型があります。では、QueryTypeを構築しましょう。これは、取得したい可能性のあるすべてのものを定義する型です。

REST APIのすべての機能を複製するために、QueryTypeに2つのフィールドを公開しましょう。

  • allPeopleフィールド(/people/に相当)
  • person(id: String)フィールド(/people/{ID}/に相当)

各フィールドは、戻り値の型、オプションの引数の定義、およびクエリ対象のデータを取得するJavaScriptメソッドで構成されます。

import {
GraphQLList,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
const QueryType = new GraphQLObjectType({
name: 'Query',
description: 'The root of all... queries',
fields: () => ({
allPeople: {
type: new GraphQLList(PersonType),
resolve: root => // Fetch the index of people from the REST API,
},
person: {
type: PersonType,
args: {
id: { type: GraphQLString },
},
resolve: (root, args) => // Fetch the person with ID `args.id`,
},
}),
});

現時点ではレゾルバーをスケッチとして残し、PersonTypeの定義に進みましょう。

import {
GraphQLList,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
const PersonType = new GraphQLObjectType({
name: 'Person',
description: 'Somebody that you used to know',
fields: () => ({
firstName: {
type: GraphQLString,
resolve: person => person.first_name,
},
lastName: {
type: GraphQLString,
resolve: person => person.last_name,
},
email: {type: GraphQLString},
id: {type: GraphQLString},
username: {type: GraphQLString},
friends: {
type: new GraphQLList(PersonType),
resolve: person => // Fetch the friends with the URLs `person.friends`,
},
}),
});

PersonTypeの定義について2点注意が必要です。まず、emailid、またはusernameのレゾルバーを提供していません。デフォルトのレゾルバーは、フィールドと同じ名前のプロパティをpersonオブジェクトから単純にアクセスします。これは、プロパティ名がフィールド名と一致しない場合(例:フィールドfirstNameはREST APIからのレスポンスオブジェクトのfirst_nameプロパティと一致しません)、またはプロパティにアクセスしても目的のオブジェクトが得られない場合(例:friendsフィールドには、URLのリストではなく、personオブジェクトのリストが必要です)を除いて、どこでも機能します。

では、REST APIから人を取得するレゾルバーを作成しましょう。ネットワークから読み込む必要があるため、すぐに値を返すことはできません。幸いなことに、resolve()は値または値のPromiseのいずれかを返すことができます。これを利用して、最終的にPersonTypeに準拠するJavaScriptオブジェクトに解決されるREST APIへのHTTPリクエストを送信します。

そして、これがスキーマの最初の完全な試みです。

import {
GraphQLList,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
} from 'graphql';
const BASE_URL = 'https://myapp.com/';
function fetchResponseByURL(relativeURL) {
return fetch(`${BASE_URL}${relativeURL}`).then(res => res.json());
}
function fetchPeople() {
return fetchResponseByURL('/people/').then(json => json.people);
}
function fetchPersonByURL(relativeURL) {
return fetchResponseByURL(relativeURL).then(json => json.person);
}
const PersonType = new GraphQLObjectType({
/* ... */
fields: () => ({
/* ... */
friends: {
type: new GraphQLList(PersonType),
resolve: person => person.friends.map(fetchPersonByURL),
},
}),
});
const QueryType = new GraphQLObjectType({
/* ... */
fields: () => ({
allPeople: {
type: new GraphQLList(PersonType),
resolve: fetchPeople,
},
person: {
type: PersonType,
args: {
id: { type: GraphQLString },
},
resolve: (root, args) => fetchPersonByURL(`/people/${args.id}/`),
},
}),
});
export default new GraphQLSchema({
query: QueryType,
});

Relayでのクライアントサイドスキーマの使用#

通常、RelayはHTTPを介してサーバーにGraphQLクエリを送信します。作成したスキーマを使用してクエリを解決するために、@taionのカスタムrelay-local-schemaネットワークレイヤーを挿入できます。このコードは、Relayアプリをマウントする前に実行されることが保証されている場所に配置してください。

npm install --save relay-local-schema
import RelayLocalSchema from 'relay-local-schema';
import schema from './schema';
Relay.injectNetworkLayer(
new RelayLocalSchema.NetworkLayer({ schema })
);

以上です。Relayはすべてのクエリをカスタムのクライアント常駐スキーマに送信し、そのスキーマは既存のREST APIへの呼び出しを行うことでクエリを解決します。

サーバーサイドRESTラッパー#

上記で説明したクライアントサイドREST APIラッパーを使用すると、Relayバージョンのアプリ(またはアプリの一部)を迅速に試すことができます。

ただし、前述のように、このアーキテクチャには、GraphQLが依然として非常にネットワーク負荷の高い基盤となるREST APIを呼び出している方法が原因で、本質的なパフォーマンス上の欠陥があります。次のステップとして、スキーマをクライアントサイドからサーバーサイドに移動して、ネットワークの待ち時間を最小限に抑え、レスポンスをキャッシュするための機能を向上させることをお勧めします。

NodeとExpressを使用して、上記のGraphQLラッパーのサーバーサイドバージョンを構築する様子を10分間ご覧ください。

ボーナスラウンド:真にRelayに準拠したスキーマ#

上記で開発したスキーマは、ある時点まではRelayで機能します。それは、Relayに既にダウンロードしたレコードのデータを再取得するように要求する時点です。Relayの再取得サブシステムは、GraphQLスキーマがデータ宇宙内の任意のエンティティをGUIDで取得できる特別なフィールドを公開することに依存しています。これをノードインターフェースと呼びます。

ノードインターフェースを公開するには、クエリルートにnode(id: String!)フィールドを提供し、すべてのIDをGUID(グローバル一意識別子)に変更する必要があります。

graphql-relayパッケージには、これを行うためのヘルパー関数がいくつか含まれています。

npm install --save graphql-relay

グローバルID#

まず、PersonTypeidフィールドをGUIDに変更しましょう。これには、graphql-relayglobalIdFieldヘルパーを使用します。

import { globalIdField } from "graphql-relay"
const PersonType = new GraphQLObjectType({
name: "Person",
description: "Somebody that you used to know",
fields: () => ({
id: globalIdField("Person"),
/* ... */
}),
})

内部的には、globalIdFieldは、型名'Person'とREST APIによって返されたIDをハッシュすることでidGraphQLStringに解決するフィールド定義を返します。後でfromGlobalIdを使用して、このフィールドの結果を'Person'とREST APIのIDに戻すことができます。

ノードフィールド#

graphql-relayの一連のヘルパーが、ノードフィールドの開発に役立ちます。あなたの仕事は、ヘルパーに2つの関数を提供することです。

  • GUIDが与えられた場合にオブジェクトを解決できる関数。
  • オブジェクトが与えられた場合に型名を取得できる関数。
import { fromGlobalId, nodeDefinitions } from "graphql-relay"
const { nodeInterface, nodeField } = nodeDefinitions(
globalId => {
const { type, id } = fromGlobalId(globalId)
if (type === "Person") {
return fetchPersonByURL(`/people/${id}/`)
}
},
object => {
if (object.hasOwnProperty("username")) {
return "Person"
}
}
)

上記のオブジェクトから型名へのレゾルバーはエンジニアリングの驚異ではありませんが、アイデアはわかります。

次に、nodeInterfacenodeFieldをスキーマに追加するだけです。完全な例を以下に示します。

import {
GraphQLList,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
} from 'graphql';
import {
fromGlobalId,
globalIdField,
nodeDefinitions,
} from 'graphql-relay';
const BASE_URL = 'https://myapp.com/';
function fetchResponseByURL(relativeURL) {
return fetch(`${BASE_URL}${relativeURL}`).then(res => res.json());
}
function fetchPeople() {
return fetchResponseByURL('/people/').then(json => json.people);
}
function fetchPersonByURL(relativeURL) {
return fetchResponseByURL(relativeURL).then(json => json.person);
}
const { nodeInterface, nodeField } = nodeDefinitions(
globalId => {
const { type, id } = fromGlobalId(globalId);
if (type === 'Person') {
return fetchPersonByURL(`/people/${id}/`);
}
},
object => {
if (object.hasOwnProperty('username')) {
return 'Person';
}
},
);
const PersonType = new GraphQLObjectType({
name: 'Person',
description: 'Somebody that you used to know',
fields: () => ({
firstName: {
type: GraphQLString,
resolve: person => person.first_name,
},
lastName: {
type: GraphQLString,
resolve: person => person.last_name,
},
email: {type: GraphQLString},
id: globalIdField('Person'),
username: {type: GraphQLString},
friends: {
type: new GraphQLList(PersonType),
resolve: person => person.friends.map(fetchPersonByURL),
},
}),
interfaces: [ nodeInterface ],
});
const QueryType = new GraphQLObjectType({
name: 'Query',
description: 'The root of all... queries',
fields: () => ({
allPeople: {
type: new GraphQLList(PersonType),
resolve: fetchPeople,
},
node: nodeField,
person: {
type: PersonType,
args: {
id: { type: GraphQLString },
},
resolve: (root, args) => fetchPersonByURL(`/people/${args.id}/`),
},
}),
});
export default new GraphQLSchema({
query: QueryType,
});

病的なクエリの抑制#

次の友達の友達の友達のクエリを考えてみましょう。

query {
person(id: "1") {
firstName
friends {
firstName
friends {
firstName
friends {
firstName
}
}
}
}
}

上記で作成したスキーマは、同じデータに対してREST APIへの複数回の往復を生成します。

Duplicate queries to the REST API

これは明らかに避けたいことです。少なくとも、これらのリクエストの結果をキャッシュする方法が必要です。

このようなクエリを抑制するために、DataLoaderと呼ばれるライブラリを作成しました。

npm install --save dataloader

特別な注意事項として、ランタイムがネイティブまたはポリフィルされたバージョンのPromiseMapを提供していることを確認してください。詳細はDataLoaderサイトをご覧ください

データローダーの作成#

DataLoaderを作成するには、キーのリストが与えられた場合にオブジェクトのリストを解決できるメソッドを提供します。この例では、キーはREST APIにアクセスするURLです。

const personLoader = new DataLoader(urls =>
Promise.all(urls.map(fetchPersonByURL))
)

このデータローダーがその存続期間中にキーを複数回検出した場合、レスポンスのメモ化(キャッシュ)されたバージョンを返します。

データの読み込み#

personLoaderload()メソッドとloadMany()メソッドを使用して、URLを一度に1つのURLより多くREST APIをヒットする心配をすることなくロードできます。完全な例を以下に示します。

import DataLoader from 'dataloader';
import {
GraphQLList,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
} from 'graphql';
import {
fromGlobalId,
globalIdField,
nodeDefinitions,
} from 'graphql-relay';
const BASE_URL = 'https://myapp.com/';
function fetchResponseByURL(relativeURL) {
return fetch(`${BASE_URL}${relativeURL}`).then(res => res.json());
}
function fetchPeople() {
return fetchResponseByURL('/people/').then(json => json.people);
}
function fetchPersonByURL(relativeURL) {
return fetchResponseByURL(relativeURL).then(json => json.person);
}
const personLoader = new DataLoader(
urls => Promise.all(urls.map(fetchPersonByURL))
);
const { nodeInterface, nodeField } = nodeDefinitions(
globalId => {
const {type, id} = fromGlobalId(globalId);
if (type === 'Person') {
return personLoader.load(`/people/${id}/`);
}
},
object => {
if (object.hasOwnProperty('username')) {
return 'Person';
}
},
);
const PersonType = new GraphQLObjectType({
name: 'Person',
description: 'Somebody that you used to know',
fields: () => ({
firstName: {
type: GraphQLString,
resolve: person => person.first_name,
},
lastName: {
type: GraphQLString,
resolve: person => person.last_name,
},
email: {type: GraphQLString},
id: globalIdField('Person'),
username: {type: GraphQLString},
friends: {
type: new GraphQLList(PersonType),
resolve: person => personLoader.loadMany(person.friends),
},
}),
interfaces: [nodeInterface],
});
const QueryType = new GraphQLObjectType({
name: 'Query',
description: 'The root of all... queries',
fields: () => ({
allPeople: {
type: new GraphQLList(PersonType),
resolve: fetchPeople,
},
node: nodeField,
person: {
type: PersonType,
args: {
id: { type: GraphQLString },
},
resolve: (root, args) => personLoader.load(`/people/${args.id}/`),
},
}),
});
export default new GraphQLSchema({
query: QueryType,
});

これで、病的なクエリは、REST APIへの次の重複除去されたリクエストセットを生成します。

De-duped queries to the REST API

クエリプランニングとそれ以降#

REST APIが既に関連付けを熱心にロードできる設定を提供している可能性があります。おそらく、個人とその直接の友達をすべてロードするには、/people/1/?include_friendsというURLにアクセスする必要があるでしょう。GraphQLスキーマでこれを使用するには、クエリ自体の構造(例:friendsフィールドがクエリの一部であるかどうか)に基づいて解決計画を作成する機能が必要です。

高度な解決戦略に関する現在の考え方に興味のある方は、プルリクエスト#304にご注目ください。

お読みいただきありがとうございます#

この記事が、機能的なGraphQLエンドポイントとユーザーとの間のいくつかの障壁を取り除き、既存のプロジェクトでGraphQLとRelayを試すきっかけになったことを願っています。