Caching là thuật ngữ chung chỉ việc lưu trữ kết quả của một đoạn code để có thể truy xuất nhanh chóng sau này. Điều này cho phép chúng ta, ví dụ, tránh truy cập database liên tục để lấy dữ liệu hiếm khi thay đổi. Mặc dù khái niệm chung giống nhau cho mọi loại caching, Rails cung cấp các hỗ trợ khác nhau tùy thuộc vào điều chúng ta muốn cache.
Đối với nhà phát triển Rails, các dạng caching phổ biến bao gồm memoization, low-level caching (cả hai đã trình bày trong các phần trước của loạt bài caching), và view caching, là chủ đề chúng ta sẽ trình bày ở đây.
Cách Ruby on Rails render view
Trước hết, hãy làm rõ một số thuật ngữ hơi gây nhầm lẫn. Những gì cộng đồng Rails gọi là "views" là các file nằm trong thư mục app/views. Thông thường đây là file .html.erb, tuy nhiên có các tùy chọn khác (tức .html thuần túy, .js.erb, hoặc file dùng preprocessor khác như slim và haml). Trong nhiều framework web khác, các file này gọi là "templates", mà tôi nghĩ mô tả đúng hơn mục đích sử dụng của chúng.
Khi ứng dụng Rails nhận request GET, nó được điều hướng đến một action controller cụ thể, ví dụ UsersController#index. Action sau đó chịu trách nhiệm thu thập thông tin cần thiết từ database và truyền nó để dùng trong việc render file view/template. Lúc này, chúng ta bước vào "view layer".
Thông thường, view (hoặc template) sẽ là sự kết hợp giữa markup HTML cứng và code Ruby động:
#app/views/users/index.html.erb <div class='user-list'> <% @users.each do |user| %> <div class='user-name'><%= user.name %></div> <% end %> </div>
Code Ruby trong file cần được thực thi để render view (đối với erb đó là bất cứ thứ gì trong thẻ <% %>). Làm mới trang 100 lần, @users.each... sẽ được thực thi 100 lần. Điều tương tự đúng cho các partial được include; processor cần load file partial html.erb, thực thi toàn bộ code Ruby bên trong, và kết hợp kết quả thành một file HTML đơn lẻ gửi lại cho người yêu cầu.
Nguyên nhân làm chậm view
Bạn có thể nhận thấy khi xem trang trong môi trường development, Rails in ra nhiều thông tin log, trông như sau:
Processing by PagesController#home as HTML Rendering layouts/application.html.erb Rendering pages/home.html.erb within layouts/application Rendered pages/home.html.erb within layouts/application (Duration: 4.0ms | Allocations: 1169) Rendered layouts/application.html.erb (Duration: 35.9ms | Allocations: 8587) Completed 200 OK in 68ms (Views: 40.0ms | ActiveRecord: 15.7ms | Allocations: 14307)
Dòng cuối cùng là hữu ích nhất cho chúng ta lúc này. Theo dõi thời gian từ trái sang phải, ta thấy tổng thời gian Rails trả về response cho trình duyệt là 68ms, trong đó 40ms dành cho render file erb và 15.7ms cho xử lý truy vấn ActiveRecord.
Mặc dù là ví dụ đơn giản, nó cũng cho thấy tại sao chúng ta có thể muốn xem xét caching view layer. Ngay cả nếu chúng ta làm cho truy vấn ActiveRecord diễn ra tức thì, chúng ta vẫn dành gấp đôi thời gian để render erb.
Có vài lý do khiến render view chậm; ví dụ, chúng ta có thể gọi truy vấn DB tốn kém trong view hoặc thực hiện nhiều công việc trong vòng lặp. Một tình huống phổ biến nhất tôi thấy đơn giản là render quá nhiều partial, có thể với nhiều cấp lồng nhau.
Hãy tưởng tượng hộp thư email, nơi có thể có một partial xử lý từng hàng:
# app/views/emails/_email.html.erb <li class="email-line"> <div class="email-sender"> <%= email.from_address %> </div> <div class="email-subject"> <%= email.subject %> </div> </div>
Và, trong trang inbox chính, chúng ta render partial cho mỗi email:
# app/views/emails/index.html.erb ... <% @emails.each do |email| %> <%= render email %> <% end %>
Nếu inbox có 100 tin nhắn, chúng ta render partial _email.html.erb 100 lần. Với ví dụ đơn giản, đây không phải lo ngại lớn. Trên máy của tôi, partial chỉ mất 15ms để render toàn bộ index. Tất nhiên, ví dụ thực tế sẽ phức tạp hơn và có thể include partial khác bên trong; không khó để thời gian render tăng lên. Dù chỉ mất 1-2ms render partial _email, cũng sẽ mất 100-200ms cho toàn bộ collection.
May mắn thay, Rails có chức năng tích hợp giúp dễ dàng thêm caching để giải quyết vấn đề này, dù bạn muốn cache chỉ partial __email, trang index, hay cả hai.
View Caching là gì?
View caching trong Ruby on Rails là lấy HTML mà view sinh ra và lưu trữ để dùng sau. Mặc dù Rails hỗ trợ ghi vào filesystem hoặc giữ trong bộ nhớ, cho production bạn gần như chắc chắn muốn server caching độc lập, như Memcached hoặc Redis. memory_store của Rails hữu ích cho development nhưng không thể chia sẻ giữa các process (ví dụ: nhiều server/dyno hoặc server fork như unicorn). Tương tự, file_store cục bộ cho server, không thể chia sẻ giữa nhiều máy, và không tự xóa entry hết hạn, nên bạn cần định kỳ gọi Rails.cache.clear để tránh ổ cứng server bị đầy.
Kích hoạt cache store có thể làm trong file cấu hình môi trường (ví dụ config/environments/production.rb):
# memory store tiện cho test # trong development nhưng không khuyến khích # cho production config.cache_store = :memory_store
Trong cài đặt mặc định, development.rb đã có sẵn cấu hình để dễ dàng bật/tắt caching trên máy. Chỉ cần chạy rails dev:cache để bật/tắt caching.
Caching view trong Rails đơn giản đến mức dễ gây hiểu lầm, nên để minh họa sự khác biệt hiệu năng, tôi sẽ dùng sleep(5) tạo độ trễ nhân tạo:
<% cache do %> <div> <p>Hi <%= @user.name %> <% sleep(5) %> </div> <% end %>
Render view lần đầu mất 5 giây, như mong đợi. Tuy nhiên, tải lại lần hai chỉ mất vài mili-giây vì mọi thứ bên trong block cache do được lấy từ cache.
Thêm View Caching qua ví dụ
Hãy lấy một view ví dụ nhỏ và đi qua các lựa chọn caching. Chúng ta giả sử view này thực sự gây vấn đề hiệu năng:
# app/views/user/show.html.erb <div> Hi <%= @user.name %>! <div> <div> Here's your list of posts, you've written <%= @user.posts.count %> so far <% @user.posts.each do |post| <div><%= post.body %></div> <% end %> </div> <% sleep(5) #artificial delay %>
Đây là khung cơ bản, kèm độ trễ nhân tạo 5 giây. Đầu tiên, chúng ta có thể wrap toàn bộ file show.html.erb trong block cache do như đã mô tả. Giờ, khi cache đã "nóng", chúng ta có thời gian render nhanh. Không lâu thì thấy vấn đề với kế hoạch này.
Thứ nhất, nếu user đổi tên thì sao? Chúng ta chưa nói với Rails khi nào expire trang cache, nên user có thể không bao giờ thấy phiên bản cập nhật. Giải pháp dễ dàng là truyền object @user cho phương thức cache:
<% cache(@user) do %> <div> Hi <%= @user.name %>! </div> ... <% sleep(5) #artificial delay %> <% end %>
Bài trước trong loạt bài về low-level caching đã trình bày chi tiết về cache key, nên tôi không lặp lại ở đây. Hiện tại, chỉ cần biết nếu truyền model cho cache(), nó sẽ dùng thuộc tính updated_at của model đó để sinh key lookup trong cache. Nói cách khác, mỗi khi @user cập nhật, trang cache này sẽ expire và Rails sẽ render lại HTML.
Chúng ta đã giải quyết trường hợp user đổi tên, nhưng bài viết của họ thì sao? Thay đổi bài viết hiện có hoặc tạo mới không thay đổi timestamp updated_at của User, nên trang cache không expire. Ngoài ra, nếu user đổi tên, chúng ta sẽ render lại tất cả bài viết của họ, даже nếu bài viết không thay đổi. Để giải quyết cả hai vấn đề, chúng ta dùng "Russian doll caching" (cache lồng nhau):
<% cache(@user) do %> <div> Hi <%= @user.name %>! <div> <div> Here's your list of posts, you've written <%= @user.posts.count %> so far<br> <% @user.posts.each do |post| %> <% cache(post) do %> <div><%= post.body %></div> <% end %> <% end %> </div> <% sleep(5) #artificial delay %> <% end %>
Chúng ta giờ cache từng post render riêng lẻ (trong thực tế, này có thể là partial). Do đó, ngay cả khi @user cập nhật, chúng ta không phải render lại post; chỉ dùng giá trị cache. Vẫn còn một vấn đề nữa. Nếu một post thay đổi, chúng ta vẫn không render cập nhật vì @user.updated_at không đổi, nên block bên trong cache(@user) do sẽ không thực thi.
Để sửa vấn đề này, cần thêm touch: true vào model Post:
class Post < ApplicationRecord belongs_to :user, touch: true end
Bằng cách thêm touch: true ở đây, chúng ta báo cho ActiveRecord biết mỗi khi post cập nhật, chúng ta muốn timestamp updated_at của user mà nó "thuộc về" cũng được cập nhật.
Tôi cũng nên thêm rằng Rails cung cấp helper đặc biệt để render collection partial, vì nó rất phổ biến:
<%= render partial: 'posts/post', collection: @posts, cached: true %>
Nó tương đương chức năng với:
<% @posts.each do |post| %> <% cache(post) do %> <%= render post %> <% end %> <% end %>
Không chỉ dạng render partial: ... cached: true ít verbose hơn, nó còn cho hiệu quả thêm vì Rails có thể issue multiget tới cache store (đọc nhiều cặp key/value trong một round-trip) thay vì truy cập cache store cho mỗi item trong collection.
Nội dung trang động
Thường thấy một số trang bao gồm một lượng nội dung 'động' thay đổi nhanh hơn nhiều so với phần còn lại của trang. Điều này đặc biệt đúng cho trang chủ hoặc dashboard, nơi có feed hoạt động/tin tức. Bao gồm chúng trong trang cache có thể khiến cache phải invalidate thường xuyên, làm giảm lợi ích từ caching.
Ví dụ đơn giản, hãy thêm ngày hiện tại vào view:
<% cache(@user) do %> <div> Hi <%= @user.name %>, hope you're having a great <%= Date.today.strftime("%A") %>! <div> ... <% end %>
Chúng ta có thể invalidate cache mỗi ngày, nhưng không thực tế vì lý do rõ ràng. Một lựa chọn là dùng giá trị placeholder (hoặc chỉ <span> rỗng) và điền bằng javascript. Cách này thường gọi là "javascript sprinkles" và được ưa chuộng bởi Basecamp, nơi nhiều core code Rails được phát triển. Kết quả như sau:
<% cache(@user) do %> <div> Hi <%= @user.name %>, hope you're having a great <span id='greeting-day-name'>Day</span>! <div> ... <% end %> <script> // assuming you're using vanilla JS with turbolinks document.addEventListener( "turbolinks:load", function() { weekdays = new Array('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'); today = weekdays[new Date().getDay()]; document.getElementById("greeting-day-name").textContent=today; }); </script>
Cách khác là chỉ cache một phần của view. Trong ví dụ, chào hỏi ở đầu trang, nên khá đơn giản để chỉ cache phần sau:
<div> Hi <%= @user.name %>, hope you're having a great <%= Date.today.strftime("%A") %>! <div> <% cache(@user) do %> ... <% end %>
Rõ ràng, điều này thường không đơn giản với layout thực tế, nên bạn phải cân nhắc kỹ nơi và cách áp dụng caching.
Lời cảnh báo
Dễ dàng nhìn view caching như liều thuốc nhanh cho vấn đề hiệu năng. Thực tế, Rails làm cho việc cache view và partial vô cùng dễ dàng, ngay cả khi chúng lồng sâu. Ở bài đầu tiên của loạt bài, tôi đã nêu các vấn đề có thể nảy sinh khi thêm caching vào hệ thống, nhưng tôi nghĩ điều này đặc biệt đúng với view-level caching.
Lý do là view, bản chất của chúng, có nhiều tương tác hơn với dữ liệu nền tảng của hệ thống. Khi áp dụng memoization hoặc low-level caching trong Rails, bạn thường không cần nhìn ra ngoài file bạn đang làm để xác định khi nào và tại sao giá trị cache nên được refresh. Một view, ngược lại, có thể gọi nhiều model khác nhau, và nếu không có kế hoạch rõ ràng, khó xác định model nào nên khiến phần nào của view render lại lúc nào.
Giống như low-level caching, lời khuyên tốt nhất là chiến lược về nơi và khi nào dùng nó. Dùng caching ít nhất có thể, ở ít nơi nhất có thể, để đạt mức hiệu năng chấp nhận được.
Caching mặc định của Rails
Cho đến nay trong loạt bài caching, chúng ta đã xem các cách cache thủ công nhưng ngay cả không có cấu hình thủ công, ActiveRecord đã làm một số caching ngầm để tăng tốc truy vấn (hoặc bỏ qua hoàn toàn). Trong bài tiếp theo của loạt bài caching, chúng ta sẽ xem ActiveRecord đang cache gì cho chúng ta, và như thế nào, với ít công sức, chúng ta có thể để nó giữ "counter cache" để các dòng như thing.children.size không cần truy cập database để lấy count cập nhật.