e.blog

主にUnity/UE周りのことについてまとめていきます

UnityでGraphQL.Clientを使ってAWS AppSyncを利用する

この記事はUnity Advent Calendar 2021の11日目の記事です。

概要

今回はNuGetからインストールできるGraphQL.Clientを利用して、AWSのAppSyncを利用する手順を書いていこうと思います。

特にSubscriptionを行うには手順が必要でこれを知るのにかなり苦戦しました。

今回のサンプルはGitHubにアップしてあります。

github.com



GraphQLとは

GraphQLについては以下の記事がとても参考になりました。

eh-career.com

上記記事から引用させていただくと以下のように説明されています。

まずGraphQLとは何でしょうか。GraphQLは、Facebookが開発しているWeb APIのための規格で、「クエリ言語」と「スキーマ言語」からなります。

クエリ言語は、GraphQL APIのリクエストのための言語で、これはさらにデータ取得系のquery、データ更新系のmutation、サーバーサイドからのイベントの通知であるsubscriptionの3種類があります。なお、この記事では、総称としてのクエリ言語は「クエリ言語」と書き、クエリの3種のひとつであるqueryは「query」と書くことにします。


スキーマ言語は、GraphQL APIの仕様を記述するための言語です。リクエストされたクエリは、スキーマ言語で記述したスキーマに従ってGraphQL処理系により実行されて、レスポンスを生成します。


GraphQLは、クエリがレスポンスデータの構造と似ていて情報量が多いこと、そしてスキーマによる型付けにより型安全な運用ができることが特徴となっています。

これを自分の言葉で解説すると、

APIスキーマ言語によって定義し、それに則したクエリを実行することでサーバ側の任意の処理を実行することができる。

という感じでしょうか。そして基本的にAPIとして必要なものは「データ取得」「データ更新」「特定処理の実行」なので、それらを querymutationに分けていると考えるといいと思います。また最近では、サーバ側のデータが更新されたことをリアルタイムに知りたい場合があり、これを実現するのが subscription ということになります。

引用した文章と同様、本記事でもクエリ言語クエリ、それぞれの個別の処理は querymutationsubscription と記載することにします。

詳細については上記記事など有用な記事がたくさんあるのでそちらに譲ります。ここでは、冒頭のクライアントを利用してUnity上でそれぞれのクエリが適切に実行できる状態まで実装した内容をまとめていきたいと思います。

AWS AppSyncの設定

まずはAWSのAppSync側からセットアップを行っていきます。今回はAWS側で用意してくれている「サンプルプロジェクトから開始する」を選択して開始します。順番に手順を見ていきましょう。

APIを作成

まずはAWSAppSyncコンソールを開きます。そしてAppSyncコンソール上で「APIを作成」ボタンを押下してプロジェクトを作成します。

f:id:edo_m18:20211114215532p:plain

サンプルプロジェクトの「イベントアプリ」を選択して「開始」を押します。

f:id:edo_m18:20211114215707p:plain

しばらく待つとDynamoDBの作成が終わり、AppSyncの操作画面に遷移します。ここではスキーマの定義やクエリのテストなどが行えるようになっています。

f:id:edo_m18:20211114215837p:plain

スキーマを見てみる

今回は「イベントアプリ」というサンプルプロジェクトなので、イベントを管理するためのアプリを想定したスキーマが最初から定義されています。主な型として Event があります。以下のような形で定義されています。

type Event {
    id: ID!
    name: String
    where: String
    when: String
    description: String
    # Paginate through all comments belonging to an individual post.
    comments(limit: Int = 10, nextToken: String): CommentConnection
}

Event typeは ID 型である必須のフィールド id と、名前や場所などの情報が含まれていることが確認できます。

クエリを見てみる

次に、テストできるクエリを見てみましょう。

コンソールの左側の「クエリ」からクエリテストビューを開くと、デフォルトでいくつかのクエリが設定されています。そのうち、イベントのリストを取得する query は以下のようになっています。

