ushumpei’s blog

生活で気になったことを随時調べて書いていきます。

Apollo Clientを使ってみるチュートリアルのようなもの

React #1 Advent Calendar 2017 12日目の記事になります。

GraphQL!(こんにちは)

この記事はGraphQLのライブラリ React Apollo を使って、ReactでGraphQLを触ってみようという内容です。

GraphQLどうなんでしょうか? 運用で使って見た系スライドとか公開してくださっている方々がいたりしますが、なかなか敷居が高そうで手が出せていませんでした。主に サーバーの実装がよくわからない、という理由です。

そんな時Full-stack React + GraphQL Tutorialという Apollo公式チュートリアル を見つけて、よしやろう、と思ったのですが、 apollo-clientのバージョンが変わって内容が合わず苦労した ので、そのあたりを自分なりに書けたらと思います。

GraphQL自体に関しては以下のリンクで勉強しました。

目次

Apolloプロジェクトの概要

ApolloMeteor Development Group(JavaScriptアプリケーションプラットフォームMeteorの会社)が開発している GraphQLのオープンソースツールセット (OSSの一群的な)です。サーバー、クライアント両方でGraphQLが使えるようにするためのライブラリがいくつも含まれています。

中でも Apollo Client はReact、Angular、Vue、その他多くのJavaScriptFrameworkでGraphQLクライアントとして使用できるそうで、これの 使い方覚えればいろんなところで便利 なのでは?と思ったりします。(注: 本家のドキュメントがあまり更新されてない印象がありますが)

Apolloで作成されているもの は主に次のようなものがあります、本当はもっとたくさんあります。詳細はリファレンスに書いてあります。または自分のざっくりした記事にも多少書いてあります。

  • apollo-client: サーバーとの間で、クエリ発行、データ取得、キャッシングなどしてくれます。2017/10に バージョン2.0がリリースされて、大幅にコードの分離 が行われました。
  • graphql-tag: GraphQLクエリ文字列をクエリの構文木(GraphQL.js AST format)に変換する gql テンプレートタグが入っています。
  • react-apollo: Apollo Clientをpropsで流すためにルートコンポーネントの親コンポーネントとして使う ApolloProviderコンポーネント 、クエリとコンポーネントからApollo Clientと結びついたコンポーネントを作るための graphql高階コンポーネント が入っています。
  • graphql-tools: サーバー側で、schemaとresolverをから実行可能なschemaを生成する makeExecutableSchema が入っています。

チュートリアルのようなもの

React Apolloを使ってGraphQLクライアントを、Apollo GraphQL Expressを使って簡単なGraphQLサーバーを作成します。注意として、データストアは使いません、メモリに置いておくだけです。WebSocketも使いません。よろしくお願い申し上げます。


ReactApollo

完成品のソースです。

Step 1 -- クライアントを作る

create-react-appコマンドでクライアントの雛形を作成します。

$ mkdir react-apollo-tutorial
$ npm install -g create-react-app
$ cd react-apollo-tutorial
$ create-react-app client

続いてクライアントにApollo Client関係、React Apollo、GraphQL関係、Mock関連のパッケージを追加します。

$ cd client
$ yarn add apollo-client apollo-cache-inmemory apollo-link apollo-link-http react-apollo graphql-tag graphql graphql-tools

react-apollo-tutorial/client/src/App.js に以下の内容を記述します。ただし この時点ではブラウザのコンソールにエラー が表示されます。

import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import gql from 'graphql-tag';
...
const link = new HttpLink();
const cache = new InMemoryCache();
const client = new ApolloClient({ link, cache });
...
const query = gql`query {
  users {
    id
    name
  }
}`;
client.query({ query })
  .then(console.log)
  .catch(console.error);

これは適切なスキーマ、ネットワークがないためです。しかしPOSTが行われたということがエラーからわかります。

Step 2 -- モックのスキーマとネットワークの作成

