【AWS Amplify ノウハウ】 7. Custom Resolver も積極的に活用しましょう!

2020.07.31

2020.08.06 追記

@function ディレクティブをつけることで以下の作業が自動化されます。 詳細は公式ドキュメントをご参照ください。

@function ディレクティブについて

イントロ

こんにちは!コンサル部のテウです。

AWS Amplify シリーズの7番目の記事です!本記事では、Amplify API を使う時の基本的に生成してくれる resolver ではなく、カスタマイズした resolver を作って使いたい時にどうすればいいのかについて説明します。

それでは始めます!:D

カスタム resolver はなぜ必要ですか?

Amplify API で自動に生成してくれた resolver も有用であり、このぐらいのレベルの resolver でも、大体の場合、サービスのMVP等のためのアプリを開発するにはほぼ問題ないかと思います。しかし、大体のアプリケーションでは、それだけではなく、カスタム resolver も必要になるのではないかと思います。

なので、まずは、最も簡単なケースである既存の resolver をコピペし、カスタマイズするケースについて簡単に説明します。

例えば、ユーザ別の権限に従ってデータソースから伝達してくる結果で、何かとフィルタリングし、ユーザの種類によって制限したい場合は、amplify/backend/api/${API名}/build/resolvers フォルダーの該当する resolver を amplify/backend/api/${API名}/resolvers にコピーしておき、VTL 文法で結果値をユーザーの種類に合わせてフィルタリングすれば良いでしょう。このようにコピペで作った resolver ファイルのファイル名が、既存のbuild フォルダーの下に配置された resolver ファイル名と同じであれば、 amplify push 時に該当する resolver は amplify/backend/api/${API名}/resolvers フォルダーに直接コピペで作った resolver ファイルを使うことになります。

AWS Amplify: Overwriting Resolvers

これぐらいのレベルでのカスタマイズは、実は、ただのファイルの上書きであり、非常に簡単にできることですので、カスタマイズだということも少し大げさかもしれません。

しかし、ECサイトなのでのアプリで注文APIを提供したり、SNSアプリで友達の場合だけいいね!が押せ、いいね!の数といいね!を押している人を同時に保存しないといけないトランザクション処理等をしたり(複雑ですね…笑)する複雑なロジックが必要な場合、VTL基盤のresolverを作成より、汎用プログラミング言語で作成した方がより効率良いケースも多くあります。すなわち、AWS Lambda をデータソースとして置いている resolver が必要な場合ですね!

本記事では、このようなケースに合わせて AWS Amplify の Lambda-backed カスタム resolver をどうやって作れるのかを説明します!(実は、Amplify公式ドキュメントを見ると出ておりますが、日本語でも一度整理した方が良いと思って書いております。)

AWS Amplify: Add a custom resolver that targets an AWS Lambda function

直接作ってみましょう

まず、 amplify init から Amplify プロジェクトを生成します。この過程の詳しい説明はスキップします!笑

1. amplify add function

既存 Lambda リソースが存在する場合は、このステップはスキップしても良いですが、amplify push と一緒にデプロイしたい場合は、amplify add function を通じて Lambda リソースを作った方が良いです。amplify add function と関連した公式ドキュメントは下のリンクをご参照ください。

AWS Amplify CLI: function Overview

また本論に戻り、 Amplify CLI を通じて amplify add function を実行させた後

? Choose the function template that you want to use:

と出る選択肢には Hello world function を選択して進行させます。

? Do you want to access other resources created in this project from your Lambda function? (Y/n)

と出る選択肢には Y を入力すると Amplify プロジェクトに登録しといたカテゴリーらを選択する質問が出ます。この過程は IAM Policy と関連して権限設定を自動化するための質問ですが、n を入力してから後ほど簡単に "直接" 入力もできますので、良く分からなければ n を押しても構いません。   残りは適当に選択し、 amplify add function 過程を終わらせると、

  • amplify function build
  • amplify function invoke ${function-name}
  • amplify push

などを適切に活用して開発すれば良いです!本記事では function よりはカスタム resolver に焦点を当てて作成しようとしているので、function はここまでにしてスキップします :)

