0

Rails Service Object

Trong lập trình Rails, chắc hẳn các bạn từng biết đến kiểu thiết kế này rồi chứ fat models, skinny controllers. Ban đầu nó có vẻ khá hợp lý và đơn giản vì chúng ta chỉ cần đưa tất cả logic cần xử lý vào model là xong, đó là việc của model mà quá dễ. Tuy nhiên đôi khi ta gặp những thứ phức tạp hơn, chẳng hạn ta cần thao tác với nhiều model, thao tác với các API bên ngoài, hay tác vụ Mail thì sao nhỉ, đưa vào model nào bây giờ ? Ngay cả khi bạn chọn được một model "phù hợp" thì bạn có bao giờ cảm thấy có gì đó vẫn chưa đúng lắm không, bạn có cảm thấy model của mình bị phình to, bị phức tạp quá không. Tôi đã từng nghĩ giá mà có thể làm gọn model lại khiến chúng đơn giản hơn nhưng không biết làm thế nào 😦. Và rồi tôi biết đến service objects.

Mục đích của Service Object

Nó không phải model, nó là những class được tạo ra cho một mục đích đặc biệt, một business riêng, những thứ mà chúng ta không biết để vào model nào 😄. Thật tuyệt phải không.

Thiết kế một Service Object

Việc này khá đơn giản vì nó không đòi hỏi phải sử dụng Gem nào đặc biệt cả, nó chỉ là một class bình thường mà chúng ta tạo ra nhằm xử lý những business logic mà chúng ta không thể muốn đưa vào model mà thôi. Tuy nhiên nó cũng có một số quy tắc nhất định, mà ta nên tuân theo để đem lại cho chúng ta những lợi ích cần thiết.

1. Do not store state

Service object nên là functional, những method chỉ hoạt động theo những gì ta truyền vào, và kết quả nên được mô tả hoàn toàn trong giá trị trả về. Nói cách khác, việc gọi method không nên ảnh hưởng tới trạng thái của service object.

Điều này không có nghĩa là service object không được có biến instance, chúng ta vẫn có thể sử dụng biến instance nhưng không bao giờ được thay đổi giá trị của chúng.

class MyService
  def initialize(timeout: 1000)
    @helper_service = MyHelper.new
    @timeout = timeout
  end
end

Đoạn code trên có thể hiểu là user sẽ phải sử dụng các object được tạo từ class, chứ không phải chính class đó, để tránh ảnh hưởng tới state của class.

2. Use Instance Methods

Một ví dụ cho vấn đề này đó là Resque . Tất cả các method Resque đều được gọi qua class Resque Resque.enqueue(MyJob). Bình thường thì việc này không sao, nhưng giả sử bên trong Resque, ta lại muốn sử dụng Resque thì sao ? Điều này có nghĩa là ứng dụng của bạn giao tiếp với nhiều hơn một Resque instance, điều này rõ ràng là không thể vì không có cách nào để nói Resque cần sử dụng implementation nào.

Nhưng nếu Resque được implement như một object, chứ không phải global, thì nếu code cần truy cập một Resque khác, thì instance sẽ không cần phải thay đổi gì vì đơn giản là ta sẽ tạo ra một object khác mà thôi.

3. Have Few Public Methods

Nhiều Rubyist có hai thói quen rất xấu đó là: sử dụng public attr_accessor cho mọi biến instance, và public tất cả method ngay cả khi chúng không là một phần của class. Tệ hơn, nhiều Rubyists còn cảm thấy cần viết test cho nhưng method đáng lý ra cần private thì lại bị public này 😦.

Kiểu code này khiến việc refactoring gặp khó khăn vì thật khó để hiểu Class này mục đích sử dụng thật sự là gì ? Nếu mục đích thật sự là người dùng chỉ gọi một hoặc hai method thôi thì chỉ nên để public những method đó là đủ.

Ví dụ với service ReturnProcessor, làm nhiệm vụ xử lý những mặt hàng được người dùng trả lại về kho. Giả sử method được người dùng gọi là process!, và nó làm hai việc:

  • tính phí khách hàng đối với mặt hàng chưa thanh toán
  • lưu lại việc return hàng đã được xử lý.
class ReturnProcessor
  def process!(the_return, user)
    if unpaid_items(the_return).any?
      checkout_service.charge!(unpaid_items(the_return))
    end
    record_return(the_return,user)
  end
end

Cách viết sai

class ReturnProcessor
  attr_accessor :checkout_service
  def initialize
    @checkout_service = CheckoutService.new
  end

  def process!(the_return,user)
    # …
  end

  def record_return(the_return,user)
  end
end

Cách viết đúng, chúng ta chỉ cần public process! là đủ, còn checkout_servicerecord_return là private.

class ReturnProcessor
  def initialize
    @checkout_service = CheckoutService.new
  end

  def process!(the_return,user)
    # …
  end

private
  attr_reader :checkout_service

  def record_return(the_return,user)
  end
end

4. Method parameters should be value objects

Method của service object hoạt động trên một vài data hoặc xử lý vài process dùng data là đầu vào. Như ví dụ trên, method process! trong ReturnProcessor tạo ra một return là đã xử lý và thu phí khác hàng cho những mặt hàng chưa thanh toán. Nghĩa là nó cần data cho việc return và user để được thu phí.