スキーマの定義 を行います。client/src/schema.js を作成して以下の内容を記述します。これは後ほどサーバー移してそのまま使います。

export const typeDefs = `
type User {
  id: ID!
  name: String
}

type Query {
  users: [User]
}
`;

User型と、その一覧を取得するQueryのusersを定義しました。

次に モックレスポンスを返す ためのclient/src/MockLink.jsを作成します。これはApolloLinkを継承したクラスです。

一般的にApolloLinkを継承したクラスはObservableを返すrequestメソッドを持つ必要があります(チュートリアルで引っかかった部分がモックをつくる部分のここでした。以下のコードはGitHubのIssueから拝借しました)。

import { ApolloLink, Observable } from 'apollo-link';
import { graphql } from 'graphql';
import { print } from 'graphql/language/printer';

export default class MockLink extends ApolloLink {
  constructor(params) {
    super();
    this.schema = params.schema;
    this.rootValue = params.rootValue;
    this.context = params.context;
  }

  request(operation) {
    const request = {
      ...operation,
      query: print(operation.query)
    };

    return new Observable(observer => {
      graphql(this.schema, request.query, this.rootValue, this.context, request.variables, request.operationName)
        .then(data => {
          if (!observer.closed) {
            observer.next(data);
            observer.complete();
          }
        })
        .catch(error => {
          if (!observer.closed) {
            observer.error(error);
          }
        });
    });
  }
}

参考

以下の内容でclient/src/App.jsを編集しましょう。

- import { HttpLink } from 'apollo-link-http';
...
+ import { makeExecutableSchema, addMockFunctionsToSchema, } from 'graphql-tools';
+ import { typeDefs } from './schema';
+ import MockLink from './MockLink';
...

+ const schema = makeExecutableSchema({ typeDefs });
+ addMockFunctionsToSchema({ schema });

- const link = new HttpLink();
+ const link = new MockLink({ schema });

graphql-tools を使ってスキーマの作成(makeExecutableSchema)と、そのスキーマに対するモックのリゾルバの定義(addMockFunctionsToSchema)を行なっています。

この変更により、 ブラウザのコンソールに2名のユーザー が表示されるようになりました。(dataプロパティを持つオブジェクトがブラウザのコンソール表示されているはずです)

Step 3 -- React Apolloを使ってApollo Clientとコンポーネントを紐づける

先ほどはclientインスタンスから直接クエリを呼び出していましたが、 コンポーネントにクエリを結びつけて画面に描画 できるようにします。

まずユーザーを一覧で表示するためのUsersListコンポーネントclient/src/UsersList.jsとして作成します。

import React from 'react';
import gql from 'graphql-tag';
import { graphql } from 'react-apollo';
// import './UsersList.css'; お好みで作成してください

const UsersList = ({ data: { loading, error, users }}) => {
  if (loading) return <p>Loading ...</p>;
  if (error) return <p>{error.message}</p>;

  return <ul className="users-list">
    { users.map(u => <li key={u.id}>{u.name}</li>) }
  </ul>;
};

export const usersListQuery = gql`query {
  users {
    id
    name
  }
}`;

export default graphql(usersListQuery)(UsersList);

UsersListコンポーネントは通常のステートレスなReactコンポーネントで、dataというプロパティの中に入っているusersをリストとして描画するものです。 最後の行の記述、graphql高階コンポーネントによって、usersクエリとUsersListコンポーネントが結びつけられています。

次にclient/src/App.jsを編集して、ApolloProviderにclientを渡すのと、UsersListコンポーネントを描画するよう変更します。

...

+ import { ApolloProvider } from 'react-apollo';
+ import UsersList from './UsersList';

...

class App extends Component {
  render() {
    return (
+     <ApolloProvider client={client}>
        <div className="App">
          <header className="App-header">
            <img src={logo} className="App-logo" alt="logo" />
            <h1 className="App-title">Welcome to React</h1>
          </header>
          <p className="App-intro">
            To get started, edit <code>src/App.js</code> and save to reload.
          </p>
+         <UsersList />
        </div>
+     </ApolloProvider>
    );
  }
}

