オブジェクト指向
リファクタリング
TypeScript
クリーンアーキテクチャ
CleanArchitecture

リファクタリングして学ぶTypeScriptでクリーンアーキテクチャ

概要

最近,ASCII Dwangoさんから「クリーンアーキテクチャ」という本が出版されました.
そこに書いてある内容は素晴らしいものでした.しかし,実際に組んでみた場合,どういう風に作るのが良いのか?どういう問題があるのか?そういった疑問が湧いてきました.そこで,

実際に非クリーンアーキテクチャのコードをリファクタリングしていくことで,クリーンアーキテクチャの要点を感じる.

という試みです.

クリーンアーキテクチャとは

ここでは簡単にしか説明しませんが,実際に本を読んで勉強することをお勧めします.
「クリーンアーキテクチャ 達人に学ぶソフトウェアの構造と設計」のp200によると

  • フレームワーク非依存:アーキテクチャは,機能満載のソフトウェアのライブラリに依存していない.これにより,システムをフレームワークの制約で縛るのではなく,フレームワークをツールとして使用できる.
  • テスト可能:ビジネスルールは,UI,データベース,ウェブサーバー,その他の外部要素がなくてもテストできる.
  • UI非依存:UIは,システムのほかの部分を変更することなく,簡単に変更できる.たとえば,ビジネスルールを変更することなく,ウェブUIはコンソールUIに置き換えることができる.
  • データベース非依存:OracleやSQL ServerをMongo,BigTable,CouchDBなどに置き換えることができる.ビジネスルールはデータベースに束縛されていない.
  • 外部エージェント非依存:ビジネスルールは外界のインターフェースについて何も知らない.

これらのアイデアを実現するのがクリーンアーキテクチャだそうです.

image.png

そして,それらを実現するために重要な「依存性のルール」というものがあります.

ソースコードの依存性は,内側(上位層レベルの方針)だけにむかっていなければいけない.

例えば,UseCasesにあたるクラスは,Entitiesにあるクラスに依存してもよいが,Controllersに含まれるクラスに依存してはならない.それを守ることで,コードがクリーンに保たれるようです.

Practice

今回は「消費税(8%)を含んだ値段を計算する.」という非常にシンプルなコードを題材にしていきたいと思います.

非クリーンアーキテクチャのコード

clean01.ts
import * as readline from 'readline';

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

rl.on('line', (input: string) => {
    const price = Number(input);
    const taxPrice = Math.round(price * 1.08);
    console.log(`税込価格:${taxPrice}`);
});

とてもシンプルなコードです.入門書に書いていそうな非常にベタなコードです.標準入出力から,価格を入力し,その結果を1.08倍して丸めて,標準入出力に返すだけのコードです.
これを順次,クリーンアーキテクチャに変えていきます.

image.png

イメージはこんな感じ.

Entityを分離したコード

まずクリーンアーキテクチャの内側であるEntityを分離します.
Entityは「最重要のビジネスルールをカプセル化したもの」と定義されています.
今回はビジネスロジックである「消費税(8%)を含んだ値段を計算する.」を分離します.

clean02.ts
import * as readline from 'readline';

class TaxEntity {
    private readonly taxRate: number = 0.08;

    public calcTaxPrice(price: number): number {
        const p = this.taxRate + 1;

        return Math.round(price * p);
    }
}

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

rl.on('line', (input: string) => {
    const taxEntity = new TaxEntity();
    const price = Number(input);
    const taxPrice = taxEntity.calcTaxPrice(price);
    console.log(`税込価格:${taxPrice}`);
});

一番のコアであるEntityが分離されました.

image.png

UseCaseを分離したコード

今度はUseCaseを分離します.
UseCaseは「アプリケーション固有のビジネスルール」が含まれます.この層がEntityをカプセル化し,ビジネスルールを実行するための役割を担います.
ここではシンプルなラッパーにしか見えないですが,「データを保存する」といった層はこのあたりが担うようです.
また,デバイスや外部とのつながりを薄めるために,TaxCalculateUseCaseInputDataとTaxCalculateUseCaseOutputDataというデータ構造を作っています.

