Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

เร่งความเร็ว Rails Views: เทคนิคพิสูจน์แล้วสำหรับประสิทธิภาพสูงสุด

ในบทความนี้ เราจะมาดูวิธีที่พิสูจน์แล้วในการปรับปรุงประสิทธิภาพ Rails views โดยเฉพาะ database efficiency, view manipulation และ caching

ผมคิดว่า phrase "premature optimization is the root of all evil" ถูกนำไปใช้ผิดบริบทบ้าง ผมได้ยิน developers ใช้ใน code reviews ตอนชี้ optimization techniques เรียบง่าย คุณรู้จัก "I'll get it working and then optimize it" - แล้ว test - แล้ว debug - แล้ว test อีก...

ขอขอบคุณ ที่มี optimization techniques เรียบง่ายและมีประสิทธิภาพ ที่ใช้ได้ตั้งแต่เริ่มเขียนโค้ด

👋 ถ้าชอบบทความนี้ ลองดู Ruby (on Rails) performance articles อื่นๆ ใน Ruby performance monitoring checklist

ตลอดบทความ เราจะใช้ basic Rails app ทำ improvements และเปรียบเทียบผลลัพธ์

Basic Rails app มี models:

  • Person (has many addresses)
    • name:string
    • votes_count:integer
  • Profile (belongs to Person)
    • address:string

โค้ด Person model:

# == Schema Information
# Table name: people
# id :integer not null, primary key
# name :string
# votes_count :integer
# created_at :datetime not null
# updated_at :datetime not null

class Person < ApplicationRecord
  # Relationships
  has_many :profiles
  # Validations
  validates_presence_of :name
  validates_uniqueness_of :name

  def vote!
    update votes_count: votes_count + 1
  end
end

โค้ด Profile model:

# == Schema Information
# Table name: profiles
# id :integer not null, primary key
# address :text
# person_id :integer
# created_at :datetime not null
# updated_at :datetime not null

class Profile < ApplicationRecord
  # Relationships
  belongs_to :person
  # Validations
  validates_presence_of :address
end

มี seed file populate 1000 people ด้วย Faker gem

สร้าง action "home" ใน ApplicationController:

def home
  @people = Person.all
end

โค้ด home.html.erb:

<ul>
  <% @people.each do |person| %>
    <li id="<%= person.id %>"><%= render person %></li>
  <% end %>
</ul>

ลอง dry run และวัด performance

หน้านี้ใช้เวลา 1066.7ms โลด ไม่ดี! นี่คือเป้าหมายที่จะลด

Database Queries

ขั้นแรกสู่ high-performance app คือ maximize resource utilization ほとんど Rails apps render จาก database ไป views มา optimize database calls ก่อน!

สำหรับ demo ใช้ MySQL database

มา看看 1066ms breakdown:

414.7 to execute 'controllers/application_controller#home'
...
(0.1ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 996]]
Rendered people/_person.html.erb (1.5ms)
(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 997]]
Rendered people/_person.html.erb (2.3ms)
...
Rendered application/home.html.erb within layouts/application (890.5ms)
Completed 200 OK in 1066ms (Views: 890.5ms | ActiveRecord: 175.4ms)

519.2 และ 132.8 สำหรับ render "application/home.html.erb" และ "people/_person.html.erb" partials

สังเกตอะไรแปลกไหม?

เราทำ database call หนึ่งครั้งใน controller แต partial ทุกตัวทำ database call ของตัวเอง! นี่คือ N+1 query problem

1. N+1 Queries

Technique นี้ popular และ simple แต่สมควรกล่าวถึงก่อนเพราะ mistake นี้พบบ่อยมาก

มา看看 "people/_person.html.erb" ทำอะไร:

<ul>
  <li> Name: <%= person.name %> </li>
  <li> Addresses: <ul>
    <% person.profiles.each do |profile| %>
      <li><%= profile.address %></li>
    <% end %>
    </ul> </li>
</ul>
<%= button_to "Vote #{person.votes_count}", vote_person_path(person) %>

Basic: query database สำหรับ profiles ของ person และ render แต่ละตัว ทำ N queries (N = จำนวน people) บวก 1 query ใน controller = N+1

