Clean Architecture in Clojure is a joy.
— Uncle Bob Martin (@unclebobmartin) 2016年6月26日
と言うことで、Clojure で Clean Architecture を意識した簡単な TODO アプリを作成してみたいと思います。
最終的なソースはこちら
Clean Architecture とは?
他のアーキテクチャ1と同様、関心事の分離を目的としたアーキテクチャです。クリーンアーキテクチャでよく見かける以下の図は、この関心事の分離を実現するために外側から内側へ依存性のルールを持っていることを表しています。4つの同心円に目が行きがちですが、4つの同心円に特に縛りはなく、必要に応じで増減させて問題ありません。大事な点は、依存性のルールに従い、円の外側にあるフレームワークやDBといった詳細が、円の内側にあるビジネスロジック(方針)の決定に影響を与えないようにすることです。
クリーンアーキテクチャに従うことにより以下の特性を持つシステムを生み出すことができます。
- フレームワーク非依存
- テスト可能
- UI非依存
- データベース非依存
- 外部エージェント非依存
TODO アプリ仕様
Clean Architecture を試すのが目的のため、簡単なユースケースを持つ TODO アプリを題材とします。
- TODO リストを表示することができる
- TODO を追加することができる
- TODO を削除することができる
- TODO を一括削除することができる
準備
Clojure の CLI を利用するため、Getting Started を参考に Clojure の CLI をインストールしておきます。Mac であれば
$ brew install clojure
でインストールできます。
次にアプリケーションのディレクトリ、ソースディレクトリ、deps.edn
を作成します。
$ mkdir clj-clean-todos
$ cd clj-clean-todos
$ mkdir -p src/clj_clean_todos
$ touch deps.edn
deps.edn
には利用するライブラリは実装しながら適時追加していますが、毎度、説明するのは煩雑なため、最終的な結果を先に示しておきます。
{:deps
{org.clojure/java.jdbc {:mvn/version "0.7.8"}
mysql/mysql-connector-java {:mvn/version "8.0.13"}
ring/ring-defaults {:mvn/version "0.3.2"}
ring/ring-jetty-adapter {:mvn/version "1.7.1"}
compojure {:mvn/version "1.6.1"}
hiccup {:mvn/version "1.0.5"}}}
同様に最終的に作成されるソースファイル、ディレクトリ構成を記載しておきます。
$tree
.
├── deps.edn
└── src
└── clj_clean_todos
├── main
│ ├── console_db.clj
│ ├── console_inmemory.clj
│ ├── web_db.clj
│ └── web_inmemory.clj
├── repos
│ ├── core.clj
│ ├── db.clj
│ └── inmemory.clj
├── spec.clj
├── ui
│ ├── console
│ │ ├── core.clj
│ │ └── view.clj
│ └── web
│ ├── core.clj
│ ├── handler.clj
│ └── view.clj
└── usecase
├── create_todo.clj
├── delete_todo.clj
├── delete_todos.clj
├── get_todos.clj
└── index.clj
エンティティ作成
アプリケーションの重要な部分である円の内側から実装をしていきたいと思います。まずは、アプリ全体で利用する TODO のエンティティを作成します。
(ns clj-clean-todos.spec
(:require [clojure.spec.alpha :as s]))
;;; Entity
(s/def ::id pos-int?)
(s/def ::title string?)
(s/def ::todo (s/keys :req-un [::id ::title]))
(s/def ::todos (s/coll-of ::todo))
オブジェクト指向言語によるクリーンアーキテクチャだとクラスでエンティティを定義するところですが、特にクラスにする必要はないので spec
で定義しておきます。
ユースケース作成
アプリ仕様でリストアップした4つのユースケースをそれぞれ作成します。
TODO リスト表示
(ns clj-clean-todos.usecase.get-todos
(:require [clj-clean-todos.repos.core :refer [find-all]]))
(defn handle
[repos]
(find-all repos))
TODO リストを保管している何らかのリポジトリからデータを取得し返す関数を追加します。クリーンアーキテクチャの図で考えると、リポジトリはユースケースの外側に存在するため、外側にあるリポジトリに内側のユースケースが依存しないように実装する必要があります。円の外側の機能について知らない状態にするため、依存関係逆転の原則(DIP)に従い、リポジトリの具体的な実装ではなく、抽象(インターフェース)を参照するように実装します。
(ns clj-clean-todos.repos.core)
(defprotocol IRepository
(find-all [this])
(store [this title])
(remove-by-id [this id])
(remove-all [this]))
他のユースケースでも同様に、IRepository
の関数を利用し TODO を保管するリポジトリに対するアクションを実装していきます。
TODO 作成
(ns clj-clean-todos.usecase.create-todo
(:require [clj-clean-todos.repos.core :refer [store]]))
(defn handle
[repos todo]
(store repos todo))
TODO 削除
(ns clj-clean-todos.usecase.delete-todo
(:require [clj-clean-todos.repos.core :refer [remove-by-id]]))
(defn handle
[repos id]
(remove-by-id repos id))
TODO 全削除
(ns clj-clean-todos.usecase.delete-todos
(:require [clj-clean-todos.repos.core :refer [remove-all]]))
(defn handle
[repos]
(remove-all repos))
コンソール UI 作成
次に、作成したユースケースへの入出力を扱う UI としてコンソール UI を作成します。
(ns clj-clean-todos.ui.console.core
(:require [clj-clean-todos.usecase.index :as usecase]
[clj-clean-todos.ui.console.view :as view]
[clojure.string :as cstr]))
...
(defn- create-todo-action
[repos]
(view/create-prompt)
(when-let [input (read-line)]
(let [title (-> input cstr/trim)
todo (usecase/create-todo repos title)]
(view/created-result todo)
true)))
(defn- delete-todo-action
[repos]
(view/delete-prompt)
(when-let [input (read-line)]
(if-let [id (input->number input)]
(if-let [result (usecase/delete-todo repos id)]
(view/delete-success id)
(view/delete-failure id))
(view/invalid-number-message))
true))
(defn- list-todos-action
[repos]
(let [todos (usecase/get-todos repos)]
(if (empty? todos)
(view/empty-message)
(view/todolist todos))
true))
(defn- delete-all-todos-action
[repos]
(usecase/delete-todos repos)
(view/delete-all-message)
true)
(defn- action-loop
[repos]
(view/action-prompt)
(when-let [input (read-line)]
(let [action (case (input->number input)
0 exit-action
1 create-todo-action
2 delete-todo-action
3 list-todos-action
4 delete-all-todos-action
retry-action)]
(when (action repos)
(action-loop repos)))))
(defn start
[repos]
(view/action-list)
(action-loop repos))
start
関数の実行でアクションループに入り、ユーザからの入力、ユーザへの出力を行います。出力に関しては view.clj
として別ファイルに記載しておきます。
(ns clj-clean-todos.ui.console.view)
(defn action-list
[]
(println "actions ---------------------")
(println "1. Create todo")
(println "2. Delete todo")
(println "3. List todos")
(println "4. Delete all todos")
(println "0. Exit")
(println "-----------------------------"))
(defn action-prompt
[]
(print "Which action? ")
(flush))
(defn bye-message
[]
(println "Bye."))
(defn retry-message
[]
(println "Please enter a number from 0 to 4."))
...
view のコードは渡されたデータを表示するだけのシンプルな実装にしておきます。
リポジトリの実装 (メモリベース)
ユースケースへの入出力を扱う UI ができたので、次は実際にデータを格納するリポジトリを実装します。
IRepository
を実装しメモリ上で動作するリポジトリを作成します。
(ns clj-clean-todos.repos.inmemory
(:require [clj-clean-todos.repos.core :refer [IRepository]]))
...
(defrecord InMemory [repository]
IRepository
(store [{:keys [repository] :as this} title]
(let [id (allocate-next-id this)
todo {:id id :title title}]
(swap! repository assoc id todo)
todo))
(find-all [{:keys [repository] :as this}]
(->> repository
deref
vals
(mapv ->entity-todo)))
(remove-by-id [{:keys [repository] :as this} id]
(let [found (contains? @repository id)]
(swap! repository dissoc id)
found))
(remove-all [{:keys [repository] :as this}]
(reset! repository {})))
(defn create-inmemory
[]
(let [repository (atom {})]
(->InMemory repository)))
create-inmemory
関数でインスタンスを作成できるようにします。 TODO は id に紐づくマップとして atom
で管理します。
Main 関数を作成
ユースケースを利用するための UI, リポジトリができたので Main 関数を作成し、コマンドラインから実行できるようにします。
(ns clj-clean-todos.main.console-inmemory
(:require [clj-clean-todos.ui.console.core :as console]
[clj-clean-todos.repos.inmemory :refer [create-inmemory]]))
(defn -main
[]
(let [repos (create-inmemory)]
(console/start repos)))
作成した Main 関数は clj
コマンドの -m
オプションで namespace を指定し実行します。
$ clj -m clj-clean-todos.main.console-inmemory
actions ---------------------
1. Create todo
2. Delete todo
3. List todos
4. Delete all todos
0. Exit
-----------------------------
Which action? 1
input todo title: send email to Mike
created: send email to Mike
Which action? 1
input todo title: go to the gym
created: go to the gym
Which action? 3
todo items ------------------
1: send email to Mike
2: go to the gym
-----------------------------
Which action? 2
input todo id: 1
deleted: todo(id=1)
Which action? 3
todo items ------------------
2: go to the gym
-----------------------------
Which action? 0
Bye.
実行するとコンソール画面に実行できるアクションのリストが表示されます。適当に TODO の作成、削除、表示を試してみてください。リポジトリとしてメモリ上にデータを保管しているため、一旦 Exit してしまうと作成した TODO はリセットされます。
リポジトリ追加 (データベース)
TODO を永続化できるようにするため、データベース(mysql)を利用したリポジトリを作成します。
(ns clj-clean-todos.repos.db
(:require [clj-clean-todos.repos.core :refer :all]
[clojure.java.jdbc :as j]))
...
(defrecord Database [db-spec]
IRepository
(store [this title]
(let [db-todo {:title title
:created_at (System/currentTimeMillis)}
result (j/insert! (:db-spec this) :todo db-todo)
id (-> result first :generated_key long)]
{:id id :title title}))
(find-all [this]
(->> (j/query (:db-spec this) ["SELECT * FROM todo"])
(mapv ->entity-todo)))
(remove-by-id [this id]
(let [result (j/delete! (:db-spec this) :todo ["id = ?" id])]
(if (= 0 (first result))
false
true)))
(remove-all [this]
(j/execute! (:db-spec this) "TRUNCATE TABLE todo")))
(defn create-database
[db-spec]
(create-todo-table db-spec)
(->Database db-spec))
InMemory のときと同様 IRepository
を実装します。find-all
では DB から取得した TODO のレコードを spec で定義したエンティティのデータ構造に変換して返します。IRepository
を実装したその他の関数でも、DB に関するデータ構造がそのまま、ユースケース側に流れ出さないようにします。データベースのデータ構造を直接返す実装にしてしまうと、クリーンアーキテクチャの図で円の内側にあるユースケースが、円の外側にあるデータベースについて知ることになり、依存性のルールに違反してしまいます。IRepository
の関数に対して、TODO エンティティを返す旨を spec に記述しておきます。
...
;;; Repository
(s/def ::repos repository?)
(s/fdef repos/store
:args (s/cat :repos ::repos :title ::title)
:ret ::todo)
(s/fdef repos/find-all
:args (s/cat :repos ::repos)
:ret ::todos)
(s/fdef repos/remove-by-id
:args (s/cat :repos ::repos :id ::id)
:ret boolean?)
(s/fdef repos/remove-all
:args (s/cat :repos ::repos))
作成したデータベースを利用する Main 関数は以下になります。
(ns clj-clean-todos.main.console-db
(:require [clj-clean-todos.ui.console.core :as console]
[clj-clean-todos.repos.db :refer [create-database]]))
(defn -main
[]
(let [mysql-db {:dbtype "mysql"
:dbname "clj_clean_todos"
:user "clj_dev"
:password "password"}
repos (create-database mysql-db)]
(console/start repos)))
console-inmemory
の -main
関数との違いは、repos の作成が create-inmemory
から create-database
になった点のみです。また、依存性のルールに従っているため、ユースケースのコードに変更は発生しません。
$ clj -m clj-clean-todos.main.console-db
で実行することができます。適当に TODO を作成し、Exit、また起動してみてください。View は同じなので操作に変化はありませんが、再起動後 TODO リストを表示した際、前回起動時に作成した TODO が表示されると思います。
Web UI 作成
今度は、コンソールではなく Web で TODO を扱えるようにしてみます。
(ns clj-clean-todos.ui.web.core
(:require [ring.adapter.jetty :refer [run-jetty]]
[clj-clean-todos.ui.web.handler :as handler]))
(defprotocol ILifecycle
(start [this])
(stop [this]))
(defrecord Jetty [repos port]
ILifecycle
(start [this]
(when-not (:server this)
(->> (run-jetty
(handler/create-handler repos)
{:port port :join? false})
(assoc this :server))))
(stop [this]
(when-let [server (:server this)]
(.stop server)
(dissoc this :server))))
(defn create-jetty
([repos]
(create-jetty repos 3000))
([repos port]
(->Jetty repos port)))
create-jetty
関数でサーバーインスタンスを作成することができます。また、作成したインスタンスを引数にし start
関数でサーバを起動、stop
関数でサーバを停止することができます。
リクエストに関する処理は、handler.clj
、表示に関する処理はview.clj
に記載しておきます。
(ns clj-clean-todos.ui.web.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
[ring.util.response :as res]
[clj-clean-todos.ui.web.view :as view]
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]
[clj-clean-todos.usecase.index :as usecase]))
(defn- show-todos
[repos req]
(-> (usecase/get-todos repos)
view/index))
(defn- create-todo
[repos {:keys [params] :as req}]
(let [title (:title params)]
(usecase/create-todo repos title))
(res/redirect "/todos"))
(defn- delete-todo
[repos {:keys [params] :as req}]
(let [id (-> (:id params) Integer/parseInt)]
(usecase/delete-todo repos id))
(res/redirect "/todos"))
(defn- create-routes
[repos]
(routes
(GET "/" req (res/redirect "/todos"))
(GET "/todos" req (show-todos repos req))
(POST "/todos" req (create-todo repos req))
(POST "/todos/:id/delete" req (delete-todo repos req))
(route/not-found "Not Found")))
(defn create-handler
[repos]
(-> (create-routes repos)
(wrap-defaults site-defaults)))
handler ではリクエストで受け取った値から、ユースケースの入力データを作成し、その値を渡して各ユースケースを呼ぶことで目的の処理を行わせます。リクエストのデータはユースケースの外側にある UI が保持するデータ構造のため、ユースケース内では使わないようにします。
(ns clj-clean-todos.ui.web.view
(:require [hiccup.page :refer [html5]]
[ring.util.anti-forgery :refer [anti-forgery-field]]
[hiccup.form :refer [form-to]]))
...
(defn- todo-input
[]
(form-to
[:post "/todos"]
(anti-forgery-field)
[:input {:type "text"
:name "title"}]))
(defn- todo-item
[todo]
(let [action (format "todos/%d/delete" (:id todo))]
[:li (:title todo)
[:input {:type "submit" :value "削除" :formaction action}]]))
(defn- todo-list
[todos]
(form-to
[:post "/todos/:id/delete"]
(anti-forgery-field)
[:ul
(for [todo todos]
(todo-item todo))]))
(defn index
[todos]
(layout
(todo-input)
(todo-list todos)))
view は コンソールの view と同様、渡されたデータを表示するだけに務めさせます。
作成した Web を UI、リポジトリを InMemory とした Main 関数は以下となります。
(ns clj-clean-todos.main.web-inmemory
(:require [clj-clean-todos.ui.web.core :refer [create-jetty start]]
[clj-clean-todos.repos.inmemory :refer [create-inmemory]]))
(defn -main
[]
(let [repos (create-inmemory)
server (create-jetty repos)]
(start server)))
また、リポジトリを Database とした Main 関数は以下となります。
(ns clj-clean-todos.main.web-db
(:require [clj-clean-todos.ui.web.core :refer [create-jetty start]]
[clj-clean-todos.repos.db :refer [create-database]]))
(defn -main
[]
(let [mysql-db {:dbtype "mysql"
:dbname "clj_clean_todos"
:user "clj_dev"
:password "password"}
repos (create-database mysql-db)
server (create-jetty repos)]
(start server)))
それぞれ、以下のコマンドで実行し、
$ clj -m clj-clean-todos.main.web-inmemory
$ clj -m clj-clean-todos.main.web-db
で動作を確認することができます。
UI として Web を Main 関数から利用できる形の実装を追加しましたが、依存性のルールに従っているため Database を追加したときと同様、ユースケースより内側にあるロジックへの変更は発生しません。
依存関係グラフの確認
最後に内側のレイヤーから外側のレイヤーへ、逆方向の依存が発生していないか namespace の依存関係グラフを確認しておきます。
各ユースケースからclj-clean-todos.repos.core
へ伸びている矢印はIRepository
の参照で、レイヤーの外側に存在するリポジトリへの依存ではありません。その他の矢印は、Main -> UI, Repository -> Usecase と外から内への依存となっているため問題なさそうです。
おわりに
簡単なTODOリストを作成することで Clojure で Clean Architecture に基づくコードの書き方を確認しました。Clean Architecture を採用することでアーキテクチャ上に境界を設けることができ、変更に強いシステムを作成することができます。その一方で、境界を完全に構築しようとするとコストは高くつき、またコードの見通しも悪くなります。今回のサンプルでは、コードの見通しが悪くなりそうだったため、ユースケースへの入出力を扱うコントローラ、プレゼンターと呼ばれるインターフェースを実装しませんでした。Clean Architecture を実際の開発に適用する際には、完全に境界を構築するのではなく、 YAGNI(You ain't gonna need it) を意識し、必要最低限の境界を設けるように心がけると良いと思います。
参考資料
- Clean Architecture 達人に学ぶソフトウェアの構造と設計
- The Clean Architecture
- 実装クリーンアーキテクチャ
- 持続可能な開発を目指す ~ ドメイン・ユースケース駆動(クリーンアーキテクチャ) + 単方向に制限した処理 + FRP
-
ヘキサゴナルアーキテクチャ, DCIアーキテクチャ, BCE など ↩