export default App;

またコンポーネントからクエリを発行するように変更したため、App.jsに書いていた、import gqlquery定数とclient.query(...)関数呼び出し、の部分は削除してしまってください。

Step 4 -- サーバーを立ててモックのレスポンスを返す

$ cd ../ # react-apollo-tutorialディレクトリに戻ります
$ mkdir -p server/src
$ cd server
$ yarn add express apollo-server-express cors body-parser graphql-tools graphql
$ yarn add --dev babel-cli babel-preset-es2015 babel-preset-stage-2 nodemon
$ cp ../client/.gitignore ./
$ cp ../client/src/schema.js src/schema.js

server/src/schema.jsをサーバー用に書き換えます。ここでもまだ先ほどのようにモックを使用するため、graphql-toolsから必要なものをインポートしておきます。

import { makeExecutableSchema, addMockFunctionsToSchema, } from 'graphql-tools';

... // typeDefs

const schema = makeExecutableSchema({ typeDefs });
addMockFunctionsToSchema({ schema });

export default schema;

次にserver/server.jsを作成します。サーバーのエントリポイントはlocalhost:3000/graphqlとして作成していきます。

import express from 'express';
import {
  graphqlExpress,
  graphiqlExpress,
} from 'apollo-server-express';
import cors from 'cors';
import bodyParser from 'body-parser';
import schema from './src/schema';

const PORT = 4000;

const server = express();
server.use('*', cors({ origin: 'http://localhost:3000' }));
server.use('/graphql', bodyParser.json(), graphqlExpress({ schema }));
server.use('/graphiql', graphiqlExpress({ endpointURL: '/graphql' }));

server.listen(PORT, () =>
  console.log(`GraphQL Server is now running`)
);

ポート3000からアクセスしたいため、corsによってCross-origin resource sharingの設定を行っています。またここで、 graphiqlExpress というクエリ実行環境の設定を行っておきます。localhost:4000/graphiqlをブラウザで開くと、現在のサーバーに対してクエリ実行が可能です。

準備がほとんど整ったのでserver/package.jsonにサーバー起動スクリプトを追記します。

{
  "scripts": {
    "start": "nodemon server.js --exec babel-node --presets es2015,stage-2"
  },
  ...
}

yarn startを実行してください。GraphQL Server is now runningというserver.jsで設定したメッセージが表示されていたら完了です。ブラウザでlocalhost:4000/graphiqlを開いてみてください。左のペインで以下のクエリを実行すると、右に実行結果(モックデータ)が表示されます。


graphiql

query {
  users {
    id
    name
  }
}

参考

クライアント側の修正を行います。MockLinkなど使わないものを削除して、HttpLinkを使ってサーバーのエンドポイントとApollo Clientを繋げましょう。

+ import { HttpLink } from "apollo-link-http";
...
- import { makeExecutableSchema, addMockFunctionsToSchema, } from 'graphql-tools';
- import { typeDefs } from './schema';
- import MockLink from './MockLink';
...
- const schema = makeExecutableSchema({ typeDefs });
- addMockFunctionsToSchema({ schema });

- const link = new MockLink({ schema });
+ const link = new HttpLink({ uri: 'http://localhost:4000/graphql' });

ついでにclient/src/schema.jsも削除しておきましょう。

Step 5 -- resolvers.jsを記述する、Mutationもしてみる

更新の前に、まずは今までデータがモックだったので実際のデータを使えるように変更します。server/src/resolvers.jsを作ってQueryの実装を記述します。

let userCount = 2;
const users = [
  { id: 0, name: 'ushumpei' },
  { id: 1, name: 'apollo' },
];

const resolvers = {
  Query: {
    users: () => users,
  }
};

