深入理解RubyGems、Bundler、CocoaPods的执行过程

3,885 阅读6分钟

前言

在大多数iOS项目中,都会使用CocoaPods作为三方库的包管理工具,某些项目还会使用Bundler来约束CocoaPods的版本、管理CocoaPods的插件等,而CocoaPods和Bundler作为Gem包,通常会使用RubyGems来安装和管理。

RubyGems、Bundler、CocoaPods都是Ruby语言开发的工具,我们在使用这套工具链的过程中,或许对中间的运行过程知之甚少,遇到问题也会有诸多疑惑。

本文将从Bundler和CocoaPods命令执行流程的角度,来理解整个工具链的运行原理。让大家在后续使用过程中,知道在终端敲下命令后,背后发生了什么,以及遇到问题怎么定位,甚至可以借鉴前辈们的思路,创造自己的工具。


bundle exec pod xxx 执行流程

直接执行pod命令,流程中只会涉及到RubyGems和Cocoapods,为了理解包括Bundler在内的整体工具链的运行原理,本文将对bundle exec pod xxx的运行过程进行剖析(xxx代表pod的任意子命令),理解了bundle exec pod xxx运行执行过程,对于pod命令运行过程的理解便是水到渠成的事。

先简单梳理下bundle exec pod xxx的执行流程,如果有不理解的地方可以先跳过,后面会展开描述各个环节的细节。

当在终端敲下bundle exec pod xxx

1、Shell命令行解释器解析出bundle命令,根据环境变量$PATH查找到bundle可执行文件

