Bài viết này đã được sửa đổi từ bản gốc xuất hiện trong Playbook Thirty-nine - Hướng Dẫn Shipping Interactive Web Apps Với Minimal Tooling, và tailored cho guest post này cho AppSignal.
Có nhiều functionality app cần handle, nhưng logic đó không nhất thiết thuộc về controller hoặc thậm chí model. Ví dụ: checkout với cart, đăng ký site, hoặc start subscription.
Bạn có thể include tất cả logic trong controller, nhưng sẽ keep repeating yourself, gọi same logic ở tất cả những places. Bạn có thể put logic trong model, nhưng đôi khi cần access things easily available trong controller, như IP address, hoặc parameter trong URL. Cái bạn cần là service object.
Job của service object là encapsulate functionality, execute one service, và provide single point of failure. Dùng service objects cũng prevent developers từ viết same code again và again khi dùng ở different parts của application.
Service object chỉ là Plain Old Ruby Object ("PORO"). Chỉ là file sống dưới specific directory. Là Ruby class trả về predictable response. Điều làm response predictable là do ba key parts. Tất cả service objects nên follow same pattern.
- Có initialization method với params argument.
- Có single public method tên là call.
- Trả về OpenStruct với success? và hoặc payload hoặc error.
OpenStruct Là Gì?
Nó như brainchild của class và hash. Bạn nghĩ nó như mini-class nhận arbitrary attributes. Trong case chúng ta, dùng nó như temporary data structure handle just two attributes.
Nếu success là true, trả về payload data.
OpenStruct.new({success?: true, payload: 'some-data'})
Nếu success là false, trả về error.
OpenStruct.new({success?: false, error: 'some-error'})
Ví dụ service object reach out grab data từ AppSignal API mới, hiện trong beta.
module AppServices
class AppSignalApiService
require 'httparty'
def initialize(params)
@endpoint = params[:endpoint] || 'markers'
end
def call
result = HTTParty.get("https://appsignal.com/api/#{appsignal_app_id}/#{@endpoint}.json?token=#{appsignal_api_key}")
rescue HTTParty::Error => e
OpenStruct.new({success?: false, error: e})
else
OpenStruct.new({success?: true, payload: result})
end
private
def appsignal_app_id
ENV['APPSIGNAL_APP_ID']
end
def appsignal_api_key
ENV['APPSIGNAL_API_KEY']
end
end
end
Gọi file trên với AppServices::AppSignalApiService.new({endpoint: 'markers'}).call. Tôi dùng liberal OpenStruct trả về predictable response. Rất valuable khi viết tests vì all logic's architectural patterns identical.
Module Là Gì?
Dùng modules cung cấp name-spacing và prevent colliding với other classes. Nghĩa là bạn dùng same method names trong all classes và không clash vì under specific namespace.
Key part khác của module name là cách files organized trong app. Service objects kept trong services folder trong project. Service object example trên, với module name AppServices, rơi vào folder AppServices trong services directory.
Tổ chức service directory thành multiple folders, mỗi folder chứa functionality cho specific part của application.
Ví dụ, directory CloudflareServices hold specific service objects tạo và xóa subdomains trên Cloudflare. Wistia và Zapier services hold respective service files.
Organize service objects như này yield better predictability implementation, và dễ thấy ở 10k-foot view app đang làm gì.
Đi sâu vào directory StripeServices. Directory này hold individual service objects interact với Stripe API. Chỉ thing these files do là take data từ application và send đến Stripe. Nếu ever cần update API call trong StripeService object tạo subscription, chỉ one place làm đó.
All logic collect data to be sent done trong separate service object, living in AppServices directory. Những files này gather data từ application và send off đến corresponding service directory để interface với external API.
Visual example: assume someone starting new subscription. Everything originates từ controller. Đây SubscriptionsController.
class SubscriptionsController < ApplicationController
def create
@subscription = Subscription.new(subscription_params)
if @subscription.save
result = AppServices::SubscriptionService.new({
subscription_params: {
subscription: @subscription,
coupon: params[:coupon],
token: params[:stripeToken]
}
}).call
if result && result.success?
sign_in @subscription.user
redirect_to subscribe_welcome_path, success: 'Subscription was successfully created.'
else
@subscription.destroy
redirect_to subscribe_path, danger: "Subscription was created, but there was a problem with the vendor."
end
else
redirect_to subscribe_path, danger: "Error creating subscription."
end
end
end
Đầu tiên create subscription in-app, nếu successful, send đó, stripeToken, và stuff như coupon vào file AppServices::SubscriptionService.
Trong file AppServices::SubscriptionService, vài thứ cần xảy ra. Đây object đó, trước khi vào chi tiết:
module AppServices
class SubscriptionService
def initialize(params)
@subscription = params[:subscription_params][:subscription]
@token = params[:subscription_params][:token]
@plan = @subscription.subscription_plan
@user = @subscription.user
end
def call
# create or find customer
customer ||= AppServices::StripeCustomerService.new({customer_params: {customer:@user, token:@token}}).call
if customer && customer.success?
subscription ||= StripeServices::CreateSubscription.new({subscription_params:{
customer: customer.payload,
items:[subscription_items],
expand: ['latest_invoice.payment_intent']
}}).call
if subscription && subscription.success?
@subscription.update_attributes(
status: 'active',
stripe_id: subscription.payload.id,
expiration: Time.at(subscription.payload.current_period_end).to_datetime
)
OpenStruct.new({success?: true, payload: subscription.payload})
else
handle_error(subscription&.error)
end
else
handle_error(customer&.error)
end
end
private
attr_reader :plan
def subscription_items
base_plan
end
def base_plan
[{ plan: plan.stripe_id }]
end
def handle_error(error)
OpenStruct.new({success?: false, error: error})
end
end
end
High-level overview, đây những gì nhìn thấy:
Phải first get Stripe customer ID để send đến Stripe tạo subscription. Đó itself là entirely separate service object làm number of things để make this happen.
- Check xem
stripe_customer_idsaved trên user profile. Nếu có, retrieve customer từ Stripe để ensure customer actually exists, return trong payload của OpenStruct. - Nếu customer không exist, create customer, save stripe_customer_id, return trong payload của OpenStruct.
Cách nào, CustomerService trả về Stripe customer ID, và làm necessary để make that happen. Đây file đó:
module AppServices
class CustomerService
def initialize(params)
@user = params[:customer_params][:customer]
@token = params[:customer_params][:token]
@account = @user.account
end
def call
if @account.stripe_customer_id.present?
OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
else
if find_by_email.success? && find_by_email.payload
OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
else
create_customer
end
end
end
private
attr_reader :user, :token, :account
def find_by_email
result ||= StripeServices::RetrieveCustomerByEmail.new({email: user.email}).call
handle_result(result)
end
def create_customer
result ||= StripeServices::CreateCustomer.new({customer_params:{email:user.email, source: token}}).call
handle_result(result)
end
def handle_result(result)
if result.success?
account.update_column(:stripe_customer_id, result.payload.id)
OpenStruct.new({success?: true, payload: account.stripe_customer_id})
else
OpenStruct.new({success?: false, error: result&.error})
end
end
end
end
Hy vọng bạn thấy tại sao structure logic across multiple service objects. Imagine one giant behemoth file với all logic này? No way!
Quay lại file AppServices::SubscriptionService. Giờ có customer send đến Stripe, complete data cần tạo subscription trên Stripe.
Ready call last service object, file StripeServices::CreateSubscription.
Lại, StripeServices::CreateSubscription service object never changes. Single responsibility: take data, send to Stripe, return success hoặc return object as payload.
module StripeServices
class CreateSubscription
def initialize(params)
@subscription_params = params[:subscription_params]
end
def call
subscription = Stripe::Subscription.create(@subscription_params)
rescue Stripe::StripeError => e
OpenStruct.new({success?: false, error: e})
else
OpenStruct.new({success?: true, payload: subscription})
end
end
end
Pretty simple right? Nhưng bạn nghĩ: file nhỏ này overkill. Xem example khác similar file nhưng augmented cho multi-tenant application via Stripe Connect.
Đây nơi things get interesting. Dùng Mavenseed làm example, logic này cũng chạy trên SportKeeper. Multi-tenant app là single monolith, sharing tables, separated by site_id column. Mỗi tenant connect to Stripe via Stripe Connect, lấy Stripe Account ID save trên account tenant.
Dùng same Stripe API calls, simply pass Stripe Account của connected account, Stripe perform API call on behalf của connected account.
Vậy StripeService object performing double-duty, cùng main application và tenants, call same file, nhưng send different data.
module StripeServices
class CreateSubscription
def initialize(params)
@subscription_params = params[:subscription_params]
@stripe_account = params[:stripe_account]
@stripe_secret_key = params[:stripe_secret_key] ? params[:stripe_secret_key] : (Rails.env.production? ? ENV['STRIPE_LIVE_SECRET_KEY'] : ENV['STRIPE_TEST_SECRET_KEY'])
end
def call
subscription = Stripe::Subscription.create(@subscription_params, account_params)
rescue Stripe::StripeError => e
OpenStruct.new({success?: false, error: e})
else
OpenStruct.new({success?: true, payload: subscription})
end
private
attr_reader :stripe_account, :stripe_secret_key
def account_params
{
api_key: stripe_secret_key,
stripe_account: stripe_account,
stripe_version: ENV['STRIPE_API_VERSION']
}
end
end
end
Vài technical notes: Tôi could shared simpler example, nhưng tôi think valuable để bạn thấy proper service object structure, including responses.
First, "call" method có rescue và else statement. Same as writing:
def call
begin
rescue Stripe::StripeError => e
else
end
end
Nhưng Ruby methods automatically begin block implicitly, so no reason add begin và end. Statement reads: "create subscription, return error if any, otherwise return subscription."
Simple, succinct, elegant. Ruby truly beautiful language và service objects highlight this.
Hy vọng bạn thấy value service files play trong applications. Chúng cung cấp very succinct way organize logic không chỉ predictable mà easily maintainable!
P.S. Nếu muốn đọc bài Ruby Magic ngay khi xuất bản, đăng ký newsletter Ruby Magic để không bỏ lỡ!
——
Đọc chapter này và hơn nữa bằng cách mua sách mới Playbook Thirty-nine - Hướng Dẫn Shipping Interactive Web Apps Với Minimal Tooling. Trong sách, tôi take top-down approach covering common patterns và techniques, based solely trên first-hand experience như solo-developer building và maintaining multiple high-traffic, high-revenue website applications.
Dùng coupon code appsignalrocks và save 30%!