0

7 pattern để cấu trúc lại ActiveRecord Models

Khi team muốn nâng cao chất lượng ứng dụng Rails, chúng ta phải tìm cách để loại bỏ thói quen làm model trở lên Fat. Fat models là gây ra các vấn đề về bảo trì trong các ứng dụng lớn.

Vì sao Model lại Fat ?

1. Áp dụng sai SRP

Chỉ gia tăng chứ không tập hợp các controllers vào theo miền logic chính là thể hiện của việc áp dụng sai SRP. Bất cứ gì liên quan tới người dùng đều không phải là single responsibility.

Ban đầu, khi áp dụng SRP thật dễ dàng. Các class ActiveRecord mới đầu chỉ xử lý các persistence, associations, và nói chung là không có quá nhiều xử lý. Nhưng dần dần các class này lớn lên. Các object vốn chỉ xử lý các persistence thì thường phải xử lý thêm tất cả các logic business. Và sau một hoặc hai năm, một class User cũng có thể vượt qua 500 dòng code với hàng trăm public method.

Khi bạn thêm xử lý phức tạp vào ứng dụng của mình, mục đích là để trải nó ra một tập hợp các đối tượng được đóng gói (ở mức cao hơn là các modules), giống như việc bạn rắc bột bánh trên chảo vậy. Lúc rắc bột bạn sẽ thấy những cục bột to, nó chính là Fat models. Bạn sẽ cấu trúc lại để phá vỡ chúng và trải đều các logic ra. Lặp lại quá trình này, bạn sẽ thu được các đối tượng đơn giản với interface làm việc cùng nhau rõ ràng hơn.

2. Rails thiếu những convention để quản lý sự phức tạp vượt quá pattern ActiveRecord có thể xử lý

Bạn có thể nghĩ rằng Rails làm cho mọi chuyện thật khó khăn để làm đúng OOP. Tuy nhiên không phải vậy, Rails không cản trở OOPmà nó chỉ thiếu những convention cho việc quản lý sự phức tạp vượt quá pattern ActiveRecord có thể xử lý. May mắn thay chúng ta có thể ghép OOP cơ bản vào những nơi Rails thiếu.

Vậy làm thế nào để Model hết Fat ?

Không trích xuất Mixins từ Fat models

Việc đưa một tập các method từ một class ActiveRecord vào concerns là một cách làm thường thấy ở các bạn DEV Ruby khi muốn làm gọn Fat model hoặc sử dụng modules để sau đó mix lại với một model. Cách này không giải quyết vấn đề một cách triệt để vì hãy tưởng tượng việc làm này giống như bạn đang làm sạch một căn phòng bằng cách đổ rác vào những ngăn kéo riêng biệt và đóng chúng lại. Nhìn bề ngoài thì căn phòng trông có vẻ sạch sẽ nhưng những ngăn kéo rác sẽ thực sự khiến nó thật khó để xác định và implement những phân tách để làm rõ model.

Vì vậy, cách làm này thật sự không được khuyến khích.

1. Trích xuất ra Value Objects

a value object is a small object that represents a simple entity whose equality is not based on identity

Định nghia hơi khó hiểu, nhưng nôm na có thể hiểu: 2 object là equality is not based on identity khi chúng có cùng giá trị nhưng chúng vân không phải là cùng một object Ví dụ: trong Rails có các lib value object như Date, URI``,Pathname`

date1 = Date.new 2017,9,26
date2 = Date.new 2017,9,26

date1 == date2 => true
date1 === date2 => false

Trong Rails, Value Objects se được sử dụng khi bạn có một thuộc tính hoặc nhóm các thuộc tính có logic gắn liền với chúng. has a Value Object named Rating that represents a simple A - F grade that each class or module receives Ví dụ: chúng ta có một Value Object tên là Rating đại diện cho cấp A - F, tương ứng với môi class hoặc module nhận được

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

class ConstantSnapshot < ActiveRecord::Base
  # …

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

2. Trích xuất ra Service Objects

Mỗi một hành động trong hệ thống đảm bảo một Service Object được đóng gói thành hoạt động của chúng. Một Service Object cần đảm bảo các tiêu chí sau:

  • Hành động phức tạp, ví dụ đóng sổ sách vào cuối kỳ kiểm toán
  • Hành động cần thông qua nhiều model: việc mua hàng e-commerce cần 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: post bài lên mạng xã hội
  • Hành động không phải mối quan tâm chính của model: xóa những data đã quá hạn sau một khoảng thời gian dài.
  • Có nhiều cách để thực hiện hành động: xác thực người dùng qua access token hoặc password.
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

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. Trích xuất Form Objects

Khi nhiều model có thể được cập nhật qua một form, Form Objects có thể tập hợp chúng lại. Lưu ý: không nên sử dụng accepts_nested_attributes_for

class Signup
  include Virtus # gem https://github.com/solnic/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

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 này sẽ hiệu quả với những trường hợp đơn giản, nhưng nếu logic của việc persist quá phức tạp, ta có thể kết hợp với Service Objects. Ngoài ra ta có thể gặp trường hợp validation theo ngữ cảnh vì vậy việc validation có thể được định nghĩa ở một object khác thay vì validation ngay trong bản thân model.

4. Trích xuất Query Objects

Với những truy vấn SQL phức tạp (có thể nằm trong scopes hoặc class method) chúng ta nên sử dụng Query Object.

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

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

5. View Objects

Nếu logic cần dùng chỉ nhằm mục đích hiển thị và không thuộc về models thì ta có thể xem xét sử dụng helper hoặc tốt hơn là View Objects.

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. Trích xuất Policy Objects

Thi thoảng các hoạt động đọc phức tạp ta có thể tách ra thành Policy Object.

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 ta cần phân biệt là Service Object dùng cho hoạt động ghi, còn Policy Object dùng cho việc đọc.

7. Trích xuất Decorators

Decorators cho phép bạn tạo một lớp bên trên chức năng cho các hoạt động đã tồn tại, và do đó nó phục vụ cho mục đích tương tự callbacks. Khi logic callbacks chỉ cần gọi trong một vài tính huống hoặc bao gồm nó trong model tạo cho model quá nhiều nhiệm vụ thì ta nên dùng 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

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

Decorators khác với Service Object vì chúng tạo lớp dựa trên interface có sẵn. Hơn nữa, trong trường hợp này, FacebookCommentNotifier instance vẫn được coi là một Comment.

Tham khảo

http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí