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

ActiveSupport::CodeGenerator で遊ぼう

カバー画像: ActiveSupport::CodeGenerator で遊ぼう

この記事は、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 に記事を書いているのでそちらをごらんください。

developers.freee.co.jp

今回はアプリケーションをどう実装するかという点ではなく、それがどう動いているのかに着目したいと思います。

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.batchgenerated_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_patternsAttributeMethodPattern を追加する仕組みとして機能しているようです。(コード)。

コメントなどによると、これは特定のフィールド名の前後に特定の文字列を付与した状態で、メソッドを生成するためのパラメータのようです。

それにより定義された 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屋さんの皆さんはぜひごらんください。


  1. いつかこのトピックも扱います
  2. 項目が増えるような設計をするべきではないという議論がありそうですが、ここでは設計論は扱わないので例として見てください
  3. パフォーマンス改善のために CodeGenerator が実装されたコミット。v7.0.0 以前とは異なる実装をしていることがわかる
  4. v7.2.0 で導入された、ActionView の Helper に対する CodeGenerator 導入の例。こういうところで、チョットずつパフォーマンス改善されているので、バージョンアップは大事ですね。