2、读取bundle可执行文件第一行shebang(!#),找到ruby解释器路径,开启新进程,加载执行ruby解释器程序,后续由ruby解释器解释执行bundle可执行文件的其他代码

3、RubyGems从已安装的Gem包中查找指定版本的bundler,加载执行bundler中的bundle脚本文件,进入bundler程序

4、bundler的CLI解析命令,解析出pod命令和参数xxx(pod子命令,例如install),分发给Bundler::Exec

5、Bundler::Exec查找pod的可执行文件,加载并执行pod可执行文件的代码

6、pod可执行文件和前面的bundle可执行文件类似,查找指定版本的Cocoapods,并找到Cocoapods里的pod可执行文件加载执行,进入Cocoapods程序

以上就是整体流程,接下来分析流程中各环节的细节


Shell命令行解释器处理bundle命令

每开一个终端,操作系统就会启动一个Shell命令行解释器程序,Shell命令行解释器会进入一个循环,等待并解析用户输入命令。

终端上可以通过watch ps查看当前正在运行的进程。如果没有watch命令,可通过brew install watch安装。

从 macOS Catalina 开始,Zsh 成为默认的Shell解释器。可以通过echo $SHELL查看当前的Shell解释器。更多关于mac上终端和Shell相关知识可以参考 终端使用手册。关于Zsh 的源码可以通过zsh.sourceforge.io/下载。

当用户输入命令并按回车键后,Shell解释器解析出命令,例如bundle,然后通过环境变量$PATH查找名为bundle的可执行文件的路径。

$ echo $PATH                        
/Users/用户名/.rvm/gems/ruby-3.0.0/bin:/Users/用户名/.rvm/gems/ruby-3.0.0@global/bin:/Users/用户名/.rvm/rubies/ruby-3.0.0/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Users/用户名/.rvm/bin

:$PATH分隔成多个路径,然后从前往后,查找某路径下是否有命令的可执行文件。例如打开/Users/用户名/.rvm/gems/ruby-3.0.0/bin,可以看到bundle可执行文件等。

image.png

也可以在终端通过which bundle查看可执行文件的路径:

$ which bundle
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle

Ruby解释器

通过cat查看上述bundle可执行文件的内容:

$ cat /Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle

#!/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'bundler' is installed as part of a gem, and
# this file is here to facilitate running it.
#
# 加载rubygems (lib目录下)
require 'rubygems'

version = ">= 0.a"

# 检查参数中是否存在以下划线包围的版本号,如果是,则取有效的版本号
str = ARGV.first
if str
  str = str.b[/\A_(.*)_\z/, 1]
  if str and Gem::Version.correct?(str)
    version = str

    ARGV.shift
  end
end

# rubygems新版本中执行activate_bin_path方法
if Gem.respond_to?(:activate_bin_path)
 # 查找bundler中名为bundle的可执行文件,加载并执行 (bundler是Gem包的名称,bundle是可执行文件名称)
 load Gem.activate_bin_path('bundler', 'bundle', version)
else
gem "bundler", version
load Gem.bin_path("bundler", "bundle", version)
end

其内容的第一行shebang(#!)指明了执行该程序的解释器为#!/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby。Shell解释器读到这一行之后,便会开启一个新进程,加载ruby解释器,后续的工作交给ruby解释器程序。

这里的ruby是使用了RVM进行了版本控制,如果是homebrew安装的,路径是/usr/local/opt/ruby/bin/ruby,系统自带的ruby的路径是/usr/bin/ruby

可以通过路径/Users/用户名/.rvm/src/ruby-3.0.0看到ruby的源码。简单看一下ruby的main函数:

int main(int argc, char **argv)
{
    #ifdef RUBY_DEBUG_ENV
       ruby_set_debug_option(getenv("RUBY_DEBUG"));
    #endif

    #ifdef HAVE_LOCALE_H
       setlocale(LC_CTYPE, "");
    #endif

    ruby_sysinit(&argc, &argv);
    {
       RUBY_INIT_STACK;
       ruby_init();
       return ruby_run_node(ruby_options(argc, argv));
    }
}

ruby程序正式运行前,会通过ruby_options函数读取环境变量RUBYOPT(ruby解释器选项),可以通过设置环境变量RUBYOPT来自定义ruby解释器的行为。

例如在用户目录下创建一个ruby文件.auto_bundler.rb,然后在Zsh的环境变量配置文件.zshrc中添加:export RUBYOPT="-r/Users/用户名/.auto_bundler.rb",执行一下source .zshrc或者新开一个终端,ruby程序运行前便会加载.auto_bundler.rb。我们可以利用该机制,在.auto_bundler.rb添加逻辑,在iOS项目下执行pod xxx时,检查如果存在Gemfile文件,自动将pod xxx替换成bundle exec pod xxx,从而达到省去bundle exec的目的。


RubyGems中查找Gem包

通过上述bundle可执行文件的顶部注释,我们还可以知道该文件是由RubyGems在安装bundler时生成,也就是在gem install bundler过程中生成的。

RubyGems是ruby库(Gem)的包管理工具,github源码地址 github.com/rubygems/ru…, 安装到电脑上的源码地址 ~/.rvm/rubies/ruby-x.x.x/lib/ruby/x.x.x 。其命令行工具的一级命令是gem

当执行gem install xxx安装Gem完成后,会结合Gem的gemspec文件里executables指定的名称,生成对应的可执行文件,并写入~/.rvm/rubies/ruby-x.x.x/bin目录下。源码细节可以查看RubyGemsinstaller.rb文件中的installgenerate_bin方法。

RubyGems生成的bundle以及其他Gem的可执行文件里的核心逻辑,是去查找指定版本的Gem包里的可执行文件,加载并执行。以下是RubyGems3.0.0版本的查找逻辑:

rubygems.rb:

def self.activate_bin_path(name, exec_name = nil, *requirements) # :nodoc:
    # 查找gemspec文件,返回Gem::Specification对象
    spec = find_spec_for_exe name, exec_name, requirements

    Gem::LOADED_SPECS_MUTEX.synchronize do
        # 这两行核心逻辑是将Gem的依赖项以及自己的gemspec文件里的require_paths(lib目录)添加到$LOAD_PATH中
        spec.activate
        finish_resolve
    end

    # 拼接完整的可执行文件路径并返回
    spec.bin_file exec_name
end


def self.find_spec_for_exe(name, exec_name, requirements)
    raise ArgumentError, "you must supply exec_name" unless exec_name

    # 通过Gem名和参数创建一个Gem::Dependency对象
    dep = Gem::Dependency.new name, requirements

    # 根据Gem名获取已加载的Gem的spec
    loaded = Gem.loaded_specs[name]

    # 如果获取到已加载的Gem的spec并且是符合条件的,则直接返回
    return loaded if loaded && dep.matches_spec?(loaded)

    #查找所有满足条件的spec
    specs = dep.matching_specs(true)

    # 过滤出executables包含传进来的可执行文件名的spec
    #(bundler的spec文件的executables:%w[bundle bundler])
    specs = specs.find_all do |spec|
        spec.executables.include? exec_name
    end if exec_name

    # 如果有多个版本,返回找到的第一个,一般是最大版本,bunder除外
    unless spec = specs.first
        msg = "can't find gem #{dep} with executable #{exec_name}"
    raise Gem::GemNotFoundException, msg
    end

    spec
end

dependency.rb:


def matching_specs(platform_only = false)
    env_req = Gem.env_requirement(name)

    # 对于多个版本的Gem,这里得到的是按版本降序的数组
    matches = Gem::Specification.stubs_for(name).find_all do |spec|
        requirement.satisfied_by?(spec.version) && env_req.satisfied_by?(spec.version)
    end.map(&:to_spec)

    # 这里会针对bundler特殊处理
    # 例如读取当前目录下Gemfile.lock文件里的“BUNDLED WITH xxx”的版本号xxx,将xxx版本号的spec放到matches的首位
    # 关于bundler特殊处理的逻辑详见RubyGems里的bundler_version_finder
    Gem::BundlerVersionFinder.filter!(matches) if name == "bundler".freeze && !requirement.specific?

    if platform_only
        matches.reject! do |spec|
            spec.nil? || !Gem::Platform.match_spec?(spec)
        end
    end

    matches
end

  
def self.stubs_for(name)
    if @@stubs_by_name[name]
        @@stubs_by_name[name]
    else
        pattern = "#{name}-*.gemspec"
        stubs = installed_stubs(dirs, pattern).select {|s| Gem::Platform.match_spec? s } + default_stubs(pattern)
        stubs = stubs.uniq {|stub| stub.full_name }.group_by(&:name)
        stubs.each_value {|v| _resort!(v) }
        @@stubs_by_name.merge! stubs
        @@stubs_by_name[name] ||= EMPTY
    end
end


# Gem名升序,版本号降序
def self._resort!(specs) # :nodoc:
    specs.sort! do |a, b|
        names = a.name <=> b.name
        next names if names.nonzero?
        versions = b.version <=> a.version
        next versions if versions.nonzero?
        Gem::Platform.sort_priority(b.platform)
    end
end

Gem::Dependencymatches_specs方法是在specifications目录下查找符合条件的gemspec文件,存在多个版本时返回最大版本。但是对bundler做了特殊处理,可以通过设置环境变量或者在项目的Gemfile中指定bundler版本等方式,返回需要的bundler版本。


Bundler查找指定版本的Cocoapods

Bundler是管理Gem依赖和版本的工具,其命令行工具的一级命令是bundlebundler,两者是等效的。

Bundler的gemspec文件里的executables为%w[bundle bundler]

bundler.gemspec部分内容


Gem::Specification.new do |s|
    s.name = "bundler"
    s.version = Bundler::VERSION
    # ...
    s.files = Dir.glob("lib/bundler{.rb,/**/*}", File::FNM_DOTMATCH).reject {|f| File.directory?(f) }
    # include the gemspec itself because warbler breaks w/o it
    s.files += %w[bundler.gemspec]
    s.files += %w[CHANGELOG.md LICENSE.md README.md]
    s.bindir = "exe"
    s.executables = %w[bundle bundler]
    s.require_paths = ["lib"]
end

安装之后RubyGems会生成bundlebundler两个可执行文件,而Bundler包里既有bundle,也有bundler可执行文件,bundler的逻辑实际上是去加载bundle可执行文件,核心逻辑在bundle可执行文件中。

Bundler中的bundle可执行文件的核心代码:

#!/usr/bin/env ruby
base_path = File.expand_path("../lib", __dir__)

if File.exist?(base_path)
    $LOAD_PATH.unshift(base_path)
end

Bundler::CLI.start(args, :debug => true)

这个函数通过命令解析和分发,到达CLI::Exec的run函数:

def run
    validate_cmd!
    # 设置bundle环境
    SharedHelpers.set_bundle_environment
    # 查找pod的可执行文件
    if bin_path = Bundler.which(cmd)
        if !Bundler.settings[:disable_exec_load] && ruby_shebang?(bin_path)
            # 加载pod可执行文件
            return kernel_load(bin_path, *args)
        end
        kernel_exec(bin_path, *args)
    else
        # exec using the given command
        kernel_exec(cmd, *args)
    end
end


def kernel_load(file, *args)
    args.pop if args.last.is_a?(Hash)
    ARGV.replace(args)
    
    $0 = file

    Process.setproctitle(process_title(file, args)) if Process.respond_to?(:setproctitle)
    # 加载执行setup.rb文件
    require_relative "../setup"
    
    TRAPPED_SIGNALS.each {|s| trap(s, "DEFAULT") }

    # 加载执行pod可执行文件
    Kernel.load(file)
    
    rescue SystemExit, SignalException
    raise
    rescue Exception # rubocop:disable Lint/RescueException
        Bundler.ui.error "bundler: failed to load command: #{cmd} (#{file})"
        Bundler::FriendlyErrors.disable!
    raise

end

终端上通过which pod查看pod可执行文件的路径,再通过cat查看其内容,可以看到内容和bundler一致

$ which pod
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/pod

$ cat /Users/用户名/.rvm/gems/ruby-3.0.0/bin/pod

#!/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'cocoapods' is installed as part of a gem, and
# this file is here to facilitate running it.
#

require 'rubygems'

version = ">= 0.a"

str = ARGV.first
if str
    str = str.b[/\A_(.*)_\z/, 1]
    if str and Gem::Version.correct?(str)
        version = str
        ARGV.shift
    end
end

if Gem.respond_to?(:activate_bin_path)
    load Gem.activate_bin_path('cocoapods', 'pod', version)
else
    gem "cocoapods", version
    load Gem.bin_path("cocoapods", "pod", version)
end

按照前面rubygems查找bundler的方式,会找到最高版本的Cocoapods。那么,bundler将命令转发给pod前,是怎么查找到Gemfile.lock文件中指定版本的cocoapods或者其他Gem呢?

实际上Bundler替换了RubyGems的activate_bin_pathfind_spec_for_exe等方法的实现。

上述的setup.rb中的核心代码是Bundler.setup,最终会执行到runtime.rb文件的setup方法

runtime.rb

def setup(*groups)
    # @definition是有Gemfile和Gemfile.lock文件生成的
    @definition.ensure_equivalent_gemfile_and_lockfile if Bundler.frozen_bundle?
    
    # Has to happen first
    clean_load_path

    # 根据definition获取所有Gem的spec信息
    specs = @definition.specs_for(groups)

    # 设置bundle的环境变量等
    SharedHelpers.set_bundle_environment

    # 替换RubyGems的一些方法,比如activate_bin_path和find_spec_for_exe等
    # 使Gem包从specs中获取(获取Gemfile中指定版本的Gem)
    Bundler.rubygems.replace_entrypoints(specs)

    # 将Gem包lib目录添加到$Load_PATH
    # Activate the specs
    load_paths = specs.map do |spec|
        check_for_activated_spec!(spec)
        Bundler.rubygems.mark_loaded(spec)
        spec.load_paths.reject {|path| $LOAD_PATH.include?(path) }
    end.reverse.flatten

    Bundler.rubygems.add_to_load_path(load_paths)

    setup_manpath
    
    lock(:preserve_unknown_sections => true)

    self
end

可以通过终端上在工程目录下执行 bundle info cocoapods找到Gemfile中指定版本的cocoapods的安装路径, 再通过cat查看其bin目录下的pod文件内容,其核心逻辑如下:


#!/usr/bin/env ruby

# ... 忽略一些对于编码处理的代码

require 'cocoapods'

# 如果环境变量配置文件文件中设置了COCOAPODS_PROFILE,会讲Cocoapod的方法耗时写入COCOAPODS_PROFILE对应的文件中
if profile_filename = ENV['COCOAPODS_PROFILE']
    require 'ruby-prof'

    # ...

    File.open(profile_filename, 'w') do |io|
        reporter.new(RubyProf.profile { Pod::Command.run(ARGV) }).print(io)
    end
else
    # 命令解析和转发等
    Pod::Command.run(ARGV)
end

Cocoapods依赖claide解析命令,Pod::Command继承自CLAide::CommandCLAide::Command的run方法如下:

def self.run(argv = [])
    # 加载插件
    plugin_prefixes.each do |plugin_prefix|
        PluginManager.load_plugins(plugin_prefix)
    end

    argv = ARGV.coerce(argv)

    # 解析出子命令
    command = parse(argv)
    ANSI.disabled = !command.ansi_output?
    unless command.handle_root_options(argv)
        command.validate!
        #命令的子类执行,例如Pod::Command::Install
        command.run
    end

rescue Object => exception
    handle_exception(command, exception)
end

插件加载:

每个pod命令的执行都会通过claidePluginManager去加载插件。Pod::Command重写了CLAide::Commandplugin_prefixes,值为%w(claide cocoapods)PluginManager会去加载当前环境下所有包含claide_plugin.rbcocoapods_plugin.rb 文件的 Gem。cocoapods插件中都会有一个cocoapods_plugin.rb文件。

关于cocoapods的其他详细解析,可以参考Cocoapods历险记系列文章。


VSCode断点调试任意Ruby项目

1、VSCode安装扩展:rdbg

2、工程目录中创建Gemfile,添加以下Gem包,然后终端执行bundle install

gem 'ruby-debug-ide'
gem 'debase', '0.2.5.beta2'

0.2.5.beta2是debase的最高版本,0.2.5.beta1和0.2.4.1都会报错,issue: 0.2.4.1 and 0.2.5.beta Fail to build on macOS Catalina 10.15.7

3、用VSCode打开要调试的ruby项目,例如Cocoapods。

  • 如果调试当前使用版本的Cocopods,找的Cocopods所在目录,VSCode打开即可。

  • 如果调试从github克隆的Cocopods,Gemfile里需要用path执行该Cocopods, 例如:

    gem "cocoapods", :path => '~/dev/CocoaPods/'
    

4、创建lanch.json

launch.json:

{
    // 使用 IntelliSense 了解相关属性。
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    
    "version": "0.2.0",
    "configurations": [
        {
            "type": "rdbg",
            "name": "pod install", //配置名称,用于在调试器中标识该配置
            "request": "launch",
            "script": "/Users/用户名/.rvm/gems/ruby-3.0.0/bin/pod", //指定要执行的脚本或可执行文件的路径
            "cwd": "/Users/用户名/Project/NC", //指定在哪个目录下执行
            "args": ["install"], //传递给脚本或可执行文件的命令行参数
            "askParameters": true, //在启动调试会话之前提示用户输入其他参数
            "useBundler": true, //使用Bundler
        }
    ]
}

5、运行


断点调试RubyGems -> Bundler -> Cocoapods的全流程

1、执行which ruby找到ruby目录,在ruby-x.x.x目录下找到lib/ruby/x.x.x/,获得rubygems源码位置

$ which ruby
/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby

其源码位置:/Users/用户名/.rvm/gems/ruby-3.0.0/lib/ruby/3.0.0

2、用VSCode打开/Users/用户名/.rvm/gems/ruby-3.0.0/bin/ruby/3.0.0,找到rubygems.rb文件,在load Gem.activate_bin_path这一行加上断点

3、在包含Gemfile的iOS项目目录下,执行which bundle,获取到bundle的可执行文件路径:

$ which bundle
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle

4、创建launch.json,在launch.json中添加如下配置:

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "rdbg",
            "name": "exec pod install", // 名字任意
            "request": "launch",
            "script": "/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle", // `which bundle`找到的路径
            "args": ["exec pod install"],
            "askParameters": false,
            "cwd": "/Users/用户名/iOSProject"  //替换为自己的iOS项目路径
        }
    ]
}

至此便可以点击VSCode的run按钮断点调试ruby工具链的主体流程了。

运行断点如果跳不到bundler或者cocoapods项目中,可以将bundler或者cocoapods源码中的文件拖到VSCode工程中。 获取项目中正在使用的bundler或者cocoapods源码的源码位置,可以在项目目录下执行bundle info bundlerbundle info cocoapods,从输出的结果中可以找到路径。

如果提示某些Gem包未安装,执行gem install xxx安装即可。