テーマ
初Adventです!
最近、メール送信時のトラブルを分析していたのですが、ActionMailerの実装についての情報がなかなか見つからず困りました。
この機会に、ActionMailerの実装を掘り下げ、「メールを送信したら内容がSlackに届く」というSlackMailerのサンプルに挑戦したいと思います。
なぞのおじさん
あらためてrailsを支えるコンポーネント軍団を眺めてみると、それはそれはツヨそうなメンバーが揃っています。
ActionPackやActiveRecordといったフレームワークを動かす最重要幹部たち、ActiveSupportという有能な参謀。さらにはActionCableやActiveStorageのような期待の大型新人もいます。
よく見るとわれらがActionMailerも「地味な窓際おじさん」といった風情で鎮座しておられますね。
いまひとつ注目されないこの古株おじさんを、愛と尊敬を持って観察してみましょう。
動きを観察する
このActionMailerおじさん、実はかなりミステリアスな技を持っています。
さっそく適当にSampleMailerクラスを作ってみましょう。
# app/mailers/sample_mailer
class SampleMailer < ActionMailer::Base
def hello(user) # emailを持つUserモデルを用意しておく
@user = user
mail(from: 'xxx@example.com' ,to: user.email, subject: "Hello")
end
end
ここにあるように、ActionMailer::Baseを継承したSampleMailerクラスに#hello
メソッドを書くだけ。簡単ですね。
対応するビューもつくっておきます。
# app/views/sample_mailer/hello.text.erb
<%= "Hello #{@user.nickname}" %>
This is a test mail.
すると、簡単にメールを作成できます。
pry(main)> user = User.first # 適当なUserオブジェクトを入れておく
pry(main)> mail = SampleMailer.hello(user)
[INFO ] Rendering sample_mailer/hello.text.erb
[INFO ] Rendered sample_mailer/hello.text.erb (1.8ms)
=> #<Mail::Message:70193536029860, Multipart: false, Headers: <From: xxxxx@example.com>, <To: yyyyy@example.com>, <Subject: Hello>, <Mime-Version: 1.0>, <Content-Type: text/plain>>
まあ、ここまでは普通です・・よね。
いやいや。
メソッド違い?
先ほどSampleMailerクラスに定義したのはインスタンスメソッドでした。
しかしSampleMailer.hello
で呼ばれるのは、SampleMailerのクラスメソッドのはずです。
おかしいと思いませんか?
試しに、似たような使い方をするコントローラーを見てみましょうか。
pry(main)> UsersController.instance_methods(false) # UsersController#showを定義
=> [:show]
pry(main)> UsersController.show # エラー
NoMethodError: undefined method `show' for UsersController:Class
当然エラーですw
なぜメールだけはクラスメソッドで呼ぶ(呼べる)のでしょう?
さらにもうひとつ
hello
メソッドはActionMailer::MessageDeliveryインスタンスを産み落とします。
pry(main)> mail.class
=> ActionMailer::MessageDelivery
これはおそらく、先程書いたメソッドの最後にある#mail
メソッドが、テンプレートhello.text.erb
をベースに作成したメールオブジェクトを作り出しているものと思われます。
試しに、「まったく何もしないメソッド」を定義してみましょうか。
class SampleMailer < ActionMailer::Base
def nop
end
end
呼び出してみます。
pry(main)> empty_mail = SampleMailer.nop
pry(main)> empty_mail.class
=> ActionMailer::MessageDelivery
あれ・・・さっきと同じクラスのオブジェクトだ。
中で何もしてないのになぜ返ってくるの?
ちなみに、あえてインスタンスメソッドとして#nopを呼んでみると
pry(main)> empty_mail2 = SampleMailer.new.nop
pry(main)> empty_mail2
=> nil
期待通りにnil
が帰ります。
ねえ、おじさん!?
私のメールに何をしてるの?
ActionMailerマジック
おじさんの真の姿は黒魔術に守られていて、pryの力を持ってしてもなかなか正体が見えてきません。
というわけで、ソースコードから解読していきましょう。
まずSampleMailerのベースクラスであるActionMailer::Baseの実装を見てみます。
我々が叩いたのはクラスメソッドのはず、ですよね。
module ActionMailer
class Base < AbstractController::Base
# モジュールのインクルード、クラス変数の宣言など
class << self
# snip
def method_missing(method_name, *args)
if action_methods.include?(method_name.to_s)
MessageDelivery.new(self, method_name, *args)
else
super
end
end
def respond_to_missing?(method, include_all = false)
action_methods.include?(method.to_s) || super
end
end
# snip
おまえかーーー!!
いきなりダークそうなやつが潜んでいました。
見ての通り、メソッド名method_name
が、action_methods
内に定義されていれば、ActionMailer::MessageDeliveryをnewして返しています。1
継承元AbstractController::Baseに定義されているaction_methods
は、SampleMailerに生えているpublicなインスタンスメソッドをまとめてSet
に入れて戻してくれます。 2 3
つまり今回のケースだと、ないはずのクラスメソッドを呼び出した時に、"nop"
、"hello"
という文字列のいずれかと一致していたら、ActionMailer::MessageDeliveryインスタンスが戻ってくるという仕組みです。
pry(main)> SampleMailer.action_methods
=> #<Set: {"nop", "hello"}>
MessageDelivery.new(self, method_name, *args)
の引数部分については(例えばSampleMailer.hello
が呼ばれたとすると)、こんな感じになるはずです。
# self ... SampleMailer
# method_name ... :hello
# *args ... user (Userインスタンス)
さらなるミステリー
これで「クラスメソッドを叩いたらインスタンスメソッドが呼ばれる」「何も書いていなくてもActionMailer::MessageDeliveryオブジェクトが返ってくる」という理由が理解できました。
しかし、なんでそんなややこしいことをしているのか?
それでは、この後の動きを詳しく追いかけてみましょう。
先ほどの処理の最後の方で、ActionMailer::MessageDelivery.newをしていましたよね。
module ActionMailer
class MessageDelivery < Delegator
def initialize(mailer_class, action, *args) #:nodoc:
@mailer_class, @action, @args = mailer_class, action, args
@processed_mailer = nil
@mail_message = nil
end
# snip
なんと、ただ渡された中身をインスタンス変数に持たせるだけです。
処理はここまで。
・・・
・・・・?
いやいやいやいやちょっと待って
pry(main)> mail = SampleMailer.hello(user)
[INFO ] Rendering sample_mailer/hello.text.erb
[INFO ] Rendered sample_mailer/hello.text.erb (1.8ms)
ログ上にはテンプレートをレンダリングしたというが形跡がしっかり残っていましたよね?
おじさん・・・まだ裏で何かやってますね。
おじさんの闇の衣
実はここで一度行き詰まっていたのですが、ヒントはDelegatorにありました。
class MessageDelivery < Delegator # おじさんがまとってる闇の衣
# snip
def __getobj__ #:nodoc:
@mail_message ||= processed_mailer.message
end
Delegatorとはその名の通り、rubyで委譲を行うために用意された方法の一つ。
ちょくちょく見かけるfowardable
とは違い、委譲先を動的に設定できるというすごい裏技です。
上の例では __get_obj__
が委譲先を指定している部分にあたります。これのおかげでActionMailer::MessageDeliveryがnewされたタイミングで、自身の持たないメソッドは、processed_mailer.message
で表されるオブジェクトに委譲する、という設定がされます。
この__getobj__
が初期化のタイミングで走るため、processed_mailer.messageが評価される ことになります。
(newした後に続きがあった!!)
module ActionMailer
class MessageDelivery < Delegator
# snip
private
def processed_mailer
@processed_mailer ||= @mailer_class.new.tap do |mailer|
mailer.process @action, *@args
end
end
# snip
先ほどの例で言うと、SampleMailerをnewした上で、そのインスタンスに対してprocess(user)
を叩きます。
ここで親クラスActionMailer::Baseの#initialize
と#process
が順次よばれます。
def initialize
super()
@_mail_was_called = false
@_message = Mail.new
end
def process(method_name, *args)
payload = {
mailer: self.class.name,
action: method_name,
args: args
}
ActiveSupport::Notifications.instrument("process.action_mailer", payload) do
super
@_message = NullMail.new unless @_mail_was_called
end
end
newの方は単純で、Mail::Messageインスタンス4を作成して@_messageという内部変数5に持たせます。このMailとは、rubyのメールライブラリで、これが配信作業を支えています。
つまりActionMailerおじさんは直接送信をさばいているわけではなく、実務をMailに丸投げしています。
一方process
はsuperのところで、AbstractControllerに定義された#processを呼び出し、それは最終的に#process_action
に渡されます。
module AbstractController
class Base
#snip
private
def process_action(method_name, *args)
send_action(method_name, *args)
end
alias send_action send
要はsend
です。レシーバselfはSampleMailer
でしたね。
ということは、ここで初めて、インスタンスメソッドSampleMailer#hello(user)が実行されたことになります!!
ついに・・ついに見つけたどー \(^o^)/
メールはどこでレンダーされているのか
処理の流れがつかめてめでたしめでたし・・・・
しかし、まだテンプレートapp/views/sample_mailer/hello.text.erb
はいったいどこでレンダーされているのか、という疑問が解決していません。
結論から言うと、最初にMailerのアクション内に書いていたmail
メソッドが、ここで初めて活躍するのです。(直前でSampleMailer#hello
がsendされたので、そのままよばれますね)
実装は、ActionMailer::Base#mailの中にあります。
def mail(headers = {}, &block)
return message if @_mail_was_called && headers.blank? && !block
# (中略) headerの中身を作成
wrap_delivery_behavior!(headers[:delivery_method], headers[:delivery_method_options])
assign_headers_to_message(message, headers)
# (中略) メッセージを取り出す(レンダリングはココ!!)
@_mail_was_called = true
# (中略) multipartメールの組み立てなど
message
end
・・・ついに、ついに発見しました!
ここで先ほど作成したMail::Messageインスタンスに様々な要素を突っ込むことで、メールを作成しています。6
メール送信はどうなっているのか
さて、ここでもう一つ。
このメソッドはメールをレンダーするだけではなく、wrap_delivery_behavior!
というメソッドによって、メールに配送手段を与えます。
Production環境以外をletter openerにしたり、ステージング用のメーラーを設定することがありますが、この仕組みによって最終的な配送手段を切り替えることができています。
ActionMailer::DeliveryMethods
というConcern
モジュールをみてみると・・
module ActionMailer
module DeliveryMethods
extend ActiveSupport::Concern
included do
# snip
def wrap_delivery_behavior(mail, method = nil, options = nil)
method ||= delivery_method
mail.delivery_handler = self
case method
# snip
when Symbol # ここで配信メソッドを与えている
if klass = delivery_methods[method]
mail.delivery_method(klass, (send(:"#{method}_settings") || {}).merge(options || {}))
# snip
end
mail.perform_deliveries = perform_deliveries
mail.raise_delivery_errors = raise_delivery_errors
end
end
def wrap_delivery_behavior!(*args) # このまま上のクラスメソッドを呼び出す
self.class.wrap_delivery_behavior(message, *args)
end
end
どうやら前半のcase文で行われていることが核心のようです。
つまり、実際に配信を担当する機能を持ったクラスを取り出してMail#delivery_method
に渡す。(ActionMailerのデフォルトではMail::SMTP
クラスが渡されています。)この時、オプション類も一緒に渡します。
Mail::Messageの実装を追いかけると、
module Mail
class Message
def delivery_method(method = nil, settings = {})
unless method
@delivery_method
else
@delivery_method = Configuration.instance.lookup_delivery_method(method).new(settings)
end
end
ここでConfiguration.instance.lookup_delivery_method
というシングルトンメソッドは、デフォルト設定された配信手段以外は、与えられたクラスをそのまま戻します。
つまりMail::Messageオブジェクトは、渡されたクラスを(一緒に渡されたオプションを使って)初期化し、作成されたオブジェクトを@develiry_methodに格納します。
なお、この配送設定を行うにはいくつか方法がありますが7、一般的にはconfig/environment/以下の設定ファイル内で
config.action_mailer.delivery_method = :hogemail # このシンボルに対応するクラスがnewされる
config.action_mailer.hogemail_settings = { # newする際に渡されるオプション
domain: 'oreore.jp'
something: :something
}
のように行っているのがそれにあたります8
そう、いつも何気なく設定していたアレは、ここのためにあったのですね。
実際の配信
さて、最終的な配信ですが、これは先ほどnewされた、配信手段を提供するオブジェクト@delivery_methodが持っている#deliver!
というメソッドが呼ばれます。
逆に言えば、#deliver!
というインスタンスメソッドを持つクラスであれば、どんなものでも配信を請け負うことが可能なんですね。9
ついでに言うと、このdeliverの前後にはinterceptorやobserverといったコールバックを設定することが可能です。これを利用して例えば、ステージング環境からのメールを特定の宛先に変更してしまう、なんていう使い方もできるのですね。
ともあれ、ようやくメール配信という仕事の全容が見えました。
まとめ
かなり話が複雑なので、ここまでの流れをまとめてみたいと思います。
1. クラスメソッドSampleMailer.hello(user)が叩かれた時
-
ActionMailer::BaseがActionMailer::Deliveryインスタンスを作成して返す
-
ActionMailer::DeliveryインスタンスはSampleMailerインスタンスを作成し、
SampleMailer#process(:hello, user)
を実行 -
ActionMailer::Deliveryインスタンスは、SampleMailerが内部で作成するMail::Messageを委譲先に設定
2. SampleMailer#process(:hello, user)で起こること
-
SampleMailer#hello(user)が実行される。
mail
メソッドが使われている場合、ActionMailer::Base#mail
が呼ばれる。 -
mail
メソッドは、ActionMailer::Baseが持つ様々なメソッドを利用する。 -
まず適切なテンプレートを探してレンダーし、その内容を、引数にもたせた値やconfigと一緒に、Mail::Messageオブジェクトに設定。
-
つぎにMail::Messageに対して、実際の配信を担うdelivery agentクラスを設定する。このクラスはすぐに
new
され、実際の配信時に#deliver!
メソッドが叩かれる。
3. メール送信を行う
-
ActionMailer#deliver_now(later)
は、Mail::Messageの#deliver
メソッドを呼ぶ -
Mail::Messageは、(2)で設定されたdelivery agentオブジェクトに対して、
#deliver!
メソッドを実行する。delivery agentにMail::Messageオブジェクトが渡される。 -
agentがMail::Messageオブジェクトを実際に発送する
とまあこんな感じでしょうか。
それにしてもややこしい😮
ActionMailer
は自分で配送作業しているわけではなく、要件ごとに発注先を組み合わせて、複雑な配送業務をそつなく回すデキるおじさんだったのですね。
遊んでみる
ここまでわかったmailerの仕組みをつかえば、たとえばメールの内容を送信する代わりにslackに流すお手製エージェントが作れそうです。
というわけで、本番環境ではない場合に限り、全ての送信メールを横取りして、代わりにslackの指定チャネルに送信してくれる機能のデモをつくってみした。
仮に「会員登録の際にお知らせメールを自動配信する雑アプリ」をつくって、その中で動かしています。
-
メールオブジェクトから内容を抜き出してスラックの指定チャネルに投げる、というSlackMailerエージェントを作成。
-
次に
#mail
メソッドをApplicationMailer
クラスでいったん上書きします。そこで条件に応じた適切なdelivery_method
オプションをヘッダーに指定した上で、superします。(以降は本来の動作になる) -
ApplicationMailer
を継承する全てのMailerでこの機能が使えます。もちろんActionMailer::Base
から直接継承したMailerをつくると、普通通りメールを出すことも可能。
デモ機能はこちらに置きました。
記事が長くなってしまったので、またニーズがあれば解説記事など書いてみたいと思います。
環境
環境は以下の通りです。
-
ruby 2.3.1
-
rails 5.1.4
-
railsリポジトリは2017/12月時点(一部を抜粋、コメントなど割愛しています)
エンジニア歴1年に満たない初心者の読み解きなので、おかしな点があればビシバシとご指摘いただければありがたいです!
あとがき
やろうと思ったきっかけ
Webサービス開発現場で、ユーザーにメールを配信するため、mailgunというAPIベースのメールサービスを利用しています。
ラッパーとして最近アップデート状況がよくなっているmailgun-ruby(公式gem)を試用していたところ、テキストメールがコケるという謎の現象に見舞われました。10
しかたなく修正PRをたてたのですが11、状況を再現するテストケースを立てる際に「そもそもメール送信がどういう実装になっているのかがわからなすぎてテストがかけない」という困った状況になったことをきっかけに調べ始めました。
マニアックな知識ですが、どこかの誰かの役に立てば幸いです^^
-
定義されていなければ通常通りに継承チェーンを上がっていき、
NoMethodError
に行き着く ↩ -
正確には、レシーバSampleMailerの継承チェーン中でもっとも末端側の「abstractクラスであるActionMailer::Baseをのぞいた子孫側 にある全クラス」に定義されているpublicなインスタンスメソッドの名前の集合。つまり、このSampleMailerがHogeMailer < ActionMailer::Baseを継承している場合は、「HogeMailerのpublicインスタンスメソッドも含めた集合」が戻る。 ↩
-
AbstractControllerがどういう存在か、そもそもabstractという概念は何を意図したものなのか、というところがまた面白かったのですが、話がそれるのでこちらを参照。 ↩
-
一見Mailインスタンスに見えるが、Mail.newは最終的に[Mail::Message.newを呼ぶため]、Mail::Messageインスタンスが戻る。(https://github.com/mikel/mail/blob/61f0f01deaaae4bddb40932179bb5e18ce518f76/lib/mail/mail.rb#L50) ↩
-
実態はattr_internalなので、アクセサ名はmessage、変数名は@_messageという形になっている。 ↩
-
mail
メソッドを全く使わない#nopのようなメソッドだと、当然レンダーはされないのですが、代わりにNullMailインスタンスが@_massageに入ります。ここで、#mail
が呼ばれたかどうかを判定しているのが@_mail_was_calledフラグにあたるようです。 ↩ -
mailメソッドに、
delivery_method
オプションを持たせることでmethod
を指定することも可能。つまり動的に配信メーラーを切り替えることもできるようになっています。 ↩ -
この場合、シンボル:hogemailに対応する配信用のクラスを設定する必要があるが、そのためにActionMailer::Base.add_develiry_methodが利用できる。 ↩
-
例えば有名なletter_openerは自身が持つ#develer!メソッドの中で送信メールオブジェクトを受け取って表示しています。 ↩
-
修正が取り入れられたので、次のバージョン1.1.9で直る予定 ↩