Rails Service Object
Bài đăng này đã không được cập nhật trong 5 năm
Giả sử ta cần đăng 1 đoạn message lên Twitter, chúng ta thường làm như sau:
class TweetController < ApplicationController
def create
send_tweet(params[:message])
end
private
def send_tweet(tweet)
client = Twitter::REST::Client.new do |config|
config.consumer_key = ENV['TWITTER_CONSUMER_KEY']
config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET']
config.access_token = ENV['TWITTER_ACCESS_TOKEN']
config.access_token_secret = ENV['TWITTER_ACCESS_SECRET']
end
client.update(tweet)
end
end
Nhìn vào đoạn code trên ta đã định nghĩa send_tweet gọi api twitter.Nếu 1 controller khác cũng gọi twitter 1 cách tương tự thì sao? Có nên cho nó vào concern?? Nhưng nó k hẳn thuộc về controller, tại sao ta k tìm cách cho Twitter API thành 1 đối tượng sau đó call khi cần
Service object là gì?
Service object được thiết kế để thực hiện một logic cụ thể nào đó mà đối tượng xử lí k hoàn toàn thuộc về 1 model nào cả. Lợi ích của Service object là nó giúp chúng ta tập chung toàn bộ logic chức năng vào 1 object riêng biệt thay vì chia nhỏ nó ở controller hay model. Lúc nào cần đến thì gọi object đó ra. Chính vì khối logic được tập trung hết vào trong 1 object nên sẽ tối giản controller và model đi rất nhiều, code clean và quá trình maintain sau này cũng đỡ vất vả hơn. Xem ví dụ trên method send_tweet thực hiện 1 logic duy nhất là tạo 1 tweet. Nếu logic này được gói gọn vào 1 class, chúng ta có thể khởi tạo và gọi theo kiểu:
tweet_creator = TweetCreator.new(params[:message])
tweet_creator.send_tweet
hoặc
TweetCreator.call(params[:message])
Thật tiện lợi đúng k. Ta chỉ việc định nghĩa nó 1 lần, có thể dùng nó ở bất cứ đâu, dễ dàng maintance sửa đổi
Tạo Service object
Chúng ta sẽ tạo TweetCreator
trong app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
add logic vào trong service:
# app/services/tweet_creator.rb
class TweetCreator
def initialize(message)
@message = message
end
def send_tweet
client = Twitter::REST::Client.new do |config|
config.consumer_key = ENV['TWITTER_CONSUMER_KEY']
config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET']
config.access_token = ENV['TWITTER_ACCESS_TOKEN']
config.access_token_secret = ENV['TWITTER_ACCESS_SECRET']
end
client.update(@message)
end
end
Sau đó bạn có thể gọi bằng cách:
TweetCreator.new(params[:message]).send_tweet
TweetCreator class name tương đối ngắn nhưng khi khởi tạo và gọi thì trông khá dài đúng không. ta có thể rút gọn câu lệnh gọi bằng cách sau đây, Nếu TweetCreator có thể giống với proc trong Ruby ta có thể gọi nó bằng TweetCreator.call(message)
Giờ chúng ta sẽ biến service object như 1 proc để thuận tiện cho việc gọi service nhé!
Tạo 1 ApplicationService:
# app/services/application_service.rb
class ApplicationService
def self.call(*args, &block)
new(*args, &block).call
end
end
Mỗi khi call dc gọi nó sẽ tạo ra 1 instance của class đó vs các biến truyền vào
# app/services/tweet_creator.rb
class TweetCreator < ApplicationService
attr_reader :message
def initialize(message)
@message = message
end
def call
client = Twitter::REST::Client.new do |config|
config.consumer_key = ENV['TWITTER_CONSUMER_KEY']
config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET']
config.access_token = ENV['TWITTER_ACCESS_TOKEN']
config.access_token_secret = ENV['TWITTER_ACCESS_SECRET']
end
client.update(@message)
end
end
Tại controller ta gọi
class TweetController < ApplicationController
def create
TweetCreator.call(params[:message])
end
end
1 cách làm cho code trở nên tối ưu hơn đúng không )
Grouping Similar Service Objects
Ở ví dụ trên ta chỉ xét 1 service object, nhưng trên thực tế có thể phức tạp hơn thế. Ví dụ ta có hàng trăm service xử lí nhiều logic khác nhau. Ta k thể nhét chúng vào 1 file sẽ rất khó quản lí đúng không. Chúng ta sẽ sử dụng namespacing, ta sẽ nhóm các service object có chung đặc điểm vào 1 module: Ví dụ:
services
├── application_service.rb
└── twitter_manager
├── profile_follower.rb
└── tweet_creator.rb
Trong service:
# services/twitter_manager/tweet_creator.rb
module TwitterManager
class TweetCreator < ApplicationService
...
end
end
# services/twitter_manager/profile_follower.rb
module TwitterManager
class ProfileFollower < ApplicationService
...
end
end
Ta gọi bằng cách
TwitterManager::TweetCreator.call(arg)
TwitterManager::ProfileManager.call(arg)
Service Objects thao tác với database
Ở ví dụ trên ta xét api call, nhưng service object cũng có thể sử dụng để gọi tới database. Nó thực sự hữu ích khi ta cập nhập nhiều DB với nhiều logic phức tạp ví dụ như:
module MoneyManager
# exchange currency from one amount to another
class CurrencyExchanger < ApplicationService
...
def call
ActiveRecord::Base.transaction do
# transfer the original currency to the exchange's account
outgoing_tx = CurrencyTransferrer.call(
from: the_user_account,
to: the_exchange_account,
amount: the_amount,
currency: original_currency
)
# get the exchange rate
rate = ExchangeRateGetter.call(
from: original_currency,
to: new_currency
)
# transfer the new currency back to the user's account
incoming_tx = CurrencyTransferrer.call(
from: the_exchange_account,
to: the_user_account,
amount: the_amount * rate,
currency: new_currency
)
# record the exchange happening
ExchangeRecorder.call(
outgoing_tx: outgoing_tx,
incoming_tx: incoming_tx
)
end
end
end
# record the transfer of money from one account to another in money_accounts
class CurrencyTransferrer < ApplicationService
...
end
# record an exchange event in the money_exchanges table
class ExchangeRecorder < ApplicationService
...
end
# get the exchange rate from an API
class ExchangeRateGetter < ApplicationService
...
end
end
Service object nên trả về gì?
Vừa rồi chúng ta thảo luận cách xây dựng gọi method call như thế nào, vậy method call nên trả về gì? Có 3 cách trả về
- Trả về true/ false
def call
...
return true if client.update(@message)
false
end
- Trả về 1 giá trị
def call
...
return false unless exchange_rate
exchange_rate
end
- Trả về 1 enum
class ExchangeRecorder < ApplicationService
RETURNS = [
SUCCESS = :success,
FAILURE = :failure,
PARTIAL_SUCCESS = :partial_success
]
def call
foo = do_something
return SUCCESS if foo.success?
return FAILURE if foo.failure?
PARTIAL_SUCCESS
end
private
def do_something
end
end
Một số rule để viết service object tốt
Mỗi Service Object chỉ nên có 1 public method
Mỗi một service object chỉ thực hiện 1 bussiness cụ thể nào đó, do vậy chỉ nên có 1 public method
Đặt tên Service object theo vai trò của nó
Ta nên đặt tên service object để người code có thể hiểu được vai trò của nó luôn
Không thực hiện nhiều action
Mỗi service object chỉ thực hiên 1 bussiness
Handle Exceptions bên trong service object
Hi vọng bài viết giúp bạn hiểu về service object và vận dụng được trong dự án. Thanks for reading.
Tham khảo tại: https://www.toptal.com/ruby-on-rails/rails-service-objects-tutorial
All rights reserved