この記事は、freee Developers Advent Calendar 2024 の3日目です。
bucyou (ぶちょー) と申します。普段は大阪は京橋にある関西拠点で freee販売の設計・実装を行っています。ちなみに、社内では freee販売を実際に利用して、ミニ店舗の運営などを行ったりなどをして日々製品づくりに研鑽しているところです。1
freee Developers Advent Calendar において他の方は技術以外のこともいろいろ書いてくれるような気がするので、私は技術の話をしたいと思います。この内容については、この前 kyobashi.rb という Ruby コミュニティのローカルイベントにて発表資料なしで挑んでいったトピックを実際に文字に起こしたものです。当日はコードを読んだり実行したりという内容が中心だったため、この機会に文章にしてまとめておきたいと思います。
この記事のコードは Ruby 3.3.0 上で Ruby on Rails 8 を動作させる場合を示しています。Struct
, Data
の扱いなどが古いバージョンで異なる場合がありますのでご注意ください。
モデルを定義したい
Rails アプリケーションで必要な概念 (ActiveRecord のモデルではないもの) をコード上で表現したいときはどうすればいいでしょうか?
Pure なクラス
最もシンプルなのは、class を定義してそこに必要な情報を持たせていくというものです。 Rubyの機能だけで作っているのでシンプルでよいのですが、項目が増えてくると大変つらいことになりそうです。2
# frozen_string_literal: true class Food attr_accessor :name, :expiration def initialize(name:, expiration_date:) @name = name @expiration_date = expiration_date end end udon = Food.new(name: 'うどん', expiration_date: 3.days.since.to_date) udon.expiration_date # Wed, 04 Dec 2024
また、特に制約を設けていないので、例えば賞味期限を表現したい expiration_date
は日付だけ持っていてほしいという制約をかけようと思うと
コンストラクタに諸々情報を書いたりする必要があり、やや面倒です。
Struct, Data を使う
もっとシンプルな入れ物を作りたければ、Struct を使うのも手でしょう。
# frozen_string_literal: true Food = Struct.new(:name, :expiration_date) udon = Food.new(name: 'うどん', expiration_date: 3.days.since.to_date) udon.expiration_date # Wed, 04 Dec 2024
または、Data という Ruby 3.2 から登場した新しいクラスを使ってもいいかもしれません。
# frozen_string_literal: true Food = Data.define(:name, :expiration_date) udon = Food.new(name: 'うどん', expiration_date: 3.days.since.to_date) udon.expiration_date # Wed, 04 Dec 2024
こちらは、メンバの値を直接には書き換えることができないので、イミュータビリティを維持したい ValueObject を扱う際に便利です。
udon.expiration_date = 4.days.since.to_date # undefined method `expiration_date=' for an instance of Food (NoMethodError)
ただし、どちらにせよ expiration_date
が日付だけを受け入れてほしいという状況に対応するには、
それぞれの定義時にブロックを渡して、コンストラクタの定義をかえるか、validation 用のメソッドを用意してやるなりを考える必要があります。
# frozen_string_literal: true Food = Data.define(:name, :expiration_date) do def initialize(**) super raise ArgumentError, 'expiration_date は日付じゃなきゃイヤ!!' unless self.expiration_date.is_a?(Date) end end udon = Food.new(name: 'うどん', expiration_date: nil) # expiration_date は日付じゃなきゃイヤ!! (ArgumentError)
ActiveModel を使う
モデルを扱うのであれば常に想定どおりの状態になっているという制約は非常に大事です。
一方で、定義するときはシンプルな表現がほしいものです。そこで、Rails にある ActiveModel::Attributes
を使いましょう。
# frozen_string_literal: true class Food include ActiveModel::Model include ActiveModel::Attributes include ActiveModel::Validations attribute :name, :string attribute :expiration_date, :date validates :expiration_date, presence: true end udon = Food.new(name: 'udon', expiration_date: 3.days.since) udon.expiration_date # Wed, 04 Dec 2024 udon.valid? # true udon = Food.new(name: 'udon', expiration_date: '2024-12-04') udon.expiration_date # Wed, 04 Dec 2024 udon.valid? # true udon = Food.new(name: 'udon', expiration_date: 'datarame') udon.expiration_date # nil udon.valid? # false
興味深いことに、expiration_date
をどんな形式で渡そうが、attribute
の第2引数で渡した型になろうとします。
無理であれば、nil
になります。
もうひと手間加えて、コンストラクタ時点で validate!
をするようにすれば、そもそも new
すら塞げるようになって
より安全になります。
オブジェクトができているということは制約が守られているということで非常に強力です。
# frozen_string_literal: true class Food include ActiveModel::Model include ActiveModel::Attributes include ActiveModel::Validations attribute :name, :string attribute :expiration_date, :date validates :expiration_date, presence: true def initialize(**) super validate! end end udon = Food.new(name: 'udon', expiration_date: 'datarame') # Validation failed: Expiration date can't be blank
ActiveModel::Attributes は便利
上記のような背景で、Rails のコード上でモデル表現を行いたいときには ActiveModel::Attributes
を使う場合があります。
アプリケーション上の使い方については、昨年の Advent Calendar に記事を書いているのでそちらをごらんください。
今回はアプリケーションをどう実装するかという点ではなく、それがどう動いているのかに着目したいと思います。
ActiveRecord を使っているときも、定義したフィールドのメソッドが動的に生えており、Rails を使い始めたとき (もう10年以上前のことになるが。。) なかなか驚きをもって迎えたのは覚えています。
他の言語やフレームワークでも、
- 呼び出されたメソッドがない場合の処理により実行される
- コードを文字列で生成してそれをソースコードとして読み取ろうとする
といったテクニックを見ますが、Rails の場合はどのように動いているのでしょうか? きっとなにか黒魔術のようなことをしているのでしょうか?
コンソールで生えてきたメソッドの正体を掴む
rails console (正確には irb) には、ls
という便利なコマンドが用意されています。これ利用して、先ほど生成した Food
のインスタンスを眺めてしましょう。
udon = Food.new(name: 'udon', expiration_date: 3.days.since) ls udon ... #<Module:0x000000011f81bcd8>#methods: expiration_date expiration_date= name name= ...
そうすると、興味深い表記として ActiveModel::Attributes
の特異メソッド attribute
によって定義された項目は、Moudule
により expiration_date
, expiration_date=
といったメソッドとして定義されているということがわかりました。
内部的にどんなことをやっているかを見てみましょう。今度は Food
クラス自体の ls
をしてみましょう。
ls Food ... ActiveModel::Attributes::ClassMethods#methods: attribute attribute_names ...
どうやら、特異メソッド attribute
は、ActiveModel::Attributes
で定義されているようですね。
ここから先は Rails のソースコード自体を参照します。
https://github.com/rails/rails/blob/v8.0.0/activemodel/lib/active_model/attributes.rb#L61
def attribute(name, ...) super define_attribute_method(name) end
ここで、define_attribute_method(name)
が呼ばれていることがわかりました。先ほどと同じ方法で探っていくと、define_attribute_method
は、ActiveModel::AttributeMethods
で定義されていることが突き止められます。 (コード)
def define_attribute_method(attr_name, _owner: generated_attribute_methods, as: attr_name) ActiveSupport::CodeGenerator.batch(_owner, __FILE__, __LINE__) do |owner| attribute_method_patterns.each do |pattern| define_attribute_method_pattern(pattern, attr_name, owner: owner, as: as) end attribute_method_patterns_cache.clear end end
これを読んでいくと、ActiveSupport::CodeGenerator.batch
が generated_attribute_methods
に対して、何らかのことをやっていそうであることがわかりました。
さらに、同モジュール内の generated_attribute_methods
を辿っていくと、以下のようなコードが見えます。 (コード)
def generated_attribute_methods @generated_attribute_methods ||= Module.new.tap { |mod| include mod } end
見る限り、Module
を新たに定義して、それを現状のスコープで include
するという内容のようです。
ここで1つわかったこととして、先ほど確認したインスタンスから見ることができた Module
というのは、これではないかと推測できます。
ActiveSupport::CodeGenerator.batch
にはここで生成された、Module
と、ファイル・行および Proc
を渡して何らかの Module
に対して作用させている雰囲気があります。
先程の define_attribute_method
に対するコードでは attribute_method_patterns
を each して、define_attribute_method_pattern
をやっています。
attribute_method_patterns
については、デフォルトで [ActiveModel::AttributeMethods::ClassMethods::AttributeMethodPattern.new]
が設定された配列のようです。(コード) ActiveModel::Attributes
では、attribute_method_suffix "=", parameters: "value"
という表記があり(コード)、attribute_method_patterns
へ AttributeMethodPattern
を追加する仕組みとして機能しているようです。(コード)。
コメントなどによると、これは特定のフィールド名の前後に特定の文字列を付与した状態で、メソッドを生成するためのパラメータのようです。
それにより定義された prefix を元に define_attribute_method_pattern
を行います。(コード)
オーバーライドがされている状況であったり、すでに内部的に用意されているメソッドがある場合などに対する実装がありますが、最終的に define_proxy_call
が呼ばれています。
更にその先まで眺めると、最終的に必要なパラメータを渡した「文字列でメソッドを作ろうとするコード」に出会います。(コード)
def define_call(code_generator, name, target_name, mangled_name, parameters, call_args, namespace:, as:) code_generator.define_cached_method(mangled_name, as: as, namespace: namespace) do |batch| body = if CALL_COMPILABLE_REGEXP.match?(target_name) "self.#{target_name}(#{call_args.join(", ")})" else call_args.unshift(":'#{target_name}'") "send(#{call_args.join(", ")})" end batch << "def #{mangled_name}(#{parameters || ''})" << body << "end" end end
ここまで読んでなんとなくわかったことというのは、ActiveSupport::CodeGenerator
というのは、一括で「文字列により作られたメソッド群」を解釈して、
渡された Module
などに定義していくものでありそうだということだということです。
少し後半の説明が文字文字しくなってしまいましたが、コンソールで ActiveSupport::CodeGenerator
を動かしてみて動作を見てみましょう。
ActiveSupport::CodeGenerator で動的にメソッドを生成する
現状 ActiveSupport::CodeGenerator
にはドキュメントがなく、:nodoc:
となっています。(コード)
このため、今までのコードリーディングの成果として、これを rails console 上で動作させてみましょう。
mod = Module.new.tap { |mod| include mod } ActiveSupport::CodeGenerator.batch(mod, __FILE__, __LINE__) do |owner| owner.define_cached_method('udon', namespace: :my_test) do |batch| batch << "def udon pp 'hoge' end" end end udon # "hoge" が出力される
rails console が実行しているスコープ上で定義したモジュールを即時で include して、CodeGenerator.batch
によりメソッドを定義していくというものです。
CodeGenerator.batch
の Proc の引数には、owner
というものが渡されこれは CodeGenerator
自体のインスタンスになります。
define_cached_method
は、CodeGenerator
インスタンスのメソッドで、第1引数はメソッド名で、(正確には canonical_name なので、内部的に利用する名前となる。衝突を避けるためにわざと機械的な名前にしても良さそう)、名前付き引数として、namespace
を取っています。
namespace
は内部的に同一のメソッドをキャッシュするために利用するキーになっていて、2回同じ処理が呼ばれたとしても同じ名前空間であれば最初の1回目の処理が利用されます。
mod = Module.new.tap { |mod| include mod } ActiveSupport::CodeGenerator.batch(mod, __FILE__, __LINE__) do |owner| owner.define_cached_method('udon', namespace: :my_test) do |batch| batch << "def udon pp 'hoge' end" end end ActiveSupport::CodeGenerator.batch(mod, __FILE__, __LINE__) do |owner| owner.define_cached_method('udon', namespace: :my_test) do |batch| batch << "def udon pp 'yobarenai' end" end end udon # "hoge" が出力される
これらのコードは、Rails 7 以前は、ActiveModel
の中に実装されていたコードのようですが、パフォーマンス改善のため3に導入されました。現在では、ActionView
でもこの共通の仕組みが利用されています。4
共通化されているので、ActiveSupport::CodeGenerator
を単体で使うことも可能で、メソッドを動的に生やしていく必要のある DSL を定義するときなどに役に立つかもしれないなどと思い立っています。
Rails コードリーディングは楽しい
ということで、今回は Rails に実装されたメタプログラミングのテクニックを見てみました。これが実用的にどう役に立つか? というのは今のところわかりませんが、 時々、ドキュメントに書いていないような不思議な動きを探検していく作業は、なかなか刺激的で面白いものです。
kyobashi.rb では、Rails の面白い実装をポジティブに紹介していこうと思っていますので、ぜひ大阪にお立ち寄りの際にタイミングが合えばお越しください!
明日の Advent Calendar は、てららさんによる「OAuth/OIDCのClient管理を"型"で制する - freee_client_typeの軌跡」です。ID屋さんの皆さんはぜひごらんください。
- いつかこのトピックも扱います↩
- 項目が増えるような設計をするべきではないという議論がありそうですが、ここでは設計論は扱わないので例として見てください↩
- パフォーマンス改善のために CodeGenerator が実装されたコミット。v7.0.0 以前とは異なる実装をしていることがわかる↩
- v7.2.0 で導入された、ActionView の Helper に対する CodeGenerator 導入の例。こういうところで、チョットずつパフォーマンス改善されているので、バージョンアップは大事ですね。↩