เพื่อ optimize ใช้ MySQL joins และ Rails ActiveRecord includes

เปลี่ยน controller เป็น:

def home
  @people = Person.all.includes(:profiles)
end

โหลด all people โดย 1 MySQL query และ profiles ทั้งหมดในอีก 1 query N+1 กลายเป็นแค่ 2 queries

มา看看 performance:

ใช้เวลาแค่ 936ms โหลดหน้า ดูด้านล่าง action "application_controller#home" ทำ 2 MySQL queries

Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.2ms)
...
Rendered application/home.html.erb within layouts/application (936.0ms)
Completed 200 OK in 936ms (Views: 927.1ms | ActiveRecord: 9.3ms)

2. โหลดแค่สิ่งที่จะใช้ (Load Only What You Will Use)

หน้า homepage หน้าตาแบบนี้

เห็นว่าเราต้องการแค่ address ไม่ใช่ profile object ทั้งตัว ใน partial "_person.html.erb" เราโหลด profile object มา看看เปลี่ยนยังไง

<li> Addresses: <ul>
  <% person.profiles.pluck(:address).each do |address| %>
    <li><%= address %></li>
  <% end %>
</ul> </li>

สำหรับ in-depth look at N+1 queries อ่าน ActiveRecord performance: the N+1 queries antipattern

ProTip: สร้าง scope สำหรับนี้ และเพิ่มใน "models/profile.rb" Raw database queries ใน view files ไม่ค่อยมีประโยชน์

3. ย้าย Database Calls ทั้งหมดไป Controller

สมมติอนาคต app นี้ อยากแสดง total users บน home page

ง่าย! เรียกใน view:

# of People: <%= @people.count %>

มี requirement อีกอย่าง ต้องการ UI element แสดง page progress แบ่งจำนวน people บนหน้าด้วย total count

Progress: <%= index / @people.count %>

น่าเสียดาย colleague ไม่รู้ว่าคุณ query แล้ว ทำซ้ำใน views

ถ้า controller เป็น:

def home
  @people = Person.all.includes(:profiles)
  @people_count = @people.count
end

จะ reuse ค่าที่คำนวณแล้วได้ง่ายขึ้น

แม้ไม่ช่วย direct improvement ใน page load speeds แต่ป้องกัน multiple calls ไป database จาก view pages ต่างๆ และเตรียมพร้อมสำหรับ optimizations ภายหลัง เช่น caching

4. Paginate ทุกที่ที่ทำได้!

เหมือน load only what you need แสดงแค่สิ่งที่ต้องการก็ช่วย! ด้วย pagination views render ส่วนหนึ่งของข้อมูล เก็บส่วนที่เหลือโหลด on demand ตัด milliseconds เยอะ! Gems will_paginate และ kaminari ทำได้ในนาที

ข้อรำคาญ: user ต้องกด "Next Page" ตลอด สำหรับนั้น 看看 "Infinite Scrolling" ให้ UX ดีกว่า

หลีกเลี่ยง HTML Reloads

ใน traditional Rails app HTML view rendering ใช้เวลามาก โชคดีมี measures ลดได้

1. Turbolinks

มาพร้อม standard Rails app Turbolinks เป็น JavaScript library ทำงานทุกที่ (แม้ไม่มี Rails เช่น static pages) และ degrade gracefully บน unsupported browsers

แปลงทุก link เป็น AJAX request และ replace ทั้ง body ผ่าน JS ปรับปรุง performance มากเพราะไม่ต้อง reload CSS, JS และ images

แตตอนเขียน custom JS ต้องระวังเขียน "Turbolinks safe JS" อ่านเพิ่มเติมที่นี่

2. ใช้ AJAX Requests

ในเส้นทางเดียวกับ Turbolinks คุณแปลงบาง links และ buttons เป็น AJAX requests ได้ ความต่างคือคุณควบคุม HTML ไหนที่ถูก replace แทนที่จะ replace ทั้ง body เหมือน Turbolinks

มา看看 AJAX ใน action!

