Как стать автором
Обновить

Что нужно знать о JavaScript Engine Switcher 3.0

Время на прочтение22 мин
Количество просмотров6.3K

Логотип третьей версии JavaScript Engine Switcher


JavaScript Engine Switcher изначально создавался как вспомогательная библиотека и его развитие во многом определялось потребностями библиотек, которые его использовали. Фактически каждая его мажорная версия решала одну или несколько крупных задач необходимых для дальнейшего развития других библиотек:


  1. В первой версии такой задачей было добавление как можно большего количества модулей-адаптеров для популярных JS-движков, поддерживающих платформу .NET. И это дало пользователям Bundle Transformer определенную гибкость: на компьютерах разработчика они могли использовать модуль MSIE, поддерживающий отладку JS-кода с помощью Visual Studio, а на серверах, на которых не было современной версии Internet Explorer или он не был установлен вовсе, они могли использовать модуль V8. Некоторым даже удавалось запускать Bundle Transformer в среде Mono на Linux и Mac, используя модули Jurassic и Jint.
  2. Основной задачей второй версии была реализация поддержки .NET Core, которая требовалась для новой версии библиотеки ReactJS.NET. Другой немаловажной задачей было создание кроссплатформенного модуля, способного быстро обрабатывать большие объемы JS-кода (модули Jurassic и Jint не подходили для этого), и таким модулем, после ряда доработок, стал модуль ChakraCore.
  3. В третьей версии основной акцент был сделан на улучшение интеграции с библиотекой ReactJS.NET и повышение производительности.

В этой статье мы рассмотрим некоторые нововведения третьей версии, которые для многих оказались неочевидными даже после прочтения текста релиза и раздела документации «How to upgrade applications to version 3.X»: изменения в классе JsEngineSwitcher, реорганизация исключений, более информативные сообщения об ошибках, прерывание и предварительная компиляция скриптов, возможность изменения максимального размера стека в модулях ChakraCore и MSIE, а также новый модуль на основе NiL.JS.


Изменения в классе JsEngineSwitcher


В новой версии класс JsEngineSwitcher реализует интерфейс IJsEngineSwitcher и больше не является синглтоном (его экземпляр можно создавать с помощью оператора new). Для получения глобального экземпляра вместо свойства Instance следует использовать свойство Current. Свойство Current, в отличие от устаревшего свойства Instance, имеет тип возвращаемого значения IJsEngineSwitcher. Также у свойства Current есть сеттер, с помощью которого можно заменить стандартную реализацию на свою собственную:


JsEngineSwitcher.Current = new MyJsEngineSwitcher();

В веб-приложениях ASP.NET Core, в которых установлен пакет JavaScriptEngineSwitcher.Extensions.MsDependencyInjection, замена реализации производится с помощью метода-расширения AddJsEngineSwitcher:


…
using JavaScriptEngineSwitcher.Extensions.MsDependencyInjection;
…

    public class Startup
    {
        …
        public void ConfigureServices(IServiceCollection services)
        {
            …
            services.AddJsEngineSwitcher(new MyJsEngineSwitcher(), options =>
                …
            )
                …
                ;
            …
        }
        …
    }
…

Данные изменения практически всегда «ломают» приложения или библиотеки, которые используют предыдущую версию JavaScript Engine Switcher. Поэтому вам нужно внести в свой код следующие изменения:


  1. Поменять тип переменных, параметров или свойств с JsEngineSwitcher на IJsEngineSwitcher.
  2. Вместо свойства Instance везде использовать свойство Current.

Стоит также отметить, что для большинства разработчиков данные изменения не принесут особой пользы, потому что их основной целью было упрощение юнит-тестов (например, раньше в юнит-тестах библиотеки ReactJS.NET использовались блокировки, а теперь можно обойтись без них).


Реорганизация исключений


До третьей версии большая часть ошибок JS-движков оборачивалась в исключения типа JsRuntimeException, и только ошибки, возникавшие в процессе инициализации движка оборачивались в JsEngineLoadException. Также существовал базовый класс JsException, от которого наследовались два вышеперечисленных типа исключений, что позволяло перехватывать абсолютно все ошибки, возникавшие в процессе работы JS-движков. Несмотря на очевидные недостатки, такая организация исключений хорошо вписывалась в концепцию унифицированного интерфейса для доступа к базовым возможностям JS-движков.


Но при реализации возможностей прерывания и предварительной компиляции скриптов (о них я расскажу в следующих разделах) возникла потребность в новом подходе к организации исключений. Первое, что нужно было сделать – это добавить новый тип исключения – JsInterruptedException, который был необходим для оповещения пользователя о прерывании выполнения скрипта. Затем нужно было явно разделить все ошибки, возникавшие при обработке скриптов, на две группы: ошибки компиляции (синтаксического анализа) и ошибки времени выполнения. Также требовалось отделить всевозможные специфические ошибки Chakra и V8, которые не были связаны с обработкой скриптов. Еще нужно было учесть наличие в движке Jint исключения, возникающего при истечении времени ожидания выполнения скрипта (таймаута). В итоге был сформирован новый подход к организации исключений, который можно представить в виде следующей иерархической структуры:


  • JsException
    • JsEngineException
      • JsEngineLoadException
    • JsFatalException
    • JsScriptException
      • JsCompilationException
      • JsRuntimeException
        • JsInterruptedException
        • JsTimeoutException
    • JsUsageException
  • JsEngineNotFoundException*

* — данное исключение возникает не на уровне JS-движка, а на уровне JavaScript Engine Switcher.


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


Унифицированный формат сообщений об ошибке


Другой проблемой предыдущих версий JavaScript Engine Switcher были сложности в определении местоположения ошибок, которые возникали при обработке скриптов. Из свойства исключения Message было сложно понять в каком именно месте кода произошла ошибка, поэтому приходилось анализировать другие свойства исключения, что не всегда было удобно. Кроме того, набора существующих свойств также было недостаточно.


Поэтому в класс JsScriptException были добавлены 2 новых свойства:


  1. Type — тип ошибки JavaScript (например, SyntaxError или TypeError);
  2. DocumentName — имя документа (обычно извлекается из значений следующих параметров: documentName методов Execute и Evaluate, path метода ExecuteFile, resourceName метода ExecuteResource и т.д.);

В класс JsRuntimeException также было добавлено одно новое свойство — CallStack, которое содержит строковое представление стека вызовов.


Раньше в свойство Message просто копировалось значение из аналогичного свойства оригинального .NET-исключения или строковое представление ошибки JavaScript. Зачастую сообщения об ошибках в разных JS-движках отличались не только форматом, но и количеством представленной в них полезной информации. Например, из-за отсутствия в некоторых сообщениях об ошибках информации о номере строки и столбца, разработчики библиотеки ReactJS.NET были вынуждены переоборачивать исключения, полученные из JavaScript Engine Switcher.


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


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


ChakraCore
==========

SyntaxError: Unexpected identifier after numeric literal
   at declinationOfSeconds.js:12:23 ->          caseIndex = number % 1O < 5 ? number % 10 : 5;

Jint
====

SyntaxError: Unexpected token ILLEGAL
   at declinationOfSeconds.js:12:25

Jurassic
========

SyntaxError: Expected operator but found 'O'
   at declinationOfSeconds.js:12

MSIE в режиме Classic
=====================

SyntaxError: Expected ';'
   at declinationOfSeconds.js:12:25 ->          caseIndex = number % 1O < 5 ? number % 10 : 5;

MSIE в режиме Chakra ActiveScript
=================================

SyntaxError: Expected ';'
   at declinationOfSeconds.js:12:25 ->          caseIndex = number % 1O < 5 ? number % 10 : 5;

MSIE в режиме Chakra IE JsRT
============================

SyntaxError: Expected ';'
   at 12:25 ->          caseIndex = number % 1O < 5 ? number % 10 : 5;

MSIE в режиме Chakra Edge JsRT
==============================

SyntaxError: Unexpected identifier after numeric literal
   at declinationOfSeconds.js:12:23 ->          caseIndex = number % 1O < 5 ? number % 10 : 5;

NiL
===

SyntaxError: Unexpected token 'O'
   at 12:25

V8
==

SyntaxError: Invalid or unexpected token
   at declinationOfSeconds.js:12:24 ->          caseIndex = number % 1O < 5 ? number % 10 : 5;

Vroom
=====

SyntaxError: Unexpected token ILLEGAL
   at declinationOfSeconds.js:12:24

Аналогичный пример для ошибки времени выполнения:


ChakraCore
==========

TypeError: Unable to get property 'Ч' of undefined or null reference
   at transliterate (russian-translit.js:929:4) ->                      newCharValue = typeof charMapping[charValue] !== 'undefined' ?
   at Global code (Script Document:1:1)

Jint
====

TypeError: charMapping is undefined
   at russian-translit.js:929:26

Jurassic
========

TypeError: undefined cannot be converted to an object
   at transliterate (russian-translit.js:929)
   at Global code (Script Document:1)

MSIE в режиме Classic
=====================

TypeError: 'undefined' is null or not an object
   at russian-translit.js:929:4

MSIE в режиме Chakra ActiveScript
=================================

TypeError: Unable to get property 'Ч' of undefined or null reference
   at russian-translit.js:929:4

MSIE в режиме Chakra IE JsRT
============================

TypeError: Unable to get property 'Ч' of undefined or null reference
   at transliterate (russian-translit.js:929:4)
   at Global code (Script Document:1:1)

MSIE в режиме Chakra Edge JsRT
==============================

TypeError: Unable to get property 'Ч' of undefined or null reference
   at transliterate (russian-translit.js:929:4)
   at Global code (Script Document:1:1)

NiL
===

TypeError: Can't get property "Ч" of "undefined"

V8
==

TypeError: Cannot read property 'Ч' of undefined
   at transliterate (russian-translit.js:929:37) ->                     newCharValue = typeof charMapping[charValue] !== 'undefined' ?
   at Script Document:1:1

Vroom
=====

TypeError: Cannot read property 'Ч' of undefined
   at russian-translit.js:929:37

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


Подсказки по развертыванию нативных сборок


Основной причиной ошибок при работе со второй версий JavaScript Engine Switcher было то, что многие разработчики забывали устанавливать NuGet-пакеты, содержащие нативные сборки для модулей ChakraCore и V8. В свое время этой проблеме был даже посвящен пост в багтрекере ReactJS.NET (доступен также русский перевод). Сейчас с такой ошибкой в основном сталкиваются только новички, которые по какой-то причине не читали документацию.


Авторы ReactJS.NET пытались свести к минимуму количество таких ошибок с помощью подсказок внутри сообщений об ошибках, но не очень удачная реализация такого подхода привела к еще большей путанице. Идея подсказок была хорошей, но требовала принципиально другой реализации, а именно реализации на уровне модулей-адаптеров JS-движков. В новой версии JavaScript Engine Switcher такие подсказки добавляются в сообщение об ошибке при оборачивании исключений DllNotFoundException и TypeLoadException в исключение JsEngineLoadException (смотрите примеры реализации для модулей ChakraCore, V8 и Vroom). Причем эти подсказки являются интеллектуальными, т.к. при их генерации учитывается ряд факторов: тип операционной системы, процессорная архитектура и среда выполнения (.NET Framework, .NET Core или Mono).


Например, при использовании модуля ChakraCore без нативной сборки в 64-битном процессе на операционной системе Windows сообщение об ошибке будет выглядеть следующим образом:


Failed to create instance of the ChakraCoreJsEngine. Most likely it happened, because the 'ChakraCore.dll' assembly or one of its dependencies was not found. Try to install the JavaScriptEngineSwitcher.ChakraCore.Native.win-x64 package via NuGet. In addition, you still need to install the Microsoft Visual C++ Redistributable for Visual Studio 2017 (https://www.visualstudio.com/downloads/#microsoft-visual-c-redistributable-for-visual-studio-2017).

В сообщении об ошибке дается подсказка, что нужно установить NuGet-пакет JavaScriptEngineSwitcher.ChakraCore.Native.win-x64, а также упоминается о том, что ChakraCore для Windows требует для своей работы распространяемый компонент Microsoft Visual C++ для Visual Studio 2017. Если эта ошибка возникнет в 32-битном процессе, то пользователю будет предложено установить пакет JavaScriptEngineSwitcher.ChakraCore.Native.win-x86.


Аналогичное сообщение об ошибке на Linux в среде .NET Core будет выглядеть следующим образом:


Failed to create instance of the ChakraCoreJsEngine. Most likely it happened, because the 'libChakraCore.so' assembly or one of its dependencies was not found. Try to install the JavaScriptEngineSwitcher.ChakraCore.Native.linux-x64 package via NuGet.

В данном случае, будет предложено установить пакет JavaScriptEngineSwitcher.ChakraCore.Native.linux-x64.


При запуске в среде Mono будет отображаться уже другая подсказка:


… JavaScriptEngineSwitcher.ChakraCore.Native.linux-* packages do not support installation under Mono, but you can to install the native assembly manually (https://github.com/Taritsyn/JavaScriptEngineSwitcher/wiki/ChakraCore#linux).

Поскольку пакет JavaScriptEngineSwitcher.ChakraCore.Native.linux-x64 совместим только с .NET Core, то в подсказке будет указана ссылка на инструкцию по ручному развертыванию нативной сборки в ОС Linux.


Можно еще привести множество примеров, но в этом нет смысла.


Прерывание выполнения скриптов


Когда мы даем пользователям возможность выполнять на сервере произвольный JS-код, то сталкиваемся с одной очень серьезной проблемой — мы не знаем сколько времени займет выполнение этого кода. Это может быть большой объем неоптимального кода или код запускающий бесконечный цикл. В любом случае это будет неконтролируемый нами код, который будет неопределенное время потреблять ресурсы нашего сервера. Чтобы хоть как-то контролировать этот процесс нам нужна возможность прерывать выполнение скриптов. При использовании движков, написанных на чистом .NET (например, Jint, Jurassic или NiL.JS), мы всегда можем запустить выполнение JS-кода в виде задачи с возможностью отмены, но для других движков такой подход не подойдет. К счастью для нас, движки, написанные на C++, имеют встроенные механизмы для прерывания скриптов.


Для обеспечения доступа к этим механизмам в интерфейс IJsEngine было добавлено свойство SupportsScriptInterruption и метод Interrupt. Поскольку не все движки поддерживают эту возможность, то перед вызовом метода Interrupt всегда следует проверять значение свойства SupportsScriptInterruption (если в предыдущий версиях JavaScript Engine Switcher вам доводилось вручную запускать сборщик мусора, то вы сразу поймете, о чем я говорю):


if (engine.SupportsScriptInterruption)
{
    engine.Interrupt();
}

Причем вызывать этот метод нужно в отдельном потоке отличном от потока, в котором производится выполнение скриптов. После вызова метода Interrupt все ранее запущенные методы Evaluate, Execute* и CallFunction будут завершены выбросом исключения JsInterruptedException.


Поскольку данный API является низкоуровневым, то для описанных в начале раздела задач рекомендуется использовать методы-расширения наподобие этого:


using System;
#if !NET40
using System.Runtime.ExceptionServices;
#endif
using System.Threading;
using System.Threading.Tasks;

using JavaScriptEngineSwitcher.Core;
#if NET40
using JavaScriptEngineSwitcher.Core.Extensions;
#endif
using JavaScriptEngineSwitcher.Core.Resources;

…
    /// <summary>
    /// Extension methods for <see cref="IJsEngine"/>
    /// </summary>
    public static class JsEngineExtensions
    {
        /// <summary>
        /// Evaluates an expression within a specified time interval
        /// </summary>
        /// <typeparam name="T">Type of result</typeparam>
        /// <param name="engine">JS engine</param>
        /// <param name="expression">JS expression</param>
        /// <param name="timeoutInterval">Interval to wait before the
        /// script execution times out</param>
        /// <param name="documentName">Document name</param>
        /// <returns>Result of the expression</returns>
        /// <exception cref="ObjectDisposedException"/>
        /// <exception cref="ArgumentNullException"/>
        /// <exception cref="ArgumentException"/>
        /// <exception cref="JsCompilationException"/>
        /// <exception cref="JsTimeoutException"/>
        /// <exception cref="JsRuntimeException"/>
        /// <exception cref="JsException"/>
        public static T Evaluate<T>(this IJsEngine engine, string expression,
            TimeSpan timeoutInterval, string documentName)
        {
            if (engine == null)
            {
                throw new ArgumentNullException(nameof(engine));
            }

            if (engine.SupportsScriptInterruption)
            {
                using (var timer = new Timer(state => engine.Interrupt(), null,
                    timeoutInterval,
#if NET40
                    new TimeSpan(0, 0, 0, 0, -1)))
#else
                    Timeout.InfiniteTimeSpan))
#endif
                {
                    try
                    {
                        return engine.Evaluate<T>(expression, documentName);
                    }
                    catch (JsInterruptedException e)
                    {
                        throw new JsTimeoutException(
                            Strings.Runtime_ScriptTimeoutExceeded,
                            e.EngineName, e.EngineVersion, e
                        );
                    }
                }
            }
            else
            {
#if NET40
                Task<T> task = Task.Factory.StartNew(() =>
#else
                Task<T> task = Task.Run(() =>
#endif
                {
                    return engine.Evaluate<T>(expression, documentName);
                });

                bool isCompletedSuccessfully = false;

                try
                {
                    isCompletedSuccessfully = task.Wait(timeoutInterval);
                }
                catch (AggregateException e)
                {
                    Exception innerException = e.InnerException;
                    if (innerException != null)
                    {
#if NET40
                        innerException.PreserveStackTrace();
                        throw innerException;
#else
                        ExceptionDispatchInfo.Capture(innerException).Throw();
#endif
                    }
                    else
                    {
                        throw;
                    }
                }

                if (isCompletedSuccessfully)
                {
                    return task.Result;
                }
                else
                {
                    throw new JsTimeoutException(
                        Strings.Runtime_ScriptTimeoutExceeded,
                        engine.Name, engine.Version
                    );
                }
            }
        }
        …
    }
…

Этот метод является надстройкой над методом движка Evaluate<T>, позволяющей с помощью параметра timeoutInterval установить таймаут ожидания выполнения скрипта. Принцип действия данного метода-расширения очень простой. Сначала мы проверяем поддерживает ли наш движок встроенный механизм прерываний. Если поддерживает, то создаем экземпляр класса Timer, который через указанный в параметре timeoutInterval интервал времени запускает метод Interrupt, затем вызываем метод движка Evaluate<T>, а в случае ошибки перехватываем исключение JsInterruptedException и оборачиваем его в исключение типа JsTimeoutException. Если движок не поддерживает прерываний, то создаем экземпляр класса Task, который запускает метод движка Evaluate<T>, потом устанавливаем интервал ожидания выполнения задачи равный значению из параметра timeoutInterval, и если задача завершается по таймауту, то генерируем исключение типа JsTimeoutException. Ниже показан пример использования этого метода-расширения:


using System;

…
using JavaScriptEngineSwitcher.Core;
using JavaScriptEngineSwitcher.Core.Helpers;
…

    class Program
    {
        …
        static void Main(string[] args)
        {
            const string expression = @"function getRandomInt(minValue, maxValue) {
    minValue = Math.ceil(minValue);
    maxValue = Math.floor(maxValue);

    return Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue;
}

function sleep(millisecondsTimeout) {
    var totalMilliseconds = new Date().getTime() + millisecondsTimeout;

    while (new Date().getTime() < totalMilliseconds)
    { }
}

var randomNumber = getRandomInt(1, 10);
sleep(randomNumber * 1000);

randomNumber;";

            using (IJsEngine engine = JsEngineSwitcher.Current.CreateDefaultEngine())
            {
                try
                {
                    int result = engine.Evaluate<int>(expression,
                        TimeSpan.FromSeconds(3), "randomNumber.js");
                    Console.WriteLine("результат = {0}", result);
                }
                catch (JsTimeoutException)
                {
                    Console.WriteLine("Во время вычисления выражения JavaScript " +
                        "было превышено время ожидания!");
                }
                catch (JsException e)
                {
                    Console.WriteLine("Во время работы JavaScript-движка произошла " +
                        "ошибка!");
                    Console.WriteLine();
                    Console.WriteLine(JsErrorHelpers.GenerateErrorDetails(e));
                }
            }
        }
        …
    }
