ValueObjectとFormObject編
社内向けに作った資料ですが、稚拙な設計でも世の中に役に立つと思い、公開に踏み切りました。
本記事を読むには、決済(iOS, Android, クレジットカード)に関するドメイン知識も必要ですが、デザインパターンの話を中心に書いています。
前提
以下はすべてRubyとRailsを使った説明をしています。
考え方自体は他の言語で置き換えられると思うので、ご参考までにお読みください。
GOAL
アプリ内課金した結果をサーバー保存してユーザーに課金した内容をサーバー側で付与したい。
今回は保存の部分まで説明します。
手順
- レシートを発行する(アプリ)
- レシートをアプリから受け取りが正しいか検証する。(サーバー)
- 検証が完了したらDBに保存をする。
Save to Database
保存したい内容は以下の通り。
項目 | 想定の値 |
---|---|
生のレシートデータ | UmVjZWlwdA== |
プラットフォームの情報 | iOS |
アプリ毎のユニークID(トランザクションID) | 12345678910 |
レシート内容の結果 | caputured(成功の場合) or failed(レシート不正などの失敗の場合) |
Issue
- 検証時のデータにiOS, Android, Web(CreditCard)でレシートデータの形式も違う
- 検証する内容も違う。
- 保存したい内容も違う。(Creditの場合、アプリとは少し違うレシート内容になる)
Sample code
変更前のコードは以下の通り。
自分で書きましたが、単一classに責務がつまりすぎて自分でレビューしたら却下したくなる内容ですね。
# frozen_string_literal: true
require_relative 'monza'
class ReceiptCreateForm
include ActiveModel::Model
attr_accessor :raw_receipt, :platform, :transaction_uuid, :item, :data
validates :raw_receipt, :platform, :transaction_uuid, presence: true
def to_model
Receipt.new(raw_receipt: raw_receipt, platform: platform, transaction_uuid: transaction_uuid, status: :captured)
end
def receipt_valid
case platform
when 'ios'
validate_ios
when 'android'
validate_android
when 'web'
validate_web
else
errors.add(:receipt, 'platformが特定できませんでした。')
end
end
def save!
raise ActiveRecord::RecordInvalid, self unless receipt_valid && errors.blank?
to_model.save!
end
private
def validate_ios
self.data = Monza::Receipt.verify(raw_receipt)
receipt_check_for_ios
# レシートが既に発行されているかチェック
errors.add(:receipt, '既に使用されたレシートです。') if receipt_exists?
self.transaction_uuid = data.receipt.in_app[0].original_transaction_id if errors.blank?
true
end
def receipt_check_for_ios
# 本番 or テスト環境チェック
self.item = Item.find_by(store_item_uuid: data.receipt.in_app[0].product_id)
errors.add(:receipt, '不正なレシートが送信されました。') unless
data.environment.downcase == current_env &&
current_bundle? &&
item.present?
end
def validate_android
raise NoMethodError
end
# Credit
def validate_web
raise NoMethodError
end
def receipt_ios_exists?
Receipt.find_by(transaction_uuid: transaction_uuid).present?
end
# レシートがどの環境で発行されるステータスか確装
def current_ios_env
return 'production' if Rails.env.production?
'sandbox'
end
def current_ios_bundle?
Settings.ios.bundle_id == data.receipt.bundle_id
end
end
Problem
-
validate_#{platform}
でプラットフォームごとの依存処理を分割しているが、1つのクラスで決済別に処理してるため、責務が肥大化する一方になる。 - 決済情報が追加されたり、validationの方法が変更されたりすると冗長的なクラスになる。
- メソッド名が冗長的でもある。
def receipt_valid
case platform
when 'ios'
validate_ios
when 'android'
validate_android
when 'web'
validate_web
else
errors.add(:receipt, 'platformが特定できませんでした。')
end
end
detail
それぞれプラットフォーム毎にしか使わないメソッドが多い。
def receipt_ios_exists?
Receipt.find_by(transaction_uuid: transaction_uuid).present?
end
# レシートがどの環境で発行されるステータスか確装
def current_ios_env
return 'production' if Rails.env.production?
'sandbox'
end
def current_ios_bundle?
Settings.ios.bundle_id == data.receipt.bundle_id
end
Proposal - Divide responsibility
変更に強くするために、1つのクラスがもってる責務を分割する。
Bad Design
- ReceiptCreateForm::IOS
- ReceiptCreateForm::Android
- ReceiptCreateForm::Web
Why is it bad?
手続きはそれぞれ同じ手順のはずなので、一つのクラスで共通した手続きを行いたい。
Solution
以下のデザインパターンを応用することで依存している部分を分けて責務を分割する。
- Form Object
- 手続きと保存処理
- Value Object
- プラットフォームごとの依存している値等の整形
- Abstract Factory
- FormObjectの手続きをValueObjectに対して強制させる部分として利用
ValidateをValueObjectにも強制させたいので、Abstract factory パターンも適用して、依存している箇所も共通化を図ります。
ここからはiOSを例に進めていきます。
Good Design
せっかくなので名前も決済側で呼称する名前に変更。
仮にその他の決済ができても、ValueObjectクラスを追加すればOKであるはず。
- ReceiptCreateForm
- Receipt::IapValue (ValueObject)
- Receipt::IabValue (ValueObject)
- Receipt::CreditValue (ValueObject)
- Receipt::BaseValue (Abstract Factory)
改善後の手順
FormObject側ではプラットフォームを意識することなくReceiptCreateForm.new(value_object_instance)
などで呼べるようにする。
form = ReceiptCreateForm.new(value_object_instance)
form.validate
form.save!
Abstract Factory
Receipt::Base
を作成して子クラスに対して、実装を強制することで依存部分もある程度の共通化を行います。
ReceiptCreateForm
クラスからはValueObjectの validate
メソッドを実装するだけで、内容を意識することなく実行できるようにしています。
# frozen_string_literal: true
class Receipt
class Base
include ActiveModel
attr_reader :raw_receipt, :platform
attr_accessor :data
def initialize
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
def validate # フォームからValidationを実行する。
self.data = receipt_decode # 生レシートでは読見込めないので、復号作業を行う
self.item = item_search # サーバ側で消費型 or 非消費型等のレコードと紐付ける
verify # 検証する
end
private
def verify
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
def receipt_decode
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
def item_search
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
# テスト環境 or 本番環境の判定
def current_env
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
def current_bundle?
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
end
end
Value Object
主にレシートの内容を表示する、Validationを行うクラスです。
親クラスでは validate
メソッドは実装されているので、子クラスで実装の制約を受けている部分をoverrideして実装していきます。
補足
アプリから送られてくるレシートを決済ごとに問い合わせてレシート自体の検証を行ってから、デコードしてレシート内容の項目を検証していきます。
レシートの検証はmonzaを使い行っているので、詳細はそちらを参照にしてください。
# frozen_string_literal: true
# iOS Receipt Value Object
class Receipt
class IapValue < Base
def initialize(receipt)
@raw_receipt = raw_receipt
@platform = :ios
end
private
def verify
errors.add(:receipt, '不正なレシートが送信されました。') unless data.environment.downcase == current_env &&
current_bundle? &&
item.present?
errors.add(:receipt, '既に使用されたレシートです。') if receipt_exists?
end
def receipt_decode
Monza::Receipt.verify(raw_receipt)
end
def item_search
self.item = Item.find_by(store_item_uuid: data.receipt.in_app[0].product_id)
end
# テスト環境 or 本番環境の判定
def current_env
return 'production' if Rails.env.production?
'sandbox'
end
def current_bundle?
Settings.ios.bundle_id == data.receipt.bundle_id
end
end
def receipt_exists?
Receipt.find_by(transaction_uuid: transaction_uuid).present?
end
end
FormObject
総まとめとして最後にFormObjectがどうなったかの結果です。
- 各アプリ内課金と決済のレシート検証部分がそれぞれのValueObjectに移動させることができました。
- 可読性と一貫性をもたせることで、全体的な見通しがよくなりました。
before
# frozen_string_literal: true
require_relative 'monza'
class ReceiptCreateForm
include ActiveModel::Model
attr_accessor :raw_receipt, :platform, :transaction_uuid, :item, :data
validates :raw_receipt, :platform, :transaction_uuid, presence: true
def to_model
Receipt.new(raw_receipt: raw_receipt, platform: platform, transaction_uuid: transaction_uuid, status: :captured)
end
def receipt_valid
case platform
when 'ios'
validate_ios
when 'android'
validate_android
when 'web'
validate_web
else
errors.add(:receipt, 'platformが特定できませんでした。')
end
end
def save!
raise ActiveRecord::RecordInvalid, self unless receipt_valid && errors.blank?
to_model.save!
end
private
def validate_ios
self.data = Monza::Receipt.verify(raw_receipt)
receipt_check_for_ios
# レシートが既に発行されているかチェック
errors.add(:receipt, '既に使用されたレシートです。') if receipt_exists?
self.transaction_uuid = data.receipt.in_app[0].original_transaction_id if errors.blank?
true
end
def receipt_check_for_ios
# 本番 or テスト環境チェック
self.item = Item.find_by(store_item_uuid: data.receipt.in_app[0].product_id)
errors.add(:receipt, '不正なレシートが送信されました。') unless
data.environment.downcase == current_env &&
current_bundle? &&
item.present?
end
def validate_android
raise NoMethodError
end
# Credit
def validate_web
raise NoMethodError
end
def receipt_ios_exists?
Receipt.find_by(transaction_uuid: transaction_uuid).present?
end
# レシートがどの環境で発行されるステータスか確装
def current_ios_env
return 'production' if Rails.env.production?
'sandbox'
end
def current_ios_bundle?
Settings.ios.bundle_id == data.receipt.bundle_id
end
end
after
# frozen_string_literal: true
require_relative 'monza'
class ReceiptCreateForm
include ActiveModel::Model
attr_accessor :values
def to_model
Receipt.new(raw_receipt: values.raw_receipt, platform: values.platform, transaction_uuid: values.transaction_uuid, status: :captured)
end
def save!
raise ActiveRecord::RecordInvalid, value if value.errors.present?
to_model.save!
end
end
Yeah!!
だいぶ薄くなりましたね!
あとがき
ある程度自己流の部分があったが温かい気持ちで見てもらいたいです(汗)
- ValueObjectはServiceObjectといえなくもないかなと思いますが、データの加工などはしていないので、ValueObject風です
ここはこういうふうに修正すると良くなるよ!など、ありましたらどしどしお願いします。
おまけ
エンジニア以外の人へ伝えるために、寿司ネタに例えて説明をしました。
なぜ寿司かっていうと Rebuildのヘビーリスナーでhakさんのファンでもあるので、見習って強引に寿司に例えてみたのです。
寿司職人がいました。
寿司職人は、いつもの通りお客さんから注文を受けて寿司を握ります。
注文が入り、早速寿司を握りを提供するためにネタを仕込みからはじめました。
また別のお客さんから玉子の注文が入り、また寿司を握りを提供するためにネタを仕込みからはじめました。
寿司職人は困っていました。
※素材をアプリ内課金や決済方法の種類がたくさんあるという風に例えて説明。