ใน sample app มีปุ่ม "Vote" สำหรับแต่ละ user วัดว่าการกระทำนั้นใช้เวลานานแค่ไหน

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by PeopleController#vote as HTML
Person Load (0.3ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.1ms) begin transaction
Person Exists (4.5ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
SQL (1.0ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 1], ["updated_at", "2020-01-21 09:20:49.941928"], ["id", 1]]
Redirected to https://localhost:3000/
Completed 302 Found in 24ms (ActiveRecord: 7.5ms)
Started GET "/" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by ApplicationController#home as HTML
Rendering application/home.html.erb within layouts/application
Rendered people/_person.html.erb (2.4ms)
(0.3ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 30]]
Rendered people/_person.html.erb (2.2ms)
...
Rendered application/home.html.erb within layouts/application (159.8ms)
Completed 200 OK in 190ms (Views: 179.0ms | ActiveRecord: 6.8ms)

ใช้เวลาเท่า reload หน้า บวกเล็กน้อยสำหรับ voting

ทำเป็น AJAX request ตอนนี้ "people/_person.html.erb":

<%= button_to "Vote #{person.votes_count}", vote_person_path(person), remote: true %>

Controller action คืน JS response:

$("#<%= @person.id %>").html("<%= j render(partial: 'person', locals: {person: @person}) %>");

เห็นว่า replace แค่ content ที่ต้องการ ให้ HTML ID hook ไปที่ div และ replace แทนที่ทั้ง partial

ผลลัพธ์?

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:52:56 +0530
Processing by PeopleController#vote as JS
Person Load (0.2ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.1ms) begin transaction
Person Exists (0.3ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
SQL (0.4ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 2], ["updated_at", "2020-01-21 09:22:56.532281"], ["id", 1]]
(1.6ms) commit transaction
Rendering people/vote.js.erb (0.2ms)
SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 1]]
Rendered people/_person.html.erb (3.2ms)
Rendered people/vote.js.erb (6.3ms)
Completed 200 OK in 31ms (Views: 14.6ms | ActiveRecord: 2.9ms)

30ms! แค่นี้! เยี่ยมมาก?

ProTip: ถ้าไม่อยากจัดการ HTML IDs และ classes เยอะ 考慮 gem render_async มันจัดการ heavy lifting ให้

3. ใช้ WebSockets

สิ่งดีของ HTML reload คือได้ fresh content จาก server ทุกครั้ง ด้วย AJAX request คุณเห็น latest content สำหรับ snippet เล็กๆ

WebSockets เป็น technology ให้ server push updates ไป client แทนที่จะ client request ข้อมูลใหม่

มีประโยชน์ตอน build dynamic webpages สมมติต้อง display score ของเกมบนเว็บ เพื่อ fetch new content คุณสามารถ:

  • บอก user reload ทั้งหน้า
  • ให้ reload button refresh แค่ score
  • ใช้ JavaScript poll backend ทุกวินาที
    • จะ ping server ตลอดแม้ไม่มีการเปลี่ยนแปลง
    • แต่ละ client call ทุกวินาที - overwhelm server ง่าย
  • ใช้ WebSockets!

ด้วย WebSockets server ควบคุมเมื่อ push data ไป all clients (หรือ subset) เพราะ server รู้เมื่อ data เปลี่ยน จึง push ก็ต่อเมื่อมีการเปลี่ยนแปลง!

Rails 5 released ActionCable จัดการ WebSockets ทั้งหมด ให้ JS framework สำหรับ client subscribe ไป server และ backend framework สำหรับ server publish changes ด้วย action cable เลือก WebSocket service ใดก็ได้ Faye, self-managed, หรือ Pusher subscription service

ส่วนตัวผมเลือก subscription เพราะลดสิ่งที่ต้องจัดการ

กลับไป WebSockets ตอน setup ActionCable เสร็จ view จะ listen JSON input จาก server รับได้ hook actions ที่เขียนจะ replace HTML content ตามที่ต้องการ

Rails docs และ Pusher มี tutorials สร้างกับ WebSockets ต้องอ่าน!

Caching

ส่วนใหญ่ของ load time ใช้ไปกับ rendering views รวม load CSS, JS, images, render HTML จาก ERB files และอื่นๆ