resolversのQueryオブジェクトの構造がschemaのtype Queryの構造と同じになっていることに注意してください(Queryの中にusersが書いてあります)。users配列を宣言し、resolversオブジェクトにQueryオブジェクトを定義して、その中にクエリの関数usersの実処理を記述しました(予想がつくかも知れませんが、後でこの中にMutationオブジェクトも追加します)。

server/src/schema.jsで上で書いたresolversを読み込みます。 addMockFunctionsToSchema を削除し、 makeExecutableSchemaresolvers を渡します。

import { makeExecutableSchema } from 'graphql-tools';
import resolvers from './resolvers';

export const typeDefs = `
type User {
  id: ID!
  name: String
}

type Query {
  users: [User]
}

const schema = makeExecutableSchema({ typeDefs, resolvers });
export default schema;

localhost:4000/graphiqlで確認してみましょう。

query {
  users {
    id
    name
  }
}

=>

{
  "data": {
    "users": [
      {
        "id": "0",
        "name": "ushumpei"
      },
      {
        "id": "1",
        "name": "apollo"
      }
    ]
  }
}

うまく表示されたでしょうか?次は、更新処理を受け付けるために、 スキーマにMutation型を設定します。

server/src/schema.jsの変更

...
type Mutation {
  addUser(name: String!): User
}
...

type Mutationを追加します。受け付けるmutationとしては、addUserという名前で、nameという文字列の引数が必須だとします。その後、userオブジェクトを戻り値としています。(MutationでもQuery同様、オブジェクトを返します、その際は同じようにフィールドを記述することができます)

server/src/resolver.jsの変更

const resolvers = {
  ...
  Mutation: {
    addUser: (root, args) => {
      const user = { id: String(userCount++), name: args.name };
      users.push(user);
      return user;
    },
  }
};

localhost:4000/graphiqlでの確認してみます。

mutation {
  addUser(name: "hoge") {
    id
    name
  }
}

=>

{
  "data": {
    "addUser": {
      "id": "2",
      "name": "hoge"
    }
  }
}

usersクエリで確かめると、Mutationによりuserが一人追加されたことがわかります。

Step 6 -- クライアントからMutationを行う。更新後のデータ制御を行う。

Mutationを行うテキストエリアのコンポーネントを作成します。このコンポーネントはReact Apolloによって引数に関数mutateが渡されるため、それを使って更新内容を記述します。

client/src/AddUser.jsを作成して以下の内容を記述します。

import React from 'react';
import gql from 'graphql-tag';
import { graphql } from 'react-apollo';

const AddUser = ({ mutate }) => {
  const handleKeyUp = (evt) => {
    if (evt.keyCode === 13) {
      mutate({
        variables: { name: evt.target.value },
      });
      evt.target.value = '';
    }
  };

  return (
    <input
      type="text"
      placeholder="New user"
      onKeyUp={handleKeyUp}
    />
  );
};

export const addUserMutation = gql`
  mutation addUser($name: String!) {
    addUser(name: $name) {
      id
      name
    }
  }
