2 cách để test preloading/eager-loading của ActiveRecord associations trong Rails
Chắc nhiều bạn đã từng dùng #includes, #preload or #eager_load để tăng performance và tránh truy vấn N+1. Nhưng trong đó chưa chắc code đã thực hiện đúng đắn và có association preloaded như ý mong muốn hay không? làm sao để test nó? Dưới đấy có 2 cách có thể giúp test. Hãy tưởng tượng rằng chúng ta ...
Chắc nhiều bạn đã từng dùng #includes, #preload or #eager_load để tăng performance và tránh truy vấn N+1. Nhưng trong đó chưa chắc code đã thực hiện đúng đắn và có association preloaded như ý mong muốn hay không? làm sao để test nó? Dưới đấy có 2 cách có thể giúp test. Hãy tưởng tượng rằng chúng ta có 2 class sau trong Rails Application đó là order có thể có nhiều order_lines.
class Order < ActiveRecord::Base has_many :order_lines class << self def last_ten limit(10).preload(:order_lines) end end end
class OrderLine < ActiveRecord::Base belongs_to :order end
Chúng ta thực hiện phương thức Order.last_ten mà nó sẽ trả về 10 order cuối cùng với một eager loaded association. Hãy xem làm sao để chắc chắn các dòng đó preload sau khi gọi nó.
association(:name).loaded?
require "test_helper" class OrderTest < ActiveSupport::TestCase test "#last_ten eager loading" do o = Order.new() o.order_lines.build o.order_lines.build o.save! orders = Order.last_ten assert orders[0].association(:order_lines).loaded? end end
Điểm cần chú ý đó là order_lines được load hay không do chúng ta gọi preload(:order_lines). Để kiểm tra nó chúng ta cần lấy một đối tượng order như orders[0] để xách nhận trên nó. Ở đây không thể check với collection order để biết được là association được load hay không.
Bây giờ test trong Rspec sẽ thực hiện như sau:
require "rails_helper" RSpec.describe Order, type: :model do specify "#last_ten eager loading" do o = Order.new() o.order_lines.build o.order_lines.build o.save! orders = Order.last_ten expect(orders[0].association(:order_lines).loaded?).to eq(true) # or alternatively expect(orders[0].association(:order_lines)).to be_loaded end end
Đếm các câu truy vấn với ActiveSupport::Notifications
Thư viện ActiveRecord có một helper method gọi là assert_queries là một phần của ActiveRecord::TestCase. Thật là không may ActiveRecord::TestCase không có sẵn là thành phần trong ActiveRecord. Nó chỉ có sẵn trong nội bộ của rails để test xách thực hành vi của nó. Tuy nhiên chúng ta vẫn có thể bắt chước nó theo nhu cần của chúng ta.
Tưởng tượng một kịch bản trong đó bạn phải hoạt động trên một đồ thị của các đối tượng ActiveRecord nhưng bạn không trả chúng về. Bạn chỉ trả về các giá trị đã tính toán. Vậy làm thế nào để chứng tỏ rằng trong trường hợp đó bạn không gặp vấn đề về N+1? Không có phản ứng phụ nào có thể quan sát được, không có các bản khi trả về để check xem nếu nó loaded? Nhưng có phải thế không?
class Order < ActiveRecord::Base has_many :order_lines class << self def average_line_gross_price_today lines = where("created_at > ?", Time.current.beginning_of_day). preload(:order_lines). flat_map do |order| order.order_lines.map(&:gross_price) end lines.sum / lines.size end end end class OrderLine < ActiveRecord::Base belongs_to :order def gross_price # ... end end
Trong tính huống này chúng ta làm thế nào để có thể test Order.average_line_gross_price_today không gặp vấn đề truy vấn N+1? Có cách nào để chắc chắn order.order_lines.map(&:gross_price) không gọi một câu truy vấn SQL khi đọc order_lines? Kết quả thì nó có gọi.
Chúng ta có thể sử dụng ActiveSupport::Notifications để lấy thông báo về tất cả SQL statement được thi hành.
require "rails_helper" RSpec.describe Order, type: :model do specify "#average_line_gross_price_today eager loading" do o = Order.new() o.order_lines.build o.order_lines.build o.save! count = count_queries{ Order.average_line_gross_price_today } expect(count).to eq(2) end private def count_queries &block count = 0 counter_f = ->(name, started, finished, unique_id, payload) { unless %w[ CACHE SCHEMA ].include?(payload[:name]) count += 1 end } ActiveSupport::Notifications.subscribed( counter_f, "sql.active_record", &block ) count end end
Nếu bạn dùng cách trên chắc chắn để tạo đủ các bản ghi để phát hiện các vấn đề tiềm ẩn với eager loading. Với một order một dòng là không đủ mặc dù có hay không có eager loading số lượng cầu truy vấn vẫn như nhau. Trong trường hợp này bạn có 2 dòng order bạn có thể thấy sự khác biết về số cầu truy vấn với preloading(2 một cho tất cả các orders và một nữa cho tất cả lines) ngược lại nếu không có preloading(3, một cho tất cả các orders và một cho mỗi một dòng tách biệt). Phải chắc chắn là test có kết quả là fail trước khi fix nó.
Trong khi sử dụng cách tiếp cận này là có khả năng báo cho chúng ta rằng là cách tốt nhất thì tách các trách nhiệm thành 2 methods nhỏ. Một phụ trách để trích ra các bản ghi đúng từ database(IO - related) và một nữa để biến đổi dữ liệu và tính toán(no IO, side-effect free). Bạn có thể tham khảo db-query-matchers gem cho Rspec matcher giúp bạn với kiểu test trên.
Tham khảoTwo ways for testing preloading/eager-loading of ActiveRecord associations in Rails - Robert Pankowecki