freeeの開発情報ポータルサイト

型を意識した Ruby on Rails 上のモデル

こんにちは。freee販売を大阪で開発しております、bucyou (ぶちょー) というものです。

クラス設計のイメージ

この記事は、freee Developers Advent Calendar 2023 の10日目です。

例によって普段は Ruby on Rails で開発をしておりますが、ここでのアーキテクチャや、モデリングに関する話題は、先日 freee技術の本 に書かせていただきました。 また、先日は TechNight にて、freee販売での取り組みを紹介させていただきました。

この本や、イベントの中で伝えたかったメッセージとしては、以下のようなものでした。

  • 実現したいビジネスを、より適切に表現するためにモデリングを重視していく。
  • ActiveRecord によって作られたモデルと、ビジネスを表現するためのモデルをしっかり分けていく。

しかし、本はみんなで書いているものだったのでページ数はある程度制限されており、泣く泣く削った内容がありますし、イベントのような場では具体的なコードを示していくのはどうしても難しいです。

ということで、freee技術の本「第 3 章 Takoyaki Rails アーキテクチャ概論」補論という形で、具体的なHowをまとめていきます。

型を意識した開発

Ruby で「型を意識した開発」というと、Sorbet を活用した開発がぱっと思いつくでしょう。freee社内でも、Sorbet は一部の製品で導入されており、CIの時点で型の整合性が取れているかをチェックしてくれて、下手なコードを書きにくいようにしてくれます。これは大変便利です!

過去に以下の記事も公開されているので確認してみてください。

developers.freee.co.jp

一方で、私たちの言う「型を意識した開発」というのは、「ビジネスを抽象化し、モデルを作る過程で制約を与えていく作業」と言ったほうが良いかもしれません。

型を考える

値を管理するにあたって、さまざまな制約があるとは思います。単純に「数値」とか「文字列」というものがとりあえずは浮かぶと思いますが、実際に業務アプリケーションを扱うと以下のような型が浮かび上がります。

  • 「量」とは「整数部と小数部合わせて8桁以下、小数部は3桁までの符号ありの数値である。0を許容しない。」
  • 「単位」とは、「最大10桁の文字列である」

「量」という型があったとき、100 のような数値は扱えますが、10000.123 のような数値は9桁となっており制約違反のためエラーとしたいです。

軽く Rails でアプリケーションを作ろうとすると、とりあえず :decimal として扱ってバリデーションルールなどで制約を与えていくことを考えたくなりますが、上記のような制約が型として扱えるとなると、そもそもメモリ上には制約違反のデータが存在しにくくなるので、より安全になります。

型を扱うクラスを定義する

型を扱うには、制約をチェックする機構や、同じ型どうしの値が一致しているかどうかをチェックするような機構を用意する必要があります。

まずは、上記で扱っている「量」を表現できる型を用意していきましょう。

値を1つだけ取り扱う型は、最終的に Ruby がデフォルトで取り扱える型に行き着きます。このため、これを扱うためのモジュールを用意しておきます。

module Values::PrimitiveWrapper
  extend ActiveSupport::Concern

  def initialize(value)
    @value = value
  end

  # 値が制約違反だった場合に発生させるエラー
  def raise_invalid_value(message = nil)
    raise ArgumentError, [message || 'invalid value object', inspect].join('; ')
  end

  def serialize
    @value
  end

  def ==(other)
    @value == (other.try(:value) || other)
  end

  def <=>(other)
    @value <=> (other.try(:value) || other)
  end

  def eql?(other)
    instance_of?(other.class) && self == other
  end

  def hash
    @value.hash
  end

  def as_json(_)
    value
  end

  included do
    delegate_missing_to :@value
    delegate :to_s, :to_json, to: :value
    attr_reader :value
  end
end

値が wrap されていたとしても、あまり意識せず利用できる工夫がされていますね。ActiveSupport にある、delegate_missing_to を利用すると、そのクラスに存在しないメソッドが呼び出されたとしても、value 側のメソッドに移譲するといったことができます。 Struct と一緒に利用すると便利なので、比較的、販売のコードではよく使われるテクニックです。

さらに、数値全般を扱うためのモジュールを用意しておきます。

module Values::Numeric
  include Values::PrimitiveWrapper
end

これについては、型のカテゴリを表現するためのモジュールです。Numeric については、特に機能を持っていません。 整数値しか扱わないようにするための Values::Integer もあるのですが、そちらには initialize のときに渡された値を to_i により変換する機能を用意しています。

class Quantity
  include ::Values::Numeric

  def initialize(value)
    super value.to_d
    ensure_valid_value!
  end

  private

  # 桁数のチェック
  def ensure_valid_value!
    # 小数部が3桁以下がどうか
    raise_invalid_value 'out of range' if value >= 1e+8 || value <= -1e+8 || value != value.floor(3)
    # ゼロは許容しない
    raise_invalid_value 'out of range' if value.zero?
    # 整数部・小数部合わせて8桁以下かどうか
    raise_invalid_value 'out of range' if ('%.11g' % value).sub('.', '').sub('-', '').length > 8
  end
end

これにより、量を扱う表現ができました。 Values::PrimitiveWrapper により、オブジェクトと、通常の数値での比較が行えるようになっているので、わざわざオブジェクトを作らなくてもよいと言う意味で、シンプルなコードに留められます。

q1 = Quantity.new(10.0) 
q2 = Quantity.new(10.0) 
q1 == q2 # true
q1 == 10.0 # true

q3 = Quantity.new(10.1)
q3 > q1 # true

Quantity.new(10000.1234) # ArgumentError
Quantity.new(1234567.89) # ArgumentError

ActiveModel::Attribute で利用する

