こんにちは。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の時点で型の整合性が取れているかをチェックしてくれて、下手なコードを書きにくいようにしてくれます。これは大変便利です!
過去に以下の記事も公開されているので確認してみてください。
一方で、私たちの言う「型を意識した開発」というのは、「ビジネスを抽象化し、モデルを作る過程で制約を与えていく作業」と言ったほうが良いかもしれません。
型を考える
値を管理するにあたって、さまざまな制約があるとは思います。単純に「数値」とか「文字列」というものがとりあえずは浮かぶと思いますが、実際に業務アプリケーションを扱うと以下のような型が浮かび上がります。
- 「量」とは「整数部と小数部合わせて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 上でのモデルと、アプリケーションを表現するためのモデルを分けているという話は、技術の本でもトピックとして記載しています。
例として、以下のようなモデルを表現したいとします。
単純な値の入れ物であれば、こういう書き方はあるとは思いますが、制約が全く与えられていません。 これでは、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
における、type
は ActiveModel::Type::Value
1 を拡張したクラスを指定する形式になります。
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 は明日も続きます! 明日は、グローバルチームでの開発の取り組みをお送りするのとです。