12/08/2018, 16:42

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ảo

Two ways for testing preloading/eager-loading of ActiveRecord associations in Rails - Robert Pankowecki

0