query ListEvents {
  listEvents {
    items {
      id
      name
    }
  }
}

これは ListEvents という query 名で、 Query としてスキーマに登録されている型の中で定義されている listEvents を呼び出していることを示しています。(ちなみに Query は以下のように定義されています)

type Query {
    # Get a single event by id.
    getEvent(id: ID!): Event
    # Paginate through events.
    listEvents(filter: TableEventFilterInput, limit: Int = 10, nextToken: String): EventConnection
}

schema {
    query: Query
    mutation: Mutation
    subscription: Subscription
}

この定義からも分かるように、 listEvents を実行した場合のレスポンスは EventConnection 型となっています。これも見てみると、

type EventConnection {
    items: [Event]
    nextToken: String
}

となっており、 Event のリストが返ることが分かります。

スキーマを定義しそれを呼び出す

以上のように、ほしい情報、ほしいアクションをスキーマとして定義し、それを querymutationsubscription という形で呼び出す、というのが一連の流れとなります。またとても大事な点として、リクエストしたクエリがほぼそのままの構造でレスポンスとして返ってくるという点です。

どういうことかと言うと、 GraphQL ではスキーマに定義されているものであれば形を省略しても問題ないようになっています。例えば例の Query は以下のようになっています。

type Query {
    // 中略
    listEvents(filter: TableEventFilterInput, limit: Int, nextToken: String): EventConnection
}

type EventConnection {
    items: [Event]
    nextToken: String
}

type Event {
    id: ID!
    name: String
    where: String
    when: String
    description: String
    # Paginate through all comments belonging to an individual post.
    comments(limit: Int, nextToken: String): CommentConnection
}

そして listEvents の戻りの型は EventConnection になっており、その中身は items という名前の配列で、中身は Event ということが分かります。つまり、最終的には Event のリストを取得するという、名前通りの振る舞いをするわけです。そして Event にはいくつかのパラメータがあります。(ex. name

しかし、クエリを実行する際はこれをすべて指定する必要はなく、冒頭で紹介したように「ほしいパラメータだけ」を指定してクエリを実行することができます。例を再掲すると以下。

query ListEvents {
  listEvents {
    items {
      id
      name
    }
  }
}

するとサーバからは、 items という配列に Eventidname だけが含まれたデータが返されます。言い換えるとリクエストからレスポンスの型を想像することができるとも言えます。

これがREST APIだと、どういうレスポンスが返ってくるかドキュメントを読まないと分かりません。しかし、 GraphQL ならリクエストからレスポンスが想像できるため実装が容易になります。これはとても大きなメリットです。

Unityによる実装

さてでは実際にUnityで実装していきましょう。Unityによる実装はこちらの記事を参考にさせていただきました。

qiita.com

NuGetをインストール

今回はNuGetで管理されている GraphQL.Client を利用して実装を行うため、まずはNuGetをインストールします。インストールは以下のパッケージをインポートすることで行なえます。

github.com

NuGetForUnityがインポートできたらWindowメニューにNuGetが追加されるので「Manage NuGet Package」をクリックしてGraphQLを検索、インストールします。

f:id:edo_m18:20211128234941p:plain

今回必要なパッケージは以下の2つです。

  • GraphQL.Client
  • GraphQL.Client.Serializer.Newtonsoft

f:id:edo_m18:20211128234852p:plain

queryを実行する

まずは query の実行から見てみましょう。

public async void SendQuery()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
    graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

    GraphQLRequest query = new GraphQLRequest
    {
        Query = _queryInputField.text,
    };

    var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

    Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");
}

実行はさほど長くありませんね。以下から、どういうことをやっているのか見ていきましょう。

GraphQLHttpClientを作成

まずは GraphQLHttpClient オブジェクトを生成し、リクエスト先のURLやヘッダを適切に設定します。

GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

GraphQLRequestを作成

次に、クエリとなる GraphQLRequest オブジェクトを生成します。今回はデータ取得の query です。_queryInputField.text にはUIに自由に query を書けるようにしているので、それを指定しています。

