Redux + immutable.js
- Record -

immutable.js Advent Calendar 2017 2日目の投稿です。「Redux に immutable.js を導入すると良いらしいが、まだ利用したことがない」という方向けの内容になります。「Reducer の Object.assign が手短に書ける便利ライブラリ」と認識されている方もいるかもしれません。immutable.Record は、immutable な collection を保持する Object に関数を付与することができ、モデルとして利用することが可能です。

React状態管理の定番感が強い Redux は、その他にも多くの Viewライブラリでバインディングがあります。Viewフレームワーク非使用のページでも、Reduxを導入する価値は大きいです。しかし通常の ReduxStore の中身は巨大な json に過ぎず、MVVMアーキテクチャを備えたフレームワークと比較すると非力であることは否めません。そこに immutable.Record を導入することで、Store はドメインモデルと呼ぶに相応しい威力を発揮します。

なぜドメインモデルが必要か?

書籍と本棚を例に、モデリングの考察を少し紹介します。以下が「書籍」モデルです。タイトル・出版日・著者・カテゴリーなどを、コレクションとして保持しています。

book.js
// @flow

import { Record } from 'immutable'

const collection: Collection = {
 title: '- Redux + immutable.js -',
 published_date: new Date('2017-12-02'),
 author: 'takefumi yoshii',
 category: 'ENGINEERING'
}

export class Book extends Record(collection) {
 getTitle (): string {
   return this.get('title')
 }
 getPublishedDate (): Date {
   return this.get('published_date')
 }
 getAuthor (): string {
   return this.get('author')
 }
 getCategory (): string {
   return this.get('category')
 }
}

次に本棚です。配列に books を持ちます。
この状態ではただの入れ物にすぎないため、普通のReduxStoreと差異がありません。

books.js
// @flow

import { Record, List } from 'immutable'
import { Book } from './book'

const collection: Collection = {
 books: List()
}

export class Books extends Record(collection) {
 constructor (args: *): Books {
   super(args)
   return this.set('books', List([new Book()]))
 }
 getBooks (): List<Book> {
   return this.get('books')
 }
}

ここに「著者が一致する書籍を抽出する」「カテゴリーが一致する書籍を抽出する」などのフィルタリング関数を生やしてみます。

books.js
// @flow

import { Record, List } from 'immutable'
import { Book } from './book'

const collection: Collection = {
 books: List()
}

export class Books extends Record(collection) {
 getBooks (): List<Book> {
   return this.get('books')
 }
 getBooksByAuthor (author: string): List<Book> {
   return this.getBooks()
     .filter((book: Book) => book.getAuthor() === author)
 }
 getBooksByCategory (category: string): List<Book> {
   return this.getBooks()
     .filter((book: Book) => book.getCategory() === category)
 }
}

フィルターを複数適用する場合は下記の様なコードになります。この様な書き方をしても元の「books」は破壊されずにフィルタリングされます。immutableならではの強みです。

books.js
// @flow

export class Books extends Record(collection) {
 ...
 getDisplayBooks (): List<Book> {
   // 別オブジェクトとして model に代入
   let model = this
   // カテゴリーのフィルタがかけられた books に変更され、新たに代入
   model = model.set('books', model.getBooksByCategory('ENGINEERING'))
   // 更に著者のフィルタがかけられる
   model = model.set('books', model.getBooksByAuthor('takefumi yoshii'))
   return model.getBooks()
 }
}

モデルを利用する component は以下の様なものになります。どれだけ検索条件が複雑になっても、この component の責務はフィルタリングされた書籍を表示するだけにすぎないため、保守・運用しやすいコードを保てます。

booksComponent.jsx
export function BooksComponent (props) {
 const { model } = props
 return (
   <div className="books">
     {(() => {
       model.getDisplayBooks().map((book, index) => {
         return (
           <li key={index}>
             <p>{ book.getTitle() }</p>
             <p>{ book.getAuthor() }</p>
             <p>{ book.getCategory() }</p>
           </li>
         )
       })
     })()}
   </div>
 )
}

ReduxにRecordモデルを採用する6つの理由

  • 業務処理の一元管理
  • OOP・抽象モデルの責務分離
  • モデルのネスト
  • 型の恩恵が受けられる
  • ボライープレートをコードジェネレータで生成
  • モデル駆動・ドメイン駆動設計の採用

業務処理の一元管理

フィルタリングの例では booksComponent.jsx に一切手を入れずに実装することが出来ました。

業務処理とは、機械的で誰が実装しても代わり映えのしないものです。先の様な配列処理を通常の Redux で行う場合、View上で分岐を書いたり、Reducerで加工したりと、配列とは疎遠な場所に処理を持たなければいけません。データと処理が近ければ近いほど、見通しの良いコードとなり、保守しやすくなります。通常のReduxにおいては、ReactComponent や ActionCreator、Reducerがすぐに混沌としてしまいます。

モデルが表現豊かになれば、Viewライブラリを問いません。不動のUseCaseが適用されているViewの責務は明らかで「ユーザー操作を受け付ける、状態を反映する」これだけです。Viewは限界まで薄くなり、素のhtmlでさえも、Reactiveに反応する事が出来ます。これは来たるべき次世代Viewライブラリのパラダイムシフトに耐えうる、という意味でもあります。

