When
2016/11/04
At
#10 市ヶ谷Geek★Night「型のあるフロントエンドの世界〜フロントエンド・フロンティア〜」
今回のGitHubリポジトリ
ovrmrw/graphql-server-dataloader
リポジトリにデモサイトへのリンクがあります。
===
話す内容
- GraphQLが型を持っていることで何が嬉しいか。
- DataLoaderの威力。
自己紹介
ちきさん
(Tomohiro Noguchi)
twitter: @ovrmrw
ただのSIer。
Angular Japan User Group (ng-japan)スタッフ。
Angularとても簡単です。みんな使いましょう。
GraphQL is what?
"A query language for your API"
- Facebookが作ったライブラリ。
- NetflixのFalcorに似ているけどもっと高機能。
- Falcorはメンテされているか怪しいがこちらは活発。
- 要求した形のJSONを返してくれる。
- クライアント(スマホ等)はリクエストを1回送るだけで完成版のJSONを受け取れる。
- データの集約作業はGraphQLサーバーが担当する。
リクエスト → →
(id 1番のuserちょうだい)
{
user (id: "1") {
id
name
age
}
}
→ → レスポンス
{
"user" {
"id": "1",
"name": "Tarou",
"age": 20
}
}
すごいリクエスト → →
(id 1番のuserの友達の友達の友達の友達の…)
{
user(id:"1") {
id
name
age
address {
zip
street
}
hobby {
name
}
follow {
id
name
age
address {
zip
street
}
hobby {
name
}
follow {
id
name
hobby {
name
}
follow {
id
name
hobby {
name
}
follow {
id
name
hobby {
name
}
}
}
}
}
}
}
→ → すごいレスポンス
クライアントは1回のリクエストを送るだけ。
{
"data": {
"user": {
"id": "1",
"name": "Tarou●",
"age": 20,
"address": {
"zip": "111",
"street": "Shibuya"
},
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
},
{
"name": "hiking"
}
],
"follow": [
{
"id": "2",
"name": "Hanako■",
"age": 30,
"address": {
"zip": "222",
"street": "Marunouchi"
},
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
}
],
"follow": [
{
"id": "3",
"name": "John▲",
"hobby": null,
"follow": [
{
"id": "1",
"name": "Tarou●",
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
},
{
"name": "hiking"
}
],
"follow": [
{
"id": "2",
"name": "Hanako■",
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
}
]
},
{
"id": "3",
"name": "John▲",
"hobby": null
}
]
}
]
}
]
},
{
"id": "3",
"name": "John▲",
"age": 40,
"address": {
"zip": "333",
"street": "Sugamo"
},
"hobby": null,
"follow": [
{
"id": "1",
"name": "Tarou●",
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
},
{
"name": "hiking"
}
],
"follow": [
{
"id": "2",
"name": "Hanako■",
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
}
],
"follow": [
{
"id": "3",
"name": "John▲",
"hobby": null
}
]
},
{
"id": "3",
"name": "John▲",
"hobby": null,
"follow": [
{
"id": "1",
"name": "Tarou●",
"hobby": [
{
"name": "movie"
},
{
"name": "swimming"
},
{
"name": "hiking"
}
]
}
]
}
]
}
]
}
]
}
}
}
Apollo is what?
"Tools & products for GraphQL"
- 本家FacebookもGraphQL用のツールを作っているが、これはサードパーティーのライブラリ。サーバーサイド向けとクライアント向け両方ある。
- 本家はReactと連携するRelayというクライアントライブラリを提供しているが、こちらはAngular, iOS, Android向けがある。
- 僕はAngular派なので現状クライアントはApolloを使うしかない。
- クライアントでApolloを使うのでなんとなくサーバーサイドもApolloを使っている。
- 更新速いし活発(?)なので個人的には好き。
Apollo公式 http://www.apollodata.com/
-
実はApolloは公式サイトよりMediumのApolloブログの方が情報が充実している。
-
でも公式サイトもいつの間にか更新されているのでたまに目を通すと良い。
-
あるとき急にガラッと変わっていたりするので楽しい。
-
"apollo-server"だったライブラリが"graphql-server"に変わったりした。
-
今朝も"apollo-client"がまあまあ変わってた。
Apolloにはクライアント向けライブラリもあるけど、今日はサーバーサイドライブラリの話をします。
graphql-tools
- 公式のGraphQL.jsよりもGraphQLを書きやすく読みやすく書くことを目指したもの、だと思う。
- なんだか色々機能ある。
- ドキュメント読んで…
graphql-server-{express,hapi,koa}
- GraphQLが動くサーバーを作る。
===
GitHubリポジトリのソースコードをざっと眺めてもらうとなんとなくわかるかと思います。
GraphQLサーバーはこれだけ。普通にただのサーバーサイドアプリ(hapi.js)。
TypeScriptで書くと型の恩恵を受けられる。
const server = new Hapi.Server();
const HOST = '0.0.0.0';
const PORT = process.env.PORT || 3000;
server.connection({
host: HOST,
port: PORT,
});
server.register({
register: graphqlHapi,
options: {
path: '/graphql',
graphqlOptions: (request: Hapi.Request) => {
console.log('='.repeat(80));
const context: Context = Object.assign(request, { loaders: createLoaders() });
return {
schema: executableSchema,
context
}
},
route: {
cors: true
}
},
});
server.register({
register: graphiqlHapi,
options: {
path: '/graphiql',
graphiqlOptions: {
endpointURL: '/graphql',
},
},
});
server.start((err) => {
if (err) {
throw err;
}
console.log(`Server running at: ${server.info.uri}`);
});
TypeScriptすごくいいです。みんな使いましょう。
GraphQLサーバーを書く
- schemaを書く。
- schemaに対応するTypeScriptのinterfaceを書く。
- resolverを書く。
- dataLoaderを書く。
- connectorを書く。(各DB向けのfetchコード)
schemaを書く
GraphQL流の型付き言語みたいな感じ。
export const schema = [`
# type definition of User
type User {
id: ID!
name: String!
age: Int
address: Address
# other users who current user follows.
follow: [User]
# current user's hobbies.
hobby: [Hobby]
}
type Address {
zip: String
street: String
}
type Hobby {
id: ID!
name: String!
}
type RootQuery {
users: [User]
user(id: ID!): User
}
schema {
query: RootQuery
}
`];
schemaに対応するTypeScriptのinterfaceを書く
export interface User {
id: ID;
name: string;
age?: number;
address?: Address;
follow?: ID[];
hobby?: ID[];
}
export interface Address {
zip?: string;
street?: string;
}
export interface Hobby {
id: ID;
name: string;
}
type ID = string;
【schemaの解説】
まずschemaというものを定義する。
queryの型はRootQuery
である。実際には他にmutationとか色々ある。
schema {
query: RootQuery
}
次にRootQuery
という型の中身を定義する。
usersはUser
型の配列を返すリゾルバー。配列は[]
で表す。
userはidを引数に取ってUser
型を返すリゾルバー。!
は省略不可の意味。
type RootQuery {
users: [User]
user(id: ID!): User
}
今度はUser
という型を定義する。
Address
,Hobby
はいずれも独自の型である。
type User {
id: ID!
name: String!
age: Int
address: Address
follow: [User]
hobby: [Hobby]
}
Address
型を定義する。
type Address {
zip: String
street: String
}
Hobby
型も定義する。
type Hobby {
id: ID!
name: String!
}
このようにクエリに必要となる型を全て定義していく。ただし文字列として。
TypeScriptの型は別途書かなければならない。
この辺なんとかなったらみんな幸せになれそう。
===
schemaがあると何が嬉しいって、GraphiQLで入力補完が利く。
resolverを書く
schemaで定義した通りの結果が返るような関数を書く。
resolverの基本形
fieldName: (root, args, context, info) => result
context
はrequestオブジェクトだと思っておけばOK。
export const resolverMap = {
RootQuery: {
users(root: any, args: {}): Promise<User[]> {
return getUserIds().then(ids => userLoader.loadMany(ids));
},
user(root: any, args: { id: string }): Promise<User[]> {
return userLoader.load(args.id);
},
},
User: {
follow(user: User, args: {}): Promise<User[]> | null {
return user.follow ? userLoader.loadMany(user.follow) : null;
},
hobby(user: User, args: {}): Promise<Hobby[]> | null {
return user.hobby ? hobbyLoader.loadMany(user.hobby) : null;
}
}
};
【schemaとresolverの対応の解説】(1/2)
こういうschemaがあったら…
type RootQuery {
users: [User]
user(id: ID!): User
}
こういうresolverを書く。
RootQuery: {
users(root: any, args: {}): Promise<User[]> {
return getUserIds().then(ids => userLoader.loadMany(ids));
},
user(root: any, args: { id: string }): Promise<User[]> {
return userLoader.load(args.id);
},
},
RootQuery
とusers
,user
の階層構造が一致している!
関数の第一引数root
は親オブジェクトを指す。この場合はrootなのでundefined
となる。
【schemaとresolverの対応の解説】(2/2)
こういうschemaがあったら…
type User {
(省略)
follow: [User]
hobby: [Hobby]
}
こういうresolverを書く。
User: {
follow(user: User, args: {}): Promise<User[]> | null {
return user.follow ? userLoader.loadMany(user.follow) : null;
},
hobby(user: User, args: {}): Promise<Hobby[]> | null {
return user.hobby ? hobbyLoader.loadMany(user.hobby) : null;
}
}
User
とfollow
,hobby
の階層構造が一致している!
関数の第一引数user
は親オブジェクトを指す。この場合はUser
型のオブジェクトとなる。
実際にクエリをリクエストときのGraphQLサーバーのログを見てみる。
(DataLoaderによるBatching, Caching無し)
リクエスト → →
{
user(id:"1") {
...userFragment
follow {
...userFragment
follow {
...userFragment
follow {
...userFragment
follow {
...userFragment
}
}
}
}
}
}
fragment userFragment on User {
id
name
address {
zip
street
}
hobby {
name
}
}
これはfragmentという機能を使ったクエリの書き方。
GraphQLサーバーのログ
userLoader fetch: { id: '1', name: 'Tarou●' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
hobbyLoader fetch: { id: '3', name: 'hiking' }
userLoader fetch: { id: '2', name: 'Hanako■' }
userLoader fetch: { id: '3', name: 'John▲' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
userLoader fetch: { id: '3', name: 'John▲' }
userLoader fetch: { id: '1', name: 'Tarou●' }
userLoader fetch: { id: '1', name: 'Tarou●' }
userLoader fetch: { id: '2', name: 'Hanako■' }
userLoader fetch: { id: '3', name: 'John▲' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
hobbyLoader fetch: { id: '3', name: 'hiking' }
hobbyLoader fetch: { id: '3', name: 'hiking' }
userLoader fetch: { id: '2', name: 'Hanako■' }
userLoader fetch: { id: '3', name: 'John▲' }
userLoader fetch: { id: '3', name: 'John▲' }
userLoader fetch: { id: '1', name: 'Tarou●' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '1', name: 'movie' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
hobbyLoader fetch: { id: '2', name: 'swimming' }
hobbyLoader fetch: { id: '3', name: 'hiking' }
これは…!!!
where DataLoader comes into play
dataLoaderを書く
new DataLoader(callback) に登録するコールバックの基本形
(keys: T[]) => Promise<R[]>
"(例えば) stringの配列を受け取って、Userの配列をPromiseで返す関数"
詳細はこれ読んで → facebook/dataloader
export function createLoaders(cache: boolean = true, batch: boolean = true): Loaders {
const options = { cache, batch };
return {
userLoader: new DataLoader(userLoaderCallback, options),
hobbyLoader: new DataLoader(hobbyLoaderCallback, options),
}
}
function userLoaderCallback(keys: string[]): Promise<User[]> {
return getUsersConnector(keys);
}
function hobbyLoaderCallback(keys: string[]): Promise<Hobby[]> {
return getHobbiesConnector(keys);
}
export async function getUserIdsConnector(): Promise<string[]> {
const snapshot = await firebase.database().ref('users').once('value') as Snapshot;
const users: User[] = lodash.toArray<User>(snapshot.val()).filter(obj => !!obj);
users.forEach(user => assert(lodash.isObject(user) && 'id' in user));
const ids: string[] = users.map(user => user.id);
return ids;
}
export async function getUsersConnector(keys: string[]): Promise<User[]> {
const snapshotPromises = keys.map(key => firebase.database().ref('users/' + key).once('value') as Promise<Snapshot>);
const snapshots = await Promise.all(snapshotPromises);
const users: User[] = snapshots.map(snapshot => snapshot.val());
users.forEach(user => assert(lodash.isObject(user) && 'id' in user));
console.log('userLoader fetch:', ...users.map(user => ({ id: user.id, name: user.name })));
return users;
}
export async function getHobbiesConnector(keys: string[]): Promise<Hobby[]> {
const snapshotPromises = keys.map(key => firebase.database().ref('hobby/' + key).once('value') as Promise<Snapshot>);
const snapshots = await Promise.all(snapshotPromises);
const hobbies: Hobby[] = snapshots.map(snapshot => snapshot.val());
hobbies.forEach(hobby => assert(lodash.isObject(hobby) && 'id' in hobby));
console.log('hobbyLoader fetch:', ...hobbies.map(hobby => ({ id: hobby.id, name: hobby.name })));
return hobbies;
}
DataLoaderを使うとさっきのアレがこう!
userLoader fetch: { id: '1', name: 'Tarou●' }
hobbyLoader fetch: { id: '1', name: 'movie' } { id: '2', name: 'swimming' } { id: '3', name: 'hiking' }
userLoader fetch: { id: '2', name: 'Hanako■' } { id: '3', name: 'John▲' }
30回のfetchが6回に!
通信コストの大幅削減!
ちなみにこれは「クライアント←→GraphQLサーバー」のログではなく、「GraphQLサーバー←→DBサーバー」のログです。
DataLoaderを併用することでGraphQLは威力を発揮する
GraphQLかなりいいです。みんな使いましょう。
今回のソースコードは全て下記のリポジトリからの引用です。
ovrmrw/graphql-server-dataloader