Bạn không nên truyền vào service object những service object khác. Giống như cách ReturnProcessor sử dụng instance của CheckoutService để thu phí người dùng cho những mặt hàng trả lại.

Việc truyền vào service lời gọi làm thế nào để tạo CheckoutService giống như việc chúng ta phơi bày ra cách chúng ta implement process! vậy, nó khiến việc thay đổi khó khăn hơn. Giống như thế này:

# Bad
ReturnProcessor.new.process!(the_return,user,CheckoutService.new)

Nếu process! xử lý đơn giản những data cho hoạt động của nó hoặc những data cần để đọc, thì sẽ dễ hiểu hơn. Cách viết tốt:

# Good
ReturnProcessor.new.process!(the_return,user)

5. Methods should return rich result objects, not booleans

Method của service object thường có ba kết quả sau:

  • Thành công
  • Thất bại nhưng trong logic đã dự kiến trước
  • Xảy ra exception

Ví dụ, một kết quả thành công yêu cầu một số mặt hàng được khuyến mại, và một số khác phải trả về kho. Một kết quả thất bại phải cần những mặt hàng thanh toán không đúng và gợi ý cho người dùng cách giải quyết. Do đó nếu đơn giản chỉ là true/false thì sẽ không thể truyền tải những thông tin đó được.

Có thể hiểu đơn giản là nhìn chung cách thiết kế chỉ return true/false thì nên tránh. Vì chúng rất khó có thể mở rộng được về sau. Nếu hôm nay sử dụng true là đủ, nhưng về sau bạn cần thêm các thông tin vào return, bạn sẽ cần thiết kế lại rất nhiều nếu bạn muốn sử dụng đối tượng phong phú hơn ban đầu.

Vì lý do này, chúng ta nên return object như một API đầy đủ. Bạn có thể tạo đối tượng này với thư viện immutable-struct

class ReturnProcessor
  Result = ImmutableStruct.new(:return_processed?, 
                               :error_messages)
end

result = return_processor.process!(the_return,user)

unless result.return_processed?
  flash[:error] = result.error_messages.join(",")
  redirect_to 'new'
end

Quản lý các service object phụ thuộc nhau

Ở phía trên, chúng ta đã biết nguyên tắc không sử dụng service object khác để làm parameter. Nhưng chúng ta không giới hạn việc các service object này truy cập/gọi tới các service object khác mà nó cần. Nó cũng giống như ví dụ trên ReturnProcessor đã sử dụng CheckoutService để thu phí khách hàng cho những mặt hàng bị trả lại.

def process!(the_return,user)
  if unpaid_items(the_return).any?
    # Bad--this business logic is coupled to the 
    #      creation of another service object
    result = CheckoutService.new.charge!(unpaid_items(the_return))
    unless result.charge_succeeded?
      return Result.new(return_processed: false, 
                        error_messages: result.error_messages)
    end
  end

  # remainder of the method

end

Tuy nhiên, việc này không phải là lý tưởng. Có vấn đề với process! ?

Vấn đề đó là process! chỉ quan tâm tới các chi tiết của việc xử lý một return - cũng như phải biết làm thế nào để tạo ra một instance CheckoutService. Nó có nghĩa là nếu bạn thay đổi các tạo CheckoutService, bạn phải thay đổi method này. Tuy nhiên chúng ta đương nhiên không muốn thay đổi nó trừ phi business thay đổi mà thôi, vậy phải làm sao.

Đây chính là cách.

class ReturnProcessor
  def process!(the_return,user)
    if unpaid_items(the_return).any?
      # Good--our code just depends on an object 
      #       that we can assume has been set up for us
      result = checkout_service.charge!(unpaid_items(the_return))
      unless result.charge_succeeded?
        return Result.new(return_processed: false, 
                          error_messages: result.error_messages)
      end
    end

    # remainder of the method

  end

private

  def checkout_service
    @checkout_service ||= CheckoutService.new
  end

Chúng ta chuyển nó xuống private. Mặc dù ReturnProcessor nhìn chung vẫn phụ thuộc vào cách CheckoutService được tạo nhưng ta sẽ không phải thay đổi method process! 😄.

Còn một cách khác nữa là gọi từ constructor

class ReturnProcessor

  def initialize(checkout_service: nil)
    @checkout_service = checkout_service || CheckoutService.new
  end

  def process!(the_return,user)
    if unpaid_items(the_return).any?
      result = checkout_service.charge!(unpaid_items(the_return))
      unless result.charge_succeeded?
        return Result.new(return_processed: false, 
                          error_messages: result.error_messages)
      end
    end

    # remainder of the method

  end

private

  attr_reader :checkout_service
end

Cách này có vẻ rườm ra hơn, nhưng nó sẽ hữu ích hơn nếu:

  • Cách tạo CheckoutService không ổn định và có khả năng sẽ thay đổi và chúng ta muốn lưu nó bên ngoài class
  • Service của chúng ta cần thực hiện với các CheckoutService khác nhau
  • CheckoutServicesingleton hoặc một vài thứ rất khó để khởi tạo và ta muốn code của mình luôn chạy ở bất kỳ đâu.
  • Bạn muốn fail fast nếu không tạo được CheckoutService

Tham khảo http://multithreaded.stitchfix.com/blog/2015/06/02/anatomy-of-service-objects-in-rails/


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í