GraphQLRequest query = new GraphQLRequest
{
    Query = _queryInputField.text,
};

リクエストを投げてレスポンスを得る

最後に、生成したクエリを送信してレスポンスを受け取ります。

var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");

これを実行すると以下のようにレスポンスを受け取ることができます。

f:id:edo_m18:20211210114119p:plain

レスポンスを受け取る型の定義

GraphQLのレスポンスを受け取るためには、専用のクラスを定義する必要があります。

SendQueryAsync メソッドの戻り値は GraphQLResponse<T> となっていて、この T が、取得したいレスポンスの型を定義したclass になります。GraphQLResponse<T> クラスには T 型の値が設定された Data というプロパティがあるので、これを経由して実際に取得した値にアクセスします。

ちなみにサンプルの例では以下のようにクラスを定義しています。

public class EventType
{
    public string id { get; set; }
    public string name { get; set; }
    public string where { get; set; }
    public string when { get; set; }
    public string description { get; set; }
}

public class QueryResponse
{
    public EventType getEvent { get; set; }
}

スキーマに定義しているものと型、名称が一致しているのが分かるかと思います。ちなみに注意点として、関数のように呼び出した場合、関数名がパラメータ名として返ってくるので QueryResponse では getEvent という名前のフィールドを定義しています。

実際のqueryとレスポンスの例は以下です。

query MyQuery {
  getEvent(id: "c49ceb83-17f5-43b3-a511-98c3721841d2") {
    id
    name
  }
}
{
  "data": {
    "getEvent": {
      "id": "c49ceb83-17f5-43b3-a511-98c3721841d2",
      "name": "My First Event"
    }
  }
}

mutationを実行する

さて、次は更新を伴う mutation です。といっても、こちらは query と大差ありません。さっとコードを見てみましょう。

public async void SendMutation()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
    graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

    GraphQLRequest request = new GraphQLRequest
    {
        Query = _mutationInputField.text,
    };

    var response = await graphQLClient.SendQueryAsync<CreateCommentResponse>(request, CancellationToken.None);

    Debug.Log($"[Mutation] {JsonConvert.SerializeObject(response.Data)}");
}

Query に渡しているテキスト以外、まったく同じことが分かります。ちなみに mutation は以下のようになります。( query 同様、更新したい内容だけを含めることができます)

mutation MyMutation {
  commentOnEvent(content: "hogehoge", createdAt: "2021/11/10", eventId: "c49ceb83-17f5-43b3-a511-98c3721841d2") {
    commentId
    content
    createdAt
    eventId
  }
}

取得・更新の流れはなんとなく分かったかと思います。次はリアルタイムに値の更新を受け取る方法です。

subscriptionでリアルタイム通知を受け取る

実は今回一番書きたかったのはここですw

これを実現するのにだいぶ苦戦しました。なぜかと言うと、他のリクエストに比べていくつかの前処理が必要になるからです。

まずは処理の流れをざっと見てみましょう。

public async void AddSubscription()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());

    AppSyncHeader appSyncHeader = new AppSyncHeader
    {
        Host = _host,
        ApiKey = _apiKey,
    };

    string header = appSyncHeader.ToBase64String();

    graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");
    graphQLClient.Options.PreprocessRequest = (req, client) =>
    {
        GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
        {
            ["data"] = JsonConvert.SerializeObject(req),
            ["extensions"] = new
            {
                authorization = appSyncHeader,
            }
        };
        return Task.FromResult(result);
    };

    await graphQLClient.InitializeWebsocketConnection();

    Debug.Log("Initialized a web scoket connection.");

    GraphQLRequest request = new GraphQLRequest
    {
        Query = _subscriptionInputField.text,
    };

    var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
    _subscription = subscriptionStream.Subscribe(
        response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
        exception => Debug.Log(exception),
        () => Debug.Log("Completed."));
}

リアルタイムな通知はWebSocketを利用する

