Полагаться на абстракции, а не на конкреции
В первые несколько лет реализации программного проекта можно добиться многого. С небольшой командой и правильными инструментами мы можем быстро предоставить функциональные особенности, которые удовлетворят как компании, так и их клиентов. На ранних стадиях проекта доставка часто приоритетнее архитектуры, но архитектура должна развиваться, если мы хотим, чтобы программное обеспечение разрабатывалось и поддерживалось в долгосрочной перспективе. Любой проект, который живет дольше нескольких лет, будет претерпевать изменения. Бизнес-требования поменяются, разработчики приходят и уходят, сервисы платформы, от которых зависит программное обеспечение, устаревают и заменяются, среды развертывания меняются, а новые технологии предлагают новые возможности, в то время как прежние технологии устаревают.
Абстракция лежит в основе архитектуры программного обеспечения. Отделение what
(что) от how
(как), позволяет нам сосредоточиться на основной бизнес-проблеме, которую мы пытаемся решить, не теряясь в деталях конкретной реализации.
Абстракция
Следует полагаться на абстракции, а не на конкреции.
-- Принцип инверсии зависимостей
Конкреция - это когда мы полагаемся на имплементации, а не абстракции. Рассмотрим следующее приложение, которое предоставляет REST API для получения статьи блога из базы данных SQL:
(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 %)
Теперь мы можем передать функцию в server/get-article
, куда ранее мы передали data-source
, в результате чего получим чистую функцию:
(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 %)
Последнее изменение необходимо внести в маршрутизатор; нам нужен способ передачи обработчика. В нашем примере показан один маршрут, но на практике, скорее всего, их будет много; мы можем использовать функцию, которая принимает ключ route
и возвращает обработчик:
(fn route->handler [route]
(fn handler [request]
response))
Мы можем реализовать route->handler
с помощью карты:
{: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)}]]))
На первый взгляд, это может показаться небольшим изменением, но версия, прошедшая рефакторинг, существенно отличается: теперь функции являются чистыми и могут быть протестированы изолированно без запущенной базы данных.
Мы можем использовать вместо get-article-by-id
любую функцию с такой же сигнатурой без каких-либо изменений в коде app.server
. Это может быть стаб/мок во время тестирования, обертка, добавляющая мониторинг/логирование/кэширование, или альтернативная реализация, которая получает статью из другой базы данных или внешнего источника.
Маршрутизатор имеет единственную обязанность: направить входящий запрос в обработчик. Обработчик также несет единственную ответственность: преобразование входящего запроса во внутренний вызов, где выполняется действие.
Протоколы
Протокол — это еще одна форма абстракции, которую мы можем использовать для разделения модулей. Такой подход в большей степени объектно-ориентированный, чем функциональный, но протоколы могут быть полезны, когда мы хотим получить более формальную абстракцию, или когда имеет смысл сформировать целостный набор моделей поведения. В предыдущем примере мы использовали единственную функцию для абстракции при извлечении статьи; в качестве альтернативы можно использовать паттерн репозитория, популярный в Domain Driven Design (Предметно-ориентированное проектирование):
(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}))
Интерфейс в этом примере является более формальным, в том смысле, что мы запрашиваем пространство имен хранилища и ссылаемся на него напрямую, что отличается от первоначального использования ссылки на функцию, определенную через defn
, поскольку мы ссылаемся на абстракцию, а не на протокол, определенный в app.db
.
Он более многословен, но передавать экземпляры протоколов может быть предпочтительнее, чем передавать множество функций, особенно когда требуется много тесно связанных между собой моделей поведения; в конечном счете, это сводится к вопросу стиля. Одним из преимуществ протоколов перед функциями является то, что читателю более понятно, от чего зависит функция; мы используем 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
...))
Одним из компромиссов такого подхода является то, что он приводит к дополнительной проводке; функции должны быть переданы их зависимостям и связаны вместе, чтобы сформировать систему. Перенаправление означает, что мы больше не можем перейти к определению get-article-by-id
в пространстве имен сервера. Стоит ли идти на компромиссы — решать вам; абстракция может оказаться преждевременной для недолговечных проектов, но развязка поможет обеспечить более крупные проекты легкостью сопровождения.
Составление больших систем может быть сложной задачей. Процессы должны запускаться и останавливаться в определенном порядке, а граф зависимостей может получиться большим, если следовать принципу инверсии зависимостей. Мы часто обращаемся к таким фреймворкам, как Component или Integrant, для помощи в построении больших систем, на момент написания статьи Integrant является рекомендуемым вариантом на нашем Clojure Radar.
Код для построения системы с помощью 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
больше не зависит от волатильной зависимости; теперь это стабильный компонент.
Альтернативная реализация с протоколом ArticleRepository
имеет дополнительные стрелки, показанные светло-зеленым цветом, которые указывают на него:
Пример: Приложение для управления проектами
На таком тривиальном примере преимущества развязки, возможно, не столь очевидны. Рассмотрим более сложный пример приложения для управления проектами, в котором пользователи могут создавать проекты и управлять тикетами в рамках проекта, с отправкой уведомлений при изменении статуса тикета:
Управляемые сервисы являются нестабильными компонентами, и их имплементация требуется непосредственно в ходе проекта, что приводит к созданию тесно связанной системы, в которой все является волатильным. Хотя некоторые компоненты в системе должны быть изменчивыми, крайне нежелательно, чтобы полностью все в ней было волатильным.
Многослойные архитектуры
Hexagonal (гексагональная) архитектура, Onion (луковичная) архитектура, Clean (чистая) архитектура и Functional Core(функциональное ядро), Imperative Shell (императивная оболочка) — все они помещают высокоуровневую бизнес-логику в ядро приложения и используют слои для того, чтобы низкоуровневые детали, такие как база данных и транспортные протоколы, оставались на периферии.
Мы можем разделить приложение для управления проектами на слои, отделив основной домен от специфики реализации. В результате получается слой domain
, который зависит только от абстракций и не знает о внешних зависимостях; обратите внимание, что стрелки никогда не направлены от домена к слою инфраструктуры. Абстракции реализуются в слое infrastructure
и соединяются вместе, образуя систему:
В многоуровневой архитектуре больше узлов и больше границ, где были введены абстракции, но граф не такой глубокий, и все компоненты в домене теперь стабильны. Нестабильные компоненты инфраструктуры обеспечивают имплементации абстракций в доменном слое, связи в доменном слое остаются прежними, но теперь обеспечиваются с помощью абстракций.
Заключение
Осознайте, что когда вы упрощаете что-то, зачастую вы в конечном итоге получаете гораздо больше. Упрощение - не означает подсчитать количество. Я бы предпочел иметь больше вещей, висящих красиво, ровно, не скрученных вместе, чем всего пару вещей, завязанных в узел. И самое прекрасное в том, чтобы разделить их, - тогда у вас будет гораздо больше возможностей изменить их, а именно в этом, как мне кажется, и заключается главное преимущество.
-- Рич Хикки (Rich Hickey) Простое сделать легким
Строгое разделение "что" и "как" — это ключ к тому, чтобы сделать "как" чьей-то проблемой. Если вы сделали это действительно хорошо, то можете переложить работу над "как" на кого-то другого. Вы можете сказать: "Механизм базы данных — выясни, как сделать эту вещь" или " Логическая схема — выясни, как найти это". Мне не нужно вникать.
-- Рич Хикки (Rich Hickey) Простое сделать легким
Абстракции очень важны, если мы стремимся разрабатывать большие системы, которые будут простыми и удобными для обслуживания в долгосрочной перспективе. Взгляните на свою собственную кодовую базу и определите, где вы привязаны к определенной реализации, нарисуйте граф и посмотрите, куда указывают все стрелки, найдите нестабильные компоненты и рассмотрите возможность внедрения абстракций для разделения модулей и отделения what
от how
.
Проектирование программного обеспечения — это хорошо изученная и понятная задача; его паттерны и принципы широко применяются с начала века, а многие идеи, лежащие в основе решений, появились за несколько десятилетий до этого. Многие из этих идей в равной степени применимы к функциональному программированию, и они могут помочь нам разрабатывать приложения, которые будут поддерживаться в долгосрочной перспективе.
Приглашаем всех желающих на открытый урок «Написание игры "Game Of Life" на Clojure». На этом уроке вы увидите, как классическая задача computer science — Game of Live, может быть реализована на Clojure. Также мы обсудим разные способы визуализации работы алгоритма, как представить состояние игры с помощью персистентных структур данных и как вести разработку интерактивно через REPL. Записаться на урок можно на странице курса "Clojure Developer".