alias_method_chain と prepend を同時に同じメソッドに適用できない問題

Rails 5.0 のリリースが近づいてきてますが、Rails 5.0 から alias_method_chain を使っていると deprecation warning が出るようになりました https://github.com/rails/rails/pull/19434 。 単純に alias_method_chainprepend に書き換えればいいかと思いきや、かなりのレアケースではあるけれども、以前実際に失敗した事例の紹介です。

#!/usr/bin/env ruby
require 'active_support/all'

module M
  def foo
    p 'M#foo'
    super
  end
end

class C
  def foo
    p 'C#foo'
  end
end

class C
  def foo_with_modified
    p 'C#foo modified'
    foo_without_modified
  end

  alias_method_chain :foo, :modified
  prepend M
end

C.new.foo

これを実行すると

"M#foo"
"C#foo modified"
"C#foo"

という出力結果が得られる。予想通り。

#!/usr/bin/env ruby
require 'active_support/all'

module M
  def foo
    p 'M#foo'
    super
  end
end

class C
  def foo
    p 'C#foo'
  end
end

class C
  def foo_with_modified
    p 'C#foo modified'
    foo_without_modified
  end

  prepend M
  alias_method_chain :foo, :modified
end

C.new.foo

これを実行すると SystemStackError。変わったのは alias_method_chainprepend の順序だけ。

言われてみればたしかにそうなるのはわかるけれども、たとえば activerecord をモンキーパッチで拡張するような gem が複数あった場合、たまたま同じメソッドに対する alias_method_chainprepend が混在していると、初期化順でエラーになったりならなかったりする。

2015年の思い出

しゃかいじんにねんめ

去年は http://eagletmt.hateblo.jp/entry/2014/12/31/032313

仕事

去年末から今年の前半にかけて簡単な外向きの API サーバや社内アプリを書いていて、仕事で初めて最初から自分で書いた Web アプリになった。 まぁ最初からといっても既にある機能の置き換えみたいなやつなので、ちょっと設計考えて実装したくらい。

今年の中盤くらいからは開発環境向けの MySQL サーバをさわる機会を得た。個人ではずっと PostgreSQLMySQL は全然わかってなかったのでいい経験だった。 去年と比べてサーバの面倒を見る時間が圧倒的に増えた。 MySQL もそうだけど、CI サーバを CentOS から Ubuntu にしたり、RRRSpec の面倒を見たり。 Web アプリ書くのも全然嫌いじゃないけど今はこっちのほうが楽しい。

発表

なんか隣の人に唆されて RubyKaigi 2015 で発表してた http://k0kubun.hatenablog.com/entry/2015/12/12/000037

hamlx の噂を聞いてから全然動きないなーと思って、haml も slim も文法同じなんだから slim と同じパフォーマンスはいけるでしょと 書き始めて *1 、完全に趣味で書いてたけど色々あって 本番で動くレベルになって 、最終的に RubyKaigi での発表になって一年を通して haml だった。

僕は勉強会とかカンファレンスはあんまり行かないけど、まぁこのまま1年に1回くらいのペースでできたらいいな。

そういえば RubyConf 2015 に行った。海外は ICPC で行ったことがあったけど、アメリカというか英語圏は初めて。 英語の案内とかは結構読めるんだけど、人が何言ってるかなかなか聞き取れなくて大変だった。人と話せないのは英語関係なく日本語でもそうなのである意味いつも通りだった…

インフラ

仕事で少しさわってるけどよくわからんなーというのは、積極的に個人の環境でも使うようにしてみている。 去年は puppet とか zabbix だったけど、今年は itamae とか Jenkins だった。あとはつい最近 OpenLDAP も立てた。 仕事で使ってるツールOSS だと個人でも使えていい。 来年はネットワークの知識をなんとかしたいと思っている。ルータ自作してみたり自宅と VPS の間に拠点間 VPN 張ってみたりすると経験値貯まるのかなー。

あと ISUCON 5 に参加した。去年とは違って普通に予選を (ギリギリ) 勝ちぬいて、本選でもそこそこの順位をとれてよかった。 http://eagletmt.hateblo.jp/entry/2015/11/03/013045

来年は Ubuntu 16.04 LTS がリリースされてついに systemd を使えるようになるので楽しみ。

アニメ

去年末からの続きだけど SHIROBAKO と クロスアンジュがとてもよかった。全然違う作品だけどどちらも今後しばらく忘れないと思う。 あとあんまり話題にならなかったけどデス・パレードが本当に大好きだった。ぜひ最初の2話を順番に見てほしい。

新規に見たものの中でとくに好きだったのはユーフォニアム、終わりのセラフ、SHOW BY ROCK、グリザイアの楽園あたり。グリザイアはまぁ去年からの続編だけど。 DOG DAYS''、ジョジョ、デレマス、黒バス、Fate UBW銀魂、プリズマイリヤゆるゆりうたわれるものあたりはもう鉄板というか当然見て当然面白かった。 あともうずっと見続けてるけどアイカツも。未だにソレイユ大好きだけどダンシングディーヴァもいいですね。

BD 買ったのは SHIROBAKO、アイカツ (映画含む)、プリズマイリヤ、SHOW BY ROCK、ストライクウィッチーズOVAゆるゆりなちゅやちゅみ、くらいか。来年はもっと増やしていいかも。

漫画・ラノベ

bookwalker で183冊買ってた。セールのときに一気に買ってる割合もそれなりにあるけど… 新規に買ったものだと終わりのセラフ明日、今日の君に逢えなくてもやがてきみになる小百合さんの妹は天使、あたりかなぁ。 終わりのセラフは小説版も買おうか迷ってる。

音楽

ついに日本でも始まった Google Play Music を使うようになった。 といっても手元にある mp3 をアップロードして便利に同期するツールとしてで、曲は相変わらず Amazon から買ってダウンロードしている。Google Play Music 品揃え悪い… 本当は全部 mp3 で買いたいんだけど、一部 CD でしか出てないので仕方なく CD も買っている。アイカツとか…

ゲーム

ムジュラの仮面3D、FE if、Splatoon、そして最近シャープ FE。 普段買わない系統で Splatoon を買って長く楽しめたのは大きい。今も定期的にやってる。 Splatoon のおかげで久しぶりに結構ゲームやってたと思う。

*1:このときはまだ fast_haml という名前だった

systemd-run によるリソース制御

systemd というと unit ファイルを書いてデーモンを起動して、というイメージが強いかもしれないけど、systemd-run を使うと単発のコマンドを systemd の管理下で実行できる。 こうすることで、CPUQuota=50% とか MemoryLimit=10M とか BlockIOWeight=10 のようにリソースを制限でき、しかも実行中に変更することもできる。

たとえば http://hb.matsumoto-r.jp/entry/2015/12/02/133448 にあるような CPU 使用率を制限しながら yes を実行する例だと、

% sudo systemd-run --scope --uid=eagletmt -p CPUQuota=10% yes > /dev/null
Running scope as unit run-rcab5dc0a5f8e4620a996d95d40f7c95a.scope.

のようになる。ここから

% sudo systemctl set-property --runtime run-rcab5dc0a5f8e4620a996d95d40f7c95a.scope CPUQuota=50%

というように動的にリソース制限を変更できる。 systemd のすべてのディレクティブを systemd-run に指定できるわけではないが、リソース制御関連のディレクティブは対応している。

この例では --scope を使ったので出力はそのままになっているけど、--scope をつけなければ service unit として実行されるので、コマンドの出力は通常の service unit と同様にデフォルトでは journald に送られる。

参考

Unicorn 5.0.0 の sd_listen_fds(3) emulation とは

Unicorn 5.0.0 がリリースされていて、most boring major release と自称している中、新機能として sd_listen_fds(3) emulation がある http://bogomips.org/unicorn-public/20151101-unicorn-5.0.0-rele@sed/t/

