你知道 require 幫你做了什麼事嗎?
也許你曾在 Ruby 或 Rails 專案中寫過這行語法:
require "digest"
puts Digest::MD5.hexdigest("I love you") # => "e4f58a805a6e1fd0f6bef58c86f9ceb3"
上面這段語法的大意是「引用 "digest" 模組,然後使用那個模組裡的某個方法產生 MD5 編碼字串」。但你知道這個 require
到底做了什麼事嗎? 以下將用我自己寫的一個名為 takami 的套件為例,它是一個完全沒功能的空包彈套件,純粹練功用途。
require 是怎麼運作的?
讓我們先打開終端機,直接試著印出 $PATH
這個變數來看看:
$ echo $PATH
/Users/user/.rvm/gems/ruby-2.3.1/bin:/Users/user/.rvm/gems/ruby-2.3.1@global/bin ...[略]...
在你電腦上的輸出結果可能跟我的不太一樣。當你想執行某個程式,例如 git log
,系統會依照 $PATH
列出來的順序,一個一個的問「請問在這個路徑是不是有 git
這個程式?」,如果有,就執行它;如果都沒有,就會出現 command not found 的訊息。
在 Ruby 裡有個叫做 $LOAD_PATH
的全域變數(可簡寫成 $:
)的功用跟 $PATH 類似,讓我們開 irb 來試試:
$ irb
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...
>> $:
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...
當你執行 require "takami"
之後,它會把這個 gem 加到 $LOAD_PATH 裡,並且把它加到另一個全域變數 $LOADED_FEATURES
(可簡寫成 $"
) 裡:
$ irb
>> $LOAD_PATH.count
=> 9
>> $LOADED_FEATURES.count
=> 59
>> require "takami"
=> true
>> $LOAD_PATH.count
=> 10
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib", ...[略]...
>> $LOADED_FEATURES
=> ["enumerator.so", "thread.rb", ...[略] ... "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib/takami.rb"]
>> $LOADED_FEATURES.count
=> 61
在 require 之前 $LOAD_PATH 只有 9 個,require 之後變 10 個了,而 $LOADED_FEATURES 的數量也從原本的 59 變成 61 了。除了可以在 $LOADED_FEATURES 看到新增加跟 takami 套件有關的東西之外,也可以看的出來 takami 套件的確在 $LOAD_PATH 裡了,這樣一來就可以直接使用我那個空包彈的 Takami 套件了。
原始碼挖挖挖!
首先,require
並不是關鍵字,它在 Ruby 裡只是個一般的方法(定義在 Kernel 模組裡),所以就讓我們試著挖 Ruby 的原始碼出來看看它到底做了什麼事。
但在開挖之前,要先講一下歷史故事,免得挖錯地方。在 Ruby 1.8 時代,Ruby 的 require 就是普通的 require,在 Ruby 1.9 之後,require 的事情則是交給 rubygems
套件來管理,所以我們現在應該要去挖 rubygems 套件的原始碼才能挖得到 require 真正的行為。
# 檔案: rubygems/lib/rubygems/core_ext/kernel_require.rb
require 'monitor'
module Kernel
RUBYGEMS_ACTIVATION_MONITOR = Monitor.new # :nodoc:
if defined?(gem_original_require) then
remove_method :require
else
alias gem_original_require require
private :gem_original_require
end
def require path
RUBYGEMS_ACTIVATION_MONITOR.enter
path = path.to_path if path.respond_to? :to_path
spec = Gem.find_unresolved_default_spec(path)
if spec
Gem.remove_unresolved_default_spec(spec)
gem(spec.name)
end
if Gem::Specification.unresolved_deps.empty? then
RUBYGEMS_ACTIVATION_MONITOR.exit
return gem_original_require(path)
end
spec = Gem::Specification.find_active_stub_by_path path
begin
RUBYGEMS_ACTIVATION_MONITOR.exit
return gem_original_require(path)
end if spec
found_specs = Gem::Specification.find_in_unresolved path
if found_specs.empty? then
found_specs = Gem::Specification.find_in_unresolved_tree path
found_specs.each do |found_spec|
found_spec.activate
end
else
names = found_specs.map(&:name).uniq
if names.size > 1 then
RUBYGEMS_ACTIVATION_MONITOR.exit
raise Gem::LoadError, "#{path} found in multiple gems: #{names.join ', '}"
end
valid = found_specs.reject { |s| s.has_conflicts? }.first
unless valid then
le = Gem::LoadError.new "unable to find a version of '#{names.first}' to activate"
le.name = names.first
RUBYGEMS_ACTIVATION_MONITOR.exit
raise le
end
valid.activate
end
RUBYGEMS_ACTIVATION_MONITOR.exit
return gem_original_require(path)
rescue LoadError => load_error
RUBYGEMS_ACTIVATION_MONITOR.enter
begin
if load_error.message.start_with?("Could not find") or
(load_error.message.end_with?(path) and Gem.try_activate(path)) then
require_again = true
end
ensure
RUBYGEMS_ACTIVATION_MONITOR.exit
end
return gem_original_require(path) if require_again
raise load_error
end
private :require
end
上面這段我把部份的註解拿掉省一點空間。當 rubygems
被 require 進來之後,原本 Kernel 模組的 require 方法就被換成 rubygems 裡的 require 方法,並且把原本 Kernel 模組定義的 require 方法 alias 成 gem_original_require
。
如果該套件本來就已經在 $LOAD_PATH 上的話,它會直接呼叫原本 Kernel 模組定義的 require 方法來載入套件;但如果在 $LOAD_PATH 上沒有找不到的話,它就會試著開始去找看看是不是在系統上有安裝這個套件,然後執行 activate
方法,將它加到 $LOAD_PATH 裡。
讓我們來玩一下分解動作:
$ irb
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...
# 原本只有 9 個
>> $LOAD_PATH.count
=> 9
# 找到我的套件
>> my_gem = Gem::Specification.find_by_name("takami")
=> #<Gem::Specification:0x3fdcf54346c8 takami-0.0.1>
# activate 方法會把這個 gem 加到 $LOAD_PATH 裡
>> my_gem.activate
=> true
# 套件現在加到 $LOAD_PATH 裡了
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib", ...[略]...
# 數量也變 10 個
>> $LOAD_PATH.count
=> 10
# 咦? 不是在 $LOAD_PATH 了嗎? 怎麼不行
>> Takimi
NameError: uninitialized constant Takimi
from (irb):8
from /Users/user/.rvm/rubies/ruby-2.3.1/bin/irb:11:in `<main>'
# 呼叫原本 Kernel 的 require 方法
>> gem_original_require 'takami'
=> true
# 可正常執行!
>> Takami
=> Takami
並不是有在 $LOAD_PATH 裡就可以直接使用,事實上它還是得靠原本 Kernel 模組的 require 方法把它加到 $LOADED_FEATURES 之後才能使用。所以說穿了,rubygems 其實只算是在幫你管理 $LOAD_PATH 的路徑而已(當然也沒這麼單純啦),真正 require 的行為還是在 Kernel 模組裡。
順帶一提,如果 require 是 Ruby 內建的標準函式庫(StdLib),例如本文一開始範例裡提到的 digest
,$LOAD_PATH 本身並不會有任何變化(因為這些內建的本來就找得到了啊),但還是會把該模組加到 $LOADED_FEATURES 裡。
原本 Kernel 模組的 require
繼續挖 Ruby 原始碼來看看:
// 檔案:load.c
VALUE
rb_require_safe(VALUE fname, int safe)
{
int result = rb_require_internal(fname, safe);
if (result > TAG_RETURN) {
JUMP_TAG(result);
}
if (result < 0) {
load_failed(fname);
}
return result ? Qtrue : Qfalse;
}
這段是在 Kernel 模組裡 require 方法的實作,真正載入的行為是在 rb_require_internal
這個 function 裡,有興趣的朋友可以繼續往下追。Ruby 會檢查那個檔案是不是已經在 $LOADED_FEATURES 裡,如果不在就會把它加進來。
另外,rb_require_internal
這個 function 的回傳結果可能有以下 4 種:
/*
* returns
* 0: if already loaded (false)
* 1: successfully loaded (true)
* <0: not found (LoadError)
* >1: exception
*/
所以,如果 require 成功,rb_require_safe
這個 function 會回傳 true
,但如果之前就已經 require 過的話則會回傳 false
,如果什麼都找不到,則是直接產生 LoadError
的錯誤訊息:
$ irb
# 第一次 require,回傳 true
>> require "takami"
=> true
# 第二次 require 相同的套件,回傳 false
>> require "takami"
=> false
# require 一個找不到的 gem
>> require "the_gem_not_install"
LoadError: cannot load such file -- the_gem_not_install
小結
也許這個簡單的 require 指令一下子就跑完了,但當你知道它背後是怎麼運作之後,你對 Ruby 就可以有更深一層的了解。在挖原始碼的過程有點辛苦但也很有趣,除了可以看看大師們是怎麼設計的,還可以看到一些平常看不到的東西 :)
工商服務
實體課程:Ruby on Rails 實戰課程
線上課程:五倍學院線上課程