7 Patterns to Refactor Fat ActiveRecord Models

Dịch lại từ bài viết 7 Patterns to Refactor Fat ActiveRecord Models Rất nhiều lập trình viên, khi đã có nhiều kinh nghiệm hơn, đã học được cách tránh những "fat models". Fat model tiềm ẩn nhiều vấn đề khi maintain, nhất là trong những app lớn. Chỉ tốt hơn việc tống tất cả logic vào trong controller một chút, cách viết này thể hiện sự thất bại trong việc áp dụng nguyên tắc Single Responsibility Principle (SRP) .

Lúc ban đầu thì SRP rất dễ làm. Nhưng càng ngày, cùng với việc mở rộng thêm chức năng, model càng lúc càng phình to, và khi nhìn lại, đột nhiên ta thấy User class với hơn 500 dòng code và hàng trăm method. Cơn ác mộng mang tên "Callback" bắt đầu. Việc cần làm lúc này là thực hiện refractoring, dàn đều các function vào từng object nhỏ, đc đóng gói tốt. Nhiều lập trình viên thường có kiểu, rút một vài method ra khỏi một class ActiveRecord lớn, nhét vào một modules nào đó, sau đó mix tất cả vào một model. Cách làm này cũng giống như dọn dẹp một căn phòng bừa bãi bằng cách gom tất cả đồ đạc nhét vào mấy cái ngăn kéo riêng, sau đó đóng kín lại. Nhìn bề ngoài thì căn phòng có vẻ gọn gàng hơn, nhưng thật ra khi muốn tìm món gì đó, ta lại mất thời gian hơn. Vậy phải làm thế nào cho đúng cách ? Sau đây là một vài nguyên tắc và best-practice có thể giúp ta làm việc này dễ dàng hơn.

1. Tách ra những Value Objects

Value Object là những object đơn giản mà khi so sánh chúng với nhau, ta dựa vào giá trị của chúng là chính. Date, URI và Pathname là những ví dụ về object loại này trong thư viện chuẩn của Ruby, ta cũng có thể định nghĩa thêm những Value Object khác. Trong Rails, Value Objects rất hữu ích khi ta có một hay một nhóm nhỏ các attribute có dính tới logic. Tất cả những trường nào ngoài textfield hay biến đếm đều là ứng cử viên cho việc bóc tách value objects.

class Rating
  include Comparable

  def self.from_cost(cost)
    if cost <= 2
      new("A")
    elsif cost <= 4
      new("B")
    elsif cost <= 8
      new("C")
    elsif cost <= 16
      new("D")
    else
      new("F")
    end
  end

  def initialize(letter)
    @letter = letter
  end

  def better_than?(other)
    self > other
  end

  def <=>(other)
    other.to_s <=> to_s
  end

  def hash
    @letter.hash
  end

  def eql?(other)
    to_s == other.to_s
  end

  def to_s
    @letter.to_s
  end
end

Khi muốn sử dụng class này

class ConstantSnapshot < ActiveRecord::Base
  # …

  def rating
    @rating ||= Rating.from_cost(cost)
  end
end

Trong ví dụ trên ngoài việc làm cho Class ConstantSnapshot trở nên tinh gọn hơn, ta còn đạt được những ưu thế sau #worse_than?better_than? cho ta những phép so sánh tùy biến và dễ đọc hơn những phép so sánh mặc định ( ví dụ như > hay < ) Khai báo #hash#eql? giúp ta có thể sử dụng Rating như một hash key. Method #to_s khiến ta dễ dàng nội suy Rating thành sring (hay bất kì template nào) mà không cần làm gì nhiều. Khai báo class kiểu này, giúp giảm thời gian cần để sửa code khi ta muốn thay đổi gì đó về rating.

2. Tách những Service Objects

Một vài action có thể cần phải có những Service Object riêng để đóng gói những thực thi của nó. Tôi thường tách ra service object khi một action đáp ứng ít nhất một trong những điều kiện sau:

  • Action đó quá phức tạp ( ví dụ như nghiệp vụ đóng sổ kế toán )
  • Action đó động tới nhiều model khác nhau ( Ví dụ như nghiệp vụ thanh toán sẽ sử dụng tới Order, CreditCardUser model )
  • Action đó tương tác với service bên ngoài ( ví dụ như post lên mạng xã hội )
  • Action đó không phải là nhiệm vụ chính của model bên dưới ( ví dụ như action clear dữ liệu đã outdated sau một khoảng thời gian nhất định)
  • Action đó có nhiều cách thực hiện ( ví dụ như khi xác thực với access token hoặc password )

Ví dụ , ta có thể tách method User#authenticate ra thành một class UserAuthenticator:

class UserAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(unencrypted_password)
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == unencrypted_password
      @user
    else
      false
    end
  end
end

Khi cần gọi đến từ SessionController

