บทความนี้ปรับเปลี่ยนจากรูปแบบเดิมใน Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling และปรับให้เหมาะกับ guest post นี้สำหรับ AppSignal
มี functionality เยอะที่แอปต้อง handle แต่ logic ไม่จำเป็นต้องอยู่ใน controller หรือแม้แต่มodel ตัวอย่างเช่น checkout ด้วย cart, register สำหรับ site, หรือ start subscription
ใส่ logic ทั้งหมดใน controller ได้ แต่จะ repeat yourself เรียก logic เดียวกันในทุกที่ ใส่ใน model ได้ แต่บางครั้งต้อง access things ที่ available ใน controller ง่ายๆ เช่น IP address หรือ parameter ใน URL สิ่งที่ต้องการคือ service object
หน้าที่ของ service object คือ encapsulate functionality, execute one service, และ provide single point of failure การใช้ service objects ป้องกัน developers เขียนโค้ดซ้ำๆ เมื่อใช้ในส่วนต่างๆ ของแอปพลิเคชัน
Service object คือ Plain Old Ruby Object ("PORO") แค่ไฟล์อยู่ภายใต้ directory เฉพาะ Ruby class ที่ return predictable response สิ่งที่ทำให้ response predictable คือสามส่วนสำคัญ Service objects ทุกตัวควร follow pattern เดียวกัน
- มี initialization method กับ params argument
- มี single public method ชื่อ call
- Return OpenStruct ด้วย success? และ payload หรือ error
OpenStruct คืออะไร?
เหมือน brainchild ของ class และ hash คิดว่าเป็น mini-class ที่รับ arbitrary attributes ได้ ในกรณีนี้ใช้เป็น temporary data structure จัดการแค่สอง attributes
ถ้า success เป็น true return payload ของ data
OpenStruct.new({success?: true, payload: 'some-data'})
ถ้า success เป็น false return error
OpenStruct.new({success?: false, error: 'some-error'})
ตัวอย่าง service object ดึงข้อมูลจาก AppSignal API ใหม่ (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
เรียกไฟล์ข้างต้นด้วย AppServices::AppSignalApiService.new({endpoint: 'markers'}).call ใช้ OpenStruct เยอะ return predictable response ค่านี้ valuable ตอนเขียน tests เพราะ architectural patterns ของ logic เหมือนกันทั้งหมด
Module คืออะไร?
ใช้ modules ให้ name-spacing และป้องกัน collision กับ classes อื่นๆ หมายถึงใช้ method names เดียวกันใน classes ทั้งหมดได้ เพราะอยู่ภายใต้ namespace เฉพาะ
อีก key part ของ module name คือการจัดไฟล์ในแอป Service objects เก็บใน services folder ในโปรเจกต์ Service object ตัวอย่างข้างต้น module name AppServices อยู่ใน folder AppServices ใน services directory
จัด service directory หลาย folders แต่ละ folder มี functionality สำหรับส่วนเฉพาะของแอป
เช่น directory CloudflareServices มี service objects สำหรับสร้างและลบ subdomains บน Cloudflare Wistia และ Zapier services มี service files ของตัวเอง
จัด service objects แบบนี้ให้ better predictability ตอน implementation และดูง่ายว่าแอปทำอะไรจาก 10k-foot view
ขอขอ StripeServices directory directory นี้มี individual service objects สำหรับ interact กับ Stripe API อีกครั้ง ไฟล์เหล่านี้แค่รับ data จากแอปส่งไป Stripe ถ้าต้อง update API call ใน StripeService object สร้าง subscription มีที่เดียวทำ
Logic ทั้งหมด collect data ส่ง อยู่ใน service object แยก ใน AppServices directory ไฟล์เหล่านี้ gather data จากแอปส่งไป service directory ที่ตรงกันสำหรับ interface กับ external API
Visual example: สมมติมีคนเริ่ม subscription ใหม่ ทุกอย่าง originate จาก controller นี่คือ 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
สร้าง subscription ใน app ก่อน ถ้าสำเร็จ ส่ง subscription, stripeToken, coupon ไป AppServices::SubscriptionService
ใน AppServices::SubscriptionService มีหลายอย่างต้องเกิด นี่คือ object ก่อนจะไปรายละเอียด:
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:
ต้อง get Stripe customer ID ก่อน ส่งไป Stripe สร้าง subscription นี่เป็น service object แยกที่ทำหลายอย่าง
- Check
stripe_customer_idบน user profile ถ้ามี retrieve customer จาก Stripe ensure customer exists แล้ว return ใน payload ของ OpenStruct - ถ้า customer ไมมี สร้าง customer save stripe_customer_id return ใน payload
ไม่ว่าจะแบบไหน CustomerService return Stripe customer ID ทำสิ่งจำเป็นทั้งหมด นี่คือไฟล์:
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
หวังว่าเห็น value ของ service files ในแอป ไม่เห็นเป็นไฟล์ยักษ์ logic ทั้งหมด ไม่มีทาง!
กลับไป AppServices::SubscriptionService ตอนนี้มี customer ส่งไป Stripe complete data สร้าง subscription บน Stripe
พร้อมเรียก service object สุดท้าย StripeServices::CreateSubscription
อีกครั้ง StripeServices::CreateSubscription service object ไม่เปลี่ยนแปลง มี single responsibility คือรับ data ส่งไป Stripe return success หรือ 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
Simple ไหม? แต่คุณคิดไฟล์เล็กนี้ overkill มาดูตัวอย่างอีกไฟล์คล้ายกัน แต่ augment สำหรับ multi-tenant application via Stripe Connect
นี่คือที่น่าสนใจ ใช้ Mavenseed เป็นตัวอย่าง logic เดียวกันรันบน SportKeeper ด้วย Multi-tenant app เป็น single monolith share tables แยกด้วย site_id column แต่ละ tenant connect Stripe via Stripe Connect ได้ Stripe Account ID save บน account ของ tenant
ใช้ Stripe API calls เดียวกัน ส่ง Stripe Account ของ connected account Stripe จะ perform API call on behalf of connected account
ในทางหนึ่ง StripeService object ทำ double-duty ทั้ง main application และ tenants เรียกไฟล์เดียวกันแต่ส่ง 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
Technical notes: อาจ share simpler example แต่คิดว่า valuable ให้เห็น proper service object structure รวม responses
ก่อนหน้า "call" method มี rescue และ else statement เหมือนเขียน:
def call
begin
...
rescue Stripe::StripeError => e
...
else
...
end
end
แต Ruby methods implicitly begin block ไม่ต้องเพิ่ม begin และ end Statement นี้อ่านว่า "create subscription return error ถ้ามี ไม่งั้น return subscription"
Simple, succinct, และ elegant Ruby เป็นภาษาสวยงามจริงๆ และ service objects highlight นี้
หวังว่าเห็น value ของ service files ในแอปพลิเคชัน พวกมันให้วิธีจัดระเบียบ logic ที่ succinct ไม่แค่ predictable แต่ easily maintainable!
P.S. หากอยากอ่าน Ruby Magic posts ทันทีที่เผยแพร่ สมัครสมาชิก Ruby Magic newsletter และไม่พลาดโพสต์เด็ดๆ!
—
อ่าน chapter นี้และมากกว่าได้จากหนังสือใหม่ Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling ในหนังสือนี้ top-down approach cover common patterns และ techniques บน first-hand experience เป็น solo-developer building และ maintaining multiple high-traffic, high-revenue website applications
ใช้ coupon code appsignalrocks ลด 30%!