Clojurianのlagénorhynque (a.k.a. カマイルカ)です。
先日、Shibuya.lisp lispmeetup #62で「re-frame à la spec」と題してre-frameというClojureScriptフレームワークの概要とclojure.specとの統合方法の一例について発表しました。
本記事では、その発表内容に関連してClojureScriptとre-frameでのSPA開発の流れについてご紹介します。
@ababup1192 さんのElmについての素晴らしい記事 Elm開発における思考フロー からサンプルコードと本文の構成を参考にさせていただきました。
こちらの記事と比較してみると、両言語/アーキテクチャの共通点や差異が見られて面白いかもしれません。
※ 本記事はClojure/ClojureScript, Reagent, clojure.specに対する基本的な理解を一応の前提としているため、馴染みのないものがある方はFurther Readingの各種ドキュメントなどを適宜ご確認ください。
re-frameとは
re-frameはClojureScriptのReactラッパーのひとつReagentを基礎とした状態管理フレームワークです。
位置付けとしては
などと同種のものです。
詳細はGitHubのdocsにドキュメントがありますが、
-
event dispatch
-
event handling
-
effect handling
-
query
-
view
-
DOM
という単方向のデータフローを利用してアプリケーションを構築します。
ClojureScript & re-frameによるSPA開発
参考元のElmの記事に倣って、じゃんけんゲーム(rock-paper-scissors)アプリを開発してみます。
※ ビルドやテストの設定、実行方法などは本記事では省略しているため、詳細はこちらのサンプルコードリポジトリをご確認ください: lagenorhynque/rock-paper-scissors
0. プロジェクトを構成する
事前準備として、re-frameベースのClojureScriptプロジェクトを生成します。
re-frameにはLeiningen向けのテンプレートre-frame-templateが提供されているので、以下のようなコマンドでプロジェクトが生成できます。
$ lein new re-frame rock-paper-scissors +cider +test
オプションの +cider
でEmacsのCIDERサポート、 +test
でClojureScriptのテスト関連サポート、 +10x
でre-frameのデバッグ支援ツールre-frame-10xを追加してみました。
以下のようなディレクトリ構成のプロジェクトが生成されます。
$ tree rock-paper-scissors
rock-paper-scissors
├── README.md
├── project.clj
├── resources
│ └── public
│ └── index.html
├── src
│ ├── clj
│ │ └── rock_paper_scissors
│ │ └── core.clj
│ └── cljs
│ └── rock_paper_scissors
│ ├── config.cljs
│ ├── core.cljs
│ ├── db.cljs
│ ├── events.cljs
│ ├── subs.cljs
│ └── views.cljs
└── test
└── cljs
└── rock_paper_scissors
├── core_test.cljs
└── runner.cljs
今回のプロジェクトでは、このあと利用するclojure.specでspecの定義を通常のソースコードとは完全に別のディレクトリの別の名前空間(specs
ディレクトリ配下の specs.cljs
)に配置する方針を採るため(こちらを参照)、最終的には以下のようなディレクトリ構成になります。
rock-paper-scissors
├── README.md
├── project.clj
├── resources
│ └── public
│ ├── css
│ │ └── style.css
│ └── index.html
├── specs
│ └── cljs
│ └── rock_paper_scissors
│ ├── db
│ │ └── specs.cljs
│ └── rps
│ └── specs.cljs
├── src
│ ├── clj
│ │ └── rock_paper_scissors
│ │ └── core.clj
│ └── cljs
│ └── rock_paper_scissors
│ ├── cofx.cljs
│ ├── config.cljs
│ ├── core.cljs
│ ├── db.cljs
│ ├── events.cljs
│ ├── rps.cljs
│ ├── subs.cljs
│ └── views.cljs
└── test
└── cljs
└── rock_paper_scissors
├── events_test.cljs
├── rps_test.cljs
└── runner.cljs
1. ユニットテストを使いビジネスロジックを考える
最初に今回のアプリのコアとなるドメイン、じゃんけんゲームのビジネスロジックを実装します。
いきなり必要な関数の実装を始めることもできますが、ここでは
-
ユニットテストを書く
-
clojure.specで扱うデータと関数の仕様を記述する
-
関数を実装する
という手順でテスト駆動開発(TDD)的に進めてみることにします。
Clojure/ClojureScriptはElmとは異なり非純粋で動的な関数型言語ですが、関数の純粋性を重視しており、原則としてイミュータブルなデータを扱うため値の等価性判定も容易です。
エディタと統合された強力なREPL環境(EmacsのCIDER、IntelliJのCursiveなど)を利用した、LispらしいREPL駆動開発(REPL-driven development)が大きな強みのひとつですが、TDDとの併用も効果的です。
上記1〜3の順序は便宜的なもので必ずしも縛られる必要はなく、REPLで素早くフィードバックを得ながらテスト、spec、実装をインクリメンタルに書き進めていくのが良さそうです。
2番目のclojure.specの利用は必須ではありませんが、Clojure/ClojureScriptにおいて静的言語の型チェックに代わる仕組みとして変更に強いプログラムにするためにも、特に重要なドメインロジックについてはspecを書いておくことをお勧めします。
ユニットテストを書く
名前空間 rock-paper-scissors.rps
にじゃんけんゲームのビジネスロジックを実装する想定で、以下のように fight
関数の振る舞いをテストコードで表現してみます。
test/cljs/rock_paper_scissors/rps_test.cljs: じゃんけんロジックのexample-based testを作成
(ns rock-paper-scissors.rps-test
(:require [cljs.test :as t :include-macros true]
[rock-paper-scissors.rps :as sut]))
(t/deftest test-fight
(t/testing "rock-paper-scissors"
(t/is (= ::sut/win (sut/fight ::sut/rock ::sut/scissors)))
(t/is (= ::sut/lose (sut/fight ::sut/scissors ::sut/rock)))
(t/is (= ::sut/draw (sut/fight ::sut/paper ::sut/paper)))))
テストケースを書いたら、早速テストを実行してパスしないことを確認します(rock-paper-scissors.rps
という名前空間が未定義なので当然エラーになります)。
$ lein doo phantom test
...
clojure.lang.ExceptionInfo: failed compiling file:test/cljs/rock_paper_scissors/rps_test.cljs {:file #object[java.io.File 0x6619692e "test/cljs/rock_paper_scissors/rps_test.cljs"]}
at clojure.core$ex_info.invokeStatic(core.clj:4739)
at clojure.core$ex_info.invoke(core.clj:4739)
at cljs.compiler$compile_file$fn__4585.invoke(compiler.cljc:1552)
at cljs.compiler$compile_file.invokeStatic(compiler.cljc:1513)
at cljs.compiler$compile_file.invoke(compiler.cljc:1489)
at cljs.closure$compile_file.invokeStatic(closure.clj:540)
at cljs.closure$compile_file.invoke(closure.clj:531)
at cljs.closure$eval6840$fn__6841.invoke(closure.clj:609)
at cljs.closure$eval6776$fn__6777$G__6765__6784.invoke(closure.clj:493)
at cljs.closure$compile_sources$iter__6964__6968$fn__6969.invoke(closure.clj:954)
...
Caused by: clojure.lang.ExceptionInfo: No such namespace: rock-paper-scissors.rps, could not locate rock_paper_scissors/rps.cljs, rock_paper_scissors/rps.cljc, or JavaScript source providing "rock-paper-scissors.rps" in file test/cljs/rock_paper_scissors/rps_test.cljs {:tag :cljs/analysis-error}
...
clojure.specで扱うデータと関数の仕様を記述する
じゃんけんゲームのビジネスロジックに関わるデータと関数はどのようなものか、clojure.specを利用して記述します。
spec | description |
---|---|
手 ::hand
|
キーワード ::rps/rock , ::rps/paper , ::rps/scissors のいずれか |
手に対応する数値 ::hand-num
|
0以上3未満の整数 |
結果(勝敗) ::result
|
キーワード ::rps/win , ::rps/lose , ::rps/draw のいずれか |
関数 rps/<-hand
|
::hand を受け取って ::hand-num を返す |
関数 rps/->hand
|
::hand-num を受け取って ::hand を返す |
関数 rps/fight
|
あなたと対戦相手の ::hand をそれぞれ受け取って ::result を返す |
specs/cljs/rock_paper_scissors/rps/specs.cljs: じゃんけんロジックのspecを定義
(ns rock-paper-scissors.rps.specs
(:require [cljs.spec.alpha :as s :include-macros true]
[rock-paper-scissors.rps :as rps]))
(s/def ::hand #{::rps/rock ::rps/paper ::rps/scissors})
(s/def ::hand-num (s/int-in 0 3))
(s/def ::result #{::rps/win ::rps/lose ::rps/draw})
(s/fdef rps/<-hand
:args (s/cat :hand ::hand)
:ret ::hand-num)
(s/fdef rps/->hand
:args (s/cat :num ::hand-num)
:ret ::hand)
(s/fdef rps/fight
:args (s/cat :you ::hand
:enemy ::hand)
:ret ::result)
関数のspecを活用してユニットテストに簡単なプロパティベースの(property-based)テストを追加しておくこともできます。
ここでは、引数のspecを満たす値をランダムに生成して関数に適用し、戻り値がそのspecを満たすことを確認する操作を1000回試行するテストを書いてみました(今回の場合は1000回も試す意味はない😅)。
test/cljs/rock_paper_scissors/rps_test.cljs: じゃんけんロジックのproperty-based testを追加
(ns rock-paper-scissors.rps-test
(:require [cljs.spec.alpha :as s]
[cljs.spec.test.alpha :as stest :include-macros true]
[cljs.test :as t :include-macros true]
[clojure.test.check.clojure-test :as tc :include-macros true]
[clojure.test.check.properties :as prop :include-macros true]
[rock-paper-scissors.rps :as sut]
[rock-paper-scissors.rps.specs]))
(t/use-fixtures
:once {:before #(stest/instrument)})
(t/deftest test-fight
(t/testing "rock-paper-scissors"
(t/is (= ::sut/win (sut/fight ::sut/rock ::sut/scissors)))
(t/is (= ::sut/lose (sut/fight ::sut/scissors ::sut/rock)))
(t/is (= ::sut/draw (sut/fight ::sut/paper ::sut/paper)))))
(tc/defspec prop-test-<-hand
1000
(let [fspec (s/get-spec #'sut/<-hand)]
(prop/for-all [[hand] (-> fspec :args s/gen)]
(s/valid? (:ret fspec)
(sut/<-hand hand)))))
(tc/defspec prop-test-->hand
1000
(let [fspec (s/get-spec #'sut/->hand)]
(prop/for-all [[num] (-> fspec :args s/gen)]
(s/valid? (:ret fspec)
(sut/->hand num)))))
(tc/defspec prop-test-fight
1000
(let [fspec (s/get-spec #'sut/fight)]
(prop/for-all [[you enemy] (-> fspec :args s/gen)]
(s/valid? (:ret fspec)
(sut/fight you enemy)))))
関数を実装する
あとは、specを満たしてユニットテストをパスするように関数を実装するだけです。
src/cljs/rock_paper_scissors/rps.cljs: じゃんけんロジックを実装
(ns rock-paper-scissors.rps)
(defn <-hand [hand]
(case hand
::rock 0
::scissors 1
::paper 2))
(defn ->hand [num]
(case num
0 ::rock
1 ::scissors
2 ::paper))
(defn fight [you enemy]
(letfn [(result [n]
(case n
0 ::draw
1 ::lose
2 ::win))]
(-> (- (<-hand you) (<-hand enemy))
(+ 3)
(mod 3)
result)))
期待通りすべてのテストケースをパスすることが確認できます。
$ lein doo phantom test
...
;; ======================================================================
;; Testing with Phantom:
Testing rock-paper-scissors.rps-test
Ran 1 tests containing 3 assertions.
0 failures, 0 errors.
2. 状態遷移について考える
ビジネスロジックが仕上がったので、ここからはre-frameを導入して画面を開発していきます。
参考元のElmでの例と同様に、画面状態は"Start", "Now Playing", "Over"の3種類があり、画面遷移をこれらの状態遷移として表現することにします。
3. 状態についてコードを書く
db
re-fameではアプリケーションの状態を db というマップを保持する単一のReagent atomで集中管理します。
そこで、今回のじゃんけんゲームアプリにおける db マップの仕様をspecで記述してみます(db にspecを付けておくと、開発時やテスト時にアプリケーションが不正な状態になればspecのエラーとして直ちに検出することも可能になります)。
::db
は以下のエントリを持つマップとします。
key | value |
---|---|
:you |
じゃんけんゲームのあなたの手 ::rps.specs/hand
|
:enemy |
じゃんけんゲームの対戦相手の手 ::rps.specs/hand
|
:scene |
画面状態 ::db/start , ::db/now-playing , ::db/over のいずれか |
specs/cljs/rock_paper_scissors/db/specs.cljs: re-frameのdbのspecを定義
(ns rock-paper-scissors.db.specs
(:require [cljs.spec.alpha :as s :include-macros true]
[rock-paper-scissors.db :as db]
[rock-paper-scissors.rps.specs :as rps.specs]))
(s/def ::you ::rps.specs/hand)
(s/def ::enemy ::rps.specs/hand)
(s/def ::scene #{::db/start ::db/now-playing ::db/over})
(s/def ::db (s/keys :req-un [::you ::enemy ::scene]))
そして、この db の仕様を満たすようにアプリケーション起動時の初期値を設定します。
ここでは、プレイヤー(あなた)と対戦相手の手はどちらもグー、画面状態は"Start"としました。
src/cljs/rock_paper_scissors/db.cljs: re-frameのdbの初期値を定義
(ns rock-paper-scissors.db
(:require [rock-paper-scissors.rps :as rps]))
(def default-db
{:you ::rps/rock
:enemy ::rps/rock
:scene ::start})
4. 状態を遷移するための入力について考える
event
re-frameではアプリケーション状態 db の変更は event によって行います。
event は [<event-id> <arg>*]
という形式のベクターとして表現します。
Elmでの実装に倣って、画面状態"Now Playing"への遷移は [::next-game]
、"Over"への遷移 [::select-your-hand h]
という event によって実現することにします。
5. 状態遷移についてコードを書く
event handler
それでは実際にre-frameの event を扱う関数(= event handler)を実装してみます。
re-frame.core/reg-event-db
関数を利用すると、 db と event を引数に取って新しい db を返す関数(Haskell風に書けば (db, event) -> db
)に名前を付けて event handler として登録することができます。
(re-frame.core/reg-event-db
<event-id>
(fn [<db> <event>]
<db>))
このように登録された event handler 自体は純粋な関数ですが、最終的にはre-frame組み込みの effect handler :db
を介して db 更新という副作用(effect)が発生することになります。
ここでは、以下の3種類の event handler を実装します。
event-id | description |
---|---|
::initialize-db |
現在の状態にかかわらず db に初期値 db/default-db を設定する |
::next-game |
画面状態を"Now Playing"(::db/now-playing )に変更する |
::select-your-hand |
あなたの手 h を受け取って設定するとともに画面状態を"Over"(::db/over )に変更する |
src/cljs/rock_paper_scissors/events.cljs: re-frameのevent handlerを実装
(ns rock-paper-scissors.events
(:require [re-frame.core :as re-frame]
[rock-paper-scissors.db :as db]))
(re-frame/reg-event-db
::initialize-db
(fn [_ _]
db/default-db))
(re-frame/reg-event-db
::next-game
(fn [db _]
(assoc db :scene ::db/now-playing)))
(re-frame/reg-event-db
::select-your-hand
(fn [db [_ h]]
(assoc db
:you h
:scene ::db/over)))
6. 状態をもとに画面の描画をするコードを書く
最後に、 db を query で参照して view を描画し、適宜 event を発生させることで動作する画面に仕上げます。
query と subscription
re-frameでは db の query のために subscription を利用します。
query は [<query-id> <arg>*]
という形式のベクターで表現されます。
re-frame.core/reg-sub
関数を利用すると、 db と query を引数に取って任意の値を返す関数(Haskell風に書けば (db, query) -> value
)に名前をつけて subscription として登録することができます。
(re-frame.core/reg-sub
<query-id>
(fn [<db> <query>]
<value>))
このように登録された subscription の関数は db が更新されるたびに呼び出され、常に最新の状態を反映した値を取り出すことができます。
ここでは、以下の2種類の subscription を実装します。
query-id | description |
---|---|
::scene |
画面状態 :scene を取り出す |
::you-enemy |
あなたの手 :you と対戦相手の手 :enemy をマップとして取り出す |
src/cljs/rock_paper_scissors/subs.cljs: re-frameのsubscriptionを実装
(ns rock-paper-scissors.subs
(:require [re-frame.core :as re-frame]))
(re-frame/reg-sub
::scene
(fn [db _]
(:scene db)))
(re-frame/reg-sub
::you-enemy
(fn [db _]
(select-keys db [:you :enemy])))
view
あとは、ここまでで用意したじゃんけんゲームのロジック、状態を変更する event 、状態を参照する subscription を利用して画面を描画する view を実装するだけです。
event は (re-frame.core/dispatch <event>)
でディスパッチし、 subscription は @(re-frame.core/subscribe <query>)
で購読することができます(見た目に冗長と思われる場合には、こちらのように >evt
, <sub
といった別名を付けても良いかもしれません)。
ReagentのHiccup風のDSLを利用して、例えば以下のように view のコンポーネントを実装することができるでしょう。
src/cljs/rock_paper_scissors/views.cljs: re-frameのviewを実装
(ns rock-paper-scissors.views
(:require [re-frame.core :as re-frame]
[rock-paper-scissors.db :as db]
[rock-paper-scissors.events :as events]
[rock-paper-scissors.rps :as rps]
[rock-paper-scissors.subs :as subs]))
(defn hands []
[:div (map (fn [h]
^{:key h}
[:input {:type "button"
:on-click #(re-frame/dispatch [::events/select-your-hand h])
:value h}])
[::rps/rock ::rps/paper ::rps/scissors])])
(defn result [you enemy]
(let [r (rps/fight you enemy)]
(case r
::rps/win [:h1 {:style {:color "red"}}
r]
::rps/lose [:h1 {:style {:color "blue"}}
r]
::rps/draw [:h1 {:style {:color "gray"}}
r])))
(defn main-panel []
(let [scene @(re-frame/subscribe [::subs/scene])]
(case scene
::db/start [:input {:type "button"
:on-click #(re-frame/dispatch [::events/next-game])
:value "Game Start"}]
::db/now-playing [hands]
::db/over (let [{:keys [you enemy]} @(re-frame/subscribe [::subs/you-enemy])]
[:div
[:h1 (str (name you) "(YOU) VS " (name enemy) "(ENEMY)")]
[result you enemy]
[:input {:type "button"
:on-click #(re-frame/dispatch [::events/next-game])
:value "Next Game"}]]))))
ブラウザで試してみると、以下の3画面の動作が確認できます。
- "Start": 「Game Start」ボタン押下で"Now Playing"へ
- "Now Playing": 「rock」「paper」「scissors」ボタン押下で"Over"へ
- "Over": 「Next Game」ボタン押下で"Now Playing"へ
7. 副作用について考える
ここまでで画面の機能は一通り完成しましたが、じゃんけんゲームで対戦相手の手が db の初期値で設定したグー固定になっているという問題があります。
簡単な対応として、 event handler ::select-you-hand
でプレイヤー(あなた)の手を設定するとともに乱数生成された値で対戦相手の手を設定してしまうこともできますが、乱数生成という典型的な副作用によって event handler 関数の純粋性が失われてしまいます。
coeffect と effect
シンプルなデータと純粋関数を可能な限り利用し、副作用を明確に分離することを重視するre-frameでは、このような場合に coeffect というものを活用することができます。
event handler を登録するために利用した re-frame.core/reg-event-db
関数は
(re-frame.core/reg-event-db
<event-id>
(fn [<db> <event>]
<db>))
という形式でしたが、実はこれは db 更新に特化した特殊な関数で、より一般的には re-frame.core/reg-event-fx
関数で
(re-frame.core/reg-event-fx
<event-id>
[<interceptor>*]
(fn [<coeffect-map> <event>]
<effect-map>))
という形式で coeffect マップと event を引数に取って effect マップを返す関数(Haskell風に書けば (coeffect-map, event) -> effect-map
)に名前をつけて event handler として登録することができます。
例えば event handler ::select-you-hand
は re-frame.core/reg-event-fx
を利用すると以下のように登録することができます。
(re-frame/reg-event-fx
::select-your-hand
[]
(fn [{:keys [db]} [_ h]]
{:db (assoc db
:you h
:enemy enemy-hand
:scene ::db/over)}))
このとき、 event handler 関数の第1引数が coeffect マップ {:db ,,,}
、返り値が effect マップ {:db ,,,}
です。
-
coeffect
- event handler の入力
-
re-frame.core/reg-cofx
で登録した coeffect handler によって外部からデータ取得する- 組み込みの例:
:db
(現在の db の値を取得する)
- 組み込みの例:
-
effect
- event handler の出力
-
re-frame.core/reg-fx
で登録した effect handler によって副作用が発生する- 組み込みの例:
:db
(指定された値で db を更新する)
- 組み込みの例:
coeffect は event handler の入力として外部からデータを取得するために、 effect は event handler の出力として副作用を発生させるために利用できるので、今回の場合は coeffect で乱数生成して対戦相手の手を取得することにしましょう。
re-frame.core/reg-cofx
関数を利用すると、 coeffect マップ(と任意の引数)を取って新しい coeffect マップを返す関数(Haskell風に書けば (coeffect-map, arg) -> coeffect-map
)に名前をつけて coeffect handler として登録することができます。
(re-frame.core/reg-cofx
<coeffect-id>
(fn [<coeffect-map> <arg>]
<coeffect-map>))
ここでは、 (rand-int 3)
で0以上3未満の整数を乱数で取得してじゃんけんの手に変換し、 :enemy-hand
というキーで coeffect マップに加える coeffect handler ::select-enemy-hand
として実装してみました。
src/cljs/rock_paper_scissors/cofx.cljs: re-frameのcoeffect handlerを実装
(ns rock-paper-scissors.cofx
(:require [re-frame.core :as re-frame]
[rock-paper-scissors.rps :as rps]))
(re-frame/reg-cofx
::select-enemy-hand
(fn [cofx _]
(assoc cofx
:enemy-hand (rps/->hand (rand-int 3)))))
event handler ::select-your-hand
を上述のように re-frame.core/reg-event-fx
で登録するように書き換え、 re-frame.core/inject-cofx
で coeffect handler ::select-enemy-hand
を interceptor として組み込むと、乱数生成された対戦相手の手 :enemy-hand
が利用可能になります。
coeffect で乱数として与えられた対戦相手の手を db に設定することで、event handler 関数の純粋性を保ったまま、実行するたびに対戦相手の手が変わるという挙動を実現することができます。
src/cljs/rock_paper_scissors/events.cljs: re-frameのeventにcoeffectを組み込む
(ns rock-paper-scissors.events
(:require [re-frame.core :as re-frame]
+ [rock-paper-scissors.cofx :as cofx]
[rock-paper-scissors.db :as db]))
(re-frame/reg-event-db
::initialize-db
(fn [_ _]
db/default-db))
(re-frame/reg-event-db
::next-game
(fn [db _]
(assoc db :scene ::db/now-playing)))
-(re-frame/reg-event-db
+(re-frame/reg-event-fx
::select-your-hand
- (fn [db [_ h]]
- (assoc db
- :you h
- :scene ::db/over)))
+ [(re-frame/inject-cofx ::cofx/select-enemy-hand)]
+ (fn [{:keys [db enemy-hand]} [_ h]]
+ {:db (assoc db
+ :you h
+ :enemy enemy-hand
+ :scene ::db/over)}))
(+α) 8. リファクタリングする
以上で参考元記事のElm版と同等の機能が実現できましたが、せっかくなのでさらに進んでよりre-frameらしい実装にリファクタリングしてみます。
re-frameでは view がロジックを持つのは最小限にし、 db データの加工などは subscription に集約するのが良いとされています(cf. Subscriptions Cleanup)。
そこで、 view のコンポーネントにあった、プレイヤー(あなた)と対戦相手の手の表示文字列の生成、対戦結果の計算、対戦結果による文字色の切り替えを subscription として抽出してみました。
関心の分離によって view の見通しが良くなるのはもちろん、 subscription を階層化したことで再計算を必要最小限に留めることが可能になり効率化も見込めます。
src/cljs/rock_paper_scissors/subs.cljs: re-frameのviewのロジックをsubscriptionに抽出
(ns rock-paper-scissors.subs
- (:require [re-frame.core :as re-frame]))
+ (:require [re-frame.core :as re-frame]
+ [rock-paper-scissors.rps :as rps]))
(re-frame/reg-sub
::scene
(fn [db _]
(:scene db)))
(re-frame/reg-sub
::you-enemy
(fn [db _]
(select-keys db [:you :enemy])))
+
+(re-frame/reg-sub
+ ::you-enemy-hands
+ :<- [::you-enemy]
+ (fn [{:keys [you enemy]} _]
+ (str (name you) "(YOU) VS " (name enemy) "(ENEMY)")))
+
+(re-frame/reg-sub
+ ::fight-result
+ :<- [::you-enemy]
+ (fn [{:keys [you enemy]} _]
+ (rps/fight you enemy)))
+
+(re-frame/reg-sub
+ ::result-color
+ :<- [::fight-result]
+ (fn [r _]
+ (case r
+ ::rps/win "red"
+ ::rps/lose "blue"
+ ::rps/draw "gray")))
src/cljs/rock_paper_scissors/views.cljs: re-frameのviewのコンポーネントを整理
(ns rock-paper-scissors.views
(:require [re-frame.core :as re-frame]
[rock-paper-scissors.db :as db]
[rock-paper-scissors.events :as events]
[rock-paper-scissors.rps :as rps]
[rock-paper-scissors.subs :as subs]))
+(defn start-button [label]
+ [:input {:type "button"
+ :on-click #(re-frame/dispatch [::events/next-game])
+ :value label}])
+
(defn hands []
[:div (map (fn [h]
^{:key h}
[:input {:type "button"
:on-click #(re-frame/dispatch [::events/select-your-hand h])
:value h}])
[::rps/rock ::rps/paper ::rps/scissors])])
-(defn result [you enemy]
- (let [r (rps/fight you enemy)]
- (case r
- ::rps/win [:h1 {:style {:color "red"}}
- r]
- ::rps/lose [:h1 {:style {:color "blue"}}
- r]
- ::rps/draw [:h1 {:style {:color "gray"}}
- r])))
+(defn result []
+ (let [r @(re-frame/subscribe [::subs/fight-result])]
+ [:h1 {:style {:color @(re-frame/subscribe [::subs/result-color])}}
+ r]))
(defn main-panel []
- (let [scene @(re-frame/subscribe [::subs/scene])]
- (case scene
- ::db/start [:input {:type "button"
- :on-click #(re-frame/dispatch [::events/next-game])
- :value "Game Start"}]
- ::db/now-playing [hands]
- ::db/over (let [{:keys [you enemy]} @(re-frame/subscribe [::subs/you-enemy])]
- [:div
- [:h1 (str (name you) "(YOU) VS " (name enemy) "(ENEMY)")]
- [result you enemy]
- [:input {:type "button"
- :on-click #(re-frame/dispatch [::events/next-game])
- :value "Next Game"}]]))))
+ (case @(re-frame/subscribe [::subs/scene])
+ ::db/start [start-button "Game Start"]
+ ::db/now-playing [hands]
+ ::db/over [:div
+ [:h1 @(re-frame/subscribe [::subs/you-enemy-hands])]
+ [result]
+ [start-button "Next Game"]]))
(+α) 9. re-frameのテストを書く
re-frameのユニットテストも書いてみましょう。
テストの方針もいくつか考えられますが(cf. Testing)、ここでは event と subscription に対してテストすることにします(こちらを参照)。
event handler などほとんどの関数が純粋関数なので、re-frame-testのユーティリティを利用して簡単にテストを書くことができます。
clojure.specで db のspecを定義してあるので、 re-frame.core/reg-fx
で組み込みの effect handler :db
を再定義し、テスト実行時に db がspecを満たさない不正な状態になった場合にはエラーを発生させるようにしてみました。
test/cljs/rock_paper_scissors/events_test.cljs: re-frameのeventに対するテストを作成
(ns rock-paper-scissors.events-test
(:require [cljs.spec.alpha :as s]
[cljs.spec.test.alpha :as stest :include-macros true]
[cljs.test :as t :include-macros true]
[day8.re-frame.test :as re-frame.test :include-macros true]
[re-frame.core :as re-frame]
[re-frame.db :refer [app-db]]
[rock-paper-scissors.cofx :as cofx]
[rock-paper-scissors.db :as db]
[rock-paper-scissors.db.specs :as db.specs]
[rock-paper-scissors.events :as sut]
[rock-paper-scissors.rps :as rps]
[rock-paper-scissors.rps.specs]
[rock-paper-scissors.subs :as subs]))
(t/use-fixtures
:once {:before #(stest/instrument)})
(defn test-fixtures []
(re-frame/reg-fx
:db
(fn [value]
(when-not (s/valid? ::db.specs/db value)
(throw (ex-info "db spec check failed" (s/explain-data ::db.specs/db value))))
(if-not (identical? @app-db value)
(reset! app-db value)))))
(t/deftest test-initialize-db
(re-frame.test/run-test-sync
(test-fixtures)
(re-frame/dispatch [::sut/initialize-db])
(t/is ::db/start @(re-frame/subscribe [::subs/scene]))
(t/is {:you ::rps/rock
:enemy ::rps/rock}
@(re-frame/subscribe [::subs/you-enemy]))))
(t/deftest test-next-game
(re-frame.test/run-test-sync
(test-fixtures)
(re-frame/dispatch [::sut/initialize-db])
(re-frame/dispatch [::sut/next-game])
(t/is ::db/now-playing @(re-frame/subscribe [::subs/scene]))))
(t/deftest test-select-your-hand
(re-frame.test/run-test-sync
(test-fixtures)
(re-frame/reg-cofx
::cofx/select-enemy-hand
(fn [cofx _]
(assoc cofx
:enemy-hand ::rps/rock)))
(re-frame/dispatch [::sut/initialize-db])
(t/testing "draw"
(re-frame/dispatch [::sut/next-game])
(re-frame/dispatch [::sut/select-your-hand ::rps/rock])
(t/is ::db/over @(re-frame/subscribe [::subs/scene]))
(t/is "rock(YOU) VS rock(ENEMY)" @(re-frame/subscribe [::subs/you-enemy-hands]))
(t/is ::rps/draw @(re-frame/subscribe [::subs/fight-result]))
(t/is "gray" @(re-frame/subscribe [::subs/result-color])))
(t/testing "win"
(re-frame/dispatch [::sut/next-game])
(re-frame/dispatch [::sut/select-your-hand ::rps/paper])
(t/is ::db/over @(re-frame/subscribe [::subs/scene]))
(t/is "paper(YOU) VS rock(ENEMY)" @(re-frame/subscribe [::subs/you-enemy-hands]))
(t/is ::rps/win @(re-frame/subscribe [::subs/fight-result]))
(t/is "red" @(re-frame/subscribe [::subs/result-color])))
(t/testing "lose"
(re-frame/dispatch [::sut/next-game])
(re-frame/dispatch [::sut/select-your-hand ::rps/scissors])
(t/is ::db/over @(re-frame/subscribe [::subs/scene]))
(t/is "scissors(YOU) VS rock(ENEMY)" @(re-frame/subscribe [::subs/you-enemy-hands]))
(t/is ::rps/lose @(re-frame/subscribe [::subs/fight-result]))
(t/is "blue" @(re-frame/subscribe [::subs/result-color])))))
まとめ
- re-frameを利用するとシンプルなデータと純粋な関数の組み合わせでClojure(Script)らしくフロントエンドの状態管理ができる
- 本記事で開発の流れとともに基本的な機能の紹介を試みたが、初期の学習コストは低くないかも
- 詳しくは公式ドキュメントdocsを読もう
- clojure.specは控えめに開発時のみ利用する方針でも十分役に立ちそう
- ドメインロジックとre-frameのeventを中心にテストする方針が良さそう
- ClojureScript楽しい>ω</
- re-frame楽しい>ω</
Further Reading
ClojureScript
Reagent & re-frame
- ClojureのDuctとClojureScriptのre-frameによるREST API + SPA開発入門
- Clojure/ClojureScript関連リンク集 > Webフロントエンド (ClojureScript)