Rails AntiPatterns, Fat Models và các giải pháp [Part 3]

Tiếp nối 2 phần trước:

Hôm nay, chúng ta sẽ chuyển sang một AntiPattern In Rails khác. Đó là Fat Models AntiPattern

1.2 AntiPattern: Fat Models

Đây là một trong những nội dung trọng điểm của AntiPattern mà chúng ta sẽ đề cập tới trong series bài viết. Sự phức tạp là sát thủ số một trong những project hiện nay, nó đi vào một ứng dụng bằng nhiều cách, kể cả thông qua những tính năng phức tạp, những nhà phát triển quá thông minh, và không quen với Ruby On Rails. Chương này, phần nhiều sẽ đề cập tới sự đơn giản từ một góc nhìn duy nhất: đơn giản hóa bên trong model bằng cách di chuyển sự phức tạp đó tới một unit mới trong ứng dụng - modules và classes. Chúng ta sẽ cùng đi qua một ví dụ minh họa, là một Order model trong một ứng dụng bán hàng online. Order model có những phương thức để tìm kiếm theo trạng thái hoặc nâng cao. Nó cũng có những hàm phục vụ cho việc trích xuất dữ liệu ra XML, JSON, PDF.

# app/models/order.rb
class Order < ActiveRecord::Base
  def self.find_purchased
    # ...
  end

  def self.find_waiting_for_review
    # ...
  end

  def self.find_waiting_for_sign_off
    # ...
  end

  def self.find_waiting_for_sign_off
    # ...
  end

  def self.advanced_search(fields, options = {})
    # ...
  end

  def self.simple_search(terms)
    # ...
  end

  def to_xml
    # ...
  end

  def to_json
    # ...
  end

  def to_csv
    # ...
  end

  def to_pdf
    # ...
  end
end

Khi nhìn nhận rằng đây chỉ là một ví dụ nhỏ của những dòng code trong Order class, thì chúng ta dễ dàng nhận thấy models có thể nhanh chóng vượt ra khỏi tầm kiểm soát. Tất nhiên chúng ta có quyền ước rằng, điều đó làm cho chúng ta thấy model sẽ vượt quá 1000 dòng code trong một file. Mà sự béo ra, to ra của model có xu hướng tới từ những lập trình viên Ruby On Rails mới vào nghề. Vấn đề khó khăn trong việc bảo trì và khả năng đọc để nắm logic của việc béo ra của model như vậy sẽ ngày càng rõ ràng. Trong những giải pháp tiếp theo, chúng ta sẽ dễ dàng thấy được cách mà Ruby On Rails làm cho bạn khi nhóm các phương thức liên quan thành các module. Sau đó, chúng ta sẽ tìm hiểu về những vấn đề cơ bản đằng sau những overweight (thừa cân 😄) class và thấy được tại sao một số phương thức nên được đưa sang lớp khác của riêng nó.

1.2.1 Delegate Responsibility to New Classes

Trong những giải pháp trước, chúng ta đã thảo luận việc sử dụng Ruby Module cho phân chia những phương thức có liên quan. Đây là một technique rất hữu ích cách ly code hoàn toàn vì lý do dễ đọc. Nó cũng là technique mà nhiều lập trình viên Ruby On Rails thường sử dụng. Cùng xem Order class.

# app/models/order.rb
class Order < ActiveRecord::Base
  ...

  def to_xml
    # ...
  end

  def to_json
    # ...
  end

  def to_csv
    # ...
  end

  def to_pdf
    # ...
  end

Thông thường, code mà chúng ta muốn di chuyển vào một module là code không phù hợp với class gốc. Những hàm ở trên thực sự không phải là một phần của Order object. Một Order object nên thực hiện những hành vi như: tính toán giá, quản lý các line items, ....

Keepin’ It Classy

Khi code không thuộc về class chứa nó, thì đó là lúc chúng ta nên cấu trúc lại code tới một class nó nên thuộc về. Đây là một áp dụng của Single Responsibility Principle (SRP). Trong khi tinh thần của nguyên tắc này luôn luôn tồn tại như một phần của Object Oriented Design (OOD), thuật ngữ này lần đầu tiên được đưa ra bởi Robert Cecil Martin, trong bài báo "SRP: The Single Responsibility Principle". Martin tóm tắt nguyên tắc này như sau: "Không bao giờ có nhiều hơn 1 lý do cho việc thay đổi 1 class".

Có nhiều class đảm nhiệm nhiều hơn một nhiệm vụ và điều này dẫn tới code dễ bị ảnh hưởng khi yêu cầu thay đổi. Theo thời gian, nhiệm vụ của một class bắt đầu lẫn lộn, và thay đổi hành vi dẫn tới những thay đổi trong phần còn lại của class là tốt. Chúng ta có thể áp dụng SRP vào model Order bằng cách chia những phương thức chuyển đổi tới một class khác gọi là OrderConverter

