Оптимизация шаблонов представления в Codeigniter Framework при помощи AST трансформаций

в 11:10, , рубрики: AST, codeigniter, php, Компиляторы, шаблонизатор

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

Предыстория

Codeigniter — прекрасный фреймворк для веб-приложений, спору нет.
Он легок, гибок, и очень прост в обучении.

Но есть несколько проблем. Одной из которых является отсутствие обработчика для представлений. В качестве шаблонизатора используется чистый php (с мелкими вставками Codeigniter).

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

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

Проблема

Когда вы используете Codeigniter для небольшого проекта то, скорее всего никаких проблем с шаблонами не будет заметно. Но когда ваш проект разрастается до сотен шаблонов — вы будете страдать от медленной компоновки шаблонов.

Так было и в моем случае — количество файлов шаблонов подключаемых при загрузке страницы достигало 50 (информация от встроенной функции get_included_files).

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

image

На странице выводиться список из 30 элементов — ресторанов и разного рода информации о них, каждый из которых, в свою очередь, компонируется из +- 35 шаблонов. Так как в качестве шаблонизатора используется php и больше ничего то никакого кеширования там нет. В итоге, нам нужно скомпонировать около 900 шаблонов.

Перед работой с шаблонами, я смог, при помощи минимальных оптимизаций кода, сократить время вывода страницы на 1 секунду (30%) до +-2 секунд:

Loading Time: Base Classes      0.0274
Controller Execution Time   1.9403
Total Execution Time    1.9687

Это было все еще слишком много

Решение

Понятное дело, что компоновка около 900 шаблонов дело затратное, тем более на php.
Поэтому, нужно было "склеить" все эти шаблоны в один, чтобы не делать это каждый раз когда запрашивается страница.

Использование готового шаблонизатора типа twig или smarty отпали сразу, так как пришлось бы переписывать все контроллеры, и шаблоны а их очень много.

В то время я уже был немного знаком с AST деревьями.
Шаблоны представляли что-то в следующем виде:

...
<div class="brand-block">
        <?php $this->load->view('payment_block', array('brand' => $brand); ?>
        <?php $this->load->view('minimal_block', array('brand' => $brand)); ?>
        <?php $this->load->view('deliverytime_block', array('brand' => $brand)); ?>
        <?php if (!$edit): ?>
             <?php $this->load->view('deliveryprice_block', array('criteria' =>$criteria); ?>
        <?php endif; ?>
</div>
...

Конструкция

$this->load->view(string $templatePath,array $params)

делает "include" с передачей дополнительных параметров $params
Суть задачи была в том, чтобы заменить все такие вызовы на содержимое самих шаблонов и передачу в них параметров inline. Рекурсивно.

Интересно, подумал я и взялся за инструменты которых нашлось аж один: Nikic PHP-Parser. Это очень мощный инструмент который позволяет делать разного рода манипуляции над абстрактным синтаксическим деревом вашего кода и потом сохранять измененное дерево обратно в php код. И все это можно делать в самом же php — парсер не имеет каких-либо зависимостей от с-расширений и может работать на php 5.2+.

Реализация

PHP-Parser предоставляет удобные инструменты для работой с AST: интерфейсы NodeVisitor и NodeTraverser при помощи которых мы и будем сооружать наш оптимизатор.

Главное — это найти все вызовы метода view на свойстве класса load и понять, что за шаблон должен быть загружен. Это можно проделать с помощью NodeVisitor. Нас интересует его метод leaveNode(Node $node) который будет вызван когда NodeTraverser будет "уходить" с узла дерева AST:

class MyNodeVisitor extends NodeVisitorAbstract {

 public function leaveNode(Node $node) {
    // если тип узла - вызов метода то обрабатываем его
    if ($node instanceof NodeExprMethodCall) {
            // проверяем, вызов ли это нужного нам метода 
            if ($node->name == 'view') {
                 // тут также нужно проверить на чем этот метод вызывается
                 // возможно это не функционал Codeigniter'a, тогда у нас будет ошибка
                 // я это проигнорировал :)

                // мы должны проверить, сможем ли мы узнать какой шаблон подключается.
                // если параметр - скалярная строка, тогда без проблем
                // можно достать информацию и с других типов, но это сложнее и мы это пропустим
                if ($node->args[0]->value instanceof PhpParserNodeScalarString_) {

                    // дадим методу уникальное имя, чтобы потом можно было правильно обработать
                    $code = md5(mt_rand(0, 7219832) . microtime(true));
                    $node->name = 'to_be_changed_' . $code;
                    $params = null;

                    // сохраним параметры, которые нам нужно будет передать `inline`
                    if (count($node->args) > 1) {
                        if ($node->args[1]->value instanceof NodeExprArray_) {
                            $params = new NodeExprArray_($node->args[1]->value->items, [
                                'kind' => NodeExprArray_::KIND_SHORT,
                            ]);
                        } else {
                            if ($node->args[1]->value->name != 'this') {
                                $params = $node->args[1]->value;
                            }
                        }
                    }

                    // сохраним место, где мы должны будем заменить шаблон
                    // замена происходит в другом прогоне по коду
                    $this->nodesToSubstitute[] = new TemplateReference($this->nodeIndex, $node->args[0]->value->value, $params, $code);
                }
            }
...

Таким образом мы сможем выделить все элементы которые должны заменить. Также можно сделать замену и любых других элементов: явных require, include и т.д.

Не забываем что замену нужно делать рекурсивно вглубь. Для этого, нужно сделать обертку над PHP-Parser где именно и будет происходить замена на внутренности шаблона:

Код обработчика

class CodeigniterTemplateOptimizer {

    private $optimizedFiles = [];
    private $parser;
    private $traverser;
    private $prettyPrinter;
    private $factory;
    private $myVisitor;
    private $templatesFolder = '';

    public function __construct(string $templatesFolder) {
        $this->parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP5);

        $this->traverser = new MyNodeTraverser();
        $this->prettyPrinter = new PrettyPrinterStandard();
        $this->factory = new BuilderFactory();

        $this->templatesFolder = $templatesFolder;

        $this->myVisitor = new MyNodeVisitor();

        $this->traverser->addVisitor($this->myVisitor);
    }

    public function optimizeTemplate(string $relativePath, $depth = 0, $keepOptimizing = true) {
        if (substr($relativePath, -4, 4) !== '.php') {
            $relativePath .= '.php';
        }

        if (!isset($this->optimizedFiles[$relativePath])) {

            $templatePath = $this->templatesFolder . $relativePath;

            if (file_exists($templatePath)) {

                $templateOffset = 0;

                $notOptimized = file_get_contents($templatePath);

                // читаем код в AST
                $stmts = $this->parser->parse($notOptimized);

                if ($keepOptimizing) {
                    $this->myVisitor->clean();

                    $this->traverser->setCurrentWorkingFile($relativePath);

                    // здесь мы обходим наше AST
                    $stmts = $this->traverser->traverse($stmts);

                    // Получаем список элементов к замене от MyNodeVisitor
                    $inlineTemplateReference = $this->myVisitor->getNodesToSubstitute();
                    ++$depth;

                    $stmsBefore = count($stmts);

                    foreach ($inlineTemplateReference as $ref) {
                        // погружаемся глубже - рекурсивно обрабатываем шаблоны вглубь
                        $nestedTemplateStatements = $this->optimizeTemplate($ref->relativePath, $depth);

                        $subtempalteLength = count($nestedTemplateStatements);

                        $insertOffset = $ref->nodeIndex + $templateOffset;

                        $pp = new PrettyPrinterStandard();

                        // вставляем параметры для шаблона `inline`:  при помощи конструкции `extract`
                        if ($ref->paramsNodes) {
                            array_unshift($nestedTemplateStatements, new NodeExprFuncCall(new NodeName('extract'), [$ref->paramsNodes]));
                        }

                        // мы нашли то место, где должны вставить содержание шаблона
                        if (get_class($stmts[$insertOffset]) === 'PhpParserNodeExprMethodCall' && ($stmts[$insertOffset]->name === "to_be_changed_" . $ref->code)) {

                            // чтобы не "ламать" набор стейтментов родительского AST 
                            // вставляем шаблон в if(1), чтобы он выглядел как один элемент
                            $stmts[$insertOffset] = new NodeStmtIf_(new NodeScalarLNumber(1), [
                                'stmts' => $nestedTemplateStatements
                            ]);
                        } else {
                            // этот кусок кода намеренно вырезан, здесь вложенная обработка ast
                        }
                    }
                }

                // записываем в кеш "оптимизированных" шаблонов. 
                // В этот момент уже все  вложенные шаблоны оптимизированы 
                $this->optimizedFiles[$relativePath] = $stmts;
            } else {
                throw new Exception("File not exists `" . $templatePath . "` when optimizing templates");
            }
        }

        // возвращаем оптимизированный шаблон
        return $this->optimizedFiles[$relativePath];
    }

    public function writeToFile(string $filePath, $nodes) {
        $code = $this->prettyPrinter->prettyPrintFile($nodes);

        // create directories in a path if they not exists
        if (!is_dir(dirname($filePath))) {
            mkdir(dirname($filePath), 0755, true);
        }

        // write to file
        file_put_contents($filePath, $code);
    }

} 

Вот и все, запускаем оптимизатор:

    // создаем объект оптимизатора с параметром - путем к шаблонам 
    $optimizer = new CodeigniterTemplateOptimizer('./views/');
    // сохраняем оптимизированный шаблон куда нужно
    $optimizer->writeToFile($to, $optimizer->optimizeTemplate($from));

При помощи DirectoryIterator можно за две минуты соорудить скрипт который будет оптимизировать всю папку шаблонов.

Выводы и результаты

После замены шаблонов на оптимизированные, мне удалось сократить более чем 1с времени на выполнение, результаты профайлера Codeigniter:

Loading Time: Base Classes      0.0229
Controller Execution Time   0.7975
Total Execution Time    0.8215

При помощи оптимизации шаблонов мне удалось сократить больше времени чем при оптимизации php-кода. Затраты на оптимизацию шаблонов несопоставимы с изменением многих строк кода. Также оптимизация шаблонов никаким образом не изменяет поведение приложения (это ж ведь просто "склеивание") что есть очень положительным фактом.

Фрагменты кода приведенные в статье были адаптированы для наведения общих моментов которые помогут вам разобраться и не претендуют на работоспособность.

Автор: dot5enko

Источник

Поделиться

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