Содержание

В этой главе мы рассмотрим, как сделать Clojure-проект удобным в плане настроек. Мы разберем основные приемы конфигурирования: форматы файлов, переменные среды, несколько библиотек, достоинства и недостатки различных подходов.

Внимание! Вы читаете черновик к книге “Clojure на производстве”. Для книги я переписывал его много раз, но в блоге осталась старая версия. Здесь она для истории, а вам я рекомендую купить книжку.

Постановка проблемы

Когда мы читаем документацию к библиотекам Clojure, иногда встречаем подобные выражения:

(def server
  (jetty/run-jetty app {:port 8080}))

(def mysql-db
  {:dbtype   "mysql"
   :dbname   "book"
   :user     "ivan"
   :password "****"})

Это веб-сервер на 8080 порту и параметры подключения к базе. Примеры полезны тем, что их можно скопировать в REPL, выполнить и оценить результат. Например, открыть браузер со страницей веб-приложения или сделать запрос к базе данных.

На практике эти выражения дорабатывают так, чтобы в них не было конкретных цифр и строк. С точки зрения проекта плохо то, что в выражении веб-сервера явно записан порт. Такая запись подходит для документации и примеров, но не для боевого проекта.

Порт 8080, ровно как и другие комбинации нулей и восьмерок, пользуется популярностью у разработчиков. Велика вероятность, что порт будет занят другим веб-сервером. Это возможно, когда вы запускаете не отдельный сервис, а их связку на время разработки или прогона тестов.

Код, написанный разработчиком, обычно проходит несколько стадий. Их набор и структура отличаются в зависимости от фирмы, но в целом это разработка, автоматическое и ручное тестирование (юнит-тесты и QA), пре-прод и боевой режим.

На каждой стадии приложение запускают бок о бок с другими проектами. Предположение о том, что порт 8080 свободен в любой момент на каждой машине, звучит как утопия. На жаргоне разработчиков это называется “хардкод” (hardcode), что соответствует идиоме “прибито гвоздями”.

Когда приложение опирается на “прибитые” значения, это вносит проблемы в его жизненный цикл. Например, вы не сможете одновременно разрабатывать и тестировать приложение. Или два тестовых сервера подключаются к одной базе данных и вы не понимаете, почему данные меняются спонтанно.

Ошибка в том, что мы неправильно распределяем ответственность. Приложение не должно решать, на каком порту запускать веб-сервер. Информация об этом должна прийти извне. В простейшем случае это текстовый файл. Приложение читает из него порт и запускает сервер именно так, как это нужно в конкретной машине.

В более сложных сценариях этот файл составляется не человеком, а специальной программой — менеджером конфигураций. Эти менеджеры хранят информацию о топологии сети, адреса машин, типы баз данных и параметры подключения к ним. По запросу они выдают файл с верными параметрами для определенной машины или сегмента сети.

Процесс, когда мы сообщаем приложению параметры работы, а оно принимает эти параметры, называется конфигурацией. Это очень интересный и важный процесс. Когда он построен удачно, проект не испытывает трудностей на разных стадиях производства.

Семантика

Конечная цель конфигурации в том, чтобы управлять программой, не меняя ее код. Это становится очевидным с ростом кодовой базы и инфраструктуры фирмы. Когда у вас скрипт на Perl или Python, нет ничего зазорного в том, чтобы открыть его и поменять константу. На некоторых предприятиях такие скрипты работают годами.

Но чем совершенней инфраструктура фирмы, тем больше в ней ограничений. Сегодняшние практики нацелены на то, чтобы свести на нет спонтанные изменения в проекте. Например, невозможно сделать git push напрямую в мастер; нельзя сделать merge, пока pull request не одобрят минимум два члена команды; приложение не загрузится на сервер, пока не пройдут тесты.

Это приводит к тому, что даже экстренное изменение в коде потребует около часа, чтобы попасть на боевой сервер. Изменение в конфигурации несоизмеримо дешевле, чем выпуск новой версии продукта. Из этого следует правило: если можно вынести что-то в конфигурацию, лучше сделать это сейчас.

В крупных фирмах практикуют то, что называют feature flag. Это логическое поле, которое включает целый пласт логики в приложении. Например, новый интерфейс, систему обработки заявок, улучшенный чат. Обновления такого масштаба долго тестируют внутри фирмы, но всегда остается риск, что в боевом режиме что-то пойдет не так. В этом случае достаточно изменить в конфигурации одно поле с истины на ложь и перезапустить сервер. Это удобно не только в техническом плане, но экономит время ваших коллег и сохранит репутацию фирмы.

Цикл конфигурации

Во время запуска каждое приложение следует определенному регламенту. Это может быть подготовка ресурсов, файлов, обмен данными по сети. В момент подготовки приложение решает, сможет ли оно продолжить работу.

Работа с конфигурацией занимает одну из первых позицией в регламенте. Это логично, поскольку чем лучше спроектировано приложение, тем больше его частей опирается на конфигурацию. При этом обработка конфигурации это не монолитная задача, а набор шагов. Перечислим наиболее важные из них.

На первом этапе программа читает конфигурацию извне. Чаще всего это текстовый файл или переменные среды. Чтобы хранить данные в тексте, уже придуманы специальные форматы: JSON, EDN, YAML. Приложение несет на борту модуль, чтобы получить их этих файлов структуру данных. Мы подробно рассмотрим плюсы и минусы каждого формата ниже.

Переменные среды это часть операционной системы. Можно представить их как глобальный словарь в памяти текущего сеанса. Каждое приложение наследует этот словарь в момент запуска. Языки и фреймворки предлагают функции, чтобы считать переменные в стандартные строки и словари.

Файлы и переменные среды дополняют друг друга в различных комбинациях. Например, приложение читает данные из файла, но путь к нему извлекает из переменной среды. Или в файле конфигурации опущены критические данные: пароли и API-ключи. Так поступают, чтобы посторонняя программа не смогла считать их. Приложение читает основные параметры из файла, а секретные ключи из переменных среды.

Продвинутые системы конфигурации поддерживают систему тегов. Например, в файле перед значением переменной ставят тег: :password #env DB_PASSWORD. Это значит, что в поле password следует поместить не строку DB_PASSWORD, а значение одноименной переменной. Мы подробно рассмотрим систему тегов в следующем разделе.

Первый этап завершается тем, что вы получили структуру данных. Неважно, был ли это файл, переменные среды или данные из сети. У вас в памяти структура, чаще всего словарь, и приложение переходит ко второму этапу — выводу типов.

Не всегда данные, которые вы прочитали извне, соответствуют типам вашего языка или платформы. Форматы JSON или YAML выделяют базовые типы: строки, числа, булево, null и коллекции: словарь и список. Легко заметить, что среди них нет типа даты. В то же время даты часто встречаются в конфигурации, чтобы регулировать промо-акции или события, связанные с праздниками (черная пятница, Новый год, и тд).

В структурированных файлах даты задают либо ISO-строкой, либо числом секунд c 1 января 1970 года (эпоха UNIX). Специальный код должен пробежать про структуре данных и перевести такие поля в тип даты, принятый в языке.

Вывод типов применяют и для коллекций. Словарей и списков может оказаться недостаточно для комфортной работы. Например, список допустимых состояний объекта логично представить множеством, поскольку поиск в множестве в среднем быстрее, чем в линейной структуре. Поэтому список, полученный из JSON-файла, заменяют на множество.

Читатель, знакомый с Clojure, заметит, что эти проблемы решены в формате EDN. Он по умолчанию поддерживает даты и множества. Это не совсем верно: в некоторых проектах пользуются сторонним типом дат, например, JodaTime. Это значит, нам потребуется вывод верного типа из строки или стандартного Date.

В других случаях требуется обернуть скалярное значение в класс. Скажем, чтобы передать ENUM-значение в запрос, нужно обернуть его в класс PGObject. Чтобы не создавать новый объект каждый раз, логично вывести тип на этапе конфигурации. Поэтому даже EDN-данные нуждаются в выводе типов.

Переменные среды не обладают такой гибкостью, как современные форматы файлов. Если JSON и аналоги выделяют скаляры, структуры и их комбинации, то переменные среды представлены только строкой. Для переменных вывод типов не просто желателен, он необходим. Будет физически невозможно передать строку в функцию, которая ожидает число.

После вывода типов приступают к валидации данных. В главе про Clojure.spec мы упоминали, что верный тип еще гарантирует корректное значение. Предположим, мы считали переменную среды DB_PORT="8080". До тех пор, пока это строка, мы не можем проверить значение на числовой диапазон. Проверка нужна, чтобы в конфигурации нельзя было указать порт 0, -1 или 80.

Из той же главы мы помним, что иногда значения верны по отдельности, но их комбинация ошибочна. Например, в конфигурации указан срок действия события. Это массив из двух элементов, где первый это дата начала, а второй дата конца. На этапе конфигурирования легко поменять даты местами, и тогда проверка текущей даты на интервал всегда вернет ложь. Валидация должна проверять и даты по отдельности, и их комбинацию (первая строго меньше второй).

Без валидации вы рискуете передать в Java-код пустые значения. Вы не заметите ошибки на уровне Clojure, потому что там nil трактуется как пустая коллекция. Но в случае с Java приложение выложит длинный трейс NullPointerException, расследовать который будет тяжело.

Если валидация прошла успешно, переходят к последней стадии. В этот момент приложение определяет, где хранить конфигурацию. В зависимости от архитектуры это может быть глобальная переменная модуля или компонент верхнего уровня. Когда конфигурация прочитана, другие части системы считывают свои параметры и запускают сами себя.

Ошибки конфигурации

На каждом из перечисленных этапов может возникнуть ошибка. Не найден файл конфигурации, забыли прочитать ENV-переменные, ошибка синтаксиса в файле, неверное значение поля. В таком случае от хорошей системы ожидают двух действий: сообщения об ошибке и немедленного завершения.

Сообщение должно четко отвечать на вопрос, что пошло не так. К сожалению, иногда разработчики описывают только положительный путь и забывают об ошибках. При запуске таких программ мы видим стек-трейс и тратим время, чтобы его осмыслить. Это грубейшая ошибка.

В главе про исключениях мы рассмотрели хорошую практику. Если логика состоит из отдельных шагов, будет правильно обернуть каждый шаг в try/catch. Если блок вышел с ошибкой, мы выводим понятное сообщение о том, что намеревались сделать и ключевые параметры исключения: его класс и сообщение.

Если ошибка случилась на этапе проверки или вывода типов, разработчик обязан объяснить, какое именно поле было тому виной. В главе про Clojure.spec мы рассмотрели, как улучшить стандартный отчет спеки. Это требует дополнительных усилий, но окупается со временем.

Сегодня IT-индустрия работает так, что одни сотрудники пишут код, а другие управляют его запуском. Скорее всего коллега из отдела DevOps даже не знает, что такое Clojure и не сможет понять отчет спеки. Поэтому он придет к вам с просьбой улучшить вывод сообщений об ошибке в конфигурации. Лучше сделать это заранее хотя бы из уважения к коллегам.

Если с конфигурацией что-то не так, программа не должна работать дальше в надежде, что все обойдется. Технически возможна ситуация, когда один из параметров не задан (nil вместо числа), но программа к нему не обращается. Этого поведения следует избегать, потому что рано или поздно ошибка всплывет, и ее будет трудно расследовать.

Когда один из шагов конфигурации не сработал, программа выводит сообщение и аварийно завершается с кодом, отличным от нуля. Сообщение пишут в канал stderr, чтобы подчеркнуть внештатную ситуацию. Современные терминалы направляют канал ошибок в стандартный вывод, но подсвечивают такие сообщения красным цветом.

Практика

Чтобы закрепить все, о чем мы говорили выше, напишем свою подсистему конфигурации для приложения. Это будет отдельный модуль примерно на 100 строк. Прежде чем садиться за редактор, определимся с основными положениями будущего кода.

Будем хранить конфигурацию в json-файле. Это не потому, что мы не знаем про edn. Предположим, что конфигурации других проектов тоже работаю на json-файлах, и у коллег из DevOps отдела уже написаны скрипты на Python для управления этими файлами. Проект с новым форматом усложнит коллегам жизнь.

Путь к файлу конфигурации передают в переменной среды CONFIG_PATH. От конфигурации мы ожидаем порт веб-сервера, параметры базы данных и диапазон дат для промо-акции. Текстовые даты должны стать объектами java.util.Date. Левое значение интервала строго меньше правого.

Готовый словарь запишем в глобальную переменную CONFIG. Если на одном из шагов случилась ошибка, выводим сообщение и завершаем программу.

Начнем со вспомогательной функции exit. Ее главное тело принимает статус завершения программы и сообщение, которое увидит пользователь. В альтернативное тело кроме статуса передают шаблон для функции format и произвольные параметры подстановки.

(defn exit

  ([code template & args]
   (exit code (apply format template args)))

  ([code message]
   (let [out (if (zero? code) *out* *err*)]
     (binding [*out* out]
       (println message)))
   (System/exit code)))

В зависимости от статуса выводим сообщение либо в стандартный поток, либо в поток ошибок и завершаем программу.

Напишем загрузчик конфигурации. Это последовательность шагов, каждый из которых принимает результат предыдущего. Логику каждого шага легко понять из имени функции. Для краткости мы совместили вывод типов и валидацию в одном шаге coerce-config. Оба этих действия работают через clojure.spec, поэтому технически срабатывают за один вызов s/conform.

(defn load-config!
  []
  (-> (get-config-path)
      (read-config-file)
      (coerce-config)
      (set-config!)))

Осталось написать логику каждого шага. Функция get-config-path читает переменную среды и проверяет, есть ли такой файл на диске. Если все в порядке, функция вернет путь к файлу. В негативных случаях она вызывает exit:

