Инструментирование JavaScript путем изменения кода: области применения и общие принципы работы

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

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

    Итак, зачем и как можно автоматически изменять код?

    Для простейшей отладки, например, может понадобиться изменить каждую функцию скрипта обернув ее тело в try-catch блок.

    Простая трассировка или логирование может осуществляться вставкой console.log, профилирование вставкой console.time/console.profile в начало и конец каждой функции, или, если точность замера не так важна или выполняющая среда не поддерживает console.time/console.profile, старым добрым Date.now().

    Более глубокая и полная трассировка может понадобиться для последующего анализа тестового или сценарного покрытия кода. Собираемая инструментационными инструкциями информация о выполнении кода сохраняется туда, откуда инструмент может позже взять ее для отчета. Качественный анализ тестового покрытия предполагает отслеживание выполнения (а соответственно инструментирование) не только строк кода, но и ветвей логических и тернарных операторов.
    function Foo(arg1, arg2) {
       if (arg1 || arg2 > 0)
          Bar1();
       return arg2 ? Bar2() : false;
    }
    

    =>
    // Пример вымышленного, недеструктивного изменения
    function Foo(arg1, arg2) {
       try {
          ping('Foo invoked');
          if ((ping('arg1 check'), arg1) || (ping('arg2 check'), arg2 > 0)) {
             ping('if branch');
             Bar1();
          }
          return arg2 ? (ping('Bar2 branch'), Bar2()) : (ping('false branch'), false);
       }
       finally {
          ping('Foo finished');
       }
    }
    


    Инструментирование кода для последующей трассировки такого рода осуществляют инструменты code coverage. Из тех с которыми мне пришлось и понравилось работать не могу не отметить istanbul. Инструмент написан на JavaScript, что в том числе помогает его популярности в использовании в grunt расширениях. Я использую istanbul вместе с Jasmine как для анализа покрытия тестами клиентского кода (PhantomJs плюс grunt-template-jasmine-istanbul), так и серверного (с grunt-jasmine-node-coverage). Взглянуть на пример отчета покрытия кода istanbul для самого себя можно здесь.



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

    Каким же образом можно автоматически изменять JavaScript код, находить нужные места и вставлять туда инструментационные инструкции? Можно конечно пытаться сделать это регулярными выражениями и вызвать дьявола, как в этом stackoverflow ответе, но правильный ответ на этот вопрос следующий: JavaScript код нужно парсить, обходить полученное абстрактное синтаксическое дерево, изменять интересующие нас узлы, преобразовывать измененное дерево назад в код.

    Существует множество легко находимых парсеров JavaScript, некоторые мы используем постоянно, даже уже и не задумываясь о том, что это еще и парсер (например, uglify.js или различные beautifier-ы JavaScript). В своем проекте я использовал esprima для получения изначального синтаксического дерева. Дерево представляет собой иерархический JSON, описывающий анализируемый код. Поиграть с синтаксическими деревьями, а также посмотреть другие примеры использования esprima, можно на сайте инструмента.



    Обход дерева с модификацией я реализовал без дополнительных инструментов. Тем не менее, такие инструменты существуют, например falafel и burrito, и избавляют от написания инфраструктуры для обхода дерева, позволяя сконцентрироваться на задаче по поиску и модификации нужных узлов.

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

    Генерацию кода для измененного дерева я произвожу с помощью escodegen, который понимает формат синтаксического дерева, выдаваемого esprima.

    К сожалению, разные парсеры/генераторы вольны использовать и используют различные форматы синтаксических деревьев. К счастью, несколького популярных парсеров используют формат синтаксического дерева SpiderMonkey parser API, и esprima/escodegen входят в число этих парсеров/генераторов.

    Для того чтобы при отладке спрятать инструментационные инструкции и заставить клиентский код в отладчике выглядеть так, как будто он не инструментирован, при генерации кода измененного дерева можно использовать source maps. С использованием escodegen, все что для этого нужно, это установка одного флага (options.sourceMap).

    Завершая, хочется заметить, что недеструктивная автоматическая модификация кода требует хорошего знания спецификации языка (или постоянной сверки с ней). В качестве постскриптума, могу привести пример подводного камня на который я натолкнулся.

    В прототипе проекта я поголовно оборачивал все что можно в блоки, то есть
    for (var x in y) {
          // тело цикла
    }
    

    превращалось в
    { 
       for (var x in y) {
          // тело цикла
       }
    }
    


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

    Читатель может при желании проверить свои знания/память до чтения ответа
    Знать о том, что в языке есть labels я конечно знал, но использовал в своей практике крайне редко и не ожидал определенного поведения для случая с continue label. Ломающим сценарием было:
    l1: 
    for (var x in y) {
       continue l1;
    }

    (см. комментарии к статье для более подробного объяснения)
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 7

      0
      > Знать о том, что в языке есть labels я конечно знал, но не догадывался что они такие капризные. Ломающим сценарием было:

      Может я чего то не понимаю, но если обернуть эту конструкцию в блок, то ничего не сломается.
        +2
        в блоки оборачивались statements, которым был For Statement
        { l1: { for (var x in y) { continue l1; } } }
          0
          Так можно и весь JavaScript капризным обозвать, мол этот expression возращает не reference, а value — какой же он капризный.

          Кстати то, что вы в статье добавили перенос строки после лэйбла ничего не поменяло. Он как добавлялся в labels list следующего statement, так и добавляется. Было бы хорошо, если бы вы в статье описали более подробно саму проблему, а не называли лэйблы копризными, хотя просто сами допустили ошибку, разрывая связь identifier и statement.

          Я не про то, что о боже мой, как можно было допустить ошибку. А про то, что «капризные лэйблы» ничего не объясняет для читателя.
            0
            Перенос строки я добавил чтобы было более понятно, что именно оборачивание в блок for-in statement-а влечет за собой проблему, а не оборачивание всего куска кода.

            Поясню проблему подробнее. Моя ошибка заключалась (как и было сказано в статье) в неверном предположении, что оборачивание *любого* for, for-in, while, do-while statement-а в блок не является деструктивным.

            Проблема заключается в том, что в случае если for, for-in, while. do-while statement-а является частью тела labeled statement и в цикле использутся continue lableName, его нельзя просто взять и обернуть в блок, так как это изменение повлечет за собой ошибку выполнения.

            То есть
            loop: 
            for (i = 0; i < 3; i++) {
              if (i == 1) {
                continue loop;
              }
              console.log(i);
            }
            

            работать будет, а
            loop: 
            { for (i = 0; i < 3; i++) {
              if (i == 1) {
                continue loop;
              }
              console.log(i);
            }}
            

            не найдет label loop при выполнении цикла.

            При всем при этом этом
            loop: 
            { for (i = 0; i < 3; i++) {
              if (i == 1) {
                break loop;
              }
              console.log(i);
            }}
            

            или
            lbl1: {{
                console.log('will be displayed');
                break lbl1;
                console.log('will not be displayed');
            }}
            

            будет работать.

            Из-за описанных особенностей я назвал labels капризными, но вы правы, так можно назвать капризными любые особенности, которых не ожидаешь. Пусть будет не капризные, а неожиданные.
              0
              Да, я имеено про это описание проблемы, которое и должно быть в статье, спасибо :)
        0
        Добрый день. А как можно использовать instanbul, в клиентских скриптах? При команде cover мне выдает, что не найден jQuery.
          0
          Я использую istanbul следующим образом:
          В проекте используется grunt с помощью которого помимо всего прочего запускаются клиентские тесты.
          Клиентские тесты написаны на Jasmine. Если вы используете другой тествовый framework (qunit или mocha например) то нужно его поискать в списке расширений и использовать. У меня из расширений grunt установлены grunt-contrib-jasmine и grunt-template-jasmine-istanbul.

          Gruntfile.js выглядит примерно так:

          //...
          grunt.initConfig({
             //...
             jasmine: {
                dev: {
                  specRoot: 'test/',
                  src: '*.js',
                  options: {
                    vendor: [
                      'libs/jquery-1.9.1.min.js',
                      'libs/*.js'
                    ],
                    specs: 'test/*Spec.js',
                    template: require('grunt-template-jasmine-istanbul'),
                    templateOptions: {
                      coverage: 'bin/client-coverage-dev/coverage.json',
                      report: 'bin/client-coverage-dev',
                      thresholds: {
                        lines: 80,
                        statements: 80,
                        branches: 80,
                        functions: 80
                      }
                    }
                  }
                }
             }
             //...
          });
          //...
          grunt.loadNpmTasks('grunt-contrib-jasmine');
          //...
          grunt.registerTask('testclient', ['jasmine:dev']);
          //...
          


          Соответственно команда 'grunt testclient' запускает тесты ('test/*Spec.js) в phantomjs и создает отчеты в bin/client-coverage-dev.

        Only users with full accounts can post comments. Log in, please.