7 cách để phòng tránh fat activerecord model

"Fat models" gây ra rất nhiều vấn đề cho việc bảo trì các app lớn. Chúng phản ánh sai lầm lớn khi áp dụng nguyên tắc Single Responsibility Principle (SRP). Khi có nhiều hơn 1 mục đích mà 1 user cần làm , thì khi đó nó không còn thoả mãn single responsibility

Ban đầu, SRP dễ dàng được áp dụng. Các lớp ActiveRecord lưu giữ các quan hệ, sự chặt chẽ và không nhiều hơn thế. Nhưng dần dần, model lớn hơn. Các đối tượng chịu trách nhiệm cho sự chặt chẽ trở thành nơi tiếp nhận tất cả các logic. Và vài năm sau, bạn sẽ có một lớp User với hơn 500 dòng code, và hàng trăm method được public.

Và khi đấy, ta sẽ tự hỏi: "Thật khó để áp dụng OOP đúng cách trong Rails"

Ban đầu, tôi thường tin như vậy. Nhưng sau một vài tìm kiếm, và thực hành, tôi nhận thấy Rails không cản trở việc áp dụng OOP. Mà chỉ do convention của Rails thiếu sót trong việc quản lý độ phức tạp dựa vào các khả năng của Active Record cung cấp. Thật may mắn, chúng ta có thể áp dụng các nguyên tắc 00P và các thực hành trong khi Rails bị thiếu.

Đừng lấy mixin từ Fat model Tôi không khuyến khích việc đưa các method ra ngoài lớp ActiveRecord lớn vào trong concerns hay module , và sau đó trộn chúng vào một model.

Tốt hơn là dùng tổ hợp / composition so với kế thừa / inheritance.

Và sau đây là một vài cách refactor

1. Tạo ra các value object

Value Object là các đối tượng đơn giản. Chúng luôn luôn bất biến. VD như Date, URI hay Pathname là các ví dụ của thư viện Ruby, nhưng ta cũng có thể tự định nghĩa ra các Value Object như vậy.

Trong Rails, Value Objects rất tuyệt khi bạn có một thuộc tính hoặc một nhóm các thuộc tính có logic gắn liền với chúng.

Ví dụ, một ứng dụng nhắn tin văn bản tôi đã làm việc đã có một PhoneNumber Value Object. Một ứng dụng thương mại điện tử cần một lớp Money. Hoắc một Value Object là Rating đại diện cho một lớp điểm A-F đơn giản mà mỗi lớp hoặc mô-đun nhận được. Tôi có thể (và ban đầu đã làm) sử dụng một thể hiện của một chuỗi Ruby, nhưng Rating cho phép tôi kết hợp hành vi với dữ liệu:

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 đấy , ta có thể sử dụng giao diện public của Rating

class ConstantSnapshot < ActiveRecord::Base
  # …

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

Ta có một vài thuận lợi khi sử dụng theo cách này:

Hàm #worse_than? và #better_than? cung cấp một cách diễn đạt rõ ràng hơn để so sánh Rating so với các toán tử tích hợp của Ruby (ví dụ: < và >).

Định nghĩa #hash và #eql? làm cho nó có thể sử dụng một Rating như là một khóa băm. Và ta có thể sử dụng cách này để nhóm các Rating bằng cách Enumberable # group_by.

Hàm #to_s cho phép tôi thêm Rating vào 1 chuỗi mà ko cần phải làm gì thêm.

Định nghĩa lớp cung cấp một vị trí phú hợp cho hàm factory method để định nghĩa Rating cho phù hợp dựa vào “remediation cost”

2. Tạo ra các Service Objects

Khi có một vài hành động trong một hệ thống bảo đảm, một Service Object sẽ đóng gói hoạt động của chúng. Tôi sử dụng Service Object trong các trường hợp sau:

  • Hành động rất phức tạp (ví dụ: đóng sách vào cuối kỳ kế toán)

  • Tác vụ đạt được phải thông qua nhiều model (ví dụ như mua hàng qua thương mại điện tử bằng cách sử dụng các đối tượng Order, CreditCard và Customer)

  • Hành động tương tác với dịch vụ bên ngoài (ví dụ như đăng lên mạng xã hội)

  • Hành động không phải là mối quan tâm cốt lõi của một model cơ bản (ví dụ như xoá dữ liệu đã lỗi thời sau một khoảng thời gian nhất định).

  • Có nhiều cách để thực hiện hành động (ví dụ: xác thực bằng mã truy cập hoặc mật khẩu truy cập). Đây là mẫu Gang of Four Strategy.

