Service Objects trong Ruby On Rails

Service Objects là gì?

Khi project của bạn ngày càng trở lên lớn hơn và phức tạp hơn thì việc các Model cũng như là Controller trở nên to hơn, nhiều code hơn là một chuyện hết sức bình thường. Ví dụ đơn giản là đôi khi chúng ta phải dùng rất nhiều dòng code để thực hiện một tác vụ nào đó như download file, đọc file hay tính toán rồi xử lý dữ liệu, bla bla... điều này sẽ khiến Model hay Controller của bạn trở nên phình to quá mức cần thiết, người ta hay gọi là Fat Controller hay Fat Model là do đó. Việc này khiến anh em Dev khá là chán nản khi đọc lại code để maintain hay fix bug. Mình đã từng gặp những action trong Controller dài đến gần 200 dòng, đọc xong vẫn chưa thể hiểu ý định của Dev là làm gì. Service Objects là Plain Old Ruby Objects (PORO) được thiết kế chuyên trị những tình huống như thế này. Nôm na là nó sẽ giúp chúng ta tạo 1 Object để xử lý một loạt các logic nhất định, do đó ở Controller hay Model ko cần phải quá dài dòng nữa mà chỉ cần 1 dòng để gọi Service ra để xử lý. Điều tuyệt vời ở đây là bạn chỉ cần xử lý logic đó ở một chỗ nhưng có thể sử dụng Service này ở tất cả mọi nơi trong project. Thật tuyệt vời đúng không nào (len)

Làm thế nào để sử dụng Service Object hiệu quả?

#1 Thống nhất một cách đặt tên

Một trong những việc khó nhất trong lập trình là đặt tên biến. Điều này không hề ngoại lệ đối với một Service Object, việc đặt tên mình thường gặp đó là thêm Service vào sau tên Model mà mình đang làm việc, vd, UserService, ProductService hay EmployeeService . Việc đặt tên như thế này đôi khi không làm rõ được mục đích chính của Service mà có thể khiến cho chúng ta sử dụng Service đó để xử lý phần lớn các action của model hiện tại. Điều này có thể dẫn đến việc phình Service (điều mà chúng ta đều không ai muốn cả). Do đó mình thấy việc đặt tên Service theo mục đích sử dụng của nó thì sẽ dễ hiểu hơn. CreateUser, SendEmail, CalculateSalary, blah blah ... cái tên cũng đã chỉ rõ ra được mục đích của Service. Tuy nhiên, dù bạn dùng cách đặt tên như thế nào thì cũng nên thống nhất cho mình một convention duy nhất. Be consistent!

#2 Không nên trực tiếp khởi tạo Service Object

Thường thì chúng ta không sử dụng đến instance của Service Object ngoại trừ việc gọi method call* của Service. Trong trường hợp này chúng ta có thể dùng một module nhỏ để thực hiện việc gọi Service Object:

module Callable
  extend ActiveSupport::Concern
  class_methods do
    def call(*args)
      new(*args).call
    end
  end
end

Khi include module này vào Service sẽ giúp chúng ta có thể rút gọn CreateUser.new(params).call hay CreateUser.new.call(params) về CreateUser.call(params). Ngắn hơn, dễ đọc hơn phải không, chúng ta vẫn có thể khởi tạo object để sử dụng khi cần.

không nhất thiết phải là call, mục tiếp theo sẽ giải thích rõ hơn

#3 Thống nhất một cách gọi Service Object

Mặc dù mình quen sử dụng method call, tuy nhiên bạn có thể dùng bất cứ một cái tên method nào bạn muốn (perform, run, execute đều thích hợp). Do Service chỉ (nên) được dùng để thực thi một việc, mà nếu bạn đã đặt tên Service đó một cách rõ ràng thì chúng ta cũng không cần đến việc phải nghĩ ra tên đúng cho method mỗi khi tạo một Service. Việc này cũng giúp cho các Dev khác dễ dùng lại Service hơn.

Hãy dùng CreateUser.call(params) thay vì UserService.new.create_user(params)

#4 Mỗi Service chỉ nên thực hiện một việc duy nhất

Service Object có được tạo ra với nhiều action khác nhau, chúng ta nên đảm bảo rằng chỉ có một action của nó được thực thi tại một thời điểm. Vì dụ về việc này nhé. Chúng ta có Service Object ManageUser, có nhiệm vụ thêm mới và xóa user. Đầu tiên, từ "Manage" không rõ ràng cho lắm, cái tên không chỉ rõ cho ta hiểu được là action nào nên được thực thi. Thay vào đó ta sử dụng 2 Service Object mới là DeleteUserCreateUser sẽ khiến code dễ đọc và tự nhiên hơn.

Hãy dùng

# Service DeleteUser
class DeleteUser
  include Callable
  def initialize user_id
    @user = User.find user_id
  end
  
  def call
    #…
  end
end
# Service CreateUser
class CreateUser
  include Callable
  def initialize user_id
    @user = User.find user_id
  end
  
  def call
    #…
  end
end
# Controller
CreateUser.call(1)
DeleteUser.call(1)

Thay vì

# Service
class ManageUser
  include Callable
  def initialize user_id
    @user = User.find user_id
  end
  
  def delete_user
    #…
  end
  
  def create_user
    #…
  end
end

# Controller
ManageUser.create_user(1)
ManageUser.delete_user(1)

#5 Giữ cho cấu trúc Service đơn giản

Việc giữ cho cấu trúc Class càng đơn giản càng tốt là điều đương nhiên trong lập trình, điều này cũng hoàn toàn đúng với các Service. Như đã nói ở trên, Service chỉ nên được dùng để thực hiện 1 action nhất định, cho nên cả Service chúng ta chỉ cần focus vào method call. Do đó những việc như lấy dữ liệu hay khởi tạo các biến thì nên được làm khi khởi tạo Service Object (trong hàm initialize), VD:

class DeleteUser
  include Callable
  def initialize(user_id:)
    @user = User.find(user_id)
  end
  
  def call
    #…
  end
end

So với

class DeleteUser
  include Callable  
  
  def initialize(user_id:)
    @user_id = user_id
  end
  def call
    #…
  end
  private
  attr_reader :user_id
  def user
    @user ||= User.find(user_id)
  end
end

Việc đơn giản hóa cấu trúc Service giúp cho việc đọc và hiểu code trở nên dễ dàng hơn cũng như là việc maintain.

#6 Đơn giản hóa các tham số

Nếu Service của bạn nhận nhiều hơn 1 tham số thì bạn nên xem xét tới việc sử dùng tham số keyword (keyword arguments) để giúp những tham số ấy dễ hiểu hơn. Dù có 1 tham số đi nữa thì việc sử dụng keyword arguments vẫn giúp cho code đọc dễ hiểu hơn nhiều:

UpdateUser.call(attributes: params[:user], send_notification: false)

Thay vì

UpdateUser.call(params[:user], false)

false ở đây là gì ? ý nghĩa và mục đích của nó là gì? keyword send_notification sẽ giúp ta hiểu rõ hơn được ý định của Dev.

#7 Miêu tả luồng một cách rõ ràng trong method call

Method call chính là trái tim của service object, cho nên việc đọc code càng dễ hiểu càng tốt chính là điều quan trọng nhất. Miêu tả luồng thực thi và hạn chế tối đa logic bằng cách đặt các tên hàm xử lí có nghĩa và rõ ý nhất có thể. Để control luồng, chúng ta có thể sử dụng and hoặc or :

class DeleteUser
  #…
  def call
    delete_user_posts
    delete_user_comments
    delete_user and send_notification_email
  end
private
  #…
end

#8 Đưa method call vào Transaction

Đôi khi một Service Object cần phải thao tác nhiều bước thì mới có thể hoàn thành mục đích của service, cho nên việc đưa các bước này vào một Transaction, vì vậy nếu có bất cứ step nào failed thì ta có thể rollback lại những thay đổi ở những step trước.

#9 Nhóm các Service Object vào các namespaces

Không sớm thì muộn, project của các bạn sẽ có trên dưới 10 service object. Để tổ chức code tốt hơn thì chúng ta nên nhóm các Service tương tự với nhau vào các namespace riêng. Có thể nhóm vào các namespace theo domain của project: AdminServices, UserServices, ... mỗi service object trong các namespace này sẽ chỉ thực hiện trong 1 domain đó. Hoặc là có thể nhóm theo sự liên quan về action như SendEmailServices, NotificationServices, ImportServices, DownloadServices ... Dù bạn có để trong bất cứ namespace thì vẫn phải giữ cho tên và đường dẫn dễ hiểu nhất có thể. Khi đã đặt ra được một convention rõ ràng rồi thì bạn sẽ không phải mất công suy nghĩ về cách đặt tên hay đường dẫn nữa.

Tổng kết

Service Object là một partern dễ dùng nhưng mạnh mẽ, chúng ta có thể dùng nó ở mọi chỗ, dễ dàng triển khai. Tuy nhiên do đơn giản nên việc sử dụng nó theo một convention nhất định lại là điều khá khó khăn. Nhưng nếu bạn đã thành thạo được việc này thì không chỉ việc triển khai Service Object mà còn trong tất cả mọi công việc lập trình hàng ngày cũng có thể được cải thiện rất nhiều. Cảm ơn các bạn đã đọc bài viết của mình.

Nguồn: Essential RubyOnRails patterns — part 1: Service Objects