186
116

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Ruby on RailsAdvent Calendar 2017

Day 15

ActionMailerは何をしているのか

Last updated at Posted at 2017-12-14

テーマ

初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)が叩かれた時

スクリーンショット 2017-12-15 10.01.03.png
  • ActionMailer::BaseがActionMailer::Deliveryインスタンスを作成して返す

  • ActionMailer::DeliveryインスタンスはSampleMailerインスタンスを作成し、SampleMailer#process(:hello, user)を実行

  • ActionMailer::Deliveryインスタンスは、SampleMailerが内部で作成するMail::Messageを委譲先に設定

2. SampleMailer#process(:hello, user)で起こること

スクリーンショット 2017-12-15 10.01.16.png
  • SampleMailer#hello(user)が実行される。mailメソッドが使われている場合、ActionMailer::Base#mailが呼ばれる。

  • mailメソッドは、ActionMailer::Baseが持つ様々なメソッドを利用する。

  • まず適切なテンプレートを探してレンダーし、その内容を、引数にもたせた値やconfigと一緒に、Mail::Messageオブジェクトに設定。

  • つぎにMail::Messageに対して、実際の配信を担うdelivery agentクラスを設定する。このクラスはすぐにnewされ、実際の配信時に#deliver!メソッドが叩かれる。

3. メール送信を行う

スクリーンショット 2017-12-15 10.01.30.png
  • ActionMailer#deliver_now(later)は、Mail::Messageの#deliverメソッドを呼ぶ

  • Mail::Messageは、(2)で設定されたdelivery agentオブジェクトに対して、#deliver!メソッドを実行する。delivery agentにMail::Messageオブジェクトが渡される。

  • agentがMail::Messageオブジェクトを実際に発送する

とまあこんな感じでしょうか。

それにしてもややこしい😮

ActionMailerは自分で配送作業しているわけではなく、要件ごとに発注先を組み合わせて、複雑な配送業務をそつなく回すデキるおじさんだったのですね。

遊んでみる

ここまでわかったmailerの仕組みをつかえば、たとえばメールの内容を送信する代わりにslackに流すお手製エージェントが作れそうです。

というわけで、本番環境ではない場合に限り、全ての送信メールを横取りして、代わりにslackの指定チャネルに送信してくれる機能のデモをつくってみした。

slack_mailer.gif

仮に「会員登録の際にお知らせメールを自動配信する雑アプリ」をつくって、その中で動かしています。

  • メールオブジェクトから内容を抜き出してスラックの指定チャネルに投げる、という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、状況を再現するテストケースを立てる際に「そもそもメール送信がどういう実装になっているのかがわからなすぎてテストがかけない」という困った状況になったことをきっかけに調べ始めました。

マニアックな知識ですが、どこかの誰かの役に立てば幸いです^^

  1. 定義されていなければ通常通りに継承チェーンを上がっていき、NoMethodErrorに行き着く

  2. 正確には、レシーバSampleMailerの継承チェーン中でもっとも末端側の「abstractクラスであるActionMailer::Baseをのぞいた子孫側 にある全クラス」に定義されているpublicなインスタンスメソッドの名前の集合。つまり、このSampleMailerがHogeMailer < ActionMailer::Baseを継承している場合は、「HogeMailerのpublicインスタンスメソッドも含めた集合」が戻る。

  3. AbstractControllerがどういう存在か、そもそもabstractという概念は何を意図したものなのか、というところがまた面白かったのですが、話がそれるのでこちらを参照。

  4. 一見Mailインスタンスに見えるが、Mail.newは最終的に[Mail::Message.newを呼ぶため]、Mail::Messageインスタンスが戻る。(https://github.com/mikel/mail/blob/61f0f01deaaae4bddb40932179bb5e18ce518f76/lib/mail/mail.rb#L50)

  5. 実態はattr_internalなので、アクセサ名はmessage、変数名は@_messageという形になっている。

  6. mailメソッドを全く使わない#nopのようなメソッドだと、当然レンダーはされないのですが、代わりにNullMailインスタンス@_massageに入ります。ここで、#mailが呼ばれたかどうかを判定しているのが@_mail_was_calledフラグにあたるようです。

  7. mailメソッドに、delivery_methodオプションを持たせることでmethodを指定することも可能。つまり動的に配信メーラーを切り替えることもできるようになっています。

  8. この場合、シンボル:hogemailに対応する配信用のクラスを設定する必要があるが、そのためにActionMailer::Base.add_develiry_methodが利用できる。

  9. 例えば有名なletter_openerは自身が持つ#develer!メソッドの中で送信メールオブジェクトを受け取って表示しています。

  10. テキストメール(またはhtmlメール)を、おかしな方法でmultipart化してしまっていることが判明。

  11. 修正が取り入れられたので、次のバージョン1.1.9で直る予定

186
116
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
186
116

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?