4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

デザインパターンの話をしよっか ( FormObject & Value Object 編 )

Last updated at Posted at 2018-08-29

ValueObjectとFormObject編

社内向けに作った資料ですが、稚拙な設計でも世の中に役に立つと思い、公開に踏み切りました。
本記事を読むには、決済(iOS, Android, クレジットカード)に関するドメイン知識も必要ですが、デザインパターンの話を中心に書いています。

前提

以下はすべてRubyとRailsを使った説明をしています。
考え方自体は他の言語で置き換えられると思うので、ご参考までにお読みください。

GOAL

アプリ内課金した結果をサーバー保存してユーザーに課金した内容をサーバー側で付与したい。
今回は保存の部分まで説明します。

手順

  1. レシートを発行する(アプリ)
  2. レシートをアプリから受け取りが正しいか検証する。(サーバー)
  3. 検証が完了したら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さんのファンでもあるので、見習って強引に寿司に例えてみたのです。

寿司職人がいました。

寿司.001.jpeg

寿司職人は、いつもの通りお客さんから注文を受けて寿司を握ります。

寿司.002.jpeg

注文が入り、早速寿司を握りを提供するためにネタを仕込みからはじめました。

寿司.003.jpeg

また別のお客さんから玉子の注文が入り、また寿司を握りを提供するためにネタを仕込みからはじめました。

寿司.004.jpeg

寿司職人は困っていました。

※素材をアプリ内課金や決済方法の種類がたくさんあるという風に例えて説明。
寿司.005.jpeg

寿司職人の本音は握ることに専念したい。

※ 大体の寿司はシャリとネタを握るだけですよね。
寿司.006.jpeg

一人で寿司を握るのではなくて、弟子(value Object)を作ればいいんだ!

寿司.007.jpeg

ネタ毎に弟子(Value Object)を割り当てて仕込み方法を教えよう

※ 責務の分割を弟子に例える。
寿司.008.jpeg

寿司職人が困ってた世界線(before)

寿司.009.jpeg

寿司職人が弟子ができた世界線(after)

寿司.010.jpeg

4
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?