AWSで3factor appを構築する方法をチュートリアルとしてまとめた
はじめに
おはようございます、加藤です。今回は3factor appをAWS上で構築する手順をチュートリアルにしてみました。
3factor appとは以下の3要素からなるHasuraが提案するアーキテクチャパターンです。
- Realtime GraphQL
- Reliable eventing
- Async serverless
詳細な説明は公式を参照頂くとして、ざっくり説明してしまうと3factor appはGraphQL Subscriptionによってフロントエンドが非同期で情報を受け取れるようにし、バックエンドはイベント・ドリブンで動くアーキテクチャです。
このアーキテクチャを知ったときは「GraphQL×イベント・ドリブン×非同期レスポンスというアーキテクチャは構築・運用難易度が高そう」という第一印象を持ちました。それが正しいかを多少なりとも確認するために最低限な範囲でサンプルアプリを作ってみました。本ブログはアプリ作成の過程をチュートリアルとしてまとめたものです。
なお非常にありがたいことに、Hasuraが3factor appのサンプルアプリの実装を公開してくれています。ただし、本ブログではこの実装から外観だけを参考とし中身はあまり見ずに作成を行いました。(自分でヤクの毛刈りをして枝葉の知識も学ぶため)
前提条件
これはAWSで3factor appを構築するためのチュートリアルです。下記のような方をターゲットとして想定しています。
- Lambdaを使ったサーバーレスなWebAPI開発を行った経験がある
- Node.js・TypeScriptを多少なりとも触ったことがある
- React(16.8以降、Hooks)に対してチュートリアルを終えた程度の経験がある
これを満たさない方が本チュートリアルに臨む場合は、手を動かす前に一度最後まで読み、知らない言葉があったら調べておくことをオススメします。
アーキテクチャ紹介
本チュートリアルでは、フードデリバリーシステムのデモアプリを作成します。ユーザー認証や店舗の選択、動的なメニューの所得などはカットしており、あくまで3factor appをAWSで実現できそうかをチェックするための最低限の実装です。
クライアントはWebアプリケーションでNext.jsを使用しています。
AppSyncは必ず認証を設定する必要があるのでAPI Key認証を使用します。
イベントシステムはAppSync・DynamoDB・EventBridgeの3つを組み合わせて実現しています。これついての詳細は後述します。
ユーザーから注文が入るとどのように処理が進むかを説明します。
注文が行われるとMutationリクエストが送信(①)され、レスポンスでorderIdを受け取ります。その後、個別オーダーのページに遷移しorderIdをキーとしてQueryリクエストによってオーダー状態が取得・描画されます。同時にSubscriptionリクエストが開始され、Browserはその注文の状態をリアルタイムで受け取れる様になります(⑦)。
AppSyncは受け取ったMutationリクエストに基づいてDynamoDBにItemの追加を行います(②)。これによって注文が初期状態でイベントシステムに投入されます。Itemの更新が発生したので、DynamoDB Streamsの機能によってリアルタイムでLambdaを非同期実行され、作成か更新アクションだった場合はEventBrideのEventBusに入力します。入力するEventのdetailの型は下記の通りです。
export interface Detail {
orderId: string;
userId: string;
address: string;
menuItems: string[];
orderValid: boolean;
paymentValid: boolean;
restaurantApproved: boolean;
driverAssigned: boolean;
createdAt: string;
}
EventBusはdetailが下記のルールを満たす場合、各Lambda Functionを実行します(③、④、⑤、⑥)。
ルール | サービス名 | Lambda Function名 |
---|---|---|
orderValid === false |
Order Service | OrderCreateHandler |
orderValid === true |
Payment Service | PaymentHandler |
paymentValid === true |
Restaurant Service | RestaurantHandler |
restaurantApproved === true |
Driver Service | DriverHandler |
各Lambda関数はそのサービスの担当する処理を終えた後に、イベントを更新しAppSyncに対してMutationリクエストを行います。たとえば、Order Serviceは注文情報が適切だった場合はorderValid = true
に更新してEventを入力します。バックエンドにおいてもEventの更新にAppSyncを経由する理由については後述します。
AppSyncは受け取ったMutationリクエストに基づいてDynamoDBにItemの更新を行います(②)。この更新が環境するとAppSyncの機能によってSubscriptionしているBrowserにも更新された結果が通知されます(⑦)。
これが何回も繰り返されて最終的に注文が完了します。
イベントシステムの説明
本来の3factor appはServerless functionからのEventをStateで受け取ります。今回のアーキテクチャに置き換えるとLambdaが更新したEventはDynamoDBで受け取るべきです。しかし、DynamoDBに対する更新(UpdateItem)は型のサポートが非常に弱く、更新をフロントエンドに通知する為には、AppSyncに対してMutationを行う必要がある[1]ため、Eventは常にAppSyncに対して送信することにしました。
AWSサービス名 | 役割 |
---|---|
AppSync | Eventのインプット フロントエンドに対するEventのアウトプット(GraphQL Subscription) |
DynamoDB Table | Eventの記録 |
EventBridge | バックエンドに対するEventのアウトプット |
Lambda Function | Eventの処理 |
これによってAppSyncに対してMutationリクエストでイベントを投入すれば、その結果がフロントエンドとバックエンドに通知される仕組みがサーバーレスで実現できました。
なお、今回はAPI Key認証ですが、AppSyncはIAM認証にも対応しているため、これを採用すればAPIキーのローテーションから解放されます。また、認証にCognitoかOIDCを使用している場合は、Client Credentials Flowを利用することでAppSyncに設定する認証方式を1つで済ませることが可能です。
実践
今回のコードはこちらのリポジトリにアップしています。チュートリアルだけ見れば完成できるはずですが、迷った場合はご参照ください。ファイル名や変数名が若干チュートリアルと違う場合があるかもしれません。
プロジェクトの初期生成
リポジトリのルートとなるディレクトリを作成します。ディレクトリ名は3factor-app-on-awsとしました。
mkdir 3factor-app-on-aws && cd $_
yarn init -y
echo "node_modules" >> .gitignore
AWS CDKとNext.jsのプロジェクトを初期生成します。
Yarn Workspaceを使うので各パッケージ内のロックファイルは不要です。Git管理も同様にルートディレクトリで行うので不要です。これらのファイルを削除します。なお、チュートリアルではGit操作について触れていません、必要に応じて適時コミットしながら行ってください。
mkdir -p packages/aws && cd $_
npx -p aws-cdk@2 cdk init app --language typescript
rm package-lock.json
cd ../
npx create-next-app --ts --use-npm frontend
rm -rf frontend/.git frontend/package-lock.json
package.json
を編集してYarn Workspaceを有効にします。
{
"name": "3factor-app-on-aws",
"private": true,
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": [
"**/*"
]
}
}
ES2019以上を要求するメソッドを使用する予定があるので、target
とlib
を更新します。それとesModuleInterop
フラグを有功化します。
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["es2019"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"esModuleInterop": true,
"typeRoots": ["./node_modules/@types"]
},
"exclude": ["node_modules", "cdk.out"]
}
バックエンドの構築
ルートディレクトリで作業を開始します。
pwd
# $YOUR_WORK_DIR/3factor-app-on-aws
スキーマの作成
GraphQLスキーマを定義schema.graphql
を作成します。
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Order {
orderId: ID!
userId: String!
address: String!
menuItems: [String!]!
orderValid: Boolean!
paymentValid: Boolean!
restaurantApproved: Boolean!
driverAssigned: Boolean!
createdAt: AWSDateTime!
}
input CreateOrderInput {
userId: String!
address: String!
menuItems: [String!]!
}
input UpdateOrderInput {
orderId: ID!
orderValid: Boolean
paymentValid: Boolean
restaurantApproved: Boolean
driverAssigned: Boolean
}
type Query {
getOrder(orderId: ID!): Order
getAllOrders: [Order!]!
}
type Mutation {
createOrder(input: CreateOrderInput!): Order!
updateOrder(input: UpdateOrderInput!): Order!
}
type Subscription {
onOrderUpdate(orderId: ID!): Order @aws_subscribe(mutations: ["updateOrder"])
}
onOrderUpdate Subscriptionで@aws_subscribe(mutations: ["updateOrder"])
というAppSync固有のディレクティブが宣言されています。
この記述をすることで、updateOrder Mutationが実行された際のレスポンスがSubscriptionで伝わります。つまりクライアントはOrderの変更をリアルタイムで受け取ることができます。
注意点として、updateOrderする際に要求するフィールドはOrder構成するすべてのフィールドを含む必要があります。
純粋な WebSockets クライアントでは、各クライアントが独自の選択セットを定義できるため、選択セットのフィルタリングはクライアントごとに行われます。この場合、サブスクリプション選択セットは、ミューテーション選択セットのサブセットである必要があります。たとえば、サブスクリプションが addedPost{author title} ミューテーションにリンクされていると、addPost(...){id author title url version} 投稿の作成者とタイトルのみを受け取ります。他のフィールドは受け取りません。ただし、ミューテーションの選択セットに作成者が欠けていた場合、サブスクライバーは作成者フィールドに対して null 値を取得します (または、スキーマ内で作成者フィールドが required/not-null と定義されている場合はエラー)。
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/aws-appsync-real-time-data.html
Mutationリクエストの例
mutation Good($oderId: ID!) {
updateOrder(input: {orderId: $orderId, orderValid: true}) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
mutation Bad1($oderId: ID!) {
updateOrder(input: {orderId: $orderId, orderValid: true}) {
orderId
}
}
mutation Bad2($oderId: ID!) {
updateOrder(input: {orderId: $orderId, orderValid: true}) {
orderId
userId
address
}
}
schema.graphql
では、AppSync固有のスカラーを使用しています。フロントエンド側でスキーマからTypeScriptの型を生成するために定義が必要なのでappsync.graphql
を作成します。
scalar AWSDate
scalar AWSTime
scalar AWSDateTime
scalar AWSTimestamp
scalar AWSEmail
scalar AWSJSON
scalar AWSURL
scalar AWSPhone
scalar AWSIPAddress
AppSync(GraphQL)の構築
CDK v2において、AppSyncのL2コンストラクトはAlphaバージョンなのでパッケージを追加インストールします。
yarn workspace aws add -D @aws-cdk/aws-appsync-alpha
AppSyncを作成するためのCDKコンストラクトを作成します。L2コンストラクトのうれしい機能として定型なVTLはMappingTemplate.dynamoDbGetItem('orderId', 'orderId')
のような感じで記述すればCDKに生成させることができます。
import {
AuthorizationType,
FieldLogLevel,
GraphqlApi,
MappingTemplate,
Schema,
} from '@aws-cdk/aws-appsync-alpha';
import {Duration, Expiration} from 'aws-cdk-lib';
import {ITable} from 'aws-cdk-lib/aws-dynamodb';
import {Construct} from 'constructs';
import {resolve} from 'path';
interface WebApiProps {
orderTable: ITable;
}
export class WebApi extends Construct {
public readonly api: GraphqlApi;
constructor(scope: Construct, id: string, props: WebApiProps) {
super(scope, id);
const api = new GraphqlApi(this, 'API', {
name: '3FactorAppOnAws',
schema: Schema.fromAsset(
resolve(__dirname, '../../../../schema.graphql')
),
authorizationConfig: {
defaultAuthorization: {
authorizationType: AuthorizationType.API_KEY,
apiKeyConfig: {expires: Expiration.after(Duration.days(30))},
},
},
xrayEnabled: true,
logConfig: {
fieldLogLevel: FieldLogLevel.ALL,
},
});
new CfnOutput(this, "ApiUrl", { value: api.graphqlUrl });
new CfnOutput(this, "ApiKey", { value: api.apiKey ?? "" });
this.api = api;
const orderDS = api.addDynamoDbDataSource(
'OrderDataSource',
props.orderTable
);
orderDS.createResolver({
typeName: 'Query',
fieldName: 'getOrder',
requestMappingTemplate: MappingTemplate.dynamoDbGetItem(
'orderId',
'orderId'
),
responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
});
orderDS.createResolver({
typeName: 'Query',
fieldName: 'getAllOrders',
requestMappingTemplate: MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: MappingTemplate.dynamoDbResultList(),
});
orderDS.createResolver({
typeName: 'Mutation',
fieldName: 'createOrder',
requestMappingTemplate: MappingTemplate.fromFile(
resolve(__dirname, '../../src/vtl/create-order.vtl')
),
responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
});
orderDS.createResolver({
typeName: 'Mutation',
fieldName: 'updateOrder',
requestMappingTemplate: MappingTemplate.fromFile(
resolve(__dirname, '../../src/vtl/update-order.vtl')
),
responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
});
}
}
オーダー作成と更新はVTLを自分で書きました。
- 作成:
createdAt
に現在時刻をサーバーサイドで設定させるため - 更新: DynamoDB UpdateItemは生成がサポートされていないため(参考: 【AWS CDK】AWS AppSync で DynamoDB を直接 UpdateItemするマッピングテンプレートを使いたい | DevelopersIO)
今回は省略していますが、どちらの場合も本来はVTL内でリクエストを投げたユーザーと指定されたuserId
が一致しているかチェックする必要があります。もしくは($ctx.identity
)からユーザーの識別子を取得し、attributeValues
に追加して、Order Serviceで一致しているかを判断させることもできます。
{
"version": "2017-02-28",
"operation": "PutItem",
"key" : {
"orderId" : $util.dynamodb.toDynamoDBJson($util.autoId())
},
"attributeValues": $util.dynamodb.toMapValuesJson({
"orderId": $ctx.args.input.orderId,
"userId": $ctx.args.input.userId,
"address": $ctx.args.input.address,
"menuItems": $ctx.args.input.menuItems,
"orderValid": false,
"paymentValid": false,
"restaurantApproved": false,
"driverAssigned": false,
"createdAt": $util.time.nowISO8601()
})
}
#set($expression = "SET")
#set($expressionNames = {})
#set($expressionValues = {})
#foreach($entry in $ctx.args.input.entrySet())
#if ($entry.key != "orderId")
#set($expression = "${expression} #$entry.key = :$entry.key")
#if ( $foreach.hasNext )
#set( $expression = "${expression}," )
#end
$util.qr($expressionNames.put("#$entry.key", $entry.key))
$util.qr($expressionValues.put(":$entry.key", $entry.value))
#end
#end
{
"version" : "2018-05-29",
"operation" : "UpdateItem",
"key" : {
"orderId": $util.dynamodb.toDynamoDBJson($ctx.args.input.orderId),
},
"update" : {
"expression": "$expression",
"expressionNames": $util.toJson($expressionNames),
"expressionValues": $util.dynamodb.toMapValuesJson($expressionValues)
}
}
EventBusの構築
EventBusを作成します。
DynamoDBにItemが追加・更新された際にDynamoDB Streamを使ってこのEventBusにイベントを投入します。
import {ITable} from 'aws-cdk-lib/aws-dynamodb';
import {EventBus} from 'aws-cdk-lib/aws-events';
import {StartingPosition, Tracing} from 'aws-cdk-lib/aws-lambda';
import {DynamoEventSource} from 'aws-cdk-lib/aws-lambda-event-sources';
import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs';
import {Construct} from 'constructs';
interface OrderEventBusProps {
orderTable: ITable;
}
export class OrderEventBus extends Construct {
public readonly orderEventBus: EventBus;
constructor(scope: Construct, id: string, props: OrderEventBusProps) {
super(scope, id);
const orderEventBus = new EventBus(this, 'OrderEventBus');
this.orderEventBus = orderEventBus;
const orderEventHandler = new NodejsFunction(this, 'OrderEventHandler', {
entry: 'src/lambda/order-event-handler.ts',
tracing: Tracing.ACTIVE,
environment: {
EVENT_BUS_NAME: orderEventBus.eventBusName,
},
});
orderEventHandler.addEventSource(
new DynamoEventSource(props.orderTable, {
startingPosition: StartingPosition.LATEST,
})
);
orderEventBus.grantPutEventsTo(orderEventHandler);
}
}
DynamoDB Streamsで直接イベントをEventBusへ送ることができないため、Lambda Functionが必要です。
必要なライブラリをインストールし、Lambda Functionを書きます。
yarn workspace aws add -D \
@types/aws-lambda \
aws-xray-sdk \
@aws-sdk/util-dynamodb \
@aws-sdk/client-eventbridge
DynamoDB Itemに対する更新が作成・更新(NewImage !== undefined
)の場合、EventBusにイベントをPushするコードを書きます。
import {DynamoDBStreamHandler} from 'aws-lambda';
import {unmarshall} from '@aws-sdk/util-dynamodb';
import {EventBridgeClient, PutEventsCommand} from '@aws-sdk/client-eventbridge';
import {captureAWSv3Client} from 'aws-xray-sdk';
const EVENT_BUS_NAME = process.env.EVENT_BUS_NAME;
export const handler: DynamoDBStreamHandler = async event => {
const details = event.Records.flatMap(({dynamodb}) =>
dynamodb?.NewImage === undefined
? []
: [unmarshall(dynamodb.NewImage as any)]
); // @types/aws-lambdaのAttributeValueと@aws-sdk/client-dynamoDBのAttributeValueの型定義がズレている模様
const client = captureAWSv3Client(new EventBridgeClient({}));
const entries = details.map(detail => ({
EventBusName: EVENT_BUS_NAME,
Source: 'order-event-handler',
DetailType: 'order status changed',
Detail: JSON.stringify(detail),
}));
if (entries.length < 1) {
return;
}
await client.send(new PutEventsCommand({Entries: entries}));
};
イベントを処理する各サービスを構築する
EventBusに入ってきたイベントを処理する各サービスを構築します。
yarn workspace aws add -D \
axios \
graphql \
graphql-tag
各サービスで共有する型定義と、ユーティリティ関数を作成します。
export type DetailType = 'order status changed';
export interface Detail {
orderId: string;
userId: string;
address: string;
menuItems: string[];
orderValid: boolean;
paymentValid: boolean;
restaurantApproved: boolean;
driverAssigned: boolean;
createdAt: string;
}
各サービスで具体的な処理を実行する代わりにスリープ処理を行うための関数を書いておきます。
export const timeout = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
};
各サービスのコードを書きます。各サービスは処理を終えた後にイベントの更新をDynamoDBに直接書き込むのではなく、Mutationリクエストを行うことによって行います。
AppSyncがSubscriptionを行うためには、関連するMutationリクエストを受け付ける必要があるためです。なお、リクエストにaxiosを使っているのことに特別な理由はありません。
また副次効果として、比較的型のサポートが弱いDynamoDBへのUpdateItem処理を各サービスで実装せずに集約できます。
import {EventBridgeHandler} from 'aws-lambda';
import {captureHTTPsGlobal, capturePromise} from 'aws-xray-sdk';
import axios from 'axios';
import {print} from 'graphql';
import gql from 'graphql-tag';
import http from 'http';
import https from 'https';
import {Detail, DetailType} from '../types/order-event';
import {timeout} from './lib/util';
const GRAPHQL_API_URL = process.env.GRAPHQL_API_URL!;
const GRAPHQL_API_KEY = process.env.GRAPHQL_API_KEY!;
captureHTTPsGlobal(http, true);
captureHTTPsGlobal(https, true);
capturePromise();
export const handler: EventBridgeHandler<
DetailType,
Detail,
void
> = async event => {
console.log(JSON.stringify(event));
await timeout(10_000); // オーダー情報の検証 (例)住所が店舗から5km居ないか、なりすましリクエストじゃないか
const UPDATE_ORDER = gql`
mutation updateOrder($orderId: ID!) {
updateOrder(input: {orderId: $orderId, orderValid: true}) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
`;
await axios({
url: GRAPHQL_API_URL,
method: 'post',
headers: {
'content-type': 'application/json',
'x-api-key': GRAPHQL_API_KEY,
},
data: {
query: print(UPDATE_ORDER),
variables: {
orderId: event.detail.orderId,
},
},
});
};
import {EventBridgeHandler} from 'aws-lambda';
import {captureHTTPsGlobal, capturePromise} from 'aws-xray-sdk';
import axios from 'axios';
import {print} from 'graphql';
import gql from 'graphql-tag';
import http from 'http';
import https from 'https';
import {Detail, DetailType} from '../types/order-event';
import {timeout} from './lib/util';
const GRAPHQL_API_URL = process.env.GRAPHQL_API_URL!;
const GRAPHQL_API_KEY = process.env.GRAPHQL_API_KEY!;
captureHTTPsGlobal(http, true);
captureHTTPsGlobal(https, true);
capturePromise();
export const handler: EventBridgeHandler<
DetailType,
Detail,
void
> = async event => {
await timeout(3_000); // クレカへの請求処理など
const UPDATE_ORDER = gql`
mutation updateOrder($orderId: ID!) {
updateOrder(input: {orderId: $orderId, paymentValid: true}) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
`;
await axios({
url: GRAPHQL_API_URL,
method: 'post',
headers: {
'content-type': 'application/json',
'x-api-key': GRAPHQL_API_KEY,
},
data: {
query: print(UPDATE_ORDER),
variables: {
orderId: event.detail.orderId,
},
},
});
};
import {EventBridgeHandler} from 'aws-lambda';
import {captureHTTPsGlobal, capturePromise} from 'aws-xray-sdk';
import axios from 'axios';
import {print} from 'graphql';
import gql from 'graphql-tag';
import http from 'http';
import https from 'https';
import {Detail, DetailType} from '../types/order-event';
import {timeout} from './lib/util';
const GRAPHQL_API_URL = process.env.GRAPHQL_API_URL!;
const GRAPHQL_API_KEY = process.env.GRAPHQL_API_KEY!;
captureHTTPsGlobal(http, true);
captureHTTPsGlobal(https, true);
capturePromise();
export const handler: EventBridgeHandler<
DetailType,
Detail,
void
> = async event => {
await timeout(3_000); // レストランへオーダーを受けて問題無いか確認
const UPDATE_ORDER = gql`
mutation updateOrder($orderId: ID!) {
updateOrder(input: {orderId: $orderId, restaurantApproved: true}) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
`;
await axios({
url: GRAPHQL_API_URL,
method: 'post',
headers: {
'content-type': 'application/json',
'x-api-key': GRAPHQL_API_KEY,
},
data: {
query: print(UPDATE_ORDER),
variables: {
orderId: event.detail.orderId,
},
},
});
};
import {EventBridgeHandler} from 'aws-lambda';
import {captureHTTPsGlobal, capturePromise} from 'aws-xray-sdk';
import axios from 'axios';
import {print} from 'graphql';
import gql from 'graphql-tag';
import http from 'http';
import https from 'https';
import {Detail, DetailType} from '../types/order-event';
import {timeout} from './lib/util';
const GRAPHQL_API_URL = process.env.GRAPHQL_API_URL!;
const GRAPHQL_API_KEY = process.env.GRAPHQL_API_KEY!;
captureHTTPsGlobal(http, true);
captureHTTPsGlobal(https, true);
capturePromise();
export const handler: EventBridgeHandler<
DetailType,
Detail,
void
> = async event => {
await timeout(3_000); // ドライバーをアサインする
const UPDATE_ORDER = gql`
mutation updateOrder($orderId: ID!) {
updateOrder(input: {orderId: $orderId, driverAssigned: true}) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
`;
await axios({
url: GRAPHQL_API_URL,
method: 'post',
headers: {
'content-type': 'application/json',
'x-api-key': GRAPHQL_API_KEY,
},
data: {
query: print(UPDATE_ORDER),
variables: {
orderId: event.detail.orderId,
},
},
});
};
各サービスのLambda Functionがイベントをトリガーの起動するように定義します。
(再掲)ルールとサービスおよびLambda Function名の対応
ルール | サービス名 | Lambda Function名 |
---|---|---|
orderValid === false |
Order Service | OrderCreateHandler |
orderValid === true |
Payment Service | PaymentHandler |
paymentValid === true |
Restaurant Service | RestaurantHandler |
restaurantApproved === true |
Driver Service | DriverHandler |
import {Duration} from 'aws-cdk-lib';
import {IEventBus, Rule} from 'aws-cdk-lib/aws-events';
import {LambdaFunction} from 'aws-cdk-lib/aws-events-targets';
import {Tracing} from 'aws-cdk-lib/aws-lambda';
import {
NodejsFunction,
NodejsFunctionProps,
} from 'aws-cdk-lib/aws-lambda-nodejs';
import {Construct} from 'constructs';
interface OrderEventRuleProps {
orderEventBus: IEventBus;
graphqlApiUrl: string;
graphqlApiKey: string;
}
export class OrderEventRule extends Construct {
public readonly orderEventRule: Rule;
constructor(
scope: Construct,
id: string,
{orderEventBus, graphqlApiKey, graphqlApiUrl}: OrderEventRuleProps
) {
super(scope, id);
const commonFunctionProps: Partial<NodejsFunctionProps> = {
tracing: Tracing.ACTIVE,
timeout: Duration.seconds(30),
environment: {
GRAPHQL_API_URL: graphqlApiUrl,
GRAPHQL_API_KEY: graphqlApiKey,
},
};
new Rule(this, 'CreateOrderEvent', {
eventBus: orderEventBus,
eventPattern: {
detail: {orderValid: [false]},
},
}).addTarget(
new LambdaFunction(
new NodejsFunction(this, 'OrderCreatedHandler', {
entry: 'src/lambda/order-created-handler.ts',
...commonFunctionProps,
})
)
);
new Rule(this, 'OrderValidatedEvent', {
eventBus: orderEventBus,
eventPattern: {
detail: {orderValid: [true]},
},
}).addTarget(
new LambdaFunction(
new NodejsFunction(this, 'PaymentHandler', {
entry: 'src/lambda/payment-handler.ts',
...commonFunctionProps,
})
)
);
new Rule(this, 'PaymentValidatedEvent', {
eventBus: orderEventBus,
eventPattern: {
detail: {paymentValid: [true]},
},
}).addTarget(
new LambdaFunction(
new NodejsFunction(this, 'RestaurantHandler', {
entry: 'src/lambda/restaurant-handler.ts',
...commonFunctionProps,
})
)
);
new Rule(this, 'RestaurantApprovedEvent', {
eventBus: orderEventBus,
eventPattern: {
detail: {restaurantApproved: [true]},
},
}).addTarget(
new LambdaFunction(
new NodejsFunction(this, 'DriverHandler', {
entry: 'src/lambda/driver-handler.ts',
...commonFunctionProps,
})
)
);
}
}
DynamoDBテーブルとStackの作成
DynamoDBテーブル自体とこれまで作成したコンストラクトを使ってStackを作成します。
import { CfnOutput, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { AttributeType, StreamViewType, Table } from "aws-cdk-lib/aws-dynamodb";
import { Construct } from "constructs";
import { OrderEventBus } from "./constructs/order-event-bus";
import { OrderEventRule } from "./constructs/order-event-rule";
import { WebApi } from "./constructs/web-api";
export class AwsStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const orderTable = new Table(this, "OrderTable", {
partitionKey: {
name: "orderId",
type: AttributeType.STRING,
},
readCapacity: 1,
writeCapacity: 1,
stream: StreamViewType.NEW_IMAGE,
removalPolicy: RemovalPolicy.DESTROY,
});
const { api } = new WebApi(this, "WebApi", {
orderTable,
});
new CfnOutput(this, "GraphqlApiUrl", { value: api.graphqlUrl });
const { orderEventBus } = new OrderEventBus(this, "OrderEventBus", {
orderTable,
});
new OrderEventRule(this, "OrderEventRule", {
orderEventBus,
graphqlApiUrl: api.graphqlUrl,
graphqlApiKey: api.apiKey!,
});
}
}
エントリーポイントを更新します。
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { AwsStack } from "../lib/aws-stack";
const app = new cdk.App();
new AwsStack(app, "ThreeFactorAppOnAwsSampleStack", {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
デプロイ
CDKでデプロイを行います。
yarn workspace aws cdk deploy
Outputsに表示されるApiUrlとApiKeyは後ほど使用するのでメモしておいてください。
動作確認
AWSマネジメントコンソールでAppSyncのコンソールを開き、MutationとSubscriptionをテストしてみます。
Mutationを実行した後に、すぐにSubscriptionを実行してオーダーステータスの変化を観察します。
まず、Subscriptionの準備をします。これをメモしておいてください。
subscription{
onOrderUpdate(orderId: "ここを書き換える") {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
オーダーの作成Mutationを実行します。
mutation {
createOrder(input: {
address: "Test",
menuItems: ["Burger", "Cola"],
userId: "TaroId"}) {
orderId
}
}
レスポンスからOrderId(data.createOrder.orderId
)をコピーします。
メモしておいたSubscriptionを書き換えて実行します。
この状態で待っていると、orderValid, paymentValid, restaurantApproved, driverAssignedが徐々に変化するのを確認できます。
もし、動作しない場合はエラーメッセージやLambdaのログを確認してみてください。
フロントエンドの構築
ルートディレクトリで作業を開始します。
pwd
# $YOUR_WORK_DIR/3factor-app-on-aws
必要になるパッケージをまとめてインストールしておきます。
yarn workspace frontend add \
@apollo/client \
aws-appsync \
aws-appsync-auth-link \
aws-appsync-subscription-link \
graphql \
@mui/material \
@emotion/react \
@emotion/styled
yarn workspace frontend add -D \
@graphql-codegen/cli \
@graphql-codegen/typed-document-node \
@graphql-codegen/typescript \
@graphql-codegen/typescript-operations \
@graphql-codegen/typescript-react-apollo
Schemaからコードを生成する
GraphQL Code Generatorを使ってコードを生成します。
定義ファイルを作成します。
overwrite: true
schema:
- '../../schema.graphql'
- '../../appsync.graphql'
documents:
- './src/graphql/operations/*.graphql'
generates:
src/graphql/generated.tsx:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typed-document-node'
config:
skipTypename: true
withHooks: true
enumsAsTypes: true
config:
scalars:
AWSJSON: string
AWSDate: string
AWSTime: string
AWSDateTime: string
AWSTimestamp: number
AWSEmail: string
AWSURL: string
AWSPhone: string
AWSIPAddress: string
コード生成をyarnコマンドから行えるようにscriptsに定義を追加します。
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"codegen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.5.10",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.5.0",
"aws-appsync": "^4.1.5",
"aws-appsync-auth-link": "^3.0.7",
"aws-appsync-subscription-link": "^3.0.10",
"graphql": "^16.3.0",
"next": "12.1.0",
"react": "17.0.2",
"react-dom": "17.0.2"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.6.2",
"@graphql-codegen/typed-document-node": "^2.2.7",
"@graphql-codegen/typescript": "^2.4.7",
"@graphql-codegen/typescript-operations": "^2.3.4",
"@graphql-codegen/typescript-react-apollo": "^3.2.10",
"@types/node": "17.0.21",
"@types/react": "17.0.39",
"eslint": "8.10.0",
"eslint-config-next": "12.1.0",
"typescript": "4.6.2"
}
}
実行したいクエリーを定義します。
- オーダー作成
- オーダー所得
- オーダー全件取得
- オーダー取得(Subscription)
という4つのクエリーを定義しました。
mutation CreateOrder($menuItems: [String!]!) {
createOrder(
input: {
userId: "8d9f3b14-8350-4b56-b6c0-f62ad9684dc6"
address: "東京都A区B1-1"
menuItems: $menuItems
}
) {
orderId
}
}
query GetOrder($orderId: ID!) {
getOrder(orderId: $orderId) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
query GetAllOrders {
getAllOrders {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
subscription onOrderUpdate($orderId: ID!) {
onOrderUpdate(orderId: $orderId) {
orderId
userId
address
menuItems
orderValid
paymentValid
restaurantApproved
driverAssigned
createdAt
}
}
このクエリーに該当するコードを生成します。
yarn workspace frontend codegen
下記のようなアウトプットが出力されていれば成功です。
✔ Parse configuration
✔ Generate outputs
✨ Done in 1.63s.
✨ Done in 1.94s.
packages/frontend/src/graphql/generated.tsx
にコードが生成されています。(.tsx
じゃなくて.ts
でも問題ないはずです。)
Apollo Clientのセットアップ
GraphQLクライアントにはApollo Clientを使用します。
Apollo ClientからAppSyncの利用をサポートしてくれるAWS AppSync Links for ApolloというApollo Linkがあるので、今回はこれを使います。
開発はアクティブに見えますがawslabsなので採用する際はコード内容やIssue・Pull Requestの流量などをチェック頂くことをオススメします。
import type { AppProps } from 'next/app';
import { AuthOptions, AUTH_TYPE, createAuthLink } from 'aws-appsync-auth-link';
import { createSubscriptionHandshakeLink } from 'aws-appsync-subscription-link';
import {
ApolloClient,
ApolloLink,
createHttpLink,
InMemoryCache,
ApolloProvider,
} from '@apollo/client';
const url = process.env.NEXT_PUBLIC_GRAPHQL_API_URL!;
const region = 'ap-northeast-1';
const auth: AuthOptions = {
type: AUTH_TYPE.API_KEY,
apiKey: process.env.NEXT_PUBLIC_GRAPHQL_API_KEY!,
};
const httpLink = createHttpLink({ uri: url });
const link = ApolloLink.from([
createAuthLink({ url, region, auth }),
createSubscriptionHandshakeLink({ url, region, auth }, httpLink),
]);
const client = new ApolloClient({
link,
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: { fetchPolicy: 'no-cache' },
query: { fetchPolicy: 'no-cache' },
mutate: { fetchPolicy: 'no-cache' },
},
});
function MyApp({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
);
}
export default MyApp;
Next.jsへAppSyncのURLとAPIキーを環境変数で伝えます。Next.jsは.env
ファイルにNEXT_PUBLIC_
プレフィックスを付けて環境変数を定義すればブラウザで実行時にも読み込むことができます。AWS環境をデプロイしたときにメモしたURLとAPIキーを設定してください。
Basic Features: 環境変数 | Next.js
NEXT_PUBLIC_GRAPHQL_API_URL=(書き換える
NEXT_PUBLIC_GRAPHQL_API_KEY=(書き換える)
コンポーネントの作成
オーダーのステータスを個別と一覧でオーダーのステータスを表示するコンポーネントを作成します。ステータスに応じて頭に絵文字の追加と文字色を変えるデコレーションを行います。
.line {
list-style: none;
padding-left: 0;
}
import styles from './OrderStatus.module.css';
const OrderStatusLine = ({
context,
status,
}: {
context: string;
status: boolean;
}) => {
return (
<li className={styles.line}>
{status ? '✅' : '❌'}{' '}
<p style={{ display: 'inline', color: status ? 'green' : undefined }}>
{context}
</p>
</li>
);
};
export const OrderStatus = (props: {
orderValid: { context: string; status: boolean };
paymentValid: { context: string; status: boolean };
restaurantApproved: { context: string; status: boolean };
driverAssigned: { context: string; status: boolean };
}) => {
return (
<ul>
<OrderStatusLine context="注文の検証" status={props.orderValid.status} />
<OrderStatusLine context="支払い" status={props.paymentValid.status} />
<OrderStatusLine
context="レストランの受け付け"
status={props.restaurantApproved.status}
/>
<OrderStatusLine
context="ドライバーのアサイン"
status={props.driverAssigned.status}
/>
</ul>
);
};
カスタムHookの作成
AWS AppSync Links for ApolloにSubscriptionをすぐにクローズ→オープンするとコネクションに失敗する既知の問題があります。これに対する対処として、Subscriptionのオープンを2秒遅延させる処理をuseSubscription
にラップしたカスタムHookを作成します。このプロジェクトでSubscriptionを行う場合はこのラップされたusePatchedSubscription
を使用します。
import {
OperationVariables,
TypedDocumentNode,
useSubscription,
DocumentNode,
SubscriptionHookOptions,
SubscriptionResult,
} from '@apollo/client';
import { useEffect, useState } from 'react';
// https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/491
export function usePatchedSubscription<
TData = any,
TVariables = OperationVariables
>(
subscription: DocumentNode | TypedDocumentNode<TData, TVariables>,
options?: SubscriptionHookOptions<TData, TVariables>
): SubscriptionResult<TData, any> {
const [delaySub, setDelaySub] = useState(true);
useEffect(() => {
let delayTimeoutId: number | null = window.setTimeout(() => {
setDelaySub(false);
delayTimeoutId = null;
}, 2_000);
return () => {
if (delayTimeoutId) {
window.clearTimeout(delayTimeoutId);
}
};
}, []);
return useSubscription(subscription, {
...options,
skip: delaySub,
});
}
ページの作成
トップページで、メニューを選んでオーダーを可能にします。オーダーすると個別のページに遷移します。
合わせて既存のオーダー一覧を表示します。このページではSubscriptionは使用しません。
import { useMutation, useQuery } from '@apollo/client';
import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import {
CreateOrderDocument,
GetAllOrdersDocument,
} from '../src/graphql/generated';
import {
Alert,
Button,
Checkbox,
FormControlLabel,
FormGroup,
Grid,
Paper,
Snackbar,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { OrderStatus } from '../components/OrderStatus';
const Home: NextPage = () => {
const { push } = useRouter();
const [open, setOpen] = useState(false);
const [menuItems, setMenuItems] = useState({
'🍕 ピザ': false,
'🍔 ハンバーガー': false,
'🍟 ポテト': false,
'🥤 コーラ': false,
'☕️ コーヒー': false,
});
const [mutateFunction, { data }] = useMutation(CreateOrderDocument);
const handleOrder = async () => {
const myOrder = Object.entries(menuItems).flatMap(([key, value]) =>
value === false ? [] : [key.split(' ')[1]]
);
if (myOrder.length < 1) {
setOpen(true);
return;
}
await mutateFunction({
variables: {
menuItems: myOrder,
},
});
};
useEffect(() => {
if (data?.createOrder.orderId != null) {
push(`/orders/${data?.createOrder.orderId}`);
}
}, [push, data]);
const { data: allOrders } = useQuery(GetAllOrdersDocument);
return (
<Grid container direction="column" alignItems="center" spacing={2}>
<Snackbar
open={open}
autoHideDuration={6000}
onClose={(_, reason) => {
console.log({ reason });
if (reason === 'clickaway') {
setOpen(false);
}
}}
>
<Alert severity="error">
1つ以上の商品を選択して注文する必要があります
</Alert>
</Snackbar>
<Grid item>
<Typography variant="h2">
3factor app example on AWS(AppSync)
</Typography>
</Grid>
<Grid item>
<Typography variant="h3">メニュー</Typography>
</Grid>
<Grid item>
<FormGroup>
{Object.keys(menuItems).map((key) => {
return (
<FormControlLabel
key={key}
control={
<Checkbox
onChange={({ target }) => {
setMenuItems({
...menuItems,
[key]: target.checked,
});
}}
name={key}
/>
}
label={key}
/>
);
})}
</FormGroup>
</Grid>
<Grid item>
<Button variant="contained" onClick={handleOrder}>
注文する
</Button>
</Grid>
<Grid item>
<Typography variant="h3">注文一覧</Typography>
</Grid>
<Grid item>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center">注文日時</TableCell>
<TableCell align="center">オーダーID</TableCell>
<TableCell align="center">商品</TableCell>
<TableCell align="center">ステータス</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allOrders?.getAllOrders?.map((order) => {
return (
<TableRow key={order.orderId}>
<TableCell>
{new Intl.DateTimeFormat('ja-Jp', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(new Date(order.createdAt))}
</TableCell>
<TableCell>{order.orderId}</TableCell>
<TableCell>{order.menuItems.join(', ')}</TableCell>
<TableCell>
<OrderStatus
orderValid={{
context: 'Order validation',
status: order.orderValid,
}}
paymentValid={{
context: 'Your payment',
status: order.paymentValid,
}}
restaurantApproved={{
context: 'Restaurant approval',
status: order.restaurantApproved,
}}
driverAssigned={{
context: 'Driver assignment',
status: order.driverAssigned,
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
);
};
export default Home;
個別のページを作成します。ページの更新時にオーダーのステータスを取得しその後もSubscriptionを使ってステータスを更新し続けます。
import { useQuery } from '@apollo/client';
import { Button, TableBody } from '@mui/material';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import {
GetOrderDocument,
OnOrderUpdateDocument,
Order,
} from '../../src/graphql/generated';
import { usePatchedSubscription } from '../../src/hooks/usePatchedSubscription';
import {
TableContainer,
Table,
TableCell,
TableRow,
Paper,
Grid,
} from '@mui/material';
import { OrderStatus } from '../../components/OrderStatus';
const Order = () => {
const router = useRouter();
const orderId = router.query.id as string;
const skip = typeof orderId !== 'string' || orderId === '';
const { data: initData } = useQuery(GetOrderDocument, {
variables: { orderId },
skip,
});
const { data: subscData } = usePatchedSubscription(OnOrderUpdateDocument, {
variables: { orderId },
skip,
});
const [orderData, setOrderData] = useState<Order | null>(null);
useEffect(() => {
if (initData?.getOrder != null) {
const prev = initData.getOrder;
const current = subscData?.onOrderUpdate;
setOrderData({
...prev,
orderValid: current?.orderValid
? current?.orderValid
: prev?.orderValid,
paymentValid: current?.paymentValid
? current?.paymentValid
: prev?.paymentValid,
restaurantApproved: current?.restaurantApproved
? current?.restaurantApproved
: prev?.restaurantApproved,
driverAssigned: current?.driverAssigned
? current?.driverAssigned
: prev?.driverAssigned,
});
}
}, [initData, subscData]);
if (orderData == null) {
return <></>;
}
return (
<Grid container direction="column" alignItems="center" spacing={2}>
<Grid item>
<TableContainer component={Paper}>
<Table>
<TableBody>
<TableRow>
<TableCell>注文日時</TableCell>
<TableCell>
{new Intl.DateTimeFormat('ja-Jp', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(new Date(orderData.createdAt))}
</TableCell>
</TableRow>
<TableRow>
<TableCell>オーダーID</TableCell>
<TableCell>{orderData.orderId}</TableCell>
</TableRow>
<TableRow>
<TableCell>商品</TableCell>
<TableCell>{orderData.menuItems.join(', ')}</TableCell>
</TableRow>
<TableRow>
<TableCell>ステータス</TableCell>
<TableCell>
<OrderStatus
orderValid={{
context: 'Order validation',
status: orderData.orderValid,
}}
paymentValid={{
context: 'Your payment',
status: orderData.paymentValid,
}}
restaurantApproved={{
context: 'Restaurant approval',
status: orderData.restaurantApproved,
}}
driverAssigned={{
context: 'Driver assignment',
status: orderData.driverAssigned,
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Grid>
<Grid item>
<Button
variant="contained"
onClick={() => {
router.push('/');
}}
>
戻る
</Button>
</Grid>
</Grid>
);
};
export default Order;
動作確認
Next.jsを開発モードで起動し、Webブラウザから動作を確認します。
yarn workspace frontend dev
上記のコマンドを実行後に、localhost:3000を開きます。
現在何もオーダーしていないので注文一覧は空です。
ピザとハンバーガーを選択して注文します。
個別の注文ページに遷移しました。各種処理が完了するのを数秒待ちます。
待っていると注文の検証が終わり、アイコンと色がわかりました。
上から順番に処理が完了します。
すべての処理が完了しました。
戻るボタンでトップページに戻ると、先ほどのオーダーが一覧に追加されています。
X-Rayの確認
ここまでとくに説明をしていませんでしたが、AWS X-Rayを有効にして構築したので処理のトレージングが行えます。
AWSマネジメントコンソールでCloudWatch Service Mapを開くとトレーニング結果が表示されています。
わかりやすくするために、Lambda Context・Functionがどのサービスか書き込んでいます。Dynamo DBに対する更新からDynamoDB Streamsの起動はざんねんながらトレースできないようで、ラインが途切れていました。
このアーキテクチャで気をつけた方が良いと思ったポイント
StateにDynamoDB(NoSQL)を採用すると型の拠り所として弱い
3factor appではStateに保存されるEventを元にすべての処理が行われるので、Eventの型が重要です。
3factor appのサンプル実装ではPostgreSQLを使用しておりRDBMSなのでテーブルには定義された型(定義された名前のカラム&型)のRecordしか投入されることはありません。
対してDynamoDBはNoSQLであり、そのため投入されるItemの型は投入する側次第です。ドキュメントを書いてそれに準拠するように実装しても、ドキュメントの更新漏れが発生してズレが発生した際に「データベースの型が正」というようにデータベースの定義に頼ることができません。
ここについては、GraphQLスキーマからのTypeScriptの型生成を駆使する、Aurora Serverless Data APIを使う(Lambdaトリガー出来ない問題があるが)など検討した方が良いでしょう。
あとがき
実際に構築してみての複雑なアーキテクチャだなという感想を抱きました。個々のサービスの視点ではイベントのI/Oだけを考えれば良いので楽な可能性がありますが、これについては実際にこのアーキテクチャで開発を行ってみないとなんとも言えないです。
また、マイクロサービスが必須になるため開発にはマイクロサービスのノウハウも必要になり、採用・運用難易度はAmazon ECSやAmazon API GatewayとAWS Lambdaを使ったWebAPIと比べると高いと考えます。非同期処理とポーリングだけでも3factor appを採用した場合と近い体験をエンドユーザーに提供できるので採用する前には他のアーキテクチャでもPoCしてじっくり検証した方が良さそうです。
以上でした!
Discussion