Những lưu ý khi áp dụng DRY

Don't Repeat Yourself là một trong những design pattern được biết đến rộng rãi nhất hiện nay, từ những training chập chững những dòng code đầu tiên cho đến các developer dày dạn kinh nghiệm đều có khái niệm và áp dụng DRY mỗi ngày.

Bản thân DRY vốn rất dễ hiểu, giống như cái tên của nó, nguyên tắc hàng đầu là không tự viết lại code của chính mình. Mỗi khi nhìn thấy một đoạn code trông giống như một đoạn khác ở một file khác, ta đều cố gắng để có thể biến đoạn code đó trở thành đoạn code dùng chung.

Phương pháp duy nhất của DRY là trừu tượng hóa bằng việc sử dụng các class, modul hay interface dùng chung. Ví dụ như với 5 dòng code để in ra kết quả, thay vì phải viết lại mỗi khi dùng đến, ta đặt nó trong một hàm và trong một file dùng chung, để mỗi khi dùng đến ta chỉ cần gọi đến hàm đó mà không cần phải định nghĩa lại.

Nhưng DRY không phải lúc nào cũng tốt

DRY tỏ ra là một patern hiệu quả, tiện lợi, nó giúp cho code dễ đọc, dễ bảo trì hơn. Nhưng cái gì quá cũng không tốt. Đôi khi, việc áp dụng DRY quá đà sẽ khiến code trở nên khó hiểu và khó làm việc hơn. Sau đây là một vài trường hợp khiến cho DRY trở thành lợi bất cập hại:

Trừu tượng hóa không cần thiết

Các đoạn code lặp thường được trừu tượng hóa một cách không cần thiết. Các chức năng test RSpec là một ví dụ điển hình cho việc này. Phần lớn chúng ta sử dụng gem capybara, một thư viện dùng để tương tác với trang web. Gem này giúp chúng ta giả lập hành động đến trang web và thực hiện một hành động nào đó. Hãy cùng quan sát ví dụ dưới đây:

feature 'Adding product to cart' do
  scenario 'Adding in stock product' do
    when_a_shopper_adds_an_in_stock_product_to_cart
    then_they_should_see_the_product_in_the_cart
  end

  def when_a_shopper_adds_an_in_stock_product_to_cart
    visit product_path(product)
    click_button 'Add to cart'
  end

  def then_they_should_see_the_product_in_the_cart
    visit cart_path
    expect(page).to have_content(product.name)
  end

  private
  let(:product) { create(:product) }
end

Đây là chức năng thêm sản phẩm vào giỏ hàng. Các bước when chứa các hàm capybara để thêm các sản phẩm. Bây giờ ta sẽ implement việc thêm sản phẩm từ một trang web khác, Bởi vì cùng là hành động thêm sản phẩm nên ta sẽ DRY nó:

feature 'Adding product to cart' do
  scenario 'Adding in stock product' do
    when_a_shopper_adds_an_in_stock_product_to_cart
    then_they_should_see_the_in_stock_product_in_the_cart
  end

  scenario 'Adding product from listing page' do
    when_a_shopper_adds_a_product_to_cart_from_listing_page
    then_they_should_see_the_product_in_the_cart
  end

  def when_a_shopper_adds_an_in_stock_product_to_cart
    add_to_cart_from product_path(product)
  end

  def then_they_should_see_the_in_stock_product_in_the_cart
    assert_product_in_cart
  end

  def when_a_shopper_adds_a_product_to_cart_from_listing_page
    add_to_cart_from products_path(category: product.category)
  end

  def then_they_should_see_the_product_in_the_cart
    assert_product_in_cart
  end

  private
  let(:product) { create(:product) }

  def add_to_cart_from(path)
    visit path
    click_button 'Add to cart'
  end

  def assert_product_in_cart
    visit cart_path
    expect(page).to have_content(product.name)
  end
end

Ở đây ta có hai method được trườ tượng hóa là #add_to_cart_from và #assert_product_in_cart. Trong trường hợp này, ta có thể nói method #add_to_cart_from có thể là không cần thiết vì nó thỉ tiết kiệm được một dòng code trong khi nội dung của hàm cũng dễ hiểu không kém gì bản thân tên của hàm, nhưng ta không bàn tới điều này.

Bây giờ ta test một chức năng liên quan: chức năng giới thiệu sản phẩm. Ta muốn test trường hợp thêm một sản phẩm vào giỏ hàng mà sản phẩm đó được giới thiệu trên chính trang web giỏ hàng:

