На утро было назначено совещание, куда позвали аж две команды. Нужно было придумать, как мы будем внедрять новый микросервис, который сосёт данные из внешнего мира, обрабатывает их, и делится результатом с монолитом. Я, понятно, отвечал за микросервис — и вопросов с моей стороны практически не было: задача ясна, вот протокол внешнего источника, вот правила перелопачивания данных, вот наш монолит — хоть сейчас открывай редактор кода и фигачь.
Другая команда подошла к вопросу посерьезнее, и разгорелся жаркий спор, как мы будем передавать прожаренные данные в монолит. HTTP грозило отыквиться из-за больших объемов и плотностей, RabbitMQ надо было привинчивать на уровне инфраструктуры (а это, как вы понимаете, занимает три года), какой-то хипстер из молодых предложил кафку, а бородатый чувак в свитере пробубнил что-то бессвязное про event triggers прямо в постгресе.
Спустя минуты три жаркой дискуссии, я сказал: «C’mon guys. It’s irrelevant now. Just let me know when you have it settled». Лид команды монолита сдержанно хихикнул и спросил: «В смысле „всё равно“?». Я пожал плечами и ответил: «Я прикручу выбранный вами способ за два часа, всё в порядке, пошли работать».
Постановка задачи
Итак, я пообещал быть агностиком в вопросе формата выхлопа. Для этого мне придется определить интерфейс типа publish/2
и реализовать его для начала для HTTP и RabbitMQ. После этого я смогу спокойно реализовывать свой микросервис (там тоже немало работы) — вызывая эту самую функцию publish/2
на интерфейсе и передавая конкретную имплементацию в виде depency injection.
Тут меня посетила мысль, что подобная функциональность может потребоваться соседям, поэтому код, занимающийся публикацией, — надо бы оформить в виде библиотеки общего назначения. Так родилась библиотека rambla, обязанная своим названием стандарту de facto для обработки входящих сообщений broadway и улице в Барселоне.
Требования к библиотеке
Что я понимал с самого начала? — Библиотека должна обеспечивать удобный API для публикации, причем потенциально в несколько разных приёмников (при переезде с HTTP на RabbitMQ мы бы точно захотели некоторое время публиковаться в монолитный продакшн и тестовый брокер). Да и вообще, яжпрограммист, где один — там и ∞. Приёмники могут меняться часто и на лету — а значит, нам нужен внятный конфиг, который и определит все, так сказать, routes.
Поэтому я в качестве аперитива написал типовой пример конфигурации, как я хотел бы его видеть в качестве пользователя этой библиотеки. Я никогда не начинаю с реализации — мне представляется разумным начинать с вызовов пока несуществующего API, чтобы удостовериться, что пользоваться библиотекой будет удобно. В этом подходе различим запашок TDD, но в реальности он немного уже и немного шире: я не пишу красные юнит-тесты, чтобы они потом позеленели, я пишу «клиентский код», добиваюсь того, чтобы он выглядел изящно (насколько могу, конечно), и только потом оформляю результат как тест. В общем, вот первый вариант конфига.
config :rambla,
rabbit: [
connection: [
host: System.get_env("RABBIT_HOST", "127.0.0.1"),
port: String.to_integer(System.get_env("RABBIT_PORT", "5672")),
exchange: "rambla"
]
],
http: [
connection: [
scheme: "https",
host: "httpbin.org",
path: "/post"
]
]
Теперь я могу вызывать пока несуществующую функцию publish/2
как-то так:
Rambla.publish([:rabbit, :http], message)
Вроде, нормально?
Нет, не нормально
Что сразу бросается в глаза в конфиге выше? Да тут вагон проблем.
один приёмник — одна конфигурация
хрен распараллелишь вызовы из разных мест (какой-нибудь POST с мегабайтом данных заткнет это бутылочное горлышко чуть ли не на секунды)
на лету конфигурацию будет очень сложно (невозможно) поменять
передавать в функцию
publish/2
надо данные сразу в том формате, который понимает приёмникпользователи моего кода не смогут ничего толком протестировать
Ну и еще некоторые, по мелочи. Итак, нам нужно несколько конфигураций на приёмник. Здесь не нужно изобретать велосипед, можно просто посмотреть, как это делают библиотеки для RabbitMQ. Ага, есть понятие «channel», которое цепляется к «connection». У библиотек HTTP (и редиса, и кликхауса, и телеметрии, и логгера, и S3
и всех остальных) такой абстракции нет, но это по недомыслию. Причиню им добро своими руками.
config :rambla,
rabbit: [
connection: […],
channels: [chan_1: [connection: :rabbit]]
],
http: [
connection: […],
channels: [chan_2: [connection: :http]]
]
Как я теперь буду выплевывать свои сообщения? — В каналы, очевидно.
Rambla.publish([:chan_1, :chan_2], message)
Но стоп. Тут попахивает дублированием кода. Словно как доставать данные из разных веток джейсона последовательными выборками, без использования линз. Никто не запрещает мне переиспользовать имена каналов для разных приёмников, чтобы объединять вызовы.
config :rambla,
rabbit: [
connection: […],
channels: [
chan_1: [connection: :rabbit],
chan_2: [connection: :rabbit]
]
],
http: [
connection: […],
channels: [chan_2: [connection: :http]]
]
Voilà — теперь вызов ниже опубликует сообщение сразу в два приёмника.
Rambla.publish([:chan_2], message)
Осталось нарисовать саму сову добавить декоратор как внешнюю зависимость для каждого соединения, чтобы можно было в HTTP и в Redis слать данные в разных форматах, несколько колбэков (я всегда позволяю прицепить колбэк ко всему, что выполняется под капотом — так и отлаживать проще, и пользователи потом расцелуют вас в десны), да возможность генерации хендлеров для разных типов соединений так, чтобы они могли быть использованы библиотекой как плагины. Это всё уже рутина, когда понятно, куда мы движемся. Код доступен, если хотите поковыряться — добро пожаловать.
Там что-то было про тестирование
Когда я написал первый хендлер, я подумал: так, этот-то код я протестирую, и пользователи могут быть уверены, что publish(:rabbit, message)
— либо засунет сообщение в брокер, либо вернет ошибку. Но что будут делать пользователи моего кода? Не заставлять же их, в самом деле, разворачивать все возможные приёмники на своём ноуте?
Нет, конечно. У нас же есть каналы, и клиентский код просто публикует сообщение в канал. А значит что? — Моки. И стабы.
Клиентский код теперь может переопределить конфиг для тестового окружения
config :rambla,
stub: [
connection: […],
channels: [chan_1: [connection: :stub]]
],
mock: [
connection: […],
channels: [chan_2: [connection: :mock]]
]
И (при условии, что клиент мне доверяет в тестировании собственно взаимодействия с приёмниками на нижнем уровне) — просто запускать свой код и проверять, что соответствующий publish/2
был вызван в нужное время в нужном месте с нужными аргументами.
Неужели так проще?
TL;DR: Да.
Во-первых, такой подход позволяет не зависеть ни от каких соседних команд. Решат они завтра обмениваться сообщениями через бакет в S3
? — Вот 100 строк кода имплементации хендлера, половина из которых — комментарии.
Во-вторых, мы вправе назвать себя королями бессвязности (decoupling). Нужно прикрутить телеметрию? — Вопрос еще сотни строк кода для хендлера (его нет в поставке, потому что telemetría для этого удобнее) и изменения конфига, хоть на лету.
В-третьих, наш код уже завтра будет переиспользован командой соседнего микросервиса.
И, наконец, мы причинили добро всему миру: библиотека открыта, а значит — бери и пользуйся.
Удачного паблишиттинга.