…

С помощью метода-расширения мы вычисляем результат JS-выражения. Результатом выражения является случайное целое число, причем это число также является количеством секунд на которое происходит задержка выполнения данного выражения. Также при вызове метода-расширения мы указываем интервал ожидания равный 3 секундам. Поскольку время вычисления выражения варьируется от 1 до 10 секунд, то в одном случае на консоль будет выводится результат выражения, а в другом сообщение о превышении времени ожидания.


Для вас не должно составить труда переделать этот метод-расширение под вызов других методов движка (например, под методы Execute*, CallFunction или другие перегруженные версии метода Evaluate). Пока я еще не решил буду ли добавлять подобные методы-расширения в саму библиотеку, потому что пока непонятно насколько они буду востребованы.


Предварительная компиляция скриптов


Добавить поддержку предварительной компиляции скриптов меня просили еще в 2015 году. В тот момент было непонятно, как такая функция может вписаться в концепцию унифицированного интерфейса. Но шло время и в JavaScript Engine Switcher стала постепенно появляться поддержка таких специальных возможностей, как сборка мусора и прерывание скриптов. На последних этапах работы над третьей версией очередь дошла и до предварительной компиляции.


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


На данный момент, предварительную компиляцию поддерживают 5 модулей-адаптеров: ChakraCore, Jint, Jurassic, MSIE (только в JsRT-режимах) и V8. Для обеспечения доступа к соответствующим механизмам движков в интерфейс IJsEngine было добавлено свойство SupportsScriptPrecompilation и 3 новых метода: Precompile, PrecompileFile и PrecompileResource. Методы Precompile* возвращают экземпляр объекта, который реализует интерфейс IPrecompiledScript. Этот объект является предварительно скомпилированным скриптом, который может использоваться разными экземплярами движков (для этих целей служит перегруженная версия метода Execute). Рассмотрим простой пример использования данного API:


using System;

…
using JavaScriptEngineSwitcher.Core;
using JavaScriptEngineSwitcher.Core.Helpers;
…

    class Program
    {
        …
        static void Main(string[] args)
        {
            const string sourceCode = @"function declinationOfSeconds(number) {
    var result,
        titles = ['секунда', 'секунды', 'секунд'],
        titleIndex,
        cases = [2, 0, 1, 1, 1, 2],
        caseIndex
        ;

    if (number % 100 > 4 && number % 100 < 20) {
        titleIndex = 2;
    }
    else {
        caseIndex = number % 10 < 5 ? number % 10 : 5;
        titleIndex = cases[caseIndex];
    }

    result = number + ' ' + titles[titleIndex];

    return result;
}";
            const string functionName = "declinationOfSeconds";
            const int itemCount = 4;

            int[] inputSeconds = new int[itemCount] { 0, 1, 42, 600 };
            string[] outputStrings = new string[itemCount];

            IJsEngineSwitcher engineSwitcher = JsEngineSwitcher.Current;
            IPrecompiledScript precompiledCode = null;

            using (var engine = engineSwitcher.CreateDefaultEngine())
            {
                if (!engine.SupportsScriptPrecompilation)
                {
                    Console.WriteLine("{0} версии {1} не поддерживает " +
                        "предварительную компиляцию скриптов!",
                        engine.Name, engine.Version);
                    return;
                }

                try
                {
                    precompiledCode = engine.Precompile(sourceCode,
                        "declinationOfSeconds.js");
                    engine.Execute(precompiledCode);

                    outputStrings[0] = engine.CallFunction<string>(functionName,
                        inputSeconds[0]);
                }
                catch (JsCompilationException e)
                {
                    Console.WriteLine("Во время предварительной компиляции скрипта " +
                        "произошла ошибка!");
                    Console.WriteLine();
                    Console.WriteLine(JsErrorHelpers.GenerateErrorDetails(e));
                    return;
                }
                catch (JsException e)
                {
                    Console.WriteLine("Во время работы JavaScript-движка произошла " +
                        "ошибка!");
                    Console.WriteLine();
                    Console.WriteLine(JsErrorHelpers.GenerateErrorDetails(e));
                    return;
                }
            }

            for (int itemIndex = 1; itemIndex < itemCount; itemIndex++)
            {
                using (var engine = engineSwitcher.CreateDefaultEngine())
                {
                    try
                    {
                        engine.Execute(precompiledCode);
                        outputStrings[itemIndex] = engine.CallFunction<string>(
                            functionName, inputSeconds[itemIndex]);
                    }
                    catch (JsException e)
                    {
                        Console.WriteLine("Во время работы JavaScript-движка " +
                            "произошла ошибка!");
                        Console.WriteLine();
                        Console.WriteLine(JsErrorHelpers.GenerateErrorDetails(e));
                        return;
                    }
                }
            }

            for (int itemIndex = 0; itemIndex < itemCount; itemIndex++)
            {
                Console.WriteLine(outputStrings[itemIndex]);
            }
        }
        …
    }
…

Сначала мы создаем движок, с помощью которого скомпилируем код функции для склонения числительных. После чего используем свойство SupportsScriptPrecompilation для того, чтобы проверить поддерживает ли движок предварительную компиляцию, если нет, то информируем об этом пользователя. Затем с помощью метода Precompile компилируем скрипт, если код скрипта содержит синтаксические ошибки, то будет выброшено исключения типа JsCompilationException. C помощью метода Execute загружаем скомпилированный скрипт в память движка, т.е. производим его инициализацию. Затем с помощью метода CallFunction вызываем функцию declinationOfSeconds и сохраняем полученный результат в массив. После чего происходит уничтожение движка. Несмотря на то, что движок был уничтожен, скомпилированный им скрипт продолжает существовать и может быть использован другими движками. Далее мы создаем еще 3 движка и каждый из них инициализируем скомпилированным скриптом. После инициализации, как и в случае с самым первым движком, вызываем функцию и сохраняем ее результат в массив. В конце примера выводим содержимое этого массива на экран, чтобы удостовериться в том, что инициализация движков прошла без ошибок.


Предыдущий пример хоть и наглядно показывает, как можно использовать предварительную компиляцию, но является немного искусственным. В реальных проектах, скорее всего, вы будете использовать пулинг движков и хранить скомпилированные скрипты в кэше. Именно такой подход используется в проекте ReactJS.NET. Пулинг движков там реализован с помощью библиотеки JSPool, а выбор реализации кэша зависит от используемого фреймворка (System.Runtime.Caching.MemoryCache для .NET Framework 4.X, System.Web.Caching.Cache для ASP.NET 4.X и Microsoft.Extensions.Caching.Memory.IMemoryCache для ASP.NET Core). Не буду вдаваться в детали реализации, потому что это займет слишком много времени (при желании вы всегда можете ознакомиться с исходным кодом). Лучше вместо этого рассмотрим, как использовать предварительную компиляцию скриптов в ReactJS.NET.


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


В ASP.NET 4.X для этого вам нужно отредактировать файл App_Start/ReactConfig.cs:


…
    public static class ReactConfig
    {
        public static void Configure()
        {
            ReactSiteConfiguration.Configuration
                …
                .SetAllowJavaScriptPrecompilation(true)
                …
                ;
            …
        }
    }
…

В ASP.NET Core эта настройка производится в файле Startup.cs:


…
    public class Startup
    {
        …
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            …
            app.UseReact(config =>
            {
                config
                    …
                    .SetAllowJavaScriptPrecompilation(true)
                    …
                    ;
            });

            app.UseStaticFiles();
            …
        }
    }
…

В конце раздела я хочу показать вам, как предварительная компиляция влияет на производительность. В качестве примера рассмотрим результаты бенчмарка JsExecutionBenchmark, реализованного средствами библиотеки BenchmarkDotNet. В качестве исполняемого скрипта здесь используется небольшая JS-библиотека для транслитерации русского текста латиницей. Весит она всего 14,9 Кбайт, но этого нам будет достаточно, чтобы увидеть эффект от применения предварительной компиляции. Во время тестирования использовалась последняя на текущий момент версия JavaScript Engine Switcher (версия 3.0.4).


Начнем с результатов, полученных при запуске в среде .NET Framework 4.7.2:


Название модуля Предварит. компиляция Средняя продолжит. выполнения Gen 0 на тыс. опер. Gen 1 на тыс. опер. Gen 2 на тыс. опер. Выделение памяти на одну опер.
ChakraCore Нет 41,72 мс - - - 74,46 Кб
Да 35,07 мс - - - 91,79 Кб
Jint Нет 27,19 мс 2 812,50 1 343,75 - 16 374,58 Кб
Да 15,54 мс 1 296,88 640,63 31,25 7 521,49 Кб
Jurassic Нет 455,70 мс 2 000,00 1 000,00 - 15 575,28 Кб
Да 78,70 мс 1 000,00 - - 7 892,94 Кб
MSIE в режиме Chakra IE JsRT Нет 30,97 мс - - - 77,75 Кб
Да 24,40 мс - - - 90,58 Кб
MSIE в режиме Chakra Edge JsRT Нет 33,14 мс - - - 78,40 Кб
Да 32,86 мс - - - 95,48 Кб
V8 Нет 41,10 мс - - - 79,33 Кб
Да 39,25 мс - - - 96,17 Кб

Наибольший выигрыш в процентном выражении мы получаем для движков, написанных на чистом .NET — скорость выполнения скрипта на Jurassic вырастает 5,79 раз, а на Jint в 1,75 разa. При работе этих движков в 2 раза сокращается объем выделяемой памяти, и соответственно более чем в 2 раза сокращается количество операций по сборке мусора. За счет снижения количества операций по сборке мусора мы получаем частичный прирост в скорости выполнения скрипта. Главная причина прироста скорости в Jurassic, также является и причиной его наихудшего результата в этом тесте: Jurassic всегда компилирует JS-код в IL-код средствами Reflection.Emit, а затем исполняет его. Именно на этапе компиляции происходит основная потеря производительности, а поскольку при предварительной компиляции это происходит всего один раз, мы и получаем выигрыш в производительности. Jint же наоборот является интерпретатором и в результате предварительной компиляции возвращает .NET-объект, представляющий абстрактное синтаксическое дерево. В случае с Jint мы экономим ресурс за счет того, что производим всего одну операцию синтаксического разбора и храним в памяти одно АСД. По результатам данного теста можно подумать, что Jint является самым быстрым движком, но это не так, потому что по мере роста объема выполняемого кода его производительность будет падать. Вообще Jint и Jurassic показывают наихудшие результаты при обработке больших объемов кода.


Лучший результат среди оберток над движками, написанными на C++, показывает MSIE в режиме Chakra IE JsRT — скорость выполнения увеличивается на 26,93%. После него идет ChakraCore (18,96%), потом V8 (4,71%) и MSIE в режиме Chakra Edge JsRT показывает худший результат (0,85%). Для меня до сих пор остается загадкой почему движок из Internet Explorer оказался быстрее движка Edge. Вообще столь скромные результаты для данного типа движков можно объяснить следующим образом. Мы получаем скомпилированный скрипт от движка в сериализованном виде (в виде массива байт) и сохраняем его в управляемой памяти. Вы можете заметить, что в последнем столбце таблицы из-за появления этого массива увеличился объем выделяемой памяти на 12-17 Кбайт (в этом тесте мы измеряем только управляемую память). При инициализации движка скомпилированным скриптом производится десериализация этого массива. Также добавьте к этому затраты на маршалинг. Тем не менее выигрыш в производительности все равно ощутим.


Запуск тестов в среде .NET Core 2.0 дает следующие результаты (модуль V8 отсутствует, потому что библиотека Microsoft ClearScript, на которой он основан, не поддерживает .NET Core):


Название модуля Предварит. компиляция Средняя продолжит. выполнения Gen 0 на тыс. опер. Gen 1 на тыс. опер. Gen 2 на тыс. опер. Выделение памяти на одну опер.
ChakraCore Нет 43,65 мс - - - 18,07 Кб
Да 36,37 мс - - - 16,59 Кб
Jint Нет 24,87 мс 2 750,00 1 375,00 - 16 300,25 Кб
Да 15,25 мс 1 281,25 593,75 62,50 7 447,44 Кб
Jurassic Нет 469,97 мс 2 000,00 1 000,00 - 15 511,70 Кб
Да 80,72 мс 1 000,00 - - 7 845,98 Кб
MSIE в режиме Chakra IE JsRT Нет 31,50 мс - - - 20,28 Кб
Да 24,52 мс - - - 18,78 Кб
MSIE в режиме Chakra Edge JsRT Нет 35,54 мс - - - 20,45 Кб
Да 31,44 мс - - - 18,99 Кб

В целом мы получили похожие результаты. Единственное, что бросается в глаза — результат MSIE в режиме Chakra Edge JsRT улучшился (7,69%). Также в данном случае при использовании предварительной компиляции движками семейства Chakra снижается потребление управляемой памяти.


Возможность изменения максимального размера стека в модулях ChakraCore и MSIE


Размер стека в движках, разработанных Microsoft, ограничивается размером стека потока, в котором выполняется движок. Поскольку в современных версиях IIS он достаточно мал (256 Кбайт для 32-разрядной версии и 512 Кбайт для 64-разрядной), то при запуске в ASP.NET больших JS-библиотек (например, компилятора TypeScript) происходит переполнение стека. Эта проблема уже давно решается в JavaScript Engine Switcher путем создания отдельного потока для выполнения этих движков. Раньше при создании таких потоков размер стека был жестко закодирован в исходном коде, и совпадал с максимальным размером стека в Node.js (492 Кбайт для 32-разрядного процесса и 984 Кбайт для 64-разрядного). Со временем выяснилось, что не всем хватает такого размера, а кто-то наоборот хочет его уменьшить. Поэтому в третьей версии в настройки модулей ChakraCore и MSIE была добавлена опция MaxStackSize, с помощью которой вы можете задать размер стека в байтах. По умолчанию используется то же значение в стиле Node.js. Если присвоить этому свойству значение равное нулю, то в качестве максимального размера стека будет использоваться значение из заголовка исполняемого файла.


Новый модуль на основе NiL.JS


В третьей версии появился новый модуль-адаптер NiL, который создан на основе движка NiL.JS. NiL.JS — это еще один JS-движок, написанный на чистом .NET. Его первая версии вышла в 2014 году, но он не получил такую популярность как Jurassic и Jint. Его основное достоинство — это производительность. В качестве примера можно привести результаты того же бенчмарка, что использовался в разделе про предварительную компиляцию.


При запуске в среде .NET Framework 4.7.2 мы получим следующие результаты:


Название модуля Средняя продолжит. выполнения Gen 0 на тыс. опер. Gen 1 на тыс. опер. Gen 2 на тыс. опер. Выделение памяти на одну опер.
Jint 27,19 мс 2 812,50 1 343,75 - 16 374,58 Кб
Jurassic 455,70 мс 2 000,00 1 000,00 - 15 575,28 Кб
NiL 17,80 мс 1 000,00 - - 4 424,09 Кб

Результаты запуска в среде .NET Core 2.0 отличаются незначительно:


Название модуля Средняя продолжит. выполнения Gen 0 на тыс. опер. Gen 1 на тыс. опер. Gen 2 на тыс. опер. Выделение памяти на одну опер.
Jint 24,87 мс 2 750,00 1 375,00 - 16 300,25 Кб
Jurassic 469,97 мс 2 000,00 1 000,00 - 15 511,70 Кб
NiL 19,67 мс 1 000,00 - - 4 419,95 Кб

Результаты этих тестов можно даже не комментировать. Такие впечатляющие результаты были получены благодаря нестандартным решениям, которые использовал его автор (подробности вы можете узнать из его статей на Хабре). Стоит также заметить, что авторы Jint тоже не сидели сложа руки и с 2016 года работают над третьей версией своего движка, одной из задач которой является повышение производительности. Если верить бенчмарку для версии 3.0.0 Beta 1353, то предварительная версия Jint выполняет скрипты в 2,4 раза быстрее, чем NiL.JS версии 2.5.1200, и показатели использования памяти у них практически совпадают.


Есть у NiL.JS и недостатки. Все идет хорошо пока мы выполняем на нем какой-то простой код, но когда мы пытаемся запустить какие-нибудь популярные библиотеки, то начинают возникать ошибки. Из всех модулей Bundle Transformer, использующих JavaScript Engine Switcher, мне удалось запустить только Hogan и Handlebars, та же ситуация и с ReactJS.NET. В ближайшее время я собираюсь задокументировать все эти ошибки и передать их автору NiL.JS.


Ссылки


  1. Страница проекта JavaScript Engine Switcher на GitHub
  2. Документация JavaScript Engine Switcher
  3. Перевод моего поста «Заблуждения о JavaScript Engine Switcher 2.X»
  4. Страница проекта JSPool на GitHub
  5. Сайт проекта ReactJS.NET
  6. Страница проекта ReactJS.NET на GitHub

Теги:
Хабы:
+9
Комментарии1

Публикации

Истории

Работа

Ближайшие события