Amplify Data(Gen2)からMySQL接続を試してみた
NTT東日本の中村です。
Amplify Gen2では、プレビュー版では行えなかったAmplify DataでのMySQL接続ができるようになり、調査を行ってみました。
Amplify Data(Gen2)のMySQL接続の概要
以前のAmplifyは、バックエンドのデータベースと接続する場合、Amplify APIというカテゴリで、AppSyncサービスのデータソースとして既存のMySQL、PostgreSQLを選択できるようになっていました。 Amplify Gen2では、Amplify Dataという名前に変わりましたが、同じ接続ができます。プレビューでは既存データベースに接続できなかったのですが、正式にサポートされました。
Gen1(V6)のドキュメント:
Gen2のドキュメント:
実際に試してみた
MySQLとの接続を試してみました。
MySQLデータベースの作成
この機能は、「既存のMySQLから構成をインポートする機能」なので、先にMySQLデータベースとテーブルを用意します。
今回はカスタムリソースでRDSを作成しています。 検証のため、リスクのあるPublic SubnetにRDSを設置しています。AWSサービスに拘らず、MySQLサーバであれば問題無く接続できます。
import { defineBackend } from "@aws-amplify/backend"; import { auth } from "./auth/resource"; import { data } from "./data/resource"; import { storage } from "./storage/resource"; import { Credentials, DatabaseInstance, DatabaseInstanceEngine, MysqlEngineVersion, } from "aws-cdk-lib/aws-rds"; import { InstanceClass, InstanceSize, InstanceType, Peer, Port, SecurityGroup, SubnetType, Vpc, } from "aws-cdk-lib/aws-ec2"; const backend = defineBackend({ storage, auth, data, }); const vpc = new Vpc("SubStack", "ampxVPC", { maxAzs: 2, subnetConfiguration: [ { cidrMask: 24, name: "public", subnetType: SubnetType.PUBLIC, }, ], }); const securityGroup = new SecurityGroup("SubStack", "RDSSecurityGroup", { vpc, allowAllOutbound: true, }); securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(3306)); securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443)); const rdsInstance = new DatabaseInstance("SubStack", "RDSInstance", { engine: DatabaseInstanceEngine.mysql({ version: MysqlEngineVersion.VER_8_0_36, }), credentials: Credentials.fromGeneratedSecret("admin"), instanceType: InstanceType.of(InstanceClass.BURSTABLE3, InstanceSize.SMALL), vpc, vpcSubnets: { subnetType: SubnetType.PUBLIC, }, securityGroups: [securityGroup], databaseName: "ampxdb", publiclyAccessible: true, }); backend.addOutput({ custom: { value: rdsInstance.dbInstanceEndpointAddress, }, });
Amplify DataがMySQLと接続する場合、内部ではAppSyncが動いており、リゾルバからLambdaを経由してMySQLに接続する仕組みです。 加えて、今回のMySQLはAWS VPC上のRDSに接続するため、LambdaはVPC内に作成されます(いわゆるVPC Lambda)。
既存データベースの接続で生成されるLambdaについては、下記に説明があります。
更に、作成されたLambdaは、Secret Managerに格納されたデータベース接続情報を使用してデータベースにアクセスするので、配置されたセキュリティグループは443ポートの許可が必要です。 CDKでも443ポートを許可しています。
カスタムリソースを作成して、サンドボックスを起動します。
npx ampx sandbox
エンドポイントが出力されました。
データベースの接続情報はSecret Managerに出力されるので、控えておきます。
MySQLクライアントから、データベースに接続できることを確認しました。
テーブルとレコードの作成
テーブルとレコードを作成します。
CREATE TABLE `Team` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `createdAt` datetime DEFAULT NULL, `updatedAt` datetime DEFAULT NULL, PRIMARY KEY (`id`) )
sql> desc team; +-----------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-----------+-------------+------+-----+---------+-------+ | id | int | NO | PRI | null | | | name | varchar(64) | YES | | null | | | updatedAt | datetime | YES | | null | | | createdAt | datetime | YES | | null | | +-----------+-------------+------+-----+---------+-------+ sql> INSERT INTO `ampxdb`.`Team` (`id`, `name`, `createdAt`, `updatedAt`) VALUES ('1', 'teamA', ' 2024-04-01 00:00:00', ' 2024-04-01 00:00:00');
レコードの作成が完了しました。
スキーマの生成
スキーマを生成する為に、接続情報をSandboxのSecretとして設定します。 SQL_CONNECTION_STRINGは、DBユーザ名、ホスト、接続先データベースを繋いで文字列にしたものを使用します。
% npx ampx sandbox secret set SQL_CONNECTION_STRING ? Enter secret value mysql://admin:[email protected]:3306/ampxdb
接続情報を元に、ampx generate schema-from-databaseを実行しますが、エラーが出て生成に失敗しました。SecretManagerのアクセスに失敗しているようです。
% npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts --stack amplify-nextamplifygen2-38ampx-sandbox-72d9a45c5b --debug TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received undefined [DEBUG] 2024-05-21T05:40:23.753Z: TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received undefined at new NodeError (node:internal/errors:405:5) at Hash.update (node:internal/crypto/hash:107:11) at getHash (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/backend_identifier_conversions.js:80:10) at BackendIdentifierConversions.toStackName (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/backend_identifier_conversions.js:53:22) at getBackendIdentifierPathPart (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:52:176) at getBackendParameterPrefix (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:45:24) at getBackendParameterFullPath (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:65:15) at ParameterPathConversions.toParameterFullPath (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:30:20) at SSMSecretClient.getSecret (file:///Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/backend-secret/lib/ssm_secret.js:19:47) at Object.handler (file:///Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/backend-cli/lib/commands/generate/schema-from-database/generate_schema_command.js:40:61)
一度だけ生成を行えれば良いので、今回はnode_modulesを書き換えてしまいました。 getSecretを呼ばず、直接SQL_CONNECTION_STRINGの値を割り当てています。 (ここは原因が分かり次第、更新します)
31: handler = async (args) => { 32: const backendIdentifier = await this.backendIdentifierResolver.resolve(args); 33: if (!backendIdentifier) { 34: throw new AmplifyFault('BackendIdentifierFault', { 35: message: 'Could not resolve the backend identifier', 36: }); 37: } 38: const outputFile = args.out; 39: await this.schemaGenerator.generate({ 40: connectionUri: { 41: secretName:"SQL_CONNECTION_STRING", 42: value: "mysql://admin:[email protected]:3306/ampxdb" 43: }, 44: out: outputFile, 45: }); 46: };
スキーマの生成に成功しました。
% npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts --stack amplify-nextamplifygen2-38ampx-sandbox-72d9a45c5b --debug ✔ Successfully fetched the database schema. The host you provided is for an RDS instance. Consider using an RDS Proxy as your data source instead. See the documentation for a discussion of how an RDS proxy can help you scale your application more effectively.
schema.sql.tsを見ると、RDSに接続するためのSecretと、VPCの設定、Mysqlから生成したスキーマがあります。
/* eslint-disable */ /* THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. */ import { a } from "@aws-amplify/data-schema"; import { configure } from "@aws-amplify/data-schema/internals"; import { secret } from "@aws-amplify/backend"; export const schema = configure({ database: { identifier: "IDMTBXNvNs0ACioBPbtDpBBg", engine: "mysql", connectionUri: secret("SQL_CONNECTION_STRING"), vpcConfig: { vpcId: "vpc-0864eb9f9a79c9117", securityGroupIds: [ "sg-02b414f3c026fb280" ], subnetAvailabilityZones: [ { subnetId: "subnet-0071bd35ddef18ce1", availabilityZone: "ap-northeast-1c" }, { subnetId: "subnet-068e4b4221ed25cfe", availabilityZone: "ap-northeast-1a" } ] } } }).schema({ "Team": a.model({ id: a.integer().required(), name: a.string(), createdAt: a.datetime(), updatedAt: a.datetime() }).identifier([ "id" ]) });
既存のスキーマと合わせて、defineDataを設定します。 combinedSchemaの名前の通り、AppSyncにDynamoDBとMySQLの2つのデータソースが設定されています。
import { type ClientSchema, a, defineData } from "@aws-amplify/backend"; import { schema as baseSqlSchema } from "./schema.sql"; const schema = a.schema({ Todo: a .model({ tag: a.string().required(), content: a.string(), done: a.boolean(), createDate: a.string().required(), priority: a.enum(["low", "medium", "high"]), }) .identifier(["tag", "createDate"]) .authorization((allow) => [allow.owner()]), }); const sqlSchema = baseSqlSchema.authorization((allow) => allow.authenticated()); const combinedSchema = a.combine([sqlSchema, schema]); export type Schema = ClientSchema<typeof combinedSchema>; export const data = defineData({ schema: combinedSchema, authorizationModes: { defaultAuthorizationMode: "userPool", }, });
フロント部分です。use clientで作成しています。
"use client"; import { generateClient } from 'aws-amplify/data'; import { type Schema } from '@/amplify/data/resource'; import "@aws-amplify/ui-react/styles.css"; import { useEffect, useState } from "react"; const client = generateClient<Schema>(); type Team = Schema['Team']['type']; function App({ searchParams, }: { searchParams?: { [key: string]: string }; }) { const [teamList, setTeamList] = useState<Team[]>([]); async function addTeam(data: FormData) { const dataStr = new Date().toISOString() const response = await client.models.Team.create({ id: "2", name: data.get("title") as string, createdAt: dataStr, updatedAt: dataStr, }); setTeamList([...teamList, response.data!]) } useEffect(() => { (async () => { const response: any = await client.models.Team.list(); setTeamList(response.data!); })(); }, []); console.log(teamList) return ( <> <h1>Hello, Amplify 👋</h1> <form action={addTeam}> <input type="text" name="title" /> <button type="submit">Add Team</button> </form> <ul> {teamList.map((team) => ( <li key={team!.name}> <a>{team!.name}</a> </li> ))} </ul> </> ); } export default App;
レコードの表示
MySQLに保存されたデータを表示することができました。
レコードの格納
レコードの格納ですが、DynamoDBがデータソースの時と同様、model.createを使用して値を格納します。 今回のTeamsテーブルは、idはInt型であり、Auto Incrementが付与されたプライマリーキーなので、idを省略したデータ登録が可能か試しましたが、エラーが出てしまいました。明示的に値を指定する必要があります。当然PKなので一意の値を指定するべきですが、今回は1レコードだけ登録を行いたく、2を指定しています。 かつ、idの入力の型としてはstringを求められるので、数字の2ではなく、文字列の2を入れることになりました。将来的に型の統一がされると思われます。
また、createdAtや、updatedAtも省略できなかったため、明示的に投入する必要がありました。 パイプラインリゾルバを使えば自動化できるようにも思えますが・・・
const response = await client.models.Team.create({ id: "2", name: data.get("title") as string, createdAt: dataStr, updatedAt: dataStr, });
MySQLのデータの登録も確認できました。
まとめ
Amplify Data(Gen2)では、AppSyncのデータソースに、DynamoDBだけではなく、既存のMySQLやPostgreSQLも接続できるようになりました。 型指定やデータ登録周りなど、やや荒削りな印象ですが、RDBMSで構築を行いたい場合の良い選択肢になると思いました。