サーバの変更をリアルタイムに受け取るための通信は WebSocket を使っています。そのため、通常のクエリを送るエンドポイントとは別に、WebSocketのエンドポイントを設定する必要があります。その設定をしているのが以下です。

// WebSocket向けのエンドポイントを設定する
graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");

// ... 中略 ...

// WebSocketのコネクションを初期化する
await graphQLClient.InitializeWebsocketConnection();

PreprocessRequestを用いてヘッダを調整する

さて、話はこれだけでは終わりません。

どうやらAWSのAppSyncは少し特殊な仕様になっているらしく、以下のように、リクエスト前にヘッダを調整しないとならないようです。

// AuthorizedAppSyncHttpRequestクラスの定義
public class AuthorizedAppSyncHttpRequest : GraphQLHttpRequest
{
    private readonly string _authorization;

    public AuthorizedAppSyncHttpRequest(GraphQLRequest request, string authorization) : base(request)
        => _authorization = authorization;

    public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer)
    {
        HttpRequestMessage result = base.ToHttpRequestMessage(options, serializer);
        result.Headers.Add("X-Api-Key", _authorization);
        return result;
    }
}


// 上記クラスを実際に使うところ
graphQLClient.Options.PreprocessRequest = (req, client) =>
{
    GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
    {
        ["data"] = JsonConvert.SerializeObject(req),
        ["extensions"] = new
        {
            authorization = appSyncHeader,
        }
    };
    return Task.FromResult(result);
};

具体的になにをしているかと言うと、リクエストヘッダに対して data にリクエスト自体をJSON化したものを、そして extensionsX-Api-Key をキーにAuthorizationトークンを指定したデータを付与しています。これを付与することで、AppSyncに対して正常にSubscriptionできるようになります。

あとは通常の手順通りSubscriptionの処理を実装するのみです。

Subscriptionする

最後、Subscriptionの処理を見てみましょう。

GraphQLRequest request = new GraphQLRequest
{
    Query = _subscriptionInputField.text,
};

var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
_subscription = subscriptionStream.Subscribe(
    response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
    exception => Debug.Log(exception),
    () => Debug.Log("Completed."));

クエリを指定する部分はQueryやMutationと代わりありませんね。そして最後の部分。 CreateSubscriptionStream を生成しているのが相違点です。リアルタイム通信なのでストリームなわけですね。

ストリームを作成したら、そのストリームに対して Subscribe してその結果を受け取ります。

実際に動かし、AppSyncのコンソールから mutation を実行した結果がこちらです↓

f:id:edo_m18:20211209181444p:plain

Subscriptionのログが表示され、更新された内容が出力されているのが確認できます。これで無事、リアルタイムに更新を受け取ることができるようになりました。

コード全文

最後に、そんなに長くはないので今回のサンプルコードを全部載せておきます。

using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GraphQL;
using UnityEngine;
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.Newtonsoft;
using Newtonsoft.Json;
using GraphQL.Client.Abstractions;
using TMPro;

public class GraphQLHelloWorld : MonoBehaviour
{
    [SerializeField] private string _host = "example12345.appsync-api.us-east-2.amazonaws.com";
    [SerializeField] private string _realtimeHost = "example12345.appsync-realtime-api.us-east-2.amazonaws.com";
    [SerializeField] private string _apiKey = "YOUR_API_KEY_HERE";

    [SerializeField] private TMP_InputField _queryInputField;
    [SerializeField] private TMP_InputField _mutationInputField;
    [SerializeField] private TMP_InputField _subscriptionInputField;
        
    public class EventType
    {
        public string id { get; set; }
        public string name { get; set; }
        public string where { get; set; }
        public string when { get; set; }
        public string description { get; set; }
    }

    public class CommentType
    {
        public string eventId { get; set; }
        public string commentId { get; set; }
        public string content { get; set; }
        public string createdAt { get; set; }
    }

    public class QueryResponse
    {
        public EventType getEvent { get; set; }
    }

