V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений
В этой статье я расскажу о долгом путешествии, в котором простая идея выноса в JavaScript часто меняющихся фрагментов алгоритма постепенно выросла в универсальный фреймворк, позволяющий быстро создавать микросервисы и так же быстро их развивать. Сейчас он служит основой для множества микросервисов в Яндекс Go. Тут не будет много специфики Go. Вместо этого будет много разработки и решений технических задач (а не продуктовых). Ещё я, конечно, расскажу про возникшие в процессе трудности: если вам, например, интересно, как V8 уживается с корутинами или как мы оптимизировали работу с ним для производительности, то добро пожаловать под кат.
Когда я целых три года назад присоединился к команде Яндекс.Такси, то начал заниматься поддержкой сервиса расчёта повышенного спроса на такси (surge pricing, сурж). Почитать про него можно здесь. Этот сервис был написан на C++, а алгоритм расчёта представлял собой cpp-файл объёмом более чем 3000 строк со сложно переплетёнными ссылками на объекты в стеке, без чёткой структуры. Не то чтобы это был жуткий спагетти-код, но чтобы в нём разобраться, требовалось немало времени. Также возникала проблема с логированием — периодически поднимался вопрос: «А сможем ли мы для конкретного заказа ответить, почему мы получили именно такой коэффициент?» Ответ был: «Да, но надо будет покопаться в логах и данных».
Конечно, в разы проще было бы решить эти проблемы обычным рефакторингом. Но кроме них были и другие, более фундаментальные.
Мотивация использования JavaScript
Алгоритм расчёта суржа постоянно эволюционирует: меняются формулы, появляются новые, добавляются источники данных, коррекции, коэффициенты. Процесс внесения правок в алгоритм выглядел примерно так:
1. Аналитики придумали крутую сущность или формулу, которая более точно предсказывает спрос.
2. Они заводят на разработчиков задачу, в которой пытаются донести, чего они хотят.
3. Разработчик интерпретирует задачу.
4. Пишет код на C++.
5. Пишет тесты.
6. Катит результат в тестовое окружение и проверяет.
7. Катит в прод под выключенным конфигом, включает и убеждается, что ничего не сломалось.
8. Аналитик по данным и логам проверяет, как фича работает.
9. (возможно) Фича работает неправильно из-за miscommunication — возвращаемся к пункту 4.
10. (возможно) Фича работает неправильно из-за бага — возвращаемся к пункту 4.
11. (возможно) На реальных данных фича «не взлетела». Abandon mission и, как итог, возможные N строк мёртвого кода.
Если мы всего лишь хотели поэкспериментировать, поменяв небольшую часть алгоритма, и оценить поведение системы, такой процесс является слишком громоздким и трудозатратным. Возможность писать изолированный код без необходимости выкатки выглядела разумным шагом для ускорения цикла разработки.
Поэтому, недолго обсудив, мы решили использовать опенсорсный JS-движок V8. Основной аргумент: он уже использовался коллегами в другом сервисе, который находился в том же репозитории, что и наш. Поэтому многое можно было переиспользовать. Ну и, конечно, это обеспечивало скорость. Наш сервис чувствителен к таймингам, так как вызывается синхронно при открытии приложения для расчёта цены. Отсюда и нагрузка, исчисляемая тысячами RPS на ручку расчёта суржа. Но поскольку мы были первыми внутри Такси, кому понадобилось использовать V8 в требовательном для производительности месте, пришлось кое-что придумывать.
Почему не Node.js
Это наверное первый вопрос, который я услышал от коллег после моего рассказа о внедрении V8 в наш плюсовый бэкенд. Когда речь заходит об использовании JavaScript в бэкенде, то на ум практически каждому приходит Node.js и возникает извечный вопрос: «Почему бы не использовать уже зрелое решение вместо того, чтобы писать свой велосипед?»
Вот причины:
- Важно понять: мы не преследуем цель сменить C++ на JS. Мы хотим получить возможность менять и дополнять алгоритм работы микросервиса во время его работы, не прибегая к выкатке.
- У нас нет инфраструктуры Node.js. То есть пришлось бы заново настраивать CI, процесс выкатки, настройки окружения, заводить отдельный репозиторий и, скорее всего, решать множество других моментов.
- У нас развитая кодовая база, написанная на C++, в которой есть множество полезных наработок, библиотек, утилит. Интеграция в неё V8 вместо перехода на Node.js позволяет нам получить JS с возможностью пробросить в него что угодно из имеющегося у нас арсенала в C++.
Слой выполнения JS-кода (namespace js: execution)
Для начала нам надо научиться выполнять JS-код и делать это максимально быстро. Это самостоятельная часть статьи, в которой будет описано, как мы, начиная с std: string с JS-кодом внутри, закачиваем результатом из JS-функции, определённой в этом коде. Если вам интереснее почитать про фреймворк, основанный на базе этого слоя, то можно сразу перейти к части «Фреймворк онлайн-вычислений».
Учимся быстро вызывать JS-функции
Если вы раньше не сталкивались с движком V8, будет неплохо сперва познакомиться с другими статьями, посвящёнными именно тому, как нужно встраивать V8 в проект на плюсах. Эта статья о другом. Здесь я лишь вкратце пробегусь по сущностям V8, чтобы ввести терминологию. Касаемо версии: так сложилось, что у нас используется далеко не самая новая 6.0.286.
Разработка идёт под наш userver. Это C++-фреймворк для написания микросервисов с использованием корутин, так что всё происходит в stackful-корутинах. В данном случае это дополнительное препятствие, так как V8 изначально спроектирован для работы исключительно в обычных потоках и корутины ему совершенно не нужны. Но поскольку необходимо уметь коммуницировать с внешним миром (захватывать наши кастомные мьютексы, работать с нашим аналогом condition_variable и прочее), мы реализовали взаимодействие с V8 внутри одной корутины, ограниченной одним потоком. То есть существует отдельный поток, на котором выполняется только одна корутина. Дальше мы будем именовать воркером связку из этого потока и корутины. Так мы ушли от неявного переключения на другой поток, которого стоит избегать: V8 хранит внутри себя данные thread_local и смена потока на лету может потенциально привести к неприятным спецэффектам. Мы также ушли от неявного переключения на другие корутины внутри одного потока, которого точно стоит избегать: V8 имеет некое глобальное состояние, в нём отражено, в какой изолят и контекст мы зашли, какой у нас сейчас v8:: HandleScope (живущий на стеке) и какой максимальный адрес (stack limit) на стеке, при преодолении которого нужно кидать stack overflow. Было много боли при попытке подружить V8 с корутинами, обновляя этот stack limit перед передачей управления движку. Однако полностью победить false positive stack overflow так и не удалось, что в итоге оказалось к лучшему, так как привело к более удачному дизайну решения. Но об этом позже.
Чтобы вызвать функцию, нам нужны v8:: Isolate и v8:: Context. Сущности перечислены в порядке убывания тяжести их конструирования. C v8:: Isolate всё просто — привязываем его к воркеру, так как в моём сценарии использования пересоздавать его бессмысленно и очень дорого. А вот с v8:: Context уже интереснее: он содержит в себе скомпилированный код, вариантов которого будет более одного. С пересоздающимся на каждый запрос контекстом производительность была ужасна. На его создание тратилось около пяти миллисекунд чистого процессорного времени, что уже даёт оценку сверху по производительности в 200 RPS на поток. При этом сам JS-код, ради которого мы пересоздаём контекст, относительно времени, затрачиваемого на запрос, не меняется целую вечность. Ниже — график нагрузочного тестирования с такой конфигурацией на машине с 32 ядрами. В JS вынесен один участок алгоритма длиной порядка 100 строк.
Получилось менее тысячи RPS, а до этого мы без проблем держали десять тысяч. Так не пойдёт, нужно кэшировать контекст.
Мы хотим выполнить код на JavaScript, лежащий в самописном in-memory-кэше, который периодически обновляется. В момент этого обновления мы и хотим инвалидировать v8:: Context. Реализуем примитивную систему кэширования. Пусть в качестве ключа она использует адрес в памяти, который идентифицирует экземпляр данных. Для этого я определил интерфейс с двумя методами:
struct CacheItem {
virtual ~CacheItem() = default;
/**
* @details возвращает true, если код, на основе которого был построен
* контекст, устарел.
*/
virtual bool IsExpired() const = 0;
/**
* @details Не стоит пугаться void* — здесь он используется по сути
* только как ключ, а не для работы с объектом, на который он указывает.
* Он служит для сопоставления некоего состояния (объекта) в C++ контексту V8.
* Если в двух словах, это ключ записи в кэше.
*/
virtual void* GetKey() const = 0;
};
Наши in-memory-кэши хранят состояние по std: shared_ptr, периодически заменяя старое состояние новым и при этом прекращая владеть старым состоянием. Поэтому в записях кэша состояний V8 мы просто храним std: weak_ptr, через который и работает IsExpired. А в качестве ключа мы просто используем адрес нужной сущности внутри состояния нашего in-memory кэша. После реализации кэширования график выглядит так:
Почти десять тысяч RPS. Уже лучше.
Этот подход работает не только для кэширования контекста V8, но и для кэширования любых загружаемых в движок данных, которые редко меняются. Понимание того, что данные не привязаны к контексту, пришло не сразу, но v8:: Value принадлежит не v8:: Context, a v8:: Isolate. Поэтому множества (кэшированные контексты и кэшированные данные) не перемножаются и могут совмещаться как угодно. Так мы можем очень дёшево загружать довольно большие объёмы редко меняющихся данных. У нас, например, есть объект, содержащий множество настроек алгоритма для текущей геозоны. Он редактируется вручную через админку, следовательно — меняется редко и для него кэширование — то, что доктор прописал.
Думаю, пора ввести в нашу систему новую и, можно сказать, основную сущность — Task.
js: execution: Task
Пользователь реализовывает интерфейс Task по своему усмотрению. У этого интерфейса также есть множество других, не упомянутых в статье возможностей, таких как разные стратегии кэширования и выполнения, возможность определить свой процессор исключений (чтобы изменять обработку исключений из JS) и логгер.
Ожидается, что Task владеет всеми ресурсами, необходимыми для успешного взаимодействия с V8. Как правило, таска разделяет владение состоянием содержащего код in-memory-кэша так, что даже если он обновится во время выполнения таски, ничего не сломается. Объект класса, реализующего этот интерфейс, передаётся внутрь компонента js: execution, и уже после этого начинается выполнение таски.
У таски есть имя, код на JS, метод инициализации (void Initialize ()) и метод выполнения (v8:: Local
Initialize вызывается только в случае промаха по кэшу контекстов V8 и может модифицировать глобальную область JS. В нём можно определять свои функции, доступные из JS, и прочее. Execute, наоборот, вызывается всегда, ему запрещено изменять глобальный объект (будет исключение), а его результат после парсинга в C++-модель становится результатом таски.
Глобальное состояние иммутабельное и для самого JS кода. Например, если забыть let перед названием переменной, то глобальная переменная не создастся. Вместо этого возникнет исключение, выкинутое заранее установленным interceptor’ом на Set в глобальный объект. Это сделано, потому что контекст кэшируется и нельзя позволить, чтобы предыдущие вычисления могли влиять на последующие.
Пользователь может конфигурировать сам интерфейс, собирая его как конструктор из доступных частей. Таким образом, при добавлении новой фичи, требующей дополнительного интерфейса к клиентскому коду, мы можем легко масштабировать возможности интерфейса js: execution. Например, добавить новый миксин-интерфейс, не затрагивая существующий клиентский код, которому такой интерфейс не нужен, и не усложняя существующие миксины. Они разбиты на категории, сейчас их две: миксины-интерфейсы выполнения и миксины-интерфейсы кэширования. При этом интерфейс выполнения обязателен, а интерфейс кэширования можно не указывать. Тогда контекст не будет кэшироваться, зато можно будет делать с глобальной областью что угодно и когда угодно. А библиотека получает таску и пытается сделать side-cast’ы (метод As) в эти миксины-интерфейсы, чтобы задействовать ту функциональность, которая используется в таске.
/**
* @brief Базовый класс для всех миксинов-интерфейсов.
*/
struct MixinInterface {
virtual ~MixinInterface() = default;
};
/**
* @brief Базовый класс для миксинов-интерфейсов,
* определяющих стратегию кэширования V8-контекстов.
*/
struct CachingMixinInterface : MixinInterface {};
/**
* @brief Базовый класс для всех миксинов-интерфейсов,
* определяющих стратегию выполнения таски.
*/
struct ExecutionMixinInterface : MixinInterface {};
/**
* @brief Базовый (common) интерфейс таски.
*/
struct Base {
virtual ~Base() = default;
/**
* @brief Получить миксин-интерфейс T, если таска его реализовывает.
*/
template
const T* As() const {
static_assert(std::is_base_of_v, "invalid T");
return dynamic_cast(this);
}
template
T* As() {
static_assert(std::is_base_of_v, "invalid T");
return dynamic_cast(this);
}
/**
* @brief Получить JS код
*/
virtual const std::string& GetScript() const = 0;
/**
* @brief Получить имя таски — будет использоваться для логирования и тому подобного.
*/
virtual const std::string& GetName() const = 0;
};
template
struct Interface : Base, public Mixins... {
static_assert((std::is_base_of_v && ...),
"некорректный интерфейс-миксин");
static_assert((std::is_base_of_v + ... + 0) > 0,
"не выбрана стратегия выполнения");
static_assert((std::is_base_of_v + ... + 0) <= 1,
"более чем одна стратегия выполнения");
static_assert((std::is_base_of_v + ... + 0) <= 1,
"более чем одна стратегия кэширования");
};
/**
* @brief Обычное выполнение таски.
* @details Выполнили Execute, уничтожили таску, распарсили её ответ,
* вернули его в клиентский код.
*/
struct OneOffExecution : ExecutionMixinInterface {
virtual v8::Local Execute() const = 0;
};
/**
* @brief Выполнение таски как генератора.
* @details Вызываем Execute, пока IsDone() == false,
* ставя таску на паузу, пока клиентский код не даст
* сигнал к продолжению.
*/
struct MultiExecution : ExecutionMixinInterface {
virtual bool IsDone() const = 0;
/**
* @brief Отличается от OneOffExecution::Execute тем, что не константен,
* так как для этой модели выполнения нужно, чтобы при выполнении
* by-design менялось состояние таски (просто обновить флаг is_done).
*/
virtual v8::Local Execute() = 0;
};
/**
* @brief Упрощённое (обёрнутое) выполнение таски как генератора.
* @details Дёргаем один раз GetGenerator, который обязан вернуть JS-объект Generator,
* и дальше вызываем его JS-функцию next(), пока он не вернёт is_done. === false
*/
struct JsGeneratorExecution : MultiExecution {
virtual v8::Local GetGenerator() const = 0;
bool IsDone() const final;
v8::Local Execute() final;
};
/**
* @brief Кэширование на основе адреса памяти.
*/
struct MemoryCaching : CachingMixinInterface {
virtual CacheItemPtr GetCacheItem() const = 0;
/**
* @brief Подготовить глобальную область свежесозданного контеста к выполнению.
*/
virtual void Initialize() const = 0;
};
/**
* @brief Другая стратегия кэширования.
* @details Она следит уже не за адресами в памяти,
* а за строковыми ключами, которые определил пользователь.
* То есть если в базе лежит иммутабельная сущность с id,
* то этот id можно использовать для идентификации состояния
* и не инвалидировать V8-контекст, даже если объект в in-memory-кэше
* заменился на другой с таким же id.
* Подобные записи в кэше состояний могут жить неограниченно долго,
* пока в них происходят попадания. Иначе через определённое время
* (по умолчанию 10 минут) они инвалидируются и будут удалены при
* следующем обращении в кэш.
*/
struct StableCaching : CachingMixinInterface {
virtual StableCacheItemPtr GetCacheItem() const = 0;
virtual void Initialize() const = 0;
};
/**
* @brief Пример использования интерфейса.
*/
class Task final : public Interface {
public:
v8::Local Execute() const override;
CacheItemPtr GetCacheItem() const override;
void Initialize() const override;
};
Когда таска создана, она помещается в очередь, которую разгребают ранее упомянутые JS-воркеры. В них находится цикл, вытаскивающий из очереди и исполняющий новые таски.
А что, если while (true);?
Внедряя JS в серверную логику, где его раньше и в помине не было, мы в некоторой степени выпускаем джинна из бутылки. Поэтому нам надо его хотя бы как-то контролировать и следить за тем, чтобы он не положил весь сервис бесконечным циклом или рекурсией. Насчет рекурсии: V8 сам умеет её определять, в случае возникновения он выкинет RangeError, так что можно не беспокоиться, верно? Не совсем. Написав тест на бесконечную рекурсию в JS, я с удивлением созерцал segfault. В чём же дело? Вспомним, в какой среде мы работаем с движком, а именно вспомним корутины. Размер их стека меньше, чем у потока, а V8 должен знать его размер, чтобы проверка на переполнение стека работала корректно. По умолчанию V8 рассчитывает, что размер стека будет 1 МБ, у наших корутин — 256 КБ. Есть два метода сообщить движку размер стека: вызвать метод SetStackLimit у изолята или через аргумент командной строки --stack-size. После установки лимита в 192 КБ (он должен быть несколько меньше реального) проверка заработала.
Для принудительного завершения в случае ситуаций вроде бесконечного цикла у изолята можно вызвать метод TerminateExecution, который сгенерирует неуловимое исключение внутри стека JS и тем самым завершит выполнение.
Как можно догадаться, JS выполняется асинхронно относительно ручки, в своём отдельном потоке и корутине. Настало время обсудить, как эти корутины синхронизируются с теми, в которых выполняется ручка.
js: execution: channel
Имеющиеся примитивы синхронизации не подходили: требовалось реализовать механизм таймаутов с принудительным завершением выполнения и парсинг из v8:: Value в C++-модель. Ещё один момент: читать переменные из V8 можно, только пока ты находишься внутри его среды, поскольку чтение происходит из его внутренней кучи. Нельзя просто получить v8:: Value и в клиентском коде распарсить его. Нужно это делать заранее и возвращать уже готовый результат. В первой реализации никакого канала не было, а была всем знакомая связка Future-Promise и возможности у нее были стандартные (Promise: Set (), Future: Get ()/Wait ()). Вызов выглядел примерно так:
auto future = js.Execute(std::move(task));
UserType value = future.Get(timeout);
Однако со временем стало понятно, что этого недостаточно. Невозможно было произвести какие-либо операции, связанные с ожиданием (типичный пример: поход по сети) и после них продолжить выполнение JS-кода. Внутри самого воркера ждать нельзя, их у нас столько же, сколько ядер, и ему в это время надо обрабатывать другие таски. Наращивать число воркеров плохо: изолят — довольно увесистая сущность, она тратит десятки мегабайт оперативки, и лишние потоки усложняют планировщику ОС жизнь. Ради этого мы в том числе и перешли в своё время на корутины. Остаётся только научиться прерывать выполнение JS-кода, выходить из контекста V8 с его сохранением и возвращаться в него, продолжая с того же места. Для этого как раз подходят генераторы JS.
Сначала мы попытались уместить асинхронные операции внутри воркера. То есть на потоке воркера для каждой новой таски создаем новую корутину, в которой работаем как обычно. Но поскольку у нас есть отдельная корутина, мы можем, предварительно покинув V8, делать наши not wait-free-процессы. В этот момент, поскольку корутина перешла в ожидание, поток переключится на другую, готовую к выполнению корутину. В ней мы заходим обратно в V8 и продолжаем работу. Выше я сказал, что так делать нельзя, но расчёт был на то, что мы знаем, когда произойдет переключение, и выходим из V8, при этом с движком по-прежнему работает тот же поток. Должно получиться — в теории. Но на практике…

Первое, что я получил, — это, конечно, segfault в недрах V8 при создании контекста V8. Перезапустившись на дебажной сборке V8, я увидел, что дело в указателе на стек. Логично, ведь мы работаем из разных корутин, у которых совершенно разные стеки и, соответственно, диапазон адресов. Казалось, это легко поправить: при заходе в V8 мы обновляем ему stack limit на актуальный для текущей корутины. Проблемы с созданием контекста исчезли. Удалось даже успешно провести нагрузочное тестирование, но когда это решение покрутилось неделю на тестовом окружении, я заметил в логах false positive stack overflow от V8.
RangeError: Maximum call stack size exceeded
Эта ошибка тоже связана со stack limit. Не припомню, сколько раз я думал, что поборол эту проблему, но она постоянно продолжала вылезать. Даже v8:: Isolate: SetStackLimit (1) не помог, хотя должен был полностью убить эту проверку. Но если бы помог, проверка все равно нужна, иначе можно положить сервис бесконечной рекурсией в JS.
Отладка с дебажным V8 не дала результатов: исключение генерируется внутри сгенерированного JIT’ом машинного кода, к которому, естественно, нет никаких символов. Если здесь и можно было что-то сделать (а, скорее всего, так и есть) — я не знал, что именно. На поиск решения требовалось время, которого и так было потрачено прилично.
После прохождения через всем известные стадии, добравшись до стадии принятия, я решил полностью поменять подход. Тут и появился js: execution: channel.
Идея была в том, чтобы не делать никаких изменений в воркерах, работающих с V8, не покидать изолят, а вместо этого транслировать ему последовательность операций, переключающих его контексты и состояние в них. А все связанные с ожиданием операции делать как обычно — на корутинах клиентского кода (ручек).
На стороне плюсов пришлось проапгрейдить Future-Promise до channel, который по сути представляет собой аналог unbuffered channel из Go. Теперь можно писать так:
function* do_stuff() {
// doing stuff
yield {/*http params*/};
let response = get_response();
// doing stuff after net
}
Такой код через yield передаёт в канал данные.
channel::Out ch = js.Execute(std::move(task));
for (UserType value: ch.Iterate()) {
// context — это некий разделяемый с Task объект,
// через который можно аффектить таску.
// Здесь он используется для проброса ответа обратно в JS.
// Пока мы находимся здесь, воркер может свободно выполнять
// другие таски.
context.response = client.Get(value.http_params);
}
Проблемы с указателями на стек исчезли. Система стала безопаснее за счёт отсутствия надобности в ручном выходе и входе в V8, стала выглядеть логичнее. Бонус: у нас сохраняется разделение на код с ожиданиями и без них. То есть измерив время на исполнение таски воркером, мы получим CPU busy time, в котором нет времени ожидания ответа какой-либо ручки. На основе этого инварианта мы реализовали отслеживание нагрузки на CPU. По нему мы оперативно реагируем и перестаём выполнять неприоритетные вычисления в моменты высокой нагрузки. И, само собой, на основе такой конструкции довольно просто поддержать async await, эта задача у нас в ближайших планах.
Фреймворк онлайн-вычислений (js-pipeline)
Итак, у нас есть фундамент. Мы можем эффективно запускать код на JavaScript, даже умеем приостанавливать его выполнение и продолжать с того же места. Этого более чем достаточно для вынесения отдельных кусков алгоритма в JS-скрипты, что и было сделано и успешно эксплуатировалось около года.
Но мы начали тесно сотрудничать с Едой, важно было переиспользовать в ней технологии Такси, а Еда тоже должна уметь в повышенный спрос. Почему бы нам не использовать свои наработки и там тоже? Параллельно у меня в голове зрело видение генерализованного фреймворка онлайн-вычислений, которое я оформил в небольшую презентацию.
Общее видение
Верхнеуровнево алгоритм вычисления суржа представляет собой последовательность этапов, модифицирующих некий объект (назовём его вывод) с использованием различных источников данных. Например, на этапе расчёта баланса спроса и предложения мы считаем некий коэффициент, используя данные о водителях и клиентах. Коэффициент записывается в вывод и на следующем этапе округляется до нужной точности с помощью настроек из конфигов. После прохождения всех этапов вывод становится результатом работы целого алгоритма.
Это незатейливое восприятие алгоритма и легло в основу дизайна фреймворка. Но, конечно же, с некоторыми изменениями. Хотелось максимально изолировать этапы: например, вместо того чтобы передавать в какой-либо из этапов вывод целиком и уже внутри манипулировать им как угодно, мы решили декларативно ограничивать область влияния этапов и предоставлять им доступ только к выбранным полям. То же касалось и читаемых данных: вместо передачи пары больших объектов, в которых есть всё, мы заранее декларативно определяли, какие конкретно переменные нужны каждому этапу.
В целом процесс проектирования сводился к стремлению как можно больше всего представить в декларативном виде вместо императивного. Можно сказать, что это было стремление добавить статику как в TypeScript в динамический JavaScript. Но если TypeScript делает это на уровне грамматики языка, то мы это делаем на уровне среды выполнения JS-кода (но, конечно, было бы неплохо однажды поддержать TS, и у нас уже есть идеи, как расширить список поддерживаемых языков). Во время проектирования появились основные сущности:
- Домен — это некий контекст, содержащий произвольные данные в JSON-формате, доступные алгоритму. Все домены живут на протяжении работы алгоритма и обладают схемой, описывающей структуру и типы данных. Всего доступных алгоритму доменов три:
- Домен ввода полностью иммутабелен и полностью доступен в самом начале работы алгоритма. Обычно это тело запроса ручки, дёргающей алгоритм.
- Домен ресурсов частично иммутабелен и может расти по мере работы алгоритма. При запросе внешних ресурсов они добавляются в этот домен, но после добавления их содержимое нельзя изменить.
- Домен вывода полностью мутабелен. Это наш вывод из абзаца выше. Этапы, выполняясь, меняют его содержимое.
- Ресурс. По сути это функтор с уникальным именем, который принимает некоторые параметры и возвращает некоторые данные. Всё. С точки зрения фреймворка остальное неважно, это может быть поход по сети в другой сервис, базу или в локальное хранилище. Пользователю фреймворка нужно описать название ресурса в файле конфигурации, там же описать схему параметров ресурса и схему экземпляра ресурса, а затем реализовать логику получения экземпляра (инстанцирования) ресурса в C++-коде своего микросервиса. При этом у пользователя есть доступ к любым компонентам этого микросервиса.
- Выражения ввода — это набор операций доступа к JSON-объекту. В выражении ввода можно обратиться к любому из трёх доменов. Например:
JSON {"foo": {"bar": 42}}Тогда, чтобы получилось число 42, выражение может иметь вид:
std::vector{StaticAccess{/*property=*/"foo"},DynamicAccess{/*expression=*/"key", /*alias=*/"arg"}}, гдеkey === "bar"Выражения генерируют набор переменных (аргументов), которые доступны в следующих выражениях, условиях, коде и выражении вывода. Так, если вернуться к нашему примеру, переменная key должна быть порождена предыдущим выражением, а само наше выражение породит новую переменную с именем arg и значением 42.
Есть два способа определения выражений ввода: текстовый и объектный. Выше был приведён объектный способ, в текстовом виде будет просто — foo.bar. Объектный способ больше подходит для веб-интерфейса с интерактивным заполнением, в то время как текстовый мы обычно используем для написания тестов.
Всё это может выглядеть довольно сложно, но только на первый взгляд. Описанное выше поведение — по сути то же, что и объявление переменных на стеке.
- Выражения вывода — похожи на выражения ввода, но если те используются для чтения данных из любых доменов, то выражения вывода напротив — для записи только в домен вывода.
- Набор ресурсов — это мапа вида {<поле ресурса>: <имя ресурса>}, где имя ресурса — то, что указывается при добавлении ресурса, а поле ресурса — это property, по которому можно обращаться к экземпляру ресурса через выражения ввода. По сути набор ресурсов представляет собой связку вида {переменная: тип}.
- Условия. Можно определить условия, проверяющие статус этапа или использующие так называемые предикаты: функции на JS с boolean результатом. Подробнее останавливаться на предикатах не буду, так как они не входят в базовую функциональность.
- Этап — блок, из которых состоит алгоритм. Содержит в себе выражения ввода, условия, выражения вывода (или набор ресурсов) и код на JavaScript. Есть два основных типа этапов: логический этап и этап получения ресурсов. Прежде чем выполнить тело этапа (код), нужно, чтобы его условие (если оно есть) вернуло true.
- Логический этап — результатом работы такого этапа являются изменения в домене вывода. У этого типа этапов есть выражения вывода.
- Этап получения ресурсов — результатом его работы является наличие новых экземпляров в домене ресурсов. У этого типа этапов есть набор ресурсов.
- Алгоритм (он же пайплайн) — последовательность этапов разных типов.
Пример
Посмотрим, как взаимодействуют описанные сущности, на примере простого алгоритма, состоящего из одного логического этапа. Представим, что у нас магазин автозапчастей, и нам нужно вернуть список с именами запчастей и ценами на них. Имена вместе с идентификаторами мы уже собрали, ресурс цен у нас в распоряжении, осталось обогатить данные ценами.
Что произошло в коде? Мы проитерировались по домену вывода вложенным циклом и для каждой детали получили её цену. Здесь бизнес-логики нет — мы просто возвращаем полученную цену как результат, который поместит выражение вывода рядом с остальными полями детали.
Конечно, всё это накладывает ограничения на структуру алгоритма, но, как известно, из ограничений рождаются возможности. Так мы намного больше знаем о структуре алгоритма ещё до его выполнения, что даёт нам возможность реализовать множество разных проверок, например, проверку по схеме данных, придать алгоритму единую, чёткую структуру и в будущем реализовать некоторые концепции на уровне фреймворка.
При этом каждый логический этап атомарен: если в процессе модификации вывода произошла ошибка, то все предыдущие модификации, порождённые данным этапом, будут забыты.
С этим видением мы пошли на техническое/архитектурное ревью, где нам дали добро на реализацию задуманного, и началась активная фаза разработки.
Реализация
Первая дилемма, представшая перед нами: реализовывать ли декларативные механизмы для конкретного алгоритма (выражения ввода/вывода, условия) посредством C++ или с помощью генерации JS-кода. В случае с C++ нужно написать интерпретатор декларативных механизмов. В случае с JS больше свободы: можно вместо интерпретатора написать компилятор этих декларативных механизмов, превращающий их в код на JS, который и будет обеспечивать поведение, описываемое конкретным механизмом при конкретных настройках.
Теперь по поводу производительности. На одной чаше весов было нежелание порождать лишние перебросы данных из JS в плюсы и обратно, а на другой — производительность плюсов относительно JS. То есть при выборе C++ у нас больше накладные расходы, но выполнение после оплаты этих накладных расходов быстрее, а при выборе JS — наоборот.
Вопрос сложности реализации тоже неочевидный. С одной стороны, реализовать фреймворк на плюсах сложнее, так как нет возможности генерировать код для конкретного алгоритма и придётся писать универсальный код. С другой стороны, чтобы генерировать JS-код, нужно сначала написать инструментарий — шаблонного движка в C++ у нас не было.
Взвесив все за и против, мы решили пойти путём генерации JS-кода, поскольку такое решение проще расширять, а по производительности мне сильно не нравилось, что объём пересылаемых данных между JS и C++ потенциально мог сильно зависеть от того, как составлены выражения ввода.
Наши микросервисы на C++ состоят из компонентов. Каждый компонент может совершать действие при старте микросервиса, перед его завершением и периодически в процессе работы. В системе выделилось три таких компонента:
- compilation — отвечает за компилирование алгоритма (из JSON в JS),
- execution — отвечает за выполнение алгоритма,
- resource_management — отвечает за получение ресурсов и всё, что с ними связано.
Во время разработки мы столкнулись с несколькими проблемами и нашли для них решения:
- Баги. В JS мы лишаемся статической типизации, из-за чего намного проще посадить баг, который доберётся до прода.
Решение: запуск на реальных данных в фоновом режиме. У нас есть возможность запускать алгоритм параллельно в фоне на части географии на реальной нагрузке, и мы это делаем перед раскаткой в прод. В то же время можно собрать данные о поведении алгоритма на реальных данных и проверить адекватность его результатов. Кроме того, у нас есть возможность откатиться на предыдущую версию если что-то пойдёт не так, о чём мы узнаем по автоматическим мониторингам.
Решение в процессе реализации: возможность покрывать алгоритм тестами. Эта фича в данный момент в разработке, и основная часть уже готова.
- В процессе модификации вывода возникает ошибка и вывод остаётся в неконсистентном состоянии.
Решение: домен вывода должен быть атомарным, то есть все изменения происходят внутри транзакции. Для того чтобы их запомнить, нужно дёрнуть commit (), а чтобы забыть — rollback ().
- Один из этапов выкидывает исключение. На уровне фреймворка специфика этапа неизвестна, из-за чего непонятно, как обрабатывать его исключения.
Решение: вводим понятие failable. Если пользователь пометил этап как failable, то при компиляции он обернётся в блок try/catch, и ошибка будет записана в лог.
- Необходим доступ к несуществующему полю в JS. Например, нам нужно умножить число на некий коэффициент, находящийся в каком-то объекте, и мы пишем a * obj.b; при b === undefined. JS не выкинет тут исключение, а вместо этого тихо вернёт NaN, который, словно зараза, обратит результаты всех последующих выражений со своим участием в себе подобные.
Решение: не пропускать из JS значения NaN, Infinity, undefined. Да, это далеко от идеала, но так проблема будет л
