pco2699’s blog

学んだコード・技術について、保存しておく場所

Typescript + Express + TypeORM + TypeDI with Clean Architecture でめっちゃテストしやすいプロジェクト構成をつくった

こんにちは、以下のツイートをしたところ、予想外に反響をいただいたので記事を書いてみることにしました。

ツイートをしたときに、「Typescript + Express + Sequelize」と言っていたんですが、Sequelizeだとどうも Repositoryの構築がきれいにできなくて途中でTypeORMに移行しました。

TypescriptだとORMはSequelizeよりTypeORMのほうが、いろいろ整備されて良さそうです。 依存性注入のライブラリであるTypeDIとのつなぎこみも楽なのでTypeORMのほうが圧倒的におすすめ。

github.com

github.com

ソースコード

github.com

プロジェクト構成の基本方針

  • Clean Architectureに基づいてプロジェクト構成をつくる。
  • 各モジュールはinterfaceに依存するようにして、ユニットテスト時にモックに差し替えられるようにする。
  • DIを用いて、実装を注入するようにして、モックに差し替えられるようにする。

今回つくるもの

以下を作ってみたいと思います。
外部とのやりとりはfirebaseとDB(Postgresql)です。

f:id:pco2699:20190130090429p:plain
プロジェクトの構成

プロジェクト構成

プロジェクトの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

あの有名な図を内側から外側に順に、説明していくイメージを持っていただければ。 f:id:pco2699:20190130210116j:plain

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ライクですね。

github.com

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パターンが多いようです。

  1. Interface: IHogeHoge 実装: HogeHoge
  2. 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になります。
(HogeHogeIHogeHogeに後から注入される。)

違和感があるので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に依存するように変更する

*1: Entityは単に、データを保持するクラスじゃない!という話もあるらしいので、そこらへんコメントもらえると泣いて喜びます

*2:これもコメントもらえると泣いて喜びます。