メモリ効率の良いテンプレートエンジンの実装をPHPで
Table of Contents
序論
PHPのWebアリプケーションではHTMLを描画するためにテンプレートエンジンが良く使われる。テンプレートエンジンは与えられたテンプレート文字列と変数を元に結果を描画する。この時、描画結果が文字列であるなら、少なくとも文字列の長さだけメモリ空間を必要とする。
一方、Webアプリケーションでは最終的にテンプレートの描画結果をレスポンスボディとして出力すればいいのであって、レスポンスボディを文字列として構築する必要はない。したがって、レスポンスボディを適当な単位に分割して出力することでメモリ使用量を節約できる。
本稿では、この点に着目してメモリ効率の良い描画を提供するテンプレートエンジンの実装を考えた。しかしながら、通常メモリ効率は実行効率とトレードオフであり、実行効率を重視した他の方式も含めた5つの実装を作成して性能を評価した。
実装コンセプト
JavaScriptにはTagged Templatesという機能がある。これはTemplate Literalとして記述されたテンプレート文字列を任意のタグ関数で処理する機能だ。
タグ関数は第1引数に定数文字列の配列、以降の引数にはプレースホルダーに埋め込まれた式が可変長引数で与えられる。したがって、テンプレートは定数文字列とその前後の式に分割される。
この機能を参考にして、テンプレートを文字列、式、文字列、式...のようなチャンクの繰り返しとして処理することを考えた。これを実装しているのがAbstractCompiler
で、後述する5つのコンパイラの実装はこのクラスを継承している。テンプレートはこのクラスのcompile()
メソッドによって描画関数を返すPHPコードとしてコンパイルされる。描画関数は変数の辞書を引数にして、返り値は実装毎に異なる描画結果(Generator
, array
, string
, resource
, void
)を返す。
テンプレートの文法はBladeのサブセットとした。各描画関数の節では以下のようなテンプレートから生成される描画関数を簡略化した形で例示する。
ベンチマーク結果
最初に、今回作成した5つのコンパイラの実装のベンチマーク結果を示す。ベンチマークではlist
とarticles
の2種類のテンプレートを使用した。list
は要素1つ1つのサイズは小さいが大量(10万)の要素を生成するテンプレートで、articles
は要素のサイズは大きいが、少数(5000)の要素を生成するテンプレートである。
処理系としては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デフォルトのテンプレートエンジン(参考)
描画関数を生成するコンパイラの実装
文字列による実装
最初に、文字列としてチャンクを結合するという基本的な実装を考えた。文字列のような基本型は高度に最適化されているので、実行効率・メモリ効率いずれもGenerator
の実装に次いで優秀だった。以降の実装はこの実装を指標として論じる。
このような描画関数を生成するものとしてStringCompiler
を作成した。
配列による実装
次に、配列にチャンクを追記していくという実装を考えた。この実装は、メモリ使用量が非常に大きく、基本的には文字列による実装に対する優位性はない。しかし、唯一articles
の描画の実行効率だけは優れていた。逆説的に要素数の多いlist
の実行速度が遅いことから、この実装は要素数が増えると不利になると考えられる。
このような描画関数を生成するものとしてArrayCompiler
を作成した。
ストリームによる実装
次に、I/Oストリームにチャンクを書き込む実装を考えた。今回はphp://memory
を使ってメモリ上へチャンクを書き込む実装を作成した。性能面では実行効率・メモリ効率いずれも文字列による実装より若干劣り、優位性はなかった。
このような描画関数を生成するものとしてStreamCompiler
を作成した。
テンプレートエンジンとしてのPHPによる実装
PHP自身はテンプレートエンジンでもあるので、それを使った実装を考えることができる。この方式はBladeやTwigと同様だ。結果は標準出力に出力されるので文字列にするためにはob_start()
を呼び出す必要がある。ベンチマークでもこの関数を呼び出しているからか、この実装は文字列による実装に比べて実行効率・メモリ効率いずれも大きく劣り、優位性はなかった。
もし、文字列化する必要がないのであれば、最も性能の良い実装になる可能性はある。しかし、PSR-15アプリケーションとの統合を考えた場合、StreamInterface
に変換するためには全体の文字列化は不可避だ。仮に、StreamInterface::emit()
のようなAPIがあって、直接標準出力に出力することができたならそれが性能的には理想である。
ジェネレータによる描画関数
最後に、メモリ効率の良いストリーム化された描画を提供する実装を考える。描画をストリーム化するためには描画を複数回に分けて行わなければならない。このような時は処理の一時停止と再開を提供するジェネレータを使うことができる。ジェネレータを使った描画関数は以下のように定義できる。
しかし、ジェネレータの停止・再開のコストが高いため、チャンクの数が多大になると実行効率もそれに伴なって悪化していた。対策として、指定されたチャンクの最大サイズを超えるまで文字列にバッファーするように実装を改良した。この実装はメモリ使用量はもちろんのこと、実行速度も文字列の実装と同等かより速く、総合的に最も優秀だった。
このような描画関数を生成するものとしてGeneratorCompiler
を作成した。
PSR-15アプリケーションとの統合に関しては、Generator
をStreamInterface
に変換することで、全体を文字列化しなくても済む。これを提供する既存の実装としてphp-stream-iteratorがある。しかしながら、この実装そのもののオーバーヘッドがあるので、総合的に実行効率では文字列による実装より劣る可能性がある。
結論
以上、5つの描画関数を生成するコンパイラの実装を作成した。中でもGeneratorを使った実装は特にメモリ効率で優れていた。当初は、文字列による実装よりも実行効率で劣るという欠点があったが、一定のサイズまで文字列としてバッファーするという方法でその点を解消することができた。それによって実行効率をトレードオフにすることなく、メモリ効率のいい描画関数を得ることができた。
今後の課題として、PSR-15アプリケーションと統合した時に実行効率が悪化する懸念がある。PSR-7のStreamInterface
には標準出力に直接出力するためのAPIがないからだ。解決策としては、独自に以下のようなインターフェイスを定義してPSR-15アプリケーションを拡張することが考えられる。
しかし、このインターフェイスにはテンプレートの描画中(ストリームの読み取り中)に、データベースへのクエリ等の副作用を実行するなどして例外が発生した場合、中途半端なテンプレートの内容が出力されてしまう問題がある。これは不可避であるので、テンプレートの描画中に例外が発生しないように注意する必要がある。
なお今回作成したライブラリのSharpはProof of Conceptのために実装したもので、全くプロダクションレディではないことに注意されたい。