class SessionsController < ApplicationController
  def create
    user = User.where(email: params[:email]).first

    if UserAuthenticator.new(user).authenticate(params[:password])
      self.current_user = user
      redirect_to dashboard_path
    else
      flash[:alert] = "Login failed."
      render "new"
    end
  end
end

3. Tách ra Form Objects

Khi ta có nhiều ActiveRecord models cùng được update trong một lần submit form, sử dụng Form Object có thể đóng gói rất tốt những thực thi này. Cách làm này gọn gàng hơn rất nhiều so với việc sử dụng accepts_nested_attributes_for. Ví dụ như khi ta có một form đăng kí, kết quả là đồng thời tạo ra CompanyUser

class Signup
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :user
  attr_reader :company

  attribute :name, String
  attribute :company_name, String
  attribute :email, String

  validates :email, presence: true
  # … more validations …

  # Forms are never themselves persisted
  def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

private

  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end

Object này vẫn giữ những chức năng hoạt động tương tự như attribute của ActiveRecord, nên khi sử dụng trên Controller, code của ta không mấy khác biệt

class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])

    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end

Cách làm này tương đối ổn với những form đơn giản nhưu trên, nhưng nếu trong những form phức tạp hơn, việc check persistence trở nên quá phực tạp, ta có thể dùng kết hợp với Service Object như nói ở trên.

4. Tách ra Query Objects

` Với những truy vấn SQL phức tạp, ta có thể tách chúng ra thành những Query objects. Mỗi object dạng này sẽ trả về một truy vấn riêng tùy theo business. Ví dụ, ta có thể có một query object trả về những người dùng thử (trial) và đã bỏ như sau

class AbandonedTrialQuery
  def initialize(relation = Account.scoped)
    @relation = relation
  end

  def find_each(&block)
    @relation.
      where(plan: nil, invites_count: 0).
      find_each(&block)
  end
end

Ta có thể dùng object này trong một action gửi email chẳng hạn

AbandonedTrialQuery.new.find_each do |account|
  account.send_offer_for_support
end

Viiệc sử dụng ActiveRecord::Relation trong Query object cho phép ta nối query dễ dàng

old_accounts = Account.where("created_at < ?", 1.month.ago)
old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)

5. Sử dụng view object

Nếu trong code của ta có những đoạn logic hoàn toàn chỉ phục vụ cho mục đích hiển thị, rõ ràng là nó không thuộc về model. Khi đó, ta nên đặt nó vào trong những helper , hay tốt hơn nữa là View object. Ví dụ , donut chart ở đây được tạo bằng cách bóc tách rating dự trên snapshot của codebase, sau đó đóng gói như một View

class DonutChart
  def initialize(snapshot)
    @snapshot = snapshot
  end

  def cache_key
    @snapshot.id.to_s
  end

  def data
    # pull data from @snapshot and turn it into a JSON structure
  end
end

6. Sử dụng những Policy Object

Có những lúc, những nghiệp vụ đọc phức tạp cũng cần tách ra object riêng. Bằng cách này, ta có giữ cho những logic rối rắm, ví dụ user thế nào thì được tính là inactive, riêng ra khỏi nghiệp vụ chính của mình.

class ActiveUserPolicy
  def initialize(user)
    @user = user
  end

  def active?
    @user.email_confirmed? &&
    @user.last_login_at > 14.days.ago
  end
end

Policy object khá giống với Service Object. Nhưng thường thì ta dùng Service Object cho những nghiệp vụ ghi dữ liệu, còn Policy Object cho những nghiệp vụ đọc dữ liệu

7. Tách ra những Decorators

Decorators cho phép bạn tạo thêm những lớp chức năng mới bên trên những thực thi đã có sẵn, phục vụ mục đích tương tự như callback. Trong những trường hợp mà logic của callback chỉ cần chạy trong điều kiện nhất định, mà nếu gắn xử lí này vào model thì sẽ làm cho model chịu quá nhiều trách nhiệm ( trái với nguyên tắc SRP ), lúc này, Decorator sẽ rất hữu dụng.

Ví dụ như khi ta post comment trên một bài blog post, việc này đôi khi có thể dẫn tới việc post bài đồng thời lên facebook. Và đây là cách ta sẽ tách logic đó vào một lớp Decorator:

class FacebookCommentNotifier
  def initialize(comment)
    @comment = comment
  end

  def save
    @comment.save && post_to_wall
  end

private

  def post_to_wall
    Facebook.post(title: @comment.title, user: @comment.author)
  end
end

Và Controller có thể sử dụng nó

class CommentsController < ApplicationController
  def create
    @comment = FacebookCommentNotifier.new(Comment.new(params[:comment]))

    if @comment.save
      redirect_to blog_path, notice: "Your comment was posted."
    else
      render "new"
    end
  end
end

All Rights Reserved