feature 'Cart product recommendations' do
  scenario 'Recommending products' do
    when_a_shopper_visits_their_cart
    then_they_should_be_recommended_products
  end

  def when_a_shopper_visits_their_cart
    add_to_cart_from product_path(product)
    visit cart_part
  end

  def then_they_should_be_recommended_products
    product.recommendations.each do |recommendation|
      expect(page).to have_content(recommendation.product.name)
    end
  end

  private
  let(:product) { create(:product, :with_recommendations) }

  def add_to_cart_from(path)
    visit path
    click_button 'Add to cart'
  end
end

Ta có thể thấy method #add_to_cart_from bị lặp lại trong chức năng này. Ta có thể DRY bằng cách đặt method này vào một modul và include vào cả hai chức năng. Hành động này sẽ giúp ta không phải lặp lại định nghĩa của method này. Nhưng ta phải tham chiếu đến nhiều file thay vì một để hiểu và làm việc được với hai chức năng này. Cái giá phải trả này đôi khi là không đáng.

Trừu tượng hóa một cách khéo léo

Bây giờ ta muốn thêm một kịch bản khác để thêm vào giỏ hàng một số lượng sản phẩm nhất định. Chức năng này rất dễ để DRY, ta có thể dùng lại gần như tất cả các method trước đó:

feature 'Adding product to cart' do
  scenario 'Adding in stock product' do
    when_a_shopper_adds_an_in_stock_product_to_cart
    then_they_should_see_the_product_in_the_cart
  end

  scenario 'Adding in stock product with size' do
    when_a_shopper_adds_an_in_stock_product_to_cart(sized: true)
    then_they_should_see_the_product_in_the_cart(sized: true)
  end

  def when_a_shopper_adds_an_in_stock_product_to_cart(options = {})
    if options[:sized]
      visit product_path(sized_product)
      select sized_product.sizes.first, from: :cart_product_size
    else
      visit product_path(product)
    end

    click_button 'Add to cart'
  end

  def then_they_should_see_the_product_in_the_cart(options = {})
    visit cart_path
    expect(page).to have_content(options[:sized] ? sized_product.name : product.name)
  end

  private
  let(:product) { create(:product) }
  let(:sized_product) { create(:product, :with_sizes) }
end

Ta chỉ cần thêm một tham số chỉ số lượng và thêm vào logic để sử dụng chúng. Hoặc là ta có thể viết các method rõ ràng hơn cho chức năng mới, các method này là riêng biệt cho từng chức năng.

feature 'Adding product to cart' do
  scenario 'Adding in stock product' do
    when_a_shopper_adds_an_in_stock_product_to_cart
    then_they_should_see_the_product_in_the_cart
  end

  scenario 'Adding in stock product with size' do
    when_a_shopper_adds_an_in_stock_sized_product_to_cart
    then_they_should_see_the_sized_product_in_the_cart
  end

  def when_a_shopper_adds_an_in_stock_product_to_cart
    visit product_path(product)
    click_button 'Add to cart'
  end

  def then_they_should_see_the_product_in_the_cart
    visit cart_path
    expect(page).to have_content(product.name)
  end

  def when_a_shopper_adds_an_in_stock_sized_product_to_cart
    visit product_path(sized_product)
    select sized_product.sizes.first, from: :cart_product_size
    click_button 'Add to cart'
  end

  def then_they_should_see_the_sized_product_in_the_cart
    visit cart_path
    expect(page).to have_content(sized_product.name)
  end

  private
  let(:product) { create(:product) }
  let(:sized_product) { create(:product, :with_sizes) }
end

Bây giờ ta có nhiều hơn một vài dòng code và trông code có vẻ ngây thơ hơn. Bù lại, code của ta cũng trong sáng, dễ đọc hơn là các logic rẽ nhánh như vị dụ ở trên.

Bài học rút ra là đôi khi trừu tượng hóa có thể làm code trở nên khó đọc hơn, vậy nên, nếu theo đuổi FRY một cách mù quáng, ta có thể có được những dòng code ngắn gọn hơn với cái giá phải trả là code trở nên khó đọc và hiểu hơn. Đôi khi, cách tốt nhất lại là simple and stupid.

Trừu tượng hóa một cách sai lầm

Bây giờ ta muốn viết chức năng thanh toán. Ta muốn test bằng cách chạy từng bước một theo kịch bản, từ xem hàng, chọn hàng,... rồi đến thanh toán. Ta có thể sử dụng lại các chức năng đã viết như sau:

