Свободная касса: как мы ушли от монолита и настроили межмодульное взаимодействие на RPC

от автора

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

Касса — это не вся POS-система «Магнита», но ее значительная часть: приложение для кассира. 15 лет назад Касса представляла из себя монолит: внутри интерфейса — таблица со списком товаров, ценами и скидками. Но со временем у нее появились новые функции: интеграция с весами, пин-падами, фискальные регистраторы и т.д.  Мы разделили приложение на модули, чтобы в случае “падения” одного из них по segfault вся Касса продолжала работать, хоть и с ограниченной функциональностью, предварительно сохранив при этом текущие данные для кассира. Теперь действия кассира в приложении отправляют запросы в ядро системы, которое в свою очередь получает информацию из множества модулей. POS-систему мы разработали на C++ на Linux CentOS 5+ с использованием стандартной библиотеки, Qt и Boost, а собрали при помощи GCC и CMake.

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

Уход от монолита: разделяй на слои и властвуй с RPC

Мы начали с внедрения технологии удаленного вызова процедур JSON-RPC. RPC полностью подходил нам для организации взаимодействия между модулями. А формат JSON мы выбрали по нескольким причинам:

  1. в отличие от бинарных протоколов JSON легко читаем. В случае удаленной отладки на объекте за несколько тысяч километров это — самое весомое преимущество;

  2. JSON не такой многословный, как XML;

  3. у нас уже была своя реализация JSON в комбинации с концептом Variant.

Variant — это такой универсальный контейнер, который мог, с одной стороны, вместить прикладные данные и структуры и сериализовать их в JSON, c другой стороны, распарсить JSON, получив оттуда структуру всех данных и тип:

Variant.fromJson()

.toString ()

.toDouble()

.toMap()

В результате мы смогли поделить Кассу на три уровня:

  1. Транспорт, который получает и отправляет JSON, затем сериализует в Variant и передает его на следующий слой;

  2. Обработчики RPC — слой, который достает данные и определяет вызов RPC;

  3. Прикладной код вызова RPC.

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

vector<Art> find(string barcode)

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

  1. В транспортном слое, который работает только с JSON, всё без изменений;

  2. В слое обработчиков 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; }
  1. На прикладном уровне без изменений:

vector<Art>  core::find(const string& barcode) { … }

Что на клиентской стороне? Примерно аналогичная история:

  1. Транспортный слой — без изменений;

  2. Слой обработчиков: надо определить клиента, который подключится к транспорту и определит для прикладного уровня некий вызов find. Так как он был связан с транспортом, он также будет связан и с Variant:

class CoreClient { public: CoreClient() { /* код подключения к транспорту */ } virtual Variant find(const Variant& params) {  Variant result = trnsp::process(“find”, params); return result; }
  1. В прикладном коде этот вызов нужно осуществить следующим образом: сначала запаковать параметры метода в 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 разработчику пришлось:

  1. Добавлять обработчики — рутинный процесс. Нужно их объявить и связать с транспортным уровнем. Инструментов оптимизации кроме копипаста в тулбоксе на тот момент не было. Так разработчики и поступали: Cntrl+C Cntrl+V. И это, признаюсь, было не только утомительно, но и весьма рискованно. Всегда есть риск скопировать, а затем забыть переделать скопированное под себя.

  2. Преобразовывать параметры в Variant и доставать их из него обратно. Так как централизованного подхода к упаковке и распаковке не было, каждому программисту на каждом вызове приходилось реализовывать это локально в своем модуле.

  3. Контролировать соответствие параметров функции. Раньше при прямом вызове компилятор проверял тип параметров и выдавал ошибки при несоответствиях. Теперь же при 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) { … } };

Наш разработчик вздохнул с облегчением. Однако всё ещё оставалось несколько недостатков.

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

