Help us understand the problem. What is going on with this article?

GraphQL in wonder typed land

More than 3 years have passed since last update.

GraphQL in wonder typed land

by ovrmrw
1 / 34

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)スタッフ。

3a2512bb-aa72-4515-af42-1f1721252f39.jpg


Angularとても簡単です。みんな使いましょう。


GraphQL is what?

"A query language for your API"

  • Facebookが作ったライブラリ。
  • NetflixのFalcorに似ているけどもっと高機能。
  • Falcorはメンテされているか怪しいがこちらは活発。
  • 要求した形のJSONを返してくれる。
  • クライアント(スマホ等)はリクエストを1回送るだけで完成版のJSONを受け取れる。
  • データの集約作業はGraphQLサーバーが担当する。

GraphQL公式 http://graphql.org/

12972006.png


リクエスト → →
(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-logo.png


  • 実はApolloは公式サイトよりMediumのApolloブログの方が情報が充実している。

  • でも公式サイトもいつの間にか更新されているのでたまに目を通すと良い。

  • あるとき急にガラッと変わっていたりするので楽しい。

  • "apollo-server"だったライブラリが"graphql-server"に変わったりした。

  • 今朝も"apollo-client"がまあまあ変わってた。


Apolloにはクライアント向けライブラリもあるけど、今日はサーバーサイドライブラリの話をします。


Apolloサーバーサイドライブラリのドキュメント

graphql-tools

  • 公式のGraphQL.jsよりもGraphQLを書きやすく読みやすく書くことを目指したもの、だと思う。
  • なんだか色々機能ある。
  • ドキュメント読んで…

graphql-server-{express,hapi,koa}

  • GraphQLが動くサーバーを作る。

===

GitHubリポジトリのソースコードをざっと眺めてもらうとなんとなくわかるかと思います。


GraphQLサーバーはこれだけ。普通にただのサーバーサイドアプリ(hapi.js)。
TypeScriptで書くと型の恩恵を受けられる。

server.ts
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流の型付き言語みたいな感じ。

schema.ts
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を書く

schema.ts
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で入力補完が利く。

GraphiQLサンプル


resolverを書く

schemaで定義した通りの結果が返るような関数を書く。

resolverの基本形
fieldName: (root, args, context, info) => result

contextはrequestオブジェクトだと思っておけばOK。

resolvers.ts
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);
    },
  },

RootQueryusers,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;
    }
  }

Userfollow,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

loaders.ts
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);
}
firebase-connectors.ts
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


Thanks!

ovrmrw
ちきさんです。ただのWebエンジニアです。
http://overmorrow.hatenablog.com/
opt
"INNOVATION AGENCY" を標榜するインターネット広告代理店。エンジニア組織 "Opt Techonologies" を中心にアドテクetc...に取り組んでいます。
https://opt-technologies.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした