はじめに
STORES 予約でエンジニアをやっている@ucksです。 大体年内にやりたかったタスクが捌けてきたので空いた時間でブログを書いています。
Railsからのメール送信でお世話になるAction Mailer。 インスタンスメソッドを定義しているのにクラスメソッドから呼び出しをしています。 どの様な仕組みになっているのか不思議に思ったことはないでしょうか。
様々な方法で、OSSのソースコードを追うことがあると思いますが、今回は追い方の一例として、実際のAction Mailerのロジックを追いながら、どの様な仕組みになっているのか、参考になるロジックはあるのかを、探索したり、拡張できないかを探ってみようかなと思います。
実際に追ってみると、Rubyらしいコードを見ることができたので、普段別の言語を使っている人も是非見ていってください。 私も当時を思い返しながら、もう一度ブログを書き直しながら追い直してみます。
Action Mailerのロジックを追うことになったきっかけ
冒頭でも述べましたが、私は初めてAction Mailerを見た時に、インスタンスメソッドを定義しているのにクラスメソッドで呼び出されているのが不思議でした。 しかし、私は面倒くさがりなので何か動いてるしまぁいいやとその時は特に深追いしていませんでした。
class UserMailer < ApplicationMailer default from: "notifications@example.com" def welcome(to:) mail( to:, content_type: 'text/plain', subject: 'ようこそ', body: 'ようこそ', ) end end UserMailer.welcome(to: 'user@example.com').deliver_now # なぜnewしてないのにwelcomeが呼べるの?
では、いつロジックを追うことになったかと言うと、私がまだネットショップの開発していた際に、なぜか毎週木曜日にメールの配信が失敗する問題が発生していました。 そのため、メールの配信ロジックに何かしらの改修を行い、失敗をリカバリーする必要がありました。
当時は、Sidekiqで非同期にメールを送っているものもあれば、様々な理由で同期的にメールを送っているものもありました。 非同期で送っているものは、Sidekiqのリトライ機能でリカバリーされるので気にする必要はありません。 しかし、同期的なメールは若干記憶は曖昧ですが、在庫通知等はシビアで、メールという仕組み上どれだけ頑張っても限界はありますが、メールが届いた時に在庫が切れていなかったり、特定の人(序盤にキューにつまれた人)にだけ届くとクレームになるという問題等もあり、ただ単純に非同期キューに詰め込んでリトライさせれば良いという問題でもありませんでした。
と言うわけで、同期的にメールを送っているところはある程度同期的にリトライをかけていく必要がありました。 ですが、同期的にメールを送っている箇所が多く、短期間で全ての箇所にリトライ処理を仕込んでいくと言うのは、当時の状況では困難でした。 そのため、Action Mailerに何かしらのモンキーパッチを当てて一旦凌げないか調査を始めたのがきっかけです。
# これは面倒臭い - UserMailer.welcome(to: user.email).deliver_now + begin + UserMailer.welcome(to: user.email).deliver_now + rescue + if (try = (try || 0) + 1) < 3 + sleep try + rand(try) # 少しだけ間隔を空けてリトライさせる + retry + end + raise + end
Rubyはソースコードを追うのが簡単?
Rubyはスクリプト言語ということもあり、IDEのコードジャンプ等で目的のメソッドの定義を見つけられないことがあったり、モジュールを使って拡張されていたりするので、思っていたロジックとは別のロジックが動いていたりすることがあり、闇の魔術的なメタプロみたいな事が多く、ソースコードを追うのが難しいと思っていた時もありました。
Rubyでロジックの定義を探すときには、便利なメソッド Object#method
があります。
このメソッドは、引数の名前のメソッドをMethodオブジェクトとして返してくれ、Procの様に利用することができます。
また、Method#source_location等の、メソッドの定義場所を返してくれる機能が用意されています。
例えば、ActiveModelを継承したUserクラスがあり、そのfindメソッドは、下記の様に、定義場所を取得することができます。
User.method(:find) => #<Method: #<Class:User (call 'User.load_schema' to load schema informations)>(ActiveRecord::Core::ClassMethods)#find(*ids) /bundle/ruby/3.3.0/gems/activerecord-8.0.1/lib/active_record/core.rb:259> User.method(:find).source_location => ["/bundle/ruby/3.3.0/gems/activerecord-8.0.1/lib/active_record/core.rb", 259]
activerecord
というgemの 8.0.1
というバージョンの lib/active_record/core.rb
の259行目に定義されているということがわかります。
(ちなみに環境用意するのが面倒なので、STORES 予約の開発環境のコンソールに入って↑を叩いています。結構新しめのバージョンに追従できているのがわかると思うので、ここアピールポイントです。)
gemのソースコードの場序を探すにはどうしたら良いかというと、RubyGemsに行き、gemの検索をかけ、対象のgemを見つけ、ソースコードのリンクを押下すると、大体目的のgemのソースコードが置かれているリポジトリに辿り着けます。
あとは、バージョンを合わせてファイル名を Go to file
等に打ち込むと目的のロジックに辿り着くことができます。
Action Mailerでも同じ様に辿り着けないか試してみましょう。
> UserMailer.method(:welcome) => #<Method: UserMailer.welcome(*)> > UserMailer.method(:welcome).source_location => nil
なん…だと…
残念ながら見つけられませんでした。
仕方がないので、ちょっと違う方向から攻めてみましょう。
UserMailer.welcome
した時に、返ってきているオブジェクトのクラスが分かれば、そのクラスに何かしらのhookを差し込めれば良いわけです。
UserMailer.welcome
した時に返ってくるオブジェクトを確認してみましょう。
UserMailer.welcome(to: 'user@example.com').class => ActionMailer::MessageDelivery
当時は巡り巡って UserMailer#welcome
で返ってくるオブジェクト、恐らく #mail
が返すオブジェクトが返ってきているのだろうなと思いつつ、確かめたら違うオブジェクトが返ってきていて、 ( ੭⌯᷄ω⌯᷅ ).。oஇ となっていた記憶があります。
UserMailer.new.welcome(to: 'user@example.com').class => Mail::Message
ちなみに、 #deliver_now
は、ここに定義されている様です。
UserMailer.welcome(to: 'user@example.com').method(:deliver_now) => #<Method: ActionMailer::MessageDelivery#deliver_now() /bundle/ruby/3.3.0/gems/actionmailer-8.0.1/lib/action_mailer/message_delivery.rb:123>
RubyGemsからactionmailerで検索をかけ、actionmailerのソースコードのリンクを踏み、バージョンを合わせて、ファイル名を入れてあげると #deilver_now
のメソッドの定義場所を見つけることができます。
この時点で、 ActionMailer::MessageDelivery#deliver_now
にモンキーパッチを当てて、リトライされる様にすれば、当初の目的を達成することは可能です。
# /config/initializers/synchronous_email_retry_deliverable.rb module SynchronousEmailRetryDeliverable def deliver_now(times: 3, ...) super rescue Net::ReadTimeout if (try = (try || 0) + 1) < times sleep try + rand(try) # 少しだけ間隔を空けてリトライさせる retry end raise end end ActionMailer::MessageDelivery.prepend(SynchronousEmailRetryDeliverable)
#deliver_now
をオーバライドしてしまうと何が起こるかわからないので、適当な別のメソッドを作っておいて、#deliver_now
を一括置換する方が無難かもしれません。
# /config/initializers/synchronous_email_retry_deliverable.rb module SynchronousEmailRetryDeliverable def deliver_sync(times: 3, ...) deliver_now(...) rescue Net::ReadTimeout # 通知は入れても良いかも if (try = try || 0 + 1) < times sleep try + rand(try) # 少しだけ間隔を空けてリトライさせる retry end raise end end ActionMailer::MessageDelivery.prepend(SynchronousEmailRetryDeliverable)
PR作って1個1個Mailerからのメソッドチェーンで呼び出されているかを確認する簡単な作業なので、全ての呼び出し箇所にリトライ処理を入れていくよりは、簡単な作業のはずです。#deliver_now
なんてメソッド、きっとメールの送信くらいでしかつけない名前なので、多分大丈夫でしょう。
Before
- UserMailer.welcome(to: 'user@example.com').deliver_now + begin + UserMailer.welcome(to: 'user@example.com').deliver_now + rescue + if (try = (try || 0) + 1) < 3 + sleep try + rand(try) # 少しだけ間隔を空けてリトライさせる + retry + end + raise + end
After
- UserMailer.welcome(to: 'user@example.com').deliver_now + UserMailer.welcome(to: 'user@example.com').deliver_sync
と言うわけで、本来の目的自体はこの時点で達成しているのですが、インスタンスメソッドを定義して、クラスメソッドから実行する仕組みがどの様になっているのか気になるところなので、もう少し深掘りしてみます。
Action Mailer のロジックを追ってみる
一旦現状を整理してみます。
UserMailer.welcome
のソースコードが見つけられないUserMailer.welcome
はActionMailer::MessageDelivery
インスタンスを返すUserMailer
はActionMailer::Base
(ApplicationMailer
) のサブクラス
ActionMailer::MessageDelivery
インスタンスを返しているので、 MessageDelivery.new
を呼び出しているはずです。
なので rails リポジトリの内を MessageDelivery.new
で検索してみます。
すると、下記の2箇所が引っかかりました。
- https://github.com/rails/rails/blob/v8.0.1/actionmailer/lib/action_mailer/parameterized.rb#L119
- https://github.com/rails/rails/blob/v8.0.1/actionmailer/lib/action_mailer/base.rb#L630
片方は ActionMailer::Base
なので怪しいので見てみます。
def method_missing(method_name, ...) if action_methods.include?(method_name.name) MessageDelivery.new(self, method_name, ...) else super end end
どうやら、これの様です。
当時は、普通に機能開発をしていたら中々使わない機能なので、#method_missing
の存在を忘れていました。
このメソッドは、呼び出されたメソッドが定義されていなかった時に、呼び出されるメソッドです。
つまり、呼び出したメソッドが定義されてなかったら、このメソッドが呼ばれ、 ActionMailer::Base
の場合は、 action_methods
に
method_name.name
が含まれていたら、 ActionMailer::MessageDelivery
インスタンスが返る様になっています。
今の知識だったら、 #method_missing
を呼び出して同じインスタンスが返ってくるか見るかもしれません。
#method
を呼び出せば定義場所も簡単に探すことができます。
# privateメソッドなので `#send` で呼んでみる UserMailer.send(:method_missing, :welcome).class => ActionMailer::MessageDelivery UserMailer.method(:method_missing) => #<Method: UserMailer(ActionMailer::Base).method_missing(method_name, *, ...) /bundle/ruby/3.3.0/gems/actionmailer-8.0.1/lib/action_mailer/base.rb:628>
#method_missing
の中で呼び出している action_methods
については、 ActionMailer::Base
の親クラスの AbstractController::Base
や AbstractController::UrlFor
を追っていただくと、詳細のロジックがわかるのですが、追って説明するのは大変なので、早い話、インスタンスメソッドの一覧を返してくれるものと思えば大丈夫だと思います。
つまり、Mailerに定義されているメソッドであれば、メソッド名が渡された ActionMailer::MessageDelivery
インスタンスが返ってくる様になっています。
そして、 #deliver_later
を呼んだ時には、配信用のキューにエンキューされたり、
def deliver_later(options = {}) enqueue_delivery :deliver_now, options end
#deliver_now
を呼んだ時には、同期的にメールを送る処理を行っています。
def deliver_now processed_mailer.handle_exceptions do processed_mailer.run_callbacks(:deliver) do message.deliver end end end
ちなみに、 #deliver_now
のロジックも追っていくと見かけ以上に複雑になっています。
どういう順番でメソッドが呼ばれ、messageが何で、messageはどこで更新されているのか等を追ってみると面白いかもしれません。
また、なぜこの様なロジックになっているのかについて、私は知りませんが、メールを送る際に一度、非同期のキューに詰め込むため、Webのプロセスで無駄なインスタンスを生成しない様にしたり、過去の互換性を保ちつつ色々拡張してきた結果なのかなと思っていたりします。 Ruby界隈では、今回の様なgemを作った人がカンファレンス等でその辺りを歩いていたりするので、話のネタにしてみてはいかがでしょうか。
さいごに
今回紹介した様に、Rubyには #method_missing
の様に、メソッドが存在しない時に呼び出されるメソッドがあったり、他の人の定義したクラスをモンキーパッチで後から拡張したりすることが容易にできます。
また、今回紹介した以外にも動的にメソッドを生成したり、クラスやインスタンスを評価する様なメソッドもあります。
Rubyには、今回の様にうまく使えば後からでもロジックを差し込めたり、挙動を変えたりすることができる闇の魔術の様な機能があります。
正しく使えば非常に強力ですが、使い方を誤ると保守が難しくなったり、諸刃の剣的な側面も強いです。
Rubyを使って開発していると、このロジックはどうなっているのか、このgemで用意されているこのメソッドを呼んだ時にアレやコレをしたい、と思うことが多々あると思います。 今回紹介した手法を使って、ロジックを追ってみたり、機能拡張をしてみたりしてみてください。 あわよくば、この様な機会からOSSに足を踏み込んで、何かに貢献できるかもしれません。
この様に、私は社内で画面共有とかしながらこういうロジックを追いかけてたりすることが年に何度かあります。 あまりOSSのコード追ったことないという人がいたりして、こういう感じで勉強になるよと誘ったきり、全然別部署になって話す機会がなかった人とかもいたりするので、今回懺悔としてブログを書いてみました。