次のステップに行く前に、私は本記事のために、Amplifyプロジェクトを新しく作りましたので、amplify add api もしてからいきます!

2. schema.graphql にカスタムQuery宣言

amplify/backend/api/${API名}/schema.graphql フォルダーに作ろうとするカスタムQueryを直接作成します。

...

input MyCustomLambdaExecutionInput {
  message: String!
}
type MyCustomLambdaExecutionResult {
  data: String!
}
type Query {
  myCustomLambdaExecution(input: MyCustomLambdaExecutionInput): MyCustomLambdaExecutionResult!
}

私はinputタイプとresultタイプも直接作成しました。ご参考までに、今回は Query について行いますが、Mutation もカスタマイズできます!笑

3. カスタムresolver用 CloudFormation テンプレートの作成

Amplify 公式ドキュメントには amplify/backend/api/${API名}/stacks/CustomResources.json というデフォルトテンプレートにリソースを追加することと説明していますが、実際のプロジェクトではこのようにカスタムリソースを追加すると、まるで一つのファイルにすべてのバックエンドロジックを作成することと同じレベルの複雑さを抱え込むような問題が生じます。

amplify/backend/api/${API名}/stacks/ 下のすべての *.json ファイルは、自動的に ampliy push 時、一緒にデプロイされるので、各カスタムリソースごとに CloudFormation テンプレートをファイル別に分割して管理することを個人的にはおすすめします!

私の場合、 myCustomLambdaExecution ですので myCustomLambdaExecution-stack.json という名前で新しいファイルを作成し、下のコードを入力してみます。

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Description": "An auto-generated nested stack.",
    "Metadata": {},
    "Parameters": {
        "AppSyncApiId": {
            "Type": "String",
            "Description": "The id of the AppSync API associated with this project."
        },
        "AppSyncApiName": {
            "Type": "String",
            "Description": "The name of the AppSync API",
            "Default": "AppSyncSimpleTransform"
        },
        "env": {
            "Type": "String",
            "Description": "The environment name. e.g. Dev, Test, or Production",
            "Default": "NONE"
        },
        "S3DeploymentBucket": {
            "Type": "String",
            "Description": "The S3 bucket containing all deployment assets for the project."
        },
        "S3DeploymentRootKey": {
            "Type": "String",
            "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory."
        }
    },
    "Resources": {
        "MyCustomLambdaExecutionLambdaResolverDataSource": {
            "Type": "AWS::AppSync::DataSource",
            "Properties": {
              "ApiId": {
                "Ref": "AppSyncApiId"
              },
              "Name": "MyCustomLambdaExecutionLambdaResolverDataSource",
              "Type": "AWS_LAMBDA",
              "ServiceRoleArn": {
                "Fn::GetAtt": [
                  "MyCustomLambdaExecutionLambdaResolverDataSourceRole",
                  "Arn"
                ]
              },
              "LambdaConfig": {
                "LambdaFunctionArn": {
                  "Fn::Sub": [
                    "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:amplifycustomresolve86b2a0f6-${env}",
                    { "env": { "Ref": "env" } }
                  ]
                }
              }
            }
        },
        "MyCustomLambdaExecutionLambdaResolverDataSourceRole": {
            "Type": "AWS::IAM::Role",
            "Properties": {
              "RoleName": {
                "Fn::Sub": [
                  "MyCustomLambdaExecutionLambdaResolverDataSourceRole-${env}",
                  { "env": { "Ref": "env" } }
                ]
              },
              "AssumeRolePolicyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                  {
                    "Effect": "Allow",
                    "Principal": {
                      "Service": "appsync.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                  }
                ]
              },
              "Policies": [
                {
                  "PolicyName": "InvokeLambdaFunction",
                  "PolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                      {
                        "Effect": "Allow",
                        "Action": [
                          "lambda:invokeFunction"
                        ],
                        "Resource": [
                          {
                            "Fn::Sub": [
                              "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:amplifycustomresolve86b2a0f6-${env}",
                              { "env": { "Ref": "env" } }
                            ]
                          }
                        ]
                      }
                    ]
                  }
                }
              ]
            }
        },
        "MyCustomLambdaExecutionLambdaResolver": {
            "Type": "AWS::AppSync::Resolver",
            "Properties": {
              "ApiId": {
                "Ref": "AppSyncApiId"
              },
              "DataSourceName": {
                "Fn::GetAtt": [
                  "MyCustomLambdaExecutionLambdaResolverDataSource",
                  "Name"
                ]
              },
              "TypeName": "Query",
              "FieldName": "myCustomLambdaExecution",
              "RequestMappingTemplateS3Location": {
                "Fn::Sub": [
                  "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.myCustomLambdaExecution.req.vtl",
                  {
                    "S3DeploymentBucket": {
                      "Ref": "S3DeploymentBucket"
                    },
                    "S3DeploymentRootKey": {
                      "Ref": "S3DeploymentRootKey"
                    }
                  }
                ]
              },
              "ResponseMappingTemplateS3Location": {
                "Fn::Sub": [
                  "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.myCustomLambdaExecution.res.vtl",
                  {
                    "S3DeploymentBucket": {
                      "Ref": "S3DeploymentBucket"
                    },
                    "S3DeploymentRootKey": {
                      "Ref": "S3DeploymentRootKey"
                    }
                  }
                ]
              }
            }
        }
    }
}

CloudFormation に慣れている方は、少し読んだだけでも何をしているのかが把握できるかもしれませんが、CloudFormation に慣れていない方のために上記のコードがどのような動作をするのかについて簡単に説明します。

まず、全体的に3つのリソース生成します。

  1. AWS::AppSync::DataSource タイプの Lambda データソース登録 (CloudFormation では登録もリソースとして扱う)

  2. AWS::IAM::Role タイプの Lambda データソースが依存関係を持つIAM Role を生成

  3. AWS::AppSync::Resolver タイプの AppSync resolver生成

それぞれの説明は本記事の要旨から離れますので、本記事では CloudFormation について前提の知識をある程度お持ちの方を対象にし、ポイントになる部分を記述します。

ポイントの説明

1. ネーミング規則をしっかり決めよう

templateコードをご覧になるとお分かりになるかと思いますが、ネーミングが非常にややこしいことが確認できます。私の場合、Query や Mutation 名を積極的に使い、

  • データソース : Query/Mutation 名 + LambdaResolver + DataSource
  • IAM ロール : Query/Mutation 名 + LambdaResolver + DataSourceRole
  • resolver : Query/Mutation 名 + LambdaResolver

の規則で Logical IDを定義し、実際のリソース名についても上の規則をそのまま適用しました。

2. Lambda の arn をちゃんと確認しよう

Lambda の arn の入力が求められるリソースは二つです。

  • データソース
  • IAM Role

amplify add function で登録された Lambda の場合、 amplify/backend/function/${function名}/${function名}-cloudformation-template.json ファイルから Resources > LambdaFunction > Properties > FunctionName に宣言されておりますので、思い出さない場合は、こちらをご確認ください。

ちなみに、Lambda 関数にも Query/Mutation の名前をそのまま適用するルールを守ると、より管理しやすくなると思います!

加えて、amplify/backend/api/${API名}/parameters.json に該当する Lambda の arn を記載し、CloudFormation の Parameters で受けることもできますが、本記事では直接入力することとします。

3. VTL ファイルの名前をちゃんと記載しよう

AppSync の resolver 宣言で Query/Mutation タイプとフィールド名、そして VTL 形式の AppSync の resolver ファイル名をミスなく記載することが大事です。本記事では "Lambda resolver" という表現を使っていますが、概念が曖昧かもしれませんので、改めて用語を整理して次に行きます。

AppSync の "resolver" は VTLタイプだけをサポートします。ですので、すべての resolver は全部 VTL 形式として記されるのが正しいです。

