What have you found for these years?

2010-04-29

daily WTF, Rails!? (2) (actionmailer backend)

ActionMailer 有一個很麻煩的問題,在於今天這種所謂
rest api 充斥的世界,往往我們會需要各種不同的寄信方式。
因此我們的寄信方式絕不只是 smtp, sendmail, test, etc.

但是 ActionMailer (下稱 AM) 讓你選擇 backend 的方式,
是 MailerClass.delivery_method = :whatever_you_want
因此如果你想要有 thread-safe (less side-effect) 的寄信,
變成需要用 subclass 的方式做出各種不同的 mailer...

class AMailer < ActionMailer::Base
  self.delivery_method = :aaa
end
class BMailer < ActionMailer::Base
  self.delivery_method = :bbb
end

然後你要在每一個 mailer 中定義 perform_delivery_???
套到上面,一個就是 perform_delivery_aaa, 另一個自然是
perform_delivery_bbb 了。

於是當你想寄同一封信,也就是同一個 template, 比方說叫
food 好了。因此我們寄信就會變成:
AMailer.deliver_food 和
BMailer.deliver_food.

因此我們的 template/action 就變成要另外定義在一個
module 裡,然後由每一個 Mailer 去 include.
AMailer.send :include, RealMailer
BMailer.send :include, RealMailer
這個 RealMailer 就是我們原本常用的方式,定義各種 email 的。

最麻煩的地方還不在這,而是在 perform_delivery_aaa 和
perform_delivery_bbb 所需要的資訊,並不見得在 TMail 裡!
TMail 是 AM bundle 進去的 library,
是真正的 email 的 object, 你沒辦法存超越 email 本身的東西。
但是 perform_delivery_bbb 是有可能會用到這樣的資訊的––
舉實例的話就是 facebook 的 sendEmail api, 你要給他 user id,
而不是給他真實的 email address.

and that make sense.

但卻很難在這種架構下實作。進入 perform_delivery_bbb 時,
能獲得的資訊只有 TMail, 沒有其他的東西了。而 TMail 是在
template/action 時就做出來的,在這邊就是 food. 像是:
tmail = AMailer.create_food
AMailer.deliver!(tmail)
因此 backend 能看到的只有 tmail.

最直覺的作法,當然就是類似這樣:
AMailer.new(additional_data).deliver_food(other_data)
然後在 initialize 裡面把 additional_data 記在 ivar 裡,
接著在 backend 裡就能把資料讀出來。

結果大失敗啊!! AMailer.new 居然回傳 nil.

這在上一篇裡面提過了。讓人非常無言的設計。
source code 翻來翻去,才發現 new 也不是真的沒有用的。
事實上你要這樣用:
AMailer.new(:food).deliver!
這樣是能用的喲!如果 new 的第一個 argument 是存在的
template/action, 則 AMailer instance 則能夠被建立。

什麼鬼!?

你在 java 裡寫 new AMailer("123") 可以,
然後寫 new AMailer() 時卻會 return null 是什麼道理??
在 c 裡面寫 malloc(-4) 然後 return 0 嗎?

最後總算找到一個方法可以讓他動了,長這樣:

def BaseMailer.send_to(contacts, action, *args)
mailer = new
mailer.contacts = [contacts].flatten
mailer.create!(action.to_s, *args)
mailer.deliver!
end

一整個蠢啊!繞了一大圈。而這是我能找到最簡單的方式了。
其他的一定會搞到更暴力... 或是我沒想到,哈哈。

我意會到一件事。就是避免 class variable,
and class instance variable. 因為如果這些值要改,
那就是 side-effect, 就沒有 thread-safe,
也很難掌握什麼時候 state 改變了。global variable 要避免,
理所當然 class variable 也要避免。理想上,連
instance variable 也要避免!因為 method 跟 method
之間也是 share state. 最好把所有要用到的東西,
全部用 argument and parameter 的方式傳遞。

其實那就是 functional programming 了,哈哈!
in some sense...

