+1

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 đó 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 UserInvitation. 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.

1-M_A5oqniEdwWQoYIx4PeaA.png

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ụ:

1-YcT7DUlD-aZh9B9NvJkKCA.png

)

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

1-MNEnCx3FtTiUBnPGKnPgGA.png

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:

1-uYA8J9jTuPWmA2rpGxXZpw.png

Đó, 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
  1. 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
  1. 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
  1. 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


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí