Clean ArchitectureをNode.js+Typescriptで実装してみる

Author
  • SAMCO サムコ STD異経レデューラエルボウ FB300>250 70>60 40RE907060
  • YOKOHAMA ヨコハマ ブルーアース AE-01F サマータイヤ 195/65R15 MANARAY Euro Speed C-07 ホイールセット 4本 15インチ 15 X 5.5 +43 4穴 100
  • こんにちは。バックエンドエンジニアの西尾です。

    スペースマーケットではAPIサイドは主にRailsを利用していますが、最近は一部サービスでNode.js+Typescriptを使い始めました。 個人的にはまだ触れていなかった領域であったのでお勉強がてら簡単なコードを書いてみたいと思っていました。

    また、Node.jsの他にもう一つ最近気になり始めていたものにClean Architectureという設計思想があり、Clean Architecture 達人に学ぶソフトウェアの構造と設計を読んでいます。

    本の概要は、アプリケーションが成長するに従い徐々に改修コストが上がってしまう、これを防ぐためにはうまい方法、つまり良い設計を知っている方法があるという導入から始まり、設計の歴史、原則とそれを実現するための1手段としてClean Architectureという設計思想について語られています。

    この最初はいいけど G-CORPORATION アクア NHP10 GS-i フロントスポイラー mono-tone シトラスオレンジマイカメタリック(4V7) 塗装済、後から辛いを防ぐというのはどんどん大きくなりつつあるスペースマーケットでも同じことが言えるのではないかという部分に共感を覚え学び始めています。

    ただ、読んでいるとなんとなく原則や思想については分かった気がするが実際にどう実装するのかという部分が見えにくいものがありました。

    そこで今回は、よりClean Architectureについての理解を深めるためによくあるTODO管理を元にNode.js + Typescriptを用いて実際のコードに落とし込んでみようと思います。

    【USミニ 直輸入純正品】 MINI Cooper Hardtopミニクーパー2009-2013年(平成21年-25年式)クロム ミラーカバー 【★送料無料】 左右 ▼フロントロアアーム▼日産

    Clean Architectureは関心事の分離をするという目的を達成するための1つの手法として提唱されています。

    Clean Architectureでは、4層の円が描かれており、各円はソフトウェアの領域を表しています。そして、最も重要なルールとして、依存性は内側だけに向かっていなければならないとしています。

    詳しい説明については、本家日本語訳、及び本(Clean Architecture 達人に学ぶソフトウェアの構造と設計)を参照ください。今回はこの思想を元に実際のコードに落とし込んでみます。

    今回作るもの

    今回はよくサンプルとかで出てくるTODO管理を元にClean Architectureを適用しようと思います。実装するものとしてはTaskを登録、更新、参照、削除するという4つの機能となります。

    また、フロントは作らず、サーバーサイドの実装のみ行います。

    使用技術は主に

    • TypeScript
    • Node.js
    • Express
    • MySQL

    の4点になります。

    ディレクトリ構成

    ディレクトリ構造は上記のようにしてみました。

    サンプルソースについては、こちら に配置しています。

    次から各層ごとの実装について説明します。

    Enterprise Business Rules レイヤー

    domainディレクトリ配下にはEnterprise Business Rulesを格納する場所としました。今回はその配下modelsにTaskのモデルを配置します。

    今回は複雑なロジックもなく単純なCRUDの実装のみなのでTask.tsについてはデータ構造とアクセサメソッドだけになります。

    // domain/models/Task.ts
    import moment from 'moment-timezone'
    export class Task {
     private _id: number
     private _title: string
     private _description: string
     private _createdAt: moment.Moment
     private _updatedAt: moment.Moment
     get id(): number {
     return this._id
     }
     set id(id: number) {
     this._id = id
     }
     get title(): string {
     return this._title
     }
     set title(title: string) {
     this._title = title
     }
    <省略 他アクセサメソッドを定義>
     constructor(title: string = null, description: string = null) {
     this._title = title
     this._description = description
     }
    }
    

    Application Business Rules レイヤー

    applicationディレクトリ配下にはApplication Business Rulesを格納する場所とし、配下にusecasesとrepositoriesの2ディレクトリとしました。

    ユースケースには

  • タスクを作成する CreateTask.ts
  • タスクを更新する UpdateTask.ts
  • タスクを1つ取得する GetTask.ts
  • タスク一覧を取得する ListTasks.ts
  • タスクを削除する DeleteTask.ts
  • 上記5ファイルを格納しています。

    repositories配下には円の境界を越えるために具象クラスではなく抽象クラスを格納しusecasesから使用するようにしています。

    円の境界を超える部分について少し説明すると、ユースケースのCreateTask.tsでやることはtitleとdescriptionを受け取って、それを永続化するということをやりたいです。これを単純に書くと次のような形になるかと思います。

    // src/application/usecases/CreateTask.ts
    import { Task } from '../../domain/models/Task'
    import { TaskRepository } from '../../interfaces/database/TaskRepository'
    export class CreateTask {
     execute(title: string, description: string, pool: any) {
     let task = new Task(title, description)
     const taskRepository = new TaskRepository(pool)
     return taskRepository.persist(task) //永続化
     }
    }
    

    しかし、この書き方では永続化するためにDBのpoolを受け取っているため、DBに依存していることがわかります。永続化のため円の外側の関心事(DB)を持ち込んでいるためこの書き方ではClean Architectureのルール違反となってしまいます。

    ではどうやってこの境界を越えるのかというと依存関係逆転の原則(DIP)を使って、具体的にはインターフェースや継承を使って解消します。


    依存関係逆転の原則(dependency inversion principle) Wikipediaより引用

    A. 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。
    B. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。

    DIPを使って書き直したコードが次のような形になります

    // src/application/usecases/CreateTask.ts
    import { Task } from '../../domain/models/Task'
    import { ITaskRepository } from '../repositories/ITaskRepository'
    export class CreateTask {
     private taskRepository: ITaskRepository
     constructor(taskRepository: ITaskRepository) {
     this.taskRepository = taskRepository
     }
     execute(title: string, description: string) {
     let task = new Task(title, description)
     return this.taskRepository.persist(task)
     }
    }
    

    ITaskRepositoryは次のようなコードになります

    // src/application/repositories/ITaskRepository.ts
    import { Task } from '../../domain/models/Task'
    export abstract class ITaskRepository {
     abstract async findAll(): Promise<Array<Task>>
     abstract async find(id: number): Promise<Task>
     abstract async persist(task: Task): Promise<Task>
     abstract async merge(task: Task): Promise<Task>
     abstract async delete(task: Task): Promise<Task>
    }
    

    ITaskRepositoryについては、どこにファイルを配置するか悩みましたが、今回はapplication配下にrepositoriesというフォルダを切り格納しています。

    ITaskRepositoryではインターフェースのみを定義し、実際に保存するという具体的な処理は記載していません。CreateTask.tsはconstructorでITaskRepositoryを受け取り、ITaskRepositoryという抽象クラスに依存している状態になります。そして、taskRepositoryのインスタンス化は円の外側のレイヤーで行うことになります。

    DIPのテクニックを使うと、ITaskRepositoryの具体的なクラスがDBに保存するような、DBTaskRepositoryであろうが、 Redisに保存するためのRedisTaskRepositoryであろうが、CreateTaskクラス内では詳細を気にしなくてよくなり(データを永続化するという抽象的なことには依存しているけど、DBにデータを永続化するという具体的なことには依存していない)、関心事が分離されている状態になります。

    このようなDIPを用いて円の境界を超えることができました。

    Interface Adaptors レイヤー

    interfacesディレクトリ配下は、controllersとdatabase,及び出力向けにjson形式にするserializersの3つとしました。

    まずは永続化, 円の中のGatewaysにあたるdatabaseは次のような実装となりました。

    // src/interfaces/database/TaskRepository.ts
    import { Task } from '../../domain/models/Task'
    import { ITaskRepository } from '../../application/repositories/ITaskRepository'
    import { IDBConnection } from './IDBConnection'
    import moment from 'moment-timezone'
    export class TaskRepository extends ITaskRepository {
     private connection: any
     constructor(connection: IDBConnection) {
     super()
     this.connection = connection
     }
     async persist(task: Task): Promise<Task> {
     let result = await this.connection.execute(
     'insert into tasks (title, description, created_at, updated_at) values (?, ?, ?, ?)',
     [
     task.title,
     task.description,
     task.getUTCCreatedAt(),
     task.getUTCUpdatedAt()
     ]
     )
     task.id = result.insertId
     return task
     }
     <他はここでは省略>
    }
    

    usecase部分で登場したITaskRepositoryの具象クラスをここで定義しています。やっていることはmodelデータを受け取り、実際にDBに保存するということをしています。また、DB接続についてはここでは具体的には定義せず、usecaseの時と同様にDIPを用い、1つ外側のレイヤーに具体的な処理を書き、ここではIDBConnectionという抽象クラスに依存するようにしました。 これにより具体的にはDB接続がmysql packageであろうが、その他独自のpackageであろうがここでは意識しなくて良いことになります。

    続いてControllerは次のようになりました

    // src/interfaces/controllers/TasksController.ts
    import { TaskSerializer } from '../serializers/TaskSerializer'
    import { TaskRepository } from '../database/TaskRepository'
    import { ListTasks } from '../../application/usecases/ListTasks'
    import { GetTask } from '../../application/usecases/getTask'
    import { CreateTask } from '../../application/usecases/CreateTask'
    import { UpdateTask } from '../../application/usecases/UpdateTask'
    import { DeleteTask } from '../../application/usecases/DeleteTask'
    import { IDBConnection } from '../database/IDBConnection'
    export class TasksController {
     private taskSerializer: TaskSerializer
     private taskRepository: TaskRepository
     constructor(dbConnection: IDBConnection) {
     this.taskSerializer = new TaskSerializer()
     this.taskRepository = new TaskRepository(dbConnection)
     }
     async createTask(req: any, res: any) {
     const { title, description } = req.body
     const useCase = new CreateTask(this.taskRepository)
     let result = await useCase.execute(title, description)
     return this.taskSerializer.serialize(result)
     }
     <他はここでは省略>
    }
    

    Controllerではrequestを解析、CreateTaskのuseCaseを実行しDBにデータを永続化、その後出力向けにデータを変換(serializers)して返しています。先ほどユースケースで登場したtaskRepositoryのインスタンス化もここで行なっています。

    次にserializersについては円で言うとpresentersにあたり 、次のようなコードを実装しました。

    // src/interfaces/serializers/TaskSerializer.ts
    import { Task } from '../../domain/models/Task'
    import moment from 'moment-timezone'
    const _serializeSingleTask = (task: Task) => {
     return {
     id: task.id,
     title: task.title,
     description: task.description,
     createdAt: moment(task.createdAt)
     .tz('Asia/Tokyo')
     .format(),
     updatedAt: moment(task.updatedAt)
     .tz('Asia/Tokyo')
     .format()
     }
    }
    export class TaskSerializer {
     serialize(data: any) {
     if (!data) {
     throw new Error('expect data to be not undefined nor null')
     }
     if (Array.isArray(data)) {
     return data.map(_serializeSingleTask)
     }
     return _serializeSingleTask(data)
     }
    }
    

    Frameworks & Drivers レイヤー

    最後に円の一番外側の部分について実装していきます。まずはDB接続部分と実行部分です。ここではpackage mysqlを使っています。

    // src/infrastructure/MysqlConnection.ts
    import mysql from 'mysql'
    import dotenv from 'dotenv'
    import util from 'util'
    import { IDBConnection } from '../interfaces/database/IDBConnection'
    export class MysqlConnection extends IDBConnection {
     private pool: any
     constructor() {
     super()
     dotenv.config()
     this.pool = mysql.createPool({
     connectionLimit: 5,
     host: process.env.DB_HOST_DEV,
     user: process.env.DB_USER_DEV,
     password: process.env.DB_PASSWORD_DEV,
     database: process.env.DB_NAME_DEV,
     timezone: 'utc'
     })
     this.pool.getConnection((error: any, connection: any) => {
     if (error) {
     if (error.code === 'PROTOCOL_CONNECTION_LOST') {
     console.error('Database connection was closed.')
     }
     if (error.code === 'ER_CON_COUNT_ERROR') {
     console.error('Database has too many connections.')
     }
     if (error.code === 'ECONNREFUSED') {
     console.error('Database connection was refused.')
     }
     }
     if (connection) connection.release()
     return
     })
     this.pool.query = util.promisify(this.pool.query)
     // pool event
     this.pool.on('connection', (connection: any) => {
     console.log('mysql connection create')
     })
     this.pool.on('release', (connection: any) => {
     console.log('Connection %d released', connection.threadId)
     })
     }
     execute(query: string, params: any = null) {
     if (params !== null) {
     return this.pool.query(query, params)
     } else {
     return this.pool.query(query)
     }
     }
    }
    

    続いてルーディング部分です。ここでExpressフレームワークが登場します。

    // src/infrastructure/router.ts
    import express = require('express')
    import { TasksController } from '../interfaces/controllers/TasksController'
    import { MysqlConnection } from './MysqlConnection'
    const mysqlConnection = new MysqlConnection()
    const tasksController = new TasksController(mysqlConnection)
    let router = express.Router()
    router.post('/tasks', async (req: express.Request, res: express.Response) => {
     let result = await tasksController.createTask(req, res)
     res.send(result)
    })
    <途中省略>
    export default router
    

    最後にserver.tsを記載します。

    // src/infrastructure/server.ts
    import express from 'express'
    import router from './router'
    import bodyParser from 'body-parser'
    const app = express()
    // bodyがundefinedにならないように
    app.use(bodyParser.urlencoded({ extended: false }))
    app.use(bodyParser.json())
    // Route設定
    app.use('/api', router)
    app.listen(3000, () => {
     console.log('listening on port 3000')
    })
    export default app
    

    このようにフレームワークやドライバーの部分は円の一番外側に記載しました。

    最終構成と実行

    最終的なツリー構造は下記のようになりました。

    実際にリクエストを投げてみるとDBに保存されていることがわかります

    やってみて

    今回やってみて、フレームワークが内部のロジックまで侵食しておらず外側で完結しているといったことや、ユースケースでデータを永続化するという時にDBに保存するという具体的なことをユースケースの層では気にしなくて良いなど、Clean Architectureに従っていくと関心事が分離されるのでソフトウェアが疎結合になるというのを少し実感することができました。

    ただ、他の方も言われているように CST ゼロ1ハイパー アルミホイール 17×9.0 5/114.3 +30 フラットブラック、クラスが多くなったり、コードの記述量が増えることから、初期にとりあえず機能を提供することを優先するといった時には向かないということも実感できました。

    しかしながら、初期のフェーズは終わり、1つ次のステージに向かうためには初速の開発速度を犠牲にしてもこのようなアーキテクチャーを導入し 14インチ サマータイヤ セット【適応車種:ミラージュ(A05A)】HOT STUFF Gスピード G02 メタリックブラック 5.5Jx14NANOエナジー 3プラス 165/65R14、システムを疎結合にしておくと後々開発速度が鈍足になることを防げるのではないかと思いました。

    今回は非常に簡単なコードでテストとかも実装していないので、後々楽になると言う部分をなんとなくの概念でしか体感することはできなかったので、もう少し複雑にした時に見えてくる世界もいつか体感してみたいと思います。そして、まだまだクリーンアーキテクチャーについて理解できていない部分も多いので(右下の図が何を示しているのかなど)引き続き調べていきたいとも思いました。

    また、今回はTypescriptで初めて実装してみたのですが、

    【USミニ 直輸入純正品】 MINI Cooper Hardtopミニクーパー2009-2013年(平成21年-25年式)クロム ミラーカバー 左右
    [ACRE] アクレ ブレーキパッド ZZC フロント用 CR-Xデルソル EJ4 95/9~99/8 1600cc ABS付車 ※代引不可 ※北海道・沖縄・離島は送料2160円!CUSCO (クスコ) フロント ハイブリッドストラットバー 品番:990 542 A トヨタ ヴェルファイア 型式:AGH30W.クラッツィオ クラッツィオ ツィール トヨタ ヴィッツ SCP90 / NCP91 H19(2007)/9~H22(2010)/12 Clazzioシートカバー1台分,KYB(カヤバ) ショックアブソーバー NEW SR スペシャル フロント/リアSET 1台分 ダイハツ ムーヴ 形式:L600S/L602S 年式:97/05~98/09 NST8016RL/NSG8013

    【USミニ 直輸入純正品】 MINI Cooper Hardtopミニクーパー2009-2013年(平成21年-25年式)クロム ミラーカバー 左右

    、個人的な感想としては、やってみるとめちゃめちゃ型定義しろしろ言われ 幌・ソフトトップ Bestop 5472335 Wrangler JK Complete Replacement Soft Top Supertop NX Black Diamo Bestop 5472335ラングラーJK完全交換用ソフトトップスーパートップNXブラックダイアモンド、普段型なし言語を使っているためか結構うっとおしかったです。ですが、固い感じのシステムを作る際にはやはり型は定義した方が良いのかなとも思いました。(あまりにうっとおしいので今回は一部anyにしちゃったところがありますが 18インチ サマータイヤ セット【適応車種:ティアナ(J32系)】WEDS レオニス NAVIA 06 マットガンメタマシニングカット 8.0Jx18ADVAN ネオバAD08R 225/45R18、、、)

    今回のサンプルコードはこちらにあげています。

    作る上で参考にした文献についても下記に記載します。

  • Clean Architecture 達人に学ぶソフトウェアの構造と設計
  • クリーンアーキテクチャ(The Clean Architecture翻訳)
  • jbuget/nodejs-clean-architecture-app
  • Clean ArchitectureでAPI Serverを構築してみる
  • 最後に

    スペースマーケットでは、現在以下の募集を行っております。 お話するだけでも大歓迎ですので、ご興味がありましたら是非一度遊びに来てください!

  • サーバサイドエンジニア (https://www.wantedly.com/projects/222428)
  • データ分析エンジニア(https://www.wantedly.com/projects/211321)
  • フロントエンドエンジニア(https://www.wantedly.com/projects/226855)