(import 'java.io.File)

(defn get-config-path
  []
  (if-let [filepath (System/getenv "CONFIG_PATH")]
    (if (-> filepath File. .exists)
      filepath
      (exit 1 "Config file does not exist"))
    (exit 1 "File path is not set")))

Шаг read-config-file считывает данные из файла по его пути. Для разбора json воспользуемся библиотекой cheshire. Поскольку файл конфигурации небольшой, нет смысла читать из файла поток. Достаточно прочитать содержимое в большую строку и разобрать ее функций json/parse-string.

(require '[cheshire.core :as json])

(defn read-config-file
  [filepath]
  (try
    (-> filepath slurp (json/parse-string true))
    (catch Exception e
      (exit 1 "Malformed config file: %s" (ex-message e)))))

Стадия проверки и вывода типов займет больше строк. Это самый важный этап во всем процессе: нельзя допустить, чтобы приложение начало работу с неверными параметрами.

Оба действия удобно совместить в спеке. Наша спека для конфигурации одновременно выводит типы и проверяет значения. В главе про спеку мы рассмотрели продвинутый способ вывода ошибок на базе собственных словарей. Способ делает сообщения понятнее для человека, но сложен в поддержке.

Для простоты воспользуемся библиотекой expound. Ее функция expound-str вернет улучшенный вариант стандартного отчета spec. Он не настолько удобен, как написанные вручную сообщения, но все же лучше, чем explain.

(require '[clojure.spec.alpha :as s])
(require '[expound.alpha :as expound])

(defn coerce-config
  [config]
  (try
    (let [result (s/conform ::config config)]
      (if (= result ::s/invalid)
        (let [report (expound/expound-str ::config config)]
          (exit 1 "Invalid config values: %s %s" \newline report))
        result))
    (catch Exception e
      (exit 1 "Wrong config values: %s" (ex-message e)))))

Логика coerce-config проста: мы выводим чистые данные из исходных функцией s/conform. Ее вызов потенциально несет исключения, поэтому оборачиваем в try/catch. Если результат conform был ключом :invalid из пакета спеки, формируем отчет об ошибке и выводим пользователю

Не хватает определения спеки. Откроем файл конфигурации и изучим его структуру. Это json-файл следующего содержания:

{
    "server_port": 8080,
    "db": {
        "dbtype":   "mysql",
        "dbname":   "book",
        "user":     "ivan",
        "password": "****"
    },
    "event": [
        "2019-07-05T12:00:00",
        "2019-07-12T23:59:59"
    ]
}

Опишем спеку сверху вниз. На верхнем уровне это словарь с ключами:

(s/def ::config
  (s/keys :req-un [::server_port ::db ::event]))

Порт сервера это комбинация из двух предикатов: число и вхождение в диапазон. Первичная проверка на число нужна, чтобы во второе выражение не попали nil и строка. Это вызовет исключение там, где его не ждали.

(s/def ::server_port
  (s/and int? #(< 0x400 % 0xffff)))

Для подключения к базе данных используем готовую спеку из пакета jdbc:

(require '[clojure.java.jdbc.spec :as jdbc])
(s/def ::db ::jdbc/db-spec)

Остался диапазон дат. Импортируем функцию для разбора строки в дату. Это пакет clojure.instant из стандартной поставки Clojure. Функция read-instant-date довольно лояльна к формату строки и читает неполные даты. Обернем ее в s/conform:

(require '[clojure.instant :as inst])
(def ->date (s/conformer inst/read-instant-date))

Запишем проверку диапазона как функцию. Она принимает вектор двух объектов java.util.Date и сравнивает их. Даты нельзя сравнивать операторами больше или меньше. Причина кроется в реализации класса Date на Java-уровне. Специальная функция compare принимает два объекта и возвращает -1, 0 и 1, что означает меньше, равно и больше.

(s/def ::date-range
  (fn [[date1 date2]]
    (neg? (compare date1 date2))))

Спека события это комбинация кортежа и проверки диапазона. Спека кортежа проверяет, что данные это коллекция строго из двух элементов, каждый их которых преобразуется в дату.

(s/def ::event
  (s/and
   (s/tuple ->date ->date)
   ::date-range))

Спека готова, и это значит, что шаг coerce-config тоже завершен. Уже на этом этапе можно закомментировать последнюю строку в load-config! и убедиться, что на выходе словарь с правильными значениями.

Шаг set-config! записывает конфигурацию в глобальную переменную. Объявим будущую конфигурацию под именем CONFIG. Написание в верхнем регистре, во-первых, подчеркивает, что это глобальная переменная. Во-вторых, тем самым мы не затеним ее локальным параметром функции.

(def CONFIG nil)

(defn set-config!
  [config]
  (alter-var-root (var CONFIG) (constantly config)))

Достаточно вызвать (load-config!) на старте программы, чтобы в CONFIG появилась проверенная конфигурация. Другие подсистемы импортируют CONFIG в свои модули и читают нужные им ключи. Вот как выглядит запуск сервера или запрос к базе данных с учетом конфигурации:

(require '[project.config :refer [CONFIG]])

(def server
  (jetty/run-jetty app {:server_port CONFIG}))

(jdbc/query (:db CONFIG) "select * from users")

Анализ результата

Итак, мы написали загрузчик конфигурации. Он простой и легкий в поддержке. Чтобы добавить новые поля, достаточно расширить спеку. Хотя это не промышленное решение, такой загрузчик подойдет для небольших проектов.

Преимущество загрузчика в том, что в любой момент можно считать конфигурацию заново. Это особенно удобно в сеансе REPL: достаточно изменить json-файл и вызвать load-config! повторно.

Один из недостатков нашей реализации в том, что код привязан к функции exit. В момент вызова она завершает программу. Это правильный подход на старте приложения: нет смысла продолжать, если входные параметры неверны. Но при разработке от этого больше проблем: ошибка в момент загрузки завершит сеанс REPL. Вам придется запускать проект и подсоединяться к нему заново.

Мы неправильно разделили ответственность. Завершение всей программы это слишком радикальное действие. Хотелось бы влиять на то, как загрузчик сигнализирует об ошибке. В идеале загрузка конфигурации и реакция системы на ошибки должны быть разделены.

Наивный способ исправить ситуацию — вызывать load-config! внутри макроса, который переопределяет exit. Пусть новая функция не вызывает System/exit, а просто кидает исключение с тем же сообщением. Вынесем эту логику в отдельную функцию:

(defn load-config-repl!
  []
  (with-redefs
    [exit (fn [_ ^String msg]
            (throw (new Exception msg)))]
    (load-config!)))

Теперь вызов load-config-repl! на конфигурации с ошибками просто бросит исключение, но не завершит сеанс.

Более удачное решение в том, чтобы передать функцию в дополнительные параметры загрузки. В сторонних библиотеках встречается параметр :die-fn или аналогичный. Это функция, которая принимает экземпляр исключения. Тем самым вы управляете реакцией на ошибку в зависимости от контекста. В боевом запуске die function завершает программу, в режиме разработки просто пишет сообщение в REPL.

В свободное время доработайте загрузчик так, чтобы он поддерживал параметр :die-fn. Продумайте поведение по умолчанию, если он не задан.

Подробнее о переменных среды

Наш загрузчик устроен так, что наиболее значимая информация приходит из файла. Из переменных среды мы берем только малую часть — путь к этому файлу. Можно изменить загрузчик так, чтобы он считывал конфигурацию из переменных и даже не нуждался в файлах.

Прежде чем это сделать, и чтобы лучше понять преимущества этого подхода, поговорим о переменных среды в отрыве от конкретного языка.

Переменные среды еще называют ENV, “энвы” (от англ. environment, окружающая среда). Это фундаментальное свойство операционной системы. Представьте переменные как глобальный словарь, который наполняется во время загрузки. В этом словаре записаны основные параметры системы. Например, это локаль, список путей, по которым система ищет исполнительные файлы, домашняя директория текущего пользователя и многие другие значения.

Чтобы увидеть текущие переменные, выполните в терминале команду env или printenv. На экране появится список пар вида ИМЯ=значение. Имена переменных принято записывать в верхнем регистре, чтобы выделить на фоне других переменных и подчеркнуть их приоритет. Большинство систем различают регистр, поэтому foo и FOO будут разными переменными. Пробелы и дефисы в имени переменных недопустимы. Лексемы разделяют подчеркиванием.

Фрагмент вывода printenv:

LANG=en_US.UTF-8
PWD=/Users/ivan
SHELL=/bin/zsh
TERM_PROGRAM=iTerm.app
COMMAND_MODE=unix2003

Каждый новый процесс операционной системы получает копию этого словаря. Процесс может добавить или удалить переменную, но изменения будут видны только в рамках этого процесса и его потомков. Если процесс запускает потомка, он наследует переменные родителя.

Таким образом, первоначальный набор переменных на уровне ОС не изменяется. Все, что мы можем сделать это установить нужные переменные в одном процессе и запустить из него дочерние. Чтобы изменить состав переменных на уровне системы, требуется менять ее конфигурационные файлы.

Локальные и глобальные переменные

Различают переменные среды и шелла, они же глобальные и локальные переменные. В них часто путаются начинающие программисты. Откройте терминал и выполните команду:

FOO=42

Тем самым вы задали переменную текущего шелла. Чтобы сослаться на переменную, то есть получить по имени ее значение, перед ней ставят знак доллара. Выражение ниже напечатает значение новой переменной:

echo $FOO
42

Если выполнить printenv, мы не увидим FOO в списке переменных среды. Это потому, что выражение FOO=42 устанавливает переменную в текущий шелл, а не среду. Переменные шелла видны только ему и не наследуются потомками. Проверим это: из текущего шелла запустим новый и попытаемся напечатать переменную.

sh
echo $FOO

Результатом будет пустая строка, потому что в процессе-потомке такой переменной нет. Выполните exit, чтобы вернуться в родительский шелл.

Выражение export означает, что переменная становится частью среды шелла. Установленная таким образом, она видна в списке printenv и доступна процессам-потомкам:

export FOO=42

printenv | grep FOO
FOO=42

sh
echo $FOO
42

Иногда мы хотим запустить процесс с переменной, но так, чтобы она не была видна другим процессам. В таком случае команда export не подойдет, потому что об этой переменной узнают все дочерние процессы. Чтобы сообщить переменную только одному процессу, его запускают сразу после выражения ИМЯ=значение:

BAR=99 printenv | grep BAR
BAR=99

Вызов printenv порождает новый процесс, внутри которого доступна переменная BAR. Но если снова напечатать $BAR, мы получим пустую строку.

Переменные особенно удобны в связке с программами, которые читают из них настройки. Например, клиент к базе данных PostgreSQL различает около двадцати переменных: PGHOST, PGDATABASE, PGUSER и многие другие. У переменных среды приоритет выше, чем у параметров --host, --user и аналогов. Это значит, если в текущем шелле выполнить команды

export PGHOST=some.staging.com PGDATABASE=project

, то любая утилита из поставки PostgreSQL сработает на заданном сервере, базе и пользователе. Это удобно, если требуется выполнить серию команд, например дамп базы, набор запросов и восстановление. Не придется передавать каждой команде параметры --host и другие.

Переменные как конфигурация

Связь между переменными среды и приложением в том, что в общем случае можно передать конфигурацию переменными. Каждый язык или фреймворк предлагает набор функций, чтобы считать переменные по отдельности в строки или целиком в словарь. Разберем преимущества и недостатки этого подхода.

Поскольку окружение хранится в памяти, приложение не обращается к диску во время чтения переменных. И дело даже не в том, что доступ к памяти быстрее диска (человек не в силах отличить сотую долю секунды от тысячной). Приложение, которое не зависит от конкретного файла конфигурации в целом более автономно и потому удобней в поддержке.

Бывает, во время деплоя на сервер файл конфигурации оказался в другой директории, и приложение не может его найти. Или, что еще хуже, запускается со старой версией конфигурации. Такие ситуации замедляют работу команды, оттягивают на себя полезное время.

В целом считается, что передавать пароли и ключи через переменные безопаснее, чем в файлах. Причина в том, что файл могут прочитать другие программы, в том числе шпионское ПО. Или файл конфигурации по ошибке попадет в репозиторий и останется в его истории. В то же время переменные среды эфемерны. Они живут только памяти операционной системы. Пользователь физически не может прочитать переменные процессов, запущенных другим пользователем.

Даже когда конфигурация составлена удачно, не всегда удобно править ее вручную. Современная индустрия постепенно уходит от файлов в сторону контейнеров и сервисов. Если раньше мы копировали файлы по FTP или SSH, то сегодня приложение запускается как множество экземпляров одного образа. Так работают системы виртуализации Kubernetes и другие.

С точки зрения системы образ это черный ящик. Технически возможно подключиться к запущенному образу (он называется контейнер). Но во-первых, изменения контейнере не сохраняются в образ и живут в памяти до завершения работы. Во-вторых, подключаться к пяти экземплярам приложения и менять что-то вручную утомительно и непродуктивно. Остается только собрать образ с новым конфигурационным файлом на борту, загружать его в хранилище и перезапускать сервис, что в целом долго.

Облачные платформы достаточно развиты, чтобы избежать таких сценариев. В панели администратора для каждого сервиса есть форма с переменными среды, которые процесс получит при старте. Если приложение читает конфигурацию из среды, достаточно поменять значение прямо в форме и перезагрузить сервис.

Принцип “конфигурация в среде” встречается в одном из пунктов The Twelve-Factor App. Это набор хороших практик при разработке приложений, надежных и удобных в поддержке. Пункт №3 документа перечисляет достоинства переменных среды: независимость от файлов, конкретного языка или формата данных, поддержка на всех платформах.

К недостаткам переменных можно отнести то, что они не типизированы. Переменные несут только один вид данных — текст. Поэтому разбор и вывод типов остается за приложением. Важно, чтобы вывод был организован не спонтанно и вручную, а декларативно.

Так, в проектах на Python часто встречается код:

db_port = int(os.environ["DB_PORT"])

Это нормально для разового применения, но иногда подобные выражения занимают несколько экранов. Будет правильно построить словарь, где ключ это имя переменной, а значение — функция вывода.

env_mapping = {"DB_PORT": int}

Специальный код обходит эту структуру и наполняет словарь результата. Этот подход справедлив и для других языков. В случае с Clojure мы пользуемся спекой.

Переменные среды не подразумевают вложенность. Это плоский набор ключей и значений, что не всегда ложиться на структуру конфигурации. Чем больше параметров в структуре, тем больше вероятность, что нам захочется сгруппировать близкие их них по смыслу. Например, вместо того, чтобы перечислять параметры базы данных с префиксом, логичнее вынести их во вложенный словарь:

;; so-so
{:db-name "book"
 :db-user "ivan"
 :db-pass "****"}

;; better
{:db {:name "book"
      :user "ivan"
      :pass "****"}}

В разных системах устоялись свои соглашения, согласно которым обрабатывают вложенные переменные. В общем порядке это решается специальными префиксами. Например, одинарное подчеркивание только разделяет лексемы, но не влияет на структуру. Двойное подчеркивание означает вложенность:

"DB_NAME=book"
;; {:db_name "book"}

"DB__NAME=book"
;; {:db {:name "book"}}

Для массивов используют квадратные скобки или запятые. При разборе строк-массивов остается риск ложного разбиения. Это случается, когда запятая или скобка относится к слову, а не к синтаксису.

Эти правила и их реализация остаются на совести разработчика. Если форматы json, yaml и другие задают четкий стандарт о том, как выглядят структуры данных, то для переменных среды единого стандарта нет. Ситуация ухудшается, когда отдельные компоненты системы принимают параметры большой вложенности, например список словарей списков. Переменные среды не способны передать такую структуру.

При разработке становится очевиден другой недостаток переменных среды — на некоторых системах они доступны только для чтения. Это верно идеологически, но вынуждает перезагружать сеанс REPL каждый раз, когда требуется новое значение переменной, что неудобно. В случае с файлом достаточно изменить его и вызвать функцию загрузки еще раз.

Env-файлы

Когда переменных среды становится слишком много, их ручной ввод через export становится утомителен. Поэтому переменные выносят в файл, который по-другому называют env-конфигурацией.

С технической точки зрения такой файл представляет собой шелл-скрипт. Однако, чем меньше скриптовых возможностей использует файл, тем лучше. В идеале файл содержит только пары ИМЯ=значение по одной на каждую строку.

DB_NAME=book
DB_USER=ivan
DB_PASS=****

Чтобы считать эти данные в шелл, вызывают команду source <file>. Это одна из команд bash, которая выполняет указанный скрипт в текущем сеансе. Cкрипт добавит переменные в шелл, и вы увидите их после завершения source. Это важное отличие от команды bash <file>, которая выполнит скрипт в отдельном шелле.

source ENV
echo $DB_NAME
book

Но если запустить из текущего шелла приложение, оно не увидит эти переменные. Вспомним, что выражение VAR=value задает локальную переменную шелла, а не среды. Поэтому DB_NAME и другие не попадут в окружение шелла и не будут унаследованы приложением. Это легко проверить с помощью printenv:

source ENV
printenv | grep DB
# exit 1

Существует два способа решить эту проблему. Первый — открыть файл и расставить перед каждой парой выражение export. Тогда source этого файла вынесет переменные в окружение:

cat ENV
export DB_NAME=book
export DB_USER=ivan
export DB_PASS=****

source ENV
printenv | grep DB
DB_NAME=book
DB_USER=ivan
DB_PASS=****

Недостаток этого способа в том, что в env-файле появляется дополнительная логика. При редактировании файла легко потерять export перед переменной, и приложение не сможет ее считать.

Другой способ заключается в особом параметре -a текущего шелла. Когда он установлен, любая локальная переменная автоматически попадает в окружение. Перед тем, как читать переменные из файла, этот параметр возводят в истину, а затем снова в ложь.

set -a
source ENV
printenv | grep DB
# prints all the vars
set +a

Выражение set немного противоречиво: параметр с минусом включает параметр, а с плюсом — отключает. Это исключение, которое придется запомнить.

Если считать переменную, которая уже была в окружении, она заменит прежнее значение. За счет этого удобно строить различные конфигурации на базе файлов-переопределений. Если вам нужны настройки для тестов, не обязательно дублировать весь файл. Достаточно создать новый файл с отличающимися полями и выполнить его после исходного.

Пусть тестовые настройки отличаются от стандартных только именем базы. Файл ENV несет в себе базовые параметры, а в файл ENV_TEST поместим новое значение параметра:

cat ENV_TEST
DB_NAME=test

set -a
source ENV
source ENV_TEST
set +a

echo $DB_NAME
test

Читатель заметит, что идея env-файлов противоречива. Сначала мы говорили, что переменные среды снимают зависимость от файла конфигурации. Но закончили тем, что создали файл с переменными. Какой в этом смысл, если просто сменился формат записи?

Разница между json- и env-файлами в том, кто их читает. В первом случае конфигурацию читает приложение, а во втором — операционная система. Если json-файл располагается в строго заданной директории, то переменные среды могут быть прочитаны откуда угодно. Тем самым мы выносим из приложения часть, ответственную за поиск конфигурации, и оставляем запас для маневра.

Переменные среды в Clojure

Переходим к практической части. С учетом всего сказанного рассмотрим, как работать с env-переменными в Clojure.

Вспомним, что Clojure это гостевая платформа. Это значит, язык не предлагает возможностей для доступа к системным ресурсам. В библиотеке Clojure нет функции для чтении переменных среды. Мы получим их из класса java.lang.System. Это особый класс Java, который не требуется импортировать. Он доступен в любом пространстве имен.

Статический метод getenv этого класса возвращает либо значение переменной по ее имени, либо словарь всех переменных, если вызвать метод без параметров.

;; a single variable
(System/getenv "HOME")
"/Users/ivan"

;; the whole map
(System/getenv)
{"JAVA_ARCH" "x86_64", "LANG" "en_US.UTF-8"} ;; truncated

Уточним, что метод без параметров возвращает не Clojure-, а Java-коллекцию. Это неизменяемая версия java.util.Map. Поэтому переменные невозможно изменить после запуска виртуальной машины JVM.

Чтобы упростить работу Java-коллекцией, переведем ее в аналогичный тип Clojure. Заодно преобразуем ключи: сейчас это строки в верхнем регистре и подчеркиваниями. В мире Clojure пользуются кейвордами для словарей и записью, известной как kebab-case: нижний регистр с дефисами.

Напишем функцию для перевода ключа:

(require '[clojure.string :as str])

(defn remap-key
  [^String key]
  (-> key
      str/lower-case
      (str/replace #"_" "-")
      keyword))

и убедимся в ее работе:

(remap-key "DB_PORT")
:db-port

Функция remap-env проходит по Java-коллекции и возвращает ее Clojure-версию с привычными ключами:

(defn remap-env
  [env]
  (reduce
   (fn [acc [k v]]
     (let [key (remap-key k)]
       (assoc acc key v)))
   {}
   env))

Результат может оказаться довольно большой коллекцией. Современные системы хранят в памяти несколько десятков переменных. Для экономии места приведем небольшую часть словаря:

(remap-env (System/getenv))

{:home "/Users/ivan"
 :lang "en_US.UTF-8"
 :term "xterm-256color"
 :java-arch "x86_64"
 :term-program "iTerm.app"
 :shell "/bin/zsh"}

Теперь когда переменные среды это обычный Clojure-словарь, его перемещают по тому же конвейеру. Это вывод типов и валидация спекой. Поскольку все поля словаря это строки, придется изменить отдельные части спеки. Например, заменить числовые поля на вывод чисел из строк. Особо удачной будет спека, которая работает и с числами для json, и с текстом для переменных среды. Вот как выглядит умный парсер числа:

(s/def ::->int
  (s/conformer
   (fn [value]
     (cond
       (int? value) value
       (string? value)
       (try (Integer/parseInt value)
            (catch Exception e
              ::s/invalid))
       :else ::s/invalid))))

С таким определением мы можем менять источник конфигурации без изменений в спеке.

Проблема лишних ключей

Словарь переменных носит существенный недостаток — в нем много посторонних полей. Приложению не требуется знать домашнюю директорию пользователя и версию терминала. Эти поля производят шум при выводе конфигурации на печать или в логи. Если конфигурация не прошла спеку, отчет explain включает эти поля в отладочную информацию. Их следует убрать.

Лучший способ выбрать подмножество словаря это функция select-keys. Она принимает словарь и список ключей. Но как узнать, какие именно ключи нужно выбрать? Перечислять их вручную долго, и фактически мы дублируем данные. Центральный объект, который знает о том, какие поля проверять это спека ::config. С помощью небольшого трюка мы вытащим из нее список ключей.

Функция s/form принимает ключ спеки и возвращает ее замороженное определение. Это цепочка cons-ячеек, где каждый элемент это символ, кейворд, вектор и так далее. Для спеки ::config мы получим список:

(clojure.spec.alpha/keys
 :req-un [:book.config/server_port
          :book.config/db
          :book.config/event])

В нашем случае достаточно взять третий элемент формы, это и будет вектор ключей спеки. Но это наивное решение. У спеки s/keys может быть до четырех типов ключей, и мы должны учесть их все.

Отбросим первый элемент формы. В ее остатке нечетные элементы будут типом ключа, а четные — ключами. Для -un ключей мы должны отбросить их пространство. Все вместе дает нам следующую функцию:

(defn spec->keys
  [spec-keys]
  (let [form (s/form spec-keys)
        params (apply hash-map (rest form))
        {:keys [req opt req-un opt-un]} params
        ->unqualify (comp keyword name)]
    (concat
     req
     opt
     (map ->unqualify opt-un)
     (map ->unqualify req-un))))

Проверим спеку загрузчика:

(spec->keys ::config)
(:server_port :db :event)

Перепишем чтение переменных в словарь:

(defn read-env-vars
  []
  (let [cfg-keys (spec->keys ::config)]
    (-> (System/getenv)
        remap-env
        (select-keys cfg-keys))))

Так вы получите только полезные ключи, то есть те, что описаны в спеке.

Загрузчик среды

Внесем изменения в загрузчик конфигурации так, чтобы он работал с переменными среды. Достаточно заменить первые два шага на read-env-vars:

(defn load-config!
  []
  (-> (read-env-vars)
      (coerce-config)
      (set-config!)))

В свободное время доработайте загрузчик так, чтобы источник данных можно было задать параметром. Например, :source "/path/to/config.json" означает считать файл, а :source :env — переменные среды.

Еще сложнее: как считать оба источника и объединить их? Как сделать объединение ассиметричным, то есть когда левый словарь замещает существующие поля правого, но не дополняет новыми?

Вывод структуры

Выше мы упоминали о проблемах со структурой в переменных среды. Редко бывает так, что конфигурация это плоский словарь. Близкие по семантике параметры группируют во вложенные словари, например отдельно настройки веб-сервера и базы данных. Сгруппированные настройки удобней в поддержке.

Улучшим загрузчик: реализуем вложенные словари для переменных окружения. Договоримся, что двойное подчеркивание означает вложенную структуру. Поместим в файл ENV_NEST следующие переменные:

DB__NAME=book
DB__USER=ivan
DB__PASS=****
HTTP__PORT=8080
HTTP__HOST=api.random.com

Прочитаем его и запустим REPL с новой средой:

set -a
source ENV_NEST
lein repl

Достаточно изменить функции преобразования ключа и обхода окружения. Функция remap-key-nest принимает строковый ключ и возвращает вектор составных частей:

(def ->keywords (partial map keyword))

(defn remap-key-nest
  [^String key]
  (-> key
      str/lower-case
      (str/replace #"_" "-")
      (str/split #"--")
      ->keywords))

(remap-key-nest "DB__PORT")
;; (:db :port)

Новая функция обхода отличается тем, что делает не assoc, а assoc-in, что порождает вложенные словари:

(defn remap-env-nest
  [env]
  (reduce
   (fn [acc [k v]]
     (let [key-path (remap-key-nest k)]
       (assoc-in acc key-path v)))
   {}
   env))

Код ниже вернет сгруппированные параметры, как и было задумано. Для удобства мы явно выбрали только нужные ключи:

(-> (System/getenv)
    remap-env-nest
    (select-keys [:db :http]))

{:db {:user "ivan", :pass "****", :name "book"},
 :http {:port "8080", :host "api.random.com"}}

Дальше действуем как обычно: пишем спеку, выводим типы из строк и так далее.

Подумайте, как задать переменными окружения массив, например, чтобы перечислить состояние объекта. Как разобрать массив на элементы? В каких случаях возможно ложное разбиение?

Простой менеджер конфигурации

К этому моменту читатель может решить, что читать конфигурацию из файла это плохая практика, и следует переносить параметры в окружение. Это не всегда так. На практике мы работаем с гибридными моделями, которые сочетают оба подхода. Приложение читает основные параметры из файла, а пароли и ключи доступа из переменных среды.

Рассмотрим несколько способов как подружить файлы и окружение. Наивное решение не потребует писать код: оно работает на утилитах командной строки. Утилита envsubst из пакета GNU gettext реализует простую шаблонную систему. Чтобы установить gettext, выполните в терминале команду

<manager> install gettext

, где <manager> это менеджер пакетов вашей системы, например brew, apt, yum и другие.

Текст шаблона приходит из stdin, а роль контекста играют переменные среды. Утилита заменяет выражения $VAR_NAME на значения одноименной переменной. Напишем шаблон будущей конфигурации:

cat config.tpl.json
{
    "server_port": $HTTP_PORT,
    "db": {
        "dbtype":   "mysql",
        "dbname":   "$DB_NAME",
        "user":     "$DB_USER",
        "password": "$DB_PASS"
    },
    "event": [
        "$EVENT_START",
        "$EVENT_END"
    ]
}

Поместим переменные среды в отдельный файл:

cat ENV_TPL

DB_NAME=book
DB_USER=ivan
DB_PASS='*(&fd}A53z#$!'
HTTP_PORT=8080
EVENT_START='2019-07-05T12:00:00'
EVENT_END='2019-07-12T23:59:59'

Считаем переменные в память и “отрендерим” шаблон:

source ENV_TPL
cat config.tpl.json | envsubst
{
    "server_port": 8080,
    "db": {
        "dbtype":   "mysql",
        "dbname":   "book",
        "user":     "ivan",
        "password": "*(&fd}A53z#$!"
    },
    "event": [
        "2019-07-05T12:00:00",
        "2019-07-12T23:59:59"
    ]
}

Чтобы записать результат в файл, добавьте в конец выражения оператор вывода:

cat config.tpl.json | envsubst > config.ready.json

Способ с envsubst кажется примитивным на первый взгляд, но неожиданно оказывается полезным на практике. Принцип шаблона снимает вопрос структуры данных. Мы храним переменные как плоский словарь и подставляем их в нужное место. Не приходится думать о правилах вложенности, массивах и так далее.

Часто приложению требуется больше одного файла конфигурации. Например, если приложение работает на порту 8080, то этот же порт должен встречаться в конфигурации Nginx, iptables, и, возможно, crontab. На базе одних и тех же переменных и разных шаблонов мы получим конфигурации для всех сервисов. Останется разложить их по нужным директориям и перезагрузить службы.

Так envsubst становится простым менеджером конфигураций. Чтобы автоматизировать процесс, добавьте простой шелл-скрипт или Make-файл. Решение не дотягивает до промышленного уровня, но подходит для несложных проектов.

Чтение среды из конфигурации

Следующие две техники делают так, что приложение читает параметры одновременно из файла и переменных среды. Разница в том, на каком логическом шаге это происходит.

Предположим, основные параметры указаны в файле, а пароль базы данных приходит из среды. Договоримся с членами команды, что поле :password в файле содержит не настоящий пароль, а имя переменной, например "DB_PASS".

Напишем спеку, которая выводит значение переменной среды по ее имени:

(s/def ::->env
  (s/conformer
   (fn [varname]
     (or (System/getenv varname)
         ::s/invalid))))

Если переменная не задана, ее вывод вернет ошибку. Поскольку это пароль, логично отбросить пустые символы по краям и убедиться, что строка не пустая, прежде чем мы подключимся к базе.

(s/def ::db-password
  (s/and ::->env
         string?
         (s/conformer str/trim)
         not-empty))

Быстрая проверка:

(s/conform ::db-password "DB_PASS")
"*(&fd}A53z#$!"

Теперь чтобы вынести любое поле из файла в переменную, замените его значение на имя переменной. Обновите спеку этого поля: добавьте ::->env в начало цепочки s/and.

Другой способ прочитать переменные из файла — расширить его синтаксис тегами. Теги это короткие символы, которые указывают, что значение за тегом следует прочитать особым образом. Из известных форматов теги поддерживают yaml и edn. Библиотеки для работы с ними предлагают уже готовые теги. В основном это вывод нативных типов платформы из примитивных значений.

Пользователь может задать теги с собственной логикой. Рассмотрим пример с EDN. Тег начинается со знака решетки и захватывает следующее за ним значение. Например, #inst "2019-07-10" означает вывод даты из строки.

Технически тег связан с функцией одного аргумента, которая вычисляет новое значение на базе исходного. Чтобы задать свой тег, в функцию clojure.edn/read-string передают дополнительный словарь тегов. Ключи этого словаря символы тегов, а значения функции.

Добавим тег #env, который вернет значение переменной по ее имени. Имя переменной может быть строкой или символом. Определим функцию:

(defn tag-env
  [varname]
  (cond
    (symbol? varname)
    (System/getenv (name varname))
    (string? varname)
    (System/getenv varname)
    :else
    (throw (new Exception "wrong var type"))))

Прочитаем edn-строку с новым тегом:

(require '[clojure.edn :as edn])

(edn/read-string
 {:readers {'env tag-env}}
 "{:db-password #env DB_PASS}")

;; {:db-password "*(&fd}A53z#$!"}

У вас могут быть и другие теги для вывода специфичных Java-объектов. Чтобы не передавать словарь тегов каждый раз, объявим partial от edn/read-string. Получим функцию, которая принимает только текст:

(def read-config
  (partial edn/read-string
           {:readers {'env tag-env}}))

Чтобы прочитать edn-конфигурацию с особыми тегами, достаточно считать его содержимое в строку и передать в read-config:

(-> "/path/to/config.edn"
    slurp
    read-config)

Формат yaml устроен похожим образом: его стандарт предусматривает теги. Тег начинается с одного или двух восклицательных знаков в зависимости от семантики. Согласно документации, сторонние теги должны начинаться с !!, чтобы их можно было отделить от стандартных, но этому соглашению не всегда следуют.

Библиотека yummy это парсер yaml-файлов, “заряженный” полезными тегами. Среди прочих он предлагает !envvar. Тег возвращает значение переменной среды по ее имени.

Опишем конфигурацию в файле config.yaml:

server_port: 8080
db:
  dbtype:   mysql
  dbname:   book
  user:     !envvar DB_USER
  password: !envvar DB_PASS

Импортируем библиотеку и прочитаем конфигурацию. На месте тегов получим значения переменных:

(require '[yummy.config :as yummy])
(yummy/load-config {:path "config.yaml"})

{:server_port 8080
 :db {:dbtype "mysql"
      :dbname "book"
      :user "ivan"
      :password "*(&fd}A53z#$!"}}

У библиотеки yummy есть и другие преимущества, о которых мы поговорим в следующем разделе.

Концепция тегов в конфигурации имеет преимущества и недостатки. С одной стороны, они делают конфигурацию плотнее. На строку с тегом приходится больше смысла. Мы выносим логику разбора тегов в стороннюю библиотеку и тем самым уменьшаем код проекта.

С другой стороны, теги привязывают конфигурацию к одной платформе и библиотеке. Например, если в вашем .yaml-файле встречается тег !envvar, то библиотека на Python не сможет его прочитать, потому что в ней нет этого тега. С технической стороны это можно исправить: игнорировать незнакомые теги или установить заглушку. Но такой подход и не гарантирует одинаковый результат на разных платформах.

Другой недостаток тегов в том, что с ними конфигурация обрастает побочными эффектами. В терминах функционального программирования она теряет чистоту. Появляется соблазн вынести в теги слишком много логики. Например, импортировать дочерние файлы, форматировать строки, восстанавливать сложные объекты из скаляров. Теги стирают грань между чтением конфигурации и ее пост-обработкой, и когда их слишком много, конфигурацию трудно поддерживать.

Оба приема — разбор спекой или тегами — оппонируют друг другу. На выбор конкретного метода влияют внутреннее устройство фирмы, рабочие процессы и соглашения внутри команды.

Короткий обзор форматов

К этому моменту мы упомянули три формата для хранения конфигурации. Это JSON, EDN и YAML. Перечислим особенности каждого из них. Мы ставим цель не выявить идеальный формат, а подготовить читателя к неочевидным моментам, которые возникнут в работе с этими файлами.

JSON

Формат JSON наверняка известен каждому разработчику. Это способ записать данные в том виде, как это принято в JavaScript. Стандарт определяет числа, строки, логический тип, null и два вида коллекций: массив и словарь. Коллекции могут быть произвольно вложены друг в друга.

Ключевое преимущество JSON в его популярности. Он стал де-факто стандартом для обмена данными между клиентом и сервером. По сравнению с XML его легче читать и поддерживать. JSON поддерживают все современные редакторы, языки и платформы. Это нативный способ хранить данные в мире JavaScript.

К сожалению, формат не предусматривает комментарии. На первый взгляд это кажется мелочью. На практике комментарии важны, особенно если в фирме принято разделение труда. Если разработчик добавил новый параметр, хорошим тоном будет написать комментарий о том, что он делает и какие значения принимает. Ради интереса посмотрите файлы конфигурации Redis, PostgreSQL или Nginx — на 90 процентов они состоят из комментариев.

Разработчики придумали десятки уловок, чтобы обойти это ограничение. Например, добавлять над каждым полем одноименное с комментарием:

{
    "server_port": "A port which the web-server is bound to.",
    "server_port": 8080
}

Расчет сделан на то, что библиотеки не проверяют ключ на вхождение в словарь, и второй server_port заменит первый. Заметим, что стандарт JSON не регулирует такой сценарий; он остается на откуп разработчиков. Логика может быть иной, например бросить исключение или не заменять ключ, который уже в словаре.

Другие способы включают пустую строку в качестве ключа комментария или специальные ключи, обрамленные подчеркиваниями. Эти методы хрупки и не всегда проходят проверку линтерами. Отдельные программисты добавляют поддержку комментариев на уровне продукта. Так, популярный редактор Sublime Text хранит настройки в .json-файлах с поддержкой JavaScript-комментариев. Но в общем случае решения этой проблемы нет.

На момент появления JSON выгодно отличался от многословного XML. JSON-данные выглядят чище и удобнее для человека, чем дерево XML-тегов. Но современные форматы выражают данные еще чище. Например, в YAML любую структуру можно записать без единой скобки, пользуясь только отступами.

Синтаксис JSON “шумит”: он требует кавычек, двоеточий и запятых в случаях, где другие форматы лояльны. Например, запятая на конце последнего элемента массива или объекта считается ошибкой. Ключи не могут быть числами или токенами.

Сравните одну и ту же структуру данных в YAML и JSON:

                              {
server_port: 8080                  "server_port": 8080,
db:                                "db": {
  dbtype:   mysql                      "dbtype":   "mysql",
  dbname:   book                       "dbname":   "book",
  user:     user                       "user":     "ivan",
  password: '****'                     "password": "****"
event:                             },
  - 2019-07-05T12:00:00            "event": [
  - 2019-07-12T23:59:59                "2019-07-05T12:00:00",
                                       "2019-07-12T23:59:59"
                                   ]
                               }

Стандарт JSON не поддерживает теги, о которых мы говорили выше.

YAML

Язык разметки YAML, как и JSON, различает базовые типы: скаляры, null и коллекции. YAML делает упор на краткости записи. Это выражается в том, что вложенность структур определяют отступы, а не скобки. Запятые не обязательны там, где они выводятся логически. Например, массив чисел в одну строку выглядит как в JSON:

numbers: [1, 2, 3]

Но в случае с многострочной записью нужда в скобках и запятых отпадает:

numbers:
  - 1
  - 2
  - 3

YAML поддерживает комментарии в стиле Python. Благодаря этому формат популярен у DevOps-инженеров. Программы docker-compose и Kubernetes работают на yaml-файлах.

В отличии от JSON, YAML предлагает синтаксис для многострочного текста. Такой текст проще читать и копировать, чем одну строку с вкраплениями \n.

description: |
  To resolve the error, please do the following:

  - Press Control + Alt + Delete;
  - Turn off your computer;
  - Walk for a while.

  Then try again.

Язык официально поддерживает теги.

Недостатки YAML вытекают из его преимуществ. Вложенность отступами кажется удачным решением до тех пор, пока файл не станет большим. Рано или поздно ваш глаз будет прыгать на большие расстояния, чтобы сверять уровни структур. Возрастает риск неприятного события, когда ключ “уезжает” в другую часть словаря из-за лишнего отступа. С точки зрения синтаксиса ошибки нет, поэтому ее трудно найти.

Иногда отказ от кавычек приводит к неверным типам или структуре. Предположим, в поле phrases перечислены фразы, которые увидит пользователь:

phrases:
  - Welcome aboard!
  - See you soon!
  - Warning: wrong email address.

Из-за двоеточия в последней фразе парсер решит, что это вложенный словарь. При попытке считать такой файл получится неверная структура:

{:phrases
 ["Welcome aboard!"
  "See you soon!"
  {:Warning "wrong email address."}]}

Другие примеры: версия продукта 3.3 это число, но 3.3.1 — строка. Телефон +79625241745 это число, потому что знак плюса считается унарным оператором по аналогии с минусом. Лидирующие нули означают восьмеричную запись: если не добавить кавычки к идентификатору 000042, вы получите число 34.

Это не значит, что YAML неудачный формат. Все перечисленные случаи описаны в стандарте и имеют логическое объяснение. Но читатель должен помнить, что отказ от кавычек потенциально ведет к ошибке конфигурации.

EDN

Формат EDN занимает особое место в этом обзоре. Он предназначен для хранения данных Clojure и поэтому играет такую же роль в экосистеме языка, как JSON в мире JavaScript. Это естественный, родной способ связать данные с файлом в Clojure.

Синтаксис EDN почти полностью соответствует компилятору Clojure. Поэтому формат охватывает больше типов, чем JSON и YAML. Например, из скалярных типов доступны символы и кейворды (типы clojure.lang.Symbol и Keyword). Кроме вектора и словаря формат поддерживает списки и множества.

Теги начинаются с символа решетки. По умолчанию стандарт предлагает два тега: #inst и #uuid. Первый читает строку в дату, а второй в экземпляр UUID. Такие идентификаторы используют в распределенных системах, например БД Cassandra. Выше мы показывали, как зарегистрировать свой тег. Достаточно связать его с функцией одного аргумента при чтении EDN-строки.

Собирательный пример с разными коллекциями и тегами:

{:task-state #{:pending :in-progress :done}
 :account-ids [1001 1002 1003]
 :server {:host "127.0.0.1" :port 8080}
 :date-range [#inst "2019-07-01" #inst "2019-07-31"]
 :cassandra-id #uuid "26577362-902e-49e3-83fb-9106be7f60e1"}

EDN-данные не отличаются от записи в .clj-файлах. Если скопировать содержимое файла в REPL или модуль Clojure, компилятор просто выполнит их. Правило работает и наоборот: вывод REPL можно скопировать в файл для дальнейшей работы.

Функция pr-str возвращает текстовую версию данных. Поэтому сброс в EDN сводится к простым шагам: “напечатать” данные в строку и записать ее в файл. Пример ниже записывает в файл dataset.edn результат функции get-huge-dataset:

(-> (get-huge-dataset)
    pr-str
    (as-> dump
        (spit "dataset.edn" dump)))

EDN поддерживает не только обычные комментарии. Комбинация #_ игнорирует следующий за ним элемент. Под элементом понимается что угодно: скаляр, коллекция. Например, если нужно проигнорировать словарь, который занимает несколько строк, достаточно поставить перед ним #_, и парсер пропустит его.

Это позволяет “отключить” целые части конфигурации. В следующем примере мы игнорируем пользователя Ioann. Если поставить обычный комментарий, он заденет закрывающие скобки, и выражение не будет окончено.

{:users [{:id 1 :name "Ivan"}
         {:id 2 :name "Juan"}
         #_{:id 3 :name "Ioann"}]}

EDN становится особо удачным выбором, когда и бекенд, и фронтенд приложения построены на одном стеке Clojure + ClojureScript.

Отметим, что EDN привязан к экосистеме Clojure и потому не известен разработчикам на других языках. Современные редакторы, как правило, не подсвечивают его синтаксис без сторонних плагинов. Это может доставить проблем вашим DevOps-коллегами, которые работали только с JSON и YAML.

Если конфигурацию обрабатывают сторонние Python- или Ruby-скрипты, придется ставить библиотеку для работы с этим форматом. Поэтому выбор в пользу EDN делают фирмы, где Clojure преобладает над другими технологиями.

Промышленные решения

Хотя Clojure-разработчику важно понимать, как работает конфигурация, мы не ожидаем, что в каждом проекте ее пишут с нуля. Логично предположить, что идеи, изложенные в этой главе, уже выражены в сторонних решениях. В заключительном разделе мы рассмотрим, какие библиотеки предлагает Clojure-сообщество для конфигурации проектов.

Мы остановили внимание на проектах cprop, aero и yummy. Библиотеки отличаются в идеологии и архитектуре. Мы специально подобрали их так, чтобы увидеть проблему с разных сторон.

Cprop

Библиотека cprop устроена по принципу “данные отовсюду”. В отличии от нашего загрузчика у cprop больше источников данных. Библиотека читает не только файл и переменные среды, но и ресурсы, property-файлы и пользовательские словари.

Cprop приводит данные из разных источников к одному виду. Можно сказать, что это обертка над различными “бекендами” данных. При помощи cprop можно задать конфигурацию разным способом, но получить одинаковый результат.

В библиотеке задан порядок обхода источников и их приоритет. Параметры из одного источника могут заменять другие. Например, переменная среды имеет более высокий приоритет, чем одноименная из файла. Для частных случаев в cprop легко задать свой порядок загрузки.

На верхнем уровне доступна функция load-config. Вызванная без параметров, функция запускает стандартный загрузчик. По умолчанию cprop ищет два источника данных: Java-ресурс и property-файл.

Под ресурсом понимают особый файл, который становится частью Java-проекта. На этапе разработки файлы ресурсов хранят в директории resources. Cprop ожидает ресурс с именем config.edn. Это первичный источник данных.

Если системное свойство conf не пустое, библиотека полагает, что это путь к property-файлу и загружает из него словарь. Системные свойства это особая группа переменных в рамках Java-машины. Можно сказать, что свойства это аналог среды окружения для JVM.

При загрузке JVM получает набор свойств по умолчанию, например, тип и версию операционной системы, параметры файловой системы, опции JVM и другие. Дополнительные свойства передают параметром -D при запуске java-программы. Пример ниже запускает скомпилированный jar-файл со свойством conf:

java -Dconf="/path/to/config.properties" -jar project.jar

Файлы .properties это пары поле=значение по одной на строку. Поля носят доменную структуру: это лексемы, разделенные точкой. Лексемы убывают по старшинству:

db.type=mysql
db.host=127.0.0.1
db.pool.connections=8

Библиотека расценивает точки как вложенные словари. Загрузка такого файла вернет структуру:

{:db {:type "mysql"
      :host "127.0.0.1"
      :pool {:connections 8}}}

Получив конфигурацию из ресурса или property-файла, cprop ищет переопределения в переменных среды. Для них работают те же правила, что и в нашем загрузчике. Например, если задана переменная DB__POOL__CONNECTIONS=16, она заменит значение 8 в примере выше. При этом cprop игнорирует переменные, которые не входят в ключи конфигурации, и тем самым не загрязняет ее.

Стандартные пути поиска можно задать ключами:

(load-config
 :resource "private/config.edn"
 :file "/path/custom/config.edn")

Для работы с ресурсами cprop предлагает функции из модуля cprop.source. Например, from-env Вернет словарь всех переменных среды, from-props-file загрузит properties-файл и так далее. Разработчик вправе построить их них такую комбинацию, которая нужна проекту.

Ключ :merge объединяет конфигурацию с произвольными источниками. Убер-пример из документации:

(load-config
 :resource "path/within/classpath/to.edn"
 :file "/path/to/some.edn"
 :merge [{:datomic {:url "foo.bar"}}
         (from-file "/path/to/another.edn")
         (from-resource "path/within/classpath/to-another.edn")
         (from-props-file "/path/to/some.properties")
         (from-system-props)
         (from-env)])

В вектор :merge можно добавить любое выражение, которое вернет словарь. Но в общем случае (load-config) без параметров будет достаточным решением.

Чтобы отследить загрузку такой сложной конфигурации, установите переменную среды DEBUG:

export DEBUG=y

С ней cprop выводит в лог служебную информацию. Это список источников, порядок их загрузки, переопределение переменных и так далее.

Cprop только загружает данные из разных источников, но не проверяет их. В библиотеке не реализована валидация спекой, как это сделано в нашем загрузчике. Этот шаг остается на усмотрение разработчика.

В cprop работает своя система вывода типов. Если строка состоит только из цифр, ее приводят к числу. Значения с запятыми становятся списками. В целом этих возможностей недостаточно для полного контроля за типами. Вам по-прежнему понадобится spec и conform для вывода типов и сообщений об ошибке.

Aero

Проект aero предлагает другой подход. В отличии от cprop, библиотека работает только с одним источником данных: файлами *.edn. Aero несет на борту теги, с которыми EDN становится похож на мини-язык программирования. В нем появляются базовые операторы ветвления, импорта, форматирования. По-другому это можно назвать “EDN на стероидах”.

Функция read-config читает файл или ресурс EDN:

(require '[aero.core :refer (read-config)])

(read-config "config.edn")
(read-config (clojure.java.io/resource "config.edn"))

Центральная точка библиотеки это теги, которые срабатывают при чтении. Разберем основные из них. Тег #env заменяет имя переменной среды на ее значение. Нам уже приходилось писать его:

{:db {:passwod #env DB_PASS}}

Тег #envf форматирует строку переменными среды. Например, параметры базы данных заданы отдельными полями, но вы предпочитаете JDBC URI – длинную строку, похожую на веб-адрес. Чтобы не дублировать данные, адрес вычисляют на базе исходных полей:

{:db-uri #envf ["jdbc:postgresql://%s/%s?user=%s"
                DB_HOST DB_NAME DB_USER]}

Тег #or похож на одноименный оператор Clojure и в основном нужен для значений по умолчанию. Так, в нашем env-файле не задан порт базы данных. Чтобы избежать неочевидности, укажем стандартный порт:

{:db {:port #or [#env DB_PORT 5432]}}

Оператор #profile предлагает интересный способ взять значение в зависимости от профиля. Значение за тегом обязательно словарь. Ключи словаря это профили, а значения – то, что получим в результате его разрешения. Профиль задают в параметрах read-config.

Пример ниже показывает, как определить имя базы данных по профилю. Без профиля мы получим "book", но для :test имя станет "book_TEST":

{:db {:name #profile {:default "book"
                      :dev     "book_DEV"
                      :test    "book_TEST"}}}

(read-config "aero.test.edn" {:profile :test})
{:db {:name "book_TEST"}}

Тег #include внедряет в конфигурацию содержимое другого edn-файла. При этом дочерний файл тоже содержит aero-теги, и библиотека выполняет их рекурсивно. К импорту прибегают, когда конфигурация отдельных компонентов становится большой.

{:queue #include "message-queue.edn"}

Тег #ref описывает ссылку на любое место конфигурации. Это вектор ключей, который обычно передают в get-in. Ссылка позволяет избежать дублирования данных. Например, сторонний компонент нуждается в пользователе, под которым мы подключаемся к базе данных:

;; config.edn
{:db {:user #env DB_USER}
 :worker {:user #ref [:db :user]}}

;; result
{:db {:user "ivan"}, :worker {:user "ivan"}}

Эти и другие теги можно комбинировать различными способами и добавлять собственные.

Фактически aero предлагает несложный язык описания конфигураций. Проект выглядит многообещающе. Aero подкупает интересными идеями и красотой реализации. Но в момент, когда вам захочется переехать с негибкого JSON на aero, подумайте об обратной стороне медали.

Конфигурацию не случайно отделяют от кода. Если бы не потребность индустрии, мы бы хранили параметры в исходных файлах. Но все же мы не делаем так, – наоборот, хорошие практики в один голос советуют отделять параметры от кода. В том числе потому, что в отличие от кода конфигурация декларативна.

Негибкие JSON- и properties-файлы обладают важным свойством: они декларативны. Когда вы открыли файл в редакторе или “катнули” его в консоль, то сразу видите данные. Отдельные их части могут дублироваться, синтаксис не настолько удобен для чтения. Но данные выражаются сами в себя, и ошибки быть не может. Вы просто видите их.

Наоборот, файл с нестандартными тегами труден в поддержке. Это не конфигурация, а код. Файл становится трудно читать, его нужно выполнить. При чтении файла у вас в голове запускается мини-интерпретатор, который не гарантирует точный результат.

Получается своего рода круг. Мы вынесли параметры в статическую конфигурацию, добавили теги и вернулись к коду. Такой подход тоже имеет право на жизнь, но к нему следует прийти осознанно.

Yummy

Библиотека yummy замыкает наш обзор. Yummy отличается от аналогов двумя свойствами. Во-первых, она работает с файлами YAML для чтения конфигурации (отсюда и название). Во-вторых, процесс ее загрузки максимально похож на тот, что мы рассмотрели в начале главы.

Вспомним, что полноценный загрузчик не только читает параметры. Цикл конфигурации включает проверку данных и вывод ошибки. Сообщение об ошибке внятно объясняет, в чем причина. С помощью необязательных параметров мы должны иметь возможность “зацепиться” за основные события. Yummy предлагает почти все из перечисленного.

Библиотека читает YAML-разметку из файла. Путь к файлу либо передан в параметрах, либо библиотека ищет его по определенному имени в переменных среды или свойствах JVM.

Вариант, когда путь задан явно:

(require '[yummy.config :refer [load-config]])

(load-config {:path "config.yaml"})

Во втором примере вместо пути задали имя программы. Yummy ищет путь к файлу в переменной среды <name>_CONFIGURATION или свойстве <name>.configuration:

export BOOK_CONFIGURATION=config.yaml
(load-config {:program-name :book})

Библиотека расширяет YAML несколькими тегами. Это знакомый вам !envvar для переменных среды:

db:
  password: !envvar DB_PASS

Тег !keyword полезен в случаях, когда вместо строки ожидают кейворд:

states:
  - !keyword task/pending
  - !keyword task/in-progress
  - !keyword task/done

Результат:

{:states [:task/pending :task/in-progress :task/done]}

Тег !uuid аналогичен #uuid для EDN. Он возвращает объект java.util.UUID из строки:

system-user: !uuid cb7aa305-997c-4d53-a61a-38e0d8628dbb

Тег !slurp читает текст из стороннего файла. Это полезно для сертификатов шифрования. Их содержимое это длинная строка, которая плохо укладываются в общую конфигурацию:

tls:
  auth: !slurp "certs/ca.pem"
  cert: !slurp "certs/cert.pem"
  pkey: !slurp "certs/key.pk8"

Если в директории certs оказались все нужные сертификаты, в ключах :auth, :cert и :pkey будет содержимое этих файлов.

Чтобы проверить данные, в параметры load-config передают ключ спеки. Когда ключ указан, yummy выполняет s/assert для параметров из yaml-файла. Если данные не прошли проверку, всплывает исключение. Yummy использует библиотеку expound, чтобы улучшить отчет спеки об ошибке.

(load-config {:program-name :book
              :spec ::config})

Словарь опцией yummy принимает параметр :die-fn. Это функция, которая будет вызвана, если любая из стадий завершится с ошибкой. Функция принимает два аргумента: объект исключения и текстовую метку, которая подсказывает, на какой стадии произошла ошибка.

Если :die-fn не задан, yummy вызывает обработчик по умолчанию. Обработчик выводит текст в stderr и завершает программу со кодом 1. Вспомним, что это неудобно на этапе разработки: мы не хотим обрывать REPL из-за ошибки в конфигурации. В интерактивном сеансе die-fn подавляет исключение и только выводит текст:

(load-config
 {:program-name :book
  :spec ::config
  :die-fn (fn [e msg]
            (binding [*out* *err*]
              (println msg)
              (println (ex-message e))))})

Но в боевом режиме мы запишем исключение в лог и завершим программу.

(load-config
 {:program-name :book
  :spec ::config
  :die-fn (fn [e msg]
            (log/errorf e "Config error")
            (System/exit 1))})

Из недостатков yummy отметим, что для работы со спекой используется s/assert. Функция не выводит новые значения, как это делает s/conform, а только выбрасывает исключение, если проверка не прошла. Поэтому эффект conform-спек не окажет действия на данные, которые вы получите.

С другой стороны, это сделано нарочно. Библиотеку писали так, что вывод типов срабатывает на этапе тегов, а спека только проверяет данные. С таким подходом все преобразования видны на уровне yaml-файла.

Заключение

Перечислим основные тезисы из разделов этой главы.

Конфигурация нужна для того, чтобы помочь приложению пройти стадии производства. На каждой стадии приложение запускают с особыми настройками, чтобы выполнить должные проверки. Без поддержки конфигурации это просто невозможно.

Загрузка конфигурации включает в себя чтение данных, вывод типов и проверку значений. В случае ошибки программа выводит понятное сообщение и завершается с аварийным кодом. Нет смысла продолжать работу с неверными параметрами.

Источником конфигурации может быть файл, Java-ресурс, переменные окружения. Допустимы гибридные схемы, когда основные данные приходят из файла, а секретные поля из окружения.

Переменные среды живут в памяти операционной системы. Если переменных много, их помещают в ENV-файл. Но приложение не читает этот файл; его загружает в память система, которая отвечает за работу приложения на сервере. С точки зрения приложения неизвестно, откуда пришли переменные.

Окружение это плоский словарь. Переменные хранят только текст, ключи никак не структурированы. В разных системах прибегают к особым соглашениям о том, как имя переменной ложится на логическую структуру. Это могут быть точки, двойные подчеркивания или что-то другие.

Форматы файлов различаются синтаксисом и типами данных. Форматы общего назначения покрывают базовые типы: строки, числа, словари и списки. Они не настолько гибки, но гарантируют поддержку в разных языках. Наоборот, форматы, созданные для конкретной платформы, обеспечивают тесную интеграцию с ней, но не популярны в других языках.

Стандарты некоторых форматов официально поддерживают систему тегов. Теги опасны тем, что при их большом количестве конфигурация превращается в код. Но эта возможность полезна в качестве запасного варианта.

Экосистема Clojure предлагает несколько интересных библиотек для конфигурации приложения. Они различаются замыслом и архитектурой, и наверняка каждый разработчик найдет то, что ему по душе.

Нет однозначного ответа на вопрос о том, какой формат, библиотека или рабочий процесс лучше. В каждой фирме действуют свои соглашения и порядки. Разработчик должен быть готов к тому, в новой команде конфигурацию готовят по-другому.