そうすると、Lambda は resolver ではないのか?への回答は、私たちは Lambda を AppSync の resolver の役割で使っていると思いながら Lambda コードを書いています、実際に AppSync から見るときに Lambda は resolver ではなく "データソース" として認識していますので、正確な用語は Lambda データソース として表現する方が正しいです。

しかし、この場合、VTL 形式の resolver は単純に Lambda へ送るデータをそのまま送るだけの Proxy の役割だけ果たしているので、便宜上、本記事では、Lambda resolver という表現を使い続けています。この概念が曖昧であれば、まずは用語の定義をしっかりしておきましょう!

もう一度本論に戻って、カスタム resolver は Query タイプと Mutation タイプを両方ともサポートしますので、Query タイプの resolver を作ろときには Query を、Mutation タイプの resolver を作るときには Mutation を必ず CloudFormation と VTL ファイル名にも同じく統一させます。

本記事では、Query タイプなので、Query と記し、VTL ファイル名も Query から始まるファイル名を入力しました。この内容は VTL ファイルの中にも一緒に適用されるべきポイントですので、ミスって時間を無駄にしないように集中して一度で修正できるようにしましょう!

4. VTL 作成

今回はもう一度 amplify/backend/api/${API名}/resolvers/` フォルダーにアクセスし、上の CloudFormation に宣言しておいた resolver ファイル2つを生成します。

私の場合には、

  • Query.myCustomLambdaExecution.req.vtl
  • Query.myCustomLambdaExecution.res.vtl

でした。それぞれの内容はほぼコピペですが、上で書いたように Query/Mutation タイプと名(フィールド名)あたりは必ず修正します!

Query.myCustomLambdaExecution.req.vtl ファイルの場合、下のように入力し、

{
    "version": "2017-02-28",
    "operation": "Invoke",
    "payload": {
        "type": "Query",
        "field": "myCustomLambdaExecution",
        "arguments": $utils.toJson($context.arguments),
        "identity": $utils.toJson($context.identity),
        "source": $utils.toJson($context.source)
    }
}

Query.myCustomLambdaExecution.res.vtl ファイルの場合は、下のように入力します。

$util.toJson($ctx.result)

5. Lambda 関数の作成 (実際のカスタム resolver の役割)

今回は、正義した Query の input および output タイプに合わせて Lambda 関数を修正します。

Node.js タイプで Lambda 環境を構成した場合には下のようにコードを入力してください。

exports.handler = async (event) => {
    const response = {
        data: "input message was: " + event.arguments.input.message
    };
    return response;
};

6. amplify push

やっと、すべての設定が完了されましたので、amplify push を通じてすべてのリソースをデプロイします。ちなみに、開発時には amplify push -y オプションをつけると途中で待ってyを押さなくて済むので、便利です!:)

7. 結果の確認

amplify console api を入力して GraphQL を選択すると AppSync の Query ウィンドウが開きます。下のように入力して、今まで作成したカスタム Query が正常作動することを確認しましょう!

query customQuery {
  myCustomLambdaExecution(input: {
    message: "Hello"
  }) {
    data
  }
}

最後に

本記事では、Amplifyで開発する時に必然的に遭遇するカスタム resolver を作成する方法について説明しました。

CloudFormationに詳しい方や、分からない方でも関数を一つ追加することなのにどうしてこんなに複雑なの?!と思われるかもしれませんね。私もそう思いますから…(涙

はい、私が思うにも、カスタム resolver を数個ぐらいではなく、数十個以上作成する必要がある場合には、AWS Amplify ではなく直接バックエンドを開発した方がより効率良いのではないかと思ったりします。一方、そうではない場合、MVPを目的にして迅速に、かつ、効率の良い開発を目的とする場合は、カスタム resolver を数個ぐらい直接設定する程度は、全体のバッグエンドを一つずつ開発するよりもっと楽に開発できるのではないかと思います。

そして、ある程度ビジネスが成長すれば特定の時点で Amplify から離れ、直接バックエンドを開発してマイグレーションを進行させた方が速く変化している市場のニーズを満たすアプリを適材適所にリリースできる戦略になるのではないかと思います。

以上、コンサル部のテウでした!:D