Thin Controller - Skinny Model by using chain service object
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 đó ...
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
https://github.com/apneadiving/waterfall