Tối ưu hiệu suất cho các view trong Rails: Chiến lược render nào phù hợp?
Hiệu suất của một ứng dụng Ruby on Rails thường được cải thiện qua việc di chuyển các logic chậm hoặc tốn kém sang job nền, xem xét các truy vấn chậm và thêm chỉ mục còn thiếu, hoặc theo dõi và khắc phục các vấn đề truy vấn N+1. Tuy nhiên, lớp view, thường bị bỏ qua, cũng là một mục tiêu quan trọng trong việc cải thiện hiệu suất. Trong bài viết này, chúng ta sẽ điểm qua các chiến lược render khác nhau trong Rails, thực hiện benchmark để thiết lập cơ sở, và phân tích để quyết định khi nào nên sử dụng chúng (hoặc khi nào không nên).
Các chiến lược render trong Rails
Trong Rails, chúng ta có thể render một template theo nhiều cách khác nhau. Để minh họa các chiến lược render khác nhau, chúng ta sẽ sử dụng một ứng dụng Rails 8 đơn giản, tương tự như trong blog 15 phút cổ điển: một model Article có nhiều Comment.
Render inline
Render inline đề cập đến một đoạn HTML có thể được modular hóa bằng cách tách nó thành một partial. Trong một view Rails, ta sẽ làm như sau:
erb
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
<h2>Comments (<%= @article.comments.count %>)</h2>
<% @article.comments.each do |comment| %>
<div class="comment">
<p><strong><%= comment.author %>:</strong> <%= comment.body %></p>
<small>Đăng lúc <%= comment.created_at.strftime("%b %d, %Y at %H:%M") %></small>
</div>
<% end %>
Render inline rất tốt cho các view nhỏ, nhưng lại khó sử dụng cho các view lớn. Và có lẽ, nhược điểm lớn nhất là nó không cho phép tái sử dụng bất kỳ phần HTML nào.
Render partial
Tại đây, chúng ta tách HTML trong vòng lặp ra thành một partial riêng, cho phép chúng ta tái sử dụng phần HTML đó ở những nơi khác và có các file nhỏ hơn, dễ làm việc hơn.
erb
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
<h2>Comments (<%= @article.comments.count %>)</h2>
<% @article.comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
Render collection
Tương tự như render partial, nhưng chúng ta ủy quyền vòng lặp cho phương thức render bằng cách sử dụng tham số collection.
erb
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
<h2>Comments (<%= @article.comments.count %>)</h2>
<%= render partial: "comments/comment", collection: @comments, as: :comment %>
Render implicit
Đây là phiên bản ngắn gọn hơn của tất cả, tương tự về ưu điểm và nhược điểm như chiến lược trước, nhưng ở đây chúng ta cũng ủy quyền cho phương thức render quyết định partial nào sẽ được sử dụng.
erb
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
<h2>Comments (<%= @article.comments.count %>)</h2>
<%= render @comments %>
Benchmark
Benchmark sẽ render mỗi view 1000 lần bằng cách sử dụng Benchmark::bmbm.
Và đây là kết quả:
plaintext
Rehearsal -----------------------------------------------------------
Inline ERB view: 1.597948 0.012652 1.610600 ( 1.611081)
Partial loop view: 6.774650 0.024155 6.798805 ( 6.799789)
Collection render view: 3.257858 0.019441 3.277299 ( 3.279077)
Implicit render view: 3.641655 0.018333 3.659988 ( 3.660372)
------------------------------------------------- total: 15.346692sec
user system total real
Inline ERB view: 1.705810 0.008909 1.714719 ( 1.715067)
Partial loop view: 6.914086 0.026164 6.940250 ( 6.944075)
Collection render view: 3.269090 0.018296 3.287386 ( 3.287694)
Implicit render view: 3.678030 0.019551 3.697581 ( 3.697888)
Phân tích
Chúng ta sẽ phân tích từ chậm nhất đến nhanh nhất, để hiểu cách các tối ưu hóa của mỗi chiến lược hoạt động.
Render partial
Tại sao render partial trong vòng lặp lại chậm?
Phương thức render là một ví dụ hoàn hảo về triết lý nén khái niệm cốt lõi trong thiết kế của Rails. Phía sau, việc render một template thành một chuỗi HTML phức tạp hơn nhiều so với những gì bạn nghĩ:
- Tìm kiếm template đã biên dịch và lưu cache (nhanh, nhưng không miễn phí)
- Tạo một ActionView::Renderer
- Thiết lập ngữ cảnh render và gán các biến địa phương cho nó (
comment: comment) - Cuối cùng, thực thi phương thức template đã lưu cache để sinh ra HTML
Công việc này được lặp lại cho mỗi 1000 comment. Điều này có rất nhiều công việc lặp lại mà chúng ta nên tránh: đó là lý do tại sao chúng ta có render collection.
Cải tiến render collection và implicit
Render collection và render implicit là hai chiến lược chị em. Trong trường hợp này, render implicit thực chất là render collection với một chút phép thuật hướng đối tượng: đối tượng biết cách render chính nó bằng cách triển khai phương thức to_partial_path (được triển khai theo mặc định).
Chúng hoạt động tốt hơn gấp 2 lần nhờ vào việc render collection, các bước 1 và 2 được thực hiện một lần cho toàn bộ vòng lặp, do đó, với 1000 partials, chúng ta tiết kiệm được 999 lần tìm kiếm template và 999 lần khởi tạo ActionView::Renderer. Đó là một khối lượng công việc đáng kể. Càng lớn hơn nữa nếu collection của bạn lớn hơn (mặc dù không thường xảy ra).
Nhưng chúng ta vẫn cần gán các biến địa phương và gọi phương thức render 1000 lần. Chúng ta có thể làm tốt hơn không?
Render inline
Giờ đây, khi chúng ta biết render làm gì dưới nắp, chúng ta có thể dễ dàng xác định lý do tại sao render inline là nhanh nhất: không có render nào cả. Do đó, không có tìm kiếm template, không có khởi tạo ngữ cảnh render hay thiết lập gán, và không có lời gọi phương thức riêng biệt nào để lắp ghép HTML. Tất cả đã được xử lý bởi partial bài viết khi nó được thực hiện một lần. Khó mà vượt qua được.
Suy nghĩ lại
Nếu bạn như tôi, bạn sẽ nghĩ: nếu cú sốc hiệu suất đến từ việc gọi phương thức render và điểm đau chính của các view inline là khả năng bảo trì và tái sử dụng... thì điều gì sẽ xảy ra nếu thay vì một partial, chúng ta đưa template vào một helper bằng cách sử dụng content_tag? Điều này sẽ cho chúng ta lợi ích tốt nhất của cả hai thế giới, đúng không? Chúng ta có thể modular hóa bằng cách sử dụng các phương thức Ruby, và không cần gọi render, vì vậy nó sẽ nhanh, đúng không?
Hãy xem! Hãy thêm phương thức này vào helper ứng dụng:
ruby
def render_comment(comment)
content_tag("div", class: "comment") do
content_tag("p") do
content_tag("strong", comment.author) +
content_tag("small", "đăng lúc #{comment.created_at.strftime("%b %d, %Y at %H:%M")}")
end
end
end
Và view của chúng ta bây giờ trở thành:
erb
<% @comments.each do |comment| %>
<%= render_comment comment %>
<% end %>
Nó gần như trông như một component! Hãy xem benchmark bây giờ:
plaintext
Rehearsal -----------------------------------------------------------
Inline ERB view: 1.576235 0.016086 1.592321 ( 1.595398)
Partial loop view: 6.798589 0.027718 6.826307 ( 6.828153)
Collection render view: 3.215288 0.017600 3.232888 ( 3.234518)
Implicit render view: 3.623890 0.020319 3.644209 ( 3.645871)
helper loop view: 6.856758 0.020698 6.877456 ( 6.878699)
------------------------------------------------- total: 22.173181sec
user system total real
Inline ERB view: 1.558490 0.010675 1.569165 ( 1.569459)
Partial loop view: 6.928491 0.026780 6.955271 ( 6.955799)
Collection render view: 3.258910 0.018507 3.277417 ( 3.277837)
Implicit render view: 3.659728 0.019208 3.678936 ( 3.679344)
helper loop view: 6.939471 0.024494 6.963965 ( 6.964710)
HA! Bạn đã bao giờ thấy một giả thuyết sụp đổ một cách ngoạn mục chưa? Có vẻ như có những thứ tồi tệ hơn cả render ở đó!
Điều gì đã xảy ra ở đó?
Để hiểu tại sao chiến lược helper lại chậm hơn rất nhiều, chúng ta cần xem ứng dụng của mình thực sự đang làm gì. Một cách tốt để xem mã của chúng ta dành phần lớn thời gian ở đâu là sử dụng profiler.
Đây là những gì ruby-prof trả về cho chúng ta (những dòng thú vị nhất):
plaintext
Measure Mode: wall_time
Thread ID: 1616
Fiber ID: 9368
Total: 0.044312
Sort by: self_time
%self total self wait child calls name location
11.18 0.005 0.005 0.000 0.000 6027 String#initialize
6.20 0.022 0.003 0.000 0.019 4002 ActionView::Helpers::TagHelper::TagBuilder#content_tag_string ...
Chà... nó có vẻ khá bừa bộn. Chúng ta đang tìm kiếm gì ở đây? Chúng ta muốn những dòng có %self cao (% của thời gian tổng số đã lấy mẫu) và thời gian child thấp, và thời gian self cao hơn hoặc bằng. Điều này có nghĩa là thời gian đã sử dụng bởi phương thức đó và không phải bởi một phương thức khác đã được gọi. Ví dụ:
String#initialize: được gọi 6027 lần và chiếm 11% thời gianTagHelper#content_tag: được gọi 4000 lần, 5%
Chúng ta có thể thấy rằng thời gian chủ yếu tiêu tốn vào việc khởi tạo String và SafeBuffer, và các phép kiểm tra/giá trị chuỗi.
Bên trong, mỗi content_tag:
- Xác thực tên thẻ
- Xử lý các thuộc tính
- Escape thuộc tính qua
ERB::Util.html_escape - Khởi tạo và trả về một
ActiveSupport::SafeBuffer
Và chúng ta có 4 điều này, 1000 lần. Các thao tác nhanh, nhưng có quá nhiều đến nỗi công việc tích tụ.
Vì vậy, render inline là người chiến thắng tuyệt đối về hiệu suất.
Khi nào nên và không nên
Chúng ta đã tập trung hoàn toàn vào hiệu suất, nhưng bạn có thể tưởng tượng rằng việc render tất cả các view của bạn trong một file ERB lớn duy nhất, vì nó là nhanh nhất, có thể không phải là lựa chọn thông minh nhất. Có những sự đánh đổi, luôn luôn:
- Bạn có thể đánh đổi một chút hiệu suất để lấy khả năng bảo trì/đọc hiểu,
- Hoặc trao đổi một chút hiệu suất để lấy khả năng tái sử dụng,
- Hoặc ngược lại, hy sinh khả năng đọc hiểu/bảo trì để có được sự gia tăng hiệu suất khi mọi thứ trở nên nghiêm trọng.
Tại đây, chúng ta bước vào lĩnh vực lựa chọn thiết kế. Như thường lệ với những khía cạnh thú vị của lập trình, không có thuốc chữa bách bệnh, không có câu trả lời đúng duy nhất. Điều này chủ yếu liên quan đến sự đồng thuận của nhóm và văn hóa dự án hơn là các quyết định thuần túy kỹ thuật.
Một số khuyến nghị
Chúng tôi thường sử dụng các quy tắc/tiêu chí sau để quyết định khi nào nên sử dụng cái này hay cái kia:
Cố gắng làm cho cây view của bạn càng nông càng tốt. Modular hóa khi có ý nghĩa, không chỉ vì cái lợi của nó, vì sự gián tiếp không miễn phí (về mặt hiệu suất và khả năng bảo trì).
Các ưu tiên của chúng tôi để quyết định:
- Tập trung vào khả năng bảo trì và đọc hiểu trước. Mã được viết một lần và đọc hàng ngàn lần. Hãy tử tế với chính mình trong tương lai.
- Luôn sử dụng render collection (nơi có thể). Chúng tôi thích nó hơn render implicit vì nó rõ ràng hơn và linh hoạt hơn về vị trí các partial, và không cần phải chuyển ngữ cảnh từ view sang model để biết partial nào sẽ được render.
- Luôn profiler mã của bạn (ví dụ, sử dụng rack-mini-profiler, hoặc bất kỳ giải pháp observability/APM nào). Mặc dù việc render không phải lúc nào cũng là lý do đầu tiên khiến các view chậm, nếu bạn phát hiện một partial làm mọi thứ chậm lại, bạn có thể thử inline nó.
Nhưng có lẽ điều quan trọng hơn cần lưu ý là việc tinh chỉnh hiệu suất render có thể là không đáng kể trong bối cảnh rộng lớn hơn. Ví dụ, nếu một trang đang tải 300 phụ thuộc JS bên thứ ba, việc tiết kiệm 80ms từ việc render view của bạn sẽ không giúp nhiều với hiệu suất ứng dụng của bạn hay những gì người dùng của bạn cảm nhận. Cũng có những lựa chọn khác nằm ngoài hiệu suất thuần túy của view, như caching hoặc đưa ra quyết định sản phẩm thông minh, nhưng mỗi vấn đề hiệu suất là khác nhau và người ta luôn nên điều tra những gì có thể trong từng vấn đề cụ thể.