メモリ効率の良いテンプレートエンジンの実装をPHPで

Table of Contents

序論

PHPのWebアリプケーションではHTMLを描画するためにテンプレートエンジンが良く使われる。テンプレートエンジンは与えられたテンプレート文字列と変数を元に結果を描画する。この時、描画結果が文字列であるなら、少なくとも文字列の長さだけメモリ空間を必要とする。

一方、Webアプリケーションでは最終的にテンプレートの描画結果をレスポンスボディとして出力すればいいのであって、レスポンスボディを文字列として構築する必要はない。したがって、レスポンスボディを適当な単位に分割して出力することでメモリ使用量を節約できる。

本稿では、この点に着目してメモリ効率の良い描画を提供するテンプレートエンジンの実装を考えた。しかしながら、通常メモリ効率は実行効率とトレードオフであり、実行効率を重視した他の方式も含めた5つの実装を作成して性能を評価した。

実装コンセプト

JavaScriptにはTagged Templatesという機能がある。これはTemplate Literalとして記述されたテンプレート文字列を任意のタグ関数で処理する機能だ。

タグ関数は第1引数に定数文字列の配列、以降の引数にはプレースホルダーに埋め込まれた式が可変長引数で与えられる。したがって、テンプレートは定数文字列とその前後の式に分割される。

function f(strings, ...exprs) {
    console.log({ strings, exprs });
}

const name = 'World';

// {
//   strings: ['<p>Hello <strong>', '</strong>!</p>'],
//   exprs: ['World'],
// }
f`<p>Hello <strong>${name}</strong>!</p>`;
JavaScriptのタグ関数の例

この機能を参考にして、テンプレートを文字列、式、文字列、式...のようなチャンクの繰り返しとして処理することを考えた。これを実装しているのがAbstractCompilerで、後述する5つのコンパイラの実装はこのクラスを継承している。テンプレートはこのクラスのcompile()メソッドによって描画関数を返すPHPコードとしてコンパイルされる。描画関数は変数の辞書を引数にして、返り値は実装毎に異なる描画結果(Generator, array, string, resource, void)を返す。

テンプレートの文法はBladeのサブセットとした。各描画関数の節では以下のようなテンプレートから生成される描画関数を簡略化した形で例示する。

<p>Hello <strong>{{$name}}</strong></p>
描画関数の説明に用いるテンプレート

ベンチマーク結果

最初に、今回作成した5つのコンパイラの実装のベンチマーク結果を示す。ベンチマークではlistarticlesの2種類のテンプレートを使用した。listは要素1つ1つのサイズは小さいが大量(10万)の要素を生成するテンプレートで、articlesは要素のサイズは大きいが、少数(5000)の要素を生成するテンプレートである。

<ul>
    @for ($i = 0; $i < 100000; $i++)
    <li>{{$i}}</li>
    @endfor
</ul>
list.blade.php
<ul>
    @foreach ($articles as $article)
    <li class="article">
        <div class="article">
            <a class="article-title-anchor" href="{{$article['url']}}">{{$article['title']}}</a>
        </div>
        <div class="article-description">{{$article['description']}}</div>
        <div class="article-date">{{$article['date']}}</div>
        <ul class="article-tags">
            @foreach ($article['tags'] as $tag)
            <li class="article-tag-item">{{$tag}}</li>
            @endforeach
        </ul>
    </li>
    @endforeach
</ul>
articles.blade.php

処理系としてはPHP 8.2を使用した。なお、コンパイル処理自体は計測対象としておらず、事前にコンパイル・キャッシュされた描画関数の実行を計測対象とした。ただし、文字列以外の描画結果を返すコンパイラの実装の場合は、描画結果の文字列が得られる所までを計測対象としている。例えばGeneratorによる実装であれば全要素を走査する。

ベンチマークの各項の詳細は以下の通りである。

  • Sharp(String) — 文字列による描画関数を生成する実装
  • Sharp(Array) — 配列による描画関数を生成する実装
  • Sharp(Stream) — ストリーム(php:memory)による描画関数を生成する実装
  • Sharp(PHP) — テンプレートエンジンとしてのPHPによる描画関数を生成する実装
  • Sharp(Generator) — Generatorによる描画関数を生成する実装
  • Sharp(Generator&Buffer) — バッファー付きのGeneratorによる描画関数を生成する実装
  • Blade — Laravelデフォルトのテンプレートエンジン(参考)
  • Twig — Symfonyデフォルトのテンプレートエンジン(参考)
Sharp(Generator&Buffer)Sharp(String)Sharp(Generator)Sharp(Array)Sharp(PHP)Sharp(Stream)TwigBlade010203040Execution Time (ms)24.714.721.821.126.220.427.118.122.718.721.514.339.719.337.919.9articleslistTemplate
各実装の実行時間
Sharp(Generator)Sharp(Generator&Buffer)Sharp(String)Sharp(Stream)Sharp(PHP)BladeTwigSharp(Array)010203040Peak Memory Usage (MB)23.53.331.325.523.53.429.15.718.11.218.11.230.97.536.39.3articleslistTemplate
各実装のピークメモリ使用量

描画関数を生成するコンパイラの実装

文字列による実装

最初に、文字列としてチャンクを結合するという基本的な実装を考えた。文字列のような基本型は高度に最適化されているので、実行効率・メモリ効率いずれもGeneratorの実装に次いで優秀だった。以降の実装はこの実装を指標として論じる。

function render(array $data): string
{
    $contents = '';
    $contents .= '<p>Hello <strong>';
    $contents .= $data['name'];
    $contents .= '</strong>!</p>';
    return $contents;
}
文字列による描画関数

このような描画関数を生成するものとしてStringCompilerを作成した。

配列による実装

次に、配列にチャンクを追記していくという実装を考えた。この実装は、メモリ使用量が非常に大きく、基本的には文字列による実装に対する優位性はない。しかし、唯一articlesの描画の実行効率だけは優れていた。逆説的に要素数の多いlistの実行速度が遅いことから、この実装は要素数が増えると不利になると考えられる。

function render(array $data): array
{
    $contents = [];
    $contents[] = '<p>Hello <strong>';
    $contents[] = $data['name'];
    $contents[] = '</strong>!</p>';
    return $contents;
}
配列による描画関数

このような描画関数を生成するものとしてArrayCompilerを作成した。

ストリームによる実装

次に、I/Oストリームにチャンクを書き込む実装を考えた。今回はphp://memoryを使ってメモリ上へチャンクを書き込む実装を作成した。性能面では実行効率・メモリ効率いずれも文字列による実装より若干劣り、優位性はなかった。

function render(array $data)
{
    $stream = fopen('php://memory', 'r+');
    fwrite($stream, '<p>Hello <strong>');
    fwrite($stream, $data['name']);
    fwrite($stream, '</strong>!</p>');
    return $stream;
}
ストリームによる描画関数

このような描画関数を生成するものとしてStreamCompilerを作成した。

テンプレートエンジンとしてのPHPによる実装

PHP自身はテンプレートエンジンでもあるので、それを使った実装を考えることができる。この方式はBladeやTwigと同様だ。結果は標準出力に出力されるので文字列にするためにはob_start()を呼び出す必要がある。ベンチマークでもこの関数を呼び出しているからか、この実装は文字列による実装に比べて実行効率・メモリ効率いずれも大きく劣り、優位性はなかった。

もし、文字列化する必要がないのであれば、最も性能の良い実装になる可能性はある。しかし、PSR-15アプリケーションとの統合を考えた場合、StreamInterfaceに変換するためには全体の文字列化は不可避だ。仮に、StreamInterface::emit()のようなAPIがあって、直接標準出力に出力することができたならそれが性能的には理想である。

<?php
function render(array $data): void
{ ?>
<p>Hello <strong><?php echo $data['name']; ?>!</strong><?php } ?>
標準出力に出力する描画関数

ジェネレータによる描画関数

最後に、メモリ効率の良いストリーム化された描画を提供する実装を考える。描画をストリーム化するためには描画を複数回に分けて行わなければならない。このような時は処理の一時停止と再開を提供するジェネレータを使うことができる。ジェネレータを使った描画関数は以下のように定義できる。

function render(array $data): \Generator
{
    yield '<p>Hello <strong>';
    yield $data['name'];
    yield '!</strong></p>';
}
ジェネレータによる描画関数

しかし、ジェネレータの停止・再開のコストが高いため、チャンクの数が多大になると実行効率もそれに伴なって悪化していた。対策として、指定されたチャンクの最大サイズを超えるまで文字列にバッファーするように実装を改良した。この実装はメモリ使用量はもちろんのこと、実行速度も文字列の実装と同等かより速く、総合的に最も優秀だった。

function render(array $data): \Generator
{
    $buffer = '';
    $buffer .= '<p>Hello <strong>';
    if (strlen($buffer) > MAX_CHUNK_SIZE) {
        yield $buffer;
        $buffer = '';
    }
    $buffer .= $data['name'];
    if (strlen($buffer) > MAX_CHUNK_SIZE) {
        yield $buffer;
        $buffer = '';
    }
    $buffer .= '!</strong></p>';
    if (strlen($buffer) > MAX_CHUNK_SIZE) {
        yield $buffer;
        $buffer = '';
    }
    if ($buffer !== '') {
        yield $buffer;
    }
}
ジェネレータによる描画関数(バッファー付き)

このような描画関数を生成するものとしてGeneratorCompilerを作成した。

PSR-15アプリケーションとの統合に関しては、GeneratorStreamInterfaceに変換することで、全体を文字列化しなくても済む。これを提供する既存の実装としてphp-stream-iteratorがある。しかしながら、この実装そのもののオーバーヘッドがあるので、総合的に実行効率では文字列による実装より劣る可能性がある。

結論

以上、5つの描画関数を生成するコンパイラの実装を作成した。中でもGeneratorを使った実装は特にメモリ効率で優れていた。当初は、文字列による実装よりも実行効率で劣るという欠点があったが、一定のサイズまで文字列としてバッファーするという方法でその点を解消することができた。それによって実行効率をトレードオフにすることなく、メモリ効率のいい描画関数を得ることができた。

今後の課題として、PSR-15アプリケーションと統合した時に実行効率が悪化する懸念がある。PSR-7のStreamInterfaceには標準出力に直接出力するためのAPIがないからだ。解決策としては、独自に以下のようなインターフェイスを定義してPSR-15アプリケーションを拡張することが考えられる。

interface EmittableStreamInterface extends \Psr\Http\Message\StreamInterface
{
    /**
     * ストリームの内容を標準出力に出力する。
     */
    public function emit(): void;

    /**
     * ストリームの内容を別のストリームにパイプする。
     */
    public function pipeTo(StreamInterface $destination): void;
}

しかし、このインターフェイスにはテンプレートの描画中(ストリームの読み取り中)に、データベースへのクエリ等の副作用を実行するなどして例外が発生した場合、中途半端なテンプレートの内容が出力されてしまう問題がある。これは不可避であるので、テンプレートの描画中に例外が発生しないように注意する必要がある。

なお今回作成したライブラリのSharpはProof of Conceptのために実装したもので、全くプロダクションレディではないことに注意されたい。

You may also like...

  • Vimのfoldexprで新たに折り畳みを定義する方法とその活用法

    Vimで十分に活用されていない組み込み機能の一つは折り畳み(Folding)だと思います。私は以前から折り畳みをいくつかの方法で活用しており、Vimのお気に入りの機能の一つになっています。この記事では、私がどのように折り畳みを設定しているのか、さらには折り畳みを活用するためのプラグインについてみなさんに共有します。

  • A keyboard-oriented system tray for X11 tiling window managers

    Typically, tiling window managers like XMonad do not have a system tray. If you want a system tray, you can use a standalone implementation like stalonetray or a status bar implementation with built-in system tray support, such as polybar.

  • Why you should use wcwidth() to calculate a character width

    Character width depends on the font, and Unicode Consortium does not provide explicit width definitions for all characters. There are characters that have ambiguous widths other than those defined as "Ambiguous (A)" in EastAsianWidth.txt. For example, "☀ (U+2600)" is defined as "Neutral (N)" in EastAsianWidth.txt, but its width may be full-width in a CJK font or an emoji font. This means that a non-East Asian character may be also the ambiguous-width character. Character width tables in default locales is problematic for both CJK and non-CJK users. You can create a custom locale to define a better character width table. wcwidth() respects defined by the locale. All TUI applications should consistently use wcwidth() to calculate the width of the character without an embedded character width table. If there is a mismatch in character width between applications, the screen will be broken.

  • XMonadでカスタマイズ可能なマルチカラムレイアウトを

    XMonadで3列以上のレイアウトを提供するモジュールとして、xmonad-contribのThreeColumnsとMultiColumnsが存在する。ThreeColumnsは3列固定のレイアウトで、MultiColumnsは列数が動的に増減するレイアウトだ。しかし、いずれのレイアウトも筆者の要求を満たすものではなく、新しいレイアウトを独自に作成するに至った。