書いた人:桑田 誠
Erubisとは、eRuby 処理系の一つであり、eruby や ERB の代替として使用できます。特長は、動作が高速なことと、拡張性が非常に高いことです。また Ruby on Rails を標準でサポートしています。
本稿では、Ruby on Rails において Erubis の Preprocessing 機能を使う方法を紹介します。Preprocessing 機能を使うと、Ruby on Rails におけるヘルパー関数の呼び出しが大幅に高速化されます。ベンチマークの結果では、最大で約2倍速くなりました。
なお本稿執筆時点での Erubis の最新版は 2.4.0 です。以降ではこのバージョンを使って説明します。
Erubisとは、eruby や ERB と同じく eRuby 処理系の一つです。次のような特長があります。
本稿ではこれらの詳細については説明せず、Erubis を Ruby on Rails で使う方法についてのみ説明します。
Erubis をインストールするには、RubyGems を使う方法と、setup.rb を使う方法の2つがあります。
RubyGems を使う場合は、管理者権限で「gem install erubis」を実行してください。
### UNIX、MacOS Xの場合
$ sudo gem install erubis
RubyGems が使えない場合は、Erubis をダウンロードして管理者権限で setup.rb を実行してください。
### UNIX、MacOS Xの場合
$ tar xzf erubis_2.4.0.tar.gz
$ cd erubis_2.4.0/
$ sudo ruby setup.rb install
インストールが成功したかどうかを確かめるために、簡単なスクリプトを実行してみましょう。 次のように表示されれば成功です。
### UNIX、MacOS Xの場合
$ cat test.rhtml
<% for i in 1..3 %>
Hello <%= @name %>
<% end %>
$ erubis -c '@name="world"' test.rhtml
Hello world
Hello world
Hello world
Ruby on Rails で Erubis を使うには、erubis/helpers/rails-helper.rb を使います。 config/environment.rb の最後に以下を追加してください。
require 'erubis/helpers/rails_helper'
#Erubis::Helpers::RailsHelper.engine_class = Erubis::Eruby # または Erubis::FastEruby
#Erubis::Helpers::RailsHelper.init_properties = {} # Erubis::Eruby.new() に渡すオプション
#Erubis::Helpers::RailsHelper.show_src = true # ログにコンパイル結果を出力する
Erubis::Helpers::RailsHelper.preprocessing = true # Preprocessing 機能を有効にする
この設定により、Ruby on Rails における eRuby エンジンとして、ERB のかわりに Erubis が使われるようになります。 Webサーバを再起動し、ログに「Erubis 2.4.0」と出力されていることを確認してください。
以下は設定オプションの説明です。
Erubis では、Ruby on Rails のために Preprocessing 機能が用意されています。これを使うと、Ruby on Rails の View 層を大幅に高速化できます。
このセクションでは、Preprocessing 機能について解説します。
Erubis の Preprocessing 機能とは、eRuby 文字列を Ruby スクリプトに変換する際に、埋め込まれたロジックの一部を実行する機能です。これにより、変換された Ruby スクリプト の実行が高速になります。
詳しく説明しましょう。ERB や Erubis のような eRuby 処理系の動作は、次のように2つのステージに分けられます。
ここでは前者を変換ステージ、後者を実行ステージと呼ぶことにします。
ここで大事なことは、同じ eRuby 文字列を何度も実行するときに、__実行ステージはその都度実行する必要があるが変換ステージは1回だけでよい__ということです。 例えばファイル list.rhtml を100回実行するとき、これを Ruby スクリプトに変換するのは最初の1回だけでよく、毎回変換する必要はないということです。 実際、Ruby on Rails では最初に list.rhtml を Ruby のメソッドに変換し、あとはこのメソッドを実行しています。
さて、通常の eRuby 処理系では、eRuby 文字列に埋め込まれた Ruby コード(文および式)は、実行ステージで実行されます。このうち、一部のコードを変換ステージで実行するのが Preprocessing 機能です。 つまり、__事前に実行できるコードはあらかじめ実行しておく__ことで、実行ステージでの負荷を減らし、View 層を高速化しようというわけです。
Erubis では、Preprocessing 用の埋め込み書式を用意しています。
サンプルを見てみましょう。 Ruby on Rails のアプリケーションを作成し、Erubis をインストールします。任意の View ファイルに次のような記述を追加します。
<%= link_to 'List', :action=>'list' %>
{{*[%= link_to 'Create', :action=>'create' %]*}}
Preprocessing 機能により、「[%= … %]」の部分は変換時に実行され、次のようになります。
<%= link_to 'List', :action=>'list' %>
{{*<a href="/myapp/create">Create</a>*}}
そして、最終的に次のような Ruby スクリプトに変換されます。変換結果がログファイルに出力されるので確かめてください。
_erbout = _buf = "";
_buf << ( link_to 'List', :action=>'list' ).to_s; _buf << '
<a href="/myapp/create">Create</a>
';
_buf.to_s
これを見ると、「<%= 式 %>」は変換後もそのまま残っているのに対し、「[%= 式 %]」は変換時ですでに実行済みであることが分かります。 つまり Preprocessing 機能を使えば、実行ステージでは Ruby 式を実行しなくて済むため、View 層の動作が高速になります。
Ruby on Rails のヘルパー関数である link_to() は、意外と動作コストがかかるうえ、頻繁に実行されます。Ruby on Rails の View 層が遅いのは、実はこういったヘルパー関数の呼び出しと実行が原因です。このコストをなくすことで、View 層はかなり高速化できます。
ヘルパー関数の引数に変数を含む場合は、それらを「?(‘…’)」で囲みます。「?()」は引数として文字列をとり、それを「<%=」と「%>」で囲んだ文字列を返すユーティリティ関数です。また erubis/helpers/rails-helper.rb で定義されています。
サンプルを見てみましょう。任意の View ファイルに次のような記述を追加します。
<%= link_to 'Edit', :action=>'edit', :id=>@item.id %>
[%= link_to 'Edit', :action=>'edit', :id=>{{*_?('@item.id')*}} %]
Preprocessing 機能により、これは次のように展開されます。
<%= link_to 'Edit', :action=>'edit', :id=>@item.id %>
<a href="/myapp/edit/{{*<%=@item.id%>*}}">Edit</a>
そして最終的には次のような Ruby スクリプトに変換されます。
_erbout = _buf = "";
_buf << ( link_to 'Edit', :action=>'edit', :id=>@item.id ).to_s; _buf << '
<a href="/myapp/edit/"; _buf << (@item.id).to_s; _buf << '>Edit</a>
';
_buf.to_s
これを見ると、「_?(‘…’)」で囲まれた部分は変換時には評価されず、実行時に評価されることがわかります。
link_to() の呼び出しは実行コストが高いですが、「@item.id」の評価だけなら実行コストはとても低いです。つまり「_?()」を使うことで、引数に変数を含むような関数呼び出しも高速化できます。
Rubyist Magazine 19号の記事『RubyOnRails を使ってみる 【第 10 回】 パフォーマンスチューニング』に、def_erb_method() を使って partial テンプレートの呼び出しを高速化する方法が紹介されています。
Preprocessing 機能を使うと、もっと簡単に partial テンプレートの呼び出しを高速化できます。
<% hidden = false; @people.each do |person| %>
{{*[% filename = "#{RAILS_ROOT}/app/views/addressbook/_person.rhtml" %]*}}
{{*[%= File.read(filename) %]*}}
<% end %>
やってることは、partial テンプレートを読み込んでその場に展開しているだけです。簡単ですね。
Partial テンプレートの中でも Preprocessing 機能を使う場合は、次のようにします。
<% hidden = false; @people.each do |person| %>
{{*[% filename = "#{RAILS_ROOT}/app/views/addressbook/_person.rhtml" %]*}}
{{*[% content = File.read(filename) %]*}}
{{*[% eruby = Erubis::Helpers::RailsHelper::PreprocessingEruby.new(content) %]*}}
{{*[%= eruby.evaluate(self) %]*}}
<% end %>
この場合は、Partial テンプレートを読み込むためのヘルパー関数を定義するとよいでしょう。
def template_filepath(basename)
return "#{RAILS_ROOT}/app/views/#{controller.controller_name}/#{basename}.rhtml"
end
def template_content(basename)
return File.read(template_filepath(basename))
end
def template_content_with_preprocessing(basename)
klass = Erubis::Helpers::RailsHelper::PreprocessingEruby
eruby = klass.new(template_content(basename))
return eruby.evaluate(self)
end
これを使うと、先ほどのテンプレートは次のように簡単になります。
<% hidden = false; @people.each do |person| %>
<%#= render :partial=>'person' %>
{{*[%= template_content_with_preprocessing('_person') %]*}}
<% end %>
ただし Preprocessing 機能を使うと、partial テンプレートのデバッグは困難になります (エラー時の行番号が変わるため)。Preprocessing 機能を使わない状態で充分テストしてから、Preprocessing 機能を使うようにしてください。
Preprocessing 機能を使って、ループをあらかじめ展開しておくこともできます。
例えば、次のような都道府県名のリストがあるとします。
JP_STATES = [
['北海道', 1],
['青森', 2],
['岩手', 3],
...(省略)...
['沖縄', 47],
]
JP_STATES.freeze()
def jp_states()
return JP_STATES
end
これを使って<select>タグと<option>タグを生成する場合、通常はヘルパー関数を使って次のようにします。
<%= select('address', 'state', jp_states(), {:include_blank=>true}) %>
これは次のような eRuby コードとだいたい同じです。
<% hash = { @address.state => 'selected="selected" } %>
<select id="address_state" name="address[state]">
<% for name, code in jp_states() %>
<option value="<%=h code%>" <%=hash[code]%>><%=h name %></option>
<% end %>
</select>
これを見れば分かるように、select()メソッド内では<option>タグを生成するために毎回ループが実行されています。
しかし日本の都道府県は数も名前も決まっており、アプリケーション実行中に変化することはありません。そのため、本来ならば毎回ループを実行する必要はなく、あらかじめ47個の<option>タグに展開しておくことが可能です。
そこで、Preprocessing 機能を使って、ループを事前に実行してしまいましょう。
<% hash = { @address.state => 'selected="selected"' } %>
<select id="address_state" name="address[state]">
<option></option>
{{*[% for name, code in jp_states() %]*}}
<option value="{{*[%=h code%]*}}" <%=hash[{{*[%=code.inspect%]*}}]%>>{{*[%=h name%]*}}</option>
{{*[% end %]*}}
</select>
このコードは Preprocessing 機能により、次のような eRuby スクリプトに変換されます。
<% hash = { @address.state => 'selected="selected"' } %>
<select id="address_state" name="address[state]">
<option></option>
<option value="1" <%=hash[1]%>北海道</option>
<option value="2" <%=hash[2]%>青森</option>
<option value="3" <%=hash[3]%>岩手</option>
...(省略)...
<option value="47" <%=hash[47]%>沖縄</option>
</select>
これを見ると、ループが事前に実行されていることがわかります。 つまりあらかじめループが展開されるため、実行速度が速くなります。
しかし、このままだと入力エラーがあった場合に、<select> タグの周りに赤い border ラインが引かれません。入力エラーのことも考慮すると、次のように書く必要があります。
<% hash = { @address.state => 'selected="selected"' } %>
{{*<% error_msg = @address.errors.on('state') %>*}}
{{*<% stag = etag = '' %>*}}
{{*<% stag, etag = '<div class="fieldWithErrors">', '</div>' if error_msg %>*}}
{{*<%= stag %>*}}<select id="address_state" name="address[state]">
<option></option>
[% for name, code in jp_states() %]
<option value="[%=h code%]" <%=hash[[%=code.inspect%]]%>>[%=h name%]</option>
[% end %]
</select>{{*<%= etag %>*}}
最終的に、かなり複雑なコードになりました。もとは「<%= select(‘address’, ‘state’, jp_states()) %>」という1行だけだったのが、Preprocessing 機能を使おうとすると10行になってしまいました。複雑さと速度を天秤に掛け、本当に速度が必要な場面でのみ使うとよいでしょう。
Ruby on Rails のヘルパー関数は、2種類に分けることができます。
これは、他のロジックについても同じことがいえます。「都道府県の一覧を出力する」のように、毎回同じ結果になるロジックは Preprocessing 機能を使うことができます。そうでない場合は、Preprocessing 機能を使うことができません。
現在、多くの Web アプリケーション用フレームワークにおいて、View 層は「テンプレートエンジン」+「ヘルパー関数」という組み合わせになっています。そのため、View 層を高速化しようとすると、テンプレートエンジンだけでなく、ヘルパー関数についても高速化をする必要があります。
今までは、ヘルパー関数を高速化するには、文字通り関数そのものを高速化するしかありませんでした。しかし Preprocessing 機能を前提とすると、関数の実行速度よりも、関数が Preprocessing 可能かどうかが重要になります。例えば text_field() を Preprocessing 可能な仕様に変更できれば、たとえ関数自体の実行速度が遅くても、View 層全体としては大幅に高速化できるでしょう。
つまり Preprocessing 機能は、View 層におけるヘルパー関数のあり方を大きく変える可能性があるといえます。
Preprocessing 機能でどのくらい速くなるのかを知るために、また ERB と Erubis とでどのくらい差が出るかを知るために、ベンチマークを実行してみました。以下、その解説です。
以下の手順で、ベンチマーク用のアプリケーションを作成しました。テーブルにデータを挿入する insert_stocks.sql をダウンロードしておいてください。
### データベースを作成する (ここでは MySQL を使用)
$ mysql -u root -p
Enter password: ********
mysql> show databases;
mysql> create database example1_development default character set utf8;
mysql> create database example1_test default character set utf8;
mysql> create database example1_production default character set utf8;
mysql> grant all on example1_development.* to foo@localhost;
mysql> grant all on example1_test.* to foo@localhost;
mysql> grant all on example1_production.* to user1@localhost identified by 'passwd1';
mysql> quit
### Rails アプリケーションを作成する
$ rails example1
$ cd example1/
$ vi config/database.yaml
### 'stocks' というテーブルを作成し、データを insert する
$ ruby script/generate migration create_stocks
$ vi db/migrate/001_create_stocks.rb
$ cat db/migrate/001_create_stocks.rb
class CreateStocks < ActiveRecord::Migration
def self.up
create_table "stocks", :force=>true do |t|
t.column "name", :string, :limit=>100, :null=>false
t.column "url", :string, :limit=>150, :null=>false
t.column "symbol", :string, :limit=>4, :null=>false
t.column "price", :float, :null=>false
t.column "change", :float, :null=>false
t.column "ratio", :float, :null=>false
end
end
def self.down
drop_table "stocks"
end
end
$ RAILS_ENV=production rake db:migrate
$ mysql -p -u user1 example1_production < /tmp/insert_stocks.sql
Enter password: *****
### scaffold を実行する
$ RAILS_ENV=production ruby script/generate scaffold stock
### サーバを起動する
$ RAILS_ENV=production ruby script/server
ここまで来たら、ブラウザで http://localhost:3000/stocks/ にアクセスし、データが表示されることを確認します。
続いて app/controllers/stocks_controller.rb を編集し、StocksController#list() を次のように変更します。
...
def list
@stock_pages, @stocks = paginate :stocks, :per_page => {{*20*}}
end
{{*alias list1 list*}}
{{*alias list2 list*}}
...
また list1.rhtml と list2.rhtml をダウンロードし、app/views/stocks/ にコピーします。 list1.rhtml は Preprocessing 機能なし、list2.rhtml は Preprocessing 機能ありのテンプレートです。
もちろん Erubis の設定も必要です。セクション『Ruby on Rails で Erubis を使う』の手順に従って、Erubis を設定します。
ここまで来たらサーバを再起動し、ブラウザで http://localhost:3000/stocks/list1 と http://localhost:3000/stocks/list2 にアクセスし、動作を確認します。
ベンチマークは、Apache Bench (ab) を使い、1000 リクエストを発行して行いました。 なお環境は MacOS X 10.4 Tiger, Intel CoreDuo 1.83MHz, Memory 2GB, Ruby 1.8.6, Ruby on Rails 1.2.3 です。
### ERB を使う設定で
$ ab -n 1000 http://localhost:3000/stocks/list1
### Erubis を使う設定で
$ ab -n 1000 http://localhost:3000/stocks/list1 # Preprocessing なし
$ ab -n 1000 http://localhost:3000/stocks/list2 # Preprcoessing あり
実行結果は次の通りです。
name | sec | rec/sec |
---|---|---|
ERB | 60.060 | 16.65 |
Erubis (Preprocessing なし) | 59.277 | 16.87 |
Erubis (Preprocessing あり) | 32.342 | 30.92 |
これを見ると、次のことが分かります。
ベンチマーク結果では、ERB と Erubis との差はほとんどありませんでした。 Erubis は確かに ERB より速いのですが、それがアプリケーション全体には影響を与えていません。
このことから Ruby on Rails では、eRuby によって費やされている時間はアプリケーション全体の実行時間からするとほんのわずかであることがわかります。つまりアプリケーション自体が遅すぎるため、ERB と比べたときの Erubis の速さなぞ誤差の範囲でしかないのです。
そもそも、ERB にしろ Erubis にしろ、単体で計測すると 1 秒あたり 1000〜2000 ページぐらい楽に生成することができます。それに比べると、1 秒あたり 16 リクエストしか処理できない Ruby on Rails のなんと遅いことでしょうか。これだけ Ruby on Rails が遅いと、単に ERB から Erubis に切り替えたところで何の効果もありません。
また Preprocessing 機能なしと比べると、Preprocessing 機能ありの場合は約2倍速くなっていることがわかります。今回のテンプレートでは、Preprocessing 機能を用いてヘルパー関数 link_to() の呼びだしを大幅に削減しています。このことから、View 層における実行時間の大部分が、ヘルパー関数によるものであることがわかります。
前に『View 層を速くするにはテンプレートエンジンだけでなくヘルパー関数も速くする必要がある』と書きました。Ruby on Rails ではたしかに View 層に時間がかかりますが、__View 層が遅い原因は ERB ではなくヘルパー関数__だったわけです。
今回のテンプレートでは、Preprocessing 機能を使って link_to() の呼び出しを削減しているわけですから、link_to() が比較的重いことがわかります。link_to() の何が重いのかは詳しく調べてみないとわかりませんが、恐らく URL を生成する部分 (つまり url_for() の呼び出し) が遅いのでしょう。やはりというか routes まわりは鬼門ですね。
Erubis に関するその他の話題について紹介します。
ERB には、trim モードという設定があります。trim モードとは、「<% %>」の前後の空白および改行を出力する・しないを設定するためのオプションです。 Ruby on Rails では、trim モードはデフォルトで以下のように設定されています。
Erubis にはこのような trim モードは存在せず、次のように動作します。
この違いを見てみましょう。例えば次のような eRuby コードがあったとします。
<!-- -->
<% for i in 1..3 %>
<%= i %>
<% end %>
<!-- -->
これを ERB で実行すると、次のように改行が出力されてしまいます。
<!-- -->
1
2
3
<!-- -->
Erubis で実行すると、余計な改行が出力されません。
<!-- -->
1
2
3
<!-- -->
また、「<% %>」の代わりに「<%- -%>」を使ってみます。
<!-- -->
<%- for i in 1..3 -%>
<%= i -%>
<%- end -%>
<!-- -->
これを ERB で実行すると、改行がまったく出力されません。
<!-- -->
1 2 3<!-- -->
Erubis だと、先ほどのと同じ結果になります。
<!-- -->
1
2
3
<!-- -->
ERB と Erubis にはこのような違いがあるので注意してください。
ERB::Util::h() は、「< > & “」を「< > & "」に変換する関数です。
ERB::Util::h() の定義は次のようになっています。
def html_escape(s)
s.to_s.gsub(/&/, "&").gsub(/\"/, """).gsub(/>/, ">").gsub(/</, "<")
end
alias h html_escape
しかし、これは文字列置換を4回も行っており、大変効率が悪いです。
そこで Erubis では、erubis/helpers/rails-helper.rb において、次のように ERB::Util::h() を再定義してきます。
module ERB::Util
ESCAPE_TABLE = { '&'=>'&', '<'=>'<', '>'=>'>', '"'=>'"' }
def h(value)
value.to_s.gsub(/[&<>"]/) { |s| ESCAPE_TABLE[s] }
end
module_function :h
end
新しい定義では、文字列置換を1回だけで済ませているため、特にエスケープする文字(「< > & “」)が少ない場合に大幅に高速化されます。
ただしエスケープする文字が多い場合は、新しい定義ではブロック呼び出しのコストが大きくなるため、もとの定義より遅くなる場合があります。そのようなときは、ERB::Util::html_escape() を使ってあげるといいでしょう(ERB::Util::html_escape() は再定義されないため)。
本稿では、Erubis の Preprocessing 機能を使うことで、Ruby on Rails の View 層を大幅に高速化する方法を紹介しました。ERB の代わりに Erubis を使うだけではほとんど効果はありませんが、Preprocessing 機能を使うとヘルパー関数の呼び出しを大幅に削減できるため、かなりの高速化が実現できます。
また Preprocessing 機能は、フレームワークにおけるヘルパー関数のあり方を大きく変える可能性があります。なぜなら、関数の実行速度よりも、関数が Preprocessing 可能かどうかのほうが速度に大きく影響するためです。さらに Preprocessing 機能の考え方は、View 層だけにとどまらず、他の部分でも利用できるかもしれません。
本稿が Ruby on Rails の発展に役立てば幸いです。