Как стать автором
Обновить
VK
Технологии, которые объединяют

Текстовые шаблонизаторы и их реализация

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

Многие из нас пользовались шаблонизаторами текстов. Twig в PHP, text/template в Go, Jinja в Python — их сотни.


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


Наиболее детально рассмотрим KTemplate, который я написал для KPHP (на PHP он тоже работает без проблем).



Какие бывают языки шаблонов


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



Обычно мы ожидаем, что в среднестатистическом шаблонизаторе будут следующие возможности:


  • циклы (for) и базовые условные конструкции (if);
  • вставка динамических данных в произвольную часть шаблона;
  • базовые функции для преобразования данных, типа trim;
  • механизмы экранирования;
  • подключение одних шаблонов в другие (наследование или include);
  • расширяемость хотя бы на уровне определения своих функций.

Больше всего мне знакомы шаблонизаторы, применяемые в server-side рендеринге. Эти шаблоны состоят из конструкций следующих категорий:


  1. Текст вне выражений. Никак не преобразуется (но на него могут влиять другие теги).
  2. Выражения, результат вычисления которых подставляется вместо самого выражения.
  3. Особые конструкции, типа if. Результат рендеринга зависит от конкретного оператора.

Выражения выделяются особыми токенами. Это может быть, например, пара {{ и }}. Особые операторы могут иметь как такой же синтаксис, так и слегка отличный (например, {% и %}).


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


{% if item %}
    <p>{{ item.name }}:</p>
{% end %}

Если посмотреть на этот шаблон с точки зрения шаблонизатора, то получим следующее:


IfStatement
    cond={
        Lookup key="item"
    }
    body={
        EchoText value="\n  <p>"
        EchoExpr expr={
            Lookup key="item.name"
        }
        EchoText value=":</p>\n"
    }

Это видение поможет нам далее, когда мы будем размышлять о том, как это всё можно вычислять.


Способы компиляции и исполнения шаблона


Для начала нам нужно ответить на вопрос, где могут применяться шаблоны.


  • В client-side и server-side рендеринге интерфейсов (не ограничено вебом).
  • В преобразовании данных или генерации новых.
  • В кодогенерации (это не всегда самый аккуратный вариант, зато простой).
  • В параметре для форматирования результатов (например, go list -f).

Несколько примеров того, что имеется в виду под go list:


# Шаблоны в Go немного причудливые...

$ go list -f '{{len .Deps}}' errors
14

$ go list -f '{{join .Imports "\n"}}' errors
internal/reflectlite

$ go list -f '{{range .GoFiles}}{{printf "> %s\n" .}}{{end}}' errors
> errors.go
> wrap.go

Шаблоны могут быть динамическими или статическими. Статическими мы можем назвать те шаблоны, которые никогда не меняются и известны на этапе запуска (или компиляции) проекта. Динамические шаблоны могут генерироваться на лету или задаваться пользователями.


Динамические шаблоны могут быть полезны для систем, которые позволяют сконфигурировать приложение без его перекомпиляции. Они также нужны в примере с go list -f. Утилита phpgrep тоже использует text/template для форматирования результатов.


Основные способы преобразовать исходный код в готовый к исполнению вид:


  1. Сгенерировать код на некотором ЯП*, который реализует конкретный шаблон.
  2. Исполнить шаблон в текущем контексте, без подгрузки нового кода.

Чаще всего «некоторый язык программирования» — это тот же язык программирования, но для приложения на C мы можем представить использование Lua для шаблонизации.

После такого преобразования (компиляции шаблона) мы можем его исполнить сколько угодно раз.


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


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


Для программ на KPHP эта опция недоступна. Если при этом нужна поддержка динамических шаблонов, то придётся выбирать второй вариант и исполнять эти шаблоны своими силами. text/template из Go тоже работает по этому принципу, а вот valyala/quicktemplate генерирует Go-код перед его запуском (первый способ, шаблоны статические).


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


KTemplate почти во всём копирует синтаксис Twig. Пока будем считать все различия незначительными, но вернёмся к ним ближе к концу статьи.


Исполнение через кодогенерацию