Ta có VD sau:

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

và trong SessionsController ta sẽ sử dụng như sau:

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

Tạo các Form Objects

Khi nhiều model ActiveRecord có thể được cập nhật bằng cách gửi một form, một Form Object có thể gói gọn tập hợp đó. Điều này còn gọn hơn việc sử dụng accepts_nested_attributes_for, theo quan điểm của tôi, accepts_nested_attributes_for nên bị deprecated, bởi vì sẽ làm cho code rời rạc, khó theo dõi . Ví dụ là một form signup sẽ tạo ra cả Company và User:

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

Điều này hoạt động tốt cho các trường hợp đơn giản như trên, nhưng nếu logic trong form quá phức tạp, bạn có thể kết hợp cách tiếp cận này với một Service Object. Và lợi ích của việc này là, vì logic validation thường định nghĩa theo bối cảnh, nên nó có thể được định nghĩa ở chính xác nơi nó có vấn đề thay vì phải định nghĩa validation trong chính ActiveRecord

4. Tạo ra các Query Objects

Đối với các truy vấn SQL phức tạp, ta thường định nghĩa các ActiveRecord subclass (hoặc là các scope hay các class methods), tuy nhiên, ta có thể xem xét cách tiếp cận mới là Query objects. Mỗi Query object có trách nhiệm trả lại một bộ kết quả dựa trên các quy tắc logic. Ví dụ: Query Object để tìm các thử nghiệm bị bỏ rơi có thể 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

Khi đó , ta có thể sử dụng:

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

Nhờ có ActiveRecord::Relation, ta có thể kết hợp nhiều query

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

5. Sử dụng View Objects

Nếu logic được yêu cầu cho mục đích hiển thị, nó không thuộc về model. Hãy tự hỏi mình "Nếu tôi đang triển khai một giao diện thay thế cho ứng dụng này, như giao diện người dùng bằng giọng nói, tôi có cần điều này không?". Nếu không, hãy xem xét đặt nó trong một helper hoặc (thường là tốt hơn) một View object.

Ví dụ:

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. Tạo ra Policy Object

Policy Object cho phép bạn giữ logic tiếp tuyến, user nào được phép tiếp cần các mục đích phân tích, hay các hàm cốt lõi của đối tượng Ví dụ:

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

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

Policy Object đóng gói một quy tắc nghiệp vụ, người dùng được coi là active nếu họ có địa chỉ email đã được xác nhận và đã đăng nhập trong vòng hai tuần vừa qua. Bạn cũng có thể sử dụng Policy Object cho một tập quy tắc kinh doanh như Authorizer, điều chỉnh dữ liệu người dùng có thể truy cập.

Policy Objects tương tự như các Service Objects, nhưng tôi sử dụng thuật ngữ "Service Object" để viết các hoạt động và "Policy Object" để đọc. Chúng cũng tương tự như Query Objects, nhưng Query Objects tập trung vào việc thực hiện SQL để trả về một tập hợp kết quả, trong khi Objects Policy hoạt động trên các models đã được nạp vào bộ nhớ.

7. Tạo ra decorator

Decorators cho phép bạn tạo tầng các chức năng dựa trên các hoạt động hiện tại, và do đó phục vụ cho mục đích tương tự như callback. Đối với trường hợp Logic callback chỉ cần chạy trong một số trường hợp hoặc trong model có quá nhiều trách nhiệm, một Decorator rất hữu ích.

Đăng một nhận xét về một bài viết trên blog có thể kích hoạt một bài đăng lên tường Facebook của một ai đó, nhưng điều đó không có nghĩa là logic nên được viết vào lớp Comment. Một dấu hiệu bạn đã thêm quá nhiều trách nhiệm trong callback là viết test quá lâu hay thêm các trường hợp không liên quan

VD:

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 chúng:

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

Decorator khác Service Object vì nó liên quan nhiều hơn đến tầng interface. Khi cần thêm logic trong việc hiển thị ra view, thì decorator là 1 giải pháp hữu ích nhất.

Trong Rails, ta có thể sử dụng gem draper để áp dụng decorator.

All Rights Reserved