Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

Mastering Service Objects in Ruby on Rails: Keep Controllers Clean & Code DRY

บทความนี้ปรับเปลี่ยนจากรูปแบบเดิมใน 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 แยกที่ทำหลายอย่าง

  1. Check stripe_customer_id บน user profile ถ้ามี retrieve customer จาก Stripe ensure customer exists แล้ว return ใน payload ของ OpenStruct
  2. ถ้า 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%!