Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Всем привет, я — Дмитрий Пестеха, ведущий разработчик С++ команды 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? И почему вы так считаете?