V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений

в 6:50, , рубрики: c++, javascript, V8, Анализ и проектирование систем, Блог компании Яндекс, высокая производительность, команда яндекс.такси, Проектирование и рефакторинг
В этой статье я расскажу о долгом путешествии, в котором простая идея выноса в JavaScript часто меняющихся фрагментов алгоритма постепенно выросла в универсальный фреймворк, позволяющий быстро создавать микросервисы и так же быстро их развивать. Сейчас он служит основой для множества микросервисов в Яндекс Go. Тут не будет много специфики Go. Вместо этого будет много разработки и решений технических задач (а не продуктовых). Ещё я, конечно, расскажу про возникшие в процессе трудности: если вам, например, интересно, как V8 уживается с корутинами или как мы оптимизировали работу с ним для производительности, то добро пожаловать под кат.

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 1

Когда я целых три года назад присоединился к команде Яндекс.Такси, то начал заниматься поддержкой сервиса расчёта повышенного спроса на такси (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 и возникает извечный вопрос: «Почему бы не использовать уже зрелое решение вместо того, чтобы писать свой велосипед?»

Вот причины:

  1. Важно понять: мы не преследуем цель сменить C++ на JS. Мы хотим получить возможность менять и дополнять алгоритм работы микросервиса во время его работы, не прибегая к выкатке.
  2. У нас нет инфраструктуры Node.js. То есть пришлось бы заново настраивать CI, процесс выкатки, настройки окружения, заводить отдельный репозиторий и, скорее всего, решать множество других моментов.
  3. У нас развитая кодовая база, написанная на 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 строк.

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 2

Получилось менее тысячи 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 кэша. После реализации кэширования график выглядит так:

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 3

Почти десять тысяч 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<v8::Value> Execute()). Оба этих метода вызываются внутри среды V8 (внутри изолята, контекста и HandleScope).

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 <typename T>
  const T* As() const {
    static_assert(std::is_base_of_v<MixinInterface, T>, "invalid T");
    return dynamic_cast<const T*>(this);
  }

  template <typename T>
  T* As() {
    static_assert(std::is_base_of_v<MixinInterface, T>, "invalid T");
    return dynamic_cast<T*>(this);
  }

  /**
   * @brief Получить JS код
   */
  virtual const std::string& GetScript() const = 0;

  /**
   * @brief Получить имя таски — будет использоваться для логирования и тому подобного.
   */
  virtual const std::string& GetName() const = 0;
};

template <typename... Mixins>
struct Interface : Base, public Mixins... {
  static_assert((std::is_base_of_v<MixinInterface, Mixins> && ...),
                "некорректный интерфейс-миксин");
  static_assert((std::is_base_of_v<ExecutionMixinInterface, Mixins> + ... + 0) > 0,
                "не выбрана стратегия выполнения");
  static_assert((std::is_base_of_v<ExecutionMixinInterface, Mixins> + ... + 0) <= 1,
                "более чем одна стратегия выполнения");
  static_assert((std::is_base_of_v<CachingMixinInterface, Mixins> + ... + 0) <= 1,
                "более чем одна стратегия кэширования");
};

/**
 * @brief Обычное выполнение таски.
 * @details Выполнили Execute, уничтожили таску, распарсили её ответ,
 *  вернули его в клиентский код.
 */
struct OneOffExecution : ExecutionMixinInterface {
    virtual v8::Local<v8::Value> 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<v8::Value> Execute() = 0;
};

/**
 * @brief Упрощённое (обёрнутое) выполнение таски как генератора.
 * @details Дёргаем один раз GetGenerator, который обязан вернуть JS-объект Generator,
 *  и дальше вызываем его JS-функцию next(), пока он не вернёт is_done. === false
 */
struct JsGeneratorExecution : MultiExecution {
  virtual v8::Local<v8::Object> GetGenerator() const = 0;

