LoginSignup
11
10

More than 5 years have passed since last update.

【2019年2月末版】Apollo + Express + Angular で GraphQL Pub/Sub実装 〜 Subscriptionちゃんとね

Posted at

Apollo + Express + Angular による GraphQL クラサバ実装

GraphQLのSubscription機能がいよいよ使えるようになってきた?とのことなので、GraphQLのPub/Sub実装にチャレンジしてみました。
バック側のGraphQLサーバーは Apollo + Expressで、フロント側のWebサーバーはApollo + Angularという構成です。
今回は実証実験が目的なので簡易にオールインワンのAngularアプリ?として構成します。

成果物はこちら → github.com/nGraphQL にございます。

オールインワンなのでExpressもTypeScriptです。ts-nodeで頑張らせてます。

参考サイト

GraphQLの定義、書き方などは本家 GraphQL.org のサイトで非常に細かく書かれておりますので、これをよく見ればよくわかると思います。(キツイですけど。。。)

またApolloのサイトにはServer, Clientの作り方が割と丁寧に書かれております。

ここら辺の断片的な情報をまとめて一本で動かせるようにしたのが今回のサンプル実装です。
また上記の Apollo Clientのドキュメントは使用するフレームワークごとにちゃんと別の記事になっているので、例えば上記のSubscriptionsのページもあれはAngularのページなのでReactのページからSubscriptionsに飛べばReactでのガイドがちゃんと載っています。なかなか頑張ってますね〜

動かし方

とりあえず動かしてみた方が手っ取り早いと思いますので以下の手順で起動します。

GraphQL サーバーの起動

$ npm run server

でGraphQLサーバーが起動し、http://localhost:4000/graphqlがエンドポイントとなります。
また、同じアドレスでws://localhost:4000/graphql がSubscription側のエンドポイントとなります。

ブラウザで直接http://localhost:4000/graphqlを開くとApolloのGraphQLワークショップ?画面が開くのでここでもGraphQL APIを直接実行できます。

Angular サーバーの起動

$ npm run start

で、いつものng serveが動きます。待ち受けURLはhttp://localhost:4200です。
ブラウザで叩けばシンプルな画面が開きます。

本の著者とタイトルの一覧、登録フォーム、削除ボタンのシンプルな構成です。

開発の手順

具体的な開発の手順を記載していきます。

準備

例によって@angular/cliから作成します。

$ ng new nGraphQL
$ cd nGraphQL

プロジェクト名は Angular系のプロジェクトでよくある"ng"から"nGraphQL"としてみましたが、特に意味はないです。

必要なパッケージをインストールします。

$ npm install -S graphql apollo-server-express apollo-client apollo-angular apollo-angular-link-http apollo-cache-inmemory apollo-link-ws

実際の依存パッケージ、スクリプトなどは package.json をご確認ください。

GraphQL サーバーの実装

Apollo + Express によるGraphQLサーバーの実装手順を記載します。

サーバー用プログラム置き場は安易にserverとしてしまいます。

$ mkdir server && cd server

さっそくですが今回作るサンプルのモデルの定義は以下のようになっております。

BookEntry.ts
export interface BookEntity {
  id: string;
  author: string;
  title: string;
}

export const bookFields = `
  id: ID
  title: String
  author: String
`;

id, author, titleの3つのフィールドだけからなるシンプルなモデルをインタフェースとして定義しました。
また、この3フィールドは次のGraphQLのスキーマ定義でも利用するためここにフィールドと型だけの文字列をbookFieldsとしてconstでexportしておきます。
GraphQLの型として面白いのが文字列だけどIDという型 があるということですね。これでデータのどこでユニークかどうか判断するか、というのがわかるようになってます。

続いて、GraphQLの定義です。

schema.ts
import { gql } from 'apollo-server-express';
import { bookFields } from './BookEntity';

export const TypeDefs = gql`
  type Query {
    books(author: String): [Book]
  }
  type Mutation {
    book(item: BookEntry!): Book
    removeBook(item: BookEntry!): Book
  }
  type Subscription {
    bookChanged: Book
  }
  type Book {
    ${bookFields}
  }
  input BookEntry {
    ${bookFields}
  }
`;

Queryではauhtorを条件とする検索クエリのAPIを定義しています。
MutationではBookEntryを受け取る、新規作成のbookと削除のremoveBookを定義しています。
SubscriptionではBookEntryの変更があったらPushされるbookChangedを定義しています。

入力値用の型はたとえフィールドが全く一緒であっても typeとinputで分けないとダメ、です。

サービスより先にこのGraphQLを直接的に実行するresolverを見てみましましょう。

