💬

AWS AppSync + Local Resolver でチャットっぽいものを作ってみる

2021/08/18に公開

AWS AppSync というと、私は「Google Firestore 対抗のオフライン対応リアルタイムデータベース、ただし API は GraphQL」という認識だったのですが、もう少し機能をブレークダウンしていくと、「単なるマネージドな WebSocket 的サービス」としても使えるな、と気付きまして、試しにチャットっぽいアプリを作ってみました。

こんなやつ↓

AppSync 側の設定

データソースは使用しません(=ローカルリゾルバーを使用)。
これによりデータは永続化されずただの 「WebSocket的サーバー」 として動作します。

データを永続化したい場合は、DynamoDB や RDB のデータソースを作成して、スキーマと接続します。
通知を受信した際に特別なロジックを呼び出したい場合は、Lambda や Httpエンドポイント のデータソースを作成して接続します。
私(の会社)の用途で多くなりそうなのは Lambda との連携ですが、今回は省略します。

スキーマやマッピングテンプレートの設定は
https://github.com/amay077/aws-appsync-chat-terminal-sample
に書きました。スキーマだけ下に載せときます(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点ほど違います。

  1. createHttpLink() したものを ApolloLink.from(... に渡しているが、それだと実行時エラー(Received status code 400)になる。
  2. ブラウザじゃないので 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 は
https://github.com/amay077/aws-appsync-chat-terminal-sample
に。

Discussion