==
不過講真的,最近我體會最深的是,原來使用 rails 也可以寫得不髒!
只要夠用心去找,還是能找到簡單乾淨的方式。這點我真的自嘆不如 XDDD
有時候寫到很火大,不自覺自己也就跟著 rails 一起髒起來... orz

==
updated 2010-04-29 01:54

test case 也變得非常複雜。
首先要把 test case 定義在另一個 module 裡面,
像是上面提到的 RealMailer. 然後把這個 module include
至各個真正的 mailer test 裡面。這也是 rails 設計的問題...
於是就有這種東西:
class GmailMailerTest < ActionMailer::TestCase
tests GmailMailer
include MailerHelper

def mailer_class
GmailMailer
end

def setup_mock(receiver, user_id, subject, body)
GmailMailer.delivery_method = :test
end
end

在 MailerHelper 裡會呼叫 mailer_class 去取得現在正要測的
真正的 mailer class, 拿他來呼叫上面的 send_to.
同時,在真正寄信的時候,這邊要呼叫 setup_mock 把 mock 做好。
這邊一定要用 mock, 不能用 AM 本身的 :test 去測試,
因為 :test 跟 :smtp, :sendmail 是平行的,
所以當我把 delivery_method 設成 :aaa, :bbb, :facebook 時,
他實際上真的會去寄信啊!!!

把 test 做成一種 backend 也真的是很無言。

我猜他自己也是用 mock 去測試吧?不然怎麼測? -_-
facebook 本身的 mock 也滿複雜的,長這樣:
def setup_mock(receiver, user_id, subject, body)
mock(Facebooker::Session).create{
mock!.send_email([user_id], subject, nil, body){
receiver
}
}
end

這邊意思就是說 Session.create 時要回傳另一個 mock,
而這個 mock 必須被呼叫 send_email, 同時帶有以上的參數...
一個參數沒有 match 的話,測試就算失敗!

為什麼要 mock? 為什麼不測試結果就好?
因為有時候沒辦法測試結果,所以要反過來只測中間的行為是否正確。
當然我可以跳到中間直接去跑 perform_delivery_aaa,
但這樣我就沒辦法測到整個流程是否正確了。

不過我覺得特別去區分 TDD or BDD 是沒什麼意義...
反正就是根據需要去測試就對了,我覺得沒什麼特別的道理。
mock 方便的地方在於,可以任意測試程式是怎麼跑的。
但有時候很多東西其實也是實作細節,這時候就應該用 fake or stub 了。
就是說其實我們並不關心過程是什麼,只是需要一些假資料而已。

之前曾經在需要用 stub 的地方使用 mock,
過不了多久就會覺得很痛苦了... 程式稍微改一下,
測試就會掛掉。然後流程因為太複雜了,有時候反而變成
去測試流程是什麼,然後反寫回 mock 裡面。
這樣有點本末倒置........

重點其實是有些流程我們要搞清楚,而有些不必,像是這樣。

==
updated 2010-04-29 02:24

忘記說了,上面那個 mock/stub 是用 rr 做的。
我對他算是滿滿意的,除了跟 1.9 有點不相容外。
但還是有不少小地方讓我覺得不是很方便,還有很奇怪。
仔細想想,我還滿想自己做一套 mock/stub/proxy library.
除了覺得能比 rr 更好外,其實最重要的是...

做這個肯定很好玩!!

因為會需要大量的技巧,哈哈。是非常能發揮 ruby 特性的問題。
各種 meta-programming, reflection, 和 dynamic 技巧。
搞不好還會因此碰到很多實作相關的問題,考驗 jruby 和 rubinius 與
mri 之間的相容與細節實作。就像我之前那 hello world...
雖然我想那是有點太誇張了。我到現在還是有點搞不懂為什麼
rubinius 和 jruby 的行為跟 mri 不一樣...

0 retries:

Post a Comment

All texts are licensed under CC Attribution 3.0