# app/models/order.rb
class Order < ActiveRecord::Base
  def converter
    OrderConverter.new(self)
  end
end

# app/models/order_converter.rb
class OrderConverter
  attr_reader :order

  def initialize(order)
    @order = order
  end

  def to_xml
    # ...
  end

  def to_json
    # ...
  end

  def to_csv
    # ...
  end

  def to_pdf
    # ...
  end
end

Bằng cách này, chúng ta đã đưa những hàm chuyển đổi về đúng nơi, bên trong một class riêng biệt và dễ dàng kiểm chứng. Tạo ra một PDF từ một order chỉ cần gọi như sau: @order.converter.to_pdf

Trong vòng đời object-oriented, điều này được biết tới là composition. Order object bao gồm một OrderConverter object và những object khác mà nó cần. Trong Rails association giữa các model (has_one, has_many, belongs_to) luôn tạo composition một cách tự động cho models.

Breaking the Law of Demeter

Mặc dù chuỗi được giới thiệu phần trước đề cao SRP, nó lại vi phạm Law of Demeter. Chúng ta có thể xử lý bằng cách sử dụng delegate như sau:

# app/models/order.rb
class Order < ActiveRecord::Base
  delegate :to_xml, :to_json, :to_csv, :to_pdf, to: :converter

  def converter
    OrderConverter.new(self)
  end
end

Bây giờ chúng ta có thể gọi như sau: @order.to_pdf. Law of Demeter và Rails delegate chúng ta đã thảo luận trong chương này ở phần giải pháp "Follow the Law of Demeter".

Crying All the Way to the Bank Kỹ thuật này chỉ để mô tả việc phân thành một class chức năng riêng biệt nằm trong class đầu tiên thông qua composition, là cơ sở để đảm bảo một ví dụ thứ 2. Class BankAccount như sau:

# app/models/bank_account.rb
class BankAccount < ActiveRecord::Base
  validates :balance_in_cents, presence: true
  validates :currency, presence: true

  def balance_in_other_currency(currency)
    # currency exchange logic...
  end

  def balance
    balance_in_cents / 100
  end

  def balance_equal?(other_bank_account)
    balance_in_cents == other_bank_account.balance_in_other_currency(currency)
  end
end

Để một bank account hoạt động bình thường, giống như là chuyển tiền, nhận tiền, đóng, mở tài khoản, class này cần có những hàm để trả về số tiền, việc so sánh và chuyển sang những đơn vị tiền khác. Rõ ràng, class này đang làm quá nhiều việc. Nó tham gia vào các hành vi của bank account và tiền nói chung. Để thực hiện theo lý luận của Martin, chúng ta cần chỉnh sửa lại bằng cách chuyển phần tính toán, so sánh tới một lớp đại diện cho tiền. Trong Rails cung cấp một phương phức composed_of. Và áp dụng chúng ta có được như sau:

# app/models/bank_account.rb
class BankAccount < ActiveRecord::Base
  validates :balance_in_cents, presence: true
  validates :currency, presence: true

  composed_of :balance, class_name: "Money",
    mapping: [%w(balance_in_cents amount_in_cents), %w(currency currency)]
end

# app/models/money.rb
class Money
  include Comparable
  attr_accessor :amount_in_cents, :currency

  def initialize(amount_in_cents, currency)
    self.amount_in_cents = amount_in_cents
    self.currency = currency
  end

  def in_currency(other_currency)
    # currency exchange logic...
  end

  def amount
    amount_in_cents / 100
  end

  def <=>(other_money)
    amount_in_cents <=> other_money.in_currency(currency).amount_in_cents
  end
end

Việc gọi những phương thức cũng khá đơn giản, ví dụ để xem tiền theo đơn vị tiền tệ là usd, chúng ta gọi như sau: @bank_account.balance.in_currency(:usd). Hoặc để so sánh chúng ta dùng: @bank_account.balance > @other_bank_account.balance.

Kết luận Tóm lại, nó là chưa đủ để làm giảm độ phức tạp của 1 model bằng cách di chuyển những phương thức tới module. Nếu 1 class có nhiều hơn một phần chức năng hoặc có nhiều hơn 1 lý do để thay đổi thì chúng ta cần phần chia thành nhiều class khác nhau. Mỗi class sẽ đảm nhiệm những công việc chuyên biệt và điều này làm tăng khả năng bảo trì.

Chúng ta sẽ tiếp tục phần sau với giải pháp: Make Use of Modules, tạm dịch là sử dụng module hóa