AWS CDK high-level construct で AWS AppSync を構築する

AWS CDK high-level construct を使って Lambda と DynamoDB のデータソースからデータを取得する例を示します。
2020.10.21

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

アプリケーションのバックエンドと通信する手段の一つに GraphQL があります。AWS AppSync は、フルマネージドの GraphQL サービスです。Apollo Clientをはじめ GraphQL まわりのエコシステムが充実し、特にフロントエンドからみた Backend for Frontend としての役割は実運用も視野に入ります。また少し前に、 AppSync は AWS CDK の high-level construct に対応しました。本稿では AWS CDK high-level construct を使ってデータソースからデータを取得する例を示します。

AWS CDK high-level construct の良さ

high-level construct がない場合、 AWS CDK で AppSync のリソースを作ろうと思ったら、CloudFormation のプロパティ名と同じものを指定してリソースを作っていく必要があります。つまり、データソースと連携するIAMロール、そのポリシーは自前で定義することになります。CDK の良さは、定義したリソースを抽象的に扱い相互連携・権限付与・要素定義を行える点にあります。裏側にある DynamoDB や Lambda Function と連携することが前提になる AppSync にとって、このメリットは大きいです。

やる内容

データソースと連携する

いくつかのデータソースと連携する例を示します。

  • Lambda Function データソース (Direct Lambda Resolvers)
  • DynamoDB データソース

環境

  • 利用言語: TypeScript
利用ツール バージョン
aws-cdk 1.69.0

なお本稿の内容は、GitHub リポジトリ で確認できます。

AppSync を high-level construct で定義する

AWS CDK のコードを書いていきましょう。関数スタイルで書くと async/await にも対応できおすすめです。

Lambda Function データソース

まずは、 Direct Lambda Resolvers でつながった Lambda Function です。基本的に、AWS AppSync で GraphQL API をつくり、Lambda Function を呼び出すためには、入出力を変換する Velocity Template(以下、VTL) を挟む必要があります。しかし機能アップデートにより Lambda Function に限り、VTLを省略できるオプションが手に入りました。別途記事をおこしていますのでご参照ください。

【Direct Lambda Resolvers: No VTL】 AWS AppSync GraphQL API で Velocity Template なしで Lambda Function を呼び出せるようになりました | Developers.IO

以下、そんな VTL なしのシンプルな GraphQL 定義です。Lambda Function とつなげます。

greeting-service-stack.ts

import * as cdk from '@aws-cdk/core';
import { Stack } from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as appsync from '@aws-cdk/aws-appsync';
import { AuthorizationType, FieldLogLevel } from '@aws-cdk/aws-appsync';
import {
    GlobalProps,
    NODE_LAMBDA_LAYER_DIR,
    NODE_LAMBDA_SRC_DIR,
} from './global-props';
import * as path from 'path';

export async function greetingServiceApplicationStack(
    scope: cdk.Construct,
    id: string,
    global: GlobalProps,
): Promise<Stack> {
    const stack = new cdk.Stack(scope, id, {
        stackName: global.getStackName(id),
    });

    // node_modules LayerVersion
    const nodeModulesLayer = new lambda.LayerVersion(
        stack,
        'NodeModulesLayer',
        {
            layerVersionName: 'NodeModulesLayer',
            code: lambda.Code.fromAsset(NODE_LAMBDA_LAYER_DIR),
            description: 'Node.js modules layer',
            compatibleRuntimes: [lambda.Runtime.NODEJS_12_X],
        },
    );

    const greetingFn = new lambda.Function(stack, 'GetGreetingReply', {
        functionName: global.getFunctionName('GetGreetingReply'),
        code: lambda.Code.fromAsset(NODE_LAMBDA_SRC_DIR),
        handler:
            'lambda/handlers/appsync/greeting/get-greeting-reply-handler.handler',
        runtime: lambda.Runtime.NODEJS_12_X,
        layers: [nodeModulesLayer],
        environment: {
            REGION: cdk.Stack.of(stack).region,
        },
        tracing: Tracing.ACTIVE,
    });

    const graphApi = new appsync.GraphqlApi(stack, 'GreetingBff', {
        name: global.getGraphApiName('GreetingBff'),
        logConfig: {
            excludeVerboseContent: true,
            fieldLogLevel: FieldLogLevel.ALL,
        },
        authorizationConfig: {
            defaultAuthorization: {
                authorizationType: AuthorizationType.API_KEY,
            },
            additionalAuthorizationModes: [],
        },
        schema: appsync.Schema.fromAsset(
            path.join(__dirname, 'schema.graphql'),
        ),
        xrayEnabled: true,
    });

    // Lambda Function Datasource
    const greetingFnDataSource = graphApi.addLambdaDataSource(
        'GreetingFnDataSource',
        greetingFn,
    );

    /**
     * Lambda Direct Resolvers
     */
    greetingFnDataSource.createResolver({
        typeName: 'Query',
        fieldName: 'getReply',
    });

    return stack;
}

分解してみていきます。

Lambda Function の定義

    // node_modules LayerVersion
    const nodeModulesLayer = new lambda.LayerVersion(
        stack,
        'NodeModulesLayer',
        {
            layerVersionName: 'NodeModulesLayer',
            code: lambda.Code.fromAsset(NODE_LAMBDA_LAYER_DIR),
            description: 'Node.js modules layer',
            compatibleRuntimes: [lambda.Runtime.NODEJS_12_X],
        },
    );

    const greetingFn = new lambda.Function(stack, 'GetGreetingReply', {
        functionName: global.getFunctionName('GetGreetingReply'),
        code: lambda.Code.fromAsset(NODE_LAMBDA_SRC_DIR),
        handler:
            'lambda/handlers/appsync/greeting/get-greeting-reply-handler.handler',
        runtime: lambda.Runtime.NODEJS_12_X,
        layers: [nodeModulesLayer],
        environment: {
            REGION: cdk.Stack.of(stack).region,
        },
        tracing: Tracing.ACTIVE,
    });

この部分で Lambda Function を定義しています。これは つなぎ先が AppSync だろうが API Gateway だろうが変わらない書き方です。なお LayerVersion ですが、node_modules を詰めてLayerVersionに上げ、それを Lambda Function から参照するということをやっています。

AppSync の定義

次に AppSync 本体です。必須ではないプロパティもあえて指定しています。

    const graphApi = new appsync.GraphqlApi(stack, 'GreetingBff', {
        name: global.getGraphApiName('GreetingBff'),
        logConfig: {
            excludeVerboseContent: true,
            fieldLogLevel: FieldLogLevel.ALL,
        },
        authorizationConfig: {
            defaultAuthorization: {
                authorizationType: AuthorizationType.API_KEY,
            },
            additionalAuthorizationModes: [],
        },
        schema: appsync.Schema.fromAsset(
            path.join(__dirname, 'schema.graphql'),
        ),
        xrayEnabled: true,
    });
logConfig

logConfigは、CloudWatch Logs に出力するログの充実度合いです。 excludeVerboseContentfalse にするとリクエストヘッダなどの付随情報が出力されます。

excluedVerboseContent による違い

false、 つまり verboseContent も出力するとした場合、以下のようなログも CloudWatch Logs へ出力されます。

Request Headers: {content-length=[1550], referer=[https://ap-northeast-1.console.aws.amazon.com/], cloudfront-viewer-country=[JP], sec-fetch-site=[cross-site], origin=[https://ap-northeast-1.console.aws.amazon.com], x-forwarded-port=[443], x-amz-user-agent=[AWS-Console-AppSync/], via=[2.0 cloudfront.net (CloudFront)], cloudfront-is-desktop-viewer=[true], host=[appsync-api.ap-northeast-1.amazonaws.com], content-type=[application/json], sec-fetch-mode=[cors], x-forwarded-proto=[https], accept-language=[en-US,en;q=0.9,ja;q=0.8,jv;q=0.7], x-forwarded-for=[], accept=[*/*], cloudfront-is-smarttv-viewer=[false], x-amzn-trace-id=[Root=1-5f8fa85e-68fd6851492868c8099568d6], x-api-key=[****], cloudfront-is-tablet-viewer=[false], cloudfront-forwarded-proto=[https], x-amz-cf-id=[B2UrwjKLWVeX5l5EL5NkxPdvaRc0-hfRcwwdCIYNBekQU-BUtAKWdw==], accept-encoding=[gzip, deflate, br], user-agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36], sec-fetch-dest=[empty], cloudfront-is-mobile-viewer=[false]}

fieldLogLevel による違い

NONE の場合、RequestSummary, RequestSummary というログタイプのものだけが CloudWatch Logs へ出力されるようです。

images

'ALL' の場合、マッピングテンプレートによる変換ログなども出力するようです。

images

なおどちらの場合も Request Headers のログは出力していますね。excluedVerboseContent の設定は fieldLogLevel によらないことがわかります。

authorizationConfig

authorizationConfig は、このAppSyncにかける認証設定です。API Key, Cognito User Pool, OpenID Connect, IAM から選ぶことができます。

schema

schema はこのGraphQLのスキーマを定義します。大抵の場合はファイルとして切り出して管理するほうが無難です。よってここでも appsync.Schema.fromAsset でスキーマファイルを指定する方法をとっています。スキーマファイルのサンプルを載せます:

schema.graphql

input Message {
    message: String!
}

type MessageTemplate {
    id: String!
    message: String!
}

type Query {
    getReply(input: Message): Reply!
}

type Mutation {
    createTemplate(input: Message): MessageTemplate!
}

type Reply {
    reply: String!
}
xrayEnabled

true にすると、AWS X-Ray によるトレーシングが有効になります。エラー率やパフォーマンスの確認に役立ちます。特に AppSync のようにバックエンドと多くつながるサービスにとっては、串刺しでモニタリングできるのは嬉しいと思いました。

xray.jpg

データソースとリゾルバの設定

Lambda Function と AppSync の定義がおわったので、それらをつないでいきます。GraphQL の世界では、GraphQL定義(スキーマ)とつながるバックエンドのことを「データソース」、スキーマに対する具体的なつなぎ方を示した操作内容のことを「リゾルバ」といいます。これを踏襲し、AppSyncの世界でもデータソースとそのリゾルバを設定する必要があります。それが以下のコードです。

    // Lambda Function Datasource
    const greetingFnDataSource = graphApi.addLambdaDataSource(
        'GreetingFnDataSource',
        greetingFn,
    );

    /**
     * Lambda Direct Resolvers
     */
    greetingFnDataSource.createResolver({
        typeName: 'Query',
        fieldName: 'getReply',
    });

addLambdaDataSource でデータソースを、 createResolver でリゾルバを定義しています。addLambdaDataSource は さきほど定義した graphApi のメソッドであり、createResolver は データソースから生えたメソッドであることがわかります。このようにリソースの従属関係がコードで表現できるのは、AWS CDK の強みの一つです。CloudFormation だと、たいていYAMLのインデントで従属関係が表現されるわけですが、どのリソースに何がぶら下がるかはドキュメントとにらめっこしないとわかりません。AWS CDK TypeScript の場合はそのあたりが型情報で表現できるため、リファレンスを見る頻度がぐっと減ったと実感します。

DynamoDB データソース

ではマッピングテンプレートが必要なタイプのデータソースを定義してみましょう。例えばメッセージのテンプレートを保存する DynamoDB を定義します。

greeting-service-stack.ts

    /**
     * Greeting Template DynamoDB
     */
    const templateTable = new dynamo.Table(stack, 'TemplateTable', {
        tableName: global.getTableName('Template'),
        partitionKey: { name: 'id', type: AttributeType.STRING },
    });

    // DynamoDB DataSource
    const ddbSource = graphApi.addDynamoDbDataSource(
        'TemplateTableDataSource',
        templateTable,
    );
    ddbSource.createResolver({
        typeName: 'Mutation',
        fieldName: 'createTemplate',
        requestMappingTemplate: MappingTemplate.dynamoDbPutItem(
            PrimaryKey.partition('id').auto(),
            Values.projecting('input'),
        ),
        responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
    });

リゾルバを定義している部分に注目してください。マッピングテンプレートを指定している風ですが、実体であるVTLはここでは一行も書いていません。そうです、MappingTemplate.dynamoDbPutItemMappingTemplate.dynamoDbResultItem は、「DynamoDBとのやりとりってだいたいこういうVTLでしょ」ということで AWS CDK が用意してくれたものです。これは大変ありがたいですね。PutItem 時には PrimaryKey.partition('id').auto() とすることで uuid を自動生成する指定もできます。 high-level construct の強みがここでも発揮されています。ちなみにこれで定義したクエリは以下のように投げることで DynamoDB に保存できます。

dynamodb-resolver.jpg

おわりに

AWS CDK high-level construct を使って AWS AppSync を構築すると、次のようなメリットが得られることを述べました:

  • CloudFormation Template のようにすべてを定義として書くのではなく、AWS CDK で用意されているメソッドを使いベースリソースを拡張する形でかける
  • Direct Lambda Resolver で VTL の利用を回避できる
  • MappingTemplate の利用で VTL のメンテナンスを回避できる

high-level construct は開発途上で、issue でトラッキングできます。これからも随時チェックして便利に使えそうになったら試してきます。

Tracking: AWS AppSync · Issue #6836 · aws/aws-cdk

参考