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

前言

在大多数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命令和参数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的可执行文件的路径。

shell 复制代码
$ 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可执行文件等。

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

shell 复制代码
$ which bundle
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle

Ruby解释器

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

ruby 复制代码
$ 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函数:

c 复制代码
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:

ruby 复制代码
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:

ruby 复制代码
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部分内容

ruby 复制代码
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会生成bundle和bundler两个可执行文件,而Bundler包里既有bundle,也有bundler可执行文件,bundler的逻辑实际上是去加载bundle可执行文件,核心逻辑在bundle可执行文件中。

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

ruby 复制代码
#!/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函数:

ruby 复制代码
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一致

ruby 复制代码
$ 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_path和find_spec_for_exe等方法的实现。

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

runtime.rb

ruby 复制代码
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文件内容,其核心逻辑如下:

ruby 复制代码
#!/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::Command,CLAide::Command的run方法如下:

ruby 复制代码
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。

arduino 复制代码
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, 例如:

    ruby 复制代码
    gem "cocoapods", :path => '~/dev/CocoaPods/'

4、创建lanch.json

launch.json:

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源码位置

shell 复制代码
$ 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的可执行文件路径:

shell 复制代码
$ which bundle
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle

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

launch.json

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安装即可。

相关推荐
恋猫de小郭2 个月前
CocoaPods 官宣进入维护模式,不在积极开发新功能,未来将是 Swift Package Manager 的时代
xcode·swift·cocoapods
sziitjin2 个月前
IOS 01 CocoaPods 安装与使用
xcode·cocoapods
依旧风轻2 个月前
不更新索引库, 可以直接使用新的版本么
cocoapods
恋猫de小郭2 个月前
Flutter 正在迁移到 Swift Package Manager ,未来会弃用 CocoaPods 吗?
flutter·swift·cocoapods
书弋江山2 个月前
iOS 创建一个私有的 CocoaPods 库
ios·xcode·cocoapods
WongKyunban2 个月前
iOS 开发包管理之CocoaPods
ios·xcode·cocoapods
棱镜七彩3 个月前
【网安播报】CocoaPods 曝关键漏洞,应用程序面临供应链攻击风险
xcode·cocoapods
Donkor-4 个月前
Mac电脑arm64芯片Cocoapods 的 ffi 兼容问题
macos·bug·xcode·cocoapods·ffi
星coding4 个月前
iOS 之homebrew ruby cocoapods 安装
ios·ruby·cocoapods
fendoudexiaoniao_ios4 个月前
Xcode 打包报错Command PhaseScriptExecution failed with a nonzero exit code
macos·xcode·cocoapods