AWS AppSync + Local Resolver でチャットっぽいものを作ってみる
AWS AppSync というと、私は「Google Firestore 対抗のオフライン対応リアルタイムデータベース、ただし API は GraphQL」という認識だったのですが、もう少し機能をブレークダウンしていくと、「単なるマネージドな WebSocket 的サービス」としても使えるな、と気付きまして、試しにチャットっぽいアプリを作ってみました。
こんなやつ↓
AppSync 側の設定
データソースは使用しません(=ローカルリゾルバーを使用)。
これによりデータは永続化されずただの 「WebSocket的サーバー」 として動作します。
データを永続化したい場合は、DynamoDB や RDB のデータソースを作成して、スキーマと接続します。
通知を受信した際に特別なロジックを呼び出したい場合は、Lambda や Httpエンドポイント のデータソースを作成して接続します。
私(の会社)の用途で多くなりそうなのは Lambda との連携ですが、今回は省略します。
スキーマやマッピングテンプレートの設定はquery
は使ってないので省略)。
type Mutation {
addPost(id: ID!, content: String, send_at: String): Post!
}
type Post {
id: ID!
content: String
send_at: String
}
type Subscription {
onAddPost: Post
@aws_subscribe(mutations: ["addPost"])
}
schema {
mutation: Mutation
subscription: Subscription
}
チャットの投稿が Mutation の addPost
で、Subsctiption の onAddPost
がそれの監視者、という単純なものです。
クライアント側
今回は TypeScript + node.js でコンソールアプリとして作成します。
GraphQL クライアントには ApolloClient を使用します。AWS 独自の要素と思われる箇所のために、 aws-mobile-appsync-sdk-js も組み合わせて使用します。
ApolloClient 初期化
// import { createAuthLink, AuthOptions } from 'aws-appsync-auth-link';
// import { createSubscriptionHandshakeLink } from 'aws-appsync-subscription-link';
// import { ApolloClient, InMemoryCache, ApolloLink, gql } from '@apollo/client/core';
// import fetch from 'node-fetch';
// import ws from 'ws';
global['fetch'] = fetch;
global['WebSocket'] = ws;
const url = appSyncConfig.aws_appsync_graphqlEndpoint;
const region = appSyncConfig.aws_appsync_region;
const auth: AuthOptions = {
type: 'API_KEY',
apiKey: appSyncConfig.aws_appsync_apiKey
};
const link = ApolloLink.from([
createAuthLink({ url, region, auth }),
createSubscriptionHandshakeLink({ url, region, auth })
]);
const client = new ApolloClient({
link,
cache: new InMemoryCache(),
});
まず ApolloClient を node で使用する場合 @apollo/client
から import しちゃダメです。こちらは react に依存しており Error: Cannot find module 'react'
エラーになります。代わりに @apollo/client/core
から import します。
よく分からないところ
Using Authorization and Subscription links with Apollo Client (No offline support) に紹介されているコードと2点ほど違います。
-
createHttpLink()
したものをApolloLink.from(...
に渡しているが、それだと実行時エラー(Received status code 400
)になる。 - ブラウザじゃないので fetch と WebSocket が無い。ので global に設定して騙しちゃってるけどこれでいいのか…。ApolloClient の createHttpLink で fetch を指定できるようだけど、 1. がエラーになるので使えず。。
動いたからヨシ!としておきます。
投稿の受信
// type Post = {
// id: string
// content: string
// send_at: string
// }
const subscription = client.subscribe<{ onAddPost: Post }>({
query: gql`
subscription MySubscription {
onAddPost {
id
content
send_at
}
}
`})
.subscribe(x => {
if (x.data?.onAddPost != null) {
const p = x.data?.onAddPost;
console_out(`${p.send_at}:${p.content}`);
}
});
.subscribe(..).subscribe(..)
という一見わけの分からないコードだけど、一つ目は ApolloClient の、二つ目は Observable の subscribe ということで理解しました。
「Observable だから .pipe(
でチェインできるのかな?」と思ったらこれは RxJS ではなく ZenObservable という別の実装だった!
投稿の実行
// const rl = readline.createInterface(process.stdin, process.stdout);
const readLinePromise = () => new Promise<string>(r => {
rl.question("comment?(\\q to exit) > ", async (comment) => {
rl.prompt(true);
r(comment);
});
})
setTimeout(async () => {
while (true) {
const comment = await readLinePromise();
if (comment === '\\q') {
subscription?.unsubscribe();
process.exit(0);
}
await client.mutate({
mutation: gql`
mutation MyMutation {
addPost(id: "${autoId()}", content: "${comment}", send_at: "${moment().toISOString()}") {
id,
content,
send_at
}
}
`,
})
}
}, 100)
- setTimeout してるのは async/await 使いたかったからだけです(Top-Level await 使えたっけ?)。
-
readLinePromise()
は ReadableStream にできたらfor await (..) { }
でもっとスマートに書けるよなあ - パラメータは、 string interpolation じゃなくて variables を使うべき?
とかいろいろあるけど、こちらも送信できたのでまずはヨシ!
Complete Code は
に。
Discussion