valyala/quicktemplate компилирует шаблоны в Go-файлы, которые затем нужно включать в сборку приложения. Обновление таких шаблонов во время исполнения в теории возможно, но на практике этим лучше не заниматься.


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


Шаблон из нашего примера выше может превратиться во что-то такое:


// {% if item %}
//     <p>{{ item.name }}:</p>
// {% end %}
func renderExample(w *writer, data any) {
    if toBool(getItem(data, "item")) {
        w.WriteString("\n  <p>")
        w.WriteAny(getItem(data, "item", "name"))
        w.WriteString(":</p>\n")
    }
}

Twig и многие другие шаблонизаторы работают по такому же алгоритму, но код может генерироваться и загружаться прямо во время работы приложения. В PHP можно без проблем создать файл с классом, а потом подключить его через autoloader или прямой require.


Когда мы запускаем скомпилированный Twig-шаблон, он всё равно будет интерпретироваться, но не нашей программой (на PHP), а самим интерпретатором PHP (на C).


Исполнение через прямое интерпретирование


text/template и html/template строят из текста шаблона что-то вроде AST с дополнительной информацией для исполнения.


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


Минусы такого подхода:


  • нетривиальная сериализация;
  • шаблоны, готовые к исполнению, занимают избыточное место в памяти;
  • относительно низкая скорость исполнения шаблонов.

Преимущества — простота реализации и скорость компиляции шаблонов.


Кстати, один из интерпретаторов Go, написанный на Go, yaegi, тоже использует обход деревьев для исполнения. Это одна из причин, почему его производительность оставляет желать лучшего.


KTemplate идёт дальше. Дерево разбора преобразуется во что-то вроде байт-кода для последующего исполнения (сам AST нам после этого не нужен).


Преимущества байт-кода почти противоположны минусам обхода дерева:


  • Простая сериализация. Просто набор байтов, который мы пишем и читаем как есть
  • Байт-код гораздо компактнее, чем развесистые деревья
  • Неплохая скорость исполнения шаблонов

Минус тоже довольно очевидный — более медленная скорость компиляции.


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


Набор команд KTemplate


Ранее я говорил, что KTemplate генерирует байт-код. Это не совсем так. Используемое представление лучше называть p-code (почти синоним), так как команды занимают не один байт.


Так как в PHP нет нативных массивов байтов, а использовать строки для этой цели не очень разумно (типа «символ» у нас тоже нет, только строки из одного символа), KTemplate кодирует команды в массивах из int’ов. И в PHP, и в KPHP int занимает 8 байт. В этот размер влезают все необходимые инструкции.


Я рассматривал FFI-массивы в качестве альтернативы, но в PHP
они работают довольно медленно по сравнению с обычными типами.

Может показаться, что это расточительно — использовать 8 байт вместо одного в байт-коде, однако это не совсем так. KTemplate использует регистровую модель виртуальной машины, поэтому практически в каждой команде у нас есть от двух до трёх операндов. В байт-коде это бы уже было 4 байтами. Некоторые команды могут достигать 7 байт из-за дополнительных операндов типа индексов констант.


Приятное свойство этого представления состоит в том, что все команды имеют одинаковую ширину. Весь int целиком мы будем называть opdata — это opcode плюс операнды инструкции. Опкод всегда занимает 1 байт и находится в младшем разряде.


Почти все интерпретаторы байт-кода выглядят как цикл по байт-коду с переключением (switch) по опкоду. Опкод описывает, какую операцию нужно произвести. Примерами опкодов могут быть JUMP, перемещающий курсор интерпретатора (program counter) в другое место внутри байт-кода, и CONCAT, выполняющий конкатенацию указанных аргументов и складывающий результат в описанное инструкцией место (например, регистр виртуальной машины).


Следующее выражение всегда извлекает opcode из opdata:


$opcode = $opdata & 0xff;

Всё остальное внутри opdata — это операнды (аргументы). Несколько примеров операндов:


  • слот (регистр) виртуальной машины (на чтение или запись);
  • индекс константы из пула;
  • непосредственное значение (для малых чисел и данных типа bool).

Большая часть операндов занимает 1 байт, но некоторые могут занимать 2 байта. Разберём инструкцию JUMP_FALSY:


