В этой статье я расскажу о долгом путешествии, в котором простая идея выноса в JavaScript часто меняющихся фрагментов алгоритма постепенно выросла в универсальный фреймворк, позволяющий быстро создавать микросервисы и так же быстро их развивать. Сейчас он служит основой для множества микросервисов в Яндекс Go. Тут не будет много специфики Go. Вместо этого будет много разработки и решений технических задач (а не продуктовых). Ещё я, конечно, расскажу про возникшие в процессе трудности: если вам, например, интересно, как V8 уживается с корутинами или как мы оптимизировали работу с ним для производительности, то добро пожаловать под кат.
![](https://habrastorage.org/r/w1560/webt/ta/bz/n1/tabzn1lmliae2ou_muvvmn6shns.png)
Когда я целых три года назад присоединился к команде Яндекс.Такси, то начал заниматься поддержкой сервиса расчёта повышенного спроса на такси (surge pricing, сурж). Почитать про него можно здесь. Этот сервис был написан на C++, а алгоритм расчёта представлял собой cpp-файл объёмом более чем 3000 строк со сложно переплетёнными ссылками на объекты в стеке, без чёткой структуры. Не то чтобы это был жуткий спагетти-код, но чтобы в нём разобраться, требовалось немало времени. Также возникала проблема с логированием — периодически поднимался вопрос: «А сможем ли мы для конкретного заказа ответить, почему мы получили именно такой коэффициент?» Ответ был: «Да, но надо будет покопаться в логах и данных».
Конечно, в разы проще было бы решить эти проблемы обычным рефакторингом. Но кроме них были и другие, более фундаментальные.
Алгоритм расчёта суржа постоянно эволюционирует: меняются формулы, появляются новые, добавляются источники данных, коррекции, коэффициенты. Процесс внесения правок в алгоритм выглядел примерно так:
Если мы всего лишь хотели поэкспериментировать, поменяв небольшую часть алгоритма, и оценить поведение системы, такой процесс является слишком громоздким и трудозатратным. Возможность писать изолированный код без необходимости выкатки выглядела разумным шагом для ускорения цикла разработки.
Поэтому, недолго обсудив, мы решили использовать опенсорсный JS-движок V8. Основной аргумент: он уже использовался коллегами в другом сервисе, который находился в том же репозитории, что и наш. Поэтому многое можно было переиспользовать. Ну и, конечно, это обеспечивало скорость. Наш сервис чувствителен к таймингам, так как вызывается синхронно при открытии приложения для расчёта цены. Отсюда и нагрузка, исчисляемая тысячами RPS на ручку расчёта суржа. Но поскольку мы были первыми внутри Такси, кому понадобилось использовать V8 в требовательном для производительности месте, пришлось кое-что придумывать.
Это наверное первый вопрос, который я услышал от коллег после моего рассказа о внедрении V8 в наш плюсовый бэкенд. Когда речь заходит об использовании JavaScript в бэкенде, то на ум практически каждому приходит Node.js и возникает извечный вопрос: «Почему бы не использовать уже зрелое решение вместо того, чтобы писать свой велосипед?»
Вот причины:
Для начала нам надо научиться выполнять JS-код и делать это максимально быстро. Это самостоятельная часть статьи, в которой будет описано, как мы, начиная с std::string с 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 строк.
![](https://habrastorage.org/r/w1560/webt/di/nu/ny/dinunyeaxe8y8tmtlcaioabzkoi.png)
Получилось менее тысячи RPS, а до этого мы без проблем держали десять тысяч. Так не пойдёт, нужно кэшировать контекст.
Мы хотим выполнить код на JavaScript, лежащий в самописном in-memory-кэше, который периодически обновляется. В момент этого обновления мы и хотим инвалидировать v8::Context. Реализуем примитивную систему кэширования. Пусть в качестве ключа она использует адрес в памяти, который идентифицирует экземпляр данных. Для этого я определил интерфейс с двумя методами:
Наши in-memory-кэши хранят состояние по std::shared_ptr, периодически заменяя старое состояние новым и при этом прекращая владеть старым состоянием. Поэтому в записях кэша состояний V8 мы просто храним std::weak_ptr, через который и работает IsExpired. А в качестве ключа мы просто используем адрес нужной сущности внутри состояния нашего in-memory кэша. После реализации кэширования график выглядит так:
![](https://habrastorage.org/r/w1560/webt/aa/uh/hq/aauhhqwbj7cjbyvx0xm6wgt9z_m.png)
Почти десять тысяч RPS. Уже лучше.
Этот подход работает не только для кэширования контекста V8, но и для кэширования любых загружаемых в движок данных, которые редко меняются. Понимание того, что данные не привязаны к контексту, пришло не сразу, но v8::Value принадлежит не v8::Context, a v8::Isolate. Поэтому множества (кэшированные контексты и кэшированные данные) не перемножаются и могут совмещаться как угодно. Так мы можем очень дёшево загружать довольно большие объёмы редко меняющихся данных. У нас, например, есть объект, содержащий множество настроек алгоритма для текущей геозоны. Он редактируется вручную через админку, следовательно — меняется редко и для него кэширование — то, что доктор прописал.
Думаю, пора ввести в нашу систему новую и, можно сказать, основную сущность — 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) в эти миксины-интерфейсы, чтобы задействовать ту функциональность, которая используется в таске.
Когда таска создана, она помещается в очередь, которую разгребают ранее упомянутые JS-воркеры. В них находится цикл, вытаскивающий из очереди и исполняющий новые таски.
![](https://habrastorage.org/r/w1560/webt/hy/2e/ho/hy2ehodv9e9i0n4jmns2qzzolyq.png)
Внедряя JS в серверную логику, где его раньше и в помине не было, мы в некоторой степени выпускаем джинна из бутылки. Поэтому нам надо его хотя бы как-то контролировать и следить за тем, чтобы он не положил весь сервис бесконечным циклом или рекурсией. Насчет рекурсии: V8 сам умеет её определять, в случае возникновения он выкинет RangeError, так что можно не беспокоиться, верно? Не совсем. Написав тест на бесконечную рекурсию в JS, я с удивлением созерцал segfault. В чём же дело? Вспомним, в какой среде мы работаем с движком, а именно вспомним корутины. Размер их стека меньше, чем у потока, а V8 должен знать его размер, чтобы проверка на переполнение стека работала корректно. По умолчанию V8 рассчитывает, что размер стека будет 1 МБ, у наших корутин — 256 КБ. Есть два метода сообщить движку размер стека: вызвать метод SetStackLimit у изолята или через аргумент командной строки --stack-size. После установки лимита в 192 КБ (он должен быть несколько меньше реального) проверка заработала.
Для принудительного завершения в случае ситуаций вроде бесконечного цикла у изолята можно вызвать метод TerminateExecution, который сгенерирует неуловимое исключение внутри стека JS и тем самым завершит выполнение.
Как можно догадаться, JS выполняется асинхронно относительно ручки, в своём отдельном потоке и корутине. Настало время обсудить, как эти корутины синхронизируются с теми, в которых выполняется ручка.
Имеющиеся примитивы синхронизации не подходили: требовалось реализовать механизм таймаутов с принудительным завершением выполнения и парсинг из v8::Value в C++-модель. Ещё один момент: читать переменные из V8 можно, только пока ты находишься внутри его среды, поскольку чтение происходит из его внутренней кучи. Нельзя просто получить v8::Value и в клиентском коде распарсить его. Нужно это делать заранее и возвращать уже готовый результат. В первой реализации никакого канала не было, а была всем знакомая связка Future-Promise и возможности у нее были стандартные (Promise::Set(), Future::Get()/Wait()). Вызов выглядел примерно так:
Однако со временем стало понятно, что этого недостаточно. Невозможно было произвести какие-либо операции, связанные с ожиданием (типичный пример: поход по сети) и после них продолжить выполнение JS-кода. Внутри самого воркера ждать нельзя, их у нас столько же, сколько ядер, и ему в это время надо обрабатывать другие таски. Наращивать число воркеров плохо: изолят — довольно увесистая сущность, она тратит десятки мегабайт оперативки, и лишние потоки усложняют планировщику ОС жизнь. Ради этого мы в том числе и перешли в своё время на корутины. Остаётся только научиться прерывать выполнение JS-кода, выходить из контекста V8 с его сохранением и возвращаться в него, продолжая с того же места. Для этого как раз подходят генераторы JS.
Сначала мы попытались уместить асинхронные операции внутри воркера. То есть на потоке воркера для каждой новой таски создаем новую корутину, в которой работаем как обычно. Но поскольку у нас есть отдельная корутина, мы можем, предварительно покинув V8, делать наши not wait-free-процессы. В этот момент, поскольку корутина перешла в ожидание, поток переключится на другую, готовую к выполнению корутину. В ней мы заходим обратно в V8 и продолжаем работу. Выше я сказал, что так делать нельзя, но расчёт был на то, что мы знаем, когда произойдет переключение, и выходим из V8, при этом с движком по-прежнему работает тот же поток. Должно получиться — в теории. Но на практике…
![](https://habrastorage.org/r/w780q1/webt/q9/im/w6/q9imw6hv6hzlqrgd56fdfmmi4da.jpeg)
Первое, что я получил, — это, конечно, segfault в недрах V8 при создании контекста V8. Перезапустившись на дебажной сборке V8, я увидел, что дело в указателе на стек. Логично, ведь мы работаем из разных корутин, у которых совершенно разные стеки и, соответственно, диапазон адресов. Казалось, это легко поправить: при заходе в V8 мы обновляем ему stack limit на актуальный для текущей корутины. Проблемы с созданием контекста исчезли. Удалось даже успешно провести нагрузочное тестирование, но когда это решение покрутилось неделю на тестовом окружении, я заметил в логах false positive stack overflow от V8.
Эта ошибка тоже связана со stack limit. Не припомню, сколько раз я думал, что поборол эту проблему, но она постоянно продолжала вылезать. Даже v8::Isolate::SetStackLimit(1) не помог, хотя должен был полностью убить эту проверку. Но если бы помог, проверка все равно нужна, иначе можно положить сервис бесконечной рекурсией в JS.
Отладка с дебажным V8 не дала результатов: исключение генерируется внутри сгенерированного JIT'ом машинного кода, к которому, естественно, нет никаких символов. Если здесь и можно было что-то сделать (а, скорее всего, так и есть) — я не знал, что именно. На поиск решения требовалось время, которого и так было потрачено прилично.
После прохождения через всем известные стадии, добравшись до стадии принятия, я решил полностью поменять подход. Тут и появился js::execution::channel.
Идея была в том, чтобы не делать никаких изменений в воркерах, работающих с V8, не покидать изолят, а вместо этого транслировать ему последовательность операций, переключающих его контексты и состояние в них. А все связанные с ожиданием операции делать как обычно — на корутинах клиентского кода (ручек).
На стороне плюсов пришлось проапгрейдить Future-Promise до channel, который по сути представляет собой аналог unbuffered channel из Go. Теперь можно писать так:
Такой код через yield передаёт в канал данные.
Проблемы с указателями на стек исчезли. Система стала безопаснее за счёт отсутствия надобности в ручном выходе и входе в V8, стала выглядеть логичнее. Бонус: у нас сохраняется разделение на код с ожиданиями и без них. То есть измерив время на исполнение таски воркером, мы получим CPU busy time, в котором нет времени ожидания ответа какой-либо ручки. На основе этого инварианта мы реализовали отслеживание нагрузки на CPU. По нему мы оперативно реагируем и перестаём выполнять неприоритетные вычисления в моменты высокой нагрузки. И, само собой, на основе такой конструкции довольно просто поддержать async await, эта задача у нас в ближайших планах.
Итак, у нас есть фундамент. Мы можем эффективно запускать код на JavaScript, даже умеем приостанавливать его выполнение и продолжать с того же места. Этого более чем достаточно для вынесения отдельных кусков алгоритма в JS-скрипты, что и было сделано и успешно эксплуатировалось около года.
Но мы начали тесно сотрудничать с Едой, важно было переиспользовать в ней технологии Такси, а Еда тоже должна уметь в повышенный спрос. Почему бы нам не использовать свои наработки и там тоже? Параллельно у меня в голове зрело видение генерализованного фреймворка онлайн-вычислений, которое я оформил в небольшую презентацию.
Верхнеуровнево алгоритм вычисления суржа представляет собой последовательность этапов, модифицирующих некий объект (назовём его вывод) с использованием различных источников данных. Например, на этапе расчёта баланса спроса и предложения мы считаем некий коэффициент, используя данные о водителях и клиентах. Коэффициент записывается в вывод и на следующем этапе округляется до нужной точности с помощью настроек из конфигов. После прохождения всех этапов вывод становится результатом работы целого алгоритма.
Это незатейливое восприятие алгоритма и легло в основу дизайна фреймворка. Но, конечно же, с некоторыми изменениями. Хотелось максимально изолировать этапы: например, вместо того чтобы передавать в какой-либо из этапов вывод целиком и уже внутри манипулировать им как угодно, мы решили декларативно ограничивать область влияния этапов и предоставлять им доступ только к выбранным полям. То же касалось и читаемых данных: вместо передачи пары больших объектов, в которых есть всё, мы заранее декларативно определяли, какие конкретно переменные нужны каждому этапу.
В целом процесс проектирования сводился к стремлению как можно больше всего представить в декларативном виде вместо императивного. Можно сказать, что это было стремление добавить статику как в TypeScript в динамический JavaScript. Но если TypeScript делает это на уровне грамматики языка, то мы это делаем на уровне среды выполнения JS-кода (но, конечно, было бы неплохо однажды поддержать TS, и у нас уже есть идеи, как расширить список поддерживаемых языков). Во время проектирования появились основные сущности:
Посмотрим, как взаимодействуют описанные сущности, на примере простого алгоритма, состоящего из одного логического этапа. Представим, что у нас магазин автозапчастей, и нам нужно вернуть список с именами запчастей и ценами на них. Имена вместе с идентификаторами мы уже собрали, ресурс цен у нас в распоряжении, осталось обогатить данные ценами.
![](https://habrastorage.org/r/w1560/webt/ae/0b/bj/ae0bbj_tgega8eyg-sexm_k-z9m.png)
Что произошло в коде? Мы проитерировались по домену вывода вложенным циклом и для каждой детали получили её цену. Здесь бизнес-логики нет — мы просто возвращаем полученную цену как результат, который поместит выражение вывода рядом с остальными полями детали.
Конечно, всё это накладывает ограничения на структуру алгоритма, но, как известно, из ограничений рождаются возможности. Так мы намного больше знаем о структуре алгоритма ещё до его выполнения, что даёт нам возможность реализовать множество разных проверок, например, проверку по схеме данных, придать алгоритму единую, чёткую структуру и в будущем реализовать некоторые концепции на уровне фреймворка.
При этом каждый логический этап атомарен: если в процессе модификации вывода произошла ошибка, то все предыдущие модификации, порождённые данным этапом, будут забыты.
С этим видением мы пошли на техническое/архитектурное ревью, где нам дали добро на реализацию задуманного, и началась активная фаза разработки.
Первая дилемма, представшая перед нами: реализовывать ли декларативные механизмы для конкретного алгоритма (выражения ввода/вывода, условия) посредством C++ или с помощью генерации JS-кода. В случае с C++ нужно написать интерпретатор декларативных механизмов. В случае с JS больше свободы: можно вместо интерпретатора написать компилятор этих декларативных механизмов, превращающий их в код на JS, который и будет обеспечивать поведение, описываемое конкретным механизмом при конкретных настройках.
Теперь по поводу производительности. На одной чаше весов было нежелание порождать лишние перебросы данных из JS в плюсы и обратно, а на другой — производительность плюсов относительно JS. То есть при выборе C++ у нас больше накладные расходы, но выполнение после оплаты этих накладных расходов быстрее, а при выборе JS — наоборот.
Вопрос сложности реализации тоже неочевидный. С одной стороны, реализовать фреймворк на плюсах сложнее, так как нет возможности генерировать код для конкретного алгоритма и придётся писать универсальный код. С другой стороны, чтобы генерировать JS-код, нужно сначала написать инструментарий — шаблонного движка в C++ у нас не было.
Взвесив все за и против, мы решили пойти путём генерации JS-кода, поскольку такое решение проще расширять, а по производительности мне сильно не нравилось, что объём пересылаемых данных между JS и C++ потенциально мог сильно зависеть от того, как составлены выражения ввода.
Наши микросервисы на C++ состоят из компонентов. Каждый компонент может совершать действие при старте микросервиса, перед его завершением и периодически в процессе работы. В системе выделилось три таких компонента:
Во время разработки мы столкнулись с несколькими проблемами и нашли для них решения:
Если взять наш предыдущий пример про магазин автозапчастей, то после компиляции JS-код будет выглядеть так:
Тут есть мелочи вроде __stage_context__, о которых я не упоминал ранее, потому что они не являются частью основной функциональности и не важны для понимания принципов работы системы. (Почти) все идентификаторы системных переменных начинаются и заканчиваются двумя нижними подчёркиваниями. В пользовательском коде мы никогда не именуем так переменные, чтобы избежать коллизий имён.
Optional chaining недоступен в нашей версии движка, а нам было необходимо, чтобы внутри кода выражений ввода не возникало исключений, и даже если бы мы, например, обратились с выражением a.b.c (при том, что a — это пустой объект), мы бы получили undefined. На этот случай здесь есть __safe_get__. В __ctx__ содержатся объекты всех доменов:
Также вы могли заметить, что пользовательский код написан без отступов. Это сделано, чтобы при возникновении ошибки мы получали правильный с точки зрения пользователя column места возникновения ошибки. Row мы вычисляем отдельно — так, если бы на строчке с return возникло (каким-то неведомым образом) исключение, то в логах появилось бы название этапа (add_prices) и место в коде 0:0, а не 31:0, как изначально вещает V8.
Компонент, с которым в основном взаимодействует ручка, использующая сервис. По большому счёту тут особо рассказывать нечего, так как основную часть работы берёт на себя js::execution.
Выполняет роль реестра, хранящего в себе все доступные ресурсы, по запросу может инстанцировать ресурс(ы) и вернуть результат.
В описании объекта домена __output__ я упомянул, что он хранит историю изменений, и нужно это именно для того, о чём сейчас пойдёт речь. Нередко разные этапы меняют одно и то же значение. Классический для нас пример — различные коррекции коэффициентов. Разработчик, который пишет код коррекции, должен сделать так, чтобы в логах содержалась информация о влиянии коррекции на вывод.
Однако в случае нашего фреймворка за счёт структуры в виде этапов у нас есть возможность автоматизировать по крайней мере часть рутинного написания логов, и мы этой возможностью воспользовались. Автоматические логи, генерируемые в процессе работы алгоритма, представляют собой JSON.
Вновь используем наш пример с автозапчастями, только представим, что в коде этапа перед return мы добавили log.info(`adding price ${part_price}`);. Это не обязательно для того, чтобы авто-логи сработали, я просто хочу заодно показать, как мы обрабатываем обычные пользовательские самописные логи. В этом случае всё будет выглядеть примерно так:
Посмотрим, из чего состоят логи:
В действительности все значения, не являющиеся словарём или массивом, заменяются на объект с $history внутри, так как домен вывода на старте работы алгоритма всегда пустой. Но ради упрощения примера я заранее положил в него данные.
Эта структура была спроектирована так, чтобы логи не содержали дублирующуюся информацию, но при этом любое изменение в JSON вывода можно было сопоставить с конкретным этапом и пользовательскими логами, записанными внутри него.
Все изменения на уровне целых объектов или массивов отображаются как изменения в листьях. Если бы мы вместо добавления price в существующий объект меняли этот объект целиком на тот, в котором price уже есть, логи выглядели бы по сути так же.
Это решение было принято с целью упростить восприятие логов, расплющив иерархию изменений, ведь в конце концов словари и массивы — это структура, а содержащиеся в них числа и строки — это конечные данные, и нам не очень важно знать, изменились ли они в результате присваивания значения или из-за замены содержащего их объекта целиком. А если всё же важно, то можно посмотреть, как составлен сам алгоритм.
Ещё это консистентно с поведением выражений ввода — им не важно, отсутствует значение или весь объект целиком, результат один — undefined.
Под обычными логами я подразумеваю массив строк, поддающихся парсингу и содержащих различную метаинформацию (время, хост, разные полезные id-шники и т. д.), и сообщение, составленное пользователем. JSON-логи, описанные мной, на мой взгляд, имеют перед ними ряд преимуществ:
Так у нас получилось реализовать достаточно подробное для нашей задачи логирование расчёта суржа.
До этого речь шла только об устройстве библиотеки js-pipeline, которая статично линкуется к микросервису, делающему полезную работу с её помощью. Но я же обещал фреймворк, к тому же нужно откуда-то брать сами алгоритмы и ресурсы и как-то интегрировать это всё в библиотеку. Поэтому поговорим о том, как происходит интеграция с фреймворком.
В конфигурационном YAML-файле разработчик сервиса описывает так называемого потребителя. По сути, потребитель определяет интерфейс алгоритма. Внутри сервиса может быть множество потребителей. У потребителя есть имя, ресурсы, их схемы, схемы ввода и вывода алгоритма. При этом можно ссылаться на любые уже имеющиеся схемы в кодовой базе. Например, если мы напрямую передаём алгоритму на ввод запрос ручки, что довольно частый случай, то можно просто сослаться на схему ручки, которая у нас обязательна, а поэтому есть всегда. Это сильно упрощает процесс конфигурации и избавляет от дублирования.
Разработчик реализует внутри своего сервиса ресурсы на C++ (A и B в нашем примере). Затем при запуске системы сборки плагин на питоне генерирует C++-код на основе упомянутого YAML-файла, который регистрирует ресурсы и все схемы всех потребителей при старте микросервиса.
При этом у микросервиса из коробки появляется вся телеметрия в графане, связанная с выполнением JS и алгоритмов на нём:
— тайминги,
— ошибки и их типы,
— обращения к ресурсам,
— падения необязательных этапов,
— и прочее…
![](https://habrastorage.org/r/w1560/webt/f8/lt/k5/f8ltk5_9pbpwy5nuioclfbsoc3s.png)
![](https://habrastorage.org/r/w1560/webt/ik/bz/ww/ikbzwwkaeqnnohodxaycmflbkvo.png)
Здесь, кстати, можно видеть тайминги работы JS: около 70 мс на 99 prc и около 50 мс на 95 prc. Это учитывая то, что алгоритм компилируется в JS размером 5500 строк, большая часть логических этапов выполняется несколько раз для каждого тарифа, коих в Москве больше 20, и мы пока не занимались глубокой оптимизацией генерируемого JS-кода.
V8 contexts/values count отображает, сколько в данный момент мы закешировали контекстов и значений V8, используя ранее описанный в статье механизм. JS load показывает примерную загруженность CPU машины, он её несколько завышает, но главное, что есть прямая корреляция с реальной нагрузкой. Ниже уже идут метрики уровня фреймворка — тут показаны ошибки при получении ресурсов и падения необязательных этапов.
После запуска бинарника микросервиса на сервере, компонент библиотеки compilation начинает периодически (раз в три минуты) ходить в другой микросервис, единый для всех использующих фреймворк. Этот микросервис владеет базой данных, которая хранит в себе все JS-алгоритмы и является по совместительству бэкендом админки для редактирования этих самых алгоритмов. Компонент в фоне получает из него все релевантные для потребителей микросервиса алгоритмы, компилирует их и хранит внутри себя.
Рассмотрим систему целиком в процессе обработки запроса.
Кликабельно
Хочу поделиться планами на будущее и идеями развития решения:
Мы добавили в наш арсенал инструмент, позволяющий быстро создавать легко эволюционирующие микросервисы, обладающие из коробки свойствами взрослых микросервисов: детальными логами и телеметрией. При этом приступать к разработке и введению в эксплуатацию микросервиса в некоторых случаях можно ещё до полного понимания того, как он будет работать внутри. Достаточно понимать, какими данными он будет оперировать, а алгоритм описать уже после того, как микросервис будет готов. Так мы можем быстро создавать прототипы и улучшать их.
Сейчас у нас зарегистрировано шесть потребителей из Еды и Лавки (помимо нас самих). При этом напрямую поддержкой микросервисов этих сервисов мы не занимаемся, имеем к ним отношение лишь опосредованно, поскольку при добавлении новой фичи в js-pipeline она автоматически становится доступна всем потребителям.
Нам удалось снизить нагрузку по продуктовым задачам, так как аналитики могут напрямую редактировать алгоритмы на JS, что они активно и делают (даже чаще, чем мы). А мы в этом случае выступаем в роли ревьюера.
В то же время фреймворк задаёт архитектуру внутри микросервиса как на стороне C++, так и на JS:
В итоге мы получили множество в той или иной мере изолированных кусков кода, которые проще поддерживать. На мой взгляд, получившееся решение — хороший пример того, как из ограничений появляются возможности, ведь можно было не придумывать все эти этапы и ресурсы, а написать алгоритм как есть, полностью на JS, получив при этом больше свободы. Но, как известно, у всего есть своя цена — и, по-моему, у нас получилась неплохая сделка. Буду рад, если вы поделитесь в комментариях своими мыслями по поводу прочитанного — с удовольствием отвечу и поучаствую в дискуссии.
![](https://habrastorage.org/webt/ta/bz/n1/tabzn1lmliae2ou_muvvmn6shns.png)
Когда я целых три года назад присоединился к команде Яндекс.Такси, то начал заниматься поддержкой сервиса расчёта повышенного спроса на такси (surge pricing, сурж). Почитать про него можно здесь. Этот сервис был написан на C++, а алгоритм расчёта представлял собой cpp-файл объёмом более чем 3000 строк со сложно переплетёнными ссылками на объекты в стеке, без чёткой структуры. Не то чтобы это был жуткий спагетти-код, но чтобы в нём разобраться, требовалось немало времени. Также возникала проблема с логированием — периодически поднимался вопрос: «А сможем ли мы для конкретного заказа ответить, почему мы получили именно такой коэффициент?» Ответ был: «Да, но надо будет покопаться в логах и данных».
Конечно, в разы проще было бы решить эти проблемы обычным рефакторингом. Но кроме них были и другие, более фундаментальные.
Мотивация использования JavaScript
Алгоритм расчёта суржа постоянно эволюционирует: меняются формулы, появляются новые, добавляются источники данных, коррекции, коэффициенты. Процесс внесения правок в алгоритм выглядел примерно так:
- Аналитики придумали крутую сущность или формулу, которая более точно предсказывает спрос.
- Они заводят на разработчиков задачу, в которой пытаются донести, чего они хотят.
- Разработчик интерпретирует задачу.
- Пишет код на C++.
- Пишет тесты.
- Катит результат в тестовое окружение и проверяет.
- Катит в прод под выключенным конфигом, включает и убеждается, что ничего не сломалось.
- Аналитик по данным и логам проверяет, как фича работает.
- (возможно) Фича работает неправильно из-за miscommunication — возвращаемся к пункту 4.
- (возможно) Фича работает неправильно из-за бага — возвращаемся к пункту 4.
- (возможно) На реальных данных фича «не взлетела». 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 строк.
![](https://habrastorage.org/webt/di/nu/ny/dinunyeaxe8y8tmtlcaioabzkoi.png)
Получилось менее тысячи 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 кэша. После реализации кэширования график выглядит так:
![](https://habrastorage.org/webt/aa/uh/hq/aauhhqwbj7cjbyvx0xm6wgt9z_m.png)
Почти десять тысяч 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-воркеры. В них находится цикл, вытаскивающий из очереди и исполняющий новые таски.
![](https://habrastorage.org/webt/hy/2e/ho/hy2ehodv9e9i0n4jmns2qzzolyq.png)
А что, если 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, при этом с движком по-прежнему работает тот же поток. Должно получиться — в теории. Но на практике…
![](https://habrastorage.org/webt/q9/im/w6/q9imw6hv6hzlqrgd56fdfmmi4da.jpeg)
Первое, что я получил, — это, конечно, 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.
- Логический этап — результатом работы такого этапа являются изменения в домене вывода. У этого типа этапов есть выражения вывода.
- Этап получения ресурсов — результатом его работы является наличие новых экземпляров в домене ресурсов. У этого типа этапов есть набор ресурсов.
- Алгоритм (он же пайплайн) — последовательность этапов разных типов.
Пример
Посмотрим, как взаимодействуют описанные сущности, на примере простого алгоритма, состоящего из одного логического этапа. Представим, что у нас магазин автозапчастей, и нам нужно вернуть список с именами запчастей и ценами на них. Имена вместе с идентификаторами мы уже собрали, ресурс цен у нас в распоряжении, осталось обогатить данные ценами.
![](https://habrastorage.org/webt/ae/0b/bj/ae0bbj_tgega8eyg-sexm_k-z9m.png)
Что произошло в коде? Мы проитерировались по домену вывода вложенным циклом и для каждой детали получили её цену. Здесь бизнес-логики нет — мы просто возвращаем полученную цену как результат, который поместит выражение вывода рядом с остальными полями детали.
Конечно, всё это накладывает ограничения на структуру алгоритма, но, как известно, из ограничений рождаются возможности. Так мы намного больше знаем о структуре алгоритма ещё до его выполнения, что даёт нам возможность реализовать множество разных проверок, например, проверку по схеме данных, придать алгоритму единую, чёткую структуру и в будущем реализовать некоторые концепции на уровне фреймворка.
При этом каждый логический этап атомарен: если в процессе модификации вывода произошла ошибка, то все предыдущие модификации, порождённые данным этапом, будут забыты.
С этим видением мы пошли на техническое/архитектурное ревью, где нам дали добро на реализацию задуманного, и началась активная фаза разработки.
Реализация
Первая дилемма, представшая перед нами: реализовывать ли декларативные механизмы для конкретного алгоритма (выражения ввода/вывода, условия) посредством 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 и алгоритмов на нём:
— тайминги,
— ошибки и их типы,
— обращения к ресурсам,
— падения необязательных этапов,
— и прочее…
![](https://habrastorage.org/webt/f8/lt/k5/f8ltk5_9pbpwy5nuioclfbsoc3s.png)
![](https://habrastorage.org/webt/ik/bz/ww/ikbzwwkaeqnnohodxaycmflbkvo.png)
Здесь, кстати, можно видеть тайминги работы JS: около 70 мс на 99 prc и около 50 мс на 95 prc. Это учитывая то, что алгоритм компилируется в JS размером 5500 строк, большая часть логических этапов выполняется несколько раз для каждого тарифа, коих в Москве больше 20, и мы пока не занимались глубокой оптимизацией генерируемого JS-кода.
V8 contexts/values count отображает, сколько в данный момент мы закешировали контекстов и значений V8, используя ранее описанный в статье механизм. JS load показывает примерную загруженность CPU машины, он её несколько завышает, но главное, что есть прямая корреляция с реальной нагрузкой. Ниже уже идут метрики уровня фреймворка — тут показаны ошибки при получении ресурсов и падения необязательных этапов.
После запуска бинарника микросервиса на сервере, компонент библиотеки compilation начинает периодически (раз в три минуты) ходить в другой микросервис, единый для всех использующих фреймворк. Этот микросервис владеет базой данных, которая хранит в себе все JS-алгоритмы и является по совместительству бэкендом админки для редактирования этих самых алгоритмов. Компонент в фоне получает из него все релевантные для потребителей микросервиса алгоритмы, компилирует их и хранит внутри себя.
Рассмотрим систему целиком в процессе обработки запроса.
![](https://habrastorage.org/webt/ng/fh/cm/ngfhcmjinctytsh5xwdxs7k64c8.png)
- Получив запрос, сервис обычно использует его для инициализации домена ввода алгоритма. Затем определяет имя алгоритма, который нужно использовать, и с этой информацией обращается к компоненту execution.
- Компонент execution идёт в компонент compilation для того, чтобы по переданному ранее имени получить сам скомпилированный алгоритм.
- После нахождения нужного алгоритма начинается выполнение его JS-кода.
- Если в алгоритме есть этапы получения ресурсов, то в сгенерированном JS-коде будет прерывание (yield). Сформированные в пользовательском JS-коде параметры ресурсов будут переданы соответствующим ресурсам для получения их экземпляров.
- После похода за внешними ресурсами продолжается выполнение JS-кода, и по прохождению всех этапов мы возвращаем управление клиентскому коду.
- Получив результат из кода библиотеки, сервис использует его, чтобы сформировать ответ, и на этом обработка запроса с использованием js-pipeline завершается.
Планы
Хочу поделиться планами на будущее и идеями развития решения:
- Автотесты. Как я уже упоминал ранее, мы хотим добавить возможность покрывать алгоритмы тестами в их админке. Это снизит количество попыток запуска алгоритма в фоне. Бывает, что нужно выкатить его раз пять, пока не поправишь все баги. С тестами это было бы намного быстрее и комфортнее.
- Нативные этапы. В процессе использования фреймворка стало очевидно, что код некоторых этапов меняется довольно редко, и можно было бы выполнять его не на JS, а на C++, пожертвовав гибкостью ради производительности. Это позволит сделать нативные этапы — написать в сервисе функцию на C++ и зарегистрировать её как ресурс, а далее в админке вместо написания кода выбрать эту функцию. Эта фича уже на стадии ревью ПР.
- Поддержка Wasm. С помощью Wasm можно поддерживать множество других языков
- Представление в виде блок-схемы. По мере усложнения алгоритма этапы обрастают неявными семантическими связями. Например, один этап может сильно зависеть от другого, и если тот этап не выполнился, то нельзя выполнить и другой, так как они являются частями одной и той же продуктовой фичи. Прописать их связь легко и сейчас, создав условие на статус этапа, но это не очень наглядно, когда твой алгоритм состоит из 65 этапов, как в случае алгоритма расчёта суржа Такси. А в блок-схеме можно отобразить все связи и более того — нарисовать диаграмму влияния этапов на основе выражений ввода/вывода, соединив этап, который обращается к данным, с тем, который в эти данные пишет.
Выводы
Мы добавили в наш арсенал инструмент, позволяющий быстро создавать легко эволюционирующие микросервисы, обладающие из коробки свойствами взрослых микросервисов: детальными логами и телеметрией. При этом приступать к разработке и введению в эксплуатацию микросервиса в некоторых случаях можно ещё до полного понимания того, как он будет работать внутри. Достаточно понимать, какими данными он будет оперировать, а алгоритм описать уже после того, как микросервис будет готов. Так мы можем быстро создавать прототипы и улучшать их.
Сейчас у нас зарегистрировано шесть потребителей из Еды и Лавки (помимо нас самих). При этом напрямую поддержкой микросервисов этих сервисов мы не занимаемся, имеем к ним отношение лишь опосредованно, поскольку при добавлении новой фичи в js-pipeline она автоматически становится доступна всем потребителям.
Нам удалось снизить нагрузку по продуктовым задачам, так как аналитики могут напрямую редактировать алгоритмы на JS, что они активно и делают (даже чаще, чем мы). А мы в этом случае выступаем в роли ревьюера.
В то же время фреймворк задаёт архитектуру внутри микросервиса как на стороне C++, так и на JS:
- C++-код декомпозируется посредством абстракции «ресурс». В неё инкапсулируются все внешние источники данных и процедура их предварительной обработки перед доставкой алгоритму.
- JS-код декомпозируется посредством абстракции «этап». Она инкапсулирует в себе некое полезное действие, результат которого состоит в изменении внутри вывода.
В итоге мы получили множество в той или иной мере изолированных кусков кода, которые проще поддерживать. На мой взгляд, получившееся решение — хороший пример того, как из ограничений появляются возможности, ведь можно было не придумывать все эти этапы и ресурсы, а написать алгоритм как есть, полностью на JS, получив при этом больше свободы. Но, как известно, у всего есть своя цена — и, по-моему, у нас получилась неплохая сделка. Буду рад, если вы поделитесь в комментариях своими мыслями по поводу прочитанного — с удовольствием отвечу и поучаствую в дискуссии.