実際には、作成した型をモデル上で利用します。 データベースの読み書きのための ActiveRecord 上でのモデルと、アプリケーションを表現するためのモデルを分けているという話は、技術の本でもトピックとして記載しています。

例として、以下のようなモデルを表現したいとします。

明細行 (Line) のクラス図, 摘要・単価・量・単位情報を持つ
明細行クラス図

単純な値の入れ物であれば、こういう書き方はあるとは思いますが、制約が全く与えられていません。 これでは、quantity に数値以外も入れられてしまいます。

Line = Struct.new(:description, :unit_price, :quantity, :unit_name)
Line.new('うどん', 50, 1, '')

Line.new('うどん', 50, '', '') # 数値以外も量で扱えてしまう

では、クラスを作り、コンストラクタで数値を渡しても、今回作った型 Quantity によるオブジェクトを渡してもいい感じに処理しつつ、型違反があったらエラーになり、「quantity」を参照したら、Quantity のインスタンスとして扱えるといういい感じの機構を考えたいですが、イチから作るのは結構骨が折れます。

そんな、ワガママに答えてくれるのが、ActiveModel::Model と、ActiveModel::Attributes です。 前者は、ActiveRecord にあるような、validation 機構や、コンストラクタの機構を提供してくれます。 Attributes は、値の型などの定義を管理してくれる優れものです。しかも、ActiveRecord のようにデータベースと一体して管理される訳ではないので、Rails 上で「データベース非依存」「型を意識する」といった、モデルを扱いたいケースに強力に対応してくれます。

とりあえず、今回作成した型は無視して、attribute の第2引数には、:string, :integer といった、予め用意されている型を渡します。

# 明細行
class Line
  include ActiveModel::Model
  include ActiveModel::Attributes

  # 摘要
  attribute :description, :string
  # 単価
  attribute :unit_price, :integer
  # 量
  attribute :quantity, :integer
  # 単位
  attribute :unit_name, :string
end
line = Line.new(description: 'うどん', unit_price: 50, quantity: 10, unit_name: '')
line.quantity # 10

# 仮に文字列で渡しても attribute の第2引数の作用により integer にキャストされる
line = Line.new(description: 'うどん', unit_price: 50, quantity: '10', unit_name: '')
line.quantity # 10

# 制約がかかっているわけではないので「一」を渡してしまうと、内部的には `to_i` が走るだけなので、
# 0となる
line = Line.new(description: 'うどん', unit_price: 50, quantity: '', unit_name: '')
line.quantity # 0

数値か文字列かについて適切扱えるようになった感はありますが、もう一歩ですね。

attribute の第2引数で渡せる型については、ActiveModel::Type.register(name, type) を呼び出すことで、拡張することができます。register における、typeActiveModel::Type::Value1 を拡張したクラスを指定する形式になります。

type を構築するときに便利になる機構を用意しておきます。

module Values::Type
  extend ActiveSupport::Concern

  # データ保存時などに利用される
  def serialize(subject)
    subject.try(:value) || subject
  end

  # モデル構築時に利用される
  def deserialize(value)
    cast(value)
  end

  def cast_value(value)
    cast(value)
  end

  module_function

  def define_type(klass, namespace: nil)
    Class.new(ActiveModel::Type::Value) do
      include Values::Type

      define_method(:type) do
        [namespace, klass.name.demodulize.underscore].compact.join('/').intern
      end

      define_method(:cast) do |value|
        # nil は nil のまま扱う
        return nil if value.presence.nil?

        case value
        # すでにオブジェクトを利用しているのであればクローン
        when klass then value.clone
        # そうではない場合、当該クラスからオブジェクト生成
        else klass.new(value)
        end
      end
    end
  end
end

Quantity では、この機構を利用して Type を用意しておきます。

class Quantity
  Type = Values::Type.define_type(self)
end

あとは、ActiveModel::Type.register(name, type) を呼んでやればいいだけです。

ActiveModel::Type.register(:quantity, Quantity::Type)

先程つくった、Line に手を加えてみましょう。:quantity という型が指定できるようになっていることにご注目ください。

# 明細行
class Line
  include ActiveModel::Model
  include ActiveModel::Attributes

  # 摘要
  attribute :description, :string
  # 単価
  attribute :unit_price, :integer
  # 量
  attribute :quantity, :quantity
  # 単位
  attribute :unit_name, :string

  # ActiveModel は、attribute 参照のタイミングでキャストを行う
  # コンストラクタで attributes を呼び出すことで、キャスト失敗をモデル生成時点で発生させる
  # 実際には、これを行う Entity モジュールを作って使い回すなどしている
  def initialize(...)
    super
    attributes
  end
end
line = Line.new(description: 'うどん', unit_price: 50, quantity: 10, unit_name: '')
line.quantity #<Quantity @value=0.1e2>

# 仮に文字列で渡しても attribute の第2引数の作用により integer にキャストされる
line = Line.new(description: 'うどん', unit_price: 50, quantity: '10', unit_name: '')
line.quantity #<Quantity @value=0.1e2>

# quantity として受け入れられない値を入力したので、エラーとなる
line = Line.new(description: 'うどん', unit_price: 50, quantity: '', unit_name: '')

ご安全に!

これで、制約を外れている場合にエラーを発生させるという挙動ができました。

もちろん入力される値は別途バリデーションされた上で、モデル生成のために利用されるのですが、バリデーション漏れを起こしていたとしても、仕様以外のものが入ってくるのを防ぐ作用を持っているため、安心な要素が増えます。

実際には今回紹介した、単一の値を扱うケースだけでなく、複数の値を組み合わせた Composed や、列挙型である Enum などを独自に実装することで、仕様上の表現を安全にモデルで扱えるようにする仕組みを入れています。

Advent Calendar は明日も続きます! 明日は、グローバルチームでの開発の取り組みをお送りするのとです。