7 Patterns to Refactor Fat ActiveRecord Models
Bài đăng này đã không được cập nhật trong 9 năm
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
All rights reserved