前回、LambdaでDenoを動かしてみたところ、思ったよりも悪くなく、正式導入を検討する余地があったため、巷で人気(?)のクリーンアーキテクチャで構成してみました。
※ 独自アーキテクチャでの導入も検討しましたが、せっかくなので...
成果物
クリーンアーキテクチャ
よく、こんな図形を目にするが、ぶっちゃけ、よくわからない。
理解を深めるため、自分なりにまとめてみました。
クリーンアーキテクチャとは
一言で言うと、それぞれの役割を綺麗に保つこと
様々なシステムに触れてきたエンジニアなら想像ができると思うが、
- Controllerが機能特有のロジックを持っていたり
- Modelが全く関連性のないModelを継承していたり
- DBへのアクセスがいろんな場所で散らばっていたり
- 各Classが超・絶・密結合になっていて、テストコードが実装できなかったり
こんな経験があるのでは?
これらは、どこでどのような処理をどう言う風に実装するのかが、開発者に委ねられているため起こる現象なのではないでしょうか。
クリーンアーキテクチャを導入することで、各レイヤーでの役割を明確にし、疎結合にすることで、各レイヤーでの役割を綺麗に保つことが可能になると理解しました。
この円状の図形は、各層の役割と、外から内へ単一方向への依存関係を表しています。
この図形上では
- Enterprise Business Rules
- Application Business Rules
- Interface Adapters
- Frameworks & Drivers
の4つの層から構成されているが、原著には4つ以上必要な場合もあり、層の数に規則はないと記載があります。
You may find that you need more than just these four. There’s no rule that says you must always have just these four.
ただし、外から内へ単一方向の依存性は担保するべきともあります。
However, The Dependency Rule always applies. Source code dependencies always point inwards. As you move inwards the level of abstraction increases. The outermost circle is low level concrete detail. As you move inwards the software grows more abstract, and encapsulates higher level policies. The inner most circle is the most general.
単一方向の依存性とは
クリーンアーキテクチャにおける、依存性は内側一方向のみとなっております。なので、外側のレイヤーでの関心が内側へ持ち込まれないため、疎結合に保つことができます。
とわ言え、外側のレイヤーを参照さざるを得ないシチュエーションは多々あります。
その場合は、抽象Classへ依存させることで依存性を逆転させます。
各レイヤー
Enterprise Business Rules
所謂Entity。
データ構造と付随する関数を持つオブジェクト。
Application Business Rules
所謂UseCase
アプリケーション固有のビジネスロジックが実装されている。
Interface Adapters
UseCaseやEntityにからDBやWebなどに最も適した形で値をコンバートするアダプター。
MVCのController, Presenter, Viewや、DBへアクセスするためのSQLなどが該当。
Frameworks & Drivers
DBやWebFWなどのFW, Toolで構成されている。
一つ上の層との通信するグルーコード以外は基本実装しない。
サンプルコード
階層
.
├── README.md
├── application # Application Business Rules.
│ ├── repositories # Repository.
│ │ └── IUserRepository.ts
│ └── usecases # UseCase.
│ ├── CreateUser.ts
│ └── GetUser.ts
├── deploy.sh # Deploy script.
├── enterprise # Enterprise Business Rules.
│ └── models # Model / Entity.
│ └── User.ts
├── frameworks # Frameworks & Drivers.
│ └── DynamoClient.ts
├── index.ts # Lambda handler.
├── interface # Interface Adapters.
│ ├── controllers # Controller.
│ │ └── UserController.ts
│ └── database # DB connection.
│ ├── IClient.ts
│ └── UserRepository.ts
├── spec # Spec(It is being created.)
└── template.yml # AWS CloudFormation yml
簡易Class図
説明
実行順にソースを追っていこうと思います。
Lambda Handlerはindex.handlerと設定しているため、Lambda実行時にhandlerメソッドが実行されます。
import { Context, Event } from 'https://deno.land/x/lambda/mod.ts';
import { UserController } from './interface/controllers/UserController.ts'
import { DynamoClient } from './frameworks/DynamoClient.ts'
export async function handler(event: Event, context: Context) {
const client = new DynamoClient()
const userController = new UserController(client)
// サンプルとして、ベタがき
const request = {id: 1}
const response = await userController.getUser(request)
console.log('response', response)
return {
statusCode: 200,
body: response,
}
}
注目する点としては、DynamoClient
をUserController
へ注入している点です。
import { UserController } from './interface/controllers/UserController.ts'
import { DynamoClient } from './frameworks/DynamoClient.ts'
~
const client = new DynamoClient()
const userController = new UserController(client)
~
DynamoClient
はDBに接続するためのClassですが、クリーンアーキテクチャで言う一番外側のFrameworks & Drivers.
に該当します。
参照や書き込みといったDBへの操作を行うために必要となるClassですが、前段で説明した通り、依存性は内側一方向のみとルール化されているので、脳死で実装するとクリーンアーキテクチャの提唱する依存性のルールに反してしまいます。
なので、DynamoClient
とではなく、抽象ClassであるIClient
へ依存するように実装しています。
import { UserRepository } from '../database/UserRepository.ts'
import { GetUser } from '../../application/usecases/GetUser.ts'
import { IClient } from '../database/IClient.ts'
export class UserController {
private userRepository: UserRepository
constructor(dbConnection: IClient) {
this.userRepository = new UserRepository(dbConnection)
}
getUser(request: any) {
const { id } = request
return new GetUser(this.userRepository).execute(id)
}
}
export abstract class IClient {
abstract get(param): any
abstract put(param): any
}
import { config } from "https://deno.land/x/dotenv/mod.ts";
import { createClient } from "https://denopkg.com/chiefbiiko/dynamodb/mod.ts";
import { IClient } from '../interface/database/IClient.ts'
export class DynamoClient extends IClient {
private client: any
constructor() {
super()
this.client = createClient({
region: 'ap-northeast-1'
})
}
get(param) {
return this.client.getItem(param)
}
}
さらに、Controllerから実際にDB操作を行うClassへ注入しています。
import { UserRepository } from '../database/UserRepository.ts'
import { IClient } from '../database/IClient.ts'
~
constructor(dbConnection: IClient) {
this.userRepository = new UserRepository(dbConnection)
}
~
import { User } from '../../enterprise/models/User.ts'
import { IUserRepository } from '../../application/repositories/IUserRepository.ts'
import { IClient } from './IClient.ts'
export class UserRepository extends IUserRepository {
private connection: any
constructor(connection: IClient) {
super()
this.connection = connection
}
get(id: number): Promise<User> {
return this.connection.get({
TableName: 'Users',
Key: { id: id },
})
}
}
そして、このinterface/database/UserRepository.ts
を実際に実行Usecaseへ注入しています。
import { GetUser } from '../../application/usecases/GetUser.ts'
~
private userRepository: UserRepository
constructor(dbConnection: IClient) {
this.userRepository = new UserRepository(dbConnection)
}
~
getUser(request: any) {
const { id } = request
return new GetUser(this.userRepository).execute(id)
}
~
import { IUserRepository } from '../repositories/IUserRepository.ts'
export class GetUser {
private userRepository: IUserRepository
constructor(userRepository: IUserRepository) {
this.userRepository = userRepository
}
async execute(id: number) {
return await this.userRepository.get(id)
}
}
Application Business Rules.
であるGetUser.ts
が外側のレイヤーであるInterface Adapters
のUserRepository.ts
を直接参照したらルール違反なので、抽象ClassであるIUserRepository.ts
に依存させます。
import { User } from '../../enterprise/models/User.ts'
export abstract class IUserRepository {
abstract get(id: number): Promise<User>
abstract create(user: User): Promise<User>
}
以上。
実際に動かしてみる
cloneして、deploy.sh内の環境変数を編集。
#!/bin/sh
LAYER_S3_BUCKET=denoのアーカイブを配置するBuket
APP_S3_BUCKET=アプリケーションを配置するBucket
STACK_NAME=CloudformationのStack
~
そして、デプロイ。
$ ./deploy.sh
実行。
$ aws lambda invoke \
--function-name LambdaDenoCleanArchitecture \
--log-type Tail \
--region ap-northeast-1 \
--payload '{}' out \
--cli-binary-format raw-in-base64-out \
--query 'LogResult' --output text | base64 -d
START RequestId: xxxx-xxx-xxx-xxx-xxxx Version: $LATEST
response { Item: { id: 1, name: "浜辺美波", age: 20 } }
END RequestId: xxxx-xxx-xxx-xxx-xxxx
REPORT RequestId: xxxx-xxx-xxx-xxx-xxxx Duration: 79.81 ms Billed Duration: 4309 ms Memory Size: 1024 MB Max Memory Used: 85 MB Init Duration: 4228.87 ms
最後に
最初は「クリーンアーキテクチャ?なにそれ?」の状態だったのが、実際に動くものを作って、ドキュメントに起こすことでまぁまぁ理解したかなと。
構築して思ったが、Lambdaにクリーンアーキテクチャはオーバーアーキテクチャ感が否めない。
Enterprise Business Rules.やFrameworks & Drivers.などのレイヤーをLambda Layerとして外だしすることができたらなかなか強力なアーキテクチャになりそう。(次の課題)
あとは、正式導入するにはSpecの充実やCI/CD周りの整備が必要。次、(次の課題)
参考文献