บทความนี้มีภาษาเกาหลีด้วย ขอบคุณ Soonsang Hong!
Scopes ของ Rails ทำให้หา records ที่ต้องการง่าย:
class Review < ActiveRecord::Base belongs_to :restaurant scope :positive, -> { where("rating > 3.0") } end
irb(main):001:0> Restaurant.first.reviews.positive.count Restaurant Load (0.4ms) SELECT `restaurants`.* FROM `restaurants` ORDER BY `restaurants`.`id` ASC LIMIT 1 (0.6ms) SELECT COUNT(*) FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0) => 5
แตถ้าไม่ระวัง จะทำลาย performance ของแอปอย่างรุนแรง
ทำไม? Preload scope ไม่ได้จริงๆ ถ้าพยายามแสดงหลาย restaurants พร้อม positive reviews:
irb(main):001:0> restauraunts = Restaurant.first(5) irb(main):002:0> restauraunts.map do |restaurant| irb(main):003:1* "#{restaurant.name}: #{restaurant.reviews.positive.length} positive reviews." irb(main):004:1> end Review Load (0.6ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0) Review Load (0.5ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 2 AND (rating > 3.0) Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 3 AND (rating > 3.0) Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 4 AND (rating > 3.0) Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 5 AND (rating > 3.0) => ["Judd's Pub: 5 positive reviews.", "Felix's Nightclub: 6 positive reviews.", "Mabel's Burrito Shack: 7 positive reviews.", "Kendall's Burrito Shack: 2 positive reviews.", "Elisabeth's Deli: 15 positive reviews."]
ใช่ นั่นคือ N+1 query สาเหตุใหญ่ที่สุดของ Rails apps ช้า
แตแก้ได้ง่าย ถ้าคิดถึง relationship แบบต่างออกไป
แปลง Scopes เป็น Associations
ตอน用 Rails association methods เช่น belongs_to และ has_many model มักเป็นแบบนี้:
class Restaurant < ActiveRecord::Base has_many :reviews end
แตถ้าเช็ค documentation จะเห็นว่าทำได้มากกว่านี้ ส่ง parameters อื่นๆ เข้าไปเปลี่ยน behavior ได้
scope เป็นหนึ่งใน parameters ที่ useful ที่สุด ทำงานเหมือน scope ตอนแรก:
class Restaurant < ActiveRecord::Base has_many :reviews has_many :positive_reviews, -> { where("rating > 3.0") }, class_name: "Review" end
irb(main):001:0> Restaurant.first.positive_reviews.count Restaurant Load (0.2ms) SELECT `restaurants`.* FROM `restaurants` ORDER BY `restaurants`.`id` ASC LIMIT 1 (0.4ms) SELECT COUNT(*) FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0) => 5
ตอนนี้ preload association ใหม่ได้ด้วย includes:
irb(main):001:0> restauraunts = Restaurant.includes(:positive_reviews).first(5) Restaurant Load (0.3ms) SELECT `restaurants`.* FROM `restaurants` ORDER BY `restaurants`.`id` ASC LIMIT 5 Review Load (1.2ms) SELECT `reviews`.* FROM `reviews` WHERE (rating > 3.0) AND `reviews`.`restaurant_id` IN (1, 2, 3, 4, 5) irb(main):002:0> restauraunts.map do |restaurant| irb(main):003:1* "#{restaurant.name}: #{restaurant.positive_reviews.length} positive reviews." irb(main):004:1> end => ["Judd's Pub: 5 positive reviews.", "Felix's Nightclub: 6 positive reviews.", "Mabel's Burrito Shack: 7 positive reviews.", "Kendall's Burrito Shack: 2 positive reviews.", "Elisabeth's Deli: 15 positive reviews."]
แทน 6 SQL calls กลายเป็น 2 calls
(用 class_name สร้าง multiple associations ไป object เดียวกันได้ ใช้บ่อย)
เรื่อง Duplication
อาจยังมีปัญหา where("rating > 3.0") อยู่บน Restaurant class ถ้าต่อไปเปลี่ยน positive reviews เป็น rating > 3.5 ต้อง update สองที่!
แย่กว่า: ถ้าอยากดึง positive reviews ทั้งหมดที่คนเคยทิ้งไว้ ต้อง duplicate scope ไป User class ด้วย:
class User < ActiveRecord::Base has_many :reviews has_many :positive_reviews, -> { where("rating > 3.0") }, class_name: "Review" end
ไม่ DRY เลย
มีวิธีหลีกเลี่ยงง่าย ภายใน where ใช้ positive scope ที่เพิ่มไว้ใน Review class:
class Restaurant < ActiveRecord::Base has_many :reviews has_many :positive_reviews, -> { positive }, class_name: "Review" end
วิธีนี้ "ความคิด" ว่าอะไรทำให้ review เป็น positive อยู่ที่เดียว
Scopes ดี ตำแหน่งที่ถูกต้อง ทำให้ query ข้อมูง่ายและสนุก แตถ้าอยากหลีกเลี่ยง N+1 queries ต้องระวัง
ถ้า scope เริ่มทำปัญหา ให้ห่อมันใน association และ preload ไม่ได้ทำงานเพิ่มมาก และประหยัด SQL calls มาก