0x29 pcdelta:rel16 cond:rslot

0x29 — это опкод. Затем идёт аргумент pcdelta, занимающий два байта. Последним аргументом идёт индекс регистра-условия (1 байт).


Распаковка всех данных инструкции:


// $pc -- это смещение внутри исполняемого байт-кода
$opdata = $code[$pc];
$op = $opdata & 0xff;
$rel16 = ($opdata >> 8) & 0xffff;
$cond_slot = ($opdata >> 24) & 0xff;

Попробуем представить эти байты инструкции (порядок выглядит обратным из-за особенностей нашего энкодинга):


[cond_slot] [rel16.1] [rel16.2] [op]

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


$cond = $state->slots[$cond_slot + $frame_offset];

А вот реализация опкода JUMP_FALSY:


if (!$cond) {
    $pc += $rel16;
}

Результаты любого выражения, заключённого в {{}}, используются для добавления результата в выходной буфер. Это означает, что мы можем ввести псевдорегистр slot0, который будет неявным и продублировать большую часть команд для поддержки этого аргумента. Сам slot0 будет локальной переменной, доступ к которой будет прямым, без массива.


Предположим, у нас есть следующий фрагмент шаблона:


{{ x ~ y }}

Результатом компиляции было бы что-то такое:


load slot1 $x
load slot2 $y
concat slot1 $x $y
echo slot1 

Введём slot0:


  load slot1 $x
  load slot2 $y
- concat slot1 $x $y
- echo slot1 
+ concat_slot0 $x $y
+ echo_slot0

Получим на две операции с массивом меньше (чтение+запись).


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


Константные данные, например числа и строки, хранятся отдельно от байт-кода, в плоских массивах. Внутри инструкции используется индекс, по которому мы можем найти нужное значение в массиве констант. Одни и те же значения имеют идентичные индексы. Уникальные индексы присваиваются на этапе компиляции шаблонов.


Полный и актуальный список набора команд можно найти в репозитории. Если интересно как именно создаётся этот байт-код, начать можно с src/Internal/Compile/Compiler.php.


Связывание данных


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


Когда внутри шаблона встречается выражение типа x или x.y, нужно понять, как извлечь эти значения из контекста исполнения.


Самый наивный вариант — это представлять данные в виде простого массива, по которому мы выполняем поиск. Тогда x будет искаться в $data['x'], а x.y в $data['x']['y']. У этого пути есть несколько недостатков:


  • работать можно будет только с массивами, доступ к объектам не сработает;
  • для запуска шаблона нужно будет собирать данные в такой массив => лишние копирования.

Если же использовать обёртку над доступом по ключу, то можно будет поддержать и объекты. Заменяем прямой поход к массиву на вызов getProp(getProp($data, 'x'), 'y') и готово. Twig делает примерно то же самое.


Внутри функции getProp будет проверяться, что за объект туда передан: массив, объект или что-то иное. В случае объектов потребуется проверка на isset($data->$key) и извлечение через $data->$key. Уже понимаете, к чему я веду? В KPHP такое работать не будет. Да и система типов не позволяет смешивать в массиве примитивные типы вместе с объектами.


Как же здесь справляется text/template из Go? Через рефлексию. Это не очень эффективно, но работает. Вызов функций происходит там через ту же рефлексию, но уже немного по другим причинам. Чтобы, например, пользователь мог привязать функцию с типизированными аргументами, а шаблонизатор как-то смог её вызвать.


В KPHP рефлексии нет. Поддержки $object->$field тоже нет. Как же быть? Ограничиваться массивами не хочется — слишком примитивно и требует копирования данных.


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


// Интерфейс состоит всего из одного метода!
interface DataProviderInterface {
    public function getData(DataKey $key): mixed;
}

Пример разрешения ключей:


public function getData(DataKey $k): mixed {
    if ($this->matchKey2($k, 'foo', 'bar')) {
        return $this->foo->bar;
    }
    if ($this->matchKey3($k, 'foo', 'baz', 'qux')) {
        return $this->foo->baz->qux;
    }
    // ...обработка остальных ключей
    return null;
}

Пример getData для ArrayDataProvider:


public function getData(DataKey $k): mixed {
    switch ($k->num_parts) {
    case 1:
        return $this->data[$k->part1];
    case 2:
        return $this->data[$k->part1][$k->part2];
    default:
        return $this->data[$k->part1][$k->part2][$k->part3];
    }
}

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


Основным минусом является то, что придётся реализовывать несколько провайдеров данных. В худшем случае — по провайдеру на каждую точку входа для отрисовки шаблонов.


Я как-то обсуждал тему того, как ещё можно реализовать доступ к данным из шаблонов, в подкасте Golang United #23. Возможно, вам будет интересно послушать.


Кеширование извлекаемых данных


Каждое извлечение a.b.c — это вызов интерфейсного метода плюс исполнение самого кода разрешения ключа. Мы ожидаем, что повторное разрешение того же ключа даст нам идентичный результат. KTemplate выполняет кеширование повторных запросов ключа. Первый доступ в дереве отрисовки шаблонов по ключу будет честным. Каждый следующий доступ будет извлекать значение из кеша.


Кеш довольно прост и эффективен. Для определения, есть ли данный ключ в кеше, используется ровно один бит из маски. Сами закешированные значения хранятся в локальных для фрейма слотах («регистрах»). Максимально во фрейме могут кешироваться до 64 ключей.


Количество чтений Время без кеширования С кешированием Разница
1 128 ns 145 ns +13%
2 221 ns 158 ns -15%
10 0,9 µs 0,39 µs -57%
100 7,0 µs 2,4 µs -66%

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


Экранирование


Шаблонизатор Стратегия экранирования по умолчанию
text/template (Go) Нет экранирования
html/template (Go) Семантическое экранирование
Twig, KTemplate (PHP) HTML-экранирование

text/template выводит текст как есть, без экранирования.


html/template пытается понять, в каком контексте выполняется вычисление выражения. Если внутри атрибута href, шаблонизатор применит url-экранирование. Если внутри тела html-тега, будет использоваться html-экранирование. Семантическое экранирование — это красиво, но оно требует понимания структуры шаблонов.


Twig и KTemplate по умолчанию экранируют всё с помощью escape('html'), но это поведение можно переопределить (в том числе отключить экранирование полностью).


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


Расширяемость


Шаблонизаторы из стандартной библиотеки Go позволяют определять свои функции для вызова из шаблонов. На этом всё.


Twig даёт невероятные возможности для расширения. Можно создавать свои теги, операторы, фильтры, функции… Почти любая ваша фантазия может быть реализована как Twig-расширение (если ваши фантазии связаны с шаблонизацией текста). Twig также поддерживает макросы.


Для KTemplate-шаблонов можно определить функции и фильтры. Поддержки макросов пока нет, но мы добавим её в будущем.


Рендеринг вложенных шаблонов


Twig и Jinja используют наследование шаблонов. В html/template из стандартной библиотеки Go вложенные шаблоны обрабатываются через команды define, block и template (их возможности более ограниченные). KTemplate использует похожую модель.


Каждый шаблон имеет уникальное имя. Используя это имя, мы можем попросить шаблонизатор отрисовать в указанном месте требуемый шаблон с переданными данными. В некотором смысле это эквивалентно вызову функции.


В простейшем случае шаблон не имеет никаких явных аргументов. Он использует только определённые в нём же локальные переменные и внешние данные, получаемые от DataProvider. Параметры шаблонов в KTemplate описываются через {% param %}. Параметры нужны в том случае, когда этот шаблон мы хотим рендерить из других шаблонов, имея при этом возможность его параметризировать.


Параметры определяются в самом верху шаблона:


{% param $name = "button" %}
{% param $label = "" %}
{% if $label %}
<label>
    {{$label}}:
    <input id="ui-{{$name}}" type="button" value="{{$name}}">
</label>
{% else %}
    <input id="ui-{{$name}}" type="button" value="{{$name}}">
{% end %}

Использование этого шаблона из другого:


{% include "ui/button.template" %}
    {% arg $name = "example1" %}
{% end %}

{% include "ui/button.template" %}
    {% arg $name = "example2" %}
    {% arg $label = "Example" %}
{% end %}

