NextLeap blog

【AWS AppSync】 API GatewayからAppSyncへ乗り換えてみよう

AppSyncでの良いとこ、悪いとこをまとめてみました


今まではAPIGateway-Lambda-DynamoDBといった形でサーバレスを組んでいたのですが、いろんなユースケースに対応するRESTAPI設計を行おうとすると、不要なレスポンスデータが増えたりしますよね?それに、アプリサイドとインタラクティブな仕組みを入れようと思うと、Push通知経由になり、タイムラグが大きかったり実装がめんどくさかったり・・・と思っていたんです。そんな時に利用したいのがこのAppSyncです!

良いところ① Pub/Subが超簡単

AppSyncでは、スキーマにRESTAPIでいうCRUDのCUDに該当するMutation、Rに該当するQueryとそれらのレスポンス、入力パラメータを登録する必要があります。

	
    type Shop {
        id: ID!
        name: String!
        address: String!
        createdAt: String!
        shopType: String
    }

    input CreateShopInput {
        name: String!
        address: String!
        payment: Int
        shopType: String
    }

    type Mutation {
        createShop(input: CreateShopInput!): Shop
    }
				    

この例では、ShopというtypeがMutationを呼び出した後のレスポンスだったり、DBに格納されるデータになります。"!"は必須のデータ項目を表していますので、shopType以外は必ずレスポンスを返す必要があります。誰かがアプリからcreateShopを呼び出したときに、他のアプリユーザに新しくShopが登録されたことを知らせたい場合は、このMutationをSubscribeするだけで、知ることができます

	
    type Subscription {
        onCreateShop: Shop
            @aws_subscribe(mutations: ["createShop"])
    }
				    

サーバサイドとしては、先ほどのスキーマに、SubscribeしたいMutationをこのように登録しておくだけです。そして、クライアントサイドでは、以下のようにします(AndroidなのでApolloを使うことになります。Webアプリの場合もAngular、React、Vueなど主要なフレームワークにも対応しているようです)

	
    private AppSyncSubscriptionCall.Callback <CreaeShopSubscription.Data> subCreateCallback = new AppSyncSubscriptionCall.Callback<CreateShopSubscription.Data>() {
        @Override
        public void onResponse(@Nonnull final Response<CreateShopSubscription.Data> response) {
            if (response == null  || response.data() == null ||
                response.data().onCreateShop() == null ) {
                    Log.d(TAG, "onCreateShop with no data");
                    return;
                }

                Log.d(TAG, "Updating post to display");
                final CreateShopSubscription.OnCreateShop p = response.data().onCreateShop();
                getActivity().runOnUiThread(new Runnable() {
                @Override
                public void run() {

                    // :TODO
                    //ここでデータを読み込んで何かしら処理できます
                }
            });
        }

        @Override
        public void onFailure(@Nonnull ApolloException e) {
            Log.e(TAG, "Error " + e.getLocalizedMessage());
        }

        @Override
        public void onCompleted() {
            Log.d(TAG, "Received onCompleted on subscription");
        }
    };
				

自分自身がCreateShopをCallした時も、Subscribeされ、データが返ってきますので注意が必要です。

 

良いところ② Pipeline リゾルバーが使えます

2018年11月に機能追加されたPipeline!今までこれがなかった状態でどうやって利用されていたのか不思議になります。基本的にAppsyncでは、一つのリゾルバーでは一つのテーブルアクセスやLambda呼び出しに限定されます。そのため、従来では、MutationやQueryでは一つのデータソースで完結する処理しか使えませんでした。ところが!このPipelineリゾルバーによって、各段に自由度が向上したのです。例えば、まずはユーザ情報のテーブルからユーザの好みを拾って、商品テーブルから該当する商品を見つけて、それをリストで返しつつ、さらに、商品の閲覧履歴テーブルに履歴を書き込んだり、Lambdaを呼び出してPush通知を送ったり・・・と様々なことがバックエンドで一気にできます

	
 {
     "errors": [],
     "mappingTemplateType": "Response Mapping",
     "context": {
         "arguments": {},
         "result": {
             "items": [
                 {
                     "id": "xxxxxxx",
                      "name": "xxxxxxxxxxx",
                      "address": "xxxxxxxxxxx",
                      "createdAt": "xxxxxxxxxxx",
                      "shopType": "xxxxxxxxxxx",
                 }
             ],
             "scannedCount": 1
         },
         "stash": {},
         "outErrors": []
     },
     "fieldInError": false
 }
				    

上記は、Pipeline内のFuctionを呼び出した時の戻りデータです。このresultがDBから取得したデータですが、次のFunctionでは、このresultデータはprev.resultという形で渡され利用することができます。では、例えば、2つ後のFuctionで、データを使いたい場合はどうするか?もっといい方法があるかもしれませんが、私の場合は、stashデータ構造に値を一時格納しておきます。このstashはPipelineの終了まで生存していますので、どのFuctionからもアクセスすることができます。