resolver.ts
import {
  BookServiceWithPub,
  pubsub,
  BOOK_INSERT,
  BOOK_DELETE
} from './BookPublishService';

const bookService = new BookServiceWithPub();

export const resolvers = {
  Query: {
    books: (obj, args, context, info) => {
      return bookService.findByAuthor(args.author);
    }
  },
  Mutation: {
    book: (obj, args, context, info) => {
      return bookService.update(args.item);
    },
    removeBook: (obj, args, context, info) => {
      return bookService.delete(args.item);
    }
  },
  Subscription: {
    bookChanged: {
      subscribe: () => pubsub.asyncIterator([BOOK_INSERT, BOOK_DELETE])
    }
  }
};

上記のGraphQLのスキーマ定義と対をなすファイルとなっております。Apolloがダイレクトにバインドするのはこのresolverです。ここから各種serviceを呼び出すようにしています。

続いてここから呼び出されるサービスのクラスは以下のようにしました。

BookPublishService.ts
import { PubSub } from 'graphql-subscriptions';
import { BookRepository } from './BookRepository';
import { BookEntity } from './BookEntity';

export const BOOK_INSERT = 'book_event_success_inserted';
export const BOOK_DELETE = 'book_event_success_deleted';
export const pubsub = new PubSub();
export class BookServiceWithPub {
  repo = new BookRepository();

  update(entity: BookEntity) {
    const ret = this.repo.change(entity);
    pubsub.publish(BOOK_INSERT, { bookChanged: ret });
    return ret;
  }

  findByAuthor(author) {
    return this.repo.findByAuthor(author);
  }

  delete(entity: BookEntity) {
    const ret = this.repo.delete(entity);
    pubsub.publish(BOOK_DELETE, { bookChanged: ret });
    return ret;
  }
}

updateとdeleteの際に pubsub.publishとやっている箇所がミソですね。Publishされるオブジェクトも {メソッド名:戻り値}とする必要があります。

BookEntryRepository以降はDB書き込みなどの処理を書いてください。今回はサンプルなので、直接配列としてダミーデータを保持していますが・・・

そして本丸、serverの index.tsです。

index.ts
import * as express from 'express';
import { createServer } from 'http';
import { ApolloServer } from 'apollo-server-express';
import { TypeDefs } from './schema';
import { resolvers } from './resolvers';

const PORT = 4000;

const app = express();

process.on('uncaughtException', err => console.error(err));
process.on('unhandledRejection', err => console.error(err));
// CORSを許可する
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept'
  );
  next();
});
// resolvers
const server = new ApolloServer({ typeDefs: TypeDefs, resolvers });
server.applyMiddleware({ app, path: '/graphql' });

