Системы в Clojure
В этой главе мы поговорим о системах. Рассмотрим, как составить проект из отдельных частей и заставить их работать вместе.
Содержание
- Подробнее о системе
- Зависимости
- Преимущества
- Подготовка к обзору
- База данных
- Docker
- Mount
- Component
- Integrant
- Заключение
Понятие системы тесно связано с конфигурацией, которую мы подробно рассмотрели недавно. На этапе черновика конфигурация и системы составляли одну главу, но со временем стало ясно, что это отдельные темы. Причина кроется не столько в объеме материала, сколько в семантике. Конфигурация и система это не одно и то же.
Внимание! Вы читаете черновик к книге “Clojure на производстве”. Для книги я переписывал его много раз, но в блоге осталась старая версия. Здесь она для истории, а вам я рекомендую купить книжку.
Конфигурация учит тому, как получить параметры из внешнего мира. Как выстроить этот процесс согласно производству и пожеланиям команды. Система означает внутреннее устройство программы. Это набор компонентов с логическими связями и иерархией.
Система зависит от конфигурации, но не строго один к одному. Для одной и той же конфигурации бывают разные системы и наоборот. Главное отличие в следующем: конфигурация отвечает на вопрос как получить параметры, а система знает, как ими распорядиться.
Подробнее о системе
Понятие системы появилось в момент, когда возник спрос на долгоиграющие приложения. При разработке скриптов или утилит этот вопрос не стоял остро. Время работы скрипта обычно коротко, и его состояние тоже живет недолго. При завершении программы занятые ресурсы освобождаются, поэтому нет нужды в особом контроле за ними.
Это не так при работе с серверными приложениями. Они рассчитаны на постоянную работу и потому устроены иначе, чем разовые скрипты. Приложение состоит из компонентов, которые работают в фоне. Каждый компонент выполняет какую-то узкую задачу. При запуске приложение включает компоненты в правильном порядке и строит между ними связи.
Компонент это любой объект, который несет состояние. На него действуют две операции: включить и выключить. Как правило, включить компонент означает установить соединение с каким-либо ресурсом, а выключить — закрыть его.
Типичные компоненты веб-приложения это HTTP-сервер, база данных и кэш. Так, чтобы не открывать новое TCP-соединение на каждый запрос к базе, понадобится пул соединений. Но создавать его вручную и передавать в функции это слишком рутинная задача. Должен быть компонент, который при включении открывает пул соединений и хранит его внутри. Для сторонних потребителей компонент предлагает функции для работы с БД. Внутри эти функции опираются на открытый пул.
На первый взгляд схема напоминает концепцию ООП и ее принцип инкапсуляции. Но в мире Clojure компоненты работают иначе. Пусть читатель не торопится с выводами. Ниже мы рассмотрим техническую разницу между объектами и компонентами.
Зависимости
Центральная проблема систем это зависимость компонентов друг от друга. В примере выше все компоненты — сервер, база и кэш — работают обособленно. Например, подключение к базе требуется только для отдельных страниц, а кэширование устроено так, что неработающий кэш не ломает приложение. Говорят, что это базовые компоненты системы.
Компоненты высокого уровня опираются на базовые. Примером такого компонента может быть фоновый поток, который читает данные из базы и отправляет письма. Будет неправильно, если компонент откроет собственные подключения к обоим ресурсам. Вместо этого он принимает уже запущенные компоненты базы и почты и работает с ними как с черным ящиком.
Важно, что компонент не пытается управлять жизненным циклом его зависимостей. В момент старта дочерние компоненты части уже запущены. Порядок запуска и остановки компонентов в правильном порядке остается на откуп системы.
Система это комбинация компонентов с учетом их зависимостей. Ключевая обязанность системы — запустить и остановить дерево компонентов в правильном порядке. Например, если компонент A зависит от B и C, то к моменту запуска A последние два уже должны быть запущены. При завершении программы компонент C нельзя тушить до тех пор, пока работает A.
Технически задача сводится к построению графа зависимостей между компонентами. При запуске и выключении нужно обойти этот граф так, чтобы удовлетворить запросы каждого компонента.
К продвинутым системам предъявляют дополнительные требования. Например, не допустить ситуацию, когда система запущена частично. Такое возможно, когда один из компонентов выбросил исключение. Включился сервер, почта, а база данных недоступна.
Это досадная ситуация, потому что мы застряли в пограничном состоянии. С точки зрения программы запуск системы не удался. Если починить БД и включить систему во второй раз, мы получим другую ошибку. Система попытается включить компоненты, которые уже работают, что приведет к конфликту в сетевых подключениях.
Будет правильно, если дойдя до проблемного компонента, загрузчик системы не вывалится с исключением, а поместит его в переменную. Затем пойдет в обратном направлении и выключит все компоненты, что уже успел включить. И только потом выбросит исключение, которое поймал.
Система должна быть устроена так, чтобы было легко добавить в нее новый компонент. Чтобы достичь этого, система должна быть описана декларативно. В идеальном случае система это структура данных — комбинация словарей и списков. Код загрузки пробегает по этой структуре и включает компоненты. Расширить систему означает добавить новый узел в коллекцию.
Когда система знает о зависимостях компонентов, появляется приятная возможность запустить ее подмножество. Например, нужно отладить работу фонового обработчика почты. Это компонент, который зависит от базы и SMTP-сервера. Веб-сервер и кэш в данном случае не нужны, и запуск всей системы будет излишним. Продвинутые системы предлагают функцию с семантикой “запусти это компонент и все его зависимости”.
Преимущества
На первый взгляд кажется, что система — лишнее усложнение в проекте. Это дополнительная библиотека, новые соглашения в команде, рефакторинг кода и ограничения. Но первичные неудобства окупаются со временем.
Система приводит проект в порядок. С ростом кодовой базы становится критически важным, чтобы разные части проекта были выполнены в одном стиле. Это особенно удобно, когда продукт состоит из отдельных сервисов. В таких случаях разработчиков выручает повторное использование кода, в том числе банальная “копипаста”.
Системы полезны на стадиях производства, особенно тестировании. Для тестов запускают измененную версию системы, где отдельные компоненты работают по-другому. Предположим, для отправки смс в систему подкладывают компонент, который пишет сообщения в файл.
Подобно конфигурации, у системы свой жизненный цикл. Понимая этот цикл, мы можем
вмешиваться в работу системы и вносить корректировки. Например, на стадии
определения системы внедрить операторы if
, when
, cond->
, чтобы добавить
или убрать компоненты в зависимости от конфигурации.
Подготовка к обзору
В главе об изменяемых данных мы впервые заговорили о
системах. Это был раздел про alter-var-root
, и мы только что узнали, как
менять глобальные переменные. Идея в том, чтобы вынести каждый компонент в
модуль и снабдить его функциями start!
и stop!
. Функции включают и выключают
состояние модуля: веб-сервер, базу данных и так далее. Запуск системы сводится к
вызову функций в правильном порядке.
Это слабое, любительское решение. Такая система не знает о зависимостях между компонентами. Она хрупкая, работает в ручном режиме, и любое ее изменение требует проверок.
В оставшейся части главы мы не будем писать свою систему. В этом нет смысла, потому что при разработке системы важна ее семантика, идеологическая часть. Когда семантика найдена, написать код становится тривиальным занятием.
В мире Clojure существует несколько библиотек для работы с системами. Мы рассмотрим проекты mount, component и integrant. Их кодовые базы невелики, каждая по нескольку сотен строк кода. Библиотеки различаются принципами, идеологией при построении систем. Они исповедуют разный подход для описания компонентов и связей между ними. Тем интересней будет взглянуть на проблему с разных сторон.
Библиотеки специально расставлены в таком порядке. Mount следует первым, потому что с него легче начать. Он будет удачным выбором для начинающих. Component стал промышленным стандартом. Мы уделим ему больше внимания и поэтому поставим в середину. Integrant замыкает обзор, потому что его рассматривают как альтернативу Component, с котором читатель уже должен быть знаком.
В практических примерах нам бы хотелось избежать банальности. Вместо наивных
foo
и bar
мы построим настоящую систему, похожую на промышленные
аналоги. Система состоит из веб-сервера, базы данных и фонового процесса,
который дополняет базу из внешнего источника.
Схема специально устроена так, чтобы в ней встречались зависимые компоненты. Например, фоновый процесс нуждается в компоненте базы данных. Так мы научимся работать с зависимостями. Топологию системы можно изобразить так:
┌────────────┐
│ Config │┼──────────────────┐
└────────────┘ │
┼ │
┌───────┴──────┐ │
│ │ │
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Server │ │ Database │┼───│ Worker │
└────────────┘ └────────────┘ └────────────┘
Стрелки на схеме означают зависимости. Выражение A → B означает “А зависит от В”. В нашей системе все компоненты зависят от конфигурации. Дополнительно фоновый обработчик зависит от базы данных. Над этой топологией мы и будем работать до конца главы.
База данных
Отдельно рассмотрим компонент базы данных. В прошлых главах мы упоминали, что открывать новое соединение на каждый запрос неоптимально. В настоящих проектах с базой работают через пул соединений. Пул это сущность с внутренним состоянием, поэтому его тоже “включают и выключают”.
Другие обучающие материалы приводят примеры с in-memory базами данных: SQLite, H2. Это базы, которые хранят данные в оперативной памяти без доступа к диску. Преимущество в том, что такие базы не нужно устанавливать в систему и запускать в отдельном процессе. Как правило, исходный код этой базы слинкован с библиотекой для вашего языка. Достаточно подключить зависимость в проект и работать с базой как с модулем.
Вариант с базой в памяти удобен для быстрого старта, но не отражает реалии производства — то, к чему мы стремимся в этой книге. Для in-memory-баз не используют пулы соединений. В этом нет смысла, потому что данные лежат в памяти процесса, а не в сети. Поэтому мы используем настоящую реляционную БД PostgreSQL в связке с пулом HikariCP.
В нашей системе работает фоновый процесс, воркер. Это отдельный поток, который выбирает из базы не до конца заполненные записи и извлекает нужную информацию из сети. В главе про изменяемость мы рассматривали случай с гео-координатами. Мобильное приложение отправляет текущие широту и долготу, и мы записываем их в базу. Поскольку вывод данных о локации может быть долгим, мы делаем это в фоне.
Для этой главы мы подобрали другой пример. Предположим, фирма ведет свою аналитику посещений. Каждый раз, когда кто-то открывает страницу, приложение записывает в базу ее адрес и базовые поля запроса: заголовок User-Agent, IP-адрес клиента. Чтобы строить отчеты в разрезе стран и городов, нужно извлечь эти данные из тех, что у нас на руках. Чаще всего это дорогая операция, поэтому записи присваивают статус “ждет обработки” и выносят логику в фон.
Docker
Возможно, у вас уже установлен PostgreSQL. Тогда вам останется только создать новую базу и таблицу в ней. Если нет, самое время попробовать Docker. Это система для запуска приложений из образов. Под образом понимают специальный файл, внутри которого спрятано приложение со всем необходимым для запуска. Запущенный образ называют контейнером.
У контейнеров несколько преимуществ. Приложение запускается в изолированной среде и тем самым отделено от основной системы. Кроме безопасности, это решает проблему чистоты — по завершении контейнер не оставляет следов работы, если это не настроено специально.
Существует публичный репозиторий образов, откуда Docker их скачивает. Производители ПО выкладывают образы программ в различных версиях и комплектации. Например, если приложению нужна БД версии 9.3 с определенными расширениями, просто скачайте этот образ. Локальная установка, скорее всего, вступит в конфликт с уже имеющейся БД.
Некоторые образы подготовлены так, что их удобно настроить переменными среды или
файлами. Когда вы запускаете базу, то, скорее всего, ожидаете определенные
таблицы и данные. Образ postgres устроен так, что при старте он загружает все
*.sql
файлы из папки /docker-entrypoint-initdb.d
. Если сопоставить этой
папке локальный том с sql-схемой и миграциями, получится готовая база без
строчки кода.
В комплекте с Docker идет утилита docker-compose. Она запускает контейнеры на
базе файла конфигурации. Стандартное имя этого файла docker-compose.yaml
. Это
YAML-документ , где перечислены образы и параметры их запуска. В примере ниже
указан один контейнер, postgres с определенными портами, томами и переменными
среды.
version: '2'
services:
postgres:
image: postgres
volumes:
- ./initdb.d:/docker-entrypoint-initdb.d
ports:
- 5432:5432
environment:
POSTGRES_DB: book
POSTGRES_USER: book
POSTGRES_PASSWORD: book
В папке initdb.d
лежат sql-файлы для старта базы. Нам достаточно файла
01.init.sql
с одной таблицей:
drop table if exists requests;
create table requests (
id serial primary key,
created_at timestamp with time zone
not null default now(),
path text not null,
ip inet not null,
is_processed boolean not null default false,
zip text,
country text,
city text,
lat float,
lon float
);
Если запустить docker-compose up
, то через некоторое время в системе
заработает сервер PostgreSQL на 5432 порту и готовой базой book.
Мы не будем уделять внимание установке Docker и тонкостям его настройки, поскольку эта тема заслуживает отдельной книги. Всю необходимую информацию вы найдете на официальном сайте проекта — docker.com.
Mount
Библиотека Mount описывает сущности с двумя состояниями: запущено и остановлено. По команде сущность “включается” и принимает значение, которое вернул код из фазы старта. При выключении срабатывает код из фазы останова.
Mount использует некоторые трюки, чтобы максимально упростить работу программисту. Ее сущности напоминают глобальные переменные, которые меняют значения по команде. С точки зрения потребителя mount устроен просто, и потому удобен для начинающих.
Центральная точка библиотеки это макрос defstate
, который объявляет новую
сущность. Макрос похож на форму def
: он тоже объявляет переменную в текущем
пространстве. Разница в том, что вместо значения defstate принимает два
выражения: первое для старта, второе для остановки.
Пока сущность не включена, в переменной хранится особое значение
DerefableState
. Когда ее включили, переменная принимает значение, которое
вернуло выражение старта. Выключение работает слегка по-другому. Выполняется код
останова, и переменная становится особым значением NotStartedState
.
Первая сущность
Опишем с помощью Mount компонент веб-сервера. Поместим его в отдельный модуль в
файле src/book/systems/mount/server.clj
. Приложение app это функция, которая
возвращает одинаковый ответ для любого запроса.
(ns book.systems.mount.server
(:require
[mount.core :as mount :refer [defstate]]
[ring.adapter.jetty :refer [run-jetty]]))
(def app (constantly {:status 200 :body "Hello"}))
(defstate server
:start (run-jetty app {:join? false :port 8080})
:stop (.stop server))
В макросе defstate
фазы отделены ключами :start
и :stop
. Пока что мы
только объявили состояние, но ничего не включили. Если выполнить выражение
server, увидим примерно следующее:
#mount.core.DerefableState[{:status :pending, :val nil} 0x358e34df]
Чтобы запустить компонент, выполните (mount/start)
. Функция пробегает по всем
объявленным сущностями и включает их. Выражение (run-jetty ...)
под ключом
:start
возвращает объект сервера, который работает в фоне. После запуска
проверьте, что сервер работает: по адресу http://127.0.0.1:8080 браузер покажет
приветствие. Переменная server
станет объектом класса Server
из пакета
jetty:
(type server)
org.eclipse.jetty.server.Server
Чтобы выключить систему, выполните (mount/stop)
. Обратите внимание, что в
выражении (.stop server)
сущность обращается к самой себе. Это нормально,
потому что на этапе выключения переменная server будет именно тем, что мы
ожидаем. После остановки server станет особым значением, которое означает
завершение.
(type server)
mount.core.NotStartedState
По такому принципу строят систему. Сперва находят сущности, которые работают в течение всего жизненного цикла программы. Как правило, это сетевые подключения или фоновые задачи. Затем выносят их в отдельные модули, где описывают логику включения и завершения.
Связь с конфигурацией
В примере выше мы допускаем ту же ошибку, что и в прошлой главе. Параметры сервера “захардкожены” на месте его запуска. Это затрудняет работу с проектом. Параметры каждой сущности должны приходить из конфигурации.
Мы уже научились работать с конфигурацией и знакомы с cprop, yummy и
аналогами. В прошлый раз мы запускали ее вручную функцией
(load-config!)
. Поскольку мы строим систему, логично вынести конфигурацию в
отдельный компонент. При его запуске срабатывают все шаги, что мы рассмотрели
ранее: чтение файла и переменных среды, вывод типов и валидация.
Поместим конфигурацию в модуль src/book/systems/mount/config.clj
. Для
краткости опустим описание спеки ::config
: пусть это будет только проверка на
словарь. Фаза :start
читает edn-файл, проверяет спекой и возвращает
данные. Замените :start
на вызов библиотеки Yummy, Aero или свое решение.
(ns book.systems.mount.config
(:require
[mount.core :as mount :refer [defstate]]
[clojure.spec.alpha :as s]
[clojure.edn :as edn]))
(s/def ::config map?)
(defstate config
:start
(-> "system.config.edn"
slurp
edn/read-string
(as-> config
(s/conform ::config config))))
Для компонента выше не нужна фаза :stop
. Конфигурация не содержит открытых
ресурсов, поэтому дополнительная логика останова не требуется.
Улучшим компонент сервера так, чтобы он зависел от конфигурации. Технически это сводится к тому, чтобы импортировать в модуль сервера объект конфигурации и работать с ним как с обычным словарем.
Пусть файл system.config.edn
содержит словарь, где ключ это имя компонента, а
значение — словарь его параметров. Поместим параметры сервера под ключ :jetty
:
{:jetty {:join? false :port 8088}}
Обновим модуль сервера. В список :require
добавим импорт конфигурации:
[book.systems.mount.config :refer [config]]
Перепишем компонент так, чтобы параметры приходили из словаря:
(defstate
server
:start
(let [{jetty-opt :jetty} config]
(run-jetty app jetty-opt))
:stop (.stop server))
На этом этапе у нас уже получилась система из двух компонентов, где один зависит
от другого. Убедитесь, что после вызова (mount/start)
сервер работает как
ожидалось.
База данных
Подготовим компонент для работы с базой данных. Понадобяться две библиотеки:
clojure.java.jdbc
и hikari-cp
. Первая предлагает универсальный доступ к
реляционным базам данных. Это набор функций, которые работают одинаково для
разных типов баз. Например, выражения:
(jdbc/get-by-id db :users 42)
(jdbc/insert! db :users {:name "Ivan" :email "ivan@test.com"})
прочитают и запишут нового пользователя в PostgreSQL, MySQL или Oracle. Для каждого бекенда JDBC построит правильный SQL-запрос.
Каждая jdbc-функция принимает первым параметром то, что называется JDBC-спекой. В простом случае это словарь с параметрами подключения к базе: адрес и порт сервера, имя базы, пользователь и пароль. На каждый запрос JDBC создает новый источник данных с этими параметрами, открывает соединение, обменивается данными и закрывает его.
Спека может содержать необязательный ключ :datasource с уже подготовленным
источником. Тогда JDBC игнорирует другие ключи и работает напрямую с
:datasource
. Библиотека hikari-cp предлагает функцию, чтобы построить источник
данных с пулом соединений. Каждый раз, когда мы запрашиваем соединение у
источника, мы не открываем новое, а получаем одно из созданных ранее.
Поскольку пул это объект с жизненным циклом, его выносят в компонент. Подготовим
модуль src/book/systems/mount/db.clj
:
(ns book.systems.mount.db
(:require
[mount.core :as mount :refer [defstate]]
[hikari-cp.core :as cp]
[book.systems.mount.config :refer [config]]))
(defstate db
:start
(let [{pool-opt :pool} config
store (cp/make-datasource pool-opt)]
{:datasource store})
:stop
(-> db :datasource cp/close-datasource))
На этапе старта мы возвращаем JDBC-спеку — словарь с одним ключом
:datasource
. На стадии выключения функция close-datasource
закрывает пул и
все открытые соединения.
В файл конфигурации добавим настройки пула:
{:pool {:minimum-idle 10
:maximum-pool-size 10
:pool-name "book-pool"
:adapter "postgresql"
:username "book"
:password "book"
:database-name "book"
:server-name "127.0.0.1"
:port-number 5432}}
Для экономии места обозначим только самые важные параметры. Это свойства подключения (адрес и имя базы, пользователь, пароль) и размерность пула. Дополнительно можно задать тайминг для каждой стадии работы с БД. Полный список параметров с их описанием перечислен на странице GitHub-проекта.
Запустите систему и выполните простой запрос:
(mount/start)
(require '[clojure.java.jdbc :as jdbc])
(jdbc/query db "select 42 as answer")
;; ({:answer 42})
Фоновая задача
Все готово для последнего и самого сложного компонента системы. Это фоновый процесс, воркер, который работает в отдельном потоке. Процесс выбирает из базы не обработанные записи и дополняет пустые поля данными из стороннего сервиса.
Напомним, что в таблице requests мы храним дату, адрес страницы и IP-адрес
клиента. Поле is_processed
это признак того, была ли уже обработана
запись. Искомые поля city
, country
и другие по умолчанию не заполняются и
равны null
.
Задача воркера сводится к тому, чтобы каждый интервал времени запрашивать одну
запись с флагом NOT is_processed
. Затем сделать запрос к сервису, который
вернет гео-данные по IP. Обновить поля записи и сохранить изменения в базе. Все
описанное делать в транзакции.
Подумаем, как выразить воркер в терминах Mount. Поскольку задача работает в отдельном потоке, очевидно это будет тред или футура с бесконечным циклом. Но мы бы хотели остановить воркер по запросу. Значит, цикл должен быть не бесконечным, а с каким-то условием. Например, с проверкой состояния на каждом шаге. Это состояние должно быть доступно и воркеру, и стороннему наблюдателю.
В Clojure эта задача решается парой атом и футура. В атоме хранят логический
флаг — признак продолжения цикла. На каждом шаге футура проверяет флаг, и если
он истина, то в очередной раз выполняет задачу. Чтобы корректно завершить
футуру, мы совершаем два действия. Первое — устанавливаем флаг в ложь. Второе —
ждем до тех пор, пока футура не станет realized
. В терминах Clojure это
значит, что футура завершила работу с некоторым результатом.
Подготовим модуль воркера. Понадобится импорт конфигурации, компонента базы, логирование и HTTP-клиент:
(ns book.systems.mount.worker
(:require
[mount.core :as mount :refer [defstate]]
[clojure.java.jdbc :as jdbc]
[clj-http.client :as client]
[clojure.tools.logging :as log]
[book.systems.mount.db :refer [db]]
[book.systems.mount.config :refer [config]]))
Технически компонент сводится к словарю с полями :flag
и :task
. Первое поле
хранит атом состояния, а второе футуру.
(defstate worker
:start
(let [{task-opt :worker} config
flag (atom true)
task (make-task flag task-opt)]
{:flag flag :task task})
:stop
(let [{:keys [flag task]} worker]
(reset! flag false)
(while (not (realized? task))
(log/info "Waiting for the task to complete")
(Thread/sleep 300))))
В фазе :start
мы создали состояние и футуру и вернули их композицию в виде
словаря. Функции make-task
пока что не существует, но мы считаем, что она
вернет футуру. В фазе :stop
мы возводим состояние в ложь и бесконечно ждем до
тех пор, пока футура не завершится. Идея в том, что flag
доступен внутри
футуры, и на очередном шаге она должна проверить его статус и завершиться.
Код выше учит хорошей практике, о которой следует сказать явно. Код компонента
не должен быть большим. В компоненте должна быть только его логика, набор
шагов. Технически возможно описать футуру прямо внутри defstate
. Но это займет
лишних десять строк, и жизненный цикл компонента станет читаться хуже.
Добавим в EDN-файл параметры воркера. Достаточно одного поля — сколько миллисекунд ждать на каждом шаге цикла.
{:worker {:sleep 1000}}
Опишем функцию make-task
. Она принимает состояние и словарь параметров.
(defn make-task
[flag opt]
(let [{:keys [sleep]} opt]
(future
(while @flag
(try
(task-fn)
(catch Throwable e
(log/error e))
(finally
(Thread/sleep sleep)))))))
В примере (task-fn)
это целевая функция, в которой заключена бизнес-логика
приложения. Недостаточно просто вызвать эту функцию; нужно обернуть ее в цикл с
условием и перехватом ошибок, чтобы футура не завершилась аварийно. Даже если
произошло исключение, мы пишем его в лог и переходим на следующую итерацию.
Тем временем кто-то мог установить flag
в ложь. Если это произошло, мы выходим
из цикла while
и логика футуры завершается.
Теперь опишем task-fn
. Функция читает из базы записи, которые еще не были
обработаны. Для каждой записи ищем гео-данные по IP с помощью get-ip-info
. Мы
пока что не знаем, как работает эта функция, но уверены, что это словарь с
полями :city
, :country
и так далее. В конце мы обновляем запись этими
полями.
(defn task-fn
[]
(jdbc/with-db-transaction [tx db]
(let [requests (jdbc/query tx requests-query)]
(doseq [request requests]
(let [{:keys [id ip]} request
info (get-ip-info ip)
fields {:is_processed true
:zip (:postal_code info)
:country (:country_name info)
:city (:city info)
:lat (:lat info)
:lon (:lng info)}]
(jdbc/update! tx :requests
fields
["id = ?" id]))))))
Запрос на чтение мы вынесли в переменную requests-query, чтобы не отвлекаться на
него в коде task-fn
. Это SQL-выражение с оператором FOR UPDATE
. Оператор
означает, что выбранная запись блокируется на изменение другими клиентами.
(def requests-query
"SELECT * FROM requests
WHERE NOT is_processed
LIMIT 1 FOR UPDATE;")
Эффект FOR UPDATE
работает только в транзакции, поэтому тело функции обернуто
в (jdbc/with-db-transaction)
. Это макрос, внутри которого доступна
транзакционная версия соединения с базой. Символ tx
связан с транзакционным
соединением, полученным на основе db. Внутри макроса мы передаем в jdbc-функции
tx
, а не db
.
Осталось написать get-ip-info
. Это функция, которая обращается стороннему HTTP
API. В нашем случае это один из многочисленных сервисов, который принимает POST
запрос с полем ip и возвращает JSON-документ. В промышленных системах это могут
быть службы Google или собственная база адресов.
(defn get-ip-info
[ip]
(:body
(client/post "https://iplocation.com"
{:form-params {:ip ip}
:as :json})))
Если вызвать get-ip-info
с адресом из диапазона сетей Берлина, получим
следующий словарь:
(get-ip-info "85.214.132.117")
{:postal_code "12529"
:continent_code "EU"
:region_name "Land Berlin"
:city "Berlin"
:isp "Strato AG"
:region "BE"
:country_code "DE"
:country_name "Germany"
:time_zone "Europe/Berlin"
:lat 52.5167
:company "Strato AG"
:lng 13.4}
Тем самым мы описали последний неизвестный элемент воркера, и он готов к работе. Проверим его: запишем в базу несколько записей, запустим воркер и через некоторое время прочитаем их снова.
(jdbc/insert! db :requests {:path "/help" :ip "31.148.198.0"})
(mount/start)
;; wait for a while
(jdbc/query db "select * from requests")
({:path "/help" :ip "31.148.198.0" :is_processed true
:city "Pinsk" :zip "225710" :id 1
:lon 26.0728 :lat 52.1214 :country "Belarus"})
Поля заполнены верно, и мы переходим к последнему этапу.
Все вместе
Когда все компоненты готовы и работают по отдельности, осталось собрать их в
единую композицию. Это модуль, который импортирует все компоненты системы. Вызов
(mount/start)
из этого модуля запустит их все.
Единый модуль решает проблему потерянного компонента. Выше мы упоминали, что
функции start
и stop
работают только с теми компонентами, которые известны
Mount. Например, если загрузить модуль воркера, то Mount будет знать о
компонентах worker
, db
и config
. Модуль book.systems.mount.server
не
будет загружен, и система не узнает про компонент веб-сервера.
Объявим модуль book.systems.mount.core
. Предоставим функцию start
для
запуска системы целиком.
(ns book.systems.mount.core
(:require
[mount.core :as mount]
book.systems.mount.server
book.systems.mount.worker))
(defn start []
(mount/start))
В списке импорта мы не указали модули db
и config
. В нашем случае это не
обязательно. Модули server
и worker
зависят от них, поэтому компилятор
загрузит db и config автоматически.
Зависимости
В начале главы мы говорили о центральной проблеме систем. Это зависимости между компонентами, их разрешение и порядок обхода. Mount предлагает интересный способ решить эти задачи, и ниже мы рассмотрим его техническое устройство.
Наверное, читатель заметил, что при объявлении компонента мы не указываем его
зависимости. Так, worker нуждается в config и db, но об этом нигде не сказано
явно. Когда мы вызываем (mount/start)
, система каким-то образом угадывает
порядок запуска компонентов: config
, db
, worker
. Если переставить любые
два элемента в этом списке, система не запустится. Как это устроено?
Чтобы определить порядок компонентов, Mount полагается на компилятор Clojure. Пространства имен в Clojure работают примерно так же, как и система компонентов. При загрузке имен компилятор ищет зависимости в теле ns и загружает их первыми. Вспомним, как выглядит ns воркера:
(ns book.systems.mount.worker
(:require
[book.systems.mount.db :refer [db]]
[book.systems.mount.config :refer [config]]))
С точки зрения компилятора граф зависимостей выглядит так:
┌──────────────┐
│ mount.worker │
└──────────────┘
│ ┌────────────┐
├─┼│ mount.db │
│ └────────────┘
│ ┌───────────────┐
└─┼│ mount.config │
└───────────────┘
Компилятор не загрузит mount.worker
до тех пор, пока не разрешит
зависимости. Он начнет с модуля mount.db
. Его упрощенное определение:
(ns book.systems.mount.db
(:require
[book.systems.mount.config :refer [config]]))
Что с точки зрения компилятора:
┌──────────────┐
│ mount.db │
└──────────────┘
│ ┌───────────────┐
└─┼│ mount.config │
└───────────────┘
Теперь прежде чем загрузить db
, компилятор займется config
. Будем считать,
что config
не зависит других модулей, и поэтому будет загружен первым. Затем
компилятор загрузит db
. Дальше он поднимется обратно на уровень
mount.worker
. Модуль mount.db
загружен, следующий по списку
mount.config
. Но конфигурация уже была загружена на этапе mount.db
. В
Clojure модуль не может быть загружен больше одного раза, поэтому компилятор
пропустит mount.config
. И на последнем шаге загрузит mount.worker
.
Мы вывели порядок загрузки модулей: config
, db
, worker
. Это значит, что
каждая форма defstate
выполняется в такой же последовательности. В этом и
заключается трюк: каждый вызов defstate
увеличивает внутренний счетчик
Mount. В момент создания компонент запоминает это число. Сущности config
, db
и worker
получат номера 1, 2 и 3. Чтобы запустить систему, Mount сортирует
компоненты по возрастанию номера, а чтобы остановить — по убыванию.
Внутреннее устройство
Mount хранит сведения о компонентах в приватных атомах. Они недоступны сторонним модулям, но Clojure оставляет техническую возможность добраться до них. Когда компоненты загружены, выполните выражение:
(def _state @@(resolve 'mount.core/meta-state))
В переменной _state
окажется словарь компонентов. Двойной оператор @
играет
следующую роль. Функция resolve
по символу возвращает объект Var
. Из прошлых
глав мы помним, что это контейнер, который хранит внутри значение. Первый @
извлекает значение из Var
. Это атом со словарем. Второй @
извлекает словарь
из атома.
Ключ словаря это текстовая ссылка на компонент, например
#'book.systems.mount.config/config
. Ей сопоставлен другой словарь с полной
информацией о текущем состоянии компонента. Нас интересует поле :order
— тот
самый счетчик, по возрастанию которого нужно включать компоненты.
Расставим компоненты вручную. Видим, что порядок их загрузки верный:
(->> _state
vals
(sort-by :order)
(map #(-> % :var meta :name)))
;; (config server db worker)
Выражение
@@(resolve 'mount.core/running)
вернет словарь запущенных компонентов с похожей структурой. Атом state-seq
хранит глобальный счетчик компонентов. Чтобы прочитать его, выполните:
@@(resolve 'mount.core/state-seq)
Результатом будет число 4, что верно. Значения от 0 до 3 уже заняты нашими компонентами.
Заметим, что при работе с Mount мы не должны изменять его внутренние атомы. Примеры выше нужны для того, чтобы читатель лучше понял устройство библиотеки.
Состояние
Легкость, с которой компонент изменяется при вызове start
и stop
напоминает
магию. Макрос defstate
изящно скрывает технические шаги, которые срабатывают в
момент его работы. На нижнем уровне состояние работает на функции
alter-var-root
, которую мы рассмотрели в главе про изменяемость.
Вспомним, как мы описали компонент сервера:
(defstate server
:start (let [{jetty-opt :jetty} config]
(run-jetty app jetty-opt))
:stop (.stop ^Server server))
В общих словах, defstate
разворачивается в несколько определений. Это
глобальная переменная без значения:
(def server)
и две анонимные функции старта и останова. Тела функций это формы :start
и
:stop
.
(fn start []
(alter-var-root #'server
(fn [_]
(let [{jetty-opt :jetty} config]
(run-jetty app jetty-opt)))))
(fn stop []
(alter-var-root #'server
(fn [_]
(.stop ^Server server))))
Mount помещает анонимные функции и прочую информацию в атом meta-state
. Чтобы
включить компонент, достаточно найти в словаре функцию включения и вызвать
ее. Эта функция назначит переменной #'server
новое значение. Остановка
компонента работает аналогично.
Выборочный запуск
До сих пор мы запускали систему целиком. Вызов (mount/start)
без аргументов
пробегает по meta-state и стартует все компоненты. Но это не всегда
удобно. Предположим, мы работаем над воркером и хотели бы запустить только его и
зависимые компоненты. В этом случае веб-сервер не нужен.
Чтобы запустить только нужные компоненты, их явно передают в функцию. Компонент
должен быть не значением, а объектом Var
.
(mount/start #'book.systems.mount.config/config
#'book.systems.mount.db/db
#'book.systems.mount.worker/worker)
Если передать значение, Mount не запустит компонент. В примере ниже не будет ошибки или сообщения, просто ничего не произойдет:
;; does nothing
(mount/start book.systems.mount.config/config
book.systems.mount.db/db
book.systems.mount.worker/worker)
Тот факт, что функция ожидает именно Var
, а не значение, сбивает с толку
новичков. Это не очевидно, поскольку в Clojure мы редко прибегаем к переменным.
Недостаток ручного запуска в том, что он не контролирует зависимости. Вспомним, что mount не хранит зависимости явно. Библиотека знает только как упорядочить компоненты, но не как они связаны. Предположим, мы забыли, что базе и воркеру требуется конфигурация:
(mount/start #'book.systems.mount.db/db
#'book.systems.mount.worker/worker)
Такой вызов выбросит странное исключение. Оно возникнет в компоненте db
, где
мы попытаемся создать пул из конфигурации. Объект config
не запущен, и
выражение (:pool config)
вернет nil
. При попытке создать пул из nil
получим исключение.
С ростом системы становится сложнее отслеживать зависимости. Это слабое место
Mount — запуск подсистемы сводится к ручному перечислению компонентов. Чтобы
облегчить этот сценарий, библиотека предлагает вспомогательные функции-селекторы
компонентов: only
, except
и другие.
Например, except
вернет имена всех компонентов кроме перечисленных. Если если
передать результат в start, получим систему без указанных компонентов. Пример
ниже запустит подмножество системы без веб-сервера:
(-> [#'book.systems.mount.server/server]
mount/except
mount/start)
Эти функции и их комбинации описаны на странице GitHub проекта.
Проблема перезагрузки
В режиме разработки наш редактор соединен с REPL-сессией. Когда мы меняем код, редактор отправляет изменения на сервер. Возникает вопрос: что случится, если внести правки в уже написанный компонент? Как mount отреагирует на повторную загрузку модуля?
Если вы работаете в Emacs и Cider, подключитесь к проекту через M-x
cider-connect
. Из модуля book.systems.mount.core
запустите систему, как мы
делали это выше. Теперь откройте модуль сервера. Выполните команду M-x
cider-eval-buffer
(или с клавиатуры через C-c C-k
). Команда исполнит
содержимое файла сервера. Это значит, что все определения, включая ns
, def
,
defstate
будут выполнены повторно.
В сеансе REPL вы увидете логи с текстом, что сервер был остановлен и снова
запущен. Mount достаточно умен и учитывает этот сценарий. Макрос defstate
проверяет, что такой компонент уже объявлен, и перезагружает его.
Перезагрузка компонента это не всегда желаемое поведение. Во время интенсивных
изменений может случиться т.н. “рассинхрон”. Это ситуация, когда компонент
считается выключенным, но ресурс все еще работает. Например, мы допустили ошибку
в фазе :stop
и не вызвали у сервера метод (.stop)
. Если выключить такой
компонент и снова включить, мы получим ошибку с семантикой “порт уже занят”.
Поведение компонента при перезагрузке задают в его метаданных. Это поле
:on-reload
, которое по умолчанию равно :restart
. С этим значением компонент
перезагружает себя при повторном вызове defstate
. Если задать :stop
,
компонент будет остановлен. Наиболее удобно значение :noop
, что значит не
делать ничего.
Компонент с метаданными выглядит так:
(defstate ^{:on-reload :noop} server
:start (run-jetty app {:join? false :port 8080})
:stop (.stop server))
Считается хорошей практикой указывать :on-reload
для всех
компонентов. Поведение :noop
удобно тем, что освобождает от побочных
эффектов. Возможно, вы изменили не компонент, а исправили опечатку в соседней
строке. Перезагружать компонент в таком случае не требуется. Но даже если
изменения относятся к компоненту, лучше перезагрузить его вручную.
Самостоятельная работа
Вспомним, как устроена функция get-ip-info
из модуля воркера. Для каждого
IP-адреса она выполняет HTTP-запрос к серверу. На низком уровне мы каждый раз
открываем TCP-соединение, работаем с ним и закрываем. Это не оптимально, и
проблему решают так же, как и с базами данных — пулом постоянных соединений.
Минимальный пример с жизненным циклом пула
;; create a new pool
(def cm (clj-http.conn-mgr/make-reusable-conn-manager
{:timeout 2 :threads 3}))
;; make a request within the pool
(client/get "http://example.org/"
{:connection-manager cm})
;; shut down the pool
(clj-http.conn-mgr/shutdown-manager cm)
Найдите в документации к clj-http более подробные примеры. Напишите компонент HTTP-клиента, который выполняет запросы через пул. Параметры компонента (тайминг, число тредов) приходят из конфигурации. На старте компонент запускает пул, при остановке закрывает его. Перепишите воркер так, чтобы он зависел от нового компонента.
Component
Библиотека Component тоже служит для описания компонентов и систем. Это небольшой фремворк, в котором главную роль играет не объем кода, а идея. Дизайн Component в корне отличаются от техники mount, которую мы рассмотрели выше.
Разница в том, что сущности Component это не глобальные переменные, а обычные объекты. В библиотеке нет скрытых атомов, которые хранят информацию о компонентах. Программист выстраивает систему явно, подобно тому как мы описываем структуру данных.
Как и в Mount, на компонент действуют две операции: start
и stop
. Каждая их
них возвращает копию компонента в другом состоянии. Функции не меняют исходный
компонент. Можно сказать, что компоненты неизменяемы. Это отсекает целый пласт
ошибок, связанных с состоянием.
Система это комбинация компонентов с указанием зависимостей. Каждый компонент в системе носит машинное имя. На первом этапе программист составляет систему в состоянии покоя. Она состоит из компонентов, которые еще не были запущены.
В момент запуска специальный код обходит компоненты и запускает их. В результате получится запущенная копия системы. Она аналогична исходной, но каждый компонент в ней заменен на включенную копию себя. Остановка работает аналогично: вы получите выключенную копию системы.
Идеи Component не терпят глобального состояния. Один компонент может работать с другим только если это указано в зависимостях. Компонент не должен хранить внутреннее состояние в атоме или другой изменяемой сущности. На каждое действие он возвращает новую копию себя.
Устройство
Технически компонент это объект, который реализует протокол
Lifecycle
. Протокол несет две операции: start
и stop
. Компоненты удобно
описывать типизированными словарями. Это сущности, которые объявляют формой
defrecord
. По-другому их называют типизированными записями или “рекорды” с
ударением на первый слог.
Обычный словарь может содержать любые поля. Запись перечисляет возможные поля в момент объявления. Эти поля называют слотами записи. Доступ к ним работает быстрее, чем у обычного словаря. Компонент резервирует несколько слотов для входных и внутренних параметров.
Форма defrecord
работает в паре с протоколом. При объявлении записи можно
сразу расширить ее протоколом. Преимущество этого подхода в том, что слоты
записи доступны протоколу как локальные переменные. Это уменьшает код и держит
наше внимание на главном.
Компонент таит в себе состояние, и только он знает, как им управлять. Будет грубой ошибкой “вынимать” из него отдельные поля и передавать их функции. Для внешних потребителей компонент реализует еще один протокол, в котором перечислены операции над компонентом.
Программирование на Component отдаленно напоминает ООП. Компонент это объект с данными и фиксированным набором операций над ним. Как и классы, компонент инициируют, запускают и останавливают. Разница в том, что, во-первых, компоненты неизменяемы. Переход к новому состоянию не изменяет слоты объекта, в то время как в промышленных ОО-языках мы их перезаписываем.
Во-вторых, принцип SOLID
и классическая тройка инкапсуляция, наследование,
полиморфизм не имеют той же силы в Clojure. Большая часть этих принципов
отпадает за ненадобностью. Программируя на Clojure, мы не волнуемся о том, что
нарушили правила ООП. Код исходит из здравого смысла.
Первый компонент
Перейдем к практике: перепишем систему из прошлого раздела на Component. Начнем
с веб-сервера. Поместим его в файл src/book/systems/comp/server.clj
. Объявим
пространство имен:
(ns book.systems.comp.server
(:require
[com.stuartsierra.component :as component]
[ring.adapter.jetty :refer [run-jetty]]))
Компонент это запись с двумя слотами: options
и server
. В опциях записаны
параметры Jetty-сервера, в server — его экземпляр. Строка component/Lifecycle
означает протокол, который реализует запись. Ниже следует реализация протокола.
(defrecord Server
[options server]
component/Lifecycle
(start [this]
(let [server (run-jetty app options)]
(assoc this :server server)))
(stop [this]
(.stop server)
(assoc this :server nil)))
Метод start
вернет ту же запись, но с непустым слотом :server
. В него
записан объект сервера. Метод stop
принимает запущенный компонент. Он
выключает сервер и возвращает новый компонент, где слот :server
установлен в
nil.
Обратите внимание: внутри методов мы свободно обращаемся к слотам записи, словно
это локальные переменные. Это работает только в тех случаях, когда методы
расположены внутри defrecord
. Если мы расширяем запись в отдельной форме,
например через extend, доступ к слотам теряется. Приходится извлекать слоты из
переменной this
.
Запустим компонент вручную. Сущность Server
это еще не компонент, а его
абстрактное описание. На первом шаге его инициируют, то есть получают экземпляр
записи. Чтобы создать экземпляр, вызывают функцию map-><Record>
, где
<Record>
это имя записи. Макрос defrecord
автоматически порождает эту
функцию. В нашем случае это map->Server
. Функция принимает обычный словарь и
возвращает его типизированную версию. Ключи словаря должны совпадать со слотами
записи. Если ключ не найден, слот равен nil.
(def s-created
(map->Server
{:options {:port 8080 :join? false}}))
Переменная s-created
это экземпляр записи Server
. При инициализации мы
указали слот options, но не server. В этом нет смысла, потому что server будет
заполнен автоматически в процессе жизненного цикла.
(def s-started (component/start s-created))
Эта строка вернет запущенную версию компонента. Откройте браузер по адресу
http://127.0.0.1:8080/ и проверьте, что сервер работает. На этот раз у записи
s-started
слот :server
заполнен:
(-> s-started :server type)
org.eclipse.jetty.server.Server
Остановите компонент. Проверьте браузер и слоты s-stopped
:
(def s-stopped (component/stop s-started))
Мы прошли жизненный цикл одного компонента. Это инициирование, запуск и остановка. Переход на каждую стадию возвращает новую копию. В промышленных системах вам не придется управлять компонентами поштучно. Этим занимается система.
Конструктор
Вспомним, как мы создали экземпляр Server
:
(def s-created
(map->Server
{:options {:port 8080 :join? false}}))
У такой записи есть недостаток. Мы вынуждены помнить, какие слоты нужны для инициализации, а какие для внутреннего использования. Для простых записей это не критично, но в боевых системах встречаются компоненты с десятью и более слотами. Чтобы программист не запутался, объявляют функцию-конструктор.
Конструктор принимает только те аргументы, которые нужны для инициализации компонента. В нашем случае это options, поэтому функция выглядит так:
(defn make-server
[options]
(map->Server {:options options}))
(def s-created (make-server {:port 8080 :join? false}))
Конструктор упрощает создание сервера. С таким подходом невозможно передать в
map->Server
что-то лишнее. Дополняйте каждый компонент системы конструктором
даже в тривиальных случаях.
Особенность слотов
При остановке компонента мы совершаем два действия: физически выключаем сервер и
замещаем его слот значением nil. Читатель заметит, почему бы не заменить assoc
на dissoc?
Зачем хранить nil, когда можно отсоединить поле?
(assoc this :server nil)
;; should have become
(dissoc this :server)
Причина в том, как устроены записи и слоты. Запись сохраняет свои уникальные
свойства до тех пор, пока все ее слоты на месте. Если забрать у записи слот
через dissoc
, получим обычный словарь. Покажем это на примере:
(-> s-stopped (assoc :server nil) type)
book.systems.comp.server.Server
(-> s-stopped (dissoc :server) type)
clojure.lang.PersistentArrayMap
Если один компонентов вызывает dissoc
на самом себе, при переходе на новую
стадию мы получим не компонент, а словарь. Это досадная ошибка, которая ведет к
странному поведению системы. Например, при попытке выключить компонент он
продолжает работу.
Когда запись расширяют протоколом, тем самым устанавливают связь между типом
первого аргумента и логикой. Для аргумента с типом Server
методы start
и
stop
выполнят одно, для типов DB
или Worker
— другое. На другие типы
действует реализация по умолчанию, которая возвращает this. Это значит, что если
метод start вернул не компонент, а словарь, мы не сможем вызвать правильный stop
для этого словаря.
Приведем неудачный пример компонента. Его метод start
Возвращает словарь с
одноименным полем server
:
(defrecord BadServer
[options server]
component/Lifecycle
(start [this]
{:server (run-jetty app options)})
(stop [this]
(.stop server)
nil))
Запуск такого компонента сработает без ошибок. Выполнив два выражения ниже, переключитесь браузер и проверьте сервер:
(def bs-created (map->BadServer {:options {:port 8080 :join? false}}))
(def bs-started (component/start bs-created))
Но переменная bs-started
уже не запись, а словарь. Реализация stop
для
словаря вернет его же без каких либо действий. Можно сколько угодно вызывать
component/stop
, но сервер не будет остановлен.
(type bs-started)
clojure.lang.PersistentArrayMap
(def bs-stopped (component/stop bs-started))
;; does nothing, the server still works
Похожие трудности возникнут, если исправить start
без учета stop
. При
остановке плохой компонент выключит сервер, но вернет nil. При запуске nil
получим исключение, что тип не реализует протокол Lifecycle
.
Следите за тем, чтобы компонент менял только значения слотов, но не их состав.
Компонент базы
Напишем компонент для работы с базой данных. Концепция пула соединений уже
известна читателю из прошлых разделов. Компонент содержит два слота: options
и
db-spec
. Первый это словарь опций будущего пула. Слот db-spec
предназначен
для внутреннего использования. Он хранит JDBC-спеку с открытым пулом.
(defrecord DB
[options db-spec]
component/Lifecycle
(start [this]
(let [pool (cp/make-datasource options)]
(assoc this :db-spec {:datasource pool})))
(stop [this]
(-> db-spec :datasource cp/close-datasource)
(assoc this :db-spec nil)))
Добавим функцию-конструктор:
(defn make-db [options]
(map->DB {:options options}))
Компонент готов к запуску, и его можно прогнать через функции make-db
→
component/start
→ component/stop
.
Пока что неясно, как выполнять запросы через этот компонент. Нас интересует слот
db-spec
, который хранит спеку. Технически возможно вычленить его и передать в
jdbc-функцию:
(let [{:keys [db-spec]} db-started
users (jdbc/query db-spec "select * from users")]
(process-users users))
Но это варварский подход. Мы не должны вторгаться во внутренности компонента, даже если язык предлагает такую возможность. Этим мы нарушаем концепцию компонента, который неделим с точки зрения стороннего наблюдателя. В этом плане компоненты близки к объектам в современных языках.
Дополним DB методами для работы с базой. Сначала определим протокол с методами
запроса и обновления. Сигнатуры аналогичны их jdbc-версиям с той разницей, что
первый параметр это не db-спека
, а this
, компонент:
(defprotocol IDB
(query [this sql-params])
(update! [this table set-map where-clause]))
В теле defrecord
, сразу после stop
, поместим реализацию этого
протокола. Реализация сводится к jdbc-функциям, в которые мы передаем слот
:db-spec
и аргументы метода.
(;; ........
(stop [this]
(-> db-spec :datasource cp/close-datasource)
(assoc this :db-spec nil))
IDB
(query [this sql-params]
(jdbc/query db-spec sql-params))
(update! [this table set-map where-clause]
(jdbc/update! db-spec table set-map where-clause)))
На этом этапе компонент уже готов к запросам. Обратите внимание, что мы вызываем не jdbc-функции, а методы протокола. Тем самым мы изолируем зависимость от JDBC внутри компонента.
(def db-created (make-db options))
(def db-started (component/start db-created))
(query db-started "select * from requests")
(update! db-started :requests {:is_processed false} ["id = ?" 42])
(def db-stopped (component/stop db-started))
Транзакционный компонент
Из прошлого раздела мы помним, что для согласованных изменений в базе нужны
транзакции. Раньше мы пользовались jdbc/with-db-transaction
. Он связывает
символ с транзакционным соединением, полученным из обычного.
Напишем одноименный макрос с такой же сигнатурой. В отличии от JDBC-версии, наш макрос работает с компонентами. Он принимает обычный компонент БД и связывает с символом его транзакционную версию. Технически макрос сводится к следующим шагам:
- получить из компонента текущее соединение;
- обернуть тело в макрос jdbc, получить транзакционное соединение;
- связать с символом
comp-tx
компонент, у которого слот :db-spec заменен на транзакционное соединение.
(defmacro with-db-transaction
[[comp-tx comp-db & trx-opt] & body]
`(let [{db-spec# :db-spec} ~comp-db]
(jdbc/with-db-transaction
[t-conn# db-spec# ~@trx-opt]
(let [~comp-tx (assoc ~comp-db :db-spec t-conn#)]
~@body))))
Читатель заметит, что в макросе мы нарушаем принцип закрытости компонента. Например, мы вручную читаем и заменяем его приватный слот. В случае с макросом это нормально. Подобно компоненту, макрос изолирует низкоуровневые действия, поэтому сторонний потребитель не заметит манипуляций со слотами.
Пример работы макроса:
(with-db-transaction
[db-tx db-started]
(let [requests (query db-tx "select * from requests limit 1 for update")]
(when-let [request (first requests)]
(update! db-tx :requests {:is_processed false} ["id = ?" (:id request)]))))
В логах PostgreSQL мы увидим следующие записи:
BEGIN
select * from requests limit 1 for update
UPDATE requests SET is_processed = $1 WHERE id = $2
DETAIL: parameters: $1 = 'f', $2 = '3'
COMMIT
Выражения select
и update
действительно выполнены в транзакции.
Воркер
Напишем компонент воркера. Для этого объявим новый модуль и подключим зависимости:
(ns book.systems.comp.worker
(:require
[com.stuartsierra.component :as component]
[book.systems.comp.db :as db]
[clj-http.client :as client]
[clojure.tools.logging :as log]))
Воркер это запись, которая реализует два протокола: Lifecycle
и
IWorker
. Протокол Lifecycle
уже знаком читателю: это функции start
и
stop
. Они опираются на методы IWorker
, который выглядит так:
(defprotocol IWorker
(make-task [this])
(task-fn [this]))
Мы ожидаем, что task-fn
это функция, которую воркер вызывает на каждом шаге
цикла. Метод make-task
оборачивает ее в цикл и try/catch
.
Запись хранит четыре слота: входные опции, флаг продолжения цикла, футура с
циклом и база данных. Это зависимый компонент, который мы описали на предыдущем
шаге. Для начала реализуем Lifecycle
:
(defrecord Worker
[options flag task db]
component/Lifecycle
(start [this]
(let [flag (atom true)
this (assoc this :flag flag) ;; note
task (make-task this)]
(assoc this :task task)))
(stop [this]
(reset! flag false)
(while (not (realized? task))
(log/info "Waiting for the task to complete")
(Thread/sleep 300))
(assoc this :flag nil :task nil))
;; to be continued
)
Отдельно прокомментируем строку с меткой ;; note
. Мы заменяем this
на его же
версию с флагом состояния. Это нужно для того, чтобы make-task
смог прочитать
флаг из переданного this
. Если строку убрать, make-task
получит запись с
флагом nil, что приведет к ошибке.
Опишем протокол IWorker
. Код make-task
и task-fn
уже знаком читателю из
прошлого раздела про Mount. Разница в том, что теперь мы работаем не с
функциями, а методами. Поскольку метод имеет прямой доступ к слотам, нет смысла
передавать db
, flag
и другие параметры, как мы делали это с
функциями. Каждый метод принимает только this
.
(defrecord Worker
;; skipped
IWorker
(make-task [this]
(future
(while @flag
(try (task-fn this)
(catch Throwable e
(log/error e))
(finally
(Thread/sleep (:sleep options)))))))
(task-fn [this]
(db/with-db-transaction [tx db]
(when-let [request (first (db/query tx requests-query))]
(let [{:keys [id ip]} request
info (get-ip-info ip)
fields {:is_processed true
:zip (:postal_code info)
:lat (:lat info)
:lon (:lng info)}]
(db/update! tx :requests fields ["id = ?" id]))))))
Добавим конструктор, и компонент готов:
(defn make-worker
[options]
(map->Worker {:options options}))
Ручные зависимости
Воркер отличается от других компонентов тем, что имеет зависимости. Пока что не ясно, как воркер узнает о базе данных, потому что его конструктор принимает только опции. Забегая вперед скажем, что эту проблему решает система, а не разработчик. Мы не должны передавать компоненты друг другу в момент их создания.
Хотелось бы проверить воркер до того, как мы двинемся дальше. Во время разработки это правило можно нарушить. Мы вручную соберем мини-систему из двух компонентов. Так мы проверим код, который написали и поймем, как работает система.
Проведем эксперимент с системой в отдельном модуле core. Импортируйте в него конструкторы и библиотеку Component:
(ns book.systems.comp.core
(:require
[com.stuartsierra.component :as component]
[book.systems.comp.server :refer [make-server]]
[book.systems.comp.worker :refer [make-worker]]
[book.systems.comp.db :refer [make-db]]))
Наивная система приведена ниже. Это функция, которая принимает конфигурацию. Мы вручную запускаем базу и воркер и возвращаем словарь компонентов. В строке с комментарием вы передаем воркеру слот с компонентом базы. Важен момент, в который мы это делаем. Компонент базы уже должен быть включен, а воркер еще нет.
(defn my-system-start
[config]
(let [{db-opt :pool
worker-opt :worker} config
db (-> db-opt
make-db
component/start)
worker (-> worker-opt
make-worker
(assoc :db db) ;; note
component/start)]
{:db db :worker worker}))
Чтобы запустить систему, передайте в функцию словарь с параметрами пула и воркера. Сохраните систему в переменной, чтобы выключить ее позже.
(def _sys (my-system-start {:pool {...} :worker {...}}))
Пока система работает, проверьте ее, как мы делали это в прошлом
разделе. Добавьте несколько записей в таблицу requests
и убедитесь, что воркер
дополняет поля.
Функция выключения останавливает компоненты в верном порядке.
(defn my-system-stop
[system]
(-> system
(update :worker component/stop)
(update :db component/stop)))
(my-system-stop _sys)
Промышленная система
Рассмотрим, как работает настоящая, промышленная система. Функция system-map
принимает цепочку значений, где каждый нечетный элемент это ключ, машинное имя
компонента в системе. Четные элементы это инициированные компоненты (т.е. вызовы
их конструкторов). Функция возвращает систему в состоянии покоя.
Построение системы не должно носить побочных эффектов. Технически вызов
system-map
аналогичен объявлению структуры данных. Конструкторы компонентов
только возвращают экземпляры записей с заполненными слотами. Если конструктор
обращается к консоли, диску и глобальному состоянию, это грубая ошибка.
Поскольку система зависит от конфигурации, ее построение оборачивают в отдельную
функцию make-system
. Эта функция принимает словарь конфигурации и разделяет
его на составные части. Каждый конструктор вызывается со своей
под-конфигурацией. Удобно, когда конфигурация повторяет топологию системы: на
верхнем уровне ключи компонентов, а под ними словари опций.
Чтобы сообщить компоненту зависимости, его оборачивают в функцию
component/using
. Вторым аргументом передают ключи компонентов, которые
необходимо сообщить целевому компоненту до его старта. Ключи могут быть вектором
или словарем. Если имя слота совпадает с именем компонента в системе, это
вектор. Если имена отличаются, передают словарь вида {:slot-name
:system-name}
.
В примере ниже функция make-system
строит систему, о которой мы договаривались
в начале главы. Компонент worker
, который зависит от базы данных, обернут в
component/using
. Поскольку имя слота :db
совпадает с именем компонента в
системе, мы передали вектор [:db]
.
(defn make-system
[config]
(let [{:keys [jetty pool worker]} config]
(component/system-map
:server (make-server jetty)
:db (make-db pool)
:worker (component/using
(make-worker worker)
[:db]))))
Если бы имя компонента в системе было :storage
, мы бы передали словарь
соответствия имен:
(component/system-map
:server (make-server jetty)
:storage (make-db pool)
:worker (component/using
(make-worker worker)
{:db :storage}))
Вариант со словарем полезен, когда вы подключаете сторонние компоненты. Их
разработчики не знают наверняка, как именуют сущности в вашем проекте. Например,
чужой компонент зависит от некого :storage
. Это слишком абстрактное имя: в
системе бывает несколько хранилищ данных: :postgres
, :cassandra
,
:redis
. Система маппинга снимает проблему расхождения имен.
Чтобы запустить систему, ее передают в component/start
. Система реализует
протокол Lifecycle
и с технической точки зрения ведет себя как
компонент. Разница в том, что система аккумулирует другие компоненты и управляет
их жизненным циклом.
В момент запуска алгоритм обходит компоненты и строит граф зависимостей. Из этого графа система выводит порядок обхода. Перед тем как запустить компонент с зависимостями, система сообщает их компоненту через assoc, как мы делали это выше. Аналогично работает выключение системы.
(def config {...})
(def sys-init (make-system config))
(def sys-started (component/start sys-init))
(def sys-stopped (component/stop sys-started))
Хранение системы
Выше мы определили систему через def
, что не совсем правильно. Система это
сущность, которая включается по требованию. Считается грубой ошибкой, если форма
def срабатывает в функции, а не на верхнем уровне модуля. Поэтому с системой
обращаются как глобальной переменной, которая меняет значение. С технической
стороны для этого подходит alter-var-root
.
В главном модуле приложения выделяют переменную для глобальной системы. Ее
объявляют через defonce, чтобы случайно не переопределить ее при перезагрузке
модуля. Функция alter-system это сокращенная версия (alter-var-root #'system
...)
. Код с ней получается короче.
Как мы выяснили, компонент может находиться в трех состояниях: покой, запуск и
остановка. То же самое применимо к системе. Функции system-init
,
system-start
и system-stop
устанавливают глобальную систему в нужное
состояние. Первая функция принимает словарь конфигурации.
(defonce system nil)
(def alter-system (partial alter-var-root #'system))
(defn system-init [config]
(alter-system (constantly (make-system config))))
(defn system-start []
(alter-system component/start))
(defn system-stop []
(alter-system component/stop))
Этот простой код дает все необходимое, чтобы управлять приложением. Функция
-main
приложения сводится к трем шагам: чтению конфигурации, подготовке
системы и ее запуску.
(defn -main [& args]
(let [config (load-config "config.edn")]
(system-init config)
(system-start)))
Заметим, что хотя система и глобальна, к ней нельзя обращаться напрямую. Если один компонент извлекает другой из недр системы, это провал разработчика. Такой подход сводит на нет саму идею системы и компонентов. Обращаться к системе напрямую можно только в режиме разработки или тестов.
Для большей надежности переменную системы делают приватной:
(defonce ^:private system nil)
Этим мы обезопасим систему от обращения извне. В режиме разработки нам все еще доступен трюк с resolve и символом переменной, чтобы сослаться на нее.
Корректное завершение
Подход с глобальной переменной идет вразрез с тем, что пишет автор библиотеки. На странице проекта в его описании встречается следующая фраза:
In production, the system map is ephemeral. It is used to start all the components running, then it is discarded.
Что в переводе:
В промышленном запуске система эфемерна. Она запускает все компоненты и затем исчезает.
Это редкий случай, когда мы не согласимся с автором. Даже в боевом режиме вам нужна ссылка на систему. Без ссылки невозможно выполнить т.н. “graceful shutdown”, что значит корректно завершить программу. Под корректностью понимают факт того, что все ресурсы были закрыты.
Система управляет состоянием приложения. Некоторые его части бывают очень сложны: это очереди задач, каналы данных, транзакции. При завершении программы мы не можем полагаться на стандартные средства JVM. Например, если вынужденно заверить компонент очереди, мы потеряем сообщение или обработаем его дважды. Мы обязаны закрыть ресурсы правильно, даже если придется подождать.
В боевом режиме приложение перехватывает POSIX
-сигналы и реагирует на них
должным образом. Например, если поступил SIGTERM
, приложение останавливает
систему, дожидается ее остановки и только потом завершается.
Библиотека spootnik/signal
предлагает изящный макрос, чтобы связать сигнал с
реакцией на него. Подключите библиотеку в проект и модуль core:
;; project.clj
[spootnik/signal "0.2.2"]
;; src/book/systems/comp/core.clj
(ns ...
(:require [signal.handler :refer [with-handler]]))
Расширьте функцию -main
: сразу после запуска системы добавьте обработчики
сигналов SIGTERM
, SIGINT
и SIGHUP
. На первые два мы завершаем систему и
выходим из программы. Сигнал SIGHUP
мы расцениваем как сигнал к перезагрузке
системы. В примере ниже функция (exit)
это аналог (System/exit)
с
дополнительной логикой.
(with-handler :term
(log/info "caught SIGTERM, quitting")
(system-stop)
(log/info "all components shut down")
(exit))
(with-handler :int
(log/info "caught SIGINT, quitting")
(system-stop)
(log/info "all components shut down")
(exit))
(with-handler :hup
(log/info "caught SIGHUP, reloading")
(system-stop)
(system-start)
(log/info "system reloaded"))
Заметим, что обработка сигналов не работает, когда проект запущен через lein
run
или вручную в REPL-сеансе. Чтобы проверить сигналы, скомпилируйте uberjar и
запустите его как java-приложение.
lein uberjar
java -jar target/myproject.jar
После запуска нажмите Ctrl-C
. Приложение завершится не сразу, и вы увидите
логи о том, что система остановлена.
При работе с системой позаботьтесь о том, чтобы безопасно остановить ее даже в аварийном случае.
Ожидание системы
Вспомним функцию -main
приложения. Это входная точка старта Java-программы:
(defn -main [& args]
(let [config (load-config "config.edn")]
(system-init config)
(system-start)))
У читателя, не знакомого с тонкостями JVM, возникнет вопрос. Что помешает
программе завершиться после вызова (system-start)
? Ниже этой формы нет
какого-то бесконечного цикла, хука или события. Почему платформа продолжит
работу?
Это стандартное поведение JVM. Если программа завершается не аварийно, главный
тред ожидает, пока не остановятся остальные. Запуск системы порождает состояние
в новых потоках (сервер, пул соединений). Поэтому после (system-start)
основной поток повиснет в ожидании их завершения. Он будет ждать до тех пор,
пока систему не выключит кто-то другой и параллельного треда или не придет
POSIX
-сигнал. Как приложению реагировать на сигналы мы рассмотрели выше.
Улучшаем зависимости
Когда мы объявили систему, то сообщили компоненту :worker
о его
зависимостях. В примере ниже мы буквально говорим компоненту: ты зависишь от
базы данных.
(component/system-map
;; ...
:worker (component/using
(make-worker worker) [:db]))
Когда компонентов много, перечисление их зависимостей занимает место и зашумляет код. Помогает следующий трюк: зависимости компонента выносят в конструктор.
В примере выше (make-worker {...})
возвращает наивную версию компонента,
которая не знает о зависимостях. Наивная версия попадает в component/using
,
которая сообщает их. Мы могли бы втянуть component/using
в конструктор, чтобы
новый компонент был сразу “заряжен” зависимостями. Тогда на уровне системы шаг
component/using
станет не нужен.
Перепишем конструктор воркера:
(defn make-worker [config]
(-> config
map->Worker
(component/using [:db])))
Теперь система выглядит чище:
(component/system-map
:server (make-server jetty)
:db (make-db pool)
:worker (make-worker worker))
Такой подход требует, чтобы имена в системе совпадали с зависимыми слотами. Если это ваши компоненты, достаточно один раз договориться с командой об именовании. Для сторонних компонентов легко написать свой конструктор.
Коротко рассмотрим, как хранятся данные о зависимостях. Очевидно, что вызов
component/using
сообщает компоненту новые сведения, однако сам компонент от
этого не меняется. У него не появляется нового поля :deps
или что-то в этом
роде. Компонент хранит зависимости в метаданных.
Метаданные это словарь дополнительных сведений об объекте. Метаданные работают с коллекциями и некоторым другим типам Clojure, например, символом или переменной. К метаданным прибегают, когда нужно сообщить добавочные сведения об объекте, не изменяя его. Зависимости компонента подходят на роль таких сведений.
Функция meta возвращает словарь метаданных объекта. Пример ниже показывает, что конструктор порождает компонент с заполненными зависимостями:
(-> {...} make-worker meta)
#:com.stuartsierra.component{:dependencies {:db :db}}
Чтобы увидеть метаданные другим способом, установите глобальную переменную print-meta в истину. Тогда при выводе объекта в REPL он будет дополнен метаданными:
(set! *print-meta* true)
(make-worker {...})
^#:com.stuartsierra.component{:dependencies {:db :db}}
#book.systems.comp.worker.Worker{...}
Группировка слотов
Слоты компонента делятся на три группы. Это параметры инициализации, поля внутреннего состояния и зависимости. Вспомним, как мы объявили компонент воркера:
(defrecord Worker
[options flag task db])
В этом примере слот options
относится к инициализации, flag
и task
к
состоянию, db
— зависимость. Чем сложнее компонент, тем больше у него слотов в
каждой группе. Когда слоты перечислены беспорядочно, трудно понять их
семантику. Поэтому хорошей практикой считается группировать слоты вручную и
разделять их комментарием:
(defrecord Worker
[;; init
options
;; runtime
flag task
;; deps
db])
Первой идет группа init
, входящие параметры. Это слоты, необходимые для нового
компонента. Ожидается, что конструктор принимает такие же параметры. Группа
runtime
перечисляет слоты, которые компонент заполнит сам в момент старта. В
deps
указаны зависимости. Вектор этих зависимостей передают в
component/using
в конструкторе.
Сортировка слотов облегчает работу с кодом. Договоритесь с членами команды, чтобы внедрить эту практику. Но когда слотов слишком много, это говорит о том, что компонент излишне сложен. Тогда часть слотов и логики выносят в отдельный компонент и подключают его как зависимость.
В последующих примерах мы не группируем слоты, потому что иначе код займет слишком много места. В вашем коде слоты должны быть сгруппированы.
Условная система
В главе про конфигурацию мы писали про т.н. feature flags. Это логические параметры, которые включают или отключают целые пласты логики. Флаги удобны тем, что быстро отключают функциональность без пересборки приложения. Достаточно поменять конфигурацию и перезагрузить сервис.
Система компонентов может быть построена не линейно, а по условиям. Вспомним,
что технически задача сводится к тому, чтобы передать ключи и компоненты в
функцию system-map
. Если список аргументов предварительно обработать, получим
нужную функциональность.
Предположим, компонент воркера все еще в испытательном режиме. Пусть в конфигурации будет поле, которое означает “включить воркер”. Если оно ложь, система должна запуститься без этого компонента.
Выделим в конфигурации группу :features
. Это словарь флагов для “фич”, которые
под вопросом:
{:features {:worker true}
:jetty {:join? false :port 8088}
;; etc
}
Перепишем функцию make-system
. Теперь компоненты, перед тем как попасть в
system-map проходят предварительный просев. Макрос cond->
“пробрасывает”
вектор базовых компонентов через цепочку условий и форм. Если выражение
(:worker features)
вернет истину, следующая за ней форма добавит в вектор
значения :worker
и (make-worker {...})
. Ниже могут располагаться другие
флаги или проверки.
(defn make-system
[config]
(let [{:keys [features jetty pool worker]} config
components
(cond-> [:server (make-server jetty)
:db (make-db pool)]
(:worker features)
(conj :worker (make-worker worker)))]
(apply component/system-map components)))
Убедимся, что механизм флагов работает. Поскольку система это запись, функция
keys вернет список ее слотов. В примерах ниже видно, что слот :worker
появляется в зависимости от конфигурации:
(keys (make-system {:features {:worker false}}))
(:server :db)
(keys (make-system {:features {:worker true}}))
(:server :db :worker)
Система флагов облегчает работу с проектом. Некоторые компоненты очень сложны и требуют специального окружения. Если такой компонент нельзя отключить, это станет проблемой для ваших коллег. И наоборот, если компонент регулируется галочкой, вы сэкономите их время и нервы.
Спуск системы
Пока сущности приложения это компоненты, они свободно общаются друг с другом. Если одному компоненту понадобился другой, мы указываем зависимость и добавляем слот. Проблемы начинаются, когда к системе обращается не компонент, а другая сущность.
Чаще всего это функция-обработчик HTTP-запроса. Мы подробно говорили о них в первой главе. По своей природе функция не ложится на концепцию компонента. Компонент хранит состояние, а функция, напротив, избегает его. Запуск и остановка функции это бессмысленная операция. Это не хорошо и не плохо, просто функция и компонент противоположны друг другу.
Рассмотрим случай, когда обработчик HTTP-запроса нуждается в компоненте базы данных. Возникает вопрос: как спустить отдельные части системы в функции, не нарушив принципы библиотеки? Обращение к системе как глобальной переменной мы не рассматриваем, потому что это плохая практика. В промышленных решениях прибегают к двум способам: пробросу и замыканию.
Проброс означает, что отдельные компоненты передают в объекте запроса. Этот вариант имеет право на жизнь, потому что запрос это часть сервера, а сервер это компонент. Поэтому сервер имеет право сообщать дополнительные поля запросу.
Чтобы компонент базы стал доступен серверу, подключим его в зависимостях. Добавим слот в запись сервера и зависимости в конструктор:
(defrecord Server
[options server db])
(defn make-server
[options]
(-> (map->Server {:options options})
(component/using [:db])))
Расширим метод сервера start
. Если раньше мы передавали app напрямую в
run-jetty
, то теперь мы вводим дополнительный шаг. Функция make-handler
оборачивает app таким образом, что каждый запрос в app дополнен компонентом
базы.
(defn make-handler [app db]
(fn [request]
(app (assoc request :db db))))
(start [this]
(let [handler (make-handler app db)
server (run-jetty handler options)]
(assoc this :server server)))
Представим, что главная страница приложения выводит данные из базы. Пример ниже показывает, как выполнить запрос к базе из обработчика HTTP-запроса. Чтобы не усложнять пример версткой HTML, мы возвращаем данные в виде текста.
(defn app
[request]
(let [{:keys [db]} request
data (db/query db "select * from requests")]
{:status 200
:body (with-out-str
(clojure.pprint/pprint data))}))
Со временем приложению могут понадобиться другие компоненты, например очередь
задач или кэш. Их добавляют аналогично: сперва вводят новый компонент в систему,
указывают зависимости в сервере и пробрасывают в make-handler
.
Когда компонентов все больше, хранить их на верхнем уровне запроса становится
неудобно: возникает риск конфликта ключей. Будет логично записывать их во
вложенный словарь :system
или :engine
. Важно понимать, что :system
содержит не глобальную систему, а ее минимальное подмножество, необходимое для
веб-части приложения.
Замыкание отличается от проброса способом передачи аргументов. В случае с замыканием компоненты передают отдельным аргументом. С таким подходом функция-HTTP обработчик принимает не один, а два аргумента: подмножество системы и текущий запрос.
Чтобы собрать нужные компоненты в одну структуру, в систему вводят особый группировочный компонент. Он ничего не делает при запуске и остановке, а только аккумулирует зависимости. Компонент сервера зависит от этого группировочного компонента. На базе него мы строим дерево маршрутов (роутер), где каждый обработчик принимает компонент первым аргументом.
Введем группировочный компонент :web
. Пока что он зависит в только от базы
данных, но в будущем, возможно, потребует и другие компоненты:
(defrecord Web [db])
(defn make-web []
(-> (map->Web {})
(component/using [:db])))
В функции make-system подключим его в систему:
(component/system-map
:web (make-web)
:server (make-server jetty)
:db (make-db pool)
:worker (make-worker worker))
Переключим зависимости сервера: теперь он зависит не от базы данных, а от
группировочного web
:
(defrecord Server
[options server web])
(defn make-server
[options]
(-> (map->Server {:options options})
(component/using [:web])))
Из первой главы про веб-разработку вспомним, как мы строили роуты. Проще всего
это сделать макросом defroutes
из пакета Compojure. Макрос возвращает функцию,
которая принимает запрос и возвращает ответ.
(defroutes app
(GET "/" request (page-index request))
(GET "/hello" request (page-hello request))
page-404)
Но теперь дерево маршрутов не статично, а строится по запросу. Функция
make-routes принимает группировочный компонент и возвращает маршруты, замкнутые
на нем. В функции page-index
и другие приходят два аргумента: компонент и
текущий запрос. Компонент будет постоянным, в том состоянии, что он пришел в
make-routes:
(defn make-routes [web]
(routes
(GET "/" request (page-index web request))
(GET "/hello" request (page-hello web request))))
Метод start
сервера строит маршруты и передает в run-jetty
:
(start [this]
(let [routes (make-routes web)
server (run-jetty routes options)]
(assoc this :server server)))
Рассмотрим, как может выглядеть обработчик page-index
, который обращается к
базе данных. Поскольку первый аргумент интересует нас только как хранилище
компонентов, мы распакуем его на уровне сигнатуры.
(defn page-index
[{:keys [db]} request]
(let [data (db/query db "select * from requests")]
{:status 200
:body (with-out-str
(clojure.pprint/pprint data))}))
Проброс и замыкание в целом похожи: они решают одну и ту же задачу. Разница в том, как технически передать аргументы в функцию. Вариант с пробросом удобен тем, что обычно HTTP-функции принимают один аргумент, и нам не придется менять все сигнатуры.
С другой стороны, передача данных через запрос не всегда очевидна. Когда в запросе слишком много полей, становится трудно его конструировать при разработке или в тестах. При записи запроса в лог или на экран возникает риск получить слишком большой выхлоп. Вариант с замыканием и двумя аргументами в целом выглядит понятнее. Сигнатура функции прямо говорит о том, какие данные ожидают на входе.
Выбор конкретного способа зависит от соглашений в команде.
Идемпотентность
До сих пор мы писали компоненты так, что их повторный запуск или остановка приводили к ошибке. Покажем это на примере веб-сервера:
(def s (-> {:port 8088 :join? false}
make-server
component/start))
(component/start s)
Execution error (BindException) at ...
Address already in use
В теле start
мы не проверяем, что сервер уже работает. При попытке включить
уже запущенный компонент получим исключение о том, что порт уже занят. Это
правильное поведение системы: мы бы не хотели, чтобы запустилось два сервера. Но
для других компонентов исключения может и не быть. Например, если повторно
запустить базу данных, получим новый пул соединений. Старый пул останется где-то
в памяти и будет работать и писать логи. Так происходит утечка ресурсов.
Свойство, когда повторная операция над объектом возвращает тот же результат, что и в начале, называется идемпотентность. Чтобы избежать утечки ресурсов, компонент должен быть идемпотентен. Повторный вызов start или stop срабатывают только один раз для данного состояния.
На уровне кода это сводится к проверке слотов перед тем, как открывать
ресурс. Например, если слот сервера nil, мы порождаем новый сервер и записываем
его в слот. Если не nil, это значит, что сервер уже запущен, и возвращают
this
.
(start [this]
(if server
this
(let [server (run-jetty app options)]
(assoc this :server server))))
Аналогично работает stop
: перед тем, как закрыть ресурс, слот проверяют на
заполненность:
(stop [this]
(when server
(.stop server))
(assoc this :server nil))
Вариант с макросом or выглядит декларативнее. При запуске мы всегда записываем слот, но значение это либо новый сервер, либо текущий.
(start [this]
(let [server (or server (run-jetty app options))]
(assoc this :server server)))
Integrant
Библиотека Integrant это следующий виток мысли о том, как строить системы. Мы поместили ее последней в обзоре по нескольким причинам. Integrant отталкивается от идей Component, которые мы только что рассмотрели. Библиотека устроена гибче и в целом более продвинута, чем Component. Читатель должен подойти к ней с некоторым практическим опытом.
Как и в случае с Component, основная задача библиотеки — избежать глобального состояния. Вместе с тем Integrant исправляет слабые моменты Component. Кратко перечислим тезисы, которые предложил автор Integrant.
Сущности Component напоминают классы и ООП. В мире Clojure, где в основном работают с данными и функциями, это выглядит усложнением. Пусть компоненты будут функциями. Функция проще объекта, потому что над ней определена только одна операция — вызов.
Component выделяет только два состояния компонента — запущен и остановлен. Integrant предлагает дополнительные стадии, например, приостановка и возобновление, валидация спекой, подготовка параметров. По умолчанию эти стадии пустые, но любой компонент может подписаться на них. С этим подходом система становится гибче и удобней в поддержке.
Integrant делает ставку на декларативность системы. Технически возможно описать систему в edn-файле и считать одной функцией. Это выгодно отличает Integrant от Component с ручным вызовом конструкторов.
Integrant лоялен к зависимостям. Если в Component зависимость требует два действия — добавить слот и метаданные, — то в Integrant это один шаг. В Component зависимость может быть только другим компонентом. Иногда объект оборачивают в компонент только чтобы выполнить это требование. В Integrant зависимостью может быть что угодно: словарь, объект, функция.
В целом Integrant выглядит легче и удобнее Component. Он решает задачи простым способом, как это принято в мире Clojure.
Базовое устройство
Работу с Integrant начинают с описания будущей системы. Это структура данных, каркас, за который цепляется дальнейшая логика. На уровне кода система это словарь. Вот так мы бы описали систему из веб-сервера и пула для базы данных:
(def config
{::server {:port 8080 :join? false}
::db {:username "book"
:password "book"
:database-name "book"
:server-name "127.0.0.1"
:port-number 5432}})
Ключ словаря это машинное имя компонента, а значение — параметры запуска. Уже на этом этапе видно одно из преимуществ Integrant — его декларативность. Эту структуру можно скопировать или считать из файла.
Система и компоненты связаны через мультиметоды. Чтобы добавить реакцию на
определенное событие, мы расширяем нужный мультиметод по ключу
компонента. Например, при старте система вызывает метод init-key
для каждого
ключа. Чтобы объяснить системе, как запускать сервер, метод предварительно
расширяют ключом ::server
.
Integrant ожидает, что ключ реализует минимум два метода: запуск и остановка. Это ключевые операции для работы с системой, поэтому для них не предусмотрены действия по умолчанию. Другие события опциональны и остаются на усмотрение разработчика.
Первые компоненты
Как и в прошлых разделах, мы начнем практику с описания компонентов сервера и
базы. Они просты и не имеют зависимостей. Подготовьте модуль
src/book/integrant.clj
со следующей шапкой:
(ns book.integrant
(:require [integrant.core :as ig]))
Для краткости мы опустим импорты Jetty, HikariCP и других библиотек. Они аналогичны тем, что мы писали в упражнениях с Mount и Component.
Начнем с сервера. Метод init-key принимает два параметра: ключ и словарь его
опций. Для конфигурации выше это значения ::server
и {:port 8080 :join?
false}
. Метод должен вернуть объект-состояние компонента. Достаточно передать в
функцию run-jetty
обработчик запроса, объявленный где-то выше, и словарь
опций.
(defmethod ig/init-key ::server
[_ options]
(run-jetty app options))
Поскольку ключ известен из определения метода, первый параметр затеняют символом подчеркивания. По аналогии опишем базу данных. Состояние компонента это JDBC-спека, которую передают в функции из пакета clojure.java.jdbc.
(defmethod ig/init-key ::db
[_ options]
{:datasource (cp/make-datasource options)})
Функция init
пробегает по каркасу системы и вызывает для каждого ключа
мультиметод init-key
. В результате получим словарь-систему, где ключ это имя
компонента, а значение — его состояние:
(def _sys (ig/init config))
(keys _sys)
(:book.integrant/db :book.integrant/server)
В терминах Integrant остановка системы называется halt
. Метод halt-key!
определяет, как выключить определенный ключ. Он принимает два параметра: ключ и
состояние, которые мы вернули из метода init-key
. Определим эти события для
сервера и базы:
(defmethod ig/halt-key! ::server [_ server]
(.stop server))
(defmethod ig/halt-key! ::db [_ db-spec]
(-> db-spec :datasource cp/close-datasource))
Функция halt! останавливает систему целиком:
(ig/halt! _sys)
Зависимости
Чтобы указать ключу зависимости, в его опции добавляют специальный ссылочный
параметр. При запуске Integrant просматривает каркас системы на наличие таких
параметров и строит по ним граф зависимостей. Ссылочный параметр задают функцией
ig/ref
. Она принимает ключ, на который следует сослаться.
Рассмотрим зависимость на примере воркера. Добавьте в конфигурацию новый ключ:
{::worker {:options {:sleep 1000}
:db (ig/ref ::db)}}
Чтобы :db
не слиплось с общими настройками воркера, мы вынесли их в отдельное
поле :options
.
Теперь когда метод init-key
дойдет до ключа ::worker
, в поле :db
будет
значение, которое init-key
вернул для этого ключа. В нашем случае это
JDBC-спека с пулом соединений.
Код запуска и остановки воркера аналогичен тому, что мы писали для Mount и
Component. Для экономии места приведем только реализации init-key
и
halt-key!
. Если вдруг вы забыли, как устроен воркер, обратитесь к прошлым
разделам главы.
(defmethod ig/init-key ::worker
[_ {:keys [db options]}]
(let [flag (atom true)
task (make-task db flag options)]
{:flag flag :task task}))
(defmethod ig/halt-key! ::worker
[_ {:keys [flag task]}]
(reset! flag false)
(while (not (realized? task))
(Thread/sleep 300)))
Параллели с Component
Многие из приемов, которые мы рассмотрели для Component, работают и в Integrant Вспомним некоторые их них.
Глобальное хранилище. Чтобы управлять системой, нужно где-то ее хранить. Проще всего добавить глобальную переменную и вспомогательные функции для запуска и остановки.
(defonce ^:private system nil)
(def alter-system (partial alter-var-root #'system))
(defn system-start []
(alter-system (constantly (ig/init config))))
(defn system-stop []
(alter-system ig/halt!))
Как и в Component, система должна быть приватной. Недопустимо, чтобы компоненты свободно обращались к ней.
Ожидание и сигналы. Перед тем как закончить работу, приложение ожидает, пока
все компоненты корректно остановятся. Пример с макросом with-handler
и
перехватом сигналов работает аналогично для Integrant:
(with-handler :term
(log/info "caught SIGTERM, quitting")
(system-stop)
(log/info "all components shut down")
(exit))
Спуск системы и маршруты. В Integrant легче решить проблему доступа к системе из HTTP-обработчика. Достаточно выразить обработчик в виде компонента с нужными зависимостями. Представим, что главная страница веб-сервера выводит число записей в базе. Добавим в систему новый ключ, который зависит от базы:
{::handler {:db (ig/ref ::db)}}
При запуске ключа вернем функцию-обработчик, замкнутую на db
:
(defmethod ig/init-key ::handler
[_ {:keys [db]}]
(fn [request]
(let [query "select count(*) as total from requests"
result (jdbc/query db query)
total (-> result first :total)]
{:status 200
:body (format "You've got %s requests." total)})))
Доработаем сервер так, чтобы он зависел от обработчика:
{::server {:options {:port 8080 :join? false}
:handler (ig/ref ::handler)}}
(defmethod ig/init-key ::server
[_ {:keys [handler options]}]
(run-jetty handler options))
Теперь браузер покажет фразу “You’ve got N requests.”, где N — число записей в
базе. Как и в примере с Component, ::handler
может вернуть дерево маршрутов,
построенное с помощью Compojure.
Условное построение. Конфигурация системы это словарь, поэтому в него можно добавить или удалить ключи по каким-либо условиям, как мы делали это в Component. Например, специальная функция определяет, будет ли запущен воркер или нет. Если будет, мы добавляем в систему ключ и его настройки.
(cond-> sys-config
(is-worker-supported?)
(assoc ::worker {:options {:sleep 1000}
:db (ig/ref ::db)}))
Есть и другой способ запустить подмножество системы, аналогичный Mount. Функция
init
принимает необязательный список ключей. Этот список, а еще лучше
множество, можно подготовить заранее на базе определенной логики.
(let [components (-> config keys set)
components (cond-> components
(not (is-worker-supported?))
(disj ::worker))]
(ig/init config components))
Проблема потери ключей
Обратите внимание, что мы указываем полные (квалифицированные) ключи для
компонентов, например ::server
или ::db
. Двойное двоеточие означает текущее
пространство имен, в котором объявлен ключ. Запись ::db
это краткий вариант
:book.integrant/db
.
Когда ключ полный, то есть с пространством, легко определить, в каком модуле он
объявлен. В промышленных системах бывает несколько десятков
компонентов. Представьте, что возникла проблема с ключом :queue
. Как понять, в
каком месте мы расширили мультиметод этим ключом? Наоборот, ключ
:my-project.utils.queue/queue
несет эту информацию. Всегда используйте полные
ключи.
Возможна ситуация, когда вы забыли импортировать модуль, в котором расширяете
мультиметод. Иногда трудно понять, почему возникла ошибка: вы точно помните, что
писали этот код. Чтобы избежать ошибки, добавьте эти модули в заголовок ns
главного модуля, который загружается всегда. Пусть это будет модуль, в котором
вы собираете систему.
(ns project.system
(:require project.db
project.server
project.worker
project.utils.queue))
Возможно, утилиты для проверки синтаксиса (линтеры) будут выдавать
предупреждение для этих модулей. С их точки зрения утилиты вы добавили модуль,
но не используете его, потому что в коде нет ни одного выражения
project.db/<something>
. Чтобы подавить эти предупреждения, добавьте модули в
конфигурацию линтера в секцию “known namespaces” или аналогичную.
Integrant предлагает функцию load-namespaces
для автоматической загрузки
модулей. На вход подают конфигурацию системы. Для каждого ключа функция
вычисляет его пространство и загружает его. Вот как выглядит промышленная
система с ключами из разных модулей:
(def config
{:project.server/server {:options {:port 8080 :join? false}
:handler (ig/ref :project.handlers/index)}
:project.db/db {...}
:project.worker/worker {:options {:sleep 1000}
:db (ig/ref :project.db/db)}
:project.handlers/index {:db (ig/ref :project.db/db)}})
Чтобы загрузить все модули, которые участвуют в этой системе, добавьте выражение:
(ig/load-namespaces config)
Заметим, что явная загрузка модулей все же предпочтительнее. Мы советуем
начинающим воздержаться от автоматических импортов. Размещайте их явно в блоке
ns
: этот вариант хоть и многословен, но зато очевиден. Прибегайте к помощи
load-namespaces
только если точно знаете, как работает система пространств в
Clojure.
Система в файле
Выше мы упоминали, что Integrant делает ставку на декларативность. В идеале
конфигурация системы это статичная структура данных, словарь. Для экономии места
систему можно вынести в EDN-файл и прочитать функцией из модуля clojure.edn
.
Читатель заметит, что мы указываем зависимости через функцию ig/ref
, и не
совсем ясно, как поместить это выражение в файл. Вспомним систему тегов: при
чтении EDN-данных мы указываем связь между тегом и функцией, которая обработает
следующее за тегом значение. Для зависимостей Integrant предлагает тег
#ig/ref
. В примере ниже мы выразили зависимость тегом:
{:project.worker/worker {:options {:sleep 1000}
:db #ig/ref :project.db/db}}
Integrant предлагает функцию read-string
что чтения EDN-данных. Это обертка
вокруг clojure.edn/read-string
, заряженная дополнительными тегами. Чтобы
прочитать систему из файла, выполните:
(def config
(-> "config.edn" slurp ig/read-string))
Из главы про конфигурацию мы помним, что нежелательно хранить в файле пароли и
ключи доступа. Этот принцип нарушает компонент :project.db/db
: пароль к базе
данных записан открыто. Сделаем так, чтобы парсер читал пароль из переменной
среды.
Вынесем конфигурацию в файл integrant.test.edn
(ниже ее фрагмент):
{:project.db/db {:password #env DB_PASSWORD}
:project.worker/worker {:options {:sleep 1000}
:db #ig/ref :project.db/db}}
Обернем чтение конфигурации в функцию. Первым аргументом в ig/read-string
укажем словарь с тегами. Функцию tag-env
для тега #env
мы перенесли из
прошлой главы. На нижнем уровне Integrant дополнит наш словарь тегов
собственными, поэтому оба #ig/ref
и #env
будут работать.
Теперь система хранится в файле, а теги описывают ее точнее и гибче.
(defn load-config
[filename]
(ig/read-string {:readers {'env tag-env}}
(slurp filename)))
(load-config "integrant.test.edn")
{:project.db/db {:password "c8497b517da25"}
:project.worker/worker {:options {:sleep 1000}
:db #integrant.core.Ref{:key :project.db/db}}}
Наследование ключей
В Clojure ключи могут выстраиваться в иерархию. Функция derive
принимает два
ключа и задает превосходство первого над вторым.
(derive ::postgresql ::database)
Когда мультиметод разрешает действие по ключу, он учитывает его
иерархию. Например, если мультиметод реализован для ::database
, вызов с
::postgresql
не приведет к ошибке: сработает реализация ::database
.
Поскольку Integrant устроен на мультиметодах, из наследования ключей можно
извлечь пользу. Например, нагруженные проекты работают с двумя базами данных:
мастер для записи и реплика для чтения. Пусть это будут компоненты ::db-master
и ::db-replica
. Технически они одинаковы и отличаются только входными
параметрами.
Если бы мы не знали про наследование, то расширили бы ig/init-key
и
ig/halt-key!
каждым ключом. Пришлось бы копировать код каждой реализации, что
не гибко и считается плохой практикой. Вспомним, что мы уже описали поведение
базы компонентом ::db
. Унаследуем от него две других базы:
(derive ::db-master ::db)
(derive ::db-replica ::db)
Изменим конфигурацию так, что в ней две базы данных: мастер и реплика. Для
реплики мы выставили флаг :read-only
true, чтобы обезопасить себя от записи не
в тот источник. Обратите внимание от какой базы зависит каждый
компонент. Поскольку воркер пишет данные в базу, он зависит от
::db-master
. Компонент ::hander
только читает данные, поэтому зависит от
::db-replica
.
(def config
{::server {:options {:port 8080 :join? false}
:handler (ig/ref ::handler)}
::db-master {;; other fields
:read-only false}
::db-replica {;; other fields
:read-only true}
::worker {:options {:sleep 1000}
:db (ig/ref ::db-master)}
::handler {:db (ig/ref ::db-replica)}})
Функция ig/refset
и одноименный тег вернут множество зависимостей с учетом
иерархии. Предположим, что один из компонентов ожидает все базы данных для
какой-то ручной синхронизации. Чтобы не ссылаться на каждую базу вручную, укажем
ее корневой ключ.
Добавим в конфигурацию компонент ::sync
с зависимостью через refset
. Объявим
пустой init-key
для этого компонента: он ничего не делает, а только возвращает
параметры. При запуске компонент получит множество баз в поле :dbs
{::sync {:dbs (ig/refset ::db)}}
(defmethod ig/init-key ::sync
[_ opt] opt)
(system-start config)
(-> system ::sync :dbs count)
2
Другие стадии компонента
Кроме запуска и остановки, Integrant выделяет дополнительные стадии, которые проходит компонент. В отличии от запуска, они не обязательны к реализации. Дополнительные стадии устроены как мультиметоды, которым задано действие по умолчанию (вернуть nil или исходный объект). Чтобы подписать компонент на событие, расширите мультиметод его ключом. Ниже мы рассмотрим несколько полезных стадий.
Подготовка
Метод ig/prep-key
служит для предварительной подготовки параметров. Чаще всего
это объединение параметров по умолчанию с теми, что мы получили из
конфигурации. Например, мы выяснили, что для нашей инфраструктуры важны именно
такие метрики пула БД. Чтобы не указывать в конфигурации все поля, вынесем их
фиксированную часть в словарь опций по умолчанию.
(def db-defaults
{:auto-commit false
:read-only false
:connection-timeout 30000
:validation-timeout 5000
:idle-timeout 600000
:max-lifetime 1800000
:minimum-idle 10
:maximum-pool-size 10})
(defmethod ig/prep-key ::db
[_ options]
(merge db-defaults options))
Метод prep-key
объединяет этот словарь с параметрами. Теперь в конфигурации
достаточно указать только параметры подключения и, если требуется,
переопределения:
{::db {:auto-commit true ;; override the default
:adapter "postgresql"
:username "book"
:password "book"
:database-name "book"
:server-name "127.0.0.1"
:port-number 5432}}
Функция ig/prep
принимает конфигурацию и запускает метод для каждого
ключа. Чтобы не забыть этот шаг, сделайте его частью функции load-config
,
которую мы описали выше.
Спека
Метод ig/pre-init-spec
связывает параметры компонента со спекой. Если метод
вернул спеку для определенного ключа, параметры проходят проверку. Например, для
базы данных обязательны параметры подключения. Проверим их перед запуском пула:
(require '[clojure.spec.alpha :as s])
(s/def :db/username string?)
;; other db fields
(defmethod ig/pre-init-spec ::db
[_]
(s/keys :req-un [:db/username
:db/password
:db/database-name
:db/server-name
:db/port-number]))
Если запустить систему с неверными параметрами, получим ошибку spec:
Execution error (ExceptionInfo) at integrant.core/spec-exception (core.cljc:385).
Spec failed on key :book.integrant/db when building system
"book" - failed: string? in: [:username] at: [:username] spec: :db/username
Приостановка
Кроме init
и halt
, Integrant выделяет третье состояние системы —
suspended
. Приостановленный компонент не теряет состояние, а только ставит на
паузу внутренние процессы. Например, если это потребитель сообщений из очереди
(KafkaConsumer)
, он не закрывает соединение с очередью, а временно перестает
читать сообщения (вызывать метод poll
). Обратная операция называется
resume. При возобновлении компонент, не порождая новых тредов или соединений,
продолжает работу.
По умолчанию приостановка и возобновление работают как halt
и init
. Это
значит, что если компонент не реализует эти события, он останавливается и
запускается заново. Чтобы задать верную реакцию на suspend и resume, реализуйте
методы ig/suspend-key!
и ig/resume-key
. Это потребует углубленного внимания
и чтения документации. Мы оставим этот раздел читателю на самостоятельное
изучение.
Заключение
Подобно тому, как машина складывается из деталей, программа состоит из компонентов. Система управляет компонентами. Это соглашение о том, как они устроены и связаны друг с другом.
Любой проект нуждается в системе, и чем дальше он развивается, тем сильнее потребность. Если в проекте нет соглашения о том, как проектировать составные части, он начинает буксовать. Поддержка проекта становится слишком затратной.
Clojure предлагает разные подходы для систем. Наиболее популярные из них — Mount, Component и Integrant — мы рассмотрели в этой главе. Библиотеки исповедуют разный подход, и скорее всего разработчик найдет то, что ему по душе.
Проект Mount отталкивается от глобальных переменных. Если проект написан в таком стиле:
(def server (run-jetty app {:port 8080}))
, то портировать его на Mount будет легко. Переменная server станет сущностью, которая меняет значение по команде. Mount подойдет тем, кто только начал знакомство с Clojure.
Библиотека Component это шаг в сторону настоящих компонентов. Такие компоненты это отдельные сущности, которые изолируют состояние. Компоненты и протоколы напоминают классы и объекты и из современных языков программирования. По этой причине некоторые разработчики недолюбливают Component и обвиняют его в излишней раздутости, “энтерпрайзности”.
Действительно, иногда решение на компонентах занимает больше места, чем на атомах и функциях. С другой стороны, именно Component дает понимание того, как строить устойчивые системы. Читатель заметит, что большую часть вопросов мы обсудили в разделе именно про Component.
Проект Integrant ставит цель исправить некоторые недостатки Component. Он лишен ООП-тяжести и целом более “кложурный”. Integrant опирается на идиомы и техники, принятые в Clojure, и тем самым подкупает опытных разработчиков.
Эта глава не ставит цель выяснить, какая из библиотек лучше. Не бросайтесь переписывать проект с условного Mount на Component. Архитектура библиотек слишком отличается друг от друга. Это изнуряющий труд, и вы не поймете, каких преимуществ достигли, пока не ощутите в них потребность.
Вопрос о том, какая система лучше ниже рангом другого, более важного вопроса: зачем система нужна проекту. Когда вы понимаете важность системного подхода, технические решения найдутся сами.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter
anonymous, 5th Sep 2019, link
Очень круто, спасибо!
Roman Dronov, 30th Jul 2020, link
Привет. Очень интересно. Заметил на мой взгляд неточность
Тут наверно Component имелся в виду а не Compojure?
С уважением и ожиданием книги, Роман
Ivan Grishaev, 30th Jul 2020, link , parent
Точно, должен быть Component, спасибо.