開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. AWS >
  4. AWS AppSyncのGraphQL Subscriptionsのクライアントを作って裏側の仕組みを学ぶ
AWS AppSyncのGraphQL Subscriptionsのクライアントを作って裏側の仕組みを学ぶ

AWS AppSyncのGraphQL Subscriptionsのクライアントを作って裏側の仕組みを学ぶ

こんにちは、ナカエです。

マネージドなGraphQLサーバを手軽に作れるAWS AppSyncがじわじわと人気を集めてきているようですね。

AppSync APIのクライアントとしては公式のSDKたるAmplifyを利用すると楽ちんです。

が、しかし今回はAppSyncのGraphQL Subscriptions実装の仕組みを確認するため、Node.jsとWebSocketクライアントを使ってリアルタイム通信のクライアントを実装しました。

GraphQLのスキーマとAppSync API の認証モード

本記事のサンプルはトピックごとにメッセージを投稿/受信する簡易なチャットルームアプリを想定しています。

Mutationはメッセージの投稿に、Subscriptionはサーバ側のAppSyncからクライアントへのメッセージの受信に対応します。

// Queryなど一部省略

// 投稿されるメッセージの型
type TopicMessage {
    userId: String!
    time: String!
    topicId: String!
    message: String!
}

// メッセージ投稿用のMutation
type Mutation {
    createTopicMessage (
        topicId: String!
        comment: String!
    ): TopicMessage
}

// メッセージ受信用のSubscription
type Subscription {
    topicMessageAdded(topicId: String!): TopicMessage
    @aws_subscribe(mutations: ["createTopicMessage"]) // ← createTopicMessageのMutationからSubscriptionをトリガーするという意味の設定
}

AppSync APIの認証モードは複数ありますが、一番手軽な API キー を選択しました。

※ 動作確認のためにはMutationの中身を別途作成する必要がありますが、本記事では紹介しません。

クライアントを書く

環境

  • Mac OS 10.15.6
  • Node v12.14.0
    • uuid v8.3.1
    • websocket v1.0.32

npmでプロジェクトを作成

mkdir subscription-client
cd subscription-client
# 初期化
npm init

# 対話形式で適宜設定する

# SubscriptionごとのIDをクライアント側で決定するため、UUIDなどの衝突しにくい文字列が必要
npm install websocket uuid
# IDEでの補助として型定義も追加
npm install --save-dev @types/websocket @types/uuid

ソースコード

AppSyncのドキュメントの リアルタイム WebSoceket クライアントの構築 およびWebSocketライブラリの theturtle32/WebSocket-Node のREADME を参考にして、リアルタイムWebSocketクライアントの処理を記述します。

subscribe.js

#!/usr/bin/env node
const Buffer = require('buffer').Buffer;
const uuidv4 = require('uuid').v4;
const WebSocketClient = require('websocket').client;
const base64encode = (data) => {
    return Buffer.from(data).toString('base64');
};

const config = {
    appSync: {
        graphQlEndPointHost: "example1234567890000.appsync-api.ap-northeast-1.amazonaws.com",
        realtimeEndpoint: "wss://example1234567890000.appsync-realtime-api.ap-northeast-1.amazonaws.com/graphql",
        apiKey: "dummy-api-key"
    },
    subscriptionId: uuidv4(),
    topicId: '623bd562-0b90-4c97-83d7-2f44a52b0dd2' // 特定のトピックに投稿されたものだけ購読する
};

const client = new WebSocketClient();

client.on('connectFailed', function(error) {
    console.error('[WebSocket] Failed to connect. ' + error.toString());
});

client.on('connect', function(connection) {
    console.log('[WebSocket] Connected');

    const initData = JSON.stringify({ type: 'connection_init' });
    console.log("[WebSocket] Send message: " + initData);
    connection.sendUTF(initData);

    connection.on('error', function(error) {
        console.log("[WebSocket] Error: " + error.toString());
    });
    connection.on('close', function() {
        console.log('[WebSocket] Closed');
    });

    connection.on('message', function(message) {
        if (message.type !== 'utf8') {
            console.error('[WebSocket] Unknown message', message);
            return;
        }

        console.log("[WebSocket] Message received (UTF8): " + message.utf8Data);

        let serverMessage;
        try {
            serverMessage = JSON.parse(message.utf8Data);
        } catch (e) {
            console.log("[WebSocket] Failed to deserialize message");
            return;
        }

        if (!serverMessage.type) {
            console.log("[WebSocket] Message has no `type` parameter");
            return;
        }

        switch (serverMessage.type) {
            case "connection_ack":
                // Subscription開始用のデータを作成する
                console.log("[WebSocket] Trying to start GraphQL subscription. id = " + config.subscriptionId);
                const startData = JSON.stringify(makeStartSubscriptionMessageData());
                console.log("[WebSocket] Send message: " + startData);
                connection.sendUTF(JSON.stringify(startData));
                return;
            case "data":
                // MEMO: 一本のWebSocket接続で複数のGraphQL Subscriptionを購読する場合
                // 受信したメッセージがどのSubscriptionに属するかは、メッセージの`id`の値で判断する
                return;
            case "ka":
                return;
        }
    });
});