    public class CreateMutationResponse
    {
        public EventType createEvent { get; set; }
    }

    public class CreateCommentResponse
    {
        public CommentType commentOnEvent { get; set; }
    }

    public class SubscriptionResponse
    {
        public CommentType subscribeToEventComments { get; set; }
    }

    private void OnDestroy()
    {
        _subscription?.Dispose();
    }

    private class AppSyncHeader
    {
        [JsonProperty("host")] public string Host { get; set; }

        [JsonProperty("x-api-key")] public string ApiKey { get; set; }

        public string ToJson()
        {
            return JsonConvert.SerializeObject(this);
        }

        public string ToBase64String()
        {
            return Convert.ToBase64String(Encoding.UTF8.GetBytes(ToJson()));
        }
    }

    public class AuthorizedAppSyncHttpRequest : GraphQLHttpRequest
    {
        private readonly string _authorization;

        public AuthorizedAppSyncHttpRequest(GraphQLRequest request, string authorization) : base(request)
            => _authorization = authorization;

        public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer)
        {
            HttpRequestMessage result = base.ToHttpRequestMessage(options, serializer);
            result.Headers.Add("X-Api-Key", _authorization);
            return result;
        }
    }

    private IDisposable _subscription;

    public async void OnClickQuery()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
        graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

        GraphQLRequest query = new GraphQLRequest
        {
            Query = _queryInputField.text,
        };

        var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

        Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");
    }

    public async void OnClickMutation()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
        graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

        GraphQLRequest request = new GraphQLRequest
        {
            Query = _mutationInputField.text,
        };

        var response = await graphQLClient.SendQueryAsync<CreateCommentResponse>(request, CancellationToken.None);

        Debug.Log($"[Mutation] {JsonConvert.SerializeObject(response.Data)}");
    }

    public async void OnClickSubscription()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());

        AppSyncHeader appSyncHeader = new AppSyncHeader
        {
            Host = _host,
            ApiKey = _apiKey,
        };

        string header = appSyncHeader.ToBase64String();

        graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");
        graphQLClient.Options.PreprocessRequest = (req, client) =>
        {
            GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
            {
                ["data"] = JsonConvert.SerializeObject(req),
                ["extensions"] = new
                {
                    authorization = appSyncHeader,
                }
            };
            return Task.FromResult(result);
        };

        await graphQLClient.InitializeWebsocketConnection();

        Debug.Log("Initialized a web scoket connection.");

        GraphQLRequest request = new GraphQLRequest
        {
            Query = _subscriptionInputField.text,
        };

        var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
        _subscription = subscriptionStream.Subscribe(
            response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
            exception => Debug.Log(exception),
            () => Debug.Log("Completed."));
    }
}

ハマったポイント

ここでは、今回のサンプル制作を通していくつかハマった点について調べた記事などを残しておこうと思います。

Subscriptionのリアルタイム更新が動かない

前述のように、subscription の実装に苦戦しました。調べたところAppSyncの実装がその他のGraphQLの実装とやや異なることが問題と分かりました。それでも情報があまりにも少なく、仕方なく開発中のGitHubのissueに質問を投げてみました。

github.com

すると以下のように回答があり、サンプルコードを公開してくれている人のリンクを教えてもらいました。

I've not worked with AppSync myself, but @bjorg created a sample app which might help you along:

https://github.com/bjorg/GraphQlAppSyncTest

実際にAppSyncに対応しているコードがあるのはこちらです。

github.com

こちらの実装を参考にしなんとか無事、subscription を利用してリアルタイムにデータを受け取ることができるようになりました。

実際のコードについては本文で示した通りです。

Subscriptionの実行でエラーになる

AWS AppSyncのコンソールには、手軽にQueryを試せるビューがあります。そこで Subscription を実行した際、エラーが表示される場合があります。これは Subscription 以外がQueryに並んでいると起きるようなので、以下のようにそれ以外を消してから実行するとうまく行きました。

f:id:edo_m18:20211114133126p:plain