🏋️‍♂️

GraphQLのSubscriptionクライアントを自前実装する

2024/03/08に公開

はじめに

この記事は、Flutterで開発していた際、よく使われているGraphQLライブラリではSubscriptionクライアントが実装できなさそうだったため自前で実装した話を個人的な備忘録としてまとめたものです。Flutter以外でも同じ手法で実装できると思います。
なお、Flutter、Dartのアップデートおよび仕様変更によって将来的にこの手順が使用できなくなる可能性があります。(記.2024/3/8)

Subscriptionとは

GraphQLのSubscriptionとは、クライアントがリアルタイムのデータ更新を受け取るためのプロトコルで、例えばライブ配信のコメントやSNSのタイムライン自動更新などで使用されることがあります。
SubscriptionはQueryやMutationと同様にクライアント側からGraphQLスキーマに従ってエンドポイントを叩くことになる訳ですが、そのあたりの処理は便利なパッケージやライブラリがよしなにやってくれることが多い印象です。
Flutterにもgraphql_flutterというライブラリがあったわけですが、どうもSubscriptionが良い感じに動いてくれなかったので自前で実装することにしました。

Subscriptionの仕組み

GraphQLは内部的にはWebSocketで、WebSocket内でやり取りするJSONデータのフォーマットが決まっています。詳しい仕様はgraphql-wsのレポジトリに記載されており、多くのサーバーサイドGraphQLライブラリはこれに則った実装がなされていると思います。
つまり、クライアント側でもWebSocketクライアントを設定し、この仕様に従って適切にWebSocketプロトコルで情報を送ってあげれば、Subscriptionの初期化と接続が可能です。この記事ではFlutterでコードを書きますが、他の環境でも同様にSubscriptionを呼び出すことができると思います。

実装

Flutterでの実装はこのようになっています。私がこのコードを書いたのも半年ほど前の話なので記憶が怪しいですが、頑張って読み取っていきましょう。

WebSocketChannel? connectGqlSubscription(String query, String connId) {
  try {
    // websocket graphql client
    final String url = dotenv.get('WEBSOCKET_SERVER_URL');
    final channel = IOWebSocketChannel.connect(
        Uri.parse(url),
        headers: {
          'Sec-WebSocket-Protocol': 'graphql-ws',
        }
    );

    // init graphql connection
    final String initMessage = """
      {
        "id": "$connId",
        "type": "connection_init"
      }
    """;
    channel.sink.add(initMessage);

    // set subscription query
    String startMessage = """
      {
        "id": "$connId",
        "type": "start",
        "payload": {
          "variables": {},
          "extensions": {},
          "operationName": null,
          "query": "${query.replaceAll('\n', '')}"
        }
      }
    """;
    channel.sink.add(startMessage);
    return channel;

  } catch (e) {
    return null;
  }
}

WebSocketへの接続

ここでは一般的なWebSocketへの接続を行っています。この実装ではWebSocketとの接続をよしなにやってくれるパッケージを使っていますが、別にこのくらいならわざわざライブラリを使わなくてもいいと思います。
前述したプロトコルに則ったSubscriptionで実装する場合、'Sec-WebSocket-Protocol': 'graphql-ws'というHeaderが無いとサーバーサイドで受け付けてくれないことがあるため、Headerを設定しましょう。

    final String url = dotenv.get('WEBSOCKET_SERVER_URL');
    final channel = IOWebSocketChannel.connect(
        Uri.parse(url),
        headers: {
          'Sec-WebSocket-Protocol': 'graphql-ws',
        }
    );

コネクションの初期化

これ以降、データのやり取りはWebSocketプロトコルでgraphql-wsの仕様に則ったJSONを送(受)信することになります。
graphql-wsで接続を行う場合、各々のコネクションが固有のIDを持っている必要があるため、ここではconnetction_initというtypeを使用してUUIDなどを適当に渡し、接続を確立しています。

    final String initMessage = """
      {
        "id": "$connId",
        "type": "connection_init"
      }
    """;
    channel.sink.add(initMessage);

Subscriptionクエリの送信

ここでpayloadにSubscriptionクエリを挿入したJSONをWebSocketプロトコルで送信します。variableやextensionなどのオプションについては私もよく分かっていません……
クエリ全体を丸々文字列としてJSONで渡す必要があるため、改行等が含まれているとサーバーサイドでJSONを展開する際の仕様によっては動作しない可能性があります。私はサーバーサイドの方も実装していてそれを知っていたので適当に改行文字を一括置換しています。

    String startMessage = """
      {
        "id": "$connId",
        "type": "start",
        "payload": {
          "variables": {},
          "extensions": {},
          "operationName": null,
          "query": "${query.replaceAll('\n', '')}"
        }
      }
    """;
    channel.sink.add(startMessage);
    return channel;

おわりに

ということでGraphQLのSubscriptionクライアントを自前実装したので解説してみました。
ここではgraphql-wsの詳しい内部仕様の説明は行っていないので、気になる方は仕様から頑張って読み取ってください。私も人に説明できるほど読み込んだ訳ではないので……

Discussion