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の作り方が割と丁寧に書かれております。
- Angular + Apollo Client
- Angular + Apollo Client の Subscriptions
- Apollo Server: Understanding schema concepts
- Graphql Subscriptions
ここら辺の断片的な情報をまとめて一本で動かせるようにしたのが今回のサンプル実装です。
また上記の 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
さっそくですが今回作るサンプルのモデルの定義は以下のようになっております。
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の定義です。
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
を見てみましましょう。
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を呼び出すようにしています。
続いてここから呼び出されるサービスのクラスは以下のようにしました。
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です。
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);
}
ここでのミソは、
-
const app = express();
で作ったExpressのインスタンス作成し - それを
server.applyMiddleware({ app, path: '/graphql' });
でApollo-serverに喰わせ - さらに
const httpServer = createServer(app);
でもう一個httpServerのインスタンス?を作らせ - それを
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
まずはこちらもコアのモデル定義から。
export interface BookEntity {
id: string;
author: string;
title: string;
}
export const bookFields = `
id
title
author
`;
サーバー側と同じ定義を書きますが、input用の定義が必要ないのでbookFieldsだけの定義でOKです。
続いてService側の実装その1、です。
まずはQueryを呼び出し、結果を queryRef.valueChanges
で受け取っているところです。
...
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
に変換しているのがちょっとわかりにくい箇所です。
続いて更新処理、ですね。
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の箇所です。
...
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の通信が互いに疎になったおかげでだいぶすっきりした感じになってますね。
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じゃきつかったよね。。。ってのが伝わってきますね!