feature 'Checkout' do
  scenario 'Adding address' do
    when_a_shopper_is_ready_to_checkout
    then_they_should_have_to_add_their_address_details
  end

  scenario 'Choosing delivery method' do
    given_a_shopper_has_added_their_address
    when_they_want_a_fast_delivery
    then_they_should_be_able_to_choose_next_day
  end

  scenario 'Paying for order' do
    given_a_shopper_is_ready_to_pay
    when_they_want_to_pay_by_paypal
    then_they_should_be_redirected_to_paypal
  end

  def when_a_shopper_is_ready_to_checkout
    # add product to cart
  end

  def then_they_should_have_to_add_their_address_details
    # fill in address details
  end

  def given_a_shopper_has_added_their_address
    when_a_shopper_is_ready_to_checkout
    then_they_should_have_to_add_their_address_details
  end

  def when_they_want_a_fast_delivery
  end

  def then_they_should_be_able_to_choose_next_day
    # choose next day
  end

  def given_a_shopper_is_ready_to_pay
    when_a_shopper_is_ready_to_checkout
    then_they_should_have_to_add_their_address_details
    when_they_want_a_fast_delivery
    then_they_should_be_able_to_choose_next_day
  end

  def when_they_want_to_pay_by_paypal
    # select paypal
  end

  def then_they_should_be_redirected_to_paypal
    # assert current url is paypal
  end
end

Điều đầu tiên để nói đến là, chạy các chức năng bằng capybara chậm hơn rất nhiều so với setup trực tiếp trên DB. Thay vì chạy qua từng bước, ta có thể dùng FactoryGirl để đưa DB đến trạng thái mong muốn. Nhờ vậy, thay vì phải chạy lại qua tất cả các kịch bản, ta chỉ cần chạy một kịch bản duy nhất cho việc test chức năng này.

Điều thứ hai là ta đang phải sử dụng các bước given/when/then lồng nhau. Việc đọc hiểu code này trở nên khó khăn hơn và không đúng trọng tâm của chức năng test.

Để tránh khỏi những điều trên, ta có thể cài đặt lại:

feature 'Checkout' do
  scenario 'Adding address' do
    when_a_shopper_is_ready_to_checkout
    then_they_should_have_to_add_their_address_details
  end

  scenario 'Choosing delivery method' do
    given_a_shopper_has_added_their_address
    when_they_want_a_fast_delivery
    then_they_should_be_able_to_choose_next_day
  end

  scenario 'Paying for order' do
    given_a_shopper_is_ready_to_pay
    when_they_want_to_pay_by_paypal
    then_they_should_be_redirected_to_paypal
  end

  def when_a_shopper_is_ready_to_checkout
    # add product to cart
  end

  def then_they_should_have_to_add_their_address_details
    # fill in address details
  end

  def given_a_shopper_has_added_their_address
    jump_to_checkout(:delivery_step)
  end

  def when_they_want_a_fast_delivery
  end

  def then_they_should_be_able_to_choose_next_day
    # choose next day
  end

  def given_a_shopper_is_ready_to_pay
    jump_to_checkout(:payment_step)
  end

  def when_they_want_to_pay_by_paypal
    # select paypal
  end

  def then_they_should_be_redirected_to_paypal
    # assert current url is paypal
  end

  private
  def jump_to_checkout(step)
    order = create(:order, :jump_to_step, step: step)
    visit checkout_path(order_token: order.token)
  end
end

Bây giờ các bước tạo dựng môi trường đã được đưa vào factory, có nghĩa là ta có nhiều tài nguyên dành cho test hơn. Trong trường hợp này, DRY chỉ nhắc nhở ta không nên tự lặp code, nhưng cách giải quyết không chỉ gói gọn trong việc sử dụng lại các bước đã viết, mà còn có thể tạo dựng môi trường sẵn có.

Việc sử dụng lại các bước capybara không chỉ ảnh hưởng đến performance, mà còn gây khó khăn trong việc trừu tượng hóa. Ở đây ta có 3 cấp trừu tượng hóa: ở kịch bản, ở các bước và các helper. Kịch bản được đặt riêng rẽ trong block do trong RSpec, và các bước là helper được đặt trong private. Mỗi bước trừu tượng hóa không nên sử dụng các method cùng cấp, mà chỉ nên lấy từ các bước trừu tượng hóa thấp hơn nó. Ở ví dụ trên, việc các bước ngang hàng chứa trong nhau phá vỡ việc phân lớp trừu tượng hóa.

Kết luận

DRY là một design pattern hiệu quả, dễ áp dụng, giúp cho code trở nên đơn giản và dễ hiểu hơn. Nhưng không phải lúc nào DRY cũng đem lại tác dụng như mong muốn. Đôi khi ta cần phải cân nhắc xem việc áp dụng DRY có thực sự cần thiết và đáng để làm hay không. Việc cân nhắc này đôi khi phụ thuộc vào kinh nghiệm và cảm giác của một lập trình viên. Hoặc đơn giản là ta thấy DRY không hợp lý và tự mình loại bỏ nó. Mục đích cuối cùng của DRY vẫn là làm cho code gọn nhẹ, dễ hiểu hơn. Vậy nên nếu việc áp dụng DRY lại đi ngược lại với mục đích đó thì không nên ngại ngùng mà loại bỏ. Chúc bạn thành công.


All Rights Reserved