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 ...
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/