【Ruby】require背后的故事


  接觸ruby有一段時間了,說起來自己和這門語言倒挺有緣。學生時代的時候,曾經沉迷於一款叫做RPG Maker的軟件。當時和朋友以班上的同學為原型寫了一部武俠劇,並計划用RPG Maker制作成游戲,樂此不疲。這個RPG Maker在內部使用了一門腳本語言來描述其游戲邏輯,這門語言便是Ruby。於是乎為了修改游戲框架、拓展引擎原有特性,自己花了不少功夫學習Ruby。可惜最后,由於高考臨近,Ruby隨着RPG Maker一塊兒,淹沒在了記憶的深處。
 
  最近突然間有了興趣,又把Ruby拿出來看看,卻發覺這五六年間,Ruby語言的變化之大,是我難以預期的。無論是語言特性本身,還是周邊的開發工具、程序庫,都較之前有了較大的發展,於是重溫變成了重新學習。
 
       既然是學習,光說不練可不行,於是乎又一邊開始學習起了Ruby on Rails來,希望能夠學以致用。RoR在Ruby社區內可是有着響當當的名號,可以說Ruby之所以名聲大噪,很大程度上都是由於RoR的功勞。RoR是一個純粹的開源軟件,它凝聚了世界上最優秀Ruby程序員的勤勞和天分。而我自己,也由於對RoR的研讀,了解到了不少Ruby的精妙之處。喟嘆之際,便也想對其抽絲剝繭,習其精髓,並記錄於博客之中,以饗諸位同好。 Y(^_^)Y
 
require到底干了什么?
 
  第一次看到require語句的時候,立刻有一種似曾相識的感覺。作為一名C語言起家的技術人,我看到了include的影子。從開發者的角度來說,這兩條指令完成了相似的工作:告訴編譯器我們想要引用其它地方的代碼。但是,仔細思考和實驗過后,卻不難發覺,這兩條指令在實現層面上乃是千差萬別。
    
       當我們include一個頭文件的時候發生了什么?答案是編譯器找到對應的頭文件,並將其原地展開。通過對頭文件中代碼的解析,編譯器知道了我們要引用的代碼的聲明(Declaration)。所謂聲明,即是指編譯器知道將要引用的代碼到底長成什么樣,而並不知道代碼具體是什么(Definition)。而為了讓我們的代碼真正地用上所引用的代碼,我們需要在鏈接的時候,向linker指明引用庫的地址。而這個所謂的引用庫,才包含了我們欲引用代碼的具體定義。
 
       反觀require呢?當我們require某個外部代碼的時候,我們實際上是告訴編譯器尋找對應的rb文件。這個rb文件中有什么?答案是,引用代碼的具體定義。這時候require和include有何區別的答案便呼之欲出了:由於Ruby沒有傳統意義上的鏈接過程,我們require實際上是載入代碼的實現/定義;而對於C編譯器來說,include只不過是告訴編譯器代碼長啥樣,這使得編譯器能夠生成"調用代碼",而"實現代碼"則是在鏈接階段予以提供的。
 
require的實現
 
        按照官方解釋,require將會在Ruby的LOAD_PATH中查找對應文件並將其載入。好奇心促使我跟蹤了require的執行過程,卻發覺require並沒有調用系統默認的Kernel#require,而是調用到了位於custom_require.rb中的Kernel#require函數之中。
 
       而恰巧的是,這個文件是屬於RubyGem的一部分。到目前為止,我們還能夠理解發生了什么:RubyGem用自己的require替代了系統默認的版本,並藉此實現了它自己的邏輯。
 
       可是這樣理解后的問題接踵而來,首當其沖的問題便是,明明沒有人引用RubyGem,那么這個文件是誰通過怎樣的方式載入的呢?再則,我們開啟irb,這時候理當沒有任何Gem被載入,可是發覺Gem這個模塊已經被定義了是怎么回事呢?
 
       啊,一定有誰在我們的代碼執行前就載入了RubyGem!我在RubyGem的代碼中添加了打印函數調用棧的邏輯,然后運行一個空的Ruby腳本,看到了如下的現象:
 
david@david-K40IN:~/Desktop/root$ ruby -e ''
/home/david/.rvm/rubies/ruby-1.9.3-p362/lib/ruby/site_ruby/1.9.1/rubygems.rb:1282:in `require'
/home/david/.rvm/rubies/ruby-1.9.3-p362/lib/ruby/site_ruby/1.9.1/rubygems.rb:1282:in `<top (required)>'
<internal:gem_prelude>:1:in `require'
<internal:gem_prelude>:1:in `<compiled>'

       可以觀察到是一個叫做gem_prelude的文件載入了RubyGem。這個文件是什么?前面的internal似乎顯示了它不一樣的地位。我搜遍Ruby的目錄,也沒有找到這么一個文件,卻只是在Ruby的源碼目錄中找到了它的身影。

 
       原來,這個文件是編譯Ruby時自動生成的(這里討論只是Ruby較新的版本(1.9.x),實現代碼見此 https://github.com/ruby/ruby/blob/trunk/tool/compile_prelude.rb),它的內容只有一句話,那便是 require 'RubyGem' if defined?(Gem) 。其中Gem符號是在ruby.c里直接定義的(Hardcode),如果你要取消該特性,編譯Ruby的時候需要指明--disable-gems選項。
 
       現在,問題的答案很明朗了。由於RubyGem使用地十分廣泛,以至於Ruby開發團隊決定予以其直接支持,這便是為什么我們總是發覺RubyGem被載入的原因了。這也同時更好地解釋了,明明也是一個Gem,為什么RubyGem沒有和其他Gem放到一塊兒,並遵循Gem名加上版本號的命名方式。
 
RubyGem - 將寶石打包
 
       咱們再來談談Ruby的包管理機制。這里的基本思路十分簡單,Ruby本身載入某個代碼只會在LOAD_PATH里尋找,如果我們定義"使用某個Gem"為將Gem所在路徑添加到Ruby的LOAD_PATH里,這樣當我們使用了某個Gem后,我們便能夠載入這個Gem的代碼了。其次,我們在包的根目錄下放上某個包含諸如作者、主頁、依賴的庫等等信息的文件,這便有了Gem的元數據信息。最后,我們把Gem的根目錄命名為Gem名加版本號,這樣多版本的管理機制也就有了。於是乎,整個Gem的工作機制便呼之欲出了。
 
       按照慣例,我們一般是用require 'arel'的方式來載入Gem代碼。可是,根據require函數定義,我們實際上需要使用類似'gems/arel-3.0.2/arel'的參數傳給require才能達到我們的目的。為什么簡單的require 'arel'能行呢?因為我們在此之前使用了gem 'arel'語句,是它按照之前提到的規律找到了arel庫某個版本的位置,並將它加入到LOAD_PATH里,這才使我們能夠簡單地require一個Gem庫。
 
       不過,正如上一節指出的,在新版本的Ruby里,Kernel#require早已自動被RubyGem里的同名函數覆蓋了。因此,在新版本的Ruby里,我們連gem語句都不需要,可以直接require了(老版本則需要我們顯式調用require 'rubygem'才行)。不過,若是你需要明確的指定使用某一版本的Gem(Gem 'arel', '>=3.0.0'),還是需要顯示地調用gem方法。
 
       RubyGem這個庫具有兩面性,上面的討論僅是從Gem的使用者角度展開的。而RubyGem同時也給予了Gem庫開發者以大量支持,但是我在這里不會討論。
 
Bundler
 
       好的,終於談到它了,它是我最喜歡的一個工具。我在Linux下開發時間雖然不長,但是痛苦程度應該比起經驗豐富的開發者也不遑多讓。原因在於傳統的Linux開發相當復雜,除了軟件系統本身的復雜性外,還得受錯綜復雜的各種依賴關系所累。常常是一個程序還沒開始編譯,就得先安裝個一天的各種依賴庫再說。
 
       但是,這個令我十分頭痛的問題卻被Bundler解決了,而且解決地十分漂亮。你的軟件需要什么依賴庫,具體依賴啥版本,你寫成一個Gemfile清單來看。我check out你的代碼后,啥也不管,簡單地bundle install,開發環境就搭建好了,各種依賴庫也到位了,好不痛快!
 
       bundle install指令致力於確保Gem所有依賴庫均被安裝到本機,但它並不保證列在Gemfile里的依賴庫在運行時被載入。為了讓我們的程序使用上這些庫,我們需要在運行時調用Bundler.setup,該方法將列舉在Gemfile里的所有Gem被添加到LOAD_PATH中去。因此,Bundler.setup的行為類似於gem方法,用於幫助我們將特定版本的Gem載入到LOAD_PATH。只不過Bundler提供了更為高級的機制,使得我們能夠在Gemfile里集中地管理所有依賴,並且省去了一一添加依賴的繁瑣過程。
 
       Bundler還提供了一個Bundler.require方法,該方法提供了比Bundler.setup更方便的特性,它將直接將Gemfile列舉的依賴全部載入內存。關於使用Bundler.setup還是Bundler.require的討論已經存在已久,無非就是Bundler.setup提供了一個妥協,使得程序可以滯后載入(Lazy Loading),從而減少不必要的加載,加快程序啟動速度。而Bundler.require則主要是基於方便開發者(Programmer Friendly)的考量,它使開發者省去了大量的精力去顯式地加載代碼。
 
  另外一個問題是有關Gemfile.lock文件的。我們是否需要check in這個文件到代碼庫里?這里的答案要度時而定。首先,我們知道Gemfile一般指明了一個依賴版本范圍(或者沒有),當我們運行bundle install的之后,生成的Gemfile.lock實際上是指明了每個依賴Gem的一個 固定版本(在滿足Gemfile指明的版本范圍前提下)。
 
  然后我們需要考慮這么一個convention,即我們開發Gem庫的時候,我們總是希望我們的庫能夠和盡可能多版本的依賴Gem一同工作,而當我們開發應用的時候,我們往往希望我們依賴的Gem版本維持不變(因為此時的測試十分嚴格,將精確到Gem庫的具體行為上),以保證我們的應用發布后十分穩定。
 
  綜合以上我們得出了結論,在開發Gem的時候,我們不添加Gemfile.lock到代碼庫,因為我們希望其他開發者通過bundle install綁定到依賴庫盡可能多的版本,借此保證我們的Gem能夠和更多的依賴庫協同工作。而當我們開發應用之時,我們希望所有的開發者以及用戶,嚴格的使用和我一樣的依賴庫版本,這使的每個人使用的依賴庫行為嚴格一致,從而保證了應用的健壯性。
 
當Bundler遇見RubyGem - Gem開發者需要知道的
 
  第一次使用bundler的時候,在為其精妙的設計而感嘆的之際,一個疑問始終縈繞在我的心中:.gemspec和Gemfile這兩個設計是不是過於冗余了?我們在開發Gem的時候,明明已經有.gemspec(RubyGem的一部分)指出依賴關系了,為什么此時Bundler還要雞肋地設計一個Gemfile呢?
 
  為了說明其原因,我們必須先回答一個基本問題:.gemspec和Gemfile分別是干嘛的?我們知道,.gemspec主要是用來描述Gem的元數據信息的,我們除了可以在此包含Gem的基本信息之外,還能夠在此指出本Gem具體依賴於哪些其他Gem。不過,值得注意的是,這里僅僅是指出依賴關系,.gemspec並沒有提供任何機制告訴我們,到哪里才能下載並部署這些被依賴的Gem。
 
  .gemspec之所以缺少運行時的支持,那是因為下載、部署、以及載入並不是其設計之初的職責所在。這些問題是被后來者Bundler解決的。那么,既然搞清了其用途上的差異,我們又當如何解決其內容的冗余這個問題呢?答案很簡單,在Gemfile文件中調用gemspec方法,它會自動告訴Bundler到同目錄下查找.gemspec文件並載入其中指明的Gem依賴。
 
Rails的代碼載入機制
 
   Rails開發者傾向於不使用顯式的代碼載入機制,為了實現這個目的,Rails使用了大量的自動載入機制。其一便是autoload,關於它的原理,以后我會撰文剖析。autoload主要用於載入Rails自己的組件,而對於依賴Gem的載入,Rails使用了Bundler.require機制。在Rails項目的文件config/application.rb負責實現此特性:
# Assets should be precompiled for production (so we don't need the gems loaded then)
Bundler.require(*Rails.groups(assets: %w(development test)))

 

寫在最后

  代碼載入看似很簡單,只不過是將代碼讀入內存並且編譯。可是,將這個需求放到真正的工程世界之后,情況便大不相同了。我們需要同時管理多個版本的組件,我們需要載入特定版本的組件,我們還需要解析某個組件的依賴……諸如此類的問題將代碼載入這個話題無限擴大了,以至於Ruby后來才有了RubyGem、Bundler等一系列的工具、程序庫來解決這些問題。我們要了解這個話題,不僅僅是一個技術人熱愛技術的本性使然,更是為了在現實世界復雜的開發、運行環境里,更好的實現我們的目的(DO Things Right)。願與諸君共勉!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM