Thin Controller - Skinny Model by using chain service object
Bài đăng này đã không được cập nhật trong 3 năm
Nêu vấn đề
Khi làm việc với Web và MVC, chắc chắn bạn đã từng nghe và được khuyên nhiều về Thin Controller. Lý do thì chúng ta đều hiểu, controller phải gánh vác nhiều công việc nặng nề, và nếu controller mà có nhiều logic thì rất khó để viết unit test. Một trong những cách làm được công nhận đó là ném bớt công việc của nó sang Model. Nghe thì rất hợp lý, Model giao tiếp với DB, vì vậy muốn xử lý logic thì hãy ném vào Model (cũng dễ để viết unit test).
Khi mà project còn bé, mọi thứ đều rất đẹp cho đến một ngày Project lớn dần, có nhiều logic hơn, logic nào ta cũng ném vào cho Model xử lý. Một ngày nào đó bạn nhìn vào Model và nhận ra nó cũng phải gánh một khối lượng công việc khổng lồ không kém: associations, validations, bloated business logic, etc ... Đó chính là lý do người ta lại phải tìm cách để "to thin fat model", cho nó đi giảm cân: http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/
Service Object approach
Có cách nào để vừa "Thin Controller" vừa "Skinny Model" được không? Là câu hỏi của các lập trình viên web bây giờ. Và người ta đã tìm ra một cách (tất nhiên là có nhiều cách) đó là xử dụng Service Objects. Hiểu nôm na đó là, tạo ta nhiều class riêng biệt chỉ để xử lý các nhiệm vụ riêng biệt.
Giả sử mình có một specs như thế này: User có thể mời người dùng (ko phải user) subcribe một website, User đó sẽ được credit nếu người được nhận lời mời trở thành registed user.
Sau đây mình sẽ giải thích kĩ hơn bằng việc làm theo 2 cách để các bạn có thể tự so sánh.
Theo cách thông thường, ta sẽ implement thế naỳ.
# controller
def accept_invitation
if invitation.accepted?
render json: { errors: ['Invitation already accepted'] }, status: 422
else
user = User.build_from_invitation(invitation)
user.save!
invitation.accept
invitation.save!
UserMailer.notify_affiliate_payment(invitation).deliver_later
render json: { user: user }
rescue ActiveRecord::ActiveRecordError => e
render json: { errors: [e.message] }, status: 422
end
# User model
class User < ActiveRecord::Base
has_many :invitations
after_create :send_welcome_email
def send_welcome_email
if Invitation.where(email: email).exists?
UserMailer.affiliate_welcome(self).deliver_later
end
end
def self.build_from_invitation(invitation)
# logic to build user goes here
end
end
# Invitation Model
class Invitation < ActiveRecord::Base
before_save :pay_inviter, if: ->{ accepted_changed? && accepted? }
belongs_to :inviter, class_name: 'User'
def accept
self.accepted = true
end
def pay_inviter
# credit logic goes here
end
end
Như đã thấy ta có 1 controller, có một action đảm nhận nhiệm vụ accept_invitation
và 2 model User
và Invitation
. Hãy để là những điểm mấu chốt đều được xử lý ở callback
(thậm chí callback có cả condition
trong đó.
Mình sẽ tô đỏ business logic
để các bạn có thể dễ tưởng tượng flow control như thế nào.
Code cho 1 nhiệm vụ được chia nhỏ ra ở 3 file (vẫn còn đơn giản nếu là 3 file, từ 6,7 file trở lên thì rất khó để theo dõi flow của code). À còn một điều nữa, Model nó chỉ biết giao tiếp với DB, nó ko phải controller nên ko biết context, vì vậy mà nó phải dùng điều kiện if: ->{ accepted_changed? && accepted? }
Tưởng tựong 1 ngày, ông viết code trên bỏ việc và bạn phải handover thì ... maintain vui vẻ nhé!
Cho nên, chúng ta thử break the code down, xem thực sự thì logic ở đây gồm những bước nào nhé.
Accepting invitation =
1) create user from invitation
- create the user with data from invitation
- welcome email to invited user
2) credit inviter
Có vẻ như code ở trên cũng ko thực sự mô tả đúng những gì cần diễn ra ở đây lắm. Một lần nữa mình sẽ visualize cái demo code trên bằng việc tô màu cho từng nhiệm vụ:
)
Ý tưởng của Service object là chia nhỏ code ra các class chuyên biệt. Khi đã hiểu logic gồm 3 bước, ta tạo 3 class đảm nhiệm cho từng bước, bằng cách này, ta thể hiện rõ ràng logic và context trong mỗi class.
Sử dụng Service Object để thin Model
Okay, giờ ta sẽ hãy bắt đầu tạo các Service Class như sau (quaylen)
: AcceptInvitation, CreditUser & CreateUserFromInvitation.
À trước tiên hãy làm quen với Waterfall
(tên của gem chứ ko phải tên của một development process đâu nhé). Ở Ruby (đén thời điểm này, theo mình được biét) ko có một phương pháp để chain method giống như cơ chế pipe của linux (một số hạn chế method của ruby mới có thể chain như Active::Record chẳng hạn). Đó là lý do mà các LTV đã viết ra gem Waterfall
. Các sơ vịt ốp dếch đều có 2 path là sucess và error.
Khi sử dụng Service Object với gem Waterfall
ta phải sửa controller lại một chút:
def accept_invitation
Wf.new
.chain(user: :user) { AcceptInvitation.new(invitation).call }
.chain {|outflow| render json: { user: outflow.user } }
.on_dam do |error_pool|
render json: { errors: error_pool.full_messages }, status: 422
end
end
Visualize code cho dễ hình dung:
Đó, về cơ bản các service object sẽ chain
vào với nhau thành 1 liên kết (flow
) liền mạch, có error và success path. Nếu mọi thứ chạy ok, ko lỗi, nó sẽ đi 1 mạch theo đường màu xanh và cuối cùng render user
.
Ở bất kì thời điểm nào khi ở 1 chain có lỗi xảy ra, application flow sẽ đi theo đường màu đỏ và render error
, ko có action nào ở success path được chạy nữa.
Show code
Oh có vẻ mọi thứ có vẻ rõ ràng và dễ hiểu rồi đấy, giờ là lúc show 1 chút code cho các bạn xem
1.To Thin the Model (tất nhiên rồi, nếu ko vì mục đích này thì bài viết sẽ chẳng có giá trị gì). Logic sẽ được chuyển vào các service object, model chỉ làm nhiệm vụ tối thiểu mà thôi.
# User model
class User < ActiveRecord::Base
has_many :invitations
end
# Invitation Model
class Invitation < ActiveRecord::Base
belongs_to :inviter, class_name: 'User'
def accept
self.accepted = true
end
end
CreateUserFromInvitation
làm nhiệm vụ tạo user mới với params tương ứng của invitation và gửi email welcome nếu thành công
class CreateUserFromInvitation
include Waterfall
def initialize(invitation)
@invitation = invitation
end
def call
chain { build_user }
when_falsy { user.save }
.dam { user.errors }
chain { UserMailer.affiliate_welcome(user).deliver_later }
end
private
attr_reader :user
def build_user
@user = #logic to build user goes here
end
end
CreditUser
làm nhiệm vụ credit user (tạo lời mời), lưu ý là class này được viét 1 cách rất chung chung và ko cần biết context gì cả
class CreditUser
include Waterfall
def initialize(user:, cents:)
@user, @cents = user, cents
end
def call
# credit logic goes here and dams on error
end
end
AcceptInvitation
làm nhiệm vụ sắp xếp các service object theo flow và update invitation nếu thành công
class AcceptInvitation
include Waterfall
include ActiveModel::Validations
CENTS_PAID_FOR_AFFILIATION = 100
validate :ensure_invitation_not_accepted
def initialize(invitation)
@invitation = invitation
end
def call
when_falsy { valid? }
.dam { errors }
chain(user: :user) { CreateUserFromInvitation.new(invitation).call }
chain do
CreditUser.new(
user: invitation.inviter,
cents: CENTS_PAID_FOR_AFFILIATION
).call
end
chain { invitation.accept }
when_falsy { invitation.save }
.dam { invitation.errors }
end
private
def ensure_invitation_not_accepted
if invitation.accepted?
errors.add :invitation_status, 'Invitation already accepted'
end
end
attr_reader :invitation
end
Kết luận
OK, một đièu dễ thấy là nhiều code hơn, nhưng ta được gì nào:
- Thin Controller - Skinny Model
- Logic đã được chia thành các service, service được đặt tên theo nhiệm vụ nó đảm nhận, very understandable and readable
- Dễ reuse, dễ maintain, dễ test
- Ko phải callback, ko cần logic, ko cần context. Mọi thư đều generic
Tham khảo
All rights reserved