JavaScript
vue.js
reactjs
React
CleanArchitecture

JavaScriptでClean Architectureを導入してみた - Vue.js・Reactのサンプルつき

事の発端

始まりはこちらのツイートから。

それはどういうことだよ・・・
フロントでどう使うんだ・・・?
と疑問に思い、自分なりに検証・実装してみたいと思ったのが事の発端です。

Clean Architectureとは?

まず根本の理解がほぼなかったので調べることにしました。

こことか
https://qiita.com/koutalou/items/07a4f9cf51a2d13e4cdc

こことか
https://blog.tai2.net/the_clean_architecture.html

こことか
https://qiita.com/Tueno@github/items/705360b357c2a00c9532

こことか
https://github.com/apavamontri/nodejs-clean

こことか
http://techblog.reraku.co.jp/entry/2017/08/08/184313
https://www.youtube.com/watch?v=fWVE1fH0MVE
https://github.com/Shinpeim/NekogataDrumSequencer

こことか
http://azu.github.io/slide/2016/child_process_sushi/almin-javascript-architecture.html
https://github.com/almin/almin
https://github.com/azu/large-scale-javascript

いろいろ漁り弄りました。
がしかし、なんだかふわっとした、雲を掴んでいるような感覚で、
理解には及びませんでした。。
(記載させていただいたページはどれもわかりやすく書かれていると思います!)

これは実装・検証が必要だ。。。

全体の構造

全体の構造は
https://github.com/almin/almin
examplesをめちゃくちゃ参考にさせていただきました。

プログラム全体を以下のリストに分割して考えることにします。
※ かなり個人的な見解を含むと思います。

  • Domain
    • プレーンなClassで、扱いたい ”もの” をわかりやすいプロパティとメソッドで書く
    • 可能な限りライブラリ等は使わない
  • Infrastructure
    • 環境依存なものを書く
    • APIなど外部との連携を書く
    • Repositoryから呼び出される
  • Repository
    • インスタンス化されたDomainを永続化させる(保存先はInfrastructureを使ってサーバだったりメモリだったり)
    • シングルトン
    • Infrastructureの存在を知っている
  • UseCase
    • Presenterから呼び出される
    • ちょっとControllerっぽい動き(極力処理の流れのみを書く感じ)
    • RepositoryからDomainを受け取ってDomainのメソッドを使ったり
  • Presenter
    • いわゆるUI実装(スタイル等も込み)
    • 本記事だとVue・React

本記事の目標

本記事では、Clean Architectureを用いて、Presenterの入れ替え(つまりVueとReactを入れ替え)を行い、Presenterがビジネスロジックに依存しないで実装できかの検証とClean Architectureがフロントエンドでどんな実装になるのかを考えます。
個人的にはClean Architectureを理解することも目標としています。

何を作ったか

できる限り簡素な実装を行いたいので、ボタンをクリックしたら値が増減するだけのカウンターをサンプルに実装します。
増減した値はローカルストレージに保存して永続化します。

書いたコードは全てCodePenで公開します。

↓できたもの

Presenterでは「Domain / Infrastructure / Repository / UseCase」をExternal Penとして追加し、
Vue.jsとReactで同じものを利用しています。

↓使用ライブラリ等

Domainの実装

まずは、アプリケーションの基盤となるDomainを実装します。
今回はカウンターをプレーンはClassで表してみました。

  • Domain
    • プレーンなClassで、扱いたい ”もの” をわかりやすいプロパティとメソッドで書く
    • 可能な限りライブラリ等は使わない
class Counter {
  /**
   * @param {Number} count
   */
  constructor(count = 0) {
    this.count = count;
  }

  countUp() {
    this.count++;
    return this._update(this);
  }

  countDown() {
    this.count--;
    return this._update(this);
  }

  /**
   * 更新した新しいインスタンスを返す
   * @param {Counter} updated
   */
  _update(updated) {
    return new Counter(updated.count);
  }
}
  • countというプロパティを持つ
  • countUpというcountを1足すメソッドを持つ
  • countDownというcountを1引くメソッドを持つ
  • _updateという新しいインスタンスを返すメソッドを持つ

こう見ると非常にシンプルなClassが出来上がりました。
またライブラリ等は使用していないプレーンな状態となりました。

ついでにですが、このCounterDomainをインスタンス化するにあたり、
Factoryも作りました。

