Ruby on Railsにおけるサービスオブジェクトの活用ガイド
本記事は、書籍『Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling』に掲載された内容をもとに、AppSignalのゲスト投稿向けに加筆・調整したものです。
アプリケーションには多くの機能が求められますが、そのロジックが必ずしもコントローラーやモデルに属するとは限りません。たとえば、カートを使ったチェックアウト処理、サイトへのユーザー登録、サブスクリプションの開始などがその典型例です。
こうしたロジックをすべてコントローラーに書くこともできますが、同じコードをあちこちで繰り返し呼び出すことになり、保守性が大きく損なわれます。かといってモデルに置くと、IPアドレスやURLパラメーターといった、コントローラーでは容易に取得できる情報が必要になるケースで困ることがあります。そこで登場するのがサービスオブジェクトです。
サービスオブジェクトの役割は、①機能をカプセル化すること、②単一のサービスを実行すること、③単一の失敗ポイント(Single Point of Failure)を提供することです。サービスオブジェクトを使えば、アプリケーション内の複数の場所で同じロジックを何度も書き直す必要がなくなります。
サービスオブジェクトは、特別な仕組みを持たないプレーンなRubyオブジェクト(PORO)です。特定のディレクトリに配置されたファイルであり、予測可能な応答を返すただのRubyクラスにすぎません。この「予測可能性」を支えるのが、以下の3つの要素です。すべてのサービスオブジェクトは、この共通パターンに従うべきです。
params引数を受け取るinitializeメソッドを持つcallという名前の公開メソッドを1つだけ持つsuccess?と、成功時はpayload、失敗時はerrorを含むOpenStructを返す
OpenStructとは?
OpenStructは、クラスとハッシュを掛け合わせたような存在です。「任意の属性を受け取れる小さなクラス」と考えるとイメージしやすいでしょう。今回のケースでは、わずか2つの属性を扱う一時的なデータ構造として利用しています。
処理が成功した場合は、データをpayloadとして返します。
OpenStruct.new({success?: true, payload: 'some-data'})失敗した場合は、errorを返します。
OpenStruct.new({success?: false, error: 'some-error'})以下は、現在ベータ版として提供されているAppSignalの新しいAPIからデータを取得するサービスオブジェクトの実装例です。
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を多用しています。すべてのロジックが同一のアーキテクチャーパターンに従うため、テストを書く際にも非常に大きなメリットになります。
モジュール(Module)とは?
モジュールを使うことで名前空間(ネームスペース)が確保され、他のクラスとの名前衝突を防げます。特定の名前空間の下に置かれていれば、各クラスで同じメソッド名を使っても衝突しなくなるのです。
また、モジュール名のもうひとつの役割は、アプリ内のファイル構成との対応付けです。サービスオブジェクトはプロジェクト内のservicesフォルダに格納されます。先ほどの例のようにモジュール名がAppServicesである場合、そのファイルはservicesディレクトリ配下のAppServicesフォルダに配置されます。
筆者はサービスディレクトリを複数のフォルダに分割し、それぞれにアプリケーションの特定領域向けの機能をまとめています。
たとえばCloudflareServicesディレクトリには、Cloudflare上でサブドメインを作成・削除する専用のサービスオブジェクトを置いています。同様にWistiaやZapier関連のサービスも、それぞれ専用フォルダで管理しています。
このようにサービスオブジェクトを整理しておくと、実装時の挙動が予測しやすくなり、10,000フィートの視点から見てもアプリ全体が何をしているのか一目瞭然になります。
それではStripeServicesディレクトリの中身を見ていきましょう。このディレクトリには、Stripe APIとやり取りする個々のサービスオブジェクトが格納されています。これらのファイルが担うのは、アプリケーションから受け取ったデータをStripeへ送信するという、ただひとつの仕事だけです。仮にサブスクリプションを作成するStripeService内のAPI呼び出しを更新したくなっても、修正すべき場所はそこ一箇所だけです。
送信するデータを収集するロジックは、すべて別個のサービスオブジェクトとしてAppServicesディレクトリに置かれています。これらのファイルがアプリケーション側からデータを集め、外部APIと接続する対応するサービスディレクトリへ渡すのです。
具体例で見てみましょう。あるユーザーが新しいサブスクリプションを開始するとします。すべての起点はコントローラーです。以下は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まずアプリケーション内部でサブスクリプションを作成し、それが成功したら、サブスクリプション情報とstripeToken、クーポンなどのデータをAppServices::SubscriptionServiceというファイルへ渡します。
AppServices::SubscriptionServiceでは、いくつかの処理を行う必要があります。内容の解説の前に、まずコード全体をご覧ください。
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全体を俯瞰すると、処理の流れは次のようになっています。
まず、Stripeに対してサブスクリプションを作成してもらうために、Stripeの顧客ID(Customer ID)を取得する必要があります。これ自体が独立した別のサービスオブジェクトになっており、そのためにいくつかの処理を行います。
- ユーザーのプロフィールに
stripe_customer_idが保存されているか確認します。保存されていれば、顧客が実際に存在することを確かめるためにStripeから取得し直し、その結果をOpenStructのpayloadとして返します。 - 顧客が存在しない場合は、新しく顧客を作成し、
stripe_customer_idを保存してから、それをOpenStructのpayloadとして返します。
どちらの経路でも、CustomerServiceは必要な処理をすべて行ったうえで、Stripeの顧客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そろそろ、ロジックを複数のサービスオブジェクトに分割している理由が見えてきたのではないでしょうか。これらすべてのロジックを巨大な一枚岩のファイルに押し込むことを想像してみてください。とても管理できたものではありません!
話をAppServices::SubscriptionServiceに戻しましょう。これでStripeへ送信できる顧客情報が揃い、Stripe上でサブスクリプションを作成するために必要なデータがすべて整いました。
いよいよ最後のサービスオブジェクト、StripeServices::CreateSubscriptionを呼び出します。
繰り返しになりますが、StripeServices::CreateSubscriptionは決して変更されることのないファイルです。責務はひとつだけ。「データを受け取り、Stripeへ送信し、成功ならオブジェクトを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非常にシンプルですね。しかし「こんな小さなファイル、大げさすぎるのでは?」と思った方もいるでしょう。そこで、似た構造の別の例を見てみましょう。今度は、Stripe Connectを使ったマルチテナントアプリケーション向けに拡張したバージョンです。
ここからが面白いところです。例としてMavenseedを挙げますが、同じロジックはSportKeeperでも動作しています。このマルチテナントアプリはテーブルを共有する単一のモノリスで、site_idカラムによってテナントを分離しています。各テナントはStripe Connect経由でStripeに接続し、発行されたStripeアカウントIDをテナントのアカウントに保存します。
同じStripe API呼び出しを使いつつ、接続済みアカウントのStripeアカウントIDを渡すだけで、Stripeがそのアカウントの代理としてAPIリクエストを実行してくれるのです。
つまりある意味、StripeServiceオブジェクトは二重の役割を果たしています。メインアプリケーションとテナント双方から同じファイルを呼び出しながら、異なるデータを渡せるわけです。
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このファイルについて補足しておきます。より単純な例を示すこともできましたが、適切に構成されたサービスオブジェクトが、応答の返し方まで含めてどう設計されているのかを見てもらうことの方が価値があると考えました。
まず、callメソッドにはrescueとelseが付いています。これは次のように書くのと同じ意味です。
def call
begin
rescue Stripe::StripeError => e
else
end
endしかしRubyのメソッドは暗黙的にbeginブロックを開始するため、beginとendを明示する必要はありません。このコードは「サブスクリプションを作成し、エラーが発生すればそれを返し、そうでなければサブスクリプションを返す」と読めます。
シンプルで簡潔、そしてエレガント。Rubyは本当に美しい言語であり、サービスオブジェクトの活用はその美しさを存分に引き立ててくれます。
サービスファイルがアプリケーションにもたらす価値をお伝えできたなら幸いです。サービスオブジェクトは、予測可能であるだけでなく、容易に保守できるロジックを整理する非常に優れた手法なのです!
P.S. Ruby Magicの最新記事をいち早くお読みになりたい方は、ぜひRuby Magicニュースレターを購読してください。すべての記事をお見逃しなく!
本章およびその他の章は、著者の新刊『Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling』でお読みいただけます。本書では、複数の高トラフィック・高収益ウェブアプリケーションを個人開発者として構築・運用してきた筆者の経験に基づき、一般的なパターンと技法をトップダウン方式で解説しています。
クーポンコード appsignalrocks を使えば30%オフになります!
-
Ruby on Railsとは?初心者にもわかる仕組み・魅力・学び方を徹底解説
Ruby on Railsとは? Ruby on Rails(略称:RoR)は、世界で最も人気のあるオープンソースのWebアプリケーションフレームワークです。プログラミング言語「Ruby」をベースに構築されており、シンプルなサイトから大規模で複雑なサービスまで、幅広いWebアプリケーションの開発を支援します。 そもそもフレームワークとは? フレームワークとは、ソフトウェア開発の際に土台となる構造を提供してくれるコードやツール、ユーティリティの集合体です。あらかじめ用意された構造に沿ってコードを書くことで、プログラムが整理され、保守性も高まります。正しく使いこなせるようになれば、開発作業は格段に
-
Rubyのfreezeメソッド完全解説 – オブジェクトの可変性と不変性を理解しよう
オブジェクトが「変更可能(ミュータブル)」であるとは、どういう意味なのでしょうか? 難しい言葉に構える必要はありません。「可変性(ミュータビリティ)」とは、単純に「オブジェクトの内部状態を後から変更できる」という意味です。これはすべてのオブジェクトのデフォルトの挙動であり、freeze(凍結)されたオブジェクトや、言語側で特別扱いされている一部のオブジェクトだけが例外となります。 つまり、Rubyのすべてのオブジェクトが変更可能というわけではないのです。 なぜ数値やシンボルは変更できないのか? たとえば、整数・シンボル、さらにはtrueやfalse(これらもすべてオブジェクトです)が変化するの