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

Clojure で Clean Architecture

More than 1 year has passed since last update.

https://twitter.com/unclebobmartin/status/747192304956497920

と言うことで、Clojure で Clean Architecture を意識した簡単な TODO アプリを作成してみたいと思います。

最終的なソースはこちら

Clean Architecture とは?

他のアーキテクチャ1と同様、関心事の分離を目的としたアーキテクチャです。クリーンアーキテクチャでよく見かける以下の図は、この関心事の分離を実現するために外側から内側へ依存性のルールを持っていることを表しています。4つの同心円に目が行きがちですが、4つの同心円に特に縛りはなく、必要に応じで増減させて問題ありません。大事な点は、依存性のルールに従い、円の外側にあるフレームワークやDBといった詳細が、円の内側にあるビジネスロジック(方針)の決定に影響を与えないようにすることです。

CleanArchitecture.jpg

クリーンアーキテクチャに従うことにより以下の特性を持つシステムを生み出すことができます。

  • フレームワーク非依存
  • テスト可能
  • 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には利用するライブラリは実装しながら適時追加していますが、毎度、説明するのは煩雑なため、最終的な結果を先に示しておきます。

dpes.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 のエンティティを作成します。

spec.clj
(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 リスト表示

get_todos.clj
(ns clj-clean-todos.usecase.get-todos
  (:require [clj-clean-todos.repos.core :refer [find-all]]))

(defn handle
  [repos]
  (find-all repos))

TODO リストを保管している何らかのリポジトリからデータを取得し返す関数を追加します。クリーンアーキテクチャの図で考えると、リポジトリはユースケースの外側に存在するため、外側にあるリポジトリに内側のユースケースが依存しないように実装する必要があります。円の外側の機能について知らない状態にするため、依存関係逆転の原則(DIP)に従い、リポジトリの具体的な実装ではなく、抽象(インターフェース)を参照するように実装します。

clj-clean-todos.repos.core.clj
(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 作成

create_todo.clj
(ns clj-clean-todos.usecase.create-todo
  (:require [clj-clean-todos.repos.core :refer [store]]))

(defn handle
  [repos todo]
  (store repos todo))

TODO 削除

delete_todo.clj
(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 全削除

delete_todos.clj
(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 を作成します。

core.clj
(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 として別ファイルに記載しておきます。

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 を実装しメモリ上で動作するリポジトリを作成します。

inmemory.clj
(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 関数を作成し、コマンドラインから実行できるようにします。

console-inmemory.clj
(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)を利用したリポジトリを作成します。

db.clj
(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 に記述しておきます。

spec.clj
...

;;; 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 を扱えるようにしてみます。

core.clj
(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に記載しておきます。

handler.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 が保持するデータ構造のため、ユースケース内では使わないようにします。

view.clj
(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 関数は以下となります。

web_inmemory.clj
(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 関数は以下となります。

web_db.clj
(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

http://localhost:3000

で動作を確認することができます。

UI として Web を Main 関数から利用できる形の実装を追加しましたが、依存性のルールに従っているため Database を追加したときと同様、ユースケースより内側にあるロジックへの変更は発生しません。

依存関係グラフの確認

最後に内側のレイヤーから外側のレイヤーへ、逆方向の依存が発生していないか namespace の依存関係グラフを確認しておきます。

graph.png

各ユースケースから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) を意識し、必要最低限の境界を設けるように心がけると良いと思います。

参考資料


  1. ヘキサゴナルアーキテクチャ, DCIアーキテクチャ, BCE など 

snufkon
フリーエンジニア(Clojureでお仕事中)。 あしあとまっぷ: http://ashimap.com/ 2012年5月〜2013年2月に自転車で日本一周しました。 開発環境: Heroku, Clojure, JavaScript
http://snufkon.hatenablog.com/
abeja
「ディープラーニング」を活用し、多様な業界、シーンにおけるビジネスの効率化・自動化を促進するベンチャー企業です。
https://abejainc.com
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