  bool IsDone() const final;
  v8::Local<v8::Value> 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<OneOffExecution, MemoryCaching> {
  public:
    v8::Local<v8::Value> Execute() const override;
    CacheItemPtr GetCacheItem() const override;
    void Initialize() const override;
};

Когда таска создана, она помещается в очередь, которую разгребают ранее упомянутые JS-воркеры. В них находится цикл, вытаскивающий из очереди и исполняющий новые таски.

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 4

А что, если 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<UserType>(std::move(task));
UserType value = future.Get(timeout);

Однако со временем стало понятно, что этого недостаточно. Невозможно было произвести какие-либо операции, связанные с ожиданием (типичный пример: поход по сети) и после них продолжить выполнение JS-кода. Внутри самого воркера ждать нельзя, их у нас столько же, сколько ядер, и ему в это время надо обрабатывать другие таски. Наращивать число воркеров плохо: изолят — довольно увесистая сущность, она тратит десятки мегабайт оперативки, и лишние потоки усложняют планировщику ОС жизнь. Ради этого мы в том числе и перешли в своё время на корутины. Остаётся только научиться прерывать выполнение JS-кода, выходить из контекста V8 с его сохранением и возвращаться в него, продолжая с того же места. Для этого как раз подходят генераторы JS.

Сначала мы попытались уместить асинхронные операции внутри воркера. То есть на потоке воркера для каждой новой таски создаем новую корутину, в которой работаем как обычно. Но поскольку у нас есть отдельная корутина, мы можем, предварительно покинув V8, делать наши not wait-free-процессы. В этот момент, поскольку корутина перешла в ожидание, поток переключится на другую, готовую к выполнению корутину. В ней мы заходим обратно в V8 и продолжаем работу. Выше я сказал, что так делать нельзя, но расчёт был на то, что мы знаем, когда произойдет переключение, и выходим из V8, при этом с движком по-прежнему работает тот же поток. Должно получиться — в теории. Но на практике…

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 5

Первое, что я получил, — это, конечно, 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<UserType>(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.
    • Логический этап — результатом работы такого этапа являются изменения в домене вывода. У этого типа этапов есть выражения вывода.
    • Этап получения ресурсов — результатом его работы является наличие новых экземпляров в домене ресурсов. У этого типа этапов есть набор ресурсов.
  • Алгоритм (он же пайплайн) — последовательность этапов разных типов.

Пример

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

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 6

Что произошло в коде? Мы проитерировались по домену вывода вложенным циклом и для каждой детали получили её цену. Здесь бизнес-логики нет — мы просто возвращаем полученную цену как результат, который поместит выражение вывода рядом с остальными полями детали.

Конечно, всё это накладывает ограничения на структуру алгоритма, но, как известно, из ограничений рождаются возможности. Так мы намного больше знаем о структуре алгоритма ещё до его выполнения, что даёт нам возможность реализовать множество разных проверок, например, проверку по схеме данных, придать алгоритму единую, чёткую структуру и в будущем реализовать некоторые концепции на уровне фреймворка.

При этом каждый логический этап атомарен: если в процессе модификации вывода произошла ошибка, то все предыдущие модификации, порождённые данным этапом, будут забыты.

С этим видением мы пошли на техническое/архитектурное ревью, где нам дали добро на реализацию задуманного, и началась активная фаза разработки.

Реализация

Первая дилемма, представшая перед нами: реализовывать ли декларативные механизмы для конкретного алгоритма (выражения ввода/вывода, условия) посредством 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. Да, это далеко от идеала, но так проблема будет локализована в рамках одного этапа. Можно было бы повесить везде колбеки на доступ к несуществующему полю и кидать исключение, но мы решили так не делать, так как это помешает писать код в обычном JS-стиле ( if (obj.b) ).

Компонент compilation

Если взять наш предыдущий пример про магазин автозапчастей, то после компиляции JS-код будет выглядеть так:

"use strict";
function* __pipeline_perform__(__ctx__) {
  function __safe_get__(value, key) {
    if (typeof value === 'object' && value !== null && key in value) {
      return value[key];
    }
    return undefined;
  }
  {
    // [logic stage] add_prices
    let __stages_meta__ = __ctx__.__sys__.__stages_meta__;
    // set status "omitted" by default
    __stages_meta__.add_prices = { status: 1 };
    let __stage_meta__ = __stages_meta__.add_prices;
    try {
      __set_logging_scope__("add_prices");
      let __stage_context__ = { __iteration_idx__: 0 };
      // [in binding] access value
      let __value0__ = __safe_get__(__ctx__.__output__, "car");
      for (let section in __value0__ || {}) {
        __stage_context__.section = section;
        let __value1__ = __safe_get__(__safe_get__(__safe_get__(__ctx__.__output__, "car"), section), "parts");
        for (let part_idx in __value1__ || {}) {
          __stage_context__.part_idx = part_idx;
          let part_number = __safe_get__(__safe_get__(__value1__, part_idx), "part_number");
          let part_price = __safe_get__(__safe_get__(__ctx__.__resource__, "prices"), part_number);

          const __tx_payload__ = (function () {
            __set_logging_region__("user_code", __stage_context__);
            // [logic stage] user code begin
return {
  price: part_price,
};
            // [logic stage] user code end
          })();
          if (typeof __tx_payload__ !== 'object') {
            throw 'returned "' + typeof __tx_payload__ + '", but only "object" allowed. Value: ' + __tx_payload__;
          }
          if (__tx_payload__ !== null) {
            __set_logging_region__("out_bindings", __stage_context__);
            let stage_outs = new Set(['price']);
            for (let alias in __tx_payload__) {
              if (!stage_outs.has(alias)) {
                throw 'no out named "' + alias + '"';
              }
            }
            if (stage_outs.length > Object.keys(__tx_payload__).length) {
              throw 'expected more outputs than provided';
            }
            (function (__root_object__, __value_to_set__) {
              // [out binding] access value
              // [out binding] assignment
              __root_object__.car[section].parts[part_idx].price = __value_to_set__;
              return;
            })(__ctx__.__output__.__trx__, __tx_payload__['price']);
          }
          ++__stage_context__.__iteration_idx__;
          // set status "passed"
          __stage_meta__.status = 0;
        }
      }
      // apply changes in output bindings
      __ctx__.__output__.__commit__();
    } finally {
      __unset_logging_scope__();
    }
  }
  return { __sys__: __ctx__.__sys__ };
}

Тут есть мелочи вроде __stage_context__, о которых я не упоминал ранее, потому что они не являются частью основной функциональности и не важны для понимания принципов работы системы. (Почти) все идентификаторы системных переменных начинаются и заканчиваются двумя нижними подчёркиваниями. В пользовательском коде мы никогда не именуем так переменные, чтобы избежать коллизий имён.

Optional chaining недоступен в нашей версии движка, а нам было необходимо, чтобы внутри кода выражений ввода не возникало исключений, и даже если бы мы, например, обратились с выражением a.b.c (при том, что a — это пустой объект), мы бы получили undefined. На этот случай здесь есть __safe_get__. В __ctx__ содержатся объекты всех доменов:

  • __input__ — обычный JS-объект, за исключением того, что у него запрещены модификации.
  • __resource__ — простая обёртка над мапой вида {“<поле ресурса>”: <экземпляр ресурса>}, где экземпляр ресурса — это обычно простой иммутабельный JS-объект, как __input__.
  • __output__ — по своему устройству самый сложный. Можно сказать, что это основной элемент рантайма алгоритма. Внутри представляет собой C++-модель JSON с поддержкой транзакций, историей изменений и проверками по схеме.

Также вы могли заметить, что пользовательский код написан без отступов. Это сделано, чтобы при возникновении ошибки мы получали правильный с точки зрения пользователя column места возникновения ошибки. Row мы вычисляем отдельно — так, если бы на строчке с return возникло (каким-то неведомым образом) исключение, то в логах появилось бы название этапа (add_prices) и место в коде 0:0, а не 31:0, как изначально вещает V8.

Компонент execution

Компонент, с которым в основном взаимодействует ручка, использующая сервис. По большому счёту тут особо рассказывать нечего, так как основную часть работы берёт на себя js::execution.

Компонент resource_management

Выполняет роль реестра, хранящего в себе все доступные ресурсы, по запросу может инстанцировать ресурс(ы) и вернуть результат.

Автоматические логи

В описании объекта домена __output__ я упомянул, что он хранит историю изменений, и нужно это именно для того, о чём сейчас пойдёт речь. Нередко разные этапы меняют одно и то же значение. Классический для нас пример — различные коррекции коэффициентов. Разработчик, который пишет код коррекции, должен сделать так, чтобы в логах содержалась информация о влиянии коррекции на вывод.

Однако в случае нашего фреймворка за счёт структуры в виде этапов у нас есть возможность автоматизировать по крайней мере часть рутинного написания логов, и мы этой возможностью воспользовались. Автоматические логи, генерируемые в процессе работы алгоритма, представляют собой JSON.

Вновь используем наш пример с автозапчастями, только представим, что в коде этапа перед return мы добавили log.info(`adding price ${part_price}`);. Это не обязательно для того, чтобы авто-логи сработали, я просто хочу заодно показать, как мы обрабатываем обычные пользовательские самописные логи. В этом случае всё будет выглядеть примерно так:

{
  "$meta": [
    {
      "$iteration": 0,
      "$logs": [
        {
          "$timestamp": "2020-05-27T14:03:10+0000",
          "$level": "info",
          "$message": "adding price 2000",
          "$region": "user_code"
        }
      ],
      "$stage": "add_prices"
    },
    {
      "$iteration": 1,
      "$logs": [
        {
          "$timestamp": "2020-05-27T14:03:10+0000",
          "$level": "info",
          "$message": "adding price 4000",
          "$region": "user_code"
        }
      ],
      "$stage": "add_prices"
    },
    {
      "$iteration": 2,
      "$logs": [
        {
          "$timestamp": "2020-05-27T14:03:10+0000",
          "$level": "info",
          "$message": "adding price 5000",
          "$region": "user_code"
        }
      ],
      "$stage": "add_prices"
    },
    {
      "$iteration": 3,
      "$logs": [
        {
          "$timestamp": "2020-05-27T14:03:10+0000",
          "$level": "info",
          "$message": "adding price 1500",
          "$region": "user_code"
        }
      ],
      "$stage": "add_prices"
    }
  ],
  "$pipeline_name": "habr_example",
  "car": {
    "body": {
      "parts": [
        {
          "name": "fender",
          "part_number": 12,
          "price": {
            "$history": [
              {
                "$meta_idx": 0,
                "$value": 2000
              }
            ]
          }
        },
        {
          "name": "front bumper",
          "part_number": 20,
          "price": {
            "$history": [
              {
                "$meta_idx": 1,
                "$value": 4000
              }
            ]
          }
        },
        {
          "name": "rear bumper",
          "part_number": 21,
          "price": {
            "$history": [
              {
                "$meta_idx": 2,
                "$value": 5000
              }
            ]
          }
        }
      ]
    },
    "engine": {
      "parts": [
        {
          "name": "intake manifold",
          "part_number": 50,
          "price": {
            "$history": [
              {
                "$meta_idx": 3,
                "$value": 1500
              }
            ]
          }
        }
      ]
    }
  }
}

Посмотрим, из чего состоят логи:

  • $meta — каждый элемент в этом массиве соответствует одному выполнению пользовательского JS-кода этапа. Это можно видеть по $iteration — на каждой итерации выполняется код.
  • $meta.*.$iteration — показывает, на какой итерации внутри одного этапа был создан элемент.
  • $meta.*.$logs — массив с пользовательскими логами, записанными в процессе выполнения кода.
  • $meta.*.$stage — имя этапа.
  • car — JSON-дерево пользовательских данных с историей изменения в листьях.
  • **.$history — массив с историей изменения значения, начиная с самого первого значения и до самого последнего.
  • **.$history.$value — присвоенное значение.
  • **.$history.$meta_idx — индекс в массиве $meta, указывающий на запись, соответствующую этапу и его итерации, в рамках которой было присвоено значение $value.

В действительности все значения, не являющиеся словарём или массивом, заменяются на объект с $history внутри, так как домен вывода на старте работы алгоритма всегда пустой. Но ради упрощения примера я заранее положил в него данные.

Эта структура была спроектирована так, чтобы логи не содержали дублирующуюся информацию, но при этом любое изменение в JSON вывода можно было сопоставить с конкретным этапом и пользовательскими логами, записанными внутри него.

Все изменения на уровне целых объектов или массивов отображаются как изменения в листьях. Если бы мы вместо добавления price в существующий объект меняли этот объект целиком на тот, в котором price уже есть, логи выглядели бы по сути так же.

Это решение было принято с целью упростить восприятие логов, расплющив иерархию изменений, ведь в конце концов словари и массивы — это структура, а содержащиеся в них числа и строки — это конечные данные, и нам не очень важно знать, изменились ли они в результате присваивания значения или из-за замены содержащего их объекта целиком. А если всё же важно, то можно посмотреть, как составлен сам алгоритм.

Ещё это консистентно с поведением выражений ввода — им не важно, отсутствует значение или весь объект целиком, результат один — undefined.

Преимущества перед обычными логами

Под обычными логами я подразумеваю массив строк, поддающихся парсингу и содержащих различную метаинформацию (время, хост, разные полезные id-шники и т. д.), и сообщение, составленное пользователем. JSON-логи, описанные мной, на мой взгляд, имеют перед ними ряд преимуществ:

  • Автоматизация — разработчику достаточно правильно по смыслу разбить свой алгоритм на этапы, и взамен он гарантированно получит логи, где каждое изменение в выводе связано с понятным действием. В нашем примере этап add_prices обогащает данные ценами, после него мог бы идти, например, apply_discounts, который применял бы скидку, и тем самым уменьшал цены. В наших логах такие изменения будут выглядеть максимально наглядно, и разработчику даже не нужно о них задумываться.
  • Улучшение обычных логов — если нам недостаточно сухой истории изменения значения, и мы хотим залогировать, например, почему на пользователя сработала скидка, то наша структура логов тоже поможет. Она помещает самописные логи в нужный контекст, ассоциируя их с интересующим нас изменением в выводе.
  • Связность с данными — в случае архивации логов в БД, как это делаем мы, к ним можно легко обращаться через запросы в БД, поскольку логи привязаны к данным. Благодаря этому становится проще строить аналитику. Например, довольно просто написать запрос, который достоверно скажет, как часто мы в среднем предлагаем скидки в магазине автозапчастей.
  • Наглядность — для наших логов можно создать UI, который, например, отображает итоговый JSON вывода, но с возможностью ткнуть в любое значение и увидеть его «историю становления»: какие этапы на него повлияли и какие логи они в этот момент писали.

Так у нас получилось реализовать достаточно подробное для нашей задачи логирование расчёта суржа.

Система целиком

До этого речь шла только об устройстве библиотеки js-pipeline, которая статично линкуется к микросервису, делающему полезную работу с её помощью. Но я же обещал фреймворк, к тому же нужно откуда-то брать сами алгоритмы и ресурсы и как-то интегрировать это всё в библиотеку. Поэтому поговорим о том, как происходит интеграция с фреймворком.

В конфигурационном YAML-файле разработчик сервиса описывает так называемого потребителя. По сути, потребитель определяет интерфейс алгоритма. Внутри сервиса может быть множество потребителей. У потребителя есть имя, ресурсы, их схемы, схемы ввода и вывода алгоритма. При этом можно ссылаться на любые уже имеющиеся схемы в кодовой базе. Например, если мы напрямую передаём алгоритму на ввод запрос ручки, что довольно частый случай, то можно просто сослаться на схему ручки, которая у нас обязательна, а поэтому есть всегда. Это сильно упрощает процесс конфигурации и избавляет от дублирования.

Разработчик реализует внутри своего сервиса ресурсы на C++ (A и B в нашем примере). Затем при запуске системы сборки плагин на питоне генерирует C++-код на основе упомянутого YAML-файла, который регистрирует ресурсы и все схемы всех потребителей при старте микросервиса.

При этом у микросервиса из коробки появляется вся телеметрия в графане, связанная с выполнением JS и алгоритмов на нём:

— тайминги,
— ошибки и их типы,
— обращения к ресурсам,
— падения необязательных этапов,
— и прочее…

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 7

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 8

Здесь, кстати, можно видеть тайминги работы JS: около 70 мс на 99 prc и около 50 мс на 95 prc. Это учитывая то, что алгоритм компилируется в JS размером 5500 строк, большая часть логических этапов выполняется несколько раз для каждого тарифа, коих в Москве больше 20, и мы пока не занимались глубокой оптимизацией генерируемого JS-кода.

V8 contexts/values count отображает, сколько в данный момент мы закешировали контекстов и значений V8, используя ранее описанный в статье механизм. JS load показывает примерную загруженность CPU машины, он её несколько завышает, но главное, что есть прямая корреляция с реальной нагрузкой. Ниже уже идут метрики уровня фреймворка — тут показаны ошибки при получении ресурсов и падения необязательных этапов.

После запуска бинарника микросервиса на сервере, компонент библиотеки compilation начинает периодически (раз в три минуты) ходить в другой микросервис, единый для всех использующих фреймворк. Этот микросервис владеет базой данных, которая хранит в себе все JS-алгоритмы и является по совместительству бэкендом админки для редактирования этих самых алгоритмов. Компонент в фоне получает из него все релевантные для потребителей микросервиса алгоритмы, компилирует их и хранит внутри себя.

Рассмотрим систему целиком в процессе обработки запроса.

V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений - 9Кликабельно

  1. Получив запрос, сервис обычно использует его для инициализации домена ввода алгоритма. Затем определяет имя алгоритма, который нужно использовать, и с этой информацией обращается к компоненту execution.
  2. Компонент execution идёт в компонент compilation для того, чтобы по переданному ранее имени получить сам скомпилированный алгоритм.
  3. После нахождения нужного алгоритма начинается выполнение его JS-кода.
  4. Если в алгоритме есть этапы получения ресурсов, то в сгенерированном JS-коде будет прерывание (yield). Сформированные в пользовательском JS-коде параметры ресурсов будут переданы соответствующим ресурсам для получения их экземпляров.
  5. После похода за внешними ресурсами продолжается выполнение JS-кода, и по прохождению всех этапов мы возвращаем управление клиентскому коду.
  6. Получив результат из кода библиотеки, сервис использует его, чтобы сформировать ответ, и на этом обработка запроса с использованием js-pipeline завершается.

Планы

Хочу поделиться планами на будущее и идеями развития решения:

  • Автотесты. Как я уже упоминал ранее, мы хотим добавить возможность покрывать алгоритмы тестами в их админке. Это снизит количество попыток запуска алгоритма в фоне. Бывает, что нужно выкатить его раз пять, пока не поправишь все баги. С тестами это было бы намного быстрее и комфортнее.
  • Нативные этапы. В процессе использования фреймворка стало очевидно, что код некоторых этапов меняется довольно редко, и можно было бы выполнять его не на JS, а на C++, пожертвовав гибкостью ради производительности. Это позволит сделать нативные этапы — написать в сервисе функцию на C++ и зарегистрировать её как ресурс, а далее в админке вместо написания кода выбрать эту функцию. Эта фича уже на стадии ревью ПР.
  • Поддержка Wasm. С помощью Wasm можно поддерживать множество других языков
  • Представление в виде блок-схемы. По мере усложнения алгоритма этапы обрастают неявными семантическими связями. Например, один этап может сильно зависеть от другого, и если тот этап не выполнился, то нельзя выполнить и другой, так как они являются частями одной и той же продуктовой фичи. Прописать их связь легко и сейчас, создав условие на статус этапа, но это не очень наглядно, когда твой алгоритм состоит из 65 этапов, как в случае алгоритма расчёта суржа Такси. А в блок-схеме можно отобразить все связи и более того — нарисовать диаграмму влияния этапов на основе выражений ввода/вывода, соединив этап, который обращается к данным, с тем, который в эти данные пишет.

Выводы

Мы добавили в наш арсенал инструмент, позволяющий быстро создавать легко эволюционирующие микросервисы, обладающие из коробки свойствами взрослых микросервисов: детальными логами и телеметрией. При этом приступать к разработке и введению в эксплуатацию микросервиса в некоторых случаях можно ещё до полного понимания того, как он будет работать внутри. Достаточно понимать, какими данными он будет оперировать, а алгоритм описать уже после того, как микросервис будет готов. Так мы можем быстро создавать прототипы и улучшать их.

Сейчас у нас зарегистрировано шесть потребителей из Еды и Лавки (помимо нас самих). При этом напрямую поддержкой микросервисов этих сервисов мы не занимаемся, имеем к ним отношение лишь опосредованно, поскольку при добавлении новой фичи в js-pipeline она автоматически становится доступна всем потребителям.

Нам удалось снизить нагрузку по продуктовым задачам, так как аналитики могут напрямую редактировать алгоритмы на JS, что они активно и делают (даже чаще, чем мы). А мы в этом случае выступаем в роли ревьюера.

В то же время фреймворк задаёт архитектуру внутри микросервиса как на стороне C++, так и на JS:

  • C++-код декомпозируется посредством абстракции «ресурс». В неё инкапсулируются все внешние источники данных и процедура их предварительной обработки перед доставкой алгоритму.
  • JS-код декомпозируется посредством абстракции «этап». Она инкапсулирует в себе некое полезное действие, результат которого состоит в изменении внутри вывода.

В итоге мы получили множество в той или иной мере изолированных кусков кода, которые проще поддерживать. На мой взгляд, получившееся решение — хороший пример того, как из ограничений появляются возможности, ведь можно было не придумывать все эти этапы и ресурсы, а написать алгоритм как есть, полностью на JS, получив при этом больше свободы. Но, как известно, у всего есть своя цена — и, по-моему, у нас получилась неплохая сделка. Буду рад, если вы поделитесь в комментариях своими мыслями по поводу прочитанного — с удовольствием отвечу и поучаствую в дискуссии.

Автор: Ислам Бозиев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js