Ruby 包管理分析
本文简单的介绍 Ruby 包管理的相关原理,写的比较粗浅,欢迎补充.
大纲
- Ruby 本身的包管理
- Rubygems
- Bundler
- RVM 与 rbenv
Ruby 本身的包管理
require method
Ruby 主要通过 require 函数来引入外部的库文件. 函数原型如下:
# http://ruby-doc.org/core-1.8.7/Kernel.html#method-i-require
# http://ruby-doc.org/core-2.2.3/Kernel.html#method-i-require
require(string) => true or false
参数需要传一个 string , 文件名或文件路径.
返回值为 boolean 值, true 为 require 成功.
演示代码:
# shell
echo 'puts "a"' > /tmp/a.rb
cd /tmp
irb
require 'csv' # 文件名方式,在 $LOAD_PATH 全局变量定义的路径里搜索
require './a' # 相对路径方式,基于进程的工作目录, Dir.pwd 可以查看当前进程的工作路径
require '/tmp/a' # 绝对路径. 1.8.7 返回 true, 1.9 以后返回false. 1.9 以后同一文件,用不同的路径方式加载,也算同一文件,不会重复加载.
$LOAD_PATH
本部分基于 ruby 1.8.7 的原因是因为 ruby 1.8.7 默认还是用 ruby 自身的 require 函数, 1.8 以后,默认用的是 Rubygems 实现的 require 函数.
大部分时候,我们使用 require 使用的是文件名,而不是相对路径或绝对路径的方式,所以 $LOAD_PATH 变量是个关键点.
ruby -e "puts $:" # shell, 用 ruby 命令的 -e 参数运行单行 ruby 代码. 以下为命令执行后的输出
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/site_ruby/1.8
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/site_ruby/1.8/x86_64-linux
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/site_ruby
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/vendor_ruby/1.8
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/vendor_ruby/1.8/x86_64-linux
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/vendor_ruby
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/1.8
/usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/1.8/x86_64-linux
$LOAD_PATH 变量为一个数组,里面存放的路径字符串.
打印出来的有三个重要的目录分类.
- site_ruby 默认优先级最高,安装本机相关库. 摘自«Ruby 编程语言» 254页.
- vendor_ruby 操作系统供应商进行定制用的,一般为空.
- 1.8 ruby 标准库目录. 比如 date, csv 库.
可以进入对应的目录查看一下,目录下有什么文件.
演示代码:
echo 'puts "priority2"' > /usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/vendor_ruby/1.8/prioritydemo.rb # vendor_ruby
ruby -e "require 'prioritydemo'" # puts priority2
echo 'puts"priority1"'> /usr/local/rvm/rubies/ruby-1.8.7-head/lib/ruby/site_ruby/1.8/prioritydemo.rb # site_ruby
ruby -e "require 'prioritydemo'" # puts priority1
通过代码演示可以看见, require 查找的顺序是基于 $LOAD_PATH 数组里面的路径的顺序来找的,找到了就不继续往下找.
上测试代码如果要强制加载 vendor_ruby 目录下的 prioritydemo 文件,可以使用绝对路径.
Rubygems
Rubygems 主要通过 ruby 的 monkey patch 特性,重写了 require 函数的实现.
gem 一般安装到和 site_ruby 平级的 gems 目录下面,我们主要关心 gems(代码) 目录和 specifications(gemspec) 目录.
rubygems require 解析
此部分基于 2.3.4 的 ruby 源码分析.
文件跳转有点晕,觉得麻烦的朋友,可以略过.结论是把 对应的 gem 的 gems 目录添加到 $LOAD_PATH 变量里面.
- 当加载 lib/rubygems.rb 时,会调用 Gem::Specification.load_defaults 代码 # 1.9自动加载
- lib/rubygems/specification.rb#load_defaults 会把 specifications 目录下的所有 gemspec 文件的 files 描述的文件通过 lib/rubygems.rb#register_default_spec 方法注册到 @path_to_default_spec_map 变量. key 文件名,value为 spec 对象
- require 方法会调用 lib/rubygems.rb#find_unresolved_default_spec , find_unresolved_default_spec 拼上 .rb .so 在 @path_to_default_spec_map 变量里查找,如果找到,则返回对应的 spec , 再调用 lib/rubygems.rb#remove_unresolved_default_spec 方法,从 @path_to_default_spec_map 变量删除这个 spec 的相关值,防止重复加载.
- 最后再调用 lib/rubygems/core_ext/kernel_gem.rb#gem, lib/rubygems/specification.rb#activate , lib/rubygems/specification.rb#add_self_to_load_path 再把这个 gems 添加到 $LOAD_PATH 变量.
演示代码:
puts Gem.instance_eval("@path_to_default_spec_map.keys.any?{|k| k =~ /minitest/}") # true
puts $LOAD_PATH # 没有 minitest gems
puts require 'minitest' # true
puts Gem.instance_eval("@path_to_default_spec_map.keys.any?{|k| k =~ /minitest/}") # false
puts $LOAD_PATH # 有 minitest gems
可以看到在 require 之前与之后的差别,多了 minitest gem 的 lib 路径( /home/outman/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/minitest-5.4.3/lib ) .
最终结论是 rubygems 所做的一切,只是为了把 gem 的 lib 目录添加到 $LOAD_PATH 变量里,再用原生的 require 方法加载.
Bundler
个人现在的使用习惯是 rbenv + bundler .而不是使用 rvm 的 gemset . 项目第一次执行 bundle install 加 –path=vendor/bundle 参数,把 gem 安装到项目的 vendor/bundle 目录下.再在 git 忽略此目录.
这样做就不会因为多个项目安装 gem 到系统目录,而导致系统里的 gem 冲突.
Bundler 和 Rubygems 一样,最终还是为了把项目的 gem 的 lib 目录添加到 $LOAD_PATH 变量里.
演示代码:
ruby -e 'puts $LOAD_PATH'
bundle exec ruby -e 'puts $LOAD_PATH' #可以看到 bundle 把项目 Gemfile 里定义的所有 gem 的 lib 目录都已经加到 $LOAD_PATH 变量里.
源码简单解析:
bundle exec 主要修改 PATH RUBYOPT RUBYLIB 变量,再用 exec 函数替换当前进程,从而继承修改后的 PATH RUBYOPT RUBYLIB 环境变量.
exec后的新进程读取 RUBYOPT 环境变量的 -rbundler/setup 值,从而会先加载运行 bundler/setup 这个文件的代码.
- lib/bundler/cli/exec.rb#run 方法
- SharedHelpers.set_bundle_environment
- 把 bundle 的 bin 目录加到了 PATH 环境变量
- bundle exec ruby -e ‘puts ENV[“PATH”]’
- 再把 -rbundler/setup 添加到 RUBYOPT 变量.
- ruby -h, -rlibrary require the library before executing your script
- echo “puts 123” > /tmp/s.rb; ruby -r ‘/tmp/s.rb’ -e ‘puts 456’
- 把 bundle 的 lib 目录添加到RUBYLIB 变量
- RUBYLIB=/tmp ruby -e ‘puts $LOAD_PATH’ # 把 /tmp 添加到 $LOAD_PATH 的第一位了
- 把 bundle 的 bin 目录加到了 PATH 环境变量
- 再执行 Kernel.exec ,用 exec 参数后面的命令替换当前进程.新进程会在修改的 ENV 执行.
- lib/bundler/setup.rb -> Bundler.setup -> lib/bundler.rb -> load.setup -> lib/bundler/runtime.rb
- Runtime 从 Bundler.definition 里拿到所有 specs ,再遍历 specs,调用 Bundler.rubygems.loaded_specs 方法把所有 gem 都加载到 $LOAD_PATH .
- bundle exec ruby -e ‘puts Bundler::Runtime.new(Bundler.root, Bundler.definition).requested_specs.first.inspect
rbenv
rbenv 的原理和 bundler 差不多,主要是先修改环境变量,再调用 exec 替换当前进程.
在 rbenv 环境我们调用 which ruby 命令可以看到, ruby 执行文件总是在 ~/.rbenv/shims 目录下面. shims 目录下的 ruby 脚本会根据 .ruby-version 文件,找到对应 ruby 的执行文件路径,修改好环境变量后,再执行 exec 命令.
rvm
rvm 与 rbenv 不同, rbenv 实现类似于设计模式里的委托模式,所有的 ruby 执行都交给 ~/.rbenv/shims 目录下的执行文件.
而 rvm 简单粗暴,直接把对应版本的 ruby 的 bin 目录添加到 PATH 环境变量里.
rvm gemset解析
rvm 的 gemset 主要是通过修改环境变量 GEM_HOME 和 GEM_PATH 变量来实现的. 此两变量 rubygems 根据其值在值定义的目录查找 gem .
演示代码:
rvm gemset use 1.8.7@testset --create
env | grep GEM
GEM_HOME=/usr/local/rvm/gems/ruby-1.8.7-head@testset
GEM_PATH=/usr/local/rvm/gems/ruby-1.8.7-head@testset:/usr/local/rvm/gems/ruby-1.8.7-head@global
gem install rack
cd /usr/local/rvm/gems/ruby-1.8.7-head@testset; ls
#bin build_info cachedoc environment gemsspecificationswrappers
ruby -e 'require "rubygems"; puts Gem.paths.path.inspect'
#["/usr/local/rvm/gems/ruby-1.8.7-head@testset", "/usr/local/rvm/gems/ruby-1.8.7-head@global"]
可以看到把 gemset 的目录添加到 Gem.paths 变量里面去了. 而且固定有 global 目录,这样当我们把 gem 安装到 global 的 gemset 里,当在我们自己的 gemset 里找不到时,会去 global 的 gemset 目录里面找.
总结
$LOAD_PATH 很强大,利用它好,可以实现不错的 hack 技巧,但注意别让自己掉到坑里去了.