Almin 0.12をリリースしました。

StoreStoreGroupが書き直されたので色々変わっていますが、マイグレーションツールでアップデートできます。

変更点

StoreGroupを書き直した

Before:

class AStore extends Store {
    getState() {
        return {a : "a value"};
    }
}
class BStore extends Store {
    getState() {
        return {b : "b value"};
    }
}
const aStore = new AStore();
const bStore = new BStore();
const storeGroup = new StoreGroup(
    aStore,
    bStore
]);
console.log(storeGroup.getState());
// { a: "a value", b: "b value" }

After:

Almin 0.12 では次のようにStoreGroupのコンストラクタに

{
 "ステート名": store
}

という組み合わせで定義を渡すようになりました。 代わりにStore#getStateでステート名を返さなくても良くなっています。

ReduxのcombineReducersと似たような感じです(変更してから似てるのに気づいた)

class AStore extends Store {
    getState() {
        return "a value";
    }
}
class BStore extends Store {
    getState() {
        return "b value";
    }
}
const aStore = new AStore();
const bStore = new BStore();
const storeGroup = new StoreGroup({
    // stateName: store
    a: aStore,
    b: bStore
});
console.log(storeGroup.getState());
// { a: "a value", b: "b value" }

古いStoreGroupの実装を使いたい場合はlegacy-store-groupにあるのでモジュールとして利用できます。

この変更の主な目的は次の2つです。

  • Store#getStateを単純にStateを返すように
  • 型の整合がとれたStoreGroup

次のようにcontext.getState()した結果が、最初にコンストラクタで渡したStoreStateの関係でマッピングされて自動的に型付けされたstateのオブジェクトを返すようになっています。

// Store <-> Stateの関係を定義
interface AState {
    a: number
}
class AStore extends Store<AState> {
    state: AState;
    // ..
    getState() {
        return this.state;
    }
}
// ...
// StoreGroup
const storeGroup = new StoreGroup({
    aState: new AStore()
});
// Context
const context = new Context({
    dispatcher,
    store: storeGroup
});
// get state
const state = context.getState();
console.log(state.unknown); // <= 知らないstateを参照してる

storegroup

ちゃんとTypeScriptでコンパイルエラーになる。

Store#receivePayload(payload: Payload)の追加

StoreにreceivePayload(payload: Payload)というメソッドを実装するとAlminのライフサイクルにおいて、それが呼びされるようになりました。 ReactのcomponentWillReceiveProps(nextProps)と役割が似ています。

これを導入した経緯は以下のIssueにも書いているのですが、クライアントサイドでCQRSをちゃんとやろうとしたのがAlminの開発目的の一つなので、その目的を再確認した結果として生まれました。

Alminの最初の目的として、writeとreadの掛け算の複雑さを一つのモデルに持たせるのはやめようというところから始まっています。 1つのモデルですべてをやるのではなく、writeとreadの2つのモデルに分けることでこの複雑さの掛け算をなくす目的です。


複雑なJavaScriptアプリケーションを考えながら作る話より

slide 1

slide 2


この考え方で、Domain(いわゆるロジックを持つモデル/Write model)とStore(ViewModelのようなViewのためのモデル/Read Model)の2種類にわけることで、ビジネスロジックはDomainに集中することができました。 またViewはどのようなUIを目指すかによって、色々な細かいStateがでてきます。

しかし、Viewを意識したコードはStore/Stateで吸収できるので、Domain modelが変にUIを意識して書くケースをかなり減らせていたと思います。

先ほどのスライドでいうと、DomainはWrite層で、StoreはRead層という分離ができていました。

しかし、書いていくとStore側はRead層であるにもかかわらず、次のような書き込む処理が出てきています。(this.onDispatchでイベントを受け取って、Storeが保持するstateを更新する処理)

import { Store } from "almin";
import CounterState from "./CounterState";
export class CounterStore extends Store {
    constructor() {
        super();
        // initial state
        this.state = new CounterState({
            count: 0
        });
        // receive event from UseCase, then update state
        this.onDispatch(payload => {
            const newState = this.state.reduce(payload);
            this.setState(newState);
        });
    }

    // return own state
    getState() {
        return this.state;
    }
}

このRead層にも書き込み処理があるのが気持ち悪いなーと思いました。 しかし、Write層で作ったものをRead層に渡す方法(タイミング)がないとViewに反映されないので、Read層のどこかでStateを更新する必要があるのは明白です。

これについて考えていて、CQRS JourneyというCQRSについて書かれた文書中に次のような図がでてきます。

image

この図ではOrderViewModelGeneratorというRead層にあるものが、Read層のRepositoryにデータを書き込んでいる様子が見えます。 (Write modelからドメインイベントを受け取り、それをOrderViewModelGeneratorがRead modelに変換して保存するという流れ)

Almin 0.11まではこのOrderViewModelGeneratorにあたるような、Read層のデータを更新する要素が明示的には存在していませんでした。 そのため、次のようにRead層でWrite層からくるイベントを受け取って更新するという処理を各Storeに書いたりしていました。

        // receive event from UseCase, then update state
        this.onDispatch(payload => {
            const newState = this.state.reduce(payload);
            this.setState(newState);
        });

この「Write層からくるイベントを受け取り、Read層を更新する」を明示的なStoreのライフサイクルとして取り入れようとしてできたのが、Store#receivePayload(payload: Payload)です。

Store#receivePayload(payload: Payload)はWrite層からイベント発火やAlminのライフサイクルで呼び出されるため、先ほどのコードは次のように書くことができます。

 import { Store } from "almin";
 import CounterState from "./CounterState";
 export class CounterStore extends Store {
     constructor() {
         super();
         // initial state
         this.state = new CounterState({
             count: 0
         });
-        // receive event from UseCase, then update state
-        this.onDispatch(payload => {
-            const newState = this.state.reduce(payload);
-            if (newState !== this.state) {
-                this.state = newState;
-                this.emitChange();
-            }
-        });
     }
 
+    // receive event from UseCase, then update state
+    receivePayload(payload) {
+        this.setState(this.state.reduce(payload));
+    }
+
+    // return own state
     getState() {
        return this.state;
     }
 }

単純に言えば、Storeのstateを更新する処理はreceivePayloadに書けばいいという形です。 (ReactでcomponentWillReceiveProps(nextProps)の中でsetStateが許可されているのと同じです)

import { Store } from "almin";
import CounterState from "./CounterState";
export class CounterStore extends Store {
    constructor() {
        super();
        // initial state
        this.state = new CounterState({
            count: 0
        });
    }

    // receive event from UseCase, then update state
    receivePayload(payload) {
        this.setState(this.state.reduce(payload));
    }

    // return own state
    getState() {
        return this.state;
    }
}

Almin 0.12でも今までのように自分でイベントを監視して更新する方法も動きます。 しかし、Store#receivePayload(payload: Payload)を使ったほうが将来的な最適化の余地が生まれるのでこちらを推奨しています。 (簡単にいうと、自分で監視したイベントと違いStore#receivePayload(payload: Payload)はAlminのライフサイクル管理下にあるので、うまいこと処理をできる可能性があるということ)

次のTodoMVCのサンプルでもその説明をしています。

Read側のState更新アプローチ

(次の前提の)Read側のState更新処理のアプローチ多く分けて2つあると思います。

前提として、Read側のStateというのはWrite側のドメインモデルやデータベースをソースにして、View向けのデータしたものという扱いです(つまりWrite -> Readという順序と関係がある)

そのため、Read側のStateは元となるソースから計算した値を持つComputed propertyのような形になってます。

Mobxのmobx-state-treeというライブラリについて紹介してるThe Quest For Immer Mutable State Managementというスライドは、その2つの方法についてわかりやすく書かれています。

Pull Based: Recompute every time value is needede

簡単に言えばgetterです。 そのプロパティの値を取得するときに、計算して返すという形です。

class Person {
    firstName = "Michel"
    lastName = "Weststrate"
    get fullName() {
        console.log("calculating!")
        return [this.firstName, this.lastName]
    }
}

Push Based: Recompute when a source value changes

もう一つは、ソースとなる値が変化した時に事前に計算結果を作って置くという方法です。 簡単に言えばキャッシュを事前に作っておくイメージです。

この場合はlastNamefirstNameの値が変化した時にfullNameというプロパティを計算して更新するということです。 person.fullNameへアクセスした時はその結果を返すだけです。

class Person {
    @observable firstName = "Michel"
    @observable lastName = "Weststrate"
    @computed get fullName() {
        console.log("calculating!")
        return [this.firstName, this.lastName]
    }
}

このPullとPushの方法 どちらがいいのかはケースバイケースで、プロパティの値を読む回数が多いなら、事前に計算結果が作れるPush Based: Recompute when a source value changesの方がコストが低くなるかもしれません。 逆に、書き込む回数の方が圧倒的に多いなら、Pull Based: Recompute every time value is neededeで実際に読むこむときに遅延評価的に結果を作って返したほうがコストが低いです。 (両方合わせるとかも考えられます)

話を戻して、先ほどのAlminのコードはソースの変更があったことを元に書き込んでいるのでPush Basedなやり方と言えます。

