Всем привет, я — Дмитрий Пестеха, ведущий разработчик С++ команды POS-систем в «Магните». Расскажу, как мы пилили монолитное приложение Касса на модули и отлаживали их взаимодействие на RPC-JSON. Спойлер: в процессе в мире появился новый самописный язык интерфейсов - IDL. Касса — это не вся POS-система «Магнита», но ее значительная часть: приложение для кассира. 15 лет назад Касса представляла из себя монолит: внутри интерфейса — таблица со списком товаров, ценами и скидками. Но со временем у нее появились новые функции: интеграция с весами, пин-падами, фискальные регистраторы и т.д.  Мы разделили приложение на модули, чтобы в случае “падения” одного из них по segfault вся Касса продолжала работать, хоть и с ограниченной функциональностью, предварительно сохранив при этом текущие данные для кассира. Теперь действия кассира в приложении отправляют запросы в ядро системы, которое в свою очередь получает информацию из множества модулей. POS-систему мы разработали на C++ на Linux CentOS 5+ с использованием стандартной библиотеки, Qt и Boost, а собрали при помощи GCC и CMake. При делении на модули возник вопрос: как настроить их взаимодействие максимально эффективно? Расскажу подробно о том, какие решения принимали мы и к чему это в итоге привело. Мы начали с внедрения технологии удаленного вызова процедур JSON-RPC. RPC полностью подходил нам для организации взаимодействия между модулями. А формат JSON мы выбрали по нескольким причинам: в отличие от бинарных протоколов JSON легко читаем. В случае удаленной отладки на объекте за несколько тысяч километров это — самое весомое преимущество; JSON не такой многословный, как XML; у нас уже была своя реализация JSON в комбинации с концептом Variant. Variant — это такой универсальный контейнер, который мог, с одной стороны, вместить прикладные данные и структуры и сериализовать их в JSON, c другой стороны, распарсить JSON, получив оттуда структуру всех данных и тип: Variant.fromJson() .toString () .toDouble() .toMap() В результате мы смогли поделить Кассу на три уровня: Транспорт, который получает и отправляет JSON, затем сериализует в Variant и передает его на следующий слой; Обработчики RPC — слой, который достает данные и определяет вызов RPC; Прикладной код вызова RPC. На первый взгляд всё отлично, однако разработчику с внедрением RPC добавилось задач. Для примера возьму функцию «Поиск товара по штрихкоду»: до перехода на три уровня Кассы этот функционал уже был в ядре. «Поиск товара по штрихкоду» принимает строчку string barcode и возвращает вектор структур с информацией по найденным товарам: vector<Art> find(string barcode) Эту функцию разработчик хочет вынести на уровень RPC для взаимодействия с другими модулями. Представьте, что он должен сделать: В транспортном слое, который работает только с JSON, всё без изменений; В слое обработчиков RPC нужно добавить обработчик on_find (Variant), который работал с транспортным слоем, затем связать его с ним, чтобы, когда от транспорта придёт вызов on_request, он понял, что это вызов RPC, которому требуется свой обработчик: Почему разработчик не мог напрямую обратиться к функции ядра find? В нашем случае из транспорта приходил контейнер Variant. Ему нужно было сначала достать параметры вызова, потом обратиться к прикладному коду core::find. Даже получив результат, он не мог просто так передать его транспорту — он должен был сначала упаковать в Variant вектор с информацией о товарах, а только потом полученный контейнер вернуть в транспорт для отправки запрашивающему модулю: На прикладном уровне без изменений: Что на клиентской стороне? Примерно аналогичная история: Транспортный слой — без изменений; Слой обработчиков: надо определить клиента, который подключится к транспорту и определит для прикладного уровня некий вызов find. Так как он был связан с транспортом, он также будет связан и с Variant: В прикладном коде этот вызов нужно осуществить следующим образом: сначала запаковать параметры метода в Variant, затем сделать вызов через клиента CoreClient (обработчик, который соединился с транспортом). Полученный результат — контейнер Variant, необходимо распаковать и только потом работать дальше с этим результатом: То есть с введением RPC разработчику пришлось: Добавлять обработчики — рутинный процесс. Нужно их объявить и связать с транспортным уровнем. Инструментов оптимизации кроме копипаста в тулбоксе на тот момент не было. Так разработчики и поступали: Cntrl+C Cntrl+V. И это, признаюсь, было не только утомительно, но и весьма рискованно. Всегда есть риск скопировать, а затем забыть переделать скопированное под себя. Преобразовывать параметры в Variant и доставать их из него обратно. Так как централизованного подхода к упаковке и распаковке не было, каждому программисту на каждом вызове приходилось реализовывать это локально в своем модуле. Контролировать соответствие параметров функции. Раньше при прямом вызове компилятор проверял тип параметров и выдавал ошибки при несоответствиях. Теперь же при RPC-вызове все параметры упаковываются в Variant — как int, так и string, и структуры, и вектора. И ошибка возникает не на этапе компиляции, а в рантайме на уровне сервера, когда он получает вызов и видит несоответствие параметра: Столько накладных расходов из-за RPC нас не устраивало: мы хотели упростить разработчику жизнь, снизить риски ошибок в работе системы и увеличить её продуктивность. Сделали ставку на макросы семейства BOOST_PP_* из библиотеки Boost.  Мы создали такой инструмент: в неком хэдере объявляется define (для примера возьмем CORE_EVENTS). Он содержал перечисление всех RPC-методов. Например, наш find и еще несколько других: В помощь разработчику с серверной стороны был определен макрос TANDER_DEFINE_SERVER(Srv, enum), который принимает на вход список событий и генерирует некий класс. Здесь определяется и код соединения с транспортным уровнем, а также все обработчики, общающиеся с транспортным уровнем через контейнер Variant.  С другой стороны для клиента был создан аналогичный макрос TANDER_DEFINE_CLIENT(Clnt, enum), который при вызове на клиентской стороне генерировал клиента RPC. В нём содержались вызовы RPC и соединения с транспортным уровнем. Наш разработчик вздохнул с облегчением. Однако всё ещё оставалось несколько недостатков. Так как появилось централизованное место, где описывались RPC-вызовы, со временем туда переехали и описания этих вызовов. Наш файл с RPC-методами пополнился богатыми комментариями, что это за методы, какие у них параметры вызова и каким ожидать результат вызова. Вот так, к примеру, выглядит файл: Другим недостатком была макросная магия. Макросы состояли из нескольких слоёв подмакросов. Генерируемый макросами код Вася увидеть не мог, он появлялся на препроцессинге. А разработчику в помощь шли только описания макросов с инструкциями, как их применять, и с примерами, что из них получается. Оставалась конвертация параметров в Variant и обратно. Мы пытались создать еще макросы, которые бы решили эту проблему, но только прибавили себе сложностей. Мы ушли от макросов и создали инструмент, который больше походил на C++, чем на макросную магию: разработали наш собственный язык IDL. В него мы заложили всё лучшее: Все максимально приближено к C++: простой синтаксис, базовые типы, виды структур (как struct, enum и тд), поддержка контейнеров vector и tuple; Написали к нему парсер и инструмент кодогенерации для RPC-клиента и RPC-сервера. Генерация кода добавлена в систему сборки. В отличие от макросного решения, генерируемый код виден разработчику и для изучения, и для отладки; Добавили конвертацию тех параметров, которые были описаны в интерфейсе, в Variant и из него. Если встречаем контейнеры по типу Vector, то добавляем распаковку и упаковку в контейнер. RPC-вызовы в генерируемом коде использовали сигнатуры как при прямом вызове. Все действия по упаковке параметров в контейнеры Variant и извлечению из Variant скрывались в детализации генерируемого кода, который использовал методы конвертации всех объявленных в интерфейсе типов: А вот как выглядит сгенерированный код: Наконец-то наша структура Art превращается в структуру C++, содержит 3 поля, 2 строки и число с плавающей точкой, методы преобразования в Variant и распаковки из Variant. Для контейнеров генерируется весь код по упаковке в Vector и распаковке из него в Variant.  Для серверной стороны генерируется модуль CoreSkeleton.hpp. Модуль содержит объявление структуры Art и код по её конвертации. Также в модуле объявлен класс CoreSkeleton, в котором в деталях скрыта работа с транспортным уровнем, упаковка и распаковка параметров в Variant. Также в классе определяется обработчик on_find, который предоставляется на верхний прикладной уровень.  Прикладной код серверной стороны упрощается следующим образом: Вася в своем модуле, добавляет include модуля скелетона. И создает наследника от серверного скелетона, переопределив метод on_find. On_find имеет уже сигнатуру прямого вызова, а именно строковый штрих-код, и возвращает vector. И в on_find помещается прикладной код, который будет отвечать за выполнения поиска в ядре. Что создаёт кодогенератор для работы клиента RPC? Генерация всего типа Art со всей распаковкой / упаковкой в Variant; Генерация специального класса CoreClient, который соединяется с транспортом и берет на себя всю работу с ним. Генерация класса CoreStub для пользовательского прикладного уровня, который самостоятельно работает с транспортом через вспомогательный CoreClient, а для разработчика предоставляет вызов find с сигнатурой прямого вызова, скрывая внутри упаковку параметров к контейнер Variant, и распаковку результата вызова.  Тогда прикладной клиентский код превращается в простой вызов, очень похожий на прямой. Только вместо модуля ядра у нас используется CoreStub:  Так при помощи IDL мы свели к минимуму всю дополнительную работу с RPC.  Мы не останавливаемся на достигнутом и продолжаем добавлять возможности для разработчиков. Внедрили: Наследование типов. Например, у разработчика есть некая структура, описывающая товар, и ему нужно более сложное описание. Допустим, добавить акцизную марку. Ему не нужно переписывать всю структуру или менять первоначальную. Он просто описывает свою структуру, наследуя от основной: В корпоративной библиотеке есть богатая коллекция своих собственных классов, которые мы используем для передачи информации между модулями. Самые используемые мы тоже внедрили в наш язык IDL. Теперь разработчик мог описать такие структуры, как «GUID» и «Дата-время», основанные на string JSON, спецификацию «Версия» или вообще бинарные данные, которые сериализуются в Base64:  Мы уже работаем над добавлением в IDL: Концепции модулей: опишем весь проект Кассы в рамках IDL, зафиксируем связи модулей и их роли, а также разграничим модули Клиент и Сервер; Концепции соединения модулей: опишем транспортную часть соединения модулей. Это может быть межпроцессный пайп (конвейер), TCP-сокет или система очереди сообщений ZMQ/AMQ/*MQ. Добавим спецификацию сериализации модулей через JSON, XML, BSON, Yaml; Кодогенерации для Python: сейчас модуль на Python может обращаться к Кассе. Но разработчику требуется писать код для упаковки всех параметров в контейнер, и потом при получении результата вызова распаковывать снова полученное. Здесь также напрашивается решение по  кодогенерации для Python, чтобы избавить разработчика от упаковок и упростить добавление функциональности. Итак, вот наш путь в три шага со звездочкой от монолита до кодогенерации. Мы оптимизировали разработку, потому что у нас получилось: Повысить отказоустойчивость: если падает один модуль, Касса продолжает работать; Изолировать модули: если один модуль работает из-под окружения Linux, то другой модуль может, например, запускаться из-под Windows в какой-нибудь вендорской dll-библиотеке получить информацию из COM-объекта и передать её в Кассу; Запускать удаленные модули на кассовом сервере, обновлять справочники кассы. Раньше в монолитной структуре без использования сторонних инструментов это было невозможно на расстоянии; Упростить разработку в условиях  огромного количества нововведений. Задача разработчика сейчас — описать интерфейсы в IDL; Внедрить инструмент для проектирования архитектуры: теперь эксперт в какой-то узкой области может описать весь интерфейс модуля, сразу разбив его на составляющие. Для этого ему необходимо  описать, какие вызовы должны идти напрямую между модулями, а какие через внешний RPC. А уже затем этот интерфейс разделить на несколько разработчиков. В этом случае риск случайной поломки архитектуры исключен. Недавно (29 ноября) мы делились историей этой разработки на Magnit.Tech++ Meetup.  ВОодушевились интересом участников и теперь хотим задать вопрос вам: стоит ли нам выносить IDL в opensource? И почему вы так считаете?