Всем привет, я — Дмитрий Пестеха, ведущий разработчик С++ команды POS-систем в «Магните». Расскажу, как мы пилили монолитное приложение Касса на модули и отлаживали их взаимодействие на RPC-JSON. Спойлер: в процессе в мире появился новый самописный язык интерфейсов - IDL.

Касса — это не вся POS-система «Магнита», но ее значительная часть: приложение для кассира. 15 лет назад Касса представляла из себя монолит: внутри интерфейса — таблица со списком товаров, ценами и скидками. Но со временем у нее появились новые функции: интеграция с весами, пин-падами, фискальные регистраторы и т.д. Мы разделили приложение на модули, чтобы в случае “падения” одного из них по segfault вся Касса продолжала работать, хоть и с ограниченной функциональностью, предварительно сохранив при этом текущие данные для кассира. Теперь действия кассира в приложении отправляют запросы в ядро системы, которое в свою очередь получает информацию из множества модулей. POS-систему мы разработали на C++ на Linux CentOS 5+ с использованием стандартной библиотеки, Qt и Boost, а собрали при помощи GCC и CMake.
При делении на модули возник вопрос: как настроить их взаимодействие максимально эффективно? Расскажу подробно о том, какие решения принимали мы и к чему это в итоге привело.
Уход от монолита: разделяй на слои и властвуй с RPC
Мы начали с внедрения технологии удаленного вызова процедур 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, которому требуется свой обработчик:
Variant core_server::on_find(const Variant& params); Variant core_server::on_request(method, params) { if (method == ”find”) return on_find(params); // Почему не core::find(string)? }
Почему разработчик не мог напрямую обратиться к функции ядра find? В нашем случае из транспорта приходил контейнер Variant. Ему нужно было сначала достать параметры вызова, потом обратиться к прикладному коду core::find. Даже получив результат, он не мог просто так передать его транспорту — он должен был сначала упаковать в Variant вектор с информацией о товарах, а только потом полученный контейнер вернуть в транспорт для отправки запрашивающему модулю:
Variant core_server::on_find(const Variant& params) { std::string barcode = params.toString(); vector<Art> core_result = core::find(barcode); // необходимо поместить vector<Art> --> Variant Variant result; for (const Art& art : core_result) { … } return result; }
На прикладном уровне без изменений:
vector<Art> core::find(const string& barcode) { … }
Что на клиентской стороне? Примерно аналогичная история:
Транспортный слой — без изменений;
Слой обработчиков: надо определить клиента, который подключится к транспорту и определит для прикладного уровня некий вызов
find. Так как он был связан с транспортом, он также будет связан и с Variant:
class CoreClient { public: CoreClient() { /* код подключения к транспорту */ } virtual Variant find(const Variant& params) { Variant result = trnsp::process(“find”, params); return result; }
В прикладном коде этот вызов нужно осуществить следующим образом: сначала запаковать параметры метода в Variant, затем сделать вызов через клиента
CoreClient(обработчик, который соединился с транспортом). Полученный результат — контейнер Variant, необходимо распаковать и только потом работать дальше с этим результатом:
void Module::doSomeStuff() { // необходимо найти товары по Штрихкоду “4660000” Variant params(“4660000”); Variant result = m_core_client.find(params); vector<Art> arts; // извлекаем vector<Art> из Variant arts = …… /// работаем с результатами поиска for (const auto& a : arts) {... } }
То есть с введением RPC разработчику пришлось:
Добавлять обработчики — рутинный процесс. Нужно их объявить и связать с транспортным уровнем. Инструментов оптимизации кроме копипаста в тулбоксе на тот момент не было. Так разработчики и поступали: Cntrl+C Cntrl+V. И это, признаюсь, было не только утомительно, но и весьма рискованно. Всегда есть риск скопировать, а затем забыть переделать скопированное под себя.
Преобразовывать параметры в Variant и доставать их из него обратно. Так как централизованного подхода к упаковке и распаковке не было, каждому программисту на каждом вызове приходилось реализовывать это локально в своем модуле.
Контролировать соответствие параметров функции. Раньше при прямом вызове компилятор проверял тип параметров и выдавал ошибки при несоответствиях. Теперь же при RPC-вызове все параметры упаковываются в Variant — как int, так и string, и структуры, и вектора. И ошибка возникает не на этапе компиляции, а в рантайме на уровне сервера, когда он получает вызов и видит несоответствие параметра:
// std::string barcode = “4600000”; int barcode = 4600000; // Прямой вызов vector<Art> result = core.find(barcode); // Ошибка компиляции, ожидается string! // Rpc вызов Variant result = core_client.find(Variant(barcode)); // Нет ошибки компиляции, Variant содержит int
Столько накладных расходов из-за RPC нас не устраивало: мы хотели упростить разработчику жизнь, снизить риски ошибок в работе системы и увеличить её продуктивность.
Заход номер раз: макросы - это хорошо (но это еще не точно)
Сделали ставку на макросы семейства BOOST_PP_* из библиотеки Boost.
Мы создали такой инструмент: в неком хэдере объявляется define (для примера возьмем CORE_EVENTS). Он содержал перечисление всех RPC-методов. Например, наш find и еще несколько других:
#define CORE_EVENTS \ /* поиск товара по Штрихкоду */ \ (find) \ /* … */ \ (method1) \ (method2) \
В помощь разработчику с серверной стороны был определен макрос TANDER_DEFINE_SERVER(Srv, enum), который принимает на вход список событий и генерирует некий класс. Здесь определяется и код соединения с транспортным уровнем, а также все обработчики, общающиеся с транспортным уровнем через контейнер Variant.
// Серверная сторона TANDER_DEFINE_SERVER(CoreSkeleton, CORE_EVENTS); class CoreSkeleton { // пустые, виртуальные обработчики virtual Variant on_find (const Variant& params) { } virtual Variant on_method1(const Variant& params) { } virtual Variant on_method2(const Variant& params) { } };
С другой стороны для клиента был создан аналогичный макрос TANDER_DEFINE_CLIENT(Clnt, enum), который при вызове на клиентской стороне генерировал клиента RPC. В нём содержались вызовы RPC и соединения с транспортным уровнем.
// Клиентская сторона TANDER_DEFINE_CLIENT(CoreClient, CORE_EVENTS); class CoreClient { virtual Variant find(const Variant& params) { … } virtual Variant method1(const Variant& params) { … } virtual Variant method2(const Variant& params) { … } };
Наш разработчик вздохнул с облегчением. Однако всё ещё оставалось несколько недостатков.
Так как появилось централизованное место, где описывались RPC-вызовы, со временем туда переехали и описания этих вызовов. Наш файл с RPC-методами пополнился богатыми комментариями, что это за методы, какие у них параметры вызова и каким ожидать результат вызова. Вот так, к примеру, выглядит файл:
core_events.h: #define CORE_EVENTS \ /* поиск товара по Штрихкоду */ \ /* параметры: */ \ /* string barcode */ \ /* возвращается: */ \ /* vector<Art> */ \ /* struct Art { */ \ /* string name */ \ /* string barcode */ \ /* double price } */ \ (find) \ \ \ /* другой метод method1 */ \ /* параметры: */ \ /* … */ \ (method1) \
Другим недостатком была макросная магия. Макросы состояли из нескольких слоёв подмакросов. Генерируемый макросами код Вася увидеть не мог, он появлялся на препроцессинге. А разработчику в помощь шли только описания макросов с инструкциями, как их применять, и с примерами, что из них получается.
Оставалась конвертация параметров в Variant и обратно. Мы пытались создать еще макросы, которые бы решили эту проблему, но только прибавили себе сложностей.
Заход номер два: пришло время сказать «нет»
Мы ушли от макросов и создали инструмент, который больше походил на C++, чем на макросную магию: разработали наш собственный язык IDL. В него мы заложили всё лучшее:
Все максимально приближено к C++: простой синтаксис, базовые типы, виды структур (как struct, enum и тд), поддержка контейнеров vector и tuple;
Написали к нему парсер и инструмент кодогенерации для RPC-клиента и RPC-сервера. Генерация кода добавлена в систему сборки. В отличие от макросного решения, генерируемый код виден разработчику и для изучения, и для отладки;
Добавили конвертацию тех параметров, которые были описаны в интерфейсе, в Variant и из него. Если встречаем контейнеры по типу Vector, то добавляем распаковку и упаковку в контейнер.
RPC-вызовы в генерируемом коде использовали сигнатуры как при прямом вызове. Все действия по упаковке параметров в контейнеры Variant и извлечению из Variant скрывались в детализации генерируемого кода, который использовал методы конвертации всех объявленных в интерфейсе типов:
core.idl: namespace core { struct Art { string name; string barcode; double price; }; interface Core { vector<Art> find(string barcode); } } // namespace core
А вот как выглядит сгенерированный код:
struct Art { std::string name; std::string barcode; double price; // методы для упаковки в Variant Art(Variant v) {…} Variant toVar() {…} // методы для упаковки векторов в Variant static std::vector<Art> fromVar(const Variant& v) {...} static Variant toVar(const std::vector<Art>& v) {…} };
Наконец-то наша структура Art превращается в структуру C++, содержит 3 поля, 2 строки и число с плавающей точкой, методы преобразования в Variant и распаковки из Variant. Для контейнеров генерируется весь код по упаковке в Vector и распаковке из него в Variant.
Для серверной стороны генерируется модуль CoreSkeleton.hpp.
struct Art { … } class CoreSkeleton { public: vitrual std::vector<Art> on_find(const string& barcode) = 0; Variant on_request(string method, Variant params) { if (method == “find”) { string barcode = params.toString(); // вызов обработчика std::vector<Art> result = on_find(barcode); // упаковка результата в Variant return Art::toVar(result)); } } };
Модуль содержит объявление структуры Art и код по её конвертации. Также в модуле объявлен класс CoreSkeleton, в котором в деталях скрыта работа с транспортным уровнем, упаковка и распаковка параметров в Variant. Также в классе определяется обработчик on_find, который предоставляется на верхний прикладной уровень.
Прикладной код серверной стороны упрощается следующим образом:
Вася в своем модуле, добавляет include модуля скелетона. И создает наследника от серверного скелетона, переопределив метод on_find. On_find имеет уже сигнатуру прямого вызова, а именно строковый штрих-код, и возвращает vector. И в on_find помещается прикладной код, который будет отвечать за выполнения поиска в ядре.
Core.hpp #include <.gen/CoreSkeleton.hpp> class Core: public CoreSkeleton { // переопределение обработчика std::vector<Art> on_find(const string& barcode) { // реализация поиска // результат - vector<Art> } };
Что создаёт кодогенератор для работы клиента RPC?
Генерация всего типа Art со всей распаковкой / упаковкой в Variant;
Генерация специального класса
CoreClient, который соединяется с транспортом и берет на себя всю работу с ним.Генерация класса
CoreStubдля пользовательского прикладного уровня, который самостоятельно работает с транспортом через вспомогательный CoreClient, а для разработчика предоставляет вызовfindс сигнатурой прямого вызова, скрывая внутри упаковку параметров к контейнер Variant, и распаковку результата вызова.
Тогда прикладной клиентский код превращается в простой вызов, очень похожий на прямой. Только вместо модуля ядра у нас используется CoreStub:
CoreStub.hpp struct Art { … }; class CoreStub { } // find(string) Module.hpp #include <.gen/CoreStub.hpp> class Module { CoreStub m_core; void doSomeStuff() { std::vector<Art> result = m_core.find(“46600000”); // обработка результата } };
Так при помощи IDL мы свели к минимуму всю дополнительную работу с RPC.
Заход «со звездочкой»: нам мало фишек
Мы не останавливаемся на достигнутом и продолжаем добавлять возможности для разработчиков. Внедрили:
Наследование типов. Например, у разработчика есть некая структура, описывающая товар, и ему нужно более сложное описание. Допустим, добавить акцизную марку. Ему не нужно переписывать всю структуру или менять первоначальную. Он просто описывает свою структуру, наследуя от основной:
core.idl: struct Art { }; struct AlcoArt: Art { string excise_mark; };
В корпоративной библиотеке есть богатая коллекция своих собственных классов, которые мы используем для передачи информации между модулями. Самые используемые мы тоже внедрили в наш язык IDL. Теперь разработчик мог описать такие структуры, как «GUID» и «Дата-время», основанные на string JSON, спецификацию «Версия» или вообще бинарные данные, которые сериализуются в Base64:
struct Data { GUID guid; DateTime time_stamp; VersionSpec version; RawData binary_data; };
Мы уже работаем над добавлением в 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? И почему вы так считаете?
