Когда к нам пришел докер и — как тот муж из анекдота — перее^W научил нас отказоустойчивости на свой манер, я написал бесчисленное количество костылей, чтобы действительно отказоустойчивый (а главное, долгоживущий) код продолжал нормально работать в условиях, где сброс горячего кэша из-за внезапного перезапуска контейнера, вызванного близостью Андромеды к Меркурию, — норма.
Потом какому-то гению из соседнего отдела пришла в голову блистательная мысль использовать consul в качестве единого конфигурационного хранилища, некоторые ошметки локальных конфигов по-прежнему валялись в редисе, каждый микросервис выдумывал свою систему легкой раскатки и предпочитал автономно управлять конфигурацией из локальных переменных среды, и в этом зоопарке, разумеется, начали возникать конфликты на почве видово́й борьбы за выживание (ласково именуемой в народе «кто первый встал — того и тапки»).
С этим надо было что-то делать, и я написал библиотеку на обоих используемых тогда в компании языках (руби и эликсире), позволяющую поддерживать общую конфигурацию приложения из нескольких источников, обновлять её в режиме реального времени через изменение этих самых источников (поменял значение в консуле/редисе/локальном джейсоне — и оно автоматически обновилось в конфиге, а уведомления разослались всем заинтересованным).
Я уже и забыл про это, потому что с тех пор практически перестал заниматься пользовательскими приложениями, сконцентрировавшись на библиотеках общего назначения, но тут промелькнул текст про «лучший конфигуратор на го», — в общем, я решил сдуть пыль со старого кода и рассказать, как готовить конфиг правильно.
Во-первых, вам всегда нужна полная прозрачность: общий конфиг должен быть доступен простейшим вызовом одной функции. Во-вторых, ограничений на источники, откуда могут поступать конфигурационные данные, быть не должно: один наш сервис хранил конфиг в СУБД, например, и если я хотел убедить их использовать моё решение, заходить с козырей типа «сначала вам надо перетащить конфиг в стильный и быстрый кликхаус» — не казалось приемлемым вариантом. И, в-третьих, обновление конфигурации при изменении источников правды — должно быть автоматизировано. Безо всяких дополнительных ухищрений.
В общем, у меня нарисовалась примерно такая архитектура:

Бежевое — моя библиотека, голубенькое — приложение, которое может подписаться на обновления каждого бэкенда в отдельности, либо их «смеси» через блендер, который сам по себе подписан на каждый бэкенд (адаптер стороннего источника) и представляет из себя просто воронку для удобства.
Еще, конечно, каждый компонент умеет оповещать окружающих об ошибках, слать метрики, и так далее, но это и так очевидно.
Blender
Это может показаться странным, но начал я с того, что выбросил сущность «Blender». (Вру, конечно, начал я с того, что придумал для библиотеки название Kungfuig (pronounced: [ˌkʌŋˈfig])). Но сразу после этого я решил: никаких дополнительных ненужных сущностей. Blender — сам по себе — тоже ведь не что иное, как Backend. Итак, осталось реализовать Backend и обвязку. И тут я принял еще одно стратегическое решение: я реализую только скелет и покажу на примерах для пользователей, как этим пользоваться. Поэтому в поставке нет никаких адаптеров для редиса или ямла. Их реализация настолько проста, что просто не имеет смысла втаскивать в саму библиотеку дополнительные зависимости.
Backend
Вот как выглядит интерфейс бэкенда:
@doc "The key this particular config would be stored under, defaults to module name"
@callback key :: atom()
@doc "The implementation of the call to remote that retrieves the data"
@callback get([Kungfuig.option()]) :: {:ok, any()} | {:error, any()}
@doc "The transformer that converts the retrieved data to internal representation"
@callback transform(any()) :: {:ok, any()} | {:error, any()}
@doc "The implementation of error reporting"
@callback report(any()) :: :ok
@optional_callbacks key: 0, transform: 1, report: 1
По сути, я требую только имплементацию функции get/1
, которая должна сходить в стороннее хранилище и стянуть оттуда данные. Если есть реализация transform/1
— библиотека её вызовет перед обновлением на вновь полученных данных. Если конфигурация некорретная, валидатор её завернёт. Кроме валидатора, можно установить частоту обновления.
Вот простенький пример для СУБД:
defmodule MyApp.Kungfuig.MySQL do
@moduledoc false
use Kungfuig.Backend, interval: 300_000 # 5 minutes
@impl Kungfuig.Backend
def get(_meta) do
with {:ok, host} <- System.fetch_env("MYSQL_HOST"),
{:ok, db} <- System.fetch_env("MYSQL_DB"),
{:ok, user} <- System.fetch_env("MYSQL_USER"),
{:ok, pass} <- System.fetch_env("MYSQL_PASS"),
{:ok, pid} when is_pid(pid) <-
MyXQL.start_link(hostname: host, database: db, username: user, password: pass),
result <- MyXQL.query!(pid, "SELECT * FROM some_table") do
GenServer.stop(pid)
result =
result.rows
|> Flow.from_enumerable()
|> Flow.map(fn [_, field1, field2, _, _] -> {field1, field2} end)
|> Flow.partition(key: &elem(&1, 0))
|> Flow.reduce(fn -> %{} end, fn {field1, field2}, acc ->
Map.update(
acc,
String.to_existing_atom(field1),
[field2],
&[field2 | &1]
)
end)
Logger.info("Loaded #{Enum.count(result)} values from " <> host)
{:ok, result}
else
:error ->
Logger.warn("Skipped reconfig, one of MYSQL_{HOST,DB,USER,PASS} is missing")
:ok
error ->
Logger.error("Reconfiguring failed. Error: " <> inspect(error))
end
end
end
Для редиса все будет короче, для джейсона — вообще одна строка. Пример с базой интересен тем, что этот конкретный конфиг очень длинный и очень «живой» — его меняет основное приложение довольно часто, поэтому клиенты просто читают из RO-реплики.
Вторую часть кода можно, теоретически, было вынести в колбэк transform/1
, но у нас иммутабельный язык и я не посчитал изящество кода в ущерб эффективности — достаточным аргументом в пользу того, чтобы гонять туда-сюда мегабайты.
Обвязка
Клиент, которому требуются уведомления об изменении конфигурации, должен быть упомянут как callback:
при старте основного процесса Kungfuig
. Это может быть:
@type callback ::
module()
| pid()
| {module(), atom()}
| (config() -> :ok)
| {GenServer.name() | pid(), {:call | :cast | :info, atom()}}
Или, в переводе на русский:
модуль, реализующий behaviour Kungfuig.Callback,
идентификатор процесса, который получит сообщение
{:kungfuig_update, state},
именованная функция арности
1
из указанного модуля,анонимная функция арности
1
имя
GenServer
-процесса и тип вызова (синхронный, асинхронный, без гарантий)
Теперь указанный(-ые) хендлеры будут получать нотификации каждый раз, когда конфигурация изменяется, а что уж с новыми данными делать — решать каждому. При старте, разумеется, всё это тоже пройдет именно по такому пути, то есть «применение» конфигурации — повторяемый сценарий, и не зависит от того, было это сделано при старте, или в процессе работы приложения.
Вот так, на мой взгляд, имеет смысл работать с конфигами.
Удачного конфигурирования!