try {
  // ws_server
  const httpServer = createServer(app);

  server.installSubscriptionHandlers(httpServer);

  httpServer.listen({ port: PORT }, err => {
    console.log(
      `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
    );
    console.log(
      `🚀 Websocket Server is now running on ws://localhost:${PORT}${
        server.subscriptionsPath
      }`
    );
    if (err) {
      throw new Error(err);
    }
  });
} catch (error) {
  console.error(error);
}

ここでのミソは、
1. const app = express();で作ったExpressのインスタンス作成し
2. それをserver.applyMiddleware({ app, path: '/graphql' });でApollo-serverに喰わせ
3. さらにconst httpServer = createServer(app);でもう一個httpServerのインスタンス?を作らせ
4. それをserver.installSubscriptionHandlers(httpServer);でApollo-serverにSubscription用のサーバーとして噛ませている

という2段構成である点です。

Query, MutationのAPIは通常のHTTPですが、SubscriptionはサーバーからPushさせるためにWebSocketを使用します。このため別のサーバーインスタンス?が必要らしいです。

また、Angularのサイトとポートが違うためクロスオリジンの制約に引っかかってきます。なのでオールインワンですが、CORSの許可を入れてます。

仕上げにtsconfig.jsonのmoduleをcommonjsに修正します。

    "module": "commonjs",

上記の手順で、

$ ts-node server/index.ts

で ExpressでのApolloサーバーが起動します。
このコマンドを package.jsonにserverとして加えておきます。

Angularクライアントの実装

クライアント以下の実装を src/app/以下で行います。

$ cd src/app

まずはこちらもコアのモデル定義から。

book.ts
export interface BookEntity {
  id: string;
  author: string;
  title: string;
}

export const bookFields = `
  id
  title
  author
`;

サーバー側と同じ定義を書きますが、input用の定義が必要ないのでbookFieldsだけの定義でOKです。

続いてService側の実装その1、です。
まずはQueryを呼び出し、結果を queryRef.valueChanges で受け取っているところです。

book.service.ts
...
export class BookService {
  constructor(private apollo: Apollo, private bookSubs: BookSubscriber) {}

  searchBooks(term: String): Observable<Map<String, BookEntity>> {
    if (!term.trim()) {
      term = '*';
    }
    const queryRef = this.apollo.watchQuery<any>({
      query: gql`{
          books(author:"${term}"){
            ${bookFields}
          }
        }`
    });
    // 追加でSubScribe
    queryRef.subscribeToMore(this.bookSubs.subscribeUpdateBooks());

    return queryRef.valueChanges.pipe(
      map(
        result =>
          result.data.books.reduce((m, o) => {
            m[o.id] = o;
            return m;
          }, {}) as Map<String, BookEntity>
      )
    );
  }

通常のQueryをwatchQueryしたあと、そのQueryRefに対してさらにSubscribeToMoreで監視するクエリを追加しています。
SubscribeToMoreの中身は後ほど載せます。
サーバーからリストで飛んでくる結果をMapに変換しているのがちょっとわかりにくい箇所です。

続いて更新処理、ですね。

book.service.ts
  updateBook(id: string, author: string, title: string) {
    return this.apollo
      .mutate({
        mutation: gql`
          mutation CreateBook($book: BookEntry!) {
            book(item: $book) {
              ${bookFields}
            }
          }
        `,
        variables: {
          book: {
            id: id,
            author: author,
            title: title
          }
        }
      })
      .subscribe(() => {
        this.apollo.getClient().cache.reset();
      });
  }

mutateの第2引数でvariablesを渡しています。当然ですがGraphQLのスキーマ通りの構造にしてあげる必要があります。any型なのでVS Code上ではコード補完の恩恵が受けれないのが渋いところです。GraphQLサーバーの例の画面からコピペしてくるのがよいかもです。
Mutationクエリを送った後、更新があったレコードはSubScriptionで通知されます。ですのでここでは戻り値を受け取らず、apolloのクエリキャッシュを消去するだけとなっております。
削除の処理も同様ですので割愛します。

続いてSubScriptionの箇所です。

book.subscriber.ts
...
const BOOKS_SUBSCRIPTION = gql`
  subscription bookChanged{
    bookChanged{
      ${bookFields}
    }
  }
`;

@Injectable({
  providedIn: 'root'
})
export class BookSubscriber {
  subscribeUpdateBooks<T extends BookEntity[]>() {
    return {
      document: BOOKS_SUBSCRIPTION,
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) {
          return prev;
        }
        const bookEntry = subscriptionData.data.bookChanged;
        if (bookEntry != null) {
          const books = prev.books as T;
          const targets = books.filter(e => e.id == bookEntry.id);
          if (targets.length == 0) {
            books.push(bookEntry);
          } else {
            delete books[books.indexOf(targets[0])];
          }
          return prev;
        } else {
          console.error('called updateQuery but bookChanged is null...');
        }
      }
    };
  }
}

ここで厄介なのはQueryで配列を受け取りますがSubScriptionでは単一のレコードが飛んでくる、という点です。
prev.booksにはQueryの結果のListが入っているので受け取った単一のレコードと突き合わせてList全体をちゃんとメンテナンスする必要があるってことです。なんか、2度手間っていうか・・・

最後はViewのバックグランドとなるcomponentです。ここはQueryとSubscriptionの通信が互いに疎になったおかげでだいぶすっきりした感じになってますね。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs';

import { debounceTime, switchMap } from 'rxjs/operators';

import { BookEntity } from '../service/book';
import { BookService } from '../service/book.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'GraphQLBooks';
  books: Observable<Map<String, BookEntity>>;

  private searchTerms = new Subject<string>();

  constructor(private service: BookService) {}

  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.books = this.searchTerms.pipe(
      debounceTime(300),
      switchMap((term: string) => this.service.searchBooks(term))
    );
  }

  add(author: string, title: string) {
    this.service.updateBook('', author, title).unsubscribe();
  }

  update(id: string, author: string, title: string) {
    this.service.updateBook(id, author, title).unsubscribe();
  }

  remove(id: string) {
    this.service.removeBook(id).unsubscribe();
  }
}

ビューのHTMLはひどいので掲載は割愛いたします。。。

さて、一通りの実装が終わったら、ng startで Angular サーバが起動します。

ブラウザから http://localhost:4200 を開いて動きをサイド確認してみましょう。

最後に

今回は Subscriptionのクライアント・サーバー共にちゃんとまとまっている情報が見当たらなかったので試行錯誤いたしました。
GithubのAPIみたいなものを見ると確かに今までのREST APIじゃきつかったよね。。。ってのが伝わってきますね!

11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10