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 ...
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? và 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 và #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, CreditCard và User 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 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
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