Redux + immutable.js- Record -
engineering

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(arg) {
  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(arg) {
  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(arg) {
  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 で挑戦してみてください。