Такое использование иногда называют частичными шаблонами (partials). С помощью include мы можем достичь поведения, похожего на наследование шаблонов. Возьмём пример из Twig:


<!-- base.html -->
<!DOCTYPE html>
<html>
    <head>
        {% block head %}
            <link rel="stylesheet" href="style.css"/>
            <title>{% block title %}{% endblock %} - My Webpage</title>
        {% endblock %}
    </head>
    <body>
        <div id="content">{% block content %}{% endblock %}</div>
        <div id="footer">
            {% block footer %}
                &copy; Copyright 2011 by <a href="http://domain.invalid/">you</a>.
            {% endblock %}
        </div>
    </body>
</html>

<!-- child.html -->
{% extends "base.twig" %}

{% block title %}Index{% endblock %}
{% block head %}
    {{ parent() }}
    <style type="text/css">
        .important { color: #336699; }
    </style>
{% endblock %}
{% block content %}
    <h1>Index</h1>
    <p class="important">
        Welcome to my awesome homepage.
    </p>
{% endblock %}

А теперь перепишем его на KTemplate:


<!-- base.html -->
{% param $title = "" %}
{% param $head = "" %}
{% param $content = "" %}
{% param $footer %}
    &copy; Copyright 2011 by <a href="http://domain.invalid/">you</a>.
{% end %}

<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet" href="style.css"/>
        <title>{{ $title }} - My Webpage</title>
        {{ $head|raw }}
    </head>
    <body>
        <div id="content">{{ $content|raw }}</div>
        <div id="footer">
            {{ $footer|raw }}
        </div>
    </body>
</html>

<!-- child.html -->
{% let $head %}
    <style type="text/css">
        .important { color: #336699; }
    </style>
{% end %}
{% let $content %}
    <h1>Index</h1>
    <p class="important">
        Welcome to my awesome homepage.
    </p>
{% end %}

{% include "base.ktemplate" %}
    {% arg $title = "Index" %}
    {% arg $head = $head %}
    {% arg $content = $content %}
{% end %}

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


  • Вместо extends пишем include и вставляем его в конец шаблона.
  • block на стороне дочернего шаблона называется arg.
  • block на стороне родительского шаблона называется param.

include создаёт новый фрейм исполнения, смещая значение $frame_offset на размер текущего фрейма. Стек фреймов умеет расширяться по необходимости.


Декомпиляция шаблонов


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


Декомпилировать сгенерированные исходные коды на Go или PHP не нужно. Это уже исходные тексты, которые можно изучать и отлаживать. А вот байт-код или дерево для исполнения просто так не отобразить без дополнительных инструментов.


KTemplate предоставляет дизассемблер для своих шаблонов:


// "-" около тега удаляет пробельные символы с указанной стороны,
// аналогично это работает в Twig и text/template
$loader = new ArrayLoader([
    'main' => '
      {%- let $x = "abc" %}
      {%- if $x %}
        {{- f($x ~ external_data) }}
      {%- end %}
      {{- external_data -}}
    ',
]);

$engine = new Engine(new Context(), $loader);
$engine->registerFunction1('f', function ($s) {
    return strrev($s);
});

$data = new ArrayDataProvider([
  'external_data' => '123',
]);

$t = $engine->load('main');
$result = $engine->renderTemplate($t, $data);
$decompiled = $engine->decompileTemplate($t);
$disasm = $decompiled->bytecode;

var_dump($result);
var_dump(implode("\n", $disasm));

Результатом рендеринга шаблона будет "321cba123".


Содержимое $disasm:


  LOAD_STRING_CONST slot2 `abc`
  JUMP_FALSY L0 slot2
  LOAD_EXTDATA_1 slot4 slot1 external_data
  CONCAT slot3 slot2 slot4
  CALL_SLOT0_FUNC1 *slot0 slot3 f
  OUTPUT_SLOT0 *slot0
L0:
  OUTPUT_EXTDATA_1 slot1 external_data $1
  RETURN

Фрейм для шаблона можно описать так:


       кеш для external_data
       |             
       |             слоты для временных значений
       |             |      \
[slot0][slot1][slot2][slot3][slot4]
|             |
|             локальная переменная $x
|
ненастоящий слот, зарезервирован

Кеширование скомпилированных шаблонов


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


Кеширование шаблонов — явление довольно специфичное. Для Go-приложений это не имеет особого смысла. Все шаблоны, как правило, компилируются один раз на старте приложения, а затем используются внутри разных запросов (если речь идёт о каком-то сервере). В случае PHP (и KPHP) модель исполнения иная — и вот здесь мы уже всерьёз можем говорить о кешировании.


Модель кеширования в Twig следующая. Шаблонизатор знает, каким должно быть имя генерируемого класса для отдельно взятого шаблона. Если этот класс уже загружен, то всё отлично, можно исполнять шаблон. Если не загружен, можно попробовать загрузить его из кеш-директории, куда мы складываем сгенерированные классы. Если же и там класса нет, будет производиться компиляция.


KTemplate работает почти по такой же схеме, но вместо PHP-классов мы сохраняем данные шаблонов. Их p-код, константы и метаданные.



Стратегия инвалидации кеша у Twig и KTemplate практически идентичная: по размеру и mtime файла.


Отличия KTemplate от Twig


Строки


В KTemplate нет интерполяции строк, но есть raw string literal. Эти литералы особо полезны для регулярных выражений.


{% if $x matches `/\d+/` %}
{# vs #}
{% if $x matches "/\\d+/" $}

Локальные переменные


В Twig есть только {% set %}. Нет синтаксического разделения на декларацию и модификацию переменных. Это похоже на то, как ведут себя переменные в PHP, но есть важные преимущества, из-за которых KTemplate использует пару {% let %} + {% set %}:


  • читабельность: становится понятно, где объявлена переменная;
  • ошибки обращения к неопределённым переменным во время компиляции шаблона;
  • нет страха коллизии имён между разными лексическими блоками;
  • более эффективная (и простая) аллокация слотов для короткоживущих переменных.

{% let $x = 10 %}
{{ $x }} => 10

{% set $x = 20 %}
{{ $x }} => 20

Лексический блок в KTemplate создают следующие конструкции: for, if и блочная форма присваивания в let/set/param/arg.


{# Блочное присваивание вводит лексический блок #}
{%- let $x %}
    {%- let $i = "foo" %}
    {{- $i -}}
{% end %}

{# Это уже другая переменная $i #}
{% let $i = "bar" %}

{# Выводит "foo" и "bar" #}
>{{ $x }}<
>{{ $i }}<

Другие различия


В KTemplate не реализованы следующие возможности Twig:


  • именованные параметры при вызове функций;
  • вызов функции без скобочек;
  • теги вроде spaceless;
  • литералы для массивов.

В KTemplate другая модель включения шаблонов и разрешения связанных с шаблоном данных.


Сравнение производительности KTemplate и Twig


Мы написали интерпретатор на интерпретируемом PHP. Какие будут последствия для производительности?


Бенчмарки будем гонять на трёх шаблонах:


$twig_templates = [
    'constant' => 'hello, world',
    'simple' => '
        {{ x ~ y }}
    ',
    'normal' => '
        {% set v = y %}
        {% for item in items %}
            {# comment #}
            {% set s = item ~ x ~ v %}
            {% if item %}
                > {{ s }}
            {% endif %}
        {% endfor %}
    ',
    'nested' => '
        {% include "normal" %}
        {% include "simple" %}
        {% include "simple" %}
        {% include "simple" %}
        {% include "simple" %}
        {% include "simple" %}
        {% include "simple" %}
    ',
];

Данные для шаблонов:


$template_data = [
    'x' => 53,
    'y' => 'foo',
    'items' => [
        'a',
        'b',
        'c',
        '',
        'd',
        'e',
        'f',
        '',
    ],
];

Запускать тесты я буду с помощью ktest. old — это Twig, new — это KTemplate:


name                    old time/op  new time/op  delta
Engine::RenderConstant  2.83µs ± 3%  0.84µs ± 1%   -70.34%
Engine::RenderSimple    3.23µs ± 3%  3.41µs ± 1%    +5.48%
Engine::RenderNormal    10.6µs ± 1%  26.3µs ± 0%  +147.28%
Engine::RenderNested    24.9µs ± 2%  51.4µs ± 3%  +106.46%

Результаты интерпретировать так:


  • Twig работает тем быстрее, чем сложнее шаблон;
  • механизм включения шаблонов в KTemplate довольно эффективен.

Для KPHP мы не можем собрать результаты для Twig. Вместо этого сравним два KTemplate, запущенных на разных языках: PHP и KPHP.


name                    php time/op  kphp time/op  delta
Engine::RenderConstant   841ns ± 1%   513ns ± 1%  -39.03%
Engine::RenderSimple    3.41µs ± 1%  1.53µs ± 0%  -55.16%
Engine::RenderNormal    26.3µs ± 0%   8.4µs ± 1%  -67.82%
Engine::RenderNested    51.4µs ± 3%  14.3µs ± 2%  -72.28%
[Geo mean]              7.88µs       3.12µs       -60.48%

KTemplate на KPHP работает более чем в 2-3 раза быстрее, чем на PHP.


А теперь самое интересное сравнение. Будет ли KTemplate на KPHP шаблонизировать медленнее, чем Twig на PHP?


name                    phh time/op  kphp time/op  delta
Engine::RenderConstant  2.83µs ± 3%  0.51µs ± 1%  -81.92%
Engine::RenderSimple    3.23µs ± 3%  1.53µs ± 0%  -52.70%
Engine::RenderNormal    10.6µs ± 1%   8.4µs ± 1%  -20.41%
Engine::RenderNested    24.9µs ± 2%  14.3µs ± 2%  -42.77%
[Geo mean]              7.01µs       3.12µs       -55.57%

Ответ: нет, не будет. Скорее всего, будет даже быстрее, так как при компиляции KTemplate в C++ мы получаем интерпретатор, написанный на C++, а не на PHP.


Использованная в бенчмарках версия PHP:


$ php --version
PHP 8.1.8 (cli) (built: Jul 11 2022 08:29:57) (NTS)

ktest по умолчанию включает JIT в PHP8+. Оба шаблонизатора в этих бенчмарках использовали закешированный шаблон, сама компиляция шаблона в замеры не попадала. Оба шаблонизатора работали с включённым экранированием со стратегией html.


Настройки JIT, используемые в ktest:


opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=96M
opcache.jit=on

Оптимизации KTemplate


Можно ли сказать, что KTemplate — это эффективный шаблонизатор для PHP? С одной стороны, мы знаем, что он работает медленнее, чем Twig. А с другой, тот разрыв, что мы наблюдаем, гораздо ниже, чем можно было бы ожидать. Всё-таки это интерпретатор внутри другого интерпретатора.


Разберём несколько примеров оптимизаций, которые делает KTemplate, чтобы быстро работать на KPHP и не сильно отставать от Twig при исполнении в режиме PHP.


Конкатенация строк


Допустим, есть некоторое выражение с конкатенацией:


{% let $a = "a" -%}
{% let $b = "b" -%}
{% let $e = "e" -%}
{{ $a ~ $b ~ "c" ~ "d" ~ $e }}

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


  LOAD_STRING_CONST slot1 `a` # init $a
  LOAD_STRING_CONST slot2 `b` # init $b
  LOAD_STRING_CONST slot3 `e` # init $c
  LOAD_STRING_CONST slot4 `cd` # const-folded "c" ~ "d"
  CONCAT3_SLOT0 *slot0 slot1 slot2 slot4
  APPEND_SLOT0 *slot0 slot3
  OUTPUT_SLOT0 *slot0

  • Где это возможно и имеет смысл, KTemplate сворачивает константные выражения.
  • Кроме обычного CONCAT, есть ещё версия для трёх операндов.
  • Для уменьшения количества временных строк мы достраиваем результат через APPEND.
  • Всё выражение проходит через slot0 — самый эффективный «регистр».

В этом примере можно было бы вообще всё свернуть в вывод константной строки, но на практике переменные не так часто хранят неизменяемые константные значения.

Слияние инструкций


Часто инструкции, не связанные с выводом результата, вставляются между OUTPUT-инструкциями. Вот пример, демонстрирующий это поведение:


{% let $a = "a" %}
{% let $b = "b" %}
{% let $e = "e" %}
Hello, World!

Наивная кодогенерация выглядела бы так:


  LOAD_STRING_CONST slot1 `a` # init $a
  OUTPUT_SAFE_STRING_CONST `\n`
  LOAD_STRING_CONST slot2 `b` # init $b
  OUTPUT_SAFE_STRING_CONST `\n`
  LOAD_STRING_CONST slot3 `e` # init $b
  OUTPUT_SAFE_STRING_CONST `\nHello, World!`

Это 6 инструкций для исполнения в интерпретаторе. Поскольку вывод не зависит от этих LOAD-инструкций, можно сгенерировать код получше:


  OUTPUT_SAFE_STRING_CONST `\n\n\nHello, World!`
  LOAD_STRING_CONST slot1 `a` # init $a
  LOAD_STRING_CONST slot2 `b` # init $b
  LOAD_STRING_CONST slot3 `e` # init $b

Это на 2 инструкции меньше. Для стороннего наблюдателя результат исполнения шаблона не изменится, а вот рендерить его стало проще.


Гибридная шаблонизация


Альтернативным решением может быть гибридная шаблонизация, когда на этапе разработки применяется динамический шаблонизатор на PHP, а при подготовке к деплою на KPHP мы используем кодогенерацию. И там, и там шаблоны будут реализовать сгенерированные PHP-классы, но в первом случае будет поддерживаться простое обновление шаблонов "на лету", а во втором случае — нет (шаблоны там будут частью исполняемого файла).


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


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


Тайпхинты


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


Однако, в случае PHP есть некоторая цена производительности, которую приходится платить. Чтобы ускорить исполнение на PHP, я перенёс все тайпхинты в phpdoc.


Пара сравнений с конкретными числами:


без тайпхинтов vs с ними

Compiler::Simple   18.2µs ± 2%  20.1µs ± 1%  +10.63%
Compiler::Normal1   248µs ± 0%   275µs ± 2%  +10.95%
Compiler::Normal2   339µs ± 0%   366µs ± 2%   +8.17%

без тайпхинтов vs с ними (string_types=1) 

Compiler::Simple   18.2µs ± 2%  20.2µs ± 1%  +11.16%
Compiler::Normal1   248µs ± 0%   272µs ± 0%   +9.65%
Compiler::Normal2   339µs ± 0%   362µs ± 0%   +6.86%

Как видимо, тайпхинты замедляют код на ~10%. Но это я специально выключил JIT. Есть популярное мнение, что JIT решает проблему тайпхинтов и что они наоборот ему помогают:


без тайпхинтов vs с ними (opcache.jit=on) 

Compiler::Simple   10.2µs ± 1%  11.7µs ± 1%  +14.86%  (p=0.000 n=9+9)
Compiler::Normal1   119µs ± 0%   158µs ± 0%  +33.01%  (p=0.000 n=8+8)
Compiler::Normal2   159µs ± 4%   203µs ± 1%  +27.69%  (p=0.000 n=9+8)

Всё с точностью до наоборот. Со включенным JIT'ом разница между наличием тайпхинтов и их отсутствием ещё более значительна. 20-30% замедления для проекта типа KTemplate ощутима.


Выводы



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


Оптимальное решение для реализации шаблонизатора часто зависит от того, на чём мы его реализуем. Если это шаблонизатор для PHP, то делать интерпретатор байт-кода будет не самым производительным вариантом. Тем не менее если мы хотим сохранить возможность AOT-компиляции (не обязательно через KPHP), то стоит подумать об этом заранее.


KTemplate можно попробовать онлайн: quasilyte.tech/ktemplate. Там можно позапускать разные шаблоны, а также посмотреть, во что они декомпилируются. Сайт обслуживается сервером на KPHP, работая на почти самой дешёвой виртуалке от Selectel. Замечу, что KPHP-бинарник я компилировал на своей машине, а потом залил его на виртуалку. При статической линковке количество shared-зависимостей будет минимальным, поэтому можно ставить легковесную систему на виртуалку.


Заинтересовались KPHP? Приглашаю вас в чатик сообщества KPHP!

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

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Руслан Дзасохов