Help us understand the problem. What is going on with this article?

フレームワークを使わずにClojureScriptでアプリケーションを書いてみる

More than 1 year has passed since last update.

概要

entity, use case等のビジネスロジックを前面に押し出すように、アプリケーションを書くことに挑戦してみます。
re-frame等のフレームワークを使わず、clojure.core.asyncに頑張ってもらいます。
Closure LibraryやReagentは普通に使います。

Clean Architectureの影響を受けています。

今回書くのは、下のようなタグを編集する機能です。

tag-edit.png

タグ編集

できることはざっくりいうと、次の通りです
- タグ編集の開始、保存、キャンセル
- タグの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を使った副作用として、代入をしなくてよくなりました
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした