抽象的なClojure
抽象的なClojure
抽象的なものに依存し、具体的なものには依存しない
2021年11月25日公開 アレックス・キング著
ソフトウェアプロジェクトの最初の数年間で、多くのことを達成することができます。小さなチームと適切なツールがあれば、企業とその顧客の両方を満足させる機能を迅速に提供することができます。プロジェクトの初期段階では、アーキテクチャよりもデリバリーが優先されがちですが、ソフトウェアが長期的に開発・維持されるためには、アーキテクチャも進化させる必要があります。ビジネス要件は変化し、開発者は入れ替わり、ソフトウェアが依存するプラットフォームサービスは廃止され、置き換えられ、導入環境は変化し、新しいテクノロジーは古いテクノロジーを陳腐化させ、新たな可能性を提供するようになります。
抽象化はソフトウェアアーキテクチャの核心であり、「何を」「どのように」を分離することで、特定の実装の詳細に迷うことなく、解決しようとするビジネスの中核問題に焦点を当てることができます。
抽象化
抽象的なものに依存し、具体的なものには依存しない
— 依存関係逆転の原則
具体的には、抽象化ではなく、実装に依存する場合です。SQLデータベースからブログ記事を取得するREST APIを公開する次のようなアプリケーションを考えてみましょう。
(ns app.db
(:require [next.jdbc.sql :as sql]))
(defn get-article-by-id
"`data-source`: javax.sql.DataSource, `id`: article id"
[data-source id]
(sql/get-by-id data-source :article id))
(ns app.server
(:require [app.db :as db]
[reitit.ring :as ring]))
(defn get-article [data-source request]
(let [id (get-in request [:path-params :id])
article (db/get-article-by-id data-source id)]
{:status 200
:body article}))
(defn router [data-source]
(ring/router
[["/api/article/:id" {:get {:parameters {:path {:id int?}}}
:handler #(get-article data-source %)}]]))
(ns app.system
(:require [app.server :as server]
[next.jdbc :as jdbc]))
(defn init [db-spec]
(let [data-source (jdbc/get-datasource db-spec) ;; javax.sql.DataSource
router (server/router data-source)] ;; reitit.core/Router
...))
この例では、 get-article
は get-article-by-id
という実装に結合されており、ルーターは get-article
に結合されています。その結果、ルーターとハンドラーは data-source
を認識する必要がありますが、 data-source
について知る必要があるのは get-article-by-id
だけという波及効果になっています。現在のルーティングの実装をテストするために、実行中のSQLデータベースが必要です。
抽象化を使って依存関係を逆転させることができます。db/get-article-by-id
と同じシグネチャを使用して、 data-source
を削除すると、 id
を受け取って article
を返す関数が残ります。
(fn get-article-by-id [id]
article)
高階関数を使って抽象化された実装を構築することができる。
(fn [id]
(db/get-article-by-id data-source id))
(partial db/get-article-by-id data-source)
#(db/get-article-by-id data-source %)
以前は data-source
を渡していた server/get-article
に関数を渡すことができるようになり、純粋な関数ができあがりました。
(defn get-article
"`get-article-by-id`: (fn [id] article)"
[get-article-by-id request]
(let [id (get-in request [:path-params :id])
article (get-article-by-id id)]
{:status 200
:body article}))
同じアプローチで、get-article
の最初のパラメータを削除して、次の抽象化であるリクエストハンドラーを形成することができます。
(fn handler [request]
response)
#(server/get-article get-article-by-id %)
最後に必要なのはルーターへの変更です。ハンドラを渡す方法が必要です。この例ではルートを1つだけ示していますが、実際には多くのルートを持つことになるでしょう。 route
のキーを受け取ってハンドラを返す関数を使用することができます。
(fn route->handler [route]
(fn handler [request]
response))
ルート→ハンドラ
をマップを使って実装することができる。
{:get-article #(server/get-article get-article-by-id %)}
これで、ルーターとハンドラを分離し、data-source
の依存関係を取り除きました。
(ns app.server
(:require [reitit.ring :as ring]))
(defn get-article
"`get-article-by-id`: (fn [id] article)"
[get-article-by-id request]
(let [id (get-in request [:path-params :id])
article (get-article-by-id id)]
{:status 200
:body article}))
(defn router
"`route->handler`: (fn [route] (fn [request] response))"
[route->handler]
(ring/router [["/api/article/:id" {:get {:parameters {:path {:id int?}}}
:handler (route->handler :get-article)}]]))
一見すると小さな変更に見えますが、リファクタリング後のバージョンは大きく変わっています。関数は純粋なものになり、データベースを実行せずに分離してテストできるようになりました。
app.server
のコードを変更することなく、 get-article-by-id
を同じシグネチャを持つ任意の関数に置き換えることができます。これはテスト中のスタブ/モック、モニタリング/ロギング/キャッシュを追加するラッパー、もしくは別のデータベースや外部ソースから記事を取得する代替の実装になります。
ルーターは、送られてくるリクエストをハンドラにルーティングするという、 一つの責任を持つ。ハンドラには、入ってきたリクエストを、アクションが実行される内部呼び出しに変換する、という責任もあります。
プロトコル
プロトコルはモジュールを分離するために使うことができる抽象化の別の形態です。アプローチは関数的というよりもオブジェクト指向ですが、より正式な抽象化を望む場合、あるいは動作のまとまったコレクションを形成することが理にかなっている場合、プロトコルは有用です。前の例では、記事を取得するために抽象化された一つの関数を使いました。代わりに、ドメイン駆動設計で一般化されたリポジトリパターンを使うこともできます。
(ns app.article-repository)
(defprotocol ArticleRepository
(create [_ article])
(get-by-id [_ id])
(publish [_ id])
(archive [_ id])
(update-title [_ id title]))
(ns app.db
(:require [app.article-repository :refer [ArticleRepository]]
[next.jdbc.sql :as sql]))
(defrecord SqlArticleRepository [data-source]
ArticleRepository
(get-by-id [_ id]
(sql/get-by-id data-source :article id))
...)
(ns app.server
(:require [reitit.ring :as ring]
[app.article-repository :as article-repository]))
(defn get-article [article-repository request]
(let [id (get-in request [:path-params :id])
article (article-repository/get-by-id article-repository id)]
{:status 200
:body article}))
この例のインターフェースは、リポジトリの名前空間を必要とし、それを直接参照するという点で、よりフォーマルです。これは、 app.db
で定義されたプロトコルの実体を参照するのではなく、抽象化を参照しているので、 defn
で定義された関数を参照するという最初の使い方とは異なっています。
より冗長になりますが、特に密接に関連した多くの動作が必要な場合、多くの関数を渡すよりもプロトコルのインスタンスを渡す方が望ましいかもしれません。関数に対するプロトコルの利点の一つは、関数が何に依存しているかが読者にとって明確であることです。私たちは名前空間を require
して、私たちのコードはプロトコルが定義されている場所を指します。一方で、これは関数型の代替案ほど柔軟でも簡潔でもありません。
構成
抽象化を導入することでデカップリングを達成したので、次のステップは関数をシステムとして構成することです。まずは data-source
を作成し、無名関数を使って db/get-article-by-id
をラップして、関数 (fn [id] article)
を作成します。同様に get-article-handler
を作成して、関数 (fn [request] response)
を作成し、マップ内に配置して route→handler
という抽象化を構築しています。
(ns app.system
(:require [app.db :as db]
[app.server :as server]
[next.jdbc :as jdbc]))
(defn init [db-spec]
(let [data-source (jdbc/get-datasource db-spec) ;; javax.sql.DataSource
get-article-by-id #(db/get-article-by-id data-source %) ;; (fn [id] article)
get-article-handler #(server/get-article get-article-by-id %) ;; (fn [request] response)
route->handler {:get-article get-article-handler} ;; (fn [route] (fn [request] response))
router (server/router route->handler)] ;; reitit.core/Router
...))
このアプローチのトレードオフの1つは、配線が増えることです。関数は依存関係を渡され、関数は依存関係を渡され、システムを形成するために一緒に配線されなければなりません。間接的なことは、もはやサーバー名前空間の get-article-by-id
の定義に飛ぶことができないことを意味します。 トレードオフの価値があるかどうかは、あなたが決めることです。抽象化は短期間のプロジェクトには時期尚早かもしれませんが、デカップリングは大規模なプロジェクトを確実に保守しやすくするのに役立ちます。
大規模なシステムを構成することは困難です。プロセスは特定の順序で開始・停止する必要があり、依存関係逆転原理に従うと依存関係のグラフが大きくなることがあります。私たちはしばしばComponentやIntegrantのようなフレームワークに手を出して、大規模なシステムの構築を手助けしてもらっています。
Integrant を使ってシステムを構築するコードは、 let
バインディングで定義したコードと似ています。
(defmethod ig/init-key ::get-article-by-id [_ {:keys [data-source]}]
#(db/get-article-by-id data-source %))
(defmethod ig/init-key ::get-article-handler [_ {:keys [get-article-by-id]}]
#(server/get-article get-article-by-id %))
(defmethod ig/init-key ::router [_ {:keys [route->handler]}]
(server/router route->handler))
マップに宣言されたシステムで。
{::data-source {:db-spec db-spec}
::get-article-by-id {:data-source (ig/ref ::data-source)}
::get-article-handler {:get-article-by-id (ig/ref ::get-article-by-id)}
::router {:route->handler {:get-article (ig/ref ::get-article-handler)}}}
システムが大きくなってきたら、まとまったモジュールに分割し、モジュールごとにマップを作成し、それをマージしてより大きなシステムを構成することができます。また、ig/pre-init-spec
を利用して、依存関係が予想通りであることを保証することができます。
(s/def ::data-source #(instance? javax.sql.DataSource %))
(defmethod ig/pre-init-spec ::get-article-by-id [_]
(s/keys :req-un [::data-source]))
プロトコルはここでうまく機能します。satisfies?
を使って検証し、システムが初期化されるときに配線の問題をキャッチすることができます。
(s/def ::article-repository #(satisfies? ArticleRepository %))
(defmethod ig/pre-init-spec ::get-article-handler [_]
(s/keys ::req-un [::article-repository]))
(defmethod ig/init-key ::get-article-handler [_ {:keys [article-repository]}]
#(server/get-article article-repository %))
ComponentやIntegrantのようなフレームワークは、大規模なシステムを構築する際に役立ちますが、フレームワークによる設計上の決定は、プロジェクト内の設計に影響を与える可能性があるため、周辺に留めておくように注意する必要があります。もし、フレームワークの制限を満たすために、内部コードで特定のデータ構造を使わざるを得なくなった場合、それがあなたのアーキテクチャを助けているのか、邪魔しているのかを考える必要があります。
安定性
安定性の方向に依存する
— 安定依存の原則
どんなシステムにも安定な部分と揮発性の部分があります。Clojureでは、関数は参照透過であれば安定していると考えることができます。逆に、外の世界と話す、あるいは参照透過でないものに依存する関数は揮発性であると考えることができます。
このアプリケーションの例では、データベースが揮発性のコンポーネントであり、その結果、サーバーとシステムの名前空間が不安定になります。図中の赤い矢印は、依存関係によって揮発性を持つコンポーネントを示しています。
抽象化の導入により、server
名前空間はもはや volatile 依存ではなく、安定したコンポーネントとなりました。
ArticleRepository
プロトコルを用いた代替実装では、プロトコルを指し示す薄緑色の矢印が追加で表示されています。
事例紹介 プロジェクト管理アプリケーション
このような些細な例では、デカップリングの利点はおそらくそれほど明白ではありません。ユーザーがプロジェクトを作成し、プロジェクト内のチケットを管理し、チケットの状態が変更されると通知が送信される、より複雑なプロジェクト管理アプリケーションの例について考えてみましょう。
マネージドサービスは揮発性のコンポーネントであり、プロジェクト全体で直接実装が必要となるため、すべてが揮発性の密結合システムとなる。システム内のいくつかのコンポーネントは揮発性でなければなりませんが、すべてが揮発性であることは望ましくありません。
レイヤーアーキテクチャ
六角形アーキテクチャ、オニオンアーキテクチャ、クリーンアーキテクチャ、機能コア、インペラティブシェルなど、いずれもハイレベルなビジネスロジックをアプリケーションの中核に据え、データベースやトランスポートプロトコルなどの低レベルの詳細を周辺に置くためにレイヤーを利用しています。
プロジェクト管理アプリケーションをレイヤーに分割し、コアドメインと実装の詳細を分離することができます。その結果、抽象化のみに依存し、外部の依存関係を全く知らない「ドメイン」レイヤーが生まれます。抽象化は infrastructure
層で実装され、システムを形成するために一緒に配線されます。
レイヤーアーキテクチャにはより多くのノードがあり、抽象化が導入されたエッジも増えているが、グラフはそれほど深くなく、ドメイン内のすべてのコンポーネントが安定した状態になっている。インフラストラクチャの揮発性コンポーネントは、ドメインレイヤーの抽象化のための実装を提供し、ドメインレイヤー内の関係は変わらないが、抽象化を通じて提供されるようになった。
結論
物事を単純化すると、多くの場合、物事が増えてしまうことを認識すること。簡素化とは、数を数えることではありません。結ばれているものがいくつかあるよりも、ねじれたりせず、まっすぐきれいにぶら下がっているものが多いほうがいいんです。それに、別々にすることで美しいのは、それを変える能力が格段に上がることで、そこにメリットがあると思うんです。
— Rich Hickey
Simple Made Easy
WhatとHowを厳密に分けることが、Howを他人事とするための鍵です。これがうまくいくと、Howの仕事を他の人に押し付けることができます。データベースエンジンは、これをどうやるか、ロジックエンジンは、これをどう検索するか、と言うことができるのです。私は知らなくてもいいんです。
— Rich Hickey
Simple Made Easy
シンプルで長期的に保守可能な大規模システムを設計しようとするなら、抽象化は基本です。自分のコードベースを見て、どこが特定の実装に縛られているか、グラフを描いてすべての矢印がどこに向いているか、揮発性のコンポーネントを見つけ、モジュールを切り離し、「何を」「どのように」分離するために抽象化を導入することを検討してみてください。
ソフトウェア設計はよく研究され理解されている問題で、パターンや原則は今世紀に入ってから広く採用され、その解決策が基づいているアイディアの多くはその何十年も前に生まれたものです。これらのアイデアの多くは、関数型プログラミングにも同様に適用可能であり、長期的に保守可能なアプリケーションを設計するのに役立ちます。
Image credit: jackrusher Generative art crafted in Clojure!