この記事はUnity Advent Calendar 2021の11日目の記事です。
概要
今回はNuGetからインストールできるGraphQL.Clientを利用して、AWSのAppSyncを利用する手順を書いていこうと思います。
特にSubscriptionを行うには手順が必要でこれを知るのにかなり苦戦しました。
今回のサンプルはGitHubにアップしてあります。
GraphQLとは
GraphQLについては以下の記事がとても参考になりました。
上記記事から引用させていただくと以下のように説明されています。
まずGraphQLとは何でしょうか。GraphQLは、Facebookが開発しているWeb APIのための規格で、「クエリ言語」と「スキーマ言語」からなります。
クエリ言語は、GraphQL APIのリクエストのための言語で、これはさらにデータ取得系のquery、データ更新系のmutation、サーバーサイドからのイベントの通知であるsubscriptionの3種類があります。なお、この記事では、総称としてのクエリ言語は「クエリ言語」と書き、クエリの3種のひとつであるqueryは「query」と書くことにします。
スキーマ言語は、GraphQL APIの仕様を記述するための言語です。リクエストされたクエリは、スキーマ言語で記述したスキーマに従ってGraphQL処理系により実行されて、レスポンスを生成します。
GraphQLは、クエリがレスポンスデータの構造と似ていて情報量が多いこと、そしてスキーマによる型付けにより型安全な運用ができることが特徴となっています。
これを自分の言葉で解説すると、
APIをスキーマ言語によって定義し、それに則したクエリを実行することでサーバ側の任意の処理を実行することができる。
という感じでしょうか。そして基本的にAPIとして必要なものは「データ取得」「データ更新」「特定処理の実行」なので、それらを query
、 mutation
に分けていると考えるといいと思います。また最近では、サーバ側のデータが更新されたことをリアルタイムに知りたい場合があり、これを実現するのが subscription
ということになります。
引用した文章と同様、本記事でもクエリ言語はクエリ、それぞれの個別の処理は query
、mutation
、subscription
と記載することにします。
詳細については上記記事など有用な記事がたくさんあるのでそちらに譲ります。ここでは、冒頭のクライアントを利用してUnity上でそれぞれのクエリが適切に実行できる状態まで実装した内容をまとめていきたいと思います。
AWS AppSyncの設定
まずはAWSのAppSync側からセットアップを行っていきます。今回はAWS側で用意してくれている「サンプルプロジェクトから開始する」を選択して開始します。順番に手順を見ていきましょう。
APIを作成
まずはAWSのAppSyncコンソールを開きます。そしてAppSyncコンソール上で「APIを作成」ボタンを押下してプロジェクトを作成します。
サンプルプロジェクトの「イベントアプリ」を選択して「開始」を押します。
しばらく待つとDynamoDBの作成が終わり、AppSyncの操作画面に遷移します。ここではスキーマの定義やクエリのテストなどが行えるようになっています。
スキーマを見てみる
今回は「イベントアプリ」というサンプルプロジェクトなので、イベントを管理するためのアプリを想定したスキーマが最初から定義されています。主な型として 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
のリストが返ることが分かります。
スキーマを定義しそれを呼び出す
以上のように、ほしい情報、ほしいアクションをスキーマとして定義し、それを query
、 mutation
、 subscription
という形で呼び出す、というのが一連の流れとなります。またとても大事な点として、リクエストしたクエリがほぼそのままの構造でレスポンスとして返ってくるという点です。
どういうことかと言うと、 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
という配列に Event
の id
と name
だけが含まれたデータが返されます。言い換えるとリクエストからレスポンスの型を想像することができるとも言えます。
これがREST APIだと、どういうレスポンスが返ってくるかドキュメントを読まないと分かりません。しかし、 GraphQL
ならリクエストからレスポンスが想像できるため実装が容易になります。これはとても大きなメリットです。
Unityによる実装
さてでは実際にUnityで実装していきましょう。Unityによる実装はこちらの記事を参考にさせていただきました。
NuGetをインストール
今回はNuGetで管理されている GraphQL.Client
を利用して実装を行うため、まずはNuGetをインストールします。インストールは以下のパッケージをインポートすることで行なえます。
NuGetForUnityがインポートできたらWindowメニューにNuGetが追加されるので「Manage NuGet Package」をクリックしてGraphQLを検索、インストールします。
今回必要なパッケージは以下の2つです。
- GraphQL.Client
- GraphQL.Client.Serializer.Newtonsoft
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)}");
これを実行すると以下のようにレスポンスを受け取ることができます。
レスポンスを受け取る型の定義
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化したものを、そして extensions
に X-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
を実行した結果がこちらです↓
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に質問を投げてみました。
すると以下のように回答があり、サンプルコードを公開してくれている人のリンクを教えてもらいました。
I've not worked with AppSync myself, but @bjorg created a sample app which might help you along:
実際にAppSyncに対応しているコードがあるのはこちらです。
こちらの実装を参考にしなんとか無事、subscription
を利用してリアルタイムにデータを受け取ることができるようになりました。
実際のコードについては本文で示した通りです。
Subscriptionの実行でエラーになる
AWS AppSyncのコンソールには、手軽にQueryを試せるビューがあります。そこで Subscription
を実行した際、エラーが表示される場合があります。これは Subscription
以外がQueryに並んでいると起きるようなので、以下のようにそれ以外を消してから実行するとうまく行きました。