import { Store } from "almin";
import CounterState from "./CounterState";
export class CounterStore extends Store {
    constructor() {
        super();
        // initial state
        this.state = new CounterState({
            count: 0
        });
        // receive event from UseCase, then update state
        this.onDispatch(payload => {
            const newState = this.state.reduce(payload);
            this.setState(newState);
        });
    }

    // return own state
    getState() {
        return this.state;
    }
}

しかし、先ほども書いたように自分でイベントを監視(this.onDispatch)だと、そのStoreがPush Basedで更新する処理があるかが外から(この場合はStoreGroupから)はわかりません。 イベントを監視 !== 更新処理がある ではないからです。

receivePayloadを実装している == 更新処理がある という形になります。(必ずも更新されるわけではないですが、更新を期待していい形になる)

    // receive event from UseCase, then update state
    receivePayload(payload) {
        this.setState(this.state.reduce(payload));
    }

このようなインターフェースが決まると何が嬉しいかというと、次のようなReduxのreducerみたいなインターフェースでStoreを書けるようにしようといったことがやりやすくなります。

import { Store, Payload } from "almin";
export interface State {
    reduce<T extends State>(payload: Payload): T |this;
}
export abstract class Reactor<T extends State> extends Store {
    abstract state: T;
    abstract reduce(prevState: T, payload: Payload): T;

    receivePayload(payload: Payload) {
        const newState = this. reduce(this.state, payload);
        if (this.shouldStateUpdate(this.state, newState)) {
            this.state = newState;
        }
    }

    getState() {
        return this.state;
    }
}
// The user implement this
class MyStore extends Reactor {
    reduce(prevState: T, payload: Payload): T {
        switch(payload.type){
            case "INCREMENT":
                return prevState + 1:
            default:
                return prevState;
        }   
    }
}

簡単にまとめると、まだ曖昧な部分があったStoreをもう少し明確な役割付けしたという話です。

Notes:

receivePayloadという名前がまだしっくりきてないところがある。 なんかもっと広い意味になる可能性がありそうなので、何かもっといい方法があるのかもしれない。

Read層にもRead用のRepositoryやRead用のDatabaseなどを用意すれば、もっとクラス的に分解できるけど、流石にそこまでやるととオーバーキルな感じがしたのでやらなかった。 理由として、Read(View)側のデータを永続的なデータベースに書き込んで保持したいというケースが浮かばなかった(Read側はそこまで複雑な永続データを持ちたくないはず)

UI的に保持したい状態ってIndexedDBじゃなくて、localStorageに保存するぐらいで終わる気がした。

FluxのStoreの話

Flux Store

マイグレーション方法

Almin 0.11から0.12へのアップグレードは次のマイグレーションツールが利用できます。

jscodeshiftというツールで動くマイグレーションスクリプトを用意しています。

# Installation
npm install -g jscodeshift @almin/migration-tools 
# 1. Storeクラスを一括で変換
jscodeshift --run-in-band -t `npm root -g`/@almin/migration-tools/scripts/store-get-state-return-object-to-flat.js src/store/*/**/*Store.js
# 2. 1.の変換結果を使ってStoreGroupを変換
jscodeshift --run-in-band -t `npm root -g`/@almin/migration-tools/scripts/store-group-arguments.js src/store/AppStore.js
# store-state-mapping.jsonは変換中に使うデータなので消していい
rm store-state-mapping.json

実際にアップデートしてる例は次のPRを見てください。

その他の変更点についてはリリースノートをみてください。

Next

Almin 0.12 + TypeScriptならStoreは、型の整合がとれるように書けるようなったと思います。 (明示的なキャストなどむりやりやらなくても型がちゃんとついてくる)

UseCaseの部分が余計な情報を明示的に渡さないと型チェックが上手くできていません。

type MyUseCaseArgs = string;
class MyUseCase extends UseCase {
    execute(value: MyUseCaseArgs) {
        this.dispatch({
            type: "test",
            value
        });
    }
}
// execute UseCase
const useCase = new MyUseCase();
context.useCase(useCase).execute<MyUseCaseArgs>("value")
  .then(() => {
     // something
  }):

これを次期Alminでは次のように書けるようにしたいと思っています。

class MyUseCase extends UseCase {
    execute(value: string) {
        this.dispatch({
            type: "test",
            value
        });
    }
}
context.useCase(new MyUseCase())
    .executor(useCase => useCase.execute(42)) // <= Error
    .then(() => {
        console.log("test");
    });

この辺で上手くBatch Action的な処理も解決できればなーと思って考えています。

次のIssueとPRで実験してるので何か意見があったらください。 (特にexecutorとかの命名が難しい…)