Scopes hay Class method

Khi review code của mọi người, đặc biệt là một số new member, tôi thường thấy mọi người hay bị hiểu sai tác dụng của scope và thường viết scope như viết class method, trường hợp ngược lại thì ít gặp hơn. Câu hỏi đặt ra là lúc nào thì viết scope, lúc nào thì viết class method ? Bài viết này sẽ trả lời câu hỏi đó.

Scope là gì ?

Scope là một cách tuyệt vời để lấy các object từ database. Với RoR, chúng ta có thể viết như sau:

# app/models/review.rb
class Review < ActiveRecord::Base
  scope :most_recent, -> (limit) { order("created_at desc").limit(limit) }
end

Để gọi scope ta có thể gọi như sau: @recent_reviews = Review.most_recent(5) Cách gọi scope khá giống với gọi class method và trên thực tế chúng ta hoàn toàn có thể viết class method giống như viết scope:

# app/models/review.rb
def self.most_recent(limit)
  order("created_at desc").limit(limit)
end

Vậy tại sao chúng ta phải sử dụng scope trong khi có thể dùng class method

Tại sao phải sử dụng scope ?

Hãy thử một yêu cầu sau: Hãy lấy tất cả những reviews được viết vào một ngày nào đó, nhưng nếu không chỉ định ngày nào thì sẽ lấy ra tất cả. Nếu dùng scope sẽ viết như sau:

# app/models/review.rb
scope :created_since, ->(time) { where("reviews.created_at > ?", time) if time.present? }

Còn nếu dùng class method thì sao:

# app/models/review.rb
def self.created_since(time)
  if time.present?
    where("reviews.created_at > ?", time)
  else
    all
  end
end

Rõ ràng nếu dùng scope chúng ta sẽ viết ngắn gọn hơn rất nhiều. Hơn nữa, scope được thiết kế để bắt buộc phải trả về scope vì vậy chúng dễ dàng được gắn kết với nhau Review.scope_1.scope_2...

Kể cả trong trường hợp, scope của bạn vì một lý do nào đó không trả về một scope thì sẽ luôn có lỗi được thông báo.

# app/models/review.rb
scope :broken, -> { "Hello!!!" }

-------------------------------
irb(main):001:0> Review.broken.most_recent(5)
NoMethodError: undefined method `most_recent' for "Hello!!!":String

Nhưng với class method thì không làm việc như vậy, bạn phải tự xử lý trường hợp mà method không trả về một scope phù hợp (trường hợp trả về nil chẳng hạn) nếu muốn các class method có thể gắn kết với nhau.

Tuy nhiên không phải lúc nào cũng nên sử dụng scope, có những trường hợp chúng ta nên sử dụng class method thay cho scope

Khi nào nên dùng class method thay thế scope ?

Có 2 trường hợp nên sử dụng class method:

  • Khi cần preload scopes, chúng ta sẽ chuyển scope thành associations thay vì dùng trực tiếp scope
  • Khi cần làm nhiều việc hơn là đơn giản chỉ là nối những scope nhỏ thành một scope lớn, ta sẽ sử dụng class method.

Trường hợp 1:

Giả sử chúng ta có một model như sau:

# app/models/review.rb
class Review < ActiveRecord::Base
  belongs_to :restaurant

  scope :positive, -> { where("rating > 3.0") }
end

Nếu dùng trực tiếp scope để lấy những review positive thì sẽ gặp N+1 query, nó sẽ làm chậm Rails app

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."]

Vì vậy chúng ta phải chuyển scope thành associations như sau để tránh trường hợp N+1 query:

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  has_many :reviews
  has_many :positive_reviews, -> { positive }, class_name: "Review"
end

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."]

Trường hợp 2:

Khi logic trong scope của bạn quá phức tạp, thì class method là một lựa chọn tốt hơn. Bởi:

  • Với một class method, bạn có thể dễ dàng đưa thêm code Ruby khác ngoài code liên quan tới database. Ví dụ: nếu cần đoạn code sorting mà nó dễ dàng hơn nếu viết bằng Ruby thì ta hoàn toàn có thể lấy objects bằng code database và dùng sort_by của Ruby để sắp xếp lại
# app/models/review.rb
def self.created_since(time)
  if time.present?
    where("reviews.created_at > ?", time)
  else
    all
  end.limit(100).sort_by{|_review| _review.created_at}
end
  • Hoặc sẽ tốt hơn, nếu sử dụng class method để lấy data từ các nguồn khác nhau: database, Redis, hoặc API hay service.

Tham khảo

https://www.justinweiss.com/articles/should-you-use-scopes-or-class-methods/ https://www.justinweiss.com/articles/how-to-preload-rails-scopes//