AWS AppSync + RDS を試してみた

この記事は [ドワンゴ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/dwango) の 7 日目の記事です。

社内向けの API に GraphQL を採用する事になったので、AWS AppSync + RDS を使った場合に開発〜デプロイがどんな形で実現できるのか、制限事項はどのようなものがあるのか、等について、試してみたあれこれを共有します (GraphQL とは何か、などの話は割愛します)。

## TL;DR

- AppSync + RDS をしたければ、Serverless Aurora か Lambda をデータソースとして利用する
- AppSync + RDS (というか AppSync + Lambda) は割と制約が厳しい
    - BatchInvoke が 5 つまでしか Batching してくれないため N+1 問題に悩まされる
    - ただし、他の AWS リソースとの連携は楽で良い
- GraphQL Code Generator は TypeScript の型定義が自動生成でき便利

## どのような制約があるか

API サーバ構築にあたっての要件は以下でした。

- バックエンドは RDS で、MySQL 5.7 を利用してる
    - JSON 型などを利用している
- RDS のテーブル定義がしっかりと固まっていない (別プロジェクトとして進行中)
- 社内向け API であり、負荷はそこまで高くならない

## GraphQL Server にどの実装を採用するか

GraphQL のサーバ実装は、言語毎に様々なものがあり、[graphql.org](https://graphql.org/code/) や [awesome-graphql](https://github.com/chentsulin/awesome-graphql) 等で多数紹介されています。言語には TypeScript を採用することにして、サーバ実装は [Apollo](https://www.apollographql.com/) か [AWS AppSync](https://docs.aws.amazon.com/appsync/latest/devguide/welcome.html) で迷ったのですが、周囲に AWS リソースを活用したサービスが多かったため、連携やサポートが豊富そうな後者をまずは試してみることにしました。
他にも、[Prisma](https://www.prisma.io/) から提供される Prisma Server は、GraphQL スキーマを入力として、ほぼコードを書かずに GraphQL サーバを立ち上げることができるので良さそうでしたが、現状 (2018/12 時点)、既存の MySQL サーバに対して適用はできなかったため、見送りました。

### AWS AppSync とは

リアルタイムなデータ同期やオフライン同期が特徴の AWS の提供するフルマネージドの GraphQL サービスです。フルマネージドであるためスケールや管理に気を使わなくて良い点や、他の AWS サービスとの連携がしやすいことが利点です。

> [20180523 AWS Black Belt Online Seminar AWS AppSync](https://www.slideshare.net/AmazonWebServicesJapan/20180523-aws-black-belt-online-seminar-aws-appsync)

### AWS AppSync + RDS をどう組み合わせるか

AWS AppSync では、そのバックエンドとなるデータストアを *データソース* と呼びます。2018/12 現在は、データソースとして DynamoDB や AWS Lambda、Amazon Elasticsearch Service、GraphQL 等が用意されており、[各々チュートリアルが用意されています](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/tutorials.html)。が、RDS の直接のサポートはありません ( Serverless Aurora に限っては、つい最近サポートが発表されました :clap:  [^1] )。

サポートされるデータソースに Lambda があるため、Lambda を介して RDS にアクセスさせることができます。公式から提供されている AppSync のサンプルプロジェクト `Blog App` [^2] でも、Lambda と RDS の組み合わせが利用されています。RDS を利用したい場合で、Serverless Aurora が利用できない場合は Lambda と組み合わせて利用するのが良いようです ( Lambda + RDS は制限事項があるので注意 [^3] )。

## 開発

### Lambda: 何を書くか

AWS AppSync では、リゾルバ毎に [リゾルバーマッピングテンプレート](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-overview.html) を、リクエスト/レスポンス用の2つ1組で定義します。これらはデータソースから取得したデータとGraphQL で定義した型とのマッピングを定義するもので、[Apache Velocity](http://velocity.apache.org/engine/1.7/user-guide.html) で記述します。
サンプルプロジェクトの `Blog App` では、リゾルバ毎に SQL クエリの組み立てはリクエストマッピングテンプレートで行い、それを Lambda に送信し、Lambda は RDS に向けてそれを実行&結果を返し、レスポンスマッピングテンプレートが GraphQL 定義に合わせた変換を実行します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/64850/54c785fe-4f9d-2004-d72f-3acd9f70eb7c.png)


本来であればこのサンプルのように、Lambda は RDS に問い合わせその結果を返すだけの層としておき、GraphQL/MySQL 間のマッピングはマッピングテンプレートに記載するのが正しいのかもしれません。が、今回は以下の理由から、クエリの生成も含めて Lambda 側で行う事にしました。

- 生のクエリを書かずにクエリビルダや ORM を使って楽をしたい
- コードを書く量を減らしつつ静的解析の恩恵を受けるために、コードの自動生成を活用したい
- AppSync 以外に移行する事になった場合にコードベースを使い回せるようにしておきたい
  - コードを書くなら他に使いまわして色々と検証できたらという考え

他にも、リゾルバ毎に Lambda を利用するか、全てのリゾルバで Lambda を共有するか、についても選択の余地があります。これは各々メリット/デメリットがありますが、今回はコード管理のしやすさや使いまわしやすさから、全てのリゾルバで 1 つの Lambda を共有する事にしました。

> [Ten Tips And Tricks for Improving Your GraphQL API with AWS AppSync (MOB401) - AWS re:Invent 2018](https://www.slideshare.net/AmazonWebServices/ten-tips-and-tricks-for-improving-your-graphql-api-with-aws-appsync-mob401-aws-reinvent-2018/30)

### Lambda: どう書くか

リゾルバを記述する上で必要な情報は [$context](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html) 変数に詰まっているので、これを JSON に変換して Payload として Lambda に渡してやれば良いです。ただし、今回は実行されているリゾルバを Lambda に伝えてやるために、リゾルバを一意に識別できるような値 (下記の例だと `query.item` ) も一緒に渡してやることにしました。

``` javascript
{
    "version" : "2017-02-28",
    "operation": "Invoke",
    "payload": { "resolve": "query.item", "context": $utils.toJson($context) }
}
```

Lambda 側ではこれを受け取り、リゾルバに引数があった場合に値が格納されている `context.arguments` や、実行されたリゾルバに親が存在した場合に親の値が格納される `context.source` を参照し、適切なクエリを実行 & 値を返します。コード片を示すと以下のような形で、リゾルバーの識別子とクエリを実行する関数を紐付けます。

``` javascript
async function getItem (src, args) {
   return await knex('item')
     .where('id', args.id)
     .first();
}

const resolvers = {
  "query.item": getItem,
  // ...
};

module.exports.handler = async (event) => {
  const id = event.resolve;
  const source = event.context.source;
  const args = event.context.arguments;
  const result = await resolvers[id](source, args);

  return result;
};
```

レスポンスマッピングテンプレートは、単純に JSON オブジェクトに変換するだけです。

``` javascript
$util.toJson($context.result)
```

後述もしますが、リクエスト/レスポンスマッピングテンプレートは、CloudFormation にベタがきし、Lambda は AWS SAM を利用してパッケージング & デプロイをすることにしました。

### Option: コードの自動生成を活用する

もののついでにコードの自動生成も利用してみたのでご紹介します。GraphQL 関連のコード自動生成ツールはいくつかありますが、今回は [GraphQL Code Generator](https://graphql-code-generator.com/docs/getting-started/) を使ってみました。GraphQL Code Generator は GraphQL スキーマを入力として TypeScript のコードを自動生成してくれるツールで、生成するコードの種類毎に [Plugin](https://graphql-code-generator.com/docs/plugins/) が用意されており、設定からそれらを自由に組み合わせられるのが特徴です。
バージョン `0.14.5` 時点 [^4] では、以下のように利用することができます。

``` graphql:schema.graphql
type Item {
  id: ID!
  name: String
}

type Query {
  item(id: ID!): Item
}
```

``` yaml:gql-gen.yml
schema: ./schema.graphql
overwrite: true
generates:
  ./types.ts:
    plugins:
      - add:
        - /* tslint:disable */
      - typescript-common
      - typescript-server
      - typescript-resolvers
```

上記を準備した後に、諸々をインストールし、実行します。

``` shell
$ yarn add -D \
    graphql \
    graphql-code-generator \
    graphql-codegen-typescript-resolvers \
    graphql-codegen-typescript-common \
    graphql-codegen-typescript-server \
    graphql-codegen-add
$ yarn gql-gen --config gql-gen.yml
```

以下のようなコードが生成されます。現状だと基本的にプラグインの適用順にコードが吐かれます。求めるプラグインのみ適用でき、異なるプラグインを適用した異なる自動生成ファイルを複数設定ファイル上で定義することもできます。

``` typescript:types.ts
/* tslint:disable */

// ====================================================
// Types
// ====================================================

export interface Query {
  item?: Item | null;
}

export interface Item {
  id: string;

  name?: string | null;
}

// ====================================================
// Arguments
// ====================================================

export interface ItemQueryArgs {
  id: string;
}

import { GraphQLResolveInfo, GraphQLScalarTypeConfig } from "graphql";

export type Resolver<Result, Parent = {}, Context = {}, Args = {}> = (
  parent: Parent,
  args: Args,
  context: Context,
  info: GraphQLResolveInfo
) => Promise<Result> | Result;

/* 中略 */

export namespace QueryResolvers {
  export interface Resolvers<Context = {}, TypeParent = {}> {
    item?: ItemResolver<Item | null, TypeParent, Context>;
  }

  export type ItemResolver<
    R = Item | null,
    Parent = {},
    Context = {}
  > = Resolver<R, Parent, Context, ItemArgs>;
  export interface ItemArgs {
    id: string;
  }
}

export namespace ItemResolvers {
  export interface Resolvers<Context = {}, TypeParent = Item> {
    id?: IdResolver<string, TypeParent, Context>;

    name?: NameResolver<string | null, TypeParent, Context>;
  }

  export type IdResolver<R = string, Parent = Item, Context = {}> = Resolver<
    R,
    Parent,
    Context
  >;
  export type NameResolver<
    R = string | null,
    Parent = Item,
    Context = {}
  > = Resolver<R, Parent, Context>;
}

/* 中略 */
```

## デプロイ

AWS AppSync のデプロイには CloudFormation、Lambda のデプロイには個別に AWS SAM を利用しました。テンプレートを分けた理由は、後々クエリのパフォーマンスチューニング等を行った際に、Lambda 側だけ差し替えたい場合があるかもしれないと考えたためです。
Lambda 単一では特にハマることもないので、AppSync 側の CloudFormation でのデプロイについてのみ少し触れます。ポイントは以下です。

- リクエスト/レスポンスマッピングテンプレートは直書き
    - 外部からテキストを埋め込む方法が見つからなかったので
    - マッピングテンプレートをゴリゴリかかないので特に問題はなし
- GraphQL スキーマ定義は、S3 にアップロードした後にそのパスを指定させることができる
  - [GraphQLSchema](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-appsync-graphqlschema.html) には S3 上の GraphQL スキーマを読み取る `DefinitionS3Location` という項目があるので、S3 バケットにテンプレートをアップロードしたのちに、そのパスを Parameter として CloudFormation のデプロイ時に渡すことができる

テンプレートの一部を示すと、以下のような形になります。

``` yaml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  APIName:
    Type: String
  RDSLambdaArnValueName:
    Type: String
  GraphQLSchemaS3BucketLocation:
    Type: String

Resources:
  SearchAPI:
    Type: "AWS::AppSync::GraphQLApi"
    Properties:
      Name: !Sub ${APIName}
      AuthenticationType: "API_KEY"

  SearchAPISchema:
    Type: "AWS::AppSync::GraphQLSchema"
    DependsOn:
      - SearchAPI
    Properties:
      ApiId: !GetAtt SearchAPI.ApiId
      DefinitionS3Location: !Sub ${GraphQLSchemaS3BucketLocation}

  AppSyncRDSDataSource:
    Type: "AWS::AppSync::DataSource"
    DependsOn:
      - SearchAPI
    Properties:
      ApiId: !GetAtt SearchAPI.ApiId
      Name: "RDSDataSource"
      Type: "AWS_LAMBDA"
      ServiceRoleArn:
        Ref: AppSyncRDSServiceRole
      LambdaConfig:
        LambdaFunctionArn:
          Fn::ImportValue:
            !Ref RDSLambdaArnValueName
```

## AWS AppSync を使ってみて

### 良いところ

- 従量課金制なのでお手軽
- フルマネージドサービスなので、管理やスケーリングを気にせずに利用できる
- 他の AWS リソースとの連携が容易
  - 一部のリゾルバを Elasticsearch Service から利用したい場合などに有用
  - DynamoDB をデータソースとすると、GraphQL スキーマの自動生成が活用できる
  - 認証方法が豊富に用意されている
      - Cognito, IAM Role, API Key,...
      - Cognito を利用すると一瞬で認証機能が作れてとても便利
- AWS コンソールの AppSync の画面から即座に API を叩いて結果を確認できる

### 制限など

- クエリ実行時間が 30 秒等の [各種制限](https://docs.aws.amazon.com/ja_jp/general/latest/gr/aws_service_limits.html#limits_appsync)
- ユーザ独自のスカラー型が利用できない
  - AppSync にはデフォルトで[独自のスカラー型](https://docs.aws.amazon.com/appsync/latest/devguide/scalars.html) が用意されているのですが、ユーザ独自のスカラー型はサポートしてないようで、そのような定義を含んだスキーマをインポートするとエラーとなります
- GraphQL スキーマに問題があった場合のエラー文言がわかりにくい
  - CloudFormation 経由で GraphQL スキーマをインポートした際、型周りのエラーでも `Schema Creation Status is FAILED with details: Failed to parse schema document - ensure it's a valid SDL-formatted document.` といった文言で済まされる場合があります
  - GraphQL スキーマの問題を発見するために、何かしらの linter を導入しておくと良いでしょう。現在はとりあえず [こちら](https://github.com/cjoudrey/graphql-schema-linter) を利用しています
- Batch Invoke が最大 5 つまでしか Batching してくれない
    - 後述

### Lambda データソースでの BatchInvoke の制限

詳しくは [ドキュメント](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-lambda-resolvers.html#advanced-use-case-batching) に記載されていますが、Lambda データソースでは、N+1 問題の解決のために BatchInvoke という呼び出し方法が利用できます。これは、GraphQL クエリの一回のリクエスト内で同じリゾルバに対し複数回の呼び出しが行われる場合、それらを 1 つの呼び出しにまとめる (Batching) ことで、本来複数回必要だった Lambda の実行を一回で済ませることができる、というものです。AppSync 以外の場合は、[DataLoader](https://github.com/facebook/dataloader) などのライブラリを利用して対処できる問題です。
この BatchInvoke ですが、現状、最大 5 つまでのアイテムしか Batching されません。これはだいぶ厳しいものがあります。下記記事は今年の 8 月くらいの記事ですが、現在手元でためてしみても、まだこの制限は緩和されていないようです。

> [AWS AppSync — the unexpected](https://medium.com/@dadc/aws-appsync-the-unexpected-a430ff7180a3)

## 終わりに

GraphQL に入門しながら手探りの中 AppSync + RDS を試してみた話となりました。今回はバックエンドがすでに RDS で決まっていたためそれに合わせましたが、バックエンドのデータストアが融通が利くのであれば DynamoDB や Elasticsearch Service 等を利用するのが良いでしょう。もしくは、 Serverless Aurora に移行してみるのも良いのかもしれません (Serverless Aurora の Data API は現在ベータ版のようですが)。
結局マッピングテンプレートもほとんど利用せず、Lambda に割とコードを書いてしまった点は反省すべき点かもしれません。こうなってくると AppSync を使い続けるメリットとは？となってきてしまうのですが、やはり AWS のエコシステムやサポートがあることでしょうか。複数のデータソースを組み合わせるのは GraphQL の魅力の１つで、それを AWS リソースに対し手軽に行えるのはやはり良いです。

一番厳しい制限は、BatchInvoke の Batching が最大 5 つという制限でした。これは *公式ドキュメントにも記載がなく* 気づくのが遅れてしまったのですが、裏が Lambda (しかもさらにその後ろが RDS) なので、クエリを気を使って設計する必要があります。これさえなければ採用しても良かったのですが、これにより少し雲行きが怪しくなっています。

AWS AppSync 自体は Serverless Aurora の他にも新機能が続々と登場しており、非常に興味深いサービスです。各所でちらほらと実用されている様子も伺えます。一方、GraphQL 界隈では割と Apollo の勢いが強いとの話も聞くので、そちらもこれから詳しくのぞいていきます。

[^1]: つい最近 [Serverless Aurora が AppSync でサポートされた](https://aws.amazon.com/jp/blogs/mobile/aws-appsync-releases-pipeline-resolvers-aurora-serverless-support-delta-sync/) というアナウンスがありました。これは、ほぼ同時期にアナウンスされた [Serverless Aurora の Data API](https://aws.amazon.com/jp/about-aws/whats-new/2018/11/aurora-serverless-data-api-beta/) を利用したものであるため、Serverless Aurora でしか利用できません。また、Serverless Aurora は現在 MySQL 5.6 までしかサポートしていないのに対し、JSON 型等の 5.7 以降特有の機能を利用していたため、採用は断念しました
[^2]: AWS AppSync は、コンソール上からの API 生成時に、サンプルプロジェクトからの生成が利用できます。その中に、RDS をバックエンドとしたサンプルとして `Blog App` があります
[^3]: Lambda + RDS は、コールドスタート時の遅さやコネクション数の制限の問題などから、アンチパターンとして有名です。が、今回は社内向け API でありそこまで負荷は高まらないことが想定されるため、この方式を採用することにしました。
[^4]: バージョン 1.0 未満なので、使用方法に大幅な変更が入る恐れがあるので注意。例えば、設定ファイルの導入は 0.13 -> 0.14 から