Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Làm Chủ Service Objects Trong Ruby on Rails: Giữ Controllers Sạch & Code DRY

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.

  1. Check xem stripe_customer_id saved trên user profile. Nếu có, retrieve customer từ Stripe để ensure customer actually exists, return trong payload của OpenStruct.
  2. 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%!