👻

AppSync における汎用 Pub/Sub API をサクッと試す

2023/04/09に公開

これは何

  • AWS AppSync における汎用 Pub/Sub API を試したメモ
    • AppSync, Amplify Framework, React を使用してシンプルな Pub/Sub アプリケーションを動かす
  • 汎用 Pub/Sub API は AppSync や GraphQL 自体に詳しくなくとも、リアルタイムのアプリケーション構築に便利に使うことが出来る

実際に試す手順

マネジメントコンソールから AppSync の GraphQL API を作成

マネジメントコンソールで AppSync を開く

API の作成から「汎用リアルタイム API を作成」を選択して「開始」をクリック

適当な API 名を設定

API の作成完了を待つ

API の作成が完了したら、画面下部の「ダウンロード」をクリックして、生成された JavaScript コード (generated.js) を取得する


React App の実装

create-react-app で React App の雛形を作成

% npx create-react-app appsync-generic-pubsub-sample

npm install aws-amplify で Amplify Framework をインストール

% npm install aws-amplify

generated.js をプロジェクトに配置

先程ダウンロードした generated.js を src ディレクトリにコピーしておく

App.js を実装

マネジメントコンソールで生成されたコードは import の書き方が古いため、以下のように書き換え

import './App.css'
import { useEffect, useState } from 'react'
import { Amplify } from '@aws-amplify/core'
import * as gen from './generated'
App.js 全文
import './App.css'
import { useEffect, useState } from 'react'
import { Amplify } from '@aws-amplify/core'
import * as gen from './generated'

Amplify.configure(gen.config)

function App() {
    const [send, setSend] = useState('')
    const [received, setReceived] = useState(null)

    //Define the channel name here
    let channel = 'robots'

    //Publish data to subscribed clients
    async function handleSubmit(evt) {
        evt.preventDefault()
        evt.stopPropagation()
        await gen.publish(channel, send)
        setSend('Enter valid JSON here... (use quotes for keys and values)')
    }

    useEffect(() => {
        //Subscribe via WebSockets
        const subscription = gen.subscribe(channel, ({ data }) => setReceived(data))
        return () => subscription.unsubscribe()
    }, [channel])

    //Display pushed data on browser
    return (
        <div className="App">
            <header className="App-header">
                <p>Send/Push JSON to channel "{channel}"...</p>
                <form onSubmit={handleSubmit}>
                    <textarea rows="5" cols="60" name="description" onChange={(e) => setSend(e.target.value)}>
                        Enter valid JSON here... (use quotes for keys and values)
                    </textarea>
                    <br />
                    <input type="submit" value="Submit" />
                </form>
                <p>Subscribed/Listening to channel "{channel}"...</p>
                <pre>{JSON.stringify(JSON.parse(received), null, 2)}</pre>
            </header>
        </div>
    )
}

export default App

generated.js を編集

コピーしてきた generated.js も import の書き方が古いため、以下のように書き換え

import { API, graphqlOperation } from '@aws-amplify/api'
generated.js 全文
import { API, graphqlOperation } from '@aws-amplify/api'

export const config = {
    "aws_appsync_graphqlEndpoint": "https://example.appsync-api.ap-northeast-3.amazonaws.com/graphql",
    "aws_appsync_region": "ap-northeast-3",
    "aws_appsync_authenticationType": "API_KEY",
    "aws_appsync_apiKey": "myapikey",
}

export const subscribeDoc = /* GraphQL */ `
    subscription Subscribe($name: String!) {
        subscribe(name: $name) {
            data
            name
        }
    }
`

export const publishDoc = /* GraphQL */ `
    mutation Publish($data: AWSJSON!, $name: String!) {
        publish(data: $data, name: $name) {
            data
            name
        }
    }
`

/**
 * @param  {string} name the name of the channel
 * @param  {Object} data the data to publish to the channel
 */
export async function publish(name, data) {
    return await API.graphql(graphqlOperation(publishDoc, { name, data }))
}

/**
 * @param  {string} name the name of the channel
 * @param  {nextCallback} next callback function that will be called with subscription payload data
 * @param  {function} [error] optional function to handle errors
 * @returns {Observable} an observable subscription object
 */
export function subscribe(name, next, error) {
    return API.graphql(graphqlOperation(subscribeDoc, { name })).subscribe({
        next: ({ provider, value }) => {
            next(value.data.subscribe, provider, value)
        },
        error: error || console.log,
    })
}

/**
 * @callback nextCallback
 * @param {Object} data the subscription response including the `name`, and `data`.
 * @param {Object} [provider] the provider object
 * @param {Object} [payload] the entire payload
 */

動作確認

  • npm start コマンドで React App を localhost で動かす
  • 2つ のブラウザタブでアプリを開いて、任意の JSON を入力して Submit すればどちらのアプリにもリアルタイムに JSON データが反映されることが確認できる
% npm start

仕組みについて

プロトコル

  • 汎用 Pub/Sub API は AppSync GraphQL の Subsription を使用して実現している
  • これは WebSocket で Pub/Sub を実現している

GraphQL スキーマとクエリ

以下のような GraphQL スキーマが生成される

type Channel {
	name: String!
	data: AWSJSON!
}

type Mutation {
	publish(name: String!, data: AWSJSON!): Channel
}

type Query {
	getChannel: Channel
}

type Subscription {
	subscribe(name: String!): Channel
		@aws_subscribe(mutations: ["publish"])
}

クエリ例

mutation PublishData {
    publish(data: "{\"msg\": \"hello world!\"}", name: "robots") {
        data
        name
    }
}
  
subscription SubscribeToData {
    subscribe(name:"channel") {
        name
        data
    }
}

リゾルバーやデータソースについて

  • ミニマムな汎用 Pub/Sub API では、特に Lambda や DynamoDB といったサービス連携は不要なので、シンプルな構成になっている
    • Mutation の publish には NoneDatasource というローカルリゾルバーが設定されている
    • データソースとしては NoneDatasource が設定されている
  • VTL のマッピングテンプレートとして以下のような設定がされている

リクエストマッピングテンプレート

{
  "version": "2017-02-28",
  "payload": {
      "name": "$context.arguments.name",
      "data": $util.toJson($context.arguments.data)
  }
}

レスポンスマッピングテンプレート

$util.toJson($context.result)

感想

  • サクッと Pub/Sub が実現できて便利
  • サンプルコードの例では、以下のような汎用性がある
    • チャンネルを設定して複数の Pub/Sub を独立して扱える
    • 任意の JSON を Pub/Sub で扱える
  • 応用として AppSync, Amplify を組み合わせて IAM や Cognito といった認証機能を付けることも可能
  • API Gateway の WebSocket API や AWS IoT を使って Pub/Sub を実現することも可能だが、個人的には最もマネージドでサクッと Pub/Sub が実現できて好感触

参考情報

Creating generic pub/sub APIs powered by serverless WebSockets - AWS AppSync
https://docs.aws.amazon.com/appsync/latest/devguide/aws-appsync-real-time-create-generic-api-serverless-websocket.html

Tutorial: Local Resolvers - AWS AppSync
https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-local-resolvers.html

Simple serverless WebSocket real-time API with AWS AppSync (little or no GraphQL experience required) | Front-End Web & Mobile
https://aws.amazon.com/jp/blogs/mobile/appsync-simple-websocket-api/

API (GraphQL) - Subscribe to data - JavaScript - AWS Amplify Docs
https://docs.amplify.aws/lib/graphqlapi/subscribe-data/q/platform/js/

試した環境

% sw_vers
ProductName:		macOS
ProductVersion:		13.3
BuildVersion:		22E252

package.json

{
  "name": "appsync-generic-pubsub-sample",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "aws-amplify": "^5.0.25",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

Discussion