OOP・抽象モデルの責務分離

immutable.Record は OOP が可能で、OOP ならではの利点を多く取り入れることができます。先の「業務処理」は単純です。ここに、アプリケーションの機能に応じた「要件・仕様」が加わります。

まずは既存の getDisplayBooksgetFilteredBooks に関数名を変更。classを、継承できる様に抽象Classファクトリー関数に変更します。

superBooks.js
function SuperBooksFactory<T: Object | P> (arg: ?T): Class<RI<T> | *> {
 return class extends Record(collection(arg)) {
   ...
   getFilteredBooks (): List<Book> {
     let model = this
     model = model.set('books', model.getBooksByCategory('ENGINEERING'))
     model = model.set('books', model.getBooksByAuthor('takefumi yoshii'))
     return model.getBooks()
   }
 }
}

「出版日が未来の場合表示させたくない」というはビジネスロジックを追加します。

subBooks.js
function SubBooksFactory<T: Object | P> (arg: ?T): Class<RI<T> | *> {
 return class extends SuperBooksFactory(collection(arg)) {
   getPublishedBooks (): List<Book> {
     const todayTime = new Date().getTime()
     return this.getBooks()
       .filter((book: Book) => {
         const dateTime = book.getPublishedDate().getTime()
         return todayTime <= dateTime
       })
   }
   getDisplayBooks (): List<Book> {
     let model = this.set('books', this.getFilteredBooks()) // 抽象クラスメソッドの呼び出し
     model = model.set('books', model.getPublishedBooks()) // ビジネスロジック
     return model.getBooks()
   }
   hasDisplayBooks (): boolean {
     return this.getDisplayBooks().size > 0
   }
   hasPagenation (): boolean {
     return this.getDisplayBooks().size > 10
   }
 }
}

「業務処理」「要件・仕様」これらを分離することで、その層が担う役割を明確にすることができます。さらには「表示上の関心ごと」を振り分けた層も必要に応じて分離できます。hasPaginationがそれに該当し、いわゆる「キメの問題」が表出化します。

モデルのネスト

「書籍」モデルはコレクションを持つだけのものでしたが、こちらもメソッドを持てます。著者・タイトルから、検索文字列が含まれているかどうかの判定持たせてみます。

book.js
export class Book extends Record(collection) {
 ...
 hasWord (word: string): boolean {
   return this.getTitle().indexOf(word)    !== -1 ||
          this.getAuthor().indexOf(word)   !== -1 ||
          this.getCategory().indexOf(word) !== -1
 }
}

「本棚」モデルは今までと同じ様に、書籍モデルのコレクションにアクセスしている様な感覚で、フィルタリングの条件を追加しましょう。わずかなコレクションから、多様なパターンの配列を取得できます。

superBooks.js
function SuperBooksFactory<T: Object | P> (arg: ?T): Class<RI<T> | *> {
 return class extends Record(collection(arg)) {
   ...
   getBooksByAuthor (author: string): List<Book> {
     return this.getBooks()
       .filter((book: Book) => book.getAuthor() === author)
   }
   getBooksByCategory (category: string): List<Book> {
     return this.getBooks()
       .filter((book: Book) => book.getCategory() === category)
   }
   getBooksByHasWord (word: string): List<Book> {
     return this.getBooks()
       .filter((book: Book) => book.hasWord(word))
   }
 }
}

型の恩恵が受けられる

OOPならではの強みとして、型が活きてきます。最低限のプリミティブ型を付与するだけでも安全で読みやすいコードになりますが、抽象クラスメソッドを推論出来るため複雑な実装にも耐えることが出来ます。

直近の v4.0.0-rc.9 では type-definitions も改良され、かなり堅牢なコードが書ける様になっています。ただし、通常のClass定義と異なる実装をしなければならず、少し工夫が必要になります。

まだ課題点がいくつかありますが、先日この点について投稿をしていますのでご参考になれば幸いです。

ボライープレートをコードジェネレータで生成

Redux にはボイラープレート(ActionTypes, ActionCreators, Reducer)がつきものです。これらのコードを順繰り書いている時、今一体何がしたいのか、意識が飛んだことはありませんかw? Action はつまるところ「状態を変更するコマンド」です。このコマンドは、immutable.Record に丸ごと委譲するが出来ます。

redux.js
const { types, creators, reducer } = createReduxBoilerPlate([
 'increment',
 'decrement'
], '/path/to/identity/')

先日の投稿でこのテクニックを述べているので、興味のあるかたはご一読いただければと思います。

モデル駆動・ドメイン駆動設計の採用

抽象クラスと継承を用いることで、モデル駆動型アーキテクチャやDDDヘキサゴナルアーキテクチャを採用し易くなります。ただし、Redux + immutable.js に加え、redux-saga や rx などの長期プロセスを構える必要があります。

SPAの様に複数エンドポイントで取得した値を再利用したり、GUIアプリケーションの様な凝った機能を導入しているWebサービスで、このアーキテクチャが生きてきます。採用ハードルはそれなりに高いですが、どこまでも複雑さに耐えられるスケーラビリティがありますので、是非 Redux + immutable.Record で挑戦してみてください。