haml の高速なレンダリングエンジン faml を書いた

haml との互換性にはかなり気を使っているけど、一部意図的に非互換にしていたり、正確な仕様がわからず再現できていない箇所があったり、haml の奇妙な挙動が直っていたりして、完全に全く同じ動作にはなっていない。

先日 faml を本番に投入して今も動いている。faml 導入にあたって実際にはアプリケーション側のビューを一部書き換えたけど、大量のビューがある中での変更点は十分少なかったと思う。

なお、このエントリ内での「元々の haml」は haml 4.0.6 を指している。過去のバージョンは知らない。

どれくらい高速なのか

元々 faml を書き始めたきっかけは「haml と slim に文法的に大きな差があるわけではないんだし、slim と同程度高速な hamlレンダリングエンジンは書けるはず」という点だった。 なので、slim と同じくらい高速ではあるものの、slim 以上に高速というわけではない。ただ、faml は一部 C 拡張を利用しているので slim よりも若干高速なケースは存在する。

マイクロベンチは faml のリポジトリ内で rake benchmark すれば実行できる。 travis-ci の after_script で rake benchmark を実行しているので、そこで結果を見ることができる。 例: https://travis-ci.org/eagletmt/faml/jobs/57138591

なぜ高速なのか

まず slim と同じバックエンドの temple を使っている。 これはシンプルながら強力なライブラリで、temple が定めた S 式にコンパイルすると temple がそれを Ruby のコードに変換してくれ、更に S 式を変換するフィルタを間に挟むことができる。 このフィルタにより最適化を行ったり、独自の文法を導入しつつそのコンパイラを挟んだりできる。

また、属性の描画を高速化するためになるべく静的にコンパイルするようにしている。 例えば %input{checked: false} とあったらリテラルしかないので静的に <input> という文字列まで作ることができる。 これを達成するには Ruby のコードを解釈する必要があるので、そのためのライブラリとして parser を使っている。 Ruby のパーサとしては標準ライブラリの ripper や ruby_parser があるけど、僕は parser が一番使いやすいと思っている。 各 AST ノードに場所の情報がちゃんと入っていたり、その場所に書いてあったコード片を文字列として取り出せるのがいい。

属性を静的にコンパイルできなかった場合は実行時に描画することになるが、この部分で C 拡張を使っている。 これは slim には無い特徴なので、動的な属性が多く存在するようなページでは slim よりも若干高速になるかもしれない。

実際に Web アプリケーションが高速になるのか

完全にアプリケーションによると思っていて、1ページ内で多くのビューをレンダリングしていて haml が支配的である場合も、そうでない場合もある。 多くの場合、スロークエリを改善したり、適切にキャッシュを導入したり、アルゴリズムを改善するほうがずっと効果がありそう。 ただ、faml は haml と高い互換性を維持しているので、アプリケーションコードを変更せずとりあえず faml に入れ替えてみるだけで、一部のページで効果があるかもしれない。この点は魅力的だと思う。

非互換な部分は何なのか

属性に Hash を渡したときの挙動

元々の haml では %span{foo: {bar: 'baz'}}<span foo-bar='baz'></span> となるが、faml では <span foo='{:bar=&gt;&quot;baz&quot;}'></span> となる。 ただし、%span{data: {bar: 'baz'}}<span data-bar='baz'></span> となる。 このように、Hash を渡したときは data 属性の場合に限り hyphenate され、それ以外は単に to_s される。

高速化のために意図的にこのような非互換性を導入している。 Hash の hyphenate は属性の描画の中では比較的重いものなので、なるべく to_s だけで済ませたい。 この挙動は data 属性のときにしか普通使わないはずだと思い、data 属性を特別扱いしている。

ちなみに、一部の属性を特別扱いするのは元の haml にも存在して、idclass が該当する。 id に Array を渡すと join("_") した値がセットされ、class に Array を渡すと join(" ") した値がセットされる。 class のほうの挙動を知ってる人は多そうだけど、id のほうは知らなかった人も結構いるんじゃないかと思う。

常時自動 html エスケープ

元々の haml では Rails プロジェクトでなければデフォルトでは自動 html エスケープが無効になっている。 たまに sinatra を使ったときにはまったりするので、faml では自動 html エスケープを常に有効化している。デフォルトを無効にする手段は提供していない。 Rails プロジェクト以外で html エスケープを無効化したい箇所では != 等の文法を使う。

ugly モードのみ

元々の haml では html をインデントするかどうかを示す ugly というフラグがあったけど、faml では常に ugly、つまりインデントを行わない。

Haml::Helpers 無し

succeed や surround といった haml 固有のヘルパメソッドが存在していたけど、faml では preserve 以外は提供していない。 preserve は使い所がまれに存在するけど、それ以外は無くても困らないと思って外した。 もし他に本当に必要なヘルパメソッドがあったら issue を立てて教えてください。

Object reference 無し

この機能を使ってる人いるんだろうか…… http://haml.info/docs/yardoc/file.REFERENCE.html#object_reference_

whitespace removal の仕様

haml には >< によって空白を取り除く文法があって、これの詳細な仕様が不明で再現しきれてない。 もし明らかに間違っているものを見つけたら issue を立てて教えてください。

元々の haml 側の挙動も怪しくて、例えば

%div<
  hello
  world
%div<
  #{'hello'}
  world

を render するとどうなるかというと、

<div>hello
world</div>
<div>helloworld</div>

になる。faml は前者の挙動が正しいと判断し、

<div>hello
world</div>
<div>hello
world</div>

となるようにしている。

haml のバグ、あるいは一貫性の無い仕様

plain フィルタに interpolation が含まれていたときの空行

whitespace removal で触れたケースに似てるけど、

:plain
  hello
  world
%br
:plain
  #{'hello'}
  world
%br

を ugly mode で render すると、元々の haml では

hello
world
<br>
hello
world

<br>

となって、謎の改行が入る。faml ではこれも haml のバグだと判断し、

hello
world
<br>
hello
world
<br>

となるようにしている。 ちなみに markdown フィルダでも同様の謎の改行が存在する。

空の plain フィルタでの空行

また、同じく :plain において、:plain の中身が空だったときに空行が入るかどうかに差がある。

%br
:plain
%br

これを元々の haml で render すると

<br>

<br>

になる一方、faml では

<br>
<br>

になる。

! または & で始まるテキスト

haml において、行頭の ! または & には意味があり、それぞれ html エスケープをしない、する、という意味を持つ。 != ではなく ! だけの場合は、その後にくるテキスト内の interpolation において html エスケープをするかしないかを制御する。

%span!hello
!hello
%span! hello
! hello

を元々の haml が render すると

<span>hello</span>
!hello
<span>hello</span>
hello

となり、タグ付きの場合は ! の後に空白がなくても !haml の記号として解釈される一方、タグ無しのテキストの場合は ! の後に空白がないと haml の記号として解釈されない。

faml では

<span>hello</span>
hello
<span>hello</span>
hello

となり、どちらの場合も行頭の !haml の記号として解釈する。& の場合も同様。 !& で始まるテキストを書く場合は、行頭を == または \ でエスケープする必要がある。

まとめ

既に haml から slim に移行してしまったり、最近だとクライアントサイド JS での描画やモバイルアプリケーションがメインになってサーバサイドは単なる JSON API サーバになってるところも多そうだけど、もし haml が好きで使ってるけど遅いのが気になっている人がいれば是非 faml を試してみてほしいです。