Caching (แคช) เป็นคำทั่วไปที่หมายถึงการเก็บผลลัพธ์ของโค้ดบางส่วนไว้ เพื่อให้สามารถเรียกใช้ได้อย่างรวดเร็วในภายหลัง ทำให้เราสามารถหลีกเลี่ยงการ Query Database ซ้ำๆ เพื่อข้อมูลที่เปลี่ยนแปลงน้อย แม้แนวคิดทั่วไปจะเหมือนกันสำหรับทุกประเภทของ Caching แต่ Rails มีเครื่องมือช่วยเหลือที่แตกต่างกันไป ขึ้นอยู่กับสิ่งที่เราต้องการ Cache
สำหรับ Rails Developers รูปแบบทั่วไปของ Caching รวมถึง Memoization, Low-level Caching (ครอบคลุมในส่วนก่อนหน้าของซีรีส์นี้) และ View Caching ซึ่งเราจะครอบคลุมในที่นี้
Ruby on Rails Render Views อย่างไร
ก่อนอื่น มาทำความเข้าใจ术语ที่อาจสับสนเล็กน้อย สิ่งที่ชุมชน Rails เรียกว่า "Views" คือไฟล์ที่อยู่ใน Directory app/views โดยปกติเป็นไฟล์ .html.erb แม้จะมีตัวเลือกอื่นๆ (เช่น .html แบบธรรมดา, .js.erb หรือไฟล์ที่ใช้ Preprocessors อื่นๆ เช่น Slim และ Haml) ใน Web Frameworks อื่นๆ ไฟล์เหล่านี้มักเรียกว่า "Templates" ซึ่งผมคิดว่านิยามได้ชัดเจนกว่า
เมื่อ Rails Application รับ Request GET จะถูก Route ไปยัง Controller Action เฉพาะ เช่น UsersController#index Action นั้นจะรับผิดชอบเก็บรวบรวมข้อมูลจาก Database และส่งต่อไปเพื่อใช้ในการ Render View/Template File ณ จุดนี้ เราเข้าสู่ "View Layer"
โดยปกติ View (หรือ Template) ของคุณจะเป็นผสมผสานระหว่าง HTML Markup แบบ Hard-coded และ Ruby Code แบบ Dynamic:
#app/views/users/index.html.erb <div class='user-list'> <% @users.each do |user| %> <div class='user-name'><%= user.name %></div> <% end %> </div> Ruby Code ในไฟล์ต้องถูก Execute เพื่อ Render View (สำหรับ ERB คือทุกอย่างใน Tags <% %>) Refresh หน้า 100 ครั้ง @users.each... จะถูก Execute 100 ครั้ง เช่นเดียวกับ Partials ที่ Include เข้าไป Processor ต้องโหลด Partial html.erb File, Execute Ruby Code ทั้งหมดภายใน และรวมผลลัพธ์เป็น HTML File เดียวส่งกลับไปยัง Requester
อะไรทำให้ Views ช้า
คุณอาจสังเกตตอนดูหน้าเว็บระหว่าง Development Rails พิมพ์ Log มาก ซึ่งหน้าตาประมาณนี้:
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) บรรทัดสุดท้ายเป็นประโยชน์ที่สุด ตามเวลาจากซ้ายไปขวา เราเห็นว่า Rails ใช้เวลาทั้งหมด 68ms ตอบกลับ Browser โดย 40ms ใช้ Render ERB Files และ 15.7ms สำหรับ ActiveRecord Queries
แม้จะเป็นตัวอย่างง่าย แต่ก็แสดงว่าเราอาจอยากดู Caching ที่ View Layer แม้เราจะทำให้ ActiveRecord Queries เร็วเป็นพิศวง เราก็ยังใช้เวลา Render ERB มากกว่าสองเท่า
มีหลายเหตุผลที่ View Rendering อาจช้า เช่น เราอาจเรียก DB Queries ที่แพงภายใน Views หรือทำงานหนักใน Loops สถานการณ์ทั่วไปที่ผมเห็นบ่อยคือการ Render Partials เยอะมาก อาจมีหลายระดับ Nested
ลองนึกภาพ Email Inbox ที่อาจมี Partial จัดการ Row เดียว:
# 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> และในหน้า Inbox หลัก เรา Render Partial สำหรับแต่ละ Email:
# app/views/emails/index.html.erb ... <% @emails.each do |email| %> <%= render email %> <% end %> ถ้า Inbox มี 100 ข้อความ เราก็ Render Partial _email.html.erb 100 ครั้ง ด้วยตัวอย่างง่ายนี้ ไม่ใช่ปัญหาใหญ่ บนเครื่องผม Partial ใช้แค่ 15ms Render ทั้ง Index แต่ตัวอย่างจริงจะซับซ้อนกว่าและอาจ Include Partials อื่นๆ ภายใน ไม่ยากที่ Render Time จะเพิ่มขึ้น แม้แค่ 1-2ms ต่อ Partial _email ก็จะใช้ 100-200ms สำหรับ Collection ทั้งหมด
โชคดีที่ Rails มี Built-in Functionality ช่วยเพิ่ม Caching แก้ปัญหานี้ได้ง่าย ไม่ว่าจะ Cache แค่ Partial __email, หน้า index หรือทั้งคู่
View Caching คืออะไร
View Caching ใน Ruby on Rails คือการนำ HTML ที่ View สร้างขึ้นมาเก็บไว้เพื่อใช้ภายหลัง แม้ Rails รองรับการเขียนลง Filesystem หรือเก็บใน Memory แต่สำหรับ Production คุณแทบแน่นอนจะต้องการ Standalone Caching Server เช่น Memcached หรือ Redis memory_store ของ Rails เหมาะสำหรับ Development แต่ไม่สามารถ Share Across Processes ได้ (เช่น Multiple Servers/Dynos หรือ Forking Servers เช่น Unicorn) เหมือนกัน file_store เป็น Local ต่อ Server ไม่ Share Across Multiple Boxes ได้ และไม่ลบ Expired Entries อัตโนมัติ คุณต้องเรียก Rails.cache.clear เป็นระยะเพื่อกัน Disk เต็ม
การ Enable Caching Store ทำได้ใน Environment Config File (เช่น config/environments/production.rb):
# memory store สะดวกสำหรับทดสอบ # ระหว่าง Development แต่ไม่แนะนำ # สำหรับ Production config.cache_store = :memory_store ใน Default Installation development.rb จะมี Config สำหรับ Toggle Caching บนเครื่องอยู่แล้ว รัน rails dev:cache เพื่อเปิด/ปิด Caching
การ Cache View ใน Rails ง่ายมาก จึงใช้ sleep(5) สร้าง Delay เทียมเพื่อแสดงความแตกต่าง:
<% cache do %> <div> <p>Hi <%= @user.name %> <% sleep(5) %> </div> <% end %> Render View ครั้งแรกใช้ 5 วินาที ตามคาด แต่โหลดครั้งที่สองใช้ไม่กี่ Milliseconds เพราะทุกอย่างใน Block cache do ถูกดึงจาก Cache
เพิ่ม View Caching ด้วยตัวอย่าง
มาลอง View ตัวอย่างเล็กๆ และเดินผ่านตัวเลือก Caching เราจะสมมติว่า View นี้ทำให้ Performance มีปัญหา:
# 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 %> เรามีโครงร่างพื้นฐานพร้อม Delay 5 วินาทีเทียม แรก เราสามารถ Wrap ทั้งไฟล์ show.html.erb ใน Block cache do ตามที่อธิบาย ตอน Cache Warm เราจะได้ Rendering Times เร็วขึ้น แต่ไม่นานก็จะเห็นปัญหา
แรก ถ้า User เปลี่ยนชื่อ จะเกิดอะไรขึ้น? เราไม่ได้บอก Rails เมื่อไหร่ Expire Cached Page ดังนั้น User อาจไม่เห็น Version ใหม่เลย วิธีแก้ง่ายคือส่ง Object @user เข้า Method cache:
<% cache(@user) do %> <div> Hi <%= @user.name %>! </div> ... <% sleep(5) #artificial delay %> <% end %> บทความก่อนหน้าในซีรีส์ Low-level Caching ครอบคลุม Cache Keys แล้ว จึงไม่ซ้ำที่นี่ เพียงพอที่จะรู้ว่า ถ้าส่ง Model เข้า cache() มันจะใช้ Attribute updated_at ของ Model นั้นสร้าง Key ค้นหาใน Cache กล่าวคือ ทุกครั้งที่ @user ถูก Update Cached Page นี้จะ Expire และ Rails จะ Re-render HTML
เราแก้กรณี User เปลี่ยนชื่อแล้ว แต่ Posts ละ? การเปลี่ยน Post เดิมหรือสร้าง Post ใหม่ไม่เปลี่ยน Timestamp updated_at ของ User ดังนั้น Cached Page ไม่ Expire นอกจากนี้ ถ้า User เปลี่ยนชื่อ เราจะ Re-render ทั้งหมด ของ Posts แม้ Posts จะไม่เปลี่ยน แก้ทั้งสองปัญหา: ใช้ "Russian Doll Caching" (Cache ภายใน Cache):
<% 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 %> ตอนนี้เรา Cache แต่ละ post ที่ Render แยกกัน (ในจริงน่าจะเป็น Partial) ดังนั้นแม้ @user จะ Update เราก็ไม่ต้อง Re-render Post ใช้ Cached Value ได้เลย แต่ยังมีอีกปัญหา: ถ้า post ถูกเปลี่ยน เรายังไม่ Render Update เพราะ @user.updated_at ไม่เปลี่ยน ดังนั้น Block ใน cache(@user) do จะไม่ Execute
เพื่อแก้ เราต้องเพิ่ม touch: true ใน Model Post:
class Post < ApplicationRecord belongs_to :user, touch: true end ด้วย touch: true เราบอก ActiveRecord ว่า ทุกครั้ง ที่ Post ถูก Update เราต้องการให้ Timestamp updated_at ของ User ที่มัน "belongs_to" ถูก Update ด้วย
ควรเพิ่มว่า Rails มี Helper เฉพาะสำหรับ Render Collection ของ Partials เนื่องจากมันพบบ่อยมาก:
<%= render partial: 'posts/post', collection: @posts, cached: true %> ซึ่งเทียบเท่ากับ:
<% @posts.each do |post| %> <% cache(post) do %> <%= render post %> <% end %> <% end %> รูปแบบ render partial: ... cached: true ไม่เพียงแต่น้อยกว่า Verbose แต่ยังมี Efficiency เพิ่ม เพราะ Rails สามารถ Multi-get จาก Cache Store (อ่าน Key/Value Pairs หลายคู่ใน Round-trip เดียว) แทนที่จะ Hit Cache Store ทีละ Item ใน Collection
Dynamic Page Content
มักพบว่า บางหน้ามี 'Dynamic' Content ที่เปลี่ยนแปลงเร็วกว่าส่วนอื่นๆ ของหน้ามาก นี่ตรงจริงมากกับ Home Pages หรือ Dashboards ที่มี Activity/News Feeds การ Include เข้าใน Cached Page อาจทำให้ Cache ต้อง Invalidate บ่อย ซึ่งลดประโยชน์ของ Caching
ตัวอย่างง่าย มาเพิ่มวันปัจจุบันใน View:
<% cache(@user) do %> <div> Hi <%= @user.name %>, hope you're having a great <%= Date.today.strftime("%A") %>! <div> ... <% end %> เราอาจ Invalidate Cache ทุกวัน แต่ไม่เป็นประโยชน์ ตัวเลือกหนึ่งคือใช้ Placeholder Value (หรือแค่ Empty <span>) และ Populate ด้วย JavaScript วิธีนี้เรียกว่า "JavaScript Sprinkles" เป็นแนวทางที่ Basecamp ชอบ (ซึ่งพัฒนา Rails Core มาก) ผลลัพธ์จะประมาณนี้:
<% 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> อีกวิธีคือ Cache แค่บางส่วนของ View ในตัวอย่าง Greeting อยู่บนสุด จึงแยก Cache ส่วนที่ตามมาได้ง่าย:
<div> Hi <%= @user.name %>, hope you're having a great <%= Date.today.strftime("%A") %>! <div> <% cache(@user) do %> ... <% end %> ชัดเจนว่าใน Real World Layouts มักซับซ้อนกว่า คุณต้องระมัดระวังในการวาง Caching อย่างไร
คำเตือน
มอง View Caching ว่าเป็น Panacea (ยาวิเศษ) แก้ Performance Problems ได้ง่ายๆ นี่เป็นเรื่องง่ายเพราะ Rails ทำให้ Cache Views และ Partials ได้ อย่าง难以置信地ง่าย แม้จะ Nested ลึก ในบทความแรกของซีรีส์ ผมเน้น Issues ที่เกิดเมื่อเพิ่ม Caching ลงใน System แต่ผมคิดว่านี่ตรงจริงมากกับ View-level Caching
เหตุผลคือ Views โดยธรรมชาติ มักมี Interaction กับ Underlying Data ของ System มากกว่า เมื่อ Apply Memoization หรือ Low-level Caching ใน Rails คุณมักไม่ต้องมองนอกไฟล์นั้นๆ เพื่อตัดสินใจเมื่อไหร่และทำไม Cached Value ควร Refresh แต่ View อาจมี Multiple Models ถูกเรียก และโดยไม่มีการวางแผนตั้งใจ อาจยากที่จะเห็นว่า Model ไหนควรทำให้ส่วนไหนของ View Re-render เมื่อไหร่
เหมือน Low-level Caching คำแนะนำที่ดีที่สุดคือเป็น Strategic เกี่ยวกับ Where และ When ใช้ ใช้ Caching น้อยที่สุด ในสถานที่น้อยที่สุด เพื่อให้ได้ Performance ระดับที่ยอมรับได้
Rails Caching By Default
จนตอนนี้ในซีรีส์ Caching เราครอบคลุมการ Cache แบบ Manual แม้ไม่มี Manual Config ใดๆ ActiveRecord ก็ทำ Caching ใต้หลอด (under-the-hood) ไว้แล้วเพื่อเร่ง Queries (หรือข้ามไปเลย) ในบทความต่อไปของซีรีส์ Caching เราจะดูว่า ActiveRecord Cache ให้เราอะไรบ้าง และว่าด้วยความพยายามเล็กน้อย เราสามารถให้มันเก็บ "Counter Cache" ไว้ เพื่อให้บรรทัดแบบ thing.children.size ไม่ต้อง Hit Database เลยเพื่อนับ Up-to-date Count