悪いところ① VTLが書きにくい

まだ良いところもあるのですが、疲れてきたので、残念ながら早々と悪いところに移ります。私はPythonでlambda実装するのが好きなのですが、みなさんもIDE使ってテストしながら実装したりしますよね?私見ですが、このVTL(Velocity Template Language)は、とにかくイヤ。読みにくいし、わかりにくい。pythonで書けるようにならないでしょうか・・・。以下は、AWS AppSyncの開発者ガイドの抜粋ですが、リゾルバーの中では、このように$util関数や#set、#ifなどを駆使しながらロジックを組み立てていくのです。わかりにくくなるのでなるべく複雑にしたくないものです

	
	#if ($T && $F)
	  $util.qr($myMap.put("AND", "TRUE"))
	#end

	##Using the foreach loop counter - $foreach.count
	#foreach ($item in $arr2)
	  #set($idx = "item" + $foreach.count)
	  $util.qr($myMap.put($idx, $item))
	#end

	##Using a Map and plucking out keys/vals
	#set($hashmap = {
	    "DynamoDB" : "https://aws.amazon.com/dynamodb/",
	    "Amplify" : "https://github.com/aws/aws-amplify",
	    "DynamoDB2" : "https://aws.amazon.com/dynamodb/",
	    "Amplify2" : "https://github.com/aws/aws-amplify"
	})

	#foreach ($key in $hashmap.keySet())
	    #if($foreach.count > 2)
	        #break
	    #end
	    $util.qr($myMap.put($key, $hashmap.get($key)))
	#end

	##concatenate strings
	#set($s1 = "Hello")
	#set($s2 = " World")
	$util.qr($myMap.put("concat","$s1$s2"))
	$util.qr($myMap.put("concat2","Second $s1 World"))

	$util.qr($myMap.put("context", $context))

	{
	    "version" : "2017-02-28",
	    "operation": "Invoke",
	    "payload": $util.toJson($myMap)
	}
				    

IDEのサポートがないと読みにくくて辛いなと感じます。dynamoDBへの処理もBoto3使ってやりたいし、Lambdaでそのようなコードがあっても使いまわせないところが効率悪いですよね

悪いところ② デバッグがしにくい

AppSyncのスキーマやリゾルバーを作成した後は、いよいよテストです。テストは、AppSyncのコンソール上にあるQueriesから行います。キャッシュが切れるまで?は一度書いたものが残っているのですが、今後もちょくちょくテストするために、このコンソールに書いたGraphQLクエリーや入力データ(バリアブル)は別途保存しておくとよいでしょう。なおGraphQLクエリーはgenerated.graphqlという形でクライアント側に保存しておく必要があるので、私はここでテスト完了したものを毎回保存し、次回また利用するという形にしています。さて、それではコンソール上の再生ボタンを押してテストしたい関数を選択しテスト実行!すると、VTLを嫌々書いているので結構ミスってたりするんです。。。。。で、エラー時のメッセージが全然わかりにくい!

	
 {
   "data": {
     "listShops": null
   },
   "errors": [
    {
      "path": [
        "listShops"
      ],
      "data": null,
      "errorType": "MappingTemplate",
      "errorInfo": null,
      "locations": [
        {
          "line": 13,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "Unable to parse the JSON document: 'Unrecognized token '$ctx': was expecting ('true', 'false' or 'null')\n at [Source: (String)\"{\n    \"version\" : \"2017-02-28\",\n    \"operation\" : \"Scan\",\n    \"filter\" : {\n    \t\"expression\" : \"(latitude between :lat_a and :lat_b) and (longitude between :lon_a and :lon_b) \",\n    \t\"expressionValues\" : {\n        \t\":lat_a\" : { \"N\" : $ctx.args.filter.latitude.between[0] },\n        \t\":lat_b\" : { \"N\" : $ctx.args.filter.latitude.between[1] },\n        \t\":lon_a\" : { \"N\" : $ctx.args.filter.longitude.between[0] },\n        \t\":lon_b\" : { \"N\" : $ctx.args.filter.longitude.between[1] }\n    \t}\n    },\n    \"li\"[truncated 33 chars]; line: 7, column: 33]'"
    }
  ]
				    

こんな感じのがコンソールに表示されるんです。lineとcolumnが表示されるのですが、どこ指しているのかさっぱり。今回のエラーは渡す引数を省略した時にDynamoDBのScanで失敗したという割とわかりやすめの例ですが、複雑なpipeline処理をしていると、どこに問題があるかわかりにくい。その場合はcloudwatchのログでpipelineの処理ステータスが一行一行表示されるので順に辿っていき、まずはどの処理がエラーになったかを見つけるとこからじっくりみていきましょう