วิธีลด load time chunk หนึ่งคือ identify ส่วนของแอปที่รู้ว่าจะ static อยู่ระยะหนึ่ง หรือจนกว่าจะมี event

ในตัวอย่าง จนกว่าใครจะ vote home page จะเหมือนกันสำหรับทุกคน (ตอนนี้ไม่มี option ให้ user edit addresses) ลอง cache ทั้ง "home.html.erb" จนกว่าจะมี event (vote)

ใช้ gem Dalli ใช้ Memcached store/retrieve fragments เร็ว Memcached ไม่มี datatype สำหรับ storage ทำให้ store อะไรก็ได้

1. Caching Views

Load time สำหรับ 2000 records โดยไม่ cache คือ 3500ms!

Cache ทุกอย่างใน "home.html.erb" ง่าย:

<% cache do %>
  <ul>
    <% @people.each do |person| %>
      <li id="<%= person.id %>"><%= render person %></li>
    <% end %>
  </ul>
<% end %>

ติดตั้ง Dalli gem และเปลี่ยน cache store ใน "development.rb" เป็น:

config.cache_store = :dalli_store

ถ้าบน Mac หรือ Linux เริ่ม Memcached service:

memcached -vv

Reload!!

ใช้เวลาประมาณ 537ms! ปรับปรุง 7x!

จะเห็น MySQL queries น้อยมาก เพราะ HTML ทั้งหน้าถูกเก็บใน Memcached และอ่านจากนั้นอีกครั้ง โดยไม่ ping database

ใน application logs จะเห็นหน้าทั้งหมดถูกอ่านจาก cache

ตัวอย่างนี้เพียงขูดผิว view caching คุณสามารถ cache partial rendering และ scope ให้แต่ละ person object (fragment caching) หรือ cache entire collection (collection caching) สำหรับ nested view rendering ลงลึก Russian Doll caching

2. Caching Database Queries

Optimization อีกอย่างสำหรับ view speed คือ cache complex database queries ถ้าแอปแสดง stats และ analytics โอกาสสูงที่คุณทำ complex database query คำนวณแต่ละ metric เก็บ output ไว้ใน Memcached และกำหนด timeout หลัง timeout calculation จะทำซ้ำและเก็บ cache ใหม่

ตัวอย่าง สมมติแอปต้อง display size ของ team ของ user การคำนวณซับซ้อนเกี่ยวกับ counts ของ direct reportees, outsourced consultants และอื่นๆ

แทนคำนวณซ้ำๆ cache มัน!

def team_size
  Rails.cache.fetch(:team_size, expires_in: 8.hour) do
    analytics_client = AnalyticsClient.query!(self)
    analytics_client.team_size
  end
end

Cache นี้ auto-expire หลัง 8 ชั่วโมง ตอนนั้น calculation ทำใหม่ และค่าล่าสุด cached สำหรับ 8 ชั่วโมงถัดไป

3. Database Indexes

เร่ง queries ด้วย indexes Query 簡単 fetch addresses ของ person:

person.addresses

Query ถาม Address table คืน addresses ที่ person_id column เป็น person.id โดยไม่มี indexes database ต้อง inspect แต่ละ row เช็ค match person.id ด้วย indexes database มี list ของ addresses ที่ match person.id แล้ว

Resource เรียนรู้ database indexes เพิ่มเติม!

สรุป

ในบทความนี้ เราสำรวจปรับปรุง Rails app view performance โดยปรับปรุง database utilization, ใช้ third-party tools/services และจำกัดสิ่งที่ user เห็น

ถ้าอยากปรับปรุง app performance เริ่ม simple และ measure ตลอด! Clean up database queries, สร้าง AJAX requests ทุกที่ที่ทำได้ และ cache views มากที่สุด จากนั้นไป WebSockets และ database caching

แตระวัง — optimization เป็น slippery slope อาจพบตัวเอง addicted เหมือนผม!

P.S. สำหรับ monitor Rails app performance ใน production เช็ค AppSignal APM - built by Ruby devs for Ruby devs. 🚀