core_events.h: #define CORE_EVENTS                                  \ /* поиск товара по Штрихкоду */                      \ /* параметры:                  */                    \ /*     string barcode         */                     \ /* возвращается:               */                    \ /*     vector<Art>              */                   \ /*     struct Art {            */                    \ /*        string name           */                   \ /*        string barcode       */                    \ /*        double price }        */                   \ (find)                                                                                                                        \                                                                                                                                                            \                                                                                 \ /* другой метод method1         */                   \ /* параметры:                  */                    \  /* …                           */                   \  (method1)                                           \
  1. Другим недостатком была макросная магия. Макросы состояли из нескольких слоёв подмакросов. Генерируемый макросами код Вася увидеть не мог, он появлялся на препроцессинге. А разработчику в помощь шли только описания макросов с инструкциями, как их применять, и с примерами, что из них получается.

  2. Оставалась конвертация параметров в Variant и обратно. Мы пытались создать еще макросы, которые бы решили эту проблему, но только прибавили себе сложностей.

Заход номер два: пришло время сказать «нет»

Мы ушли от макросов и создали инструмент, который больше походил на C++, чем на макросную магию: разработали наш собственный язык IDL. В него мы заложили всё лучшее:

  1. Все максимально приближено к C++: простой синтаксис, базовые типы, виды структур (как struct, enum и тд), поддержка контейнеров vector и tuple;

  2. Написали к нему парсер и инструмент кодогенерации для RPC-клиента и RPC-сервера. Генерация кода добавлена в систему сборки. В отличие от макросного решения, генерируемый код виден разработчику и для изучения, и для отладки;

  3. Добавили конвертацию тех параметров, которые были описаны в интерфейсе, в Variant и из него. Если встречаем контейнеры по типу Vector, то добавляем распаковку и упаковку в контейнер.

  4. 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?

  1. Генерация всего типа Art со всей распаковкой / упаковкой в Variant;

  2. Генерация специального класса CoreClient, который соединяется с транспортом и берет на себя всю работу с ним.

  3. Генерация класса 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. 

Заход «со звездочкой»: нам мало фишек

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

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

core.idl: struct Art {  }; struct AlcoArt: Art  { string excise_mark; };
  1. В корпоративной библиотеке есть богатая коллекция своих собственных классов, которые мы используем для передачи информации между модулями. Самые используемые мы тоже внедрили в наш язык IDL. Теперь разработчик мог описать такие структуры, как «GUID» и «Дата-время», основанные на string JSON, спецификацию «Версия» или вообще бинарные данные, которые сериализуются в Base64: 

struct Data  { GUID            guid; DateTime      time_stamp; VersionSpec version; RawData       binary_data; };

Мы уже работаем над добавлением в IDL:

  1. Концепции модулей: опишем весь проект Кассы в рамках IDL, зафиксируем связи модулей и их роли, а также разграничим модули Клиент и Сервер;

  2. Концепции соединения модулей: опишем транспортную часть соединения модулей. Это может быть межпроцессный пайп (конвейер), TCP-сокет или система очереди сообщений ZMQ/AMQ/*MQ. Добавим спецификацию сериализации модулей через JSON, XML, BSON, Yaml;

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

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

  1. Повысить отказоустойчивость: если падает один модуль, Касса продолжает работать;

  2. Изолировать модули: если один модуль работает из-под окружения Linux, то другой модуль может, например, запускаться из-под Windows в какой-нибудь вендорской dll-библиотеке получить информацию из COM-объекта и передать её в Кассу;

  3. Запускать удаленные модули на кассовом сервере, обновлять справочники кассы. Раньше в монолитной структуре без использования сторонних инструментов это было невозможно на расстоянии;

  4. Упростить разработку в условиях  огромного количества нововведений. Задача разработчика сейчас — описать интерфейсы в IDL;

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

Недавно (29 ноября) мы делились историей этой разработки на Magnit.Tech++ Meetup.  ВОодушевились интересом участников и теперь хотим задать вопрос вам: стоит ли нам выносить IDL в opensource? И почему вы так считаете?


ссылка на оригинал статьи https://habr.com/ru/company/magnit/blog/596879/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *