Weitere ähnliche Inhalte Ähnlich wie サービス開発における フロントエンド・ドメイン駆動設計の実践 (20) サービス開発における フロントエンド・ドメイン駆動設計の実践6. React + Redux という構成は以前から存在したが、
部分的な機能に閉じており、機能追加で Store 乱立が懸念された。
各エンドポイントで、Single Store を維持しつつ
分割統合を柔軟に行いたい。
リニューアル課題
13. React + Redux + redux-saga + immutable.js
flowtype + jest
Hexagonal Redux モジュール構成
16. `domains` 配下に「イベント・サービス・モデル」==
「Redux + redux-saga + immutable.js」
`views` には React コード
`applications` には エントリーポイント
Hexagonal Redux パッケージ構成
./front/javascripts/
├── applications
├── constants
├── domains
│ ├── achievementQueue
│ ├── articles
│ ├── healthRecords
│ │ ├── stepCounts
│ │ ├── weights
│ │ └── ...
│ ├── recommendedArticles
│ │ ├── model.js
│ │ └── redux.js
│ ├── renderWidget
│ │ ├── model.js
│ │ ├── redux.js
│ │ └── saga.js
│ └── ...
├── helpers
├── lib
└── views
27. ReduxAction をモデルに委譲する higher-order-reducer を生成。
export function createReducer (commands: string[], namespace: string): Function {
return (initialModel: immutable.Record) => {
return (model = initialModel, action: ActionCreator) => {
const fn = action.type.replace(namespace, '')
if (model[fn] !== undefined) return model[fn](action.payload)
return model
}
}
}
抽象化の文脈 - Reduxにおける抽象化とは? -
28. ActionCreators と ActionTypes を生成。
export function createActions (commands: string[], namespace: string) {
const types: ActionTypes = {}
const creators: ActionCreators = {}
commands.map((row: string) => {
const type: ActionType = `${namespace}${row}`
types[row] = type
creators[row] = payload => { return { type, payload } }
})
return { types, creators }
}
抽象化の文脈 - Reduxにおける抽象化とは? -
30. 普段の3種のボイラープレート実装は
状態を変化する「コマンド」の StringArray 宣言のみ、
という明快なものとなった。 (要別途 payload 型定義)
const { types, creators, reducer } = createReduxBoilerplate([
'registerAchievement',
'registerAchievements',
'deleteQueueItemByIndex'
], '/domains/achievementQueue/')
export { types, creators, reducer }
抽象化の文脈 - Reduxにおける抽象化とは? -
名前空間
31. export const abstractCommands = [
'registerHealthRecordsSrc',
'updateHealthRecordsSrc',
'shiftShowRange',
'shiftCurrentDate'
]
const { types, creators, reducer } = createReduxBoilerplate([
...abstractCommands
], '/domains/healthRecords/stepCounts/')
const { types, creators, reducer } = createReduxBoilerplate([
...abstractCommands,
'setEditorCurrentIndex',
'toggleTimingActive'
], '/domains/healthRecords/bloodSugars/')
ReduxAction の抽象コード実体は StringArray。
32. export const abstractCommands = [
'registerHealthRecordsSrc',
'updateHealthRecordsSrc',
'shiftShowRange',
'shiftCurrentDate'
]
const { types, creators, reducer } = createReduxBoilerplate([
...abstractCommands
], '/domains/healthRecords/stepCounts/')
const { types, creators, reducer } = createReduxBoilerplate([
...abstractCommands,
'setEditorCurrentIndex',
'toggleTimingActive'
], '/domains/healthRecords/bloodSugars/')
ReduxAction の抽象コード実体は StringArray。
New
New
37. この課題を immutable.Record をモデルとして扱う手法で解決。
先ほど宣言した Action が Dispatch されると、
Reducer から委譲された setter/updater が実行され状態が変化する。
export class BloodSugarsModel extends MultiItemModel(props) {
setEditorCurrentIndex (value: number): BloodSugarsModel {
return this.set('editor_current_index', value)
}
toggleTimingActive (index: number): BloodSugarsModel {
return this.update('timings', timings => {
return timings.update(index, timing => timing.toggleActive())
})
}
}
抽象化の文脈 - Reduxにおけるモデルとは? -
38. Immutable.Record は継承可能。
この抽象レイヤーで、先ほど宣言した Action と
同一抽象レベルのメソッドを定義。
export const MultiItemModel = (opt: Props) => class extends ItemQueryModel(props(opt)) {
updateHealthRecordsSrc (src: RecordProps): MultiItemModel {
const { base_date, end_date, records } = src
return this._updateRecords(base_date, end_date, fromJS(records))
}
}
抽象化の文脈 - Reduxにおけるモデルとは? -
41. OOP デザインパターンを FRP パラダイムに
持ち込むことが可能となった。
課題領域(ドメイン)を分離し、責務に応じて
ドメインをスケールさせる、DDD の基礎が出来上がった。
抽象化の文脈 - Reduxにおけるモデルとは? -
52. export function * mapRequestStateToUI ({ creators }: { creators: ActionCreators }) {
// 無限ループをもって継続的コルーチンを起動
while (true) {
// requestQueueドメインを購読する
yield take(action => {
return action.type === RequestQueueTypes.requestSend ||
action.type === RequestQueueTypes.receivedSuccess ||
action.type === RequestQueueTypes.receivedError
})
const { requestQueue } = yield select()
const isProcessing = requestQueue.isProcessing()
// 横断的関心事である XHR処理中という状態を、遷移ボタンを押せなくする状態に変換
yield put(creators.setDisabledUI(isProcessing))
}
}
【横断的関心事を結合するサービス】
55. redux-saga は async/await にデザインが似ており、標準に近い。
分割統合・手続きの差し込み・変更なども容易。
将来的に async/await を利用する様になっても、
設計は変わらないことが期待出来る。
コンテキストマップの文脈 - サービスの抽象化 -
58. export function activityGoalLogsSagas () {
return [
activityGoalLogsSaga({
types: Daily.types,
creators: Daily.creators,
modelName: 'activityGoalLogsDaily'
}),
activityGoalLogsSaga({
types: Weekly.types,
creators: Weekly.creators,
modelName: 'activityGoalLogsWeekly'
}),
activityGoalLogsSettingsSaga()
]
}
【抽象サービスのコンテキストマップ】
61. ドメインクライアント = View = React
ReactComponent の抽象化パターンはこれまで通り。
mapStateToProps・mapDispatchToProps で、
文脈にあった State・ActionCreator を抽象名でマッピングする。
コンテキストマップの文脈 - ドメインクライアント -
63. export function HealthRecordPanel ({ model }: { model: HealthRecordQueryModel }) {
const ctx = 'c-indexHealthRecordPanel'
// model の getter で得られる値は、期間分岐・ラベル・丸め処理・添字付与済み
return (
<div className={`${ctx}`}>
<div className={`${ctx}__upper`}>
<p className={`${ctx}__itemLabel`}>{model.getUpperPanelItemLabel()}</p>
<p className={`${ctx}__dateRangeLabel`}>{model.getUpperPanelDateRangeLabel()}</p>
<p className={`${ctx}__value`} dangerouslySetInnerHTML={model.getUpperPanelValueLabel()} />
</div>
<div className={`${ctx}__lower`}>
<p className={`${ctx}__itemLabel`}>{model.getLowerPanelItemLabel()}</p>
<p className={`${ctx}__dateRangeLabel`}>{model.getLowerPanelDateRangeLabel()}</p>
<p className={`${ctx}__value`} dangerouslySetInnerHTML={model.getLowerPanelValueLabel()} />
</div>
</div>
)
}
【ビジネスロジックが引き剥がされたコンポーネント】
70. export function * renderAchievementQueue (store: Store) {
while (true) {
// achievementQueue ドメインモデルを取得
const { achievementQueue } = yield select()
// achievementQueue に登録されたの先頭要素を取得
const achievementItem = achievementQueue.getFirstQueueItem()
// achievementQueue の要素が無くなったら loop を抜ける
if (achievementItem === undefined) break
// achievementItem を render
yield call(renderAchievementWidget, achievementItem, store)
// achievementQueue から 先頭要素を削除
const index = achievementQueue.getQueueItemIndex(achievementItem)
yield put(creators.deleteQueueItemByIndex(index))
}
// マウント先コンポーネントを空にする
disposeReact('[data-react-widget-achievements]')
}
【表示キューの継続的コルーチン】
71. export function renderAchievementWidget (achievementItem: ItemModel, store: Store) {
// Promise に紐づけられた React.render。役目を終えると resolve する
return new Promise(resolve => {
const selector = '[data-react-widget-achievements]'
renderReact(selector, AchievementWidget, store, { resolve, achievementItem })
})
}
【Promise で wrap された表示キューアイテム・レンダラー】
72. 【Promise で wrap された表示キューアイテム・レンダラー】(コード要約)
1. 先頭のキューアイテムを取得
2. キューアイテムがなければ終了
3. キューアイテムの表示
4. 表示が終わるまで待つ( Promise)
5. キューアイテムを削除する
6. 1.に戻る
3.
2.DONE
1.
4.
React
表示キュー
サービス
AchievementQueue
ドメインモデル
外部
Promise.resolve()
76. export const commonReducers = {
requestQueue: RequestQueueReducer(new RequestQueueModel()),
modalQueue: ModalQueueReducer(new ModalQueueModel()),
notificationQueue: NotificationQueueReducer(new NotificationQueueModel()),
achievementQueue: AchievementQueueReducer(new AchievementQueueModel()),
renderWidget: RenderWidgetReducer(new RenderWidgetModel()),
pointAccount: PointAccountReducer(new PointAccountModel()),
insurancePointAccount: InsurancePointAccountReducer(new InsurancePointAccountModel())
}
【全エントリーポイントに存在するドメインモデル集約】
77. export const healthRecordsReducers = {
healthRecordsCalendar: CalendarReducer(new CalendarModel()),
healthRecordsEditor: EditorReducer(new EditorModel()),
healthRecordsSettings: SettingsReducer(new SettingsModel()),
healthRecordsStepCounts: StepCountsReducer(new StepCountsModel()),
healthRecordsWeights: WeightsReducer(new WeightsModel()),
healthRecordsBloodPressures: BloodPressuresReducer(new BloodPressuresModel()),
healthRecordsBloodSugars: BloodSugarsReducer(new BloodSugarsModel())
}
export const activityGoalLogsReducers = {
activityGoalLogsDaily: DailyReducer(new DailyModel()),
activityGoalLogsWeekly: WeeklyReducer(new WeeklyModel()),
activityGoalLogsSettings: SettingsReducer(new SettingsModel())
}
【機能パッケージ単位のドメインモデル集約】
78. // create Store
const aggregateRoot = extendReducers(
commonReducers,
healthRecordsReducers,
activityGoalLogsReducers
)
const store = createReduxStore(aggregateRoot)
// run services
runRootSaga(commonSagas(store), [
...healthRecordsSagas(),
...activityGoalLogsSagas()
])
// view scripts
applyCommonViewScripts(store)
renderAppReactViews(store)
【集約ルートでStore生成、Service層・View層に注入】
集約ルート生成
Store生成
サービス起動
View起動
全エントリーポイントが
この様式に
84. 分割統合された Store は初期状態で zero value の状態。
rails に載せるうえで、erb から初期値を取得したい。
この課題を jQueryプラグインライクな vanilla plugin で解決。
plugin 適用時に Store を引数に与えることで、
ReduxAction を Dispatch するDOMに変化する。
分割統合の文脈 - DI of HTML to Store -
85. export function LoadActionDispatcher (selector, store) {
return document.querySelectorAll(selector).forEach(element => {
const dataset = JSON.parse(element.dataset.domLoadActionDispatcher)
function dispatch (data) {
const { type, payload } = data
store.dispatch({ type, payload })
}
if (Array.isArray(dataset)) {
dataset.map(data => dispatch(data))
} else {
dispatch(dataset)
}
})
}
【HTMLにレンダリングされた json を ActionDispatch するプラグイン】
86. <%= content_tag :div, nil, class: ctx, data: {
'dom-load-action-dispatcher': [
{
type: '/healthRecords/stepCounts/registerHealthRecordsSrc',
payload: {
base_date: @health_record[:base_date],
end_date: @health_record[:end_date],
src: @health_record['step_counts']
}
}
]
} %>
【プラグインが適用されるHTML】
87. 【DI of HTML to Store】(コード要約)
/index.js
共有
ドメイン集約
バイタル
ドメイン集約
行動目標記録
ドメイン集約
記事
ドメイン
ポイント
ドメイン
/articles.js
/vitals.js
/points.js
サーバーから得られるインスタンスを Load時に Dispatch
/vitals/index.html.erb /index.html.erb
/articles/index.html.erb /points/index.html.erb
93. Reduxにドメイン層を導入する@Qiita
実装 - Hexagonal Redux - @Qiita
MobXは複雑さに耐えられるのか?@Qiita
async/await で Modal の Queueing @Qiita
redux-ddd-example @github.com
immutablejs-record-oop-example @github.com
関連資料