`;

export default graphql(addUserMutation)(AddUser);

if文の部分が更新処理になります。mutate関数にクエリの引数nameを渡してます。

AddUserをclient/src/App.jsから読み込みます。

...
import AddUser from './AddUser';
...
class App extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <div className="App">
          ...
+         <AddUser />
          <UsersList />
        </div>
      </ApolloProvider>
    )
  }
}

画面にテキストエリアが表示されているかと思います。文字入力後Enterで更新クエリが飛ぶようになりました。しかし画面の更新はリロードが必要な状態です。クエリ発行後に更新を行うよう設定できます。

...
+ import { usersListQuery } from './UsersList';

const AddUser = ({ mutate }) => {
  const handleKeyUp = (evt) => {
    if (evt.keyCode === 13) {
      mutate({
        variables: { ... },
+       update: (store, { data: { addUser } }) => {
+         const data = store.readQuery({ query: usersListQuery });
+         data.users.push(addUser);
+         store.writeQuery({ query: usersListQuery, data });
+       },

mutateにupdateという関数を渡します。 ユーザー一覧を取得するusersListQueryの結果を更新することでサーバーへの再問合せなしにユーザー一覧に新たなユーザーを表示します。 usersListQueryの結果はキャッシュに入っているため、readQueryで配列データを取得し、配列にpushして更新し、writeQueryでキャッシュを更新します(キャッシュというかstoreと思った方がわかりやすいかもしれません)。これで更新されるようになりました。

この更新処理はMutationを発行した画面にしか適応されません。同じページを複数人が見ている時のことを考えて、一覧に関して、定期的にデータを更新するように pollInterval を設定しておきましょう。

client/src/UsersList.jsに以下の設定を追記します。

export default graphql(usersListQuery, {
+ options: { pollInterval: 5000 },
})(UsersList);

これだけで5秒おきにクエリを確認しに行ってくれます。

まとめ

以上で完成です。以下のことを扱いました。

  • Apollo Clientにモックネットワークを設定してレスポンスのテストをする
  • React Apolloを使ってコンポーネントとクエリを結びつける
  • Apollo Srver Expressを使ってGraphQLサーバーを立てる
  • Query、Mutationを定義して、実装する

感想

非常に散らかった記事になってしまい申し訳無いです。ここまで読んでくださってありがとうございます。Full-stack React + GraphQL Tutorial劣化コピーっぽくて申し訳ないです、この記事は参考程度にご確認ください。

この記事ではWebSocket部分は扱っていないし、ApolloClientのいい部分をあまり紹介できていない気がします。とりあえず私がApolloClientで面白いと感じた部分は以下の部分です(主に気にしなければならないであろう、データ更新を気軽に実装できるところ);

  • Mutation後のデータ更新
  • Query発行のインターバル制御: pollInterval
  • キャッシュへのAPI: (readQuery,writeQuery, readFragment and writeFragment)
  • OptimisticUI(楽観的UI描画?)
  • クライアントはバックエンドをブラックボックスとして扱うという考え: the backend as a black box

何かお気付きの点ございましたら、お気軽にコメントください!さようなら!

(おまけ) Full-stack React + GraphQL Tutorialで私が詰まったところの解消方法

MockNetworkの作成

これは本記事に書いてあります

customResolversがcacheResolversに変わっていた

Apollo ClientはGraphQLが階層型であるということを利用して、クエリをサーバーに投げる前に、欲しいデータをすでにキャッシュしていないか確かめることができます。そのために、データを一意に表す値に紐づけてキャッシュしておく設定を書きます。

この設定を記述する場所が、Apollo ClientのオプションcustomResolversからInMemoryCacheのcacheResolversに変更になっていたことに大変困惑しました。

const inMemoryCache = new InMemoryCache({
  cacheResolvers: {
    Query: {
      channel: (_, args) => {
        return toIdValue(dataIdFromObject({
          __typename: 'Channel',
          id: args['id'],
        }))
      },
    },
  },
});

ボットとのやりとりが愉快

WebSocketの設定

Apollo CLientのオプションnetworkInterfaceがなくなったため、ApolloLinkとしてWebSocket込みのLinkを作成します。Subscriptionのみの場合、WebSocketの設定はWebSocket用のLinkとHttp用のLinkを組み合わせる必要があります。

const wsLink = new WebSocketLink(new SubscriptionClient('ws://localhost:4000/subscriptions', { reconnect: true }));
const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });
const link = ApolloLink.split(
  ({ query, operationName }) => {
    const operationAST = getOperationAST(query, operationName);
    return !!operationAST && operationAST.operation === 'subscription';
  },
  wsLink,
  httpLink,
);

という感じです。

参考: addGraphQLSubscriptions is removed from 0.9.0 version?