こんにちは、以下のツイートをしたところ、予想外に反響をいただいたので記事を書いてみることにしました。
ツイートをしたときに、「Typescript + Express + Sequelize」と言っていたんですが、Sequelizeだとどうも Repositoryの構築がきれいにできなくて途中でTypeORMに移行しました。
Typescript + Express + Sequelize with Clean Architectureでびっくりするほどテストが書きやすいプロジェクト構成を作り出したが、ここに書くには余白が足りない(こんどブログにまとめたい)
— takayama.kazuyuki(30) (@pco2699) 2019年1月9日
TypescriptだとORMはSequelizeよりTypeORMのほうが、いろいろ整備されて良さそうです。 依存性注入のライブラリであるTypeDIとのつなぎこみも楽なのでTypeORMのほうが圧倒的におすすめ。
ソースコード
プロジェクト構成の基本方針
- Clean Architectureに基づいてプロジェクト構成をつくる。
- 各モジュールはinterfaceに依存するようにして、ユニットテスト時にモックに差し替えられるようにする。
- DIを用いて、実装を注入するようにして、モックに差し替えられるようにする。
今回つくるもの
以下を作ってみたいと思います。
外部とのやりとりはfirebaseとDB(Postgresql)です。
プロジェクト構成
プロジェクトのtreeはこちらです。
. ├── src │ ├── contollers │ ├── entity │ ├── gateways │ ├── repositories │ ├── usecases │ ├── viewmodel │ ├── app.ts │ └── server.ts ├── package.json ├── tsconfig.json ├── tslint.json └── webpack.config.dev.js
それぞれの構成要素の説明
それぞれの部分について、説明していきます。
Clean Architectureの基本的な用語などは、いい記事がいっぱいあるので省略させていただきます(おい
- Entity
- UseCase
- Gateway
- Controller
- Repository
あの有名な図を内側から外側に順に、説明していくイメージを持っていただければ。
Entity
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column() email: string; @Column() firebase_uuid: string; }
データを保持するクラスとして、Userのentityを宣言し、各種値の定義しています。
データベースのテーブルとEntityは1対1で、保持するようにしてます。 *1
UseCase
export interface RegisterUsecase { executeRegister(email: string, password: string): Promise<void>; saveUser(email: string, name: string): Promise<UserResponse>; } export const RegisterUsecaseToken = new Token<RegisterUsecase>(); @Service(RegisterUsecaseToken) class RegisterUsecaseImpl implements RegisterUsecase { constructor(@Inject(AuthGatewayToken) private readonly authGateway: AuthGateway, @InjectRepository() private readonly userRepository: UserRepository) { } async executeRegister(email: string, password: string) { await this.authGateway.register(email, password); } async saveUser(email: string, name: string) { // 省略 } }
UseCaseにビジネスロジックを書きます。今回は、firebaseとdbに登録する処理をUseCaseに記載しています。
最初にinterfaceを定義して、メソッドの定義のみ記載し その後、実装を書いていきます。
後に説明しますが、userRepositoryとfirebaseGatewayは、interfaceに依存しており
実装に依存していないため、モックに差し替えが可能です。
※(2/3 UPDATE) UseCaseが思いっくそ、firebaseに依存してるやんけ!とコメント受けて
コード変更しました。(すいませんw)
Gateway
export interface AuthGateway { register(email: string, password: string): void; getUUID(): string; } export const AuthGatewayToken = new Token<AuthGateway>(); @Service(AuthGatewayToken) class FirebaseGateway implements AuthGateway { firebase: firebase.app.App; constructor() { // firebaseの初期化 } async register(email: string, password: string): Promise<void> { // 省略 } getUUID(): string { // 省略 } }
Gatewayは主にAPIなどの外部との接続を担保します。
今回は、実質firebaseとのライブラリをラップしているだけなので少々、冗長です。
実際の運用だと、無くしてしまって、直接firebaseのライブラリを叩くのが良いです。
他のAPIを自力で実装する場合などでゴリゴリロジックを書く場合は、こういった形でGatewayを設けると良いと思います。
Controller
@JsonController('/register') export class ApiController { constructor(@Inject(RegisterUsecaseToken) private readonly registerUsecase: RegisterUsecase) {} @Post('/') async register(@Body({ required: true }) userReq: UserRequest): Promise<UserResponse> { const user = await this.registerUsecase.executeRegisterWithFirebase(userReq.email, userReq.password); return await this.registerUsecase.saveUser(user, userReq.name); } }
Controllerはrouting-controllers
で、ルーティングもデコレータで記述してます。
非常にSpringライクですね。
UseCaseにビジネスロジックを書いていて、それを呼び出すだけので、非常にシンプルです。
Repository
export interface UserRepository { findByEmail(email: string): Promise<User | undefined>; countByEmail(email: string): Promise<Number>; save<User>(user: User): Promise<User>; } @Service() @EntityRepository(User) export default class UserRepositoryImpl extends Repository<User> implements UserRepository { public findByEmail(email: string) { return this.findOne({ email }); } public countByEmail(email: string) { return this.count({ email }); } public save<User>(user: User) { return super.save(user); } }
RepositoryはTypeORMのレポジトリパターンをそのまま踏襲してます。
DBにアクセスする際のデータの処理方法を記載していくイメージ。
ちなみに、RepositoryだけTypeORM, TypeDIの制約でinterfaceを定義できませんでした。。。*2
あとで試してみたら、普通にinterfaceを定義できたので更新しました。
その他
インターフェースと実装の命名規則について
実装例を見ていると以下の2パターンが多いようです。
- Interface: IHogeHoge 実装: HogeHoge
- Interface: HogeHoge 実装: HogeHogeImpl
// パターン1 interface IHogeHoge { sayHoge(): void } class HogeHoge implements IHogeHoge { sayHoge() { console.log('hoge'); } } // パターン2 interface HogeHoge { sayHoge(): void } class HogeHogeImpl implements HogeHoge { sayHoge() { console.log('hoge'); } }
個人的には1のパターンだと、実際に利用するのは、IHogeHoge
になります。
(HogeHoge
はIHogeHoge
に後から注入される。)
違和感があるので2の命名規則が個人的には好みです。
TypeDIによる依存性注入について
実装に依存することを避けるため、TypeDI
という依存性注入ライブラリを利用しました。
このTypeDIは、Java Springライクにデコレータで依存性注入がサクッと行えるので非常に便利です。
以下のような形です。
import {Container, Service, Inject, Token} from "typedi"; // インターフェースの定義 export interface Factory { create(): void; } // 識別子の定義 export const FactoryService = new Token<Factory>(); // @Serviceをつけるとつけたクラスが注入される @Service(FactoryService) export class BeanFactory implements Factory { create() { } } // ↑で定義したファクトリークラスを注入するクラス @Service() export class CoffeeMaker { private factory: Factory; // @Inject(識別子)で実装を注入できる constructor(@Inject(FactoryService) factory: Factory) { this.factory = factory; } make() { this.factory.create(); } } // 実際に上記クラス群を呼び出すコード let coffeeMaker = Container.get(CoffeeMaker); coffeeMaker.make(); let factory = Container.get(FactoryService); // factory is instance of Factory factory.create();
今後Updateするポイント
以下のポイントがまだ不十分なので今後Updateしていき
- FirebaseGatewayのせいでUseCaseがinfraに依存してしまっている。->firebasegateway周りをリファクタ・抽象化してUseCaseにinfraが入らないようにする
- Repositoryがinterfaceになっていないので、DBからすげ変わったときに大変 -> interfaceに依存するように変更する