はじめに
TypeScript+Node.js+expressで作成したAPIをAWSのAPIGataway+Lambdaで公開する方法を紹介しています。前回ではプロジェクトの作成をしたのちにCodePipelineを用いてGitへのプッシュ時に自動でデプロイさせるところまで作成しました。
今回はDynamoDBを組み込んでデータの永続化を行なった上で、クリーンアーキテクチャを用いた実装を行っていきます。 参考は以下に。
DynamoDB
1.コンソールからDynamoDBにアクセスしてテーブルを作成
- パーテーションキーはid
に
DynamoDBライブラリのインストール
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
コーディング
クリーンアーキテクチャを意識して行います。ざっくり以下のようなレイヤーで分割します。
- Domain層
- Entity (ヒト・モノ)
- Interfaces (依存関係の逆転に用いる)
- Repository
- Application層
- Usecase (外部ライブラリに依存しないロジック)
- Interface層
- Controller (リクエストの解析とレスポンスの生成)
- Router (エンドポイントの設定)
- Infrastructure層
- Repository (DBとの接続)
Interface層とInterfacesは似ているようで別物です。前者は"入口"的な意味で使っており(UIと同じ)、後者はオブジェクト指向の抽象クラスにあたります。
- Repository (DBとの接続)
依存関係の逆転について説明します。以下の図は実線が実際の処理の流れ、点線が依存関係を表します。
実線通りに実装するとApplication層がInfrastructure層に依存しますが、Application層はDomain層にしか依存できません。そこでDomain層にインターフェイスを定義してInfrastructure層ではその実装を行います。Application層が外部ライブラリを用いるときはDomain層のインターフェイスにアクセスする体で行います。実際どうやっているかはこの後のコードをみてもらえれば理解できると思います。
Domain/Entity
ライブラリには一切依存せずに記述します。(あってもDateとかのみ)
ユーザーを定義します。簡単にidと名前だけにしました。Mock用にsample
を用意しておくと後々便利です。
interface User {
id: string;
name: string;
}
export default User;
export const sample: User = {
id: "0001",
name: "Bob",
};
index.ts
でexport
するとパスが/domain/entities/users
までで済むので楽です。
import User, { sample } from "./User";
export { User, sample as userSample };
Domain/Interfaces
TypeScriptではabstract class
を使ってインターフェイスを作成します。今回はDBに接続するRepository
を抽象で定義し、のちに実装を作ります。基本的にDB操作は非同期になると思うのでPromise<T>
を戻り値にします。
abstract class IUserRepository {
abstract get(id: string): Promise<User | null>;
abstract getAll(): Promise<User[]>;
abstract put(item: User): Promise<string>;
abstract delete(id: string): Promise<string>;
}
export default IUserRepository;
Application/Usecase
こちらもその他ライブラリには一切依存せずにドメイン層にのみ依存して記述します。Domain/Interfacesで定義したIUserRepository
をコンストラクタで受け取り、DBに見立ててアクセスします。
GET
GET
はシンプルにかけます。
export default class GetUsecase {
constructor(private repository: IUserRepository) {}
execute = async (id: string): Promise<User> => {
const user = await this.repository.get(id);
if (!user) {
throw Errors.NotFound();
}
return user;
};
}
export default class GetAllUsecase {
constructor(private repository: IUserRepository) {}
execute = async (): Promise<User[]> => {
const users = await this.repository.getAll();
return users;
};
}
Getが複雑になる場合を考えてみましょう。例えばGroup
を追加して以下のようになった場合を考えます。
//Userのモデル(DBで扱うモデル)
interface User {
id: string
name: string
groupId: string
}
//Groupのモデル(同)
interface Group {
id: string
name: string
}
// '/user/:idで返してほしいモデル
interface UserResponse {
id: string
name: string
groupName: string
}
このときUsecaseでUser
とGroup
を結合するのではないかなと思います。(特にNoSQLの場合)
const user = await this.userRepository.get(id);
const group = await this.groupRepository.get(user.groupId);
const response: UserResponse = { ... user, groupName: group.name}
POST
新規作成なので存在しないことを確認してからDBに保存します。
export default class PostUsecase {
constructor(private repository: IUserRepository) {}
execute = async (item: User): Promise<string> => {
const exsit = await this.repository.get(item.id);
if (exsit) {
throw Errors.Conflict();
}
const message = await this.repository.put(item);
return message;
};
}
PUT
idが合致していることと存在していることを確認し、その後上書きします。
export default class PutUsecase {
constructor(private repository: IUserRepository) {}
execute = async (id: string, item: User): Promise<string> => {
if (id != item.id) {
throw Errors.BadRequest();
}
const exsit = await this.repository.get(id);
if (!exsit) {
throw Errors.NotFound();
}
const message = await this.repository.put(item);
return message;
};
}
DELETE
存在を確認してから削除します。
export default class GetUsecase {
constructor(private repository: IUserRepository) {}
execute = async (id: string): Promise<string> => {
const exsit = await this.repository.get(id);
if (!exsit) {
throw Errors.NotFound();
}
const message = await this.repository.delete(id);
return message;
};
}
Infrastructure/Repository
ここでRepositoryの実装を行います。今回は本番環境用のDynamoDBとテスト用のMockの2つを作ります。
DynamoDB
コンストラクタではclient
とtableName
を受け取ります。DynamoDBではスネークケースでの保存が推奨されています。ここでは自作関数のtoSnakeCase
とtoCamelCase
を用いて変換を行っています。このようにDB都合の処理は全てここで吸収することを目指してください。
class DynamoDBUserRepository implements IUserRepository {
constructor(
private client: DynamoDBDocumentClient,
private tableName: string
) {}
get = async (id: string): Promise<User | null> => {
try {
const result = await this.client.send(
new GetCommand({
TableName: this.tableName,
Key: { id },
})
);
if (!result.Item) return null;
return toCamelCase<User>(result.Item);
} catch (error) {
throw Errors.InternalServerError();
}
};
getAll = async (): Promise<User[]> => {
try {
const result = await this.client.send(
new ScanCommand({
TableName: this.tableName,
})
);
const items = result.Items ?? [];
return toCamelCase<[User]>(items);
} catch (err) {
console.log(err);
throw Errors.InternalServerError();
}
};
put = async (item: User): Promise<string> => {
const snakedItem = toSnakeCase(item);
try {
await this.client.send(
new PutCommand({
TableName: this.tableName,
Item: snakedItem,
ConditionExpression: "attribute_exists(id)",
})
);
return "Success";
} catch (err) {
throw Errors.InternalServerError();
}
};
delete = async (id: string): Promise<string> => {
try {
await this.client.send(
new DeleteCommand({
TableName: this.tableName,
Key: { id },
})
);
return "Success";
} catch (err) {
console.log(err);
throw Errors.InternalServerError();
}
};
}
export default { DynamoDBUserRepository };
先ほど説明した自作関数です。
export const toCamelCase = <T>(input: Record<string, any> | Array<any>): T => {
if (Array.isArray(input)) {
return input.map(toCamelCase) as unknown as T;
} else if (typeof input === "object" && input !== null) {
return Object.fromEntries(
Object.entries(input).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
toCamelCase(value),
])
) as unknown as T;
}
return input as unknown as T;
};
export const toSnakeCase = (input: any): Record<string, any> | Array<any> => {
if (Array.isArray(input)) {
return input.map(toSnakeCase);
} else if (typeof input === "object" && input !== null) {
return Object.fromEntries(
Object.entries(input).map(([key, value]) => [
key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
toSnakeCase(value),
])
);
}
return input;
};
Mock
テスト用の模擬DBを作成します。定数を返せばOKです。
import { IUserRepository } from "../../../domain/interfaces/repositories";
import { User, userSample } from "../../../domain/entities/users";
class MockUserRepository implements IUserRepository {
constructor() {}
get = async (id: string): Promise<User | null> => {
return userSample;
};
getAll = async (): Promise<User[]> => {
return [userSample];
};
put = async (item: User): Promise<string> => {
return "Success";
};
delete = async (id: string): Promise<string> => {
return "Success";
};
}
export default MockUserRepository;
Interface/Controller
こちらではリクエストの解析とレスポンスの作成を行います。コンストラクタでは外部からUsecaseをもらいます。こちらでも自作のparseParams
、parseQuery
、parseBody
を使ってリクエストを解析します。それらで得たid
やitem
をUsecase
に投げて、結果をsuccessResponse
やerrorResponse
でレスポンスに変換します。
class UserController {
constructor(private usecases: UserUsecases) {}
get = async (req: APIGatewayRequest): Promise<ApiResponse> => {
try {
const { id } = parseParams(req, (params) => ({
id: params.id as string,
}));
const result = await this.usecases.get.execute(id);
return successResponse(result);
} catch (error) {
console.log(error);
return errorResponse(error);
}
};
getAll = async (req: APIGatewayRequest): Promise<ApiResponse> => {
try {
const result = await this.usecases.getAll.execute();
return successResponse(result);
} catch (error) {
console.log(error);
return errorResponse(error);
}
};
post = async (req: APIGatewayRequest): Promise<ApiResponse> => {
try {
const item = parseBody<User>(req);
const result = await this.usecases.post.execute(item);
return successResponse(result);
} catch (error) {
console.log(error);
throw errorResponse(error);
}
};
put = async (req: APIGatewayRequest): Promise<ApiResponse> => {
try {
const { id } = parseParams(req, (params) => ({
id: params.id as string,
}));
const item = parseBody<User>(req);
const result = await this.usecases.put.execute(id, item);
return successResponse(result);
} catch (error) {
console.log(error);
throw errorResponse(error);
}
};
delete = async (req: APIGatewayRequest): Promise<ApiResponse> => {
try {
const { id } = parseParams(req, (params) => ({
id: params.id as string,
}));
const result = await this.usecases.delete.execute(id);
return successResponse(result);
} catch (error) {
console.log(error);
throw errorResponse(error);
}
};
}
export default UserController;
リクエストの解析は以下にまとめました。
type APIGatewayRequest = Request & { apiGateway?: any };
const parseQuery = <T>(
value: Request,
predicate: (input: Record<string, any>) => T
): T => {
try {
return predicate(value.query);
} catch (error) {
throw Errors.BadRequest(String(error));
}
};
const parseParams = <T>(
value: Request,
predicate: (input: Record<string, any>) => T
): T => {
try {
return predicate(value.params);
} catch (error) {
throw Errors.BadRequest(String(error));
}
};
const parseBody = <T>(
value: Request,
predicate: (input: any) => T = (input) => {
return input as T;
}
): T => {
try {
return predicate(value.body);
} catch (error) {
throw Errors.BadRequest(String(error));
}
};
export { APIGatewayRequest, parseQuery, parseParams, parseBody };
同様にレスポンス
const successResponse = (body: any, status: number = 200): ApiResponse => {
return {
statusCode: status,
body: body,
};
};
const errorResponse = (error: any): ApiResponse => {
if (error instanceof APIError) {
return {
statusCode: error.statusCode,
body: { error: error.message },
};
} else {
return {
statusCode: 500,
body: { error: String(error) },
};
}
};
interface ApiResponse {
statusCode: number;
body: any;
}
export { ApiResponse, successResponse, errorResponse };
Interface/Router
ルーティングはexporess
を用いて行います。
import express from "express";
const createRouter = (controller: UserController) => {
const router = express.Router();
const asyncHandler = (
action: (req: APIGatewayRequest) => Promise<ApiResponse>
) => {
return async (req: express.Request, res: express.Response) => {
try {
const result = await action(req);
res.status(result.statusCode).json(result.body);
} catch (error) {
console.error(error);
res.status(500).json({ message: "Internal Server Error" });
}
};
};
router.get("/users/:id", asyncHandler(controller.get));
router.get("/users", asyncHandler(controller.getAll));
router.post("/users", asyncHandler(controller.post));
router.put("/users/:id", asyncHandler(controller.put));
router.delete("/users/:id", asyncHandler(controller.delete));
return router;
};
export default createRouter;
index.ts
プロジェクトのルートかつ全ての汚れ役を請け負います。まず前半では依存関係の注入(Dependency Injection)を行います。基本的にindex.tsでのみ初期化(new
)を行います。仮に実装が変わったりテストのためにRepository
を入れ替えるとき、index.ts
をいじれば対応できます。後半ではexpress
を用いてアプリケーションを立ち上げます。
import express from "express";
import serverless from "@vendia/serverless-express";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
//依存関係の注入(DI)
const userTableName = "my-api-users";
const dynamoDBClient = new DynamoDBClient({ region: "ap-northeast-1" });
const client = DynamoDBDocumentClient.from(dynamoDBClient);
const userRepository: IUserRepository = new DynamoDBUserRepository(
client,
userTableName
);
const usecases = createUsecases(userRepository);
const controllers = new UserController(usecases);
const router = createRouter(controllers);
// 初期化
const app = express();
app.use(express.json());
app.use(router);
app.get("/", (req, res) => {
res.send("Hello");
});
export const handler = serverless({ app });
Utils/Errors
各レイヤー共通で用いたエラーです。もっと作り込むなら各レイヤーごとにエラーを作成してハンドリングすべきですが今回は妥協します。
export class APIError extends Error {
public statusCode: number;
constructor(statusCode: number, message?: string) {
super(message);
this.statusCode = statusCode;
}
}
export const Errors = {
BadRequest: (msg = "Bad Request") => new APIError(400, msg),
Unauthorized: (msg = "Unauthorized") => new APIError(401, msg),
Forbidden: (msg = "Forbidden") => new APIError(403, msg),
NotFound: (msg = "Not Found") => new APIError(404, msg),
MethodNotAllowed: (msg = "Method Not Allowed") => new APIError(405, msg),
Conflict: (msg = "Conflict") => new APIError(409, msg),
InternalServerError: (msg = "Internal Server Error") =>
new APIError(500, msg),
NotImplemented: (msg = "Not Implemented") => new APIError(501, msg),
};
動作確認
以下を実行して動作確認ができます。POST
では{id:0001,name:Bob}
を作成してPUT
ではname:Alice
に変更します。
curl -X GET "<自分のURL>/users/0001"
curl -X GET "<自分の>/users"
curl -X POST "<自分のURL>/users" \
-H "Content-Type: application/json" \
-d '{"id":"0001", "name":"Bob"}'
curl -X PUT "<自分のURL>/users/0001" \
-H "Content-Type: application/json" \
-d '{"id":"0001", "name":"Alice"}'
curl -X DELETE "<自分のURL>/users/0001"
まとめ
これで
- DBの読み書きができる
- 自動ビルド&デプロイ
- クリーンアーキテクチャ
という三拍子揃ったAPIが完成しました。
参考記事