sd_listen_fds(3) は systemd の socket activation という機能に対応するための単純な関数で、socket activation は簡単に言うと systemd が listen してその fd を service unit のプロセスに渡す機能。 この機能の詳細やモチベーションを説明するのは面倒なので作者のブログ記事などを読んでほしい http://0pointer.de/blog/projects/socket-activation.html

今回 Unicorn はそれに対応したため、以下のような unicorn.service と unicorn.socket を用意することで、socket activation が可能になる。

[Unit]
Description=Unicorn

[Service]
User=non-root
WorkingDirectory=/path/to/current
ExecStart=/usr/bin/bundle exec --keep-file-descriptors unicorn -c config/unicorn.rb -E production
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -QUIT $MAINPID
PIDFile=/path/to/current/tmp/pids/unicorn.pid

[Install]
WantedBy=multi-user.target
[Socket]
ListenStream=/run/unicorn.sock

[Install]
WantedBy=sockets.target

設定ファイルの unicorn.rb 側には変更は不要。socket activation を利用すると listen の設定が無視されるくらい。

bundle exec に --keep-file-descriptors を渡す必要がある点に気付くまでしばらくはまっていた。 ちゃんと LISTEN_PIDS と LISTEN_FDS は設定されてるのに、なぜか fd=3 が socket ではない別の何かになっていて謎だった…… Ruby 2.0.0 から 0, 1, 2 以外の fd は Kernel#exec するときにデフォルトでは閉じられてしまうそうで、それを防ぐためのオプションが bundle exec にある。 https://github.com/bundler/bundler/pull/2629 https://bugs.ruby-lang.org/issues/5041

また Unicorn のアナウンスでは「You may now stop using PID files and other process monitoring software when using systemd.」とあるけど、Unicorn の SIGUSR2 による graceful restart を利用している場合、 master の pid (systemd 的に言うと main pid) が変わるので、PIDFile は相変わらず必要になるはず。 勘違いだったら教えてください。

実際 Unicorn が socket activation に対応して嬉しいかというと、まぁそんなにメリットは無いと思う。 ほとんどアクセスが無いような個人のサービスで、実際にアクセスがくるまで Unicorn の起動を遅延できる、とかはあるかも…?

socket activation することで、Unicorn 自身が graceful restart しなくても、graceful shutdown ができればクライアントからのコネクションを途中で切ることなくアプリケーションを再起動できるメリットはある。 ただ、新しい worker を用意しつつ準備ができたら切り替える、というような細かい制御は当然 systemd 側からはできず、一旦完全に Unicorn を落としてから再度 socket activation によって起動する流れになるので、接続に失敗することはないけど Unicorn の起動にかかる時間だけブロックする形になる。 どうしても Unicorn を SIGUSR2 によるリロードではなく真っ新な状態から再起動したいときの手段が用意されているのは便利かもしれない。

ISUCON5 本戦に参加して5位でした

去年 に引き続き†空中庭園†《ガーデンプレイス》として @ryotarai@gfx と参加して、結果は5位でした http://isucon.net/archives/45869121.html 。コードは https://github.com/ryotarai/isucon5

ISUCON5 予選の反省として、最初はプロファイルと手元での動作に最低限必要な変更以外はせずにコードを読むと決めていて、nginx の log_format を変えたり stackproflineprof を仕込んだりしてベンチマークを実行した後は、各自アプリケーションの理解に集中していた。 僕は /data から叩かれている外部 API の挙動をとくに調べていて、

  • GET リクエストしかないし、エンドポイントの情報は DB に入ってるけど更新されることもない
  • ken と ken2 という2つの似たエンドポイントがあるけど、実は全く同じっぽくて、しかも zipcode に対して返ってくる答えは一定っぽいので、2つをまとめた上で zipcode をキーにしてキャッシュできそう
  • surname、givenname もそれぞれ q をキーにしてずっとキャッシュできそう
  • tenki は毎回違う値が返ってくる
    • 実際には3秒くらいで変わることに @ryotarai が後で気付いて、その期間だけキャッシュするようにしていた
  • perfectsec、perfectsec_attacked の2つは https でリクエストするようになっていて、http だと通らない
  • perfectsec、perfectsec_attacked の2つも毎回違う値が返っていてキャッシュできなさそう
    • 実際には perfectsec_attacked は20秒くらいはキャッシュできることが後からわかって、最終的にはキャッシュが入ってた
  • それぞれのエンドポイント間に依存関係はなくて、全部並列にとってこれそう
    • このときは適当に Thread.start すればいいかと思っていたけど、@gfx が並列化を実装するときにちゃんとスレッドプールくらいは使いたいという話から expeditor 使おうということになった