/**
 * GraphQLサブスクリプション登録のメッセージの内容を作成
 */
const makeStartSubscriptionMessageData = () => {
    // サブスクリプションの登録
    const query =
      "subscription MySubscription($topicId: String!) {\n" +
      "topicMessageAdded(topicId: $topicId) {\n" +
      "    message\n" +
      "    time\n" +
      "    topicId\n" +
      "    userId\n" +
      "  }\n" +
      "}\n";

    const payload = {
        data: JSON.stringify({
            query: query,
            variables: { topicId: config.topicId }
        }),
        extensions: {
            authorization: {
                host: config.appSync.graphQlEndPointHost,
                "x-api-key": config.appSync.apiKey,
            }
        }
    };

    // MEMO: 一本のWebsocket接続で複数のGraphQL Subscriptionを購読可能なため、ここで指定したidがSubscriptionの識別に用いられる
    // Subscriptionの購読解除にも必要
    return {
        id: config.subscriptionId,
        payload: payload,
        type: "start"
    };
}

// 接続先のURLを作成
const header = {
    host: config.appSync.graphQlEndPointHost,
    "x-api-key": config.appSync.apiKey
};
const payload = {};
const url = config.appSync.realtimeEndpoint
    + "?header=" + base64encode(JSON.stringify(header))
    + "&payload=" + base64encode(JSON.stringify(payload));

console.log("[WebSocket] Trying to connect: " + url);
client.connect(
  url,
  ['graphql-ws'] // プロトコルは graphql-ws を指定
);

実行する

CLIから suscribe.js を実行します。

node subscribe.js

出力は例えば下記のようになります。

※ Subscriptionをトリガーするため、購読の開始後にAppSyncの管理画面からMutationを実行しました。

[WebSocket] Trying to connect: wss://example1234567890000.appsync-realtime-api.ap-northeast-1.amazonaws.com/graphql?header=eyJob3N0IjoiZXhhbXBsZTEyMzQ1Njc4OTAwMDAuYXBwc3luYy1hcGkuYXAtbm9ydGhlYXN0LTEuYW1hem9uYXdzLmNvbSIsIngtYXBpLWtleSI6ImR1bW15LWFwaS1rZXkifQ==&payload=e30=
[WebSocket] Connected
[WebSocket] Send message: {"type":"connection_init"}
[WebSocket] Message received (UTF8): {"type":"connection_ack","payload":{"connectionTimeoutMs":300000}}
[WebSocket] Trying to start GraphQL subscription. id = f4cb587d-1cb2-411a-b36b-534bf4947231
[WebSocket] Send message: {"id":"f4cb587d-1cb2-411a-b36b-534bf4947231","payload":{"data":"{\"query\":\"subscription MySubscription($topicId: String!) {\\ntopicMessageAdded(topicId: $topicId) {\\n    message\\n    time\\n    topicId\\n    userId\\n  }\\n}\\n\",\"variables\":{\"topicId\":\"623bd562-0b90-4c97-83d7-2f44a52b0dd2\"}}","extensions":{"authorization":{"host":"example1234567890000.appsync-api.ap-northeast-1.amazonaws.com","x-api-key":"dummy-api-key"}}},"type":"start"}
[WebSocket] Message received (UTF8): {"type":"ka"}
[WebSocket] Message received (UTF8): {"id":"f4cb587d-1cb2-411a-b36b-534bf4947231","type":"start_ack"}
[WebSocket] Message received (UTF8): {"id":"f4cb587d-1cb2-411a-b36b-534bf4947231","type":"data","payload":{"data":{"topicMessageAdded":{"message":"Hello, world!","time":"1605255328","topicId":"623bd562-0b90-4c97-83d7-2f44a52b0dd2","userId":"7"}}}}
[WebSocket] Message received (UTF8): {"type":"ka"}

受信したかった topicMessageAdded のデータに加え、キープアライブのメッセージが一定時間ごとに送られてきているのも確認できます。
通信の手順はドキュメント通りですね。

スクリーンショット 2020-11-13 18.54.20.png

まとめ

  • NodeとWebSocketのライブラリを利用して、AppSyncの GraphQL Subscriptionのデータを購読するクライアントを実装した
  • 一から作るのは手間なのでなるべく公式の Amplify に頼りたい
  • なお、通信内容を見るだけであれば、AmplifyやAWS AppSync管理コンソールでSubscriptionsのデータを購読し、ChromeのデベロッパーツールのNetworkタブでWebSocket通信を観察できる
TOPに戻る