What have you found for these years?

2009-12-16

app-deploy released (3) ruby, rake, mutual dependency

updated 2009-12-17 00:07 標題打錯,網址會反映出來 :(
幸好這次太長了,打錯的部份沒出現 XDDDD

*

寫著寫著忽然發現一件事,就是 task dependency 很多時候不聽使喚。
收拾東西,走在路上,忽然間才想到 rake 的 task 有記錄是否曾經
invoked 過,如果已經 invoked 過了,再呼叫就不會有反應。

回去之後,試了一陣子,想到很多事情。

*

這件事跟 ruby 的 require 很類似,只要 require 過,
無論如何下次 require 就會跳過去。這造成 multi-threaded 時,
ruby 的 require 不可靠。因為 A require 了,B 再 require
一樣的東西會是 non-block, 直接 return, 然而,這個 require
很可能還沒完成,造成 B 看不到應該看到的東西。

這在 ruby-core 曾經被討論很多,也是批評 ruby multi-threaded
很容易有問題的重點之一。也是造成 autoload 不可靠的原因。
我自己也碰過好幾次奇怪的 constant name error, 大概都跟這有關。

這邊會有兩種想法。一種是本來要用到時,我們才應該讀入記憶體,
就像 autoload 這樣。另一種則是由於這很容易造成問題,
所以應該在 server 啟動時,就把所有東西都先讀入記憶體。

基本上我現在是傾向後者,因為這樣你會有比較穩定的執行狀況,
不會說突然發生什麼狀況,或是其實某樣東西有錯,你卻不知道。
但是對於實作 library 的人來說,這其實是很大的困擾。
因為你當然會想提供很多東西,但又要讓別人可以選擇用什麼。
不能一次全部讀入記憶體,又不容易用 autoload 做到穩定的作法。

那麼問題回到 ruby 的 require 為什麼要這樣設計?
原因非常簡單,就是避免 mutual dependency...
A require B, B require A, 這在現在的 ruby 是沒問題的,
因為當 B require A 時,會立刻 return, 沒有效果。
但如果會 block, 會確保 require 完成,會 lock 住 thread,
那這樣就 deadlock 了。

我不確定是否有辦法偵測這樣的狀況,並通知使用者錯誤。
如果有的話,或許可以這樣做沒問題。但如果沒有的話,
這樣對 programmer 來說,其實很容易寫出 deadlock 程式,
而且自己一點知覺都沒有。這邊只有 A 和 B,
程式大一點,很容易會有 A~Z, 這種時候,到底搞出什麼,很難追蹤...

在 c/c++ 和 java 這個問題都不是很嚴重。c/c++ 是利用 header file,
declaration 和 definition 分離,而 java 則可以用技巧繞過去。
細節我不清楚,但可以這樣想:java import 並不是「讀取」,而只是
某種說明需要使用某個 namespace 的作法。然而在 ruby 中,
require 就意味著「讀取」,也意味著「執行」,因為對 ruby 來說,
compile time 就是 runtime, 這之間沒有分別...

*

回到 rake task, 檢查是否 invoked 過,應該也是避免 mutual
dependency 造成問題。但這樣對我來說不是很方便,因為我是把 task
當成 function 在用的。例如在 server restart task 中,
我會希望依序讓每一個 server 都透過 signal task 來 restart.
而 restart 又是 stop => start, 這幾個 task 都會重複呼叫很多次。

因此我會希望關掉這樣的檢查,反正我知道自己不會寫出 mutual dependency...
然而這個檢查並沒有辦法直接關掉,必須對每一個 task 呼叫 reenable [0]
為了實作這樣的功能,只好下海去看 rake 底層是怎麼實作的,然後....

[0] 有點蠢,我一直看成 reen.able, google 了才發現其實是 re-enable.

*

我總算搞懂之前一直覺得 rake 很怪的地方了。比方說,
我不懂為什麼 Task#execute 和 Task#invoke 之間的差異。
原本以為是 execute 會忽略 dependency, 而 invoke 則
很奇怪,有時候在 dependency 很多時會被忽略掉??

事實上是,兩者都會因為 invoked 過而被忽略。
而差異真的只在是不是會執行 dependency 而已。
因此一般情況下,我們只會想用 invoke 而不是 execute.

還有其他很多誤解,rdoc 其實沒寫得很清楚,看 source 都懂了...
早知道就不要偷懶,一開始就該看的,又不是像 rails 那樣很難懂 :/

*

我們常常會看到這種作法:

$ rake db:migrate RAILS_ENV=production

這種作法在 rake 裡面,其實是改寫 ENV, 也就是 environment variable.
像是 ruby:

ENV['RAILS_ENV'] = 'production'

或是 bash:

export RAILS_ENV=production

或是 fish:

set -x RAILS_ENV production

對 rake 來說,如果你這樣寫:

task :task do |t, args|

然後用 args[:RAILS_ENV] 的話,可以抓到那個值,
因為 args 裡如果完全找不到,最後會到 ENV 裡面去抓。
但如果你用 rake 提供的 argument 的功能,就完全不一樣了。

task :task, [:RAILS_ENV] do |t, args|

然後這樣呼叫:

$ rake task RAILS_ENV=production

這樣取:

args[:RAILS_ENV]

得到的原因是 ENV['RAILS_ENV'] 可以抓到,而不是 rake argument.
但如果是這樣呼叫:

$ rake task[production]

這樣就是真正的 rake argument 了,用

args[:RAILS_ENV]

抓得到,但是用:

ENV['RAILS_ENV']

就抓不到了。基本上我會建議都用 rake argument,
因為不想跟 environment variable 打架,是吧?

那 dependency 要怎麼吃 rake argument 呢?

task :dependency do |t, args|
task :task, [RAILS_ENV] => [:dependency] do |t, args|

然後這樣呼叫:

$ rake task[production]

不管在 dependency 或 task 裡面,args[:RAILS_ENV] 都是可見的。
因為 rake 會把 args 包起來丟給 dependency, 用 parent 的方式。
所以我可以寫得很簡潔:

task :restart, [:script, :pidfile, :timeout, :name] => [:stop, :start]

後面的 stop/start 都看得到所有的 args, 也不用擔心順序。
但如果是自己 invoke, 那就會有 args 的問題了。
基本上 invoke 吃的 argument, 跟 task 是完全一樣的。
所以是可以把 task 想成某種 function. 例如 invoke 上面的用:

Rake::Task[:restart].invoke(script, pidfile, timeout, name)

當然啦,你要一直修改 ENV 也行... 感覺就比較難看,怕污染罷了。

*

此外,除了 dependency 是 array 外,action 也是 array.

task(:task => [:depA], &doA)
task(:task => [:depB], &doB)
task(:task => [:depC], &doC)

這樣執行順序會是:

depA, depB, depC, doA, doB, doC

可以根據這個做 Task#reenable, 掛在每一個 task 下面:

Rake::Task.tasks.each{ |t| t.enhance{ |tt| tt.reenable } }

這樣就真的是掛在每一個下面了...
不過我覺得最好用 namespace 管控一下:

namespace('name:space'){}.
tasks.each{ |t| t.enhance{ |tt| tt.reenable } }

這樣就是在 name:space 底下的全部都是可無窮呼叫。

*

再來是實作問題。在上面可以看到,會有很多 pass argument 的機會。
例如:

Rake::Task['a:b:c'].invoke(args[:a], args[:b], c, args[:d])

類似這樣的狀況,我需要修改 c, 但其他只要 forward.
寫到覺得有點麻煩,所以弄了個:

  def pass_args t, args, hash
t.arg_names.map{ |name| hash[name] || args[name] }
end

於是:

Rake::Task['a:b:c'].invoke(pass_args(t, args, :c => c))

變短了,對吧?但有短很多嗎?更進一步如何?

AppDeploy.invoke('a:b:c', t, args, :c => c)

更短了,對吧?

.
.
.
.
.

好處在哪?改半天忽然在想這件事。是有比較短沒錯,但有必要嗎?
argument 其實最多也才四個而已,不會有 100 個。
但是我讓 call stack 多疊上了兩層,一層是 AppDeploy.invoke,
另一個是 AppDeploy.pass_args. 同時,增加產生 bug 的可能。
pass_args 寫的是對的嗎?有正確傳過去嗎?

抽出來有個好處,就是如果變動了,修改的地方只有一個。
那問題來了,是 rake 比較可能改,還是 app-deploy ?
rake 很穩定,基本上是幾乎不會改的。app-deploy,
當然不用說常常在改了....

結果我把不太會變動的東西,用很可能會變動的東西包起來?
這不是自找麻煩嗎?更何況還可能會有多餘的 bug?

想想 ramaze/innate/unicorn 為什麼很容易讀。
想想 ramaze/innate/unicorn 為什麼效率很好。
他們有特別針對效能去寫嗎?並沒有。但是東西單純,
自然而然就跑得快,效能好。

我程式寫多久了?居然現在才意會這麼簡單的道理.....
過去真的玩太多莫名其妙的花招了。
事實上,DRY, Don't Repeat Yourself,
很多時候只是在自找麻煩而已.... Rails 深諳此道 :D

或許這也是為什麼 meta-programming/code generator 往往並不好的原因,
多拉了一個層次,本來就會變得比較難驗證正確性。如果不能帶來「極大」的好處,
那通常就意味著壞處了... code bloat 也往往是從這邊來的。

真的要小心不要走火入魔.....

不過事實上是,單純簡單又有效的程式,反而更難寫。
這之間的 trade off 拿捏,沒點經驗真的很難做。

也讓我想到,早期我們學會做網頁時,往往雜七雜八什麼東西都給他灌下去。
肥料摻了金坷拉,一袋能抵兩袋灑....
也許我們都需要走過那段,不懂什麼才叫設計的那段時間吧。社會也是這個樣子...

4 retries:

老林 said...

「結果我把不太會變動的東西,用很可能會變動的東西包起來?」

這句話令人深思 ....


肥料了摻了金坷拉,一袋能抵兩袋灑 XDDDDDDDDDD

godfat 真常 said...

另一種說法是,如果反正都要改了,
有抽跟沒抽要改的比例則是 n+1 : n
沒抽少 1...

沒說我還沒注意到多打一個「了」 XD

老林 said...

如果是包裝完後是這種結果真的是沒意思,
還多花了 useless refactoring 的時間。

My ASIO test repository
沒事可看看 :p
雖然沒啥檔案結構,就一層平的。不過這寫到一定程度就要納進 psc 了

godfat 真常 said...

希望可以沒事... XD

Post a Comment

All texts are licensed under CC Attribution 3.0