この記事は弥生 Advent Calendar 2022の18日目の記事です。
こんちには、2022年7月に入社したバックエンドエンジニアの永野です。
弥生にはエンジニアリンググループという取り組みがあり、
そこで検証したAWS CDKでAppSyncを構築する方法について、ご紹介しようと思います。
なお、今回はAWS CDKとAppSync、またGraphQLについての説明は割愛させていただきます。
すでに詳しい記事が世に沢山あることと、そもそも私自身が実は上記に触るのが初めてで、知るより慣れろということで、手を動かすことをメインに取り組みを進めてきたためです。
エンジニアリンググループとは
技術情報やノウハウの蓄積/共有を行い、エンジニアを育成するためのバーチャルグループです。
毎週水曜日に2~3時間ほど活動を行っています。
活動内容はグループごとに決めています。私が参加しているグループでは、直近はAWSのスキルアップを目的に、各自がハンズオンの実施やサービスの調査・検証などを行い、その結果の共有や意見交換を行っております。
ちなみに、この活動のために限らずですが、弥生では申請すればAWSの勉強用環境を利用することができるので、AWS利用コストを気にせず自己学習を進めることができます。
今回実装するもの
AppSyncで下記2つのコマンドを持つGraphQL APIを実装します。
1. addBookFromIsbnミューテーション
リクエストで送ったISBNコードでGoogleBooksAPIから書籍情報を取得して、それをDynamoDBに登録する操作を行うコマンドです。
データソースとしてLambdaを使用して、LambdaHandlerの中で必要な処理を行います
2. allBooksクエリ
DynamoDBに登録されている全書籍データを取得するコマンドです。
データソースとしてDynamoDBを使用して、AppSyncから直接データを取得して呼び出し元に返します。
事前準備
AWS CDK の使用を開始する 入門ガイドに記載のとおり、CDKを使用するためにAWS CLIとNode.JSのインストールが事前に必要です。
今回はWindowsマシーンで開発を行うので、Windowsで使用できるパッケージマネージャのWingetでインストールしていきます。
Node.JSのインストール
winget install -e --id OpenJS.NodeJS.LTS
AWS CLIのインストール
winget install -e --id Amazon.AWSCLI
認証情報は、名前付きプロファイルを利用して保存しておきます。
また、今回はAWS SSOで認証するので、aws sso configure
コマンドを実行し、プロンプトに対話的に表示される設問に対して入力していきます。(参考)
aws configure sso --profile enggrp
設定後はログインしている状態となりますが、時間が経ってから利用する場合は、
aws sso login
コマンドでログインをしておいてください。
aws sso login --profile enggrp
CDKプロジェクトの作成
さて、事前準備が終わったところで、さっそくCDKを利用するためのプロジェクトを作成していきます。
まずは、今回プロジェクトを作成するフォルダの中で、cdk init
コマンドを使用してプロジェクトを作成します。
npx cdk@latest init app --language typescript
実行するとフォルダの中にCDKのプロジェクトが作成されます。
自動作成されたフォルダとファイルに、いくつか手動で追加して今回使用する全てのファイルとなります。
(手動追加と記載しているものは、後述の中で作成していくものです)
📦eng-grp-cdk-appsync
┣ 📂bin
┃ ┗ 📜eng-grp-cdk-appsync.ts
┣ 📂graphql # 手動追加
┃ ┗ 📜schema.graphql # 手動追加
┣ 📂lambda # 手動追加
┃ ┣ 📜appsync-resolver.ts # 手動追加
┃ ┗ 📜graphql.ts # 手動追加(コードジェネレータで生成する)
┣ 📂lib
┃ ┗ 📜eng-grp-cdk-appsync-stack.ts
┣ 📂node_modules
┣ 📂test
┃ ┗ 📜eng-grp-cdk-appsync.test.ts
┣ 📜.gitignore
┣ 📜.npmignore
┣ 📜cdk.json
┣ 📜codegen.ts # 手動追加
┣ 📜jest.config.js
┣ 📜package-lock.json
┣ 📜package.json
┣ 📜README.md
┗ 📜tsconfig.json
プロジェクトの作成が終わったら、最後にAWSアカウントのブートストラップを行います。
npx cdk bootstrap --profile enggrp
追加パッケージのインストール
今回の実装や作業で使用するnpmパッケージを追加していきます。
aws-cdk/aws-appsync-alpha
今回AppSyncを構築するにあたって、L2コンストラクトを使用したいです。
CDKはV2から、安定版(stable)のAPIはaws-cdk-lib
パッケージに全リソースのものが含まれています。
一方で、実験的な(experimental)APIは、aws-cdk-lib
には含まれず、別のパッケージで提供されます。(参考)
AppSyncのL2コンストラクトはまだ、experimentalなものしかないので、そちらを追加でインストールします。
npm install @aws-cdk/aws-appsync-alpha
このように末尾に-alpha
と付いているものが、experimental apiになります。
esbuild
NodejsFunctionコンストラクトが、TypeScriptで実装したLambdaHandlerをトンランスコンパイルやバンドルをするために使用します。
今回、LambdaについてもTypeScriptを使用して実装します。
CDKのNodejsFunctionコンストラクトを使うとTypeScriptで実装したコードに対して、自動でトランスコンパイルやバンドルをしたLambda関数を作成してくれるのです。楽ちんです。
そのコンパイル作業をDockerコンテナ内で実行させることもできるのですが、今回はローカルにesbuildをインストールして使用して行います。
esbuildがインストールされている場合、自動でこちらを使用します。
npm install -D esbuild
types/aws-lambda
TypescriptでLambda関数ハンドラーを扱うための型定義をインストールします。
npm install -D @types/aws-lambda
aws-sdk dynamodb関連パッケージ
今回作成するLambda関数の中でDynamoDBを扱うため、
DynamoDB操作のクライアントとユーティリティをインストールします
npm install @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb
axios
HTTPクライアントとしてaxiosをインストールします。
Lambda関数内で、GoogleBookAPIとのやり取りをするために使用します。
npm install axios
コードジェネレータ
GraphQLスキーマからTypeScriptの型定義のコードを作成するために使用するパッケージをインストールします。
npm install -D @graphql-codegen/cli @graphql-codegen/typescript
GraphQLスキーマの定義
まず、今回AppSyncを使って作成するGraphQL APIのスキーマ定義を外部ファイルとして作成しておきます。
CDKでリソースを作成する際に、こちらのファイルを読み込んでAppSyncのスキーマ定義を実装します。
また、Lambdaを作成する際に、スキーマ定義からTypeScriptの型定義を生成するためにもこのファイルを使用します。
# graphql/schema.graphql
scalar AWSDate
type Book {
id: ID!
title: String!
subtitle: String
isbn: String
authors: [String]
publishedDate:AWSDate
thumbnailLink: String
}
type Mutation {
addBookFromIsbn(isbn: String!): Book
}
type Query {
allBooks: [Book]
}
AWSDate
はAppSyncでデフォルトで使用できる日付型のスカラー定義です。
スキーマ定義上で型として使用する分には、いちいちスカラー定義を再度しなくても使用できるのですが、今回は後述で出てくるコードジェネレータに認識させるために定義をしています。
AppSyncで使用できるスカラー型はこちらに記載があります。
LambdaHandlerの実装
次にLambda関数としてデプロイするLambdaHandlerを実装してしまいます。
今回のLambdaは、受け取ったISBNをもとに、Google Books APIに問い合わせをして書籍情報を取得してDynamoDBに登録する処理を行います。
GraphQLスキーマからTypeScriptの型定義を作成
まず、コードジェネレータというツールを使って、GraphQLスキーマからTypeScriptの型定義のコードを作成します。
設定ファイルの作成
コードジェネレータの挙動を決める設定ファイルをプロジェクトルートに作成します。
作成したソースは下記になります。
codegen.ts
import { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: "./graphql/schema.graphql",
generates: {
"./lambda/graphql.ts": {
plugins: ["typescript"],
},
},
config: {
skipTypename: true,
useTypeImports: true,
scalars: {
AWSDate: "Date",
},
},
};
export default config;
少しピックアップして見てみます。
スキーマ情報の取得先パスを指定
schema: "./graphql/schema.graphql",
今回は、スキーマ情報の取得元として、前の手順で作成したGraphQLのスキーマ定義を記載しているファイルを指定します。
本来は、AppSyncを先に作成して公開したエンドポイントを取得元に指定して、各種サーバやクライアント側で使用する型定義を生成するのが正しそうですが、今回はLambdaも同時にデプロイする都合上、ローカルのファイルに定義しているスキーマ情報から型を生成します。
生成されるファイルパスと生成に使用するプラグインの指定
generates: {
"./lambda/graphql.ts": {
plugins: ["typescript"],
},
},
generatesには、生成されるファイルのパスをキーに、使用するプラグインの情報などを記載していきます。
つまり、複数のプラグインを使って別々のファイルを同時に生成することも可能です。
プラグインについては、今回Lambdaの中ではスキーマに基づく型定義しか使用しないので、@graphql-codegen/typescript のみを使用します。
オプション設定を指定
config: {
skipTypename: true,
useTypeImports: true,
scalars: {
AWSDate: "Date",
},
},
生成にかかわるオプション設定を指定していきます。
ポイントになるのはscalars
で、これは定義したスカラーをTypeScript上のどの型に変換するかを指定します。
今回はコードジェネレータがデフォルトで認識できない、AWSDateをDate型に変換するように指示しています。他の型はデフォルトで変換される型が決まっているので特に指定不要です。
ソースコードの生成を実行
設定ファイルが準備できたら、下記コマンドを使用して、コードを生成を実行します。
npx graphql-codegen --config codegen.ts
実行の結果lambda/graphql.tsが生成されます。この中にTypeScriptで使用できる型定義が含まれているので、LambdaHandlerのソースコードにインポートして使用します。
Lamdba Handlerの実装
次にLambda Handlerとして使用するソースコードを作成します。
Lambda Handlerのソースは、今回はCDKのプロジェクト内に作成して、後々スタックのコードから相対パスで指定します。
作成したソースは下記になります。
lambda/appsync-resolver.ts
import { AppSyncResolverHandler } from "aws-lambda";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
import axios from "axios";
import { randomUUID } from "crypto";
import { Book, MutationAddBookFromIsbnArgs } from "./graphql";
const httpClient = axios.create({
baseURL: "https://www.googleapis.com/books",
});
const dbClient = new DynamoDBClient({});
export const handler: AppSyncResolverHandler<
MutationAddBookFromIsbnArgs,
Book
> = async (event, context) => {
try {
const response = await httpClient.get<GoogleApiResponseData>(
"/v1/volumes",
{
params: {
q: `isbn:${event.arguments.isbn}`,
},
}
);
const responseData = response.data.items[0];
if (!responseData) {
throw new Error("GoogleBookApiからデータが取得できません");
}
// DynamoDbに登録
let data: Book = {
id: randomUUID(),
title: responseData.volumeInfo?.title,
subtitle: responseData.volumeInfo?.subtitle ?? "",
isbn: event.arguments.isbn,
authors: responseData.volumeInfo?.authors ?? [],
publishedDate: responseData.volumeInfo?.publishedDate ?? null,
thumbnailLink: responseData.volumeInfo?.imageLinks?.thumbnail ?? "",
};
await dbClient.send(
new PutItemCommand({
TableName: "books",
Item: marshall(data),
})
);
return data;
} catch (err) {
console.error(err);
throw new Error(`データ登録に失敗しました。`);
}
};
interface GoogleApiResponseData {
items: BookItem[];
kind: string;
totalItems: number;
}
interface BookItem {
id: string;
volumeInfo: {
title: string;
subtitle?: string;
authors?: string[];
publishedDate?: Date;
imageLinks?: {
thumbnail?: string;
};
};
}
内容については、今回のメインから外れるので割愛しますが、
ポイントとしては、コードジェネレータを使用して生成した型を使うことで、handlerの引数と戻り値の型定義を、スキーマで定義した addBookFromIsbn Mutationの型定義と一致させられることです。
export const handler: AppSyncResolverHandler<MutationAddBookFromIsbnArgs, Book>
handerとしてexportする関数をAppSyncResolverHandler型で定義して、1つ目の型引数にMutationの引数の型を、2つ目に戻り値の型を指定しています。
スタックの実装
いよいよスタックのソースコードに対して、作成するリソースの定義を追加していきます。
作成したソースは下記になります。
lib/eng-grp-cdk-appsync-stack.ts
import {
Duration,
Expiration,
RemovalPolicy,
Stack,
StackProps,
} from "aws-cdk-lib";
import {
AuthorizationType,
FieldLogLevel,
GraphqlApi,
MappingTemplate,
} from "@aws-cdk/aws-appsync-alpha";
import { Construct } from "constructs/lib";
import { AttributeType, Table } from "aws-cdk-lib/aws-dynamodb";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import { SchemaFile } from "@aws-cdk/aws-appsync-alpha/lib/schema";
export class EngGrpCdkAppsyncStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// DynamoDB
const table = new Table(this, "DynamoDbTable", {
tableName: "books",
partitionKey: {
name: "id",
type: AttributeType.STRING,
},
removalPolicy: RemovalPolicy.DESTROY,
});
// Lambdaを定義
const lambdaHandler = new NodejsFunction(this, "LambdaHandler", {
entry: "lambda/appsync-resolver.ts",
logRetention: RetentionDays.ONE_DAY,
});
// dynamoDbのread/write権限を付与
table.grantReadWriteData(lambdaHandler);
// AppSyncリソースを定義
const api = new GraphqlApi(this, "GraphqlApi", {
name: "eng_group_cdk_demo",
schema: SchemaFile.fromAsset("./graphql/schema.graphql"), // プロジェクトルートからの相対パス
authorizationConfig: {
defaultAuthorization: {
authorizationType: AuthorizationType.API_KEY,
apiKeyConfig: {
expires: Expiration.after(Duration.days(365)),
},
},
},
logConfig: {
fieldLogLevel: FieldLogLevel.ALL,
retention: RetentionDays.ONE_DAY,
},
});
// Query「allBooks」とDynamoDBを関連付けるデータソースを作成
const dataSource1 = api.addDynamoDbDataSource("DataSource1", table);
// リゾルバを定義
dataSource1.createResolver("Resolver1", {
typeName: "Query",
fieldName: "allBooks",
requestMappingTemplate: MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: MappingTemplate.dynamoDbResultList(),
});
// Mutation「addBookFromIsbn」とLambdaを関連付けるデータソースを作成
const dataSource2 = api.addLambdaDataSource("DataSource2", lambdaHandler);
// リゾルバを定義
dataSource2.createResolver("Resolver2", {
typeName: "Mutation",
fieldName: "addBookFromIsbn",
});
}
}
分解して見ていきます。
DynamoDBの定義を追加
book という名前のテーブルを定義します。
const table = new Table(this, "DynamoDbTable", {
tableName: "books",
partitionKey: {
name: "id",
type: AttributeType.STRING,
},
removalPolicy: RemovalPolicy.DESTROY,
});
Lambda関数の定義を追加
前述したとおり、
NodejsFunctionコンストラクトを使用することで、TypeScriptで実装したLambdaHandlerのソースコードをトンランスコンパイルやバンドルした上でLambda関数としてデプロイしてくれます。
entry
には、前述のステップで作成したLambda Handlerのソースコード(lambda/appsync-resolver.ts
)のパスをプロジェクトルートからの相対パスで記載します。
const lambdaHandler = new NodejsFunction(this, "LambdaHandler", {
entry: "lambda/appsync-resolver.ts",
logRetention: RetentionDays.ONE_DAY,
});
DynamoDbテーブルのRead/Write権限をLambdaのロールに付与
今回Lambda関数からDynamoDBへの操作が必要なため、
作成した bookテーブルの読み取り/書き込み権限をLamdba関数のロールに付与します。
ここまで特にロールの定義は出てきていませんが、Lambda関数の定義時にロールの指定を省略した場合は自動で作成されています。
table.grantReadWriteData(lambdaHandler);
AppSync GraphQL APIの定義を追加
GraphQL APIには、スキーマの定義を持たせる必要があります。
今回は前述のステップで作成した定義ファイル(graphql/schema.graphql
)を読み込んで設定するようにしています。
また、今回APIの認証方式としては、APIキー方式を使用しています。
他にもAWS Cognitoと連携して認証を行うことなども可能です。
const api = new GraphqlApi(this, "GraphqlApi", {
name: "eng_group_cdk_demo",
schema: SchemaFile.fromAsset("./graphql/schema.graphql"),
authorizationConfig: {
defaultAuthorization: {
authorizationType: AuthorizationType.API_KEY,
apiKeyConfig: {
expires: Expiration.after(Duration.days(365)),
},
},
},
logConfig: {
fieldLogLevel: FieldLogLevel.ALL,
retention: RetentionDays.ONE_DAY,
},
});
allBooksクエリとDynamoDBを関連付けるリゾルバを作成
スキーマで定義したallBooksクエリとデータソースとして指定するDynamoDBのbookテーブルを解決するためのリゾルバを定義します。
リゾルバでは、VTL言語を使用してマッピングテンプレート という、GraphQL リクエスト及びレスポンスとバックエンドのデータを変換する方法の定義を記載しないといけないのですが、AppSyncのL2コンストラクタは、単純なものであれば記述なしでマッピングテンプレートを生成することも可能です。楽ちんです。
const dataSource1 = api.addDynamoDbDataSource("DataSource1", table);
dataSource1.createResolver("Resolver1", {
typeName: "Query",
fieldName: "allBooks",
requestMappingTemplate: MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: MappingTemplate.dynamoDbResultList(),
});
addBookFromIsbnミューテーションとDynamoDBを関連付けるリゾルバを作成
スキーマで定義したaddBookFromIsbnミューテーションとデータソースとして指定するLambda関数を解決するためのリゾルバを定義します。
データソースにLambda関数を使用する場合は、Direct Lambda Resolvers を使用することで、マッピングテンプレート定義を省略できるので、テンプレートの指定は不要です。
const dataSource2 = api.addLambdaDataSource("DataSource2", lambdaHandler);
dataSource2.createResolver("Resolver2", {
typeName: "Mutation",
fieldName: "addBookFromIsbn",
});
リソースの作成を実行
ここまでで必要なソースコードは揃ったので、cdk deploy
コマンドを使用して実際にAWS上にリソースを作成します。
npx cdk deploy --profile enggrp
コマンドを実行するとプロンプト上に作成されるリソースの変更差分と、
Do you wish to deploy these changes (y/n)?
のメッセージが表示されます。
問題なければ、y
を入力してエンターを押下します。
特にエラーが発生しなければ、これで定義したリソースがAWS上に構築されます。
ローカル端末からリクエストを送信
最後に動作確認として、ローカル端末からリクエストを送ってみます。
リクエストの送信にはPowershell CoreのInvoke-RestMethod
コマンドレットを使用します。
まず、作成したGraphQLAPIのエンドポイントURIとAPIキーをAWS CLIを使用して確認します。
# API IDとUrlを確認
aws appsync list-graphql-apis --query "graphqlApis[?name=='eng_group_cdk_demo'].{apiId:apiId,uri:uris.GRAPHQL}" --profile enggrp
# API IDを使用してAPI KEYを確認
aws appsync list-api-keys --api-id ${取得したAPI ID} --profile enggrp
確認したURIとAPIキーを埋め込みつつ、下記スクリプトをPowershellで実行します
$uri = "${確認したAPI URL} "
$headers = @{
"X-API-KEY" = "${確認したAPI KEY} "
}
$contentType = "application/json"
$allBooksQuery = @{"query" = @"
query {
allBooks {
id
title
subtitle
isbn
authors
publishedDate
thumbnailLink
}
}
"@
} | ConvertTo-Json
$addBookFromIsbn = @{"query" = @"
mutation {
addBookFromIsbn(isbn:"@isbn") {
id
title
subtitle
isbn
authors
publishedDate
thumbnailLink
}
}
"@
} | ConvertTo-Json
# 2つのISBNコードで書籍登録Mutationを実行
Invoke-RestMethod -Method Post -Uri $uri -ContentType $contentType -Headers $headers -Body ($addBookFromIsbn -replace "@isbn","9784822262969") > $null
Invoke-RestMethod -Method Post -Uri $uri -ContentType $contentType -Headers $headers -Body ($addBookFromIsbn -replace "@isbn","9784295010654") > $null
# 全書籍取得Queryを実行して結果を表示
(Invoke-RestMethod -Method Post -Uri $uri -ContentType $contentType -Headers $headers -Body $allBooksQuery) | ConvertTo-Json -Depth 10
実行して、allBooksクエリが下記結果を返したことを確認できました。
allBooksクエリの結果
{
"data": {
"allBooks": [
{
"id": "751494de-0769-454a-a4dc-466c479d7bd5",
"title": "Amazon Web Services基礎からのネットワーク&サーバー構築",
"subtitle": "さわって学ぶクラウドインフラ",
"isbn": "9784822262969",
"authors": [
"玉川憲",
"片山暁雄",
"今井雄太"
],
"publishedDate": "2014-07-22",
"thumbnailLink": "http://books.google.com/books/content?id=zMzgoAEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
},
{
"id": "0aac1d4a-f622-4b60-a75c-c9b1e151562d",
"title": "改訂新版 徹底攻略 AWS認定 ソリューションアーキテクト − アソシエイト教科書[SAA-C02]対応",
"subtitle": "",
"isbn": "9784295010654",
"authors": [
"鳥谷部 昭寛",
"宮口 光平",
"菖蒲 淳司"
],
"publishedDate": "2021-01-08",
"thumbnailLink": "http://books.google.com/books/content?id=a7USEAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api"
}
]
}
}
aws dynamodb scan --table-name "books" --profile enggrp
こちらもDBにデータが投入されていることを確認できました。
aws dynamodb scanの結果
{
"Items": [
{
"thumbnailLink": {
"S": "http://books.google.com/books/content?id=zMzgoAEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
},
"isbn": {
"S": "9784822262969"
},
"subtitle": {
"S": "さわって学ぶクラウドインフラ"
},
"id": {
"S": "751494de-0769-454a-a4dc-466c479d7bd5"
},
"publishedDate": {
"S": "2014-07-22"
},
"title": {
"S": "Amazon Web Services基礎からのネットワーク&サーバー構築"
},
"authors": {
"L": [
{
"S": "玉川憲"
},
{
"S": "片山暁雄"
},
{
"S": "今井雄太"
}
]
}
},
{
"thumbnailLink": {
"S": "http://books.google.com/books/content?id=a7USEAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api"
},
"isbn": {
"S": "9784295010654"
},
"subtitle": {
"S": ""
},
"id": {
"S": "0aac1d4a-f622-4b60-a75c-c9b1e151562d"
},
"publishedDate": {
"S": "2021-01-08"
},
"title": {
"S": "改訂新版 徹底攻略 AWS認定 ソリューションアーキテクト - アソシエイト教科書[SAA-C02]対応"
},
"authors": {
"L": [
{
"S": "鳥谷部 昭寛"
},
{
"S": "宮口 光平"
},
{
"S": "菖蒲 淳司"
}
]
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
あとがき
AppSyncを構築する方法を調べるとよく出てくるのはAmplify CLIですが、
L2コンストラクトを使用すれば、CDKでもかなり簡単に実装できることがわかりました。
これだけ簡単に実装できれば、APIとしてGraphQLを選択する敷居は大分下がるのではないでしょうか。
これから実際のアプリに組み込んだりして使いこなしていこうと思います。