Всем привет, я — Дмитрий Пестеха, ведущий разработчик С++ команды 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? И почему вы так считаете?

Комментарии (4)


  1. Playa
    22.12.2021 01:54

    Уже есть как минимум TAO со своим IDL, зачем писать свой велосипед?


    1. cr0nk
      22.12.2021 13:08
      +1

      Наверно для того чтобы не тянуть TAO а с ним и ACE ради IDL


    1. dima18 Автор
      22.12.2021 13:17
      +1

      Тот же вопрос можно задать разработчикам TAO. Они не изобрели IDL, это одна из реализаций. У каждой свои особенности и характеристики. Мы делали решение, оптимизированное под наш RTL и максимально полно покрывающее те практики, которые уже зарекомендовали себя в наших проектах до перехода на IDL.


  1. Flaria17
    22.12.2021 19:30
    +1

    Огромная работа и отличный рассказ! Молодцы =)