class CounterFactory {
  static createFromLocalStorage(counter) {
    let count = 0;
    if (!counter) {
      return new Counter(count);      
    }

    if (typeof counter.count !== 'number') {
      count = 0;
    } else {
      count = counter.count;
    }
    return new Counter(count);      
  }
}

例えばですが、DomainをJSONから作る必要がある場合など、
Factoryからインスタンスを生成するようにすれば、
Domain内でデータ変換等をする必要がなくなります。
(→ つまりDomainではJSONの仕様を知る必要がなくなる。 = 依存しない)

今回はローカルストレージにデータを保存しようと思うので、
ローカルストレージのデータからCounterDomainを作るメソッドを作成しました。

Infrastructureの実装

次に環境依存なInfrastructureを実装しました。
今回はローカルストレージへの書き込み、読み出しの実装となります。

  • Infrastructure
    • 環境依存なものを書く
    • APIなど外部との連携を書く
    • Repositoryから呼び出される
class LocalStorage {
  /**
   * ローカストレージから値を取得する
   * @param {String} name
   * @return {Promise}
   */
  get (name) {
    return localforage.getItem(name);
  }

  /**
   * ローカストレージへ値を入れる
   * @param {String} name
   * @param {*} value
   * @return {Promise}
   */
  set (name, value) {
    return localforage.setItem(name, value);
  }
}

ここではlocalforageに依存して実装を行っている事がわかります。
これは、Web APIだったりDOM APIだったりしても同じようにInfrastructureに実装します。

Repositoryの実装

続いてはRepositoryの実装です。

  • Repository
    • インスタンス化されたDomainを永続化させる(保存先はInfrastructureを使ってサーバだったりメモリだったり)
    • シングルトン
    • Infrastructureの存在を知っている
const REPOSITORY_CHANGE = 'REPOSITORY_CHANGE'; // Repository内部のemit名
class CounterRepository extends EventEmitter {
  /**
   * @param {Map} memory
   * @param {LocalStorage} localStorage
   */
  constructor(memory = new Map(), localStorage = new LocalStorage()) {
    super();
    this._memory = memory;
    this._localStorage = localStorage;
  }

  /**
   * ローカルストレージに格納されている
   * カウンターを取り出す
   */
  async getLocalStorageData () {
    return await this._localStorage.get('counter');
  }

  /**
   * メモリ上に永続化されたカウンタードメインを返す
   */
  getCounter () {
    return this._memory.get('counter')
  }

  /**
   * カウンタードメイン永続化
   * @param {Counter} counter
   */
  save (counter) {
    this._memory.set('counter', counter);
    this._localStorage.set('counter', counter);
    this.emit(REPOSITORY_CHANGE, counter);
  }

  /**
   * emitterへハンドラ設定
   * @param {Function} handler
   */
  onChange(handler) {
    this.on(REPOSITORY_CHANGE, handler);
  }
}

// リポシトリーのシングルトン
const counterRepository = new CounterRepository();

CounterRepositoryではメモリ上に永続化させるためのMapと、
先ほどInfrastructureで作成したLocalStorageを引数に、
EventEmitterを親クラスとしたシングルトンとします。

今回の実装はとても簡単なので問題にならないですが、
複雑になってくると、UseCaseとRepositoryどっちに実装しよう。。。と迷うことがありそうです。
RepositoryはInfrastructureへの参照を利用する、保持するという役割以外のことは極力さけるほうが良さそうです。

また、RepositoryはDomainを永続化させる際(saveメソッドが実行されたら)、
自身のemitを実行し、その引数には永続化されたDomainを代入します。

onChangeメソッドはPresenterから更新時のハンドラを登録するために実装してあります。

UseCaseの実装

Presenter以外の実装はこれで最後です。

  • UseCase
    • Presenterから呼び出される
    • ちょっとControllerっぽい動き(極力処理の流れのみを書く感じ)
    • RepositoryからDomainを受け取ってDomainのメソッドを使ったり
// UseCase
class CounterUseCase {
  /**
   * @param {CounterRepository} counterRepository
   */
  constructor(counterRepository) {
    this.counterRepository = counterRepository;
  }

  /**
   * CounterUseCaseを作って返す
   */
  static create() {
    return new CounterUseCase(counterRepository);
  }

  /**
   * ローカルストレージから値を取得して、初期化する
   * 1.ローカルストレージから値を取得する
   * 2.取得した値を使ってドメインを作成する
   * 3.作成したドメインを永続化する
   */
  async initialize() {
    const value = await this.counterRepository.getLocalStorageData();
    const counter = CounterFactory.createFromLocalStorage(value);
    this.counterRepository.save(counter);
  }

  /**
   * 値を一つ足す
   * 1.Repositoryから永続化されたドメインを取得する
   * 2.ドメインのcountUpメソッドを呼び出し、更新したドメインを取得する
   * 3.更新したドメインを永続化する
   */
  countUp() {
    let counter = this.counterRepository.getCounter()
    counter = counter.countUp();
    this.counterRepository.save(counter);
  }

  /**
   * 値を一つ引く
   * 1.Repositoryから永続化されたドメインを取得する
   * 2.ドメインのcountDownメソッドを呼び出し、更新したドメインを取得する
   * 3.更新したドメインを永続化する
   */
  countDown() {
    let counter = this.counterRepository.getCounter()
    counter = counter.countDown();
    this.counterRepository.save(counter);
  }
}

UseCaseではRepositoryから永続化されたDomainのインスタンスを受け取り、
そのインスタンスやRepositoryを使った処理の流れ自体を記述していきます。

UseCaseは実際に必要な時のみ存在するよう、
static createメソッドで自身のインスタンスを返す実装にしました。

Domain / Infrastructure / Repository / UseCaseまでの実装

Presenter (Vue.js)

さてここからがUI部分の実装に入ります。
まずはVue.jsから。

const VuePresenter = Vue.component('VuePresenter', {
  template: `
<div>
  <p>カウント:{{ count }}</p>
  <button @click="onClickCountUp">countUp</button>
  <button @click="onClickCountDown">countDown</button>
</div>
`,
  data () {
    return {
      count: null
    }
  },
  beforeCreate() {
    // Domainを初期化
    CounterUseCase.create().initialize();
  },
  created() {
    // Repositoryを購読
    counterRepository.onChange(counter => {
      this.count = counter.count;
    })
  },
  methods: {
    onClickCountUp() {
      CounterUseCase.create().countUp();
    },
    onClickCountDown() {
      CounterUseCase.create().countDown();
    }
  }
})

// render
new Vue({
  el: '#vue-app',
  template: '<VuePresenter />'
})

beforeCreatedでまずDomainを初期化します。
ここで重要なのはPresenter側では処理の流れ自体は何も知らないということです。

createdではRepositoryの購読を行います。
onChangeのコールバックでは、引数で渡されるDomainを使って、
ローカルデータの更新を行います。
※ PresenterがDomainの実装を知っている状態になるので、本当は一つ経由するもの(Presenterが使用するデータの形に変換するもの)を用意したほうがよいかもしれません。

methodsでは各ボタンが押された際の、UseCaseの呼び出しを行います。

Presenter (React)

次にReactで実装してみました。

class ReactPresenter extends React.Component {
  constructor() {
    super();
    this.state = {
      count: null
    };
  }

  componentWillMount() {
    // Domainを初期化
    CounterUseCase.create().initialize();

    // Repositoryを購読
    counterRepository.onChange(counter => {
      this.setState({
        count: counter.count
      })
    })
  }

  onClickCountUp() {
    CounterUseCase.create().countUp();
  }

  onClickCountDown() {
    CounterUseCase.create().countDown();
  }

  render () {
    return (
      <div>
        <p>カウント:{ this.state.count }</p>
        <button onClick={() => this.onClickCountUp()}>countUp</button>
        <button onClick={() => this.onClickCountDown()}>countDown</button>
      </div>
    )
  }
}

// render
ReactDOM.render(
  <ReactPresenter />,
  document.getElementById('react-app')
);

やっていることはVue.jsのときと一緒です。
componentWillMountでDomainの初期化とRepositoryの購読を行っています。

まとめ・思ったこと

ひとまずは、Presenterの入れ替え(Vue.jsとReact)を行うことはできました。
これの意味するところはPresenterの修正はビジネスロジック部分へ全く影響を及ぼさないということになるかと思います。

ただ、まだこの実装がClean Architectureといえるのか・・・?ふわっとした感覚で、実感はない状態です。
(依存関係は外側に向かっている用に見えるけど・・・うーーん。。。)

もっと複雑な実装を行った時どうなるんだろう・・・とまだまだわからないことだらけです。
みなさんのご意見・ご指摘お待ちしております!!!