DynamoDB をできるだけ型安全に扱う実装例
業務で何度か DynamoDB を使ってきましたが、NoSQL はつらいです。
- 型がない、安心して選べる ORM もない
- テーブル設計の変更が困難
- うっかり変なデータを入れることができてしまう(数値列に文字列とか…)
これらをどうにか出来そうな設計が最近組めたので、実装をまとめておきます。言語例は typescript です。
今回はテーブル作成部分には触れません。「CDK 等で作ったあと、テーブルをいかに型安全で楽に扱うか?」に絞った内容になります。
設計概要
zod & レイヤードアーキテクチャ(クリーンアーキテクチャ?) でどうにかします。
- SDK を直接使うのを避け、ラッパーを自作して提供する
- DynamoDB とのやり取りを行う箇所が危険なので、入出力で zod を使い確実にガードする
- DB 側に schema_version 列を追加しておき、これを利用し読み取り時にデータをマイグレーションする
実例
テーブル定義
まずはテーブル側から。
zod でざっくり定義を作ってみます。
ポイントは schema_version: z.literal(1) として固定のバージョン情報を埋め込んでおくことです。
import * as z from "zod";
const schemaUserV1 = z.object({
id: z.string(),
name: z.string(),
company_id: z.string(),
is_adimn: z.boolean(),
created_at: z.string().datetime({ offset: true }),
updated_at: z.string().datetime({ offset: true }),
schema_version: z.literal(1),
});
V1 を定義したあと、管理者フラグのタイプミスと、メールアドレス列を忘れていたことに気づきました。
次のようにコードを付け足します。
- V1 との差分を omit と extend で定義 (使わずゼロから定義してもいい)
- schema_version は 2 で張りなおす
- スキーマバージョンを上げるための処理を作る
const schemaUserV2 = schemaUserV1
.omit({
is_adimn: true,
schema_version: true,
})
.extend({
is_admin: z.boolean(),
mail_address: z.string(),
schema_version: z.literal(2),
});
const V1toV2 = (
v1: z.infer<typeof schemaUserV1>
): z.infer<typeof schemaUserV2> => {
const { is_adimn, ...others } = v1;
return {
...others,
is_admin: is_adimn,
mail_address: "",
schema_version: 2,
};
};
外部からは最新スキーマだけが見えるようにしておきます。
以降 V3, V4 と増えたらここを張り替えます。
/**
* DB全項目スキーマ
*/
export const userTableSchema = schemaUserV2;
マイグレーションを作る
古いバージョンのデータを最新定義まで自動的に上げられるようにしておきましょう。
まずは全テーブルで使う汎用処理を定義します。
import * as z from "zod";
/**
* バージョン判定を行うための汎用スキーマ
*/
export const versionSchema = z.object({ schema_version: z.number() });
type Validator<T> = (v: unknown) => T;
/**
* 単体のバリデータを配列に対応させるジェネレータ
*
* 未対応の場合は例外を投げる
*/
export const ValidateArraySchemaGenerator = <T>(
validator: Validator<T>,
): ((v: unknown) => T[]) => {
const f = (v: unknown): T[] => {
if (!Array.isArray(v)) {
throw new Error("Invalid array");
}
return v.map((item) => validator(item));
};
return f;
};
// null対応版コードも手元にあるが割愛
これを使って、単体データと配列データのマイグレーションを提供します。
ポイントはスキーマ検証を 2 回に分け、まずはバージョンだけ判定し、switch の振り分けに使うことです。
// インポートを追加すること
import { ValidateArraySchemaGenerator, versionSchema } from "./utils";
/**
* Usersタイプチェッカー
*
* - 古い場合は最新スキーマにマイグレーションして返却する
* - 未対応の場合は例外を投げる
*/
export const ValidateSchemaUser = (v: unknown): UserTable => {
const version = versionSchema.parse(v);
switch (version.schema_version) {
case 1:
return V2toV3(V1toV2(schemaUserV1.parse(v)));
case 2:
return V2toV3(schemaUserV2.parse(v));
case 3:
return schemaUserV3.parse(v);
default:
throw new Error(`Unsupported schema version: ${version}`);
}
};
/**
* 配列対応版Usersタイプチェッカー
*/
export const ValidateSchemaUserArray =
ValidateArraySchemaGenerator(ValidateSchemaUser);
DynamoDB のラッパーを作る
ユーザーテーブルに対して行える操作一式を提供します。
取得した値は必ず ValidateSchemaUser, ValidateSchemaUserArray を経由して返却するので
- DynamoDB 戻り値への型付け
- DB に古いデータが残っていても、自動的に最新化して返却
が実現できます。
論理削除か物理削除か、利用する GSI 等もこのレイヤーに閉じ込められると良いですね。DynamoDB で論理削除はおすすめしませんが。
import { DeleteCommand, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
// 環境情報など含むので以下は紹介なし
import {
DynamoDBIndexes,
DynamoTableNames,
dynamoDBClient,
} from "@/aws/dynamodb-client";
// lastEvaluatedKeyを使って完全な全件取得をするラッパーを作ってある。
// こちらも本筋から反れるので省略
import { DynamoQueryCommandAll, DynamoScanCommandAll } from "@/lib/DynamoAll";
import {
type UserTable,
ValidateSchemaUser,
ValidateSchemaUserArray,
} from "@/tables/user-table";
/**
* ユーザーをすべて取得する
*/
const ListUsers = async (): Promise<UserTable[]> => {
const users = (await DynamoScanCommandAll({
TableName: DynamoTableNames.User,
})) as Record<string, unknown>[];
return ValidateSchemaUserArray(users);
};
/**
* 指定した企業に所属するユーザーをすべて取得する
*/
const ListCompanyUsers = async (companyId: string): Promise<UserTable[]> => {
const users = (await DynamoQueryCommandAll({
TableName: DynamoTableNames.User,
IndexName: DynamoDBIndexes.User.company_id_index.indexName,
KeyConditionExpression: "company_id = :companyId",
ExpressionAttributeValues: {
":companyId": companyId,
},
})) as Record<string, unknown>[];
return ValidateSchemaUserArray(users);
};
/**
* ユーザーを上書き登録する
*/
const PutUser = async (_user: UserTable): Promise<UserTable> => {
const command = new PutCommand({
TableName: DynamoTableNames.User,
Item: user,
});
await dynamoDBClient.send(command);
return _user;
};
const GetUser = async (userId: string): Promise<UserTable | undefined> => {
const command = new GetCommand({
TableName: DynamoTableNames.User,
Key: { id: userId },
});
const result = await dynamoDBClient.send(command);
if (!result.Item) {
// return null;
return undefined;
}
return ValidateSchemaUser(result.Item);
};
/**
* 物理削除する
*/
const HardDeleteUser = async (userId: string) => {
const command = new DeleteCommand({
TableName: DynamoTableNames.User,
Key: { id: userId },
ReturnValues: "ALL_OLD",
});
const res = await dynamoDBClient.send(command);
return res;
};
export const UserRepository = {
ListUsers,
ListCompanyUsers,
PutUser,
GetUser,
DeleteUser: HardDeleteUser,
};
export type IUserRepository = typeof UserRepository;
以降 UserRepository, または IUserRepository 経由で操作コマンドを受け取れば型付きで DynamoDB が扱えます。
IUserRepository を別で実装すればモックも作れます。
入力部分のガードは hono openapi でバリデーションしたり、typescript 時点で型エラーが出るのでいいかなと思いつけていませんが、万全を期すなら put, update 等にも付けるべきですね。
実装例は以上です。
(実際はこれを使って usecase 層を実装しているのですが、こちらも本筋から反れるので割愛します。)
あとがき
助っ人で入った新規開発のプロジェクトでこの設計を実践したところ、すごく快適で生産性も高まりました。
マイグレーション管理が積みあがると大変になりそうなのが課題ですが、そもそも DynamoDB で頻繁に設計変更したらどう頑張っても辛いため深く気にしなくてよいだろうと思ってますが、いかがでしょうか。
パフォーマンス面が気になるので zod を typebox あたりに変えた方がいい気はします。諸事情により zod を使いましたが、筆者は valibot 派です。
もっといい方法があったら教えてください。
参考文献
こちらの記事を読んだのがこの実装を閃いたきっかけでした。ありがとうございます。
Discussion