概要
entity, use case等のビジネスロジックを前面に押し出すように、アプリケーションを書くことに挑戦してみます。
re-frame等のフレームワークを使わず、clojure.core.asyncに頑張ってもらいます。
Closure LibraryやReagentは普通に使います。
Clean Architectureの影響を受けています。
今回書くのは、下のようなタグを編集する機能です。
タグ編集
できることはざっくりいうと、次の通りです
- タグ編集の開始、保存、キャンセル
- タグのattach、detach
- 新しいタグの追加、既存のタグの削除
一つ一つ実装してみます。
Entities
概念の定義を置く場所をentitiesとしています。
タグ
タグやタグの編集等、今回のアプリケーションで取り扱う概念を定義します。
(ns entities.tag)
;; タグを表現するクラス
;; タグのIDと名前で表現します。
(defrecord Tag [tag-id name])
;; タグの編集状態を表すクラス
;; 全部のタグと、付与されているタグにより、編集状態を表します。
(defrecord AttachedTagEdit [tags attached-tags])
(defn attach [attached-tag-edit tag]
(assoc attached-tag-edit :attached-tags conj tag))
(defn detach [attached-tag-edit tag]
(let [tag-id (:tag-id tag)]
(update attached-tag-edit :attached-tags
(fn [tags] (remove #(= tag-id (:tag-id %)) tags)))))
;; 新しく追加するタグの編集状態を表すクラス
;; 追加するタグの名前で編集状態を表します。
(defrecord NewTagEdit [name])
Transaction
現在の状態を取得、現在の状態を更新するプロトコルを、Transactionと呼ぶことにします。
アプリケーションが状態を取得したり状態を更新したりするときは、このプロトコルを経由します。
(ns entities.transaction)
(defprotocol Transaction
;; 現在の状態を取得する
(state [this])
;; 状態を取って状態を返す関数 (f: State -> State) を受けとって、状態を更新する
(update-state [this f]))
例えば、以下のようなStateを考えます。
(defrecord State [counts])
Stateのcountsをインクリメントするコードは下ようになります。
これにより、非同期でStateが更新されます。
(defn increment-by [transaction diff]
(update-state transaction (fn [state] (update state :counts inc diff))))
短く書くと下のようになります。
(defn increment-by [transaction diff]
(update-state transaction #(update % :counts inc diff)))
なお、一貫性をもったStateをどうやって保持し続けるかは、Transaction実装時の関心です。したがって、Transactionを利用する側はこれを考えないようにします。
API
サーバへHTTP Requestを投げる場所です。
以下のAPIがあると仮定して以降の話を続けていきます (実装はしません)
ここはライブラリが必要になるでしょう。
(ns api
(:require [entities.tag]))
;; すべてのTagをmapとして取得します。
;; keyはtag-idで、valueはTagです。
;; 取得した後はそれを引数にcallbackを実行します。
(defn list-tags [callback])
;; nameを名前としてTagを追加します。追加後は無引数でcallbackを実行します。
(defn add-tag [name callback])
;; Tagを削除します
(defn delete-tag [tag-id callback])
;; 付与されたTagをリストとして取得します。
(defn list-attached-tags [callback])
;; 付与されたTagのリストを保存します。
(defn save-attached-tags [tags callback])
Use Cases
アプリケーションのコードを書く場所はuse_casesとしています。
entities, api等を使って、アプリケーションのアルゴリズムを書く場所です。
アプリケーションのアルゴリズムは、基本的に、状態を引数にとって新しい状態を生成する関数を生成します。
(ns use_cases
(:require [entities.tag]
[entities.transaction :refer [state update-state]]
[api]))
;; アプリケーションにより遷移する状態を表します
(derecord State [attached-tag-edit new-tag-edit])
;;; タグ編集の開始
(defn start-edit [transaction]
(api/list-tags (fn [tags]
(api/list-attached-tags (fn [attached-tags]
(update-state transaction
;; 現在のStateの、new-tag-editとattached-tag-editを変更する。
#(-> %
(assoc :new-tag-edit (entities.tag/NewTagEdit. ""))
(assoc :attached-tag-edit (entities.tag/AttachedTagEdit. tags attached-tags)))))))`
;;; タグ編集のキャンセル
(defn cancel-edit [transaction]
(update-state transaction #(State. nil nil)))
;;; タグ編集の保存
(defn save-edit [transaction]
(let [attached-tags (-> transaction state :attached-tags)]
(api/save-attached-tags attached-tags (fn []
(update-state transaction #(State. nil nil)))))
;;; タグのattach
(defn attach-tag [transaction tag-id]
(update-state transaction (fn [state]
(if-let [tag (get tag-id (-> state :attached-tag-edit :tags))]
(update state :attached-tag-edit entities.tag/attach tag)
state)))
;;; タグのdetach
(defn detach-tag [transaction tag-id]
(update-state transaction (fn [state]
(if-let [tag (get tag-id (-> state :attached-tag-edit :tags))]
(update state :attached-tag-edit entities.tag/detach tag)
state)))
;;; 新しいタグ名の編集
(defn change-new-tag-name [transaction name]
(update-state transaction #(assoc-in % [:new-tag-edit :name] name)))
;;; 新しいタグの追加
(defn add-new-tag [transaction]
(let [tag-name (-> transaction state :new-tag-edit :name)]
(api/add-tag tag-name (fn []
(api/list-tags (fn [tags]
(update-state transaction
#(-> %
(assoc-in [:new-tag-edit] (NewTagEdit. ""))
(assoc-in [:attached-tag-edit :tags] tags)))))))
;;; 既存のタグの削除
(defn delete-tag [transaction tag-id]
(detach-tag transaction tag-id)
(api/delete-tag tag-id (fn []
(api/list-tags (fn [tags]
(update-state transaction
#(assoc-in % [:attached-tag-edit :tags] tags)))))
Components
componentを置きます。Reagentをフルに使いますが、詳細は省略。
(ns components
(:require [reagent.core :as r]))
Transactionやuse casesを見れるコンテナコンポーネントや、純粋にどう見せるかだけに関心を持つプレゼンテーショナルコンポーネントに分けるとよさそうです。
main
mainはアプリケーションの起点となるところです。
Transactionの生成、ページとTransactionの対応付け等を行います。
今回の場合、ページが関わるStateはuse_cases/Stateの一つだけです。
しかし一般にページは複数のStateからなりえます。
すると、Stateに対応するTransactionも複数必要です。
ここで、ページで扱うTransactionをまとめた概念をStoreと呼ぶことにします。
(ns main
(:require [clojure.core.async :refer [go <! chan]]
[goog.dom :as gdom]
[reagent.core :as r]
[entities.transaction]
[use_cases]
[components]))
;; stateの更新処理をupdate-fnに委譲するTransactionの実装です
(defrecord DelegateTransaction [state update-fn]
entities.transaction/Transaction
(entities.transaction/state [this]
(-> this :state))
(entities.transaction/update-state [this f]
((-> this :update-fn) f)))
;; storeはTransactionの集まりです。
;; 今回のstoreはただ一つのTransactionを持てば十分です。
;; DelegateTransactionのコンストラクタに渡すupdate-fnは、
;; きちんとStateが更新されるように設定します。
(defun create-store [update-store]
{:transaction
(DelegateTransaction. (use_cases/State. nil nil)
#(fn [s] (update-in s [:transaction :state] %))})
(defn main [create-store render]
(let [update-store-fn-chan (chan)]
(go (loop [store (create-store #(put! update-store-fn-chan %))]
;; storeを元に、画面を描画します
(render store)
;; storeの更新が起きる (= storeの更新関数がput!される) のを待ちます
(let [update-store-fn (<! update-store-fn-chan)]
;; storeを更新してループします
(recur (update-store-fn store)))))))
(main create-store
(let [elem (gdom/getElement "app")]
(fn [store]
(r/render [components/tag-edit
:transaction (-> store :transaction)]
elem))))
まとめ
- 主要なビジネスロジックをできるだけClojureScriptでだけ実装する挑戦
- API以外は、だいたいそんな感じになった?
- clojure.core.asyncを使った副作用として、代入をしなくてよくなりました