早速ですが、みなさんはviewで部分テンプレートを利用されていますでしょうか?
こういうやつです。よく見ますね。
<%= render 'path', user: user %>
私はコードの見た目がきれいになるだけでなく、管理もしやすくなるといった理由から多用しています。
そんな便利な部分テンプレートですが、実は使いすぎるとパフォーマンスが悪くなってしまうことを知っていますか?
今回はその理由と改善点を説明できればと思います。
eachの中でrenderを使ってはいけない
例えば以下のようなコードがあるとします。(適当な例です。こんなコード書かないよ、というのは一旦置いといてください。)
<% @users.each do |user| %>
<%= render 'user', user: user %>
<% end %>
で、これの何が良くないかですが、単純にパフォーマンスが悪いです。
雑な例ですが、10,000件のユーザを一覧に表示してみます。データはscaffoldとdb:seedで適当に作成しました。
①renderを使わない(1441ms)
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= user.name %></td>
<td><%= user.email %></td>
<td><%= link_to 'Show', user %></td>
<td><%= link_to 'Edit', edit_user_path(user) %></td>
<td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
Started GET "/users" for 172.23.0.1 at 2022-03-20 15:34:35 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
Rendering layout layouts/application.html.erb
Rendering users/index.html.erb within layouts/application
User Load (10.4ms) SELECT "users".* FROM "users"
↳ app/views/users/index.html.erb:15
Rendered users/index.html.erb within layouts/application (Duration: 1336.3ms | Allocations: 1028471)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 1386.1ms | Allocations: 1032213)
Completed 200 OK in 1441ms (Views: 1408.8ms | ActiveRecord: 48.9ms | Allocations: 1033540)
②renderをeachの中で使う(47554ms)
<tbody>
<% @users.each do |user| %>
<%= render 'user', user: user %>
<% end %>
</tbody>
<tr>
<td><%= user.name %></td>
<td><%= user.email %></td>
<td><%= link_to 'Show', user %></td>
<td><%= link_to 'Edit', edit_user_path(user) %></td>
<td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
Started GET "/users" for 172.23.0.1 at 2022-03-20 15:34:35 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
Rendering layout layouts/application.html.erb
Rendering users/index.html.erb within layouts/application
User Load (10.4ms) SELECT "users".* FROM "users"
↳ app/views/users/index.html.erb:15
Rendered users/_user.html.erb (Duration: 6.7ms | Allocations: 125)
Rendered users/_user.html.erb (Duration: 7.4ms | Allocations: 125)
: × 10,000
Rendered users/_user.html.erb (Duration: 6.3ms | Allocations: 125)
Rendered users/_user.html.erb (Duration: 7.4ms | Allocations: 125)
Rendered users/index.html.erb within layouts/application (Duration: 47458.4ms | Allocations: 1891572)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 47537.5ms | Allocations: 1895479)
Completed 200 OK in 47554ms (Views: 47530.6ms | ActiveRecord: 11.6ms | Allocations: 1896610)
原因
結果の通り、eachの中で部分テンプレートをrenderする方法はめちゃめちゃパフォーマンスが悪いことがわかりました。
ログを見てもらえればわかると思いますが、②では1つ1つテンプレートを読み込む処理が走ってしまうことが原因のようです。
今回は単純な例なので、こんな書き方することはないと思いますが、どうしても処理をまとめようと部分テンプレートに切り分けて、、という作業をすると知らず知らずこのような書き方になってしまって、気づかないうちにパフォーマンスを低下させてしまっていることが私も過去に何回かありました。
解決策
では、解決策です。
①を使えば解決するのですが、それではeachがあるところではrenderを使うなって言ってるようなものなので良くないですね。
というわけで、③つ目の方法です。
③renderの対象をコレクションにする(939ms)
<tbody>
<%= render @users %>
</tbody>
<tr>
<td><%= user.name %></td>
<td><%= user.email %></td>
<td><%= link_to 'Show', user %></td>
<td><%= link_to 'Edit', edit_user_path(user) %></td>
<td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
Started GET "/users" for 172.23.0.1 at 2022-03-20 16:08:01 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
Rendering layout layouts/application.html.erb
Rendering users/index.html.erb within layouts/application
User Load (6.4ms) SELECT "users".* FROM "users"
↳ app/views/users/index.html.erb:15
Rendered collection of users/_user.html.erb [10000 times] (Duration: 602.4ms | Allocations: 960182)
Rendered users/index.html.erb within layouts/application (Duration: 893.5ms | Allocations: 1075930)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 927.3ms | Allocations: 1079708)
Completed 200 OK in 939ms (Views: 932.6ms | ActiveRecord: 15.7ms | Allocations: 1080830)
すでに気づいている方もいらっしゃったと思いますが、Railsにはコレクションを渡すとそれらを1つ1つよしなに処理してくれる機能が備わっています。
書き方は②に似ていますが、テンプレートが一度しか呼ばれないので①と同じくらいパフォーマンスが良いです。
ただしこれ、わたしは使いづらいなって思っていました。
<%= render @users %>
と書くためには、 @users
がUserのコレクションであり、かつusers/_user.html.erb
というファイル配置でないと対応しません。
プロジェクトが大きくなってくると、フォルダを分けて管理しやすくするために、テンプレートの配置が複雑になることは多々あります。
例えば、users/partials/_user.html.erb
みたいな配置にすると<%= render @users %>
のような書き方はできなくなってしまうのです。
そこでどんなフォルダ構成になっていても使える方法を記載します。
万能なコレクションの書き方
<tbody>
<%= render partial: "partial/user", collection: @users, as: :user, locals: { title: "Users Page" } %>
</tbody>
partial:
に部分テンプレートのパス、collection:
にコレクションを渡します。ちなみにActiveRecordでなくても、配列やハッシュもコレクションなのでこの書き方で使えます。
as:
は部分テンプレート内でコレクションの1つ1つ処理される対象の変数です。partials/_user.html.erbは②や③と同じですのでそちらを参照してください。
locals:
も今までと同じように使えます。
ちなみに、部分テンプレートの中で何回目の繰り返しかを取得するには、下記のように asの引数
+ _counter
で取得できます(カウントは0からです)。
<%= user_counter %>
どうしようもない場合
上記の方法でも解決できないコードを書かなければいけないパターンもコードをたくさん書いていると遭遇します。
そんなときはどうすればいいのかも簡単に紹介します。
renderを使うのを諦める
行数がそこまでないのなら部分テンプレートで切り分けずに、そのまま書いてしまってもいいかもしれません。
renderをeachの中で使うことを許容する
eachの繰り返し回数が少ないと決まっているなら、パフォーマンスに著しい低下が起こらないという前提で使ってしまって良いと思います。
一度それぞれのパターンで記述してみて、どれくらい速度に差があるか検証してみましょう。
helperに書く
実はhelperに書いた場合は、同じように切り分けているのにも関わらず、速度はそんなに変わりません。
下記は例が悪いので適当な書き方になっていますが、部分テンプレートのようにhtmlで記述することはできないので、あまり複雑な場合は逆に見づらくなってしまうかもしれません。
<tbody>
<% @users.each do |user| %>
<%= user_tr(user) %>
<% end %>
</tbody>
module UsersHelper
def user_tr(user)
user_tr = ""
user_tr += "<tr>"
user_tr += "<td>#{ user.name }</td>"
user_tr += "<td>#{ user.email }</td>"
user_tr += "<td>#{ link_to 'Show', user }</td>"
user_tr += "<td>#{ link_to 'Edit', edit_user_path(user) }</td>"
user_tr += "<td>#{ link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } }</td>"
user_tr += "</tr>"
user_tr.html_safe
end
end
Started GET "/users" for 172.23.0.1 at 2022-04-01 14:11:22 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
Rendering layout layouts/application.html.erb
Rendering users/index.html.erb within layouts/application
User Load (5.5ms) SELECT "users".* FROM "users"
↳ app/views/users/index.html.erb:15
Rendered users/index.html.erb within layouts/application (Duration: 603.1ms | Allocations: 1170888)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 647.2ms | Allocations: 1174478)
Completed 200 OK in 650ms (Views: 642.6ms | ActiveRecord: 5.5ms | Allocations: 1174883)
参考
偉そうにいろいろ書いていましたが、実は公式リファレンスに載っています。
この方法を知らずにしばらくパフォーマンスの悪い書き方を続けてしまっていたことが恥ずかしい。。