clean03.ts
import * as readline from 'readline';

class TaxEntity {
    private readonly taxRate: number = 0.08;

    public calcTaxPrice(price: number): number {
        const p = this.taxRate + 1;

        return Math.round(price * p);
    }
} 

class TaxCalculateUseCaseInputData {
    public readonly price: number;

    constructor(price: number) {
        this.price = price;
    }
} 

class TaxCalculateUseCaseOutputData {
    public readonly price: number;

    constructor(price: number) {
        this.price = price;
    }
}

interface ITaxCalculateUseCase {
    calcTaxPrice(inputData: TaxCalculateUseCaseInputData): TaxCalculateUseCaseOutputData;
} 

class TaxCalculateUseCase implements ITaxCalculateUseCase {
    public calcTaxPrice(inputData: TaxCalculateUseCaseInputData): TaxCalculateUseCaseOutputData {
        const taxEntity = new TaxEntity();
        const price = inputData.price;
        const taxPrice = taxEntity.calcTaxPrice(price);

        return new TaxCalculateUseCaseOutputData(taxPrice);
    }
} 

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

rl.on('line', (input: string) => {
    const useCase = new TaxCalculateUseCase();
    const price = Number(input);
    const inputData = new TaxCalculateUseCaseInputData(price);
    const outputData = useCase.calcTaxPrice(inputData);
    const taxPrice = outputData.price;
    console.log(`税込価格:${taxPrice}`);
});

image.png

MVCに分離したコード

最後にUI周りを抽象化します.
この節で実装する層は,インターフェースアダプターと呼ばれ,「ユースケースやエンティティに便利なフォーマットから,データベースやウェブなどの外部エージェントに便利なフォーマットにデータを変換する」役割を持っているそうです.

clean04-mvc.ts
import * as readline from 'readline';

class TaxEntity {
    private readonly taxRate: number = 0.08;

    public calcTaxPrice(price: number): number {
        const p = this.taxRate + 1;

        return Math.round(price * p);
    }
}  

class TaxCalculateUseCaseInputData {
    public readonly price: number;

    constructor(price: number) {
        this.price = price;
    }
}  

class TaxCalculateUseCaseOutputData {
    public readonly price: number;

    constructor(price: number) {
        this.price = price;
    }
}  

interface ITaxCalculateUseCase {
    calcTaxPrice(inputData: TaxCalculateUseCaseInputData): TaxCalculateUseCaseOutputData;
}  

class TaxCalculateUseCase implements ITaxCalculateUseCase {
    public calcTaxPrice(inputData: TaxCalculateUseCaseInputData): TaxCalculateUseCaseOutputData {
        const taxEntity = new TaxEntity();
        const price = inputData.price;
        const taxPrice = taxEntity.calcTaxPrice(price);

        return new TaxCalculateUseCaseOutputData(taxPrice);
    }
}   

interface ITaxCalculateView {
    display(outputData: TaxCalculateUseCaseOutputData): void;
}   

class TaxCalculateView implements ITaxCalculateView {
    public display(outputData: TaxCalculateUseCaseOutputData): void {
        const taxPrice = outputData.price;
        console.log(`税込価格:${taxPrice}`);
    }
}

class TaxCalculateController {
    private readonly useCase: ITaxCalculateUseCase;
    private readonly view: ITaxCalculateView;

    public constructor(useCase: ITaxCalculateUseCase, view: ITaxCalculateView) {
        this.useCase = useCase;
        this.view = view;
    }

    public calcTaxPrice(priceText: string): void {
        const price = Number(priceText);
        const inputData = new TaxCalculateUseCaseInputData(price);
        const outputData = this.useCase.calcTaxPrice(inputData);
        this.view.display(outputData);
    }
}   

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
}); 