あたりを見ていた。

だいたい12:30頃から実際に app.rb を編集し始めて、僕は

  • ken2 の結果を Redis に無期限でキャッシュしてベンチマークが通るかどうか → 通った
  • endpoints を DB から Ruby の定数にもってくる
  • ken を ken2 にマージしてベンチマークが通るかどうか → 通った (これで実質 ken もキャッシュされている)
  • surname、givenname も無期限でキャッシュしてベンチマークが通るかどうか → 通った
  • users を PostgreSQL から Redis へ。同時にパスワードをハッシュするのをやめて直接保存するように

ということをしていた。このへんが終わったのが15時過ぎくらいで、1台のみ使って2万点くらい出していた気がする。 ここから @ryotarai が3台使うような構成に変更していって、5万点をこえ始めていた。 Web API コールがどうしても遅くて /data が詰まると unicorn のワーカを使い切ってしまうので、unicorn のワーカ数をかなり大きめにしつつ3台に配置し、a にはベンチマーカからリクエストを受ける nginx を置いて3台にプロキシし、b には Redis を置くようにした。 endpoints は Ruby の定数にしたし、users は /initialize で Redis に入れてるし、subscriptions も @gfx が /initialize で Redis に入れるようにしていたので、すでに PostgreSQL は /initialize 以外では不要になっていた。

ベンチマークがたまに fail するようになっていて、そのデバッグで16:30頃から1時間くらい使っていた。何で fail しているのか分かるまで時間がかかってしまったのがつらい。 最終的にわかったのはどうも /initialize がたまに30秒以内に終わってないっぽくて、/initialize は Redis がいる b に常にプロキシするようにしたら安定した。 スコアは5万点だったり3万点だったりして、あんまり安定しなくて結構不安だった。

最後は a にいる nginx の CPU 使用率が高いのが気になっていて、@ryotarai が振り分けの重み付けを調整しながら僕はアセットを gzip して gzip_static するようにしたりしていた。 終了15分前くらいに念のため3台とも reboot してみて、ちゃんと Redis が起動してベンチマークが fail しないことを確認して終了。 最終結果のスコアは86,210点で、競技中のベストよりも高い結果だった。

終了後に聞いた話の中では RACK_ENV=production が非常に痛かった http://diary.sorah.jp/2015/11/02/isucon5f 。3位との差はそんなに大きくなかったので、もしかしたら3位には入れていたかもしれないと思うと悔しいけどしょうがない。

去年から参加し始めたけど去年よりよくなってる感触はあるので次は入賞したい。

faml と slim、hamlit のパフォーマンスの差

http://k0kubun.hatenablog.com/entry/2015/03/31/004021 を見て、「faml は slim と同等とか言いながら slim よりずっと遅いじゃん」と思われると悔しいので一応解説しておく。 なお、このエントリは slim 3.0.3 と hamlit 0.4.2 に基いている。

なぜこのベンチマークで faml が遅いのか、結論から言うと、この中で faml だけ自動 html エスケープが有効になっているからだ。 haml はデフォルトでは自動 html エスケープは無効であり、hamlit もそれに倣っている。

slim のベンチマークで用いている slim のビューでは == を使って明示的に html エスケープを無効化している https://github.com/slim-template/slim/blob/v3.0.3/benchmarks/view.slim 。 そのため、faml だけが自動 html エスケープを行っている。

faml が行ってる slim とのベンチマーク比較では、元々の view.slim から === に変更して html エスケープを有効にしている。 そして haml, hamlit でも escape_html: true というオプションを渡して slim のベンチマークを実行すると、travis-ci 上のある実行では以下のような結果になった。

https://travis-ci.org/eagletmt/faml/jobs/57304778

ruby benchmark/slim.rb
Calculating -------------------------------------
                Haml     1.261k i/100ms
                Faml     3.741k i/100ms
              Hamlit     4.369k i/100ms
                Slim     3.846k i/100ms
-------------------------------------------------
                Haml     14.524k (± 2.6%) i/s -     73.138k
                Faml     52.477k (± 2.3%) i/s -    265.611k
              Hamlit     64.082k (± 2.8%) i/s -    323.306k
                Slim     55.219k (± 1.7%) i/s -    276.912k
Comparison:
              Hamlit:    64082.1 i/s
                Slim:    55219.4 i/s - 1.16x slower
                Faml:    52477.4 i/s - 1.22x slower
                Haml:    14524.2 i/s - 4.41x slower

faml はたしかに slim より遅い結果になっているが、それほど大きな差は無い。 実際、このベンチマークで生成される Ruby のコードにはほとんど差が無い。差といえば、Faml::Helpers を extend してるかどうか、出力する html に改行文字が含まれるかどうか、余分な変数代入や to_s があるかどうか、くらいだ。

% bundle exec slimrb --compile benchmark/view.slim
_buf = []; _buf << ("<!DOCTYPE html><html><head><title>Simple Benchmark</title></head><body><h1>".freeze);
;
;
;
;
; _buf << (::Temple::Utils.escape_html((header)));
; _buf << ("</h1>".freeze); unless item.empty?;
; _buf << ("<ul>".freeze);
; for i in item do;
; if i[:current];
; _buf << ("<li><strong>".freeze);
; _buf << (::Temple::Utils.escape_html((i[:name])));
; _buf << ("</strong></li>".freeze); else;
; _buf << ("<li><a".freeze);
; _slim_codeattributes1 = i[:url]; case (_slim_codeattributes1); when true; _buf << (" href=\"\"".freeze); when false, nil; else; _buf << (" href=\"".freeze); _buf << (::Temple::Utils.escape_html((_slim_codeattributes1))); _buf << ("\"".freeze); end; _buf << (">".freeze); _buf << (::Temple::Utils.escape_html((i[:name])));
; _buf << ("</a></li>".freeze); end; end; _buf << ("</ul>".freeze); else;
; _buf << ("<p>The list is empty.</p>".freeze);
; end; _buf << ("</body></html>".freeze); _buf = _buf.join
% bundle exec faml compile benchmark/view.haml
_buf = []; extend ::Faml::Helpers; _buf << ("<!DOCTYPE html>\n<html>\n<head>\n<title>Simple Benchmark</title>\n</head>\n<body>\n<h1>".freeze);
;
;
;
;
;
; _faml_compiler1 = (header;
; ); _buf << (::Temple::Utils.escape_html((_faml_compiler1.to_s))); _buf << ("</h1>\n".freeze); unless item.empty?;
; _buf << ("<ul>\n".freeze);
; for i in item;
; if i[:current];
; _buf << ("<li>\n<strong>".freeze);
; _faml_compiler2 = (i[:name];
; ); _buf << (::Temple::Utils.escape_html((_faml_compiler2.to_s))); _buf << ("</strong>\n</li>\n".freeze); else;
; _buf << ("<li>\n<a".freeze);
; _faml_html1 = (i[:url]); case (_faml_html1); when true; _buf << (" href".freeze); when false, nil; else; _buf << (" href='".freeze); _buf << (::Temple::Utils.escape_html((_faml_html1))); _buf << ("'".freeze); end; _buf << (">".freeze); _faml_compiler3 = (i[:name];
; ); _buf << (::Temple::Utils.escape_html((_faml_compiler3.to_s))); _buf << ("</a>\n</li>\n".freeze); end; end; _buf << ("</ul>\n".freeze); else;
; _buf << ("<p>The list is empty.</p>\n".freeze);
; end; _buf << ("</body>\n</html>\n".freeze); _buf = _buf.join

さて、このベンチマークではたしかに hamlit が faml や slim をはっきり上回っている。hamlit 0.4.2 がどのような Ruby のコードを生成しているか見てみる。

% bundle exec ruby -e 'require "hamlit"; puts Hamlit::Engine.new(escape_html: true).call(File.read("benchmark/view.haml"))'
_buf = []; _buf << ("<!DOCTYPE html>\n<html>\n<head>\n<title>Simple Benchmark</title>\n</head>\n<body>\n<h1>".freeze);
;
;
;
; _buf << (::Temple::Utils.escape_html(((header).to_s))); _buf << ("</h1>\n".freeze);
; unless item.empty?; _buf << ("<ul>\n".freeze); for i in item; if i[:current]; _buf << ("<li>\n<strong>".freeze); _buf << (::Temple::Utils.escape_html(((i[:name]).to_s))); _buf << ("</strong>\n</li>\n".freeze);
;
;
; else; _buf << ("<li>\n<a href='".freeze); _buf << (i[:url]); _buf << ("'>".freeze); _buf << (::Temple::Utils.escape_html(((i[:name]).to_s))); _buf << ("</a>\n</li>\n".freeze);
;
; end;
; end;
; _buf << ("</ul>\n".freeze);
;
; else; _buf << ("<p>The list is empty.</p>\n".freeze);
; end;
; _buf << ("</body>\n</html>\n".freeze);
;
; _buf = _buf.join

headerfor i in item のような埋め込み式の行番号がずれてしまってる問題は今回は無視するとして*1、よく見てみると i[:url] がそのまま埋め込まれている。 これには2つの問題がある。

  1. &< 等が html エスケープされない
    • これはさすがに厳しいのでは*2
  2. i[:url]nil や false だったとき、<a href='false'> のような出力になる
    • haml では、属性の値が nil または false のときは、その属性を出力しない仕様
    • ちなみに slim が生成したコードを見てわかるように、slim も同じ仕様
    • ただ、これに関しては「hamlit ではこういう仕様です」と言うこともできると思う

というわけで、この差によって slim のベンチマークにおいて hamlit が高速になっていると考えられる。

最後に恣意的に用意したマイクロベンチマークの結果を自慢しておくと、実行時に Hash のマージと attribute 全体のレンダリングを行う必要があるケースでは faml が最も高速になっている。 というか、slim は文法はともかくコンパイラは本当によくできていて、この点くらいしかぼくには勝てそうにない。 先程と同じ travis-ci 上での実行から引用すると、

ruby benchmark/rendering.rb benchmark/attribute_builder.haml benchmark/attribute_builder.slim
Calculating -------------------------------------
                Haml   693.000  i/100ms
        Faml (Array)     2.417k i/100ms
       Faml (String)     2.064k i/100ms
      Hamlit (Array)     1.896k i/100ms
     Hamlit (String)     1.712k i/100ms
        Slim (Array)     2.005k i/100ms
       Slim (String)     1.653k i/100ms
-------------------------------------------------
                Haml      7.767k (± 4.4%) i/s -     38.808k
        Faml (Array)     30.186k (±17.5%) i/s -    147.437k
       Faml (String)     28.314k (±10.8%) i/s -    140.352k
      Hamlit (Array)     22.381k (±20.5%) i/s -    106.176k
     Hamlit (String)     21.914k (±10.4%) i/s -    109.568k
        Slim (Array)     26.194k (± 9.7%) i/s -    130.325k
       Slim (String)     20.715k (± 5.7%) i/s -    104.139k
Comparison:
        Faml (Array):    30186.2 i/s
       Faml (String):    28313.7 i/s - 1.07x slower
        Slim (Array):    26194.2 i/s - 1.15x slower
      Hamlit (Array):    22381.0 i/s - 1.35x slower
     Hamlit (String):    21914.2 i/s - 1.38x slower
       Slim (String):    20714.7 i/s - 1.46x slower
                Haml:     7766.6 i/s - 3.89x slower

となっている。haml 遅いですね……

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 を試してみてほしいです。