rl.on('line', (input: string) => {
    const useCase = new TaxCalculateUseCase();
    const view = new TaxCalculateView();
    const controller = new TaxCalculateController(useCase, view);
    controller.calcTaxPrice(input);
}); 

これで一通りクリーンアーキテクチャになったと思います.

image.png

コード量の遷移

まず初めに自分が一番気になったことを調べます.それは,ソースコードの量です.
クリーンアーキテクチャは「委譲」と「データ構造の変換」が多いアーキテクトだと感じました.そのため,かなりソースコードの量が増えるんじゃないか?という予測がありました.そこで,それぞれのリファクタリング段階でのソースコードの行数をグラフ化してみました.

image.png

もともと12行ぐらいだったソースコードが最終的には81行まで増えています.こう見るとコード量が増えて,クリーンアーキテクチャは良くない.といった印象があるかもしれません.
ただきちんと議論するのであれば,クリーンアーキテクチャが目指している,「フレームワーク非依存」「テスト可能」「UI非依存」「データベース非依存」「外部エージェント非依存」の観点から議論すべきだと思います.ただここでざっくりとした感想だけ書くと,「テスト可能」っぽいな.という設計になっていると思います.しかし,一方で行数がかさむのでプロトタイピングのような,脳内の構想をだらだらと垂れ流す,ざっくりとしたモノづくりには辛い書き方だな.という印象です.

実装の困った点

これは完全に個人的な感想になってしまいますが,

「MVCに分離」

が一番難しかったです.というのも,最初この図を当てにして組んでいました.

image.png

さて,「ControllerとPresenterとViewModelとViewとはなんだ?」という疑問がありました.私自身,それらを知ってはいますが,これが同時に存在する概念が今一つよくわかりませんでした.「MVC」なら「ModelViewController」,「MVP」なら「ModelViewPresenter」で,「MVVM」なら「ModelViewViewModel」だけれども,これは何を表しているんだ・・?となってしまいました.実際は本文を熟読すれば分かります.
ただこのあたり微妙に迷うポイントもあって,書籍ではUseCaseInteractorがPresenterを呼んでいるように見えます.表示という行為はUseCaseの責任範囲にあるのかな?と.実際このあたりも記述があり,

たとえば,ユースケースからプレゼンターを呼び出す必要があるとしよう.依存性のルールに違反するため,直接呼び出すことはできない.円の外側にある名前は,円の内側から触れることはできないからだ.

となっています.これをどう解釈するかにもよるのですが,「私はユースケースからプレゼンターを呼び出したいことがある」というぐらいで,「ユースケースからプレゼンターを読みだすことは必須ではない」と読み替え,割と古典的なMVCに落としました.そのためViewの呼び出し責任はControllerにあり,Viewで表示用の加工処理と表示をさせています.

感想

 クリーンアーキテクチャの本を読んで思ったことは,「歴史は繰り返す.」ということでしょうか.筆者の仕事の歴史が書いてあり,その時代の話を見ると,コンピューターが激しく変化していった時期なのだな.と感じました.その歴史から見ると,少し前までサーバー周りの技術は安定している印象を受けました.しかし,近年になって,当たり前のようにクラウドネイティブだったり,PaaSやCaaSが出ては潰れ,改修され,CI/CDで1日に三桁回リリースされ,そしてそれらが技術負債を抱えながらつなぎこまれる.という世の中になっていて,激動の時代を歩んでいるように思います.今ではDockerを知っていてもdotcloudを知っている人は少ない!!その中で,過去にあった激しい変化の中で積み上げられてきたアーキテクチャは,今の世の中でも役に立つのかな.と思っています.
 他にも「サンプルコードが難しい」という問題があります.上記したコードも,Qiitaに書くには少し大きい印象があります.これでも,Gatewayに関して記述していない段階で,まだ書き足りない部分もあって残念です.
 最後に,書いてみることは大事だな.と思いました.やはり設計したって実装が決まらないことと同様で読んでみても実装できることは違うな.と再度痛感しました.

参考