對於iOS開發者而言,CocoaPods並不陌生,通過pod相關的命令操作,就可以很方便的將項目中用到的三方依賴庫資源集成到項目環境中,大大的提升了開發的效率。CocoaPods作為iOS項目的包管理工具,它在命令行背後做了什麼操作?而又是通過什麼樣的方式將命令指令聲明出來供我們使用的?這些實現的背... ...
對於iOS開發者而言,CocoaPods並不陌生,通過pod相關的命令操作,就可以很方便的將項目中用到的三方依賴庫資源集成到項目環境中,大大的提升了開發的效率。CocoaPods作為iOS項目的包管理工具,它在命令行背後做了什麼操作?而又是通過什麼樣的方式將命令指令聲明出來供我們使用的?這些實現的背後底層邏輯是什麼?都是本文想要探討挖掘的。
一、Ruby是如何讓系統能夠識別已經安裝的Pods指令的?
我們都知道在使用CocoaPods管理項目三方庫之前,需要安裝Ruby環境,同時基於Ruby的包管理工具gem再去安裝CocoaPods。通過安裝過程可以看出來,CocoaPods本質就是Ruby的一個gem包。而安裝Cocoapods的時候,使用了以下的安裝命令:
sudo gem install cocoapods
安裝完成之後,就可以使用基於Cocoapods的 pod xxxx
相關命令了。gem install xxx
到底做了什麼也能讓 Terminal
正常的識別 pod 命令?gem的工作原理又是什麼?瞭解這些之前,可以先看一下 RubyGems
的環境配置,通過以下的命令:
gem environment
通過以上的命令,可以看到Ruby的版本信息,RubyGem的版本,以及gems包安裝的路徑,進入安裝路徑 /Library/Ruby/Gems/2.6.0 後,我們能看到當前的Ruby環境下所安裝的擴展包,這裡能看到我們熟悉的Cocoapods相關的功能包。除了安裝包路徑之外,還有一個 EXECUTABLE DIRECTORY 執行目錄 /usr/local/bin,可以看到擁有可執行許可權的pod文件,如下:
預覽一下pod文件內容:
#!/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/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
在安裝 Cocoapods
的時候自動生成的,同時會將當前的執行文件放到系統的環境變數路徑中,也即存放到了 /usr/local/bin
中了,這也就解釋了為什麼我們通過gem安裝cocoapods之後,就立馬能夠識別pod可執行環境了。
雖然能夠識別pod可執行文件,但是具體的命令參數是如何進行識別與實現呢?繼續看以上的pod的文件源碼,會發現最終都指向了 Gem
的 activate_bin_path
與 bin_path
方法,為了搞清楚Gem到底做了什麼,在官方的RubyGems源碼的rubygems.rb
文件中找到了兩個方法的相關定義與實現,摘取了主要的幾個方法實現,內容如下:
##
# Find the full path to the executable for gem +name+. If the +exec_name+
# is not given, an exception will be raised, otherwise the
# specified executable's path is returned. +requirements+ allows
# you to specify specific gem versions.
#
# A side effect of this method is that it will activate the gem that
# contains the executable.
#
# This method should *only* be used in bin stub files.
def self.activate_bin_path(name, exec_name = nil, *requirements) # :nodoc:
spec = find_spec_for_exe name, exec_name, requirements
Gem::LOADED_SPECS_MUTEX.synchronize do
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
# 創建一個Dependency對象
dep = Gem::Dependency.new name, requirements
# 獲取已經載入的gem
loaded = Gem.loaded_specs[name]
# 存在直接返回
return loaded if loaded && dep.matches_spec?(loaded)
# 查找複合條件的gem配置
specs = dep.matching_specs(true)
specs = specs.find_all do |spec|
# 匹配exec_name 執行名字,如果匹配結束查找
spec.executables.include? exec_name
end if exec_name
# 如果沒有找到符合條件的gem,拋出異常
unless spec = specs.first
msg = "can't find gem #{dep} with executable #{exec_name}"
raise Gem::GemNotFoundException, msg
end
#返回結果
spec
end
private_class_method :find_spec_for_exe
##
# Find the full path to the executable for gem +name+. If the +exec_name+
# is not given, an exception will be raised, otherwise the
# specified executable's path is returned. +requirements+ allows
# you to specify specific gem versions.
def self.bin_path(name, exec_name = nil, *requirements)
requirements = Gem::Requirement.default if
requirements.empty?
# 通過exec_name 查找gem中可執行文件
find_spec_for_exe(name, exec_name, requirements).bin_file exec_name
end
class Gem::Dependency
def matching_specs(platform_only = false)
env_req = Gem.env_requirement(name)
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)
if prioritizes_bundler?
require_relative "bundler_version_finder"
Gem::BundlerVersionFinder.prioritize!(matches)
end
if platform_only
matches.reject! do |spec|
spec.nil? || !Gem::Platform.match_spec?(spec)
end
end
matches
end
end
class Gem::Specification < Gem::BasicSpecification
def self.stubs_for(name)
if @@stubs
@@stubs_by_name[name] || []
else
@@stubs_by_name[name] ||= stubs_for_pattern("#{name}-*.gemspec").select do |s|
s.name == name
end
end
end
end
通過當前的實現可以看出在兩個方法實現中,通過 find_spec_for_exe 方法依據名稱name查找sepc對象,匹配成功之後返回sepc對象,最終通過spec對象中的bin_file方法來進行執行相關的命令。以下為gems安裝的配置目錄集合:
註:bin_file
方法的實現方式取決於 gem 包
的類型和所使用的操作系統。在大多數情況下,它會根據操作系統的不同,使用不同的查找演算法來確定二進位文件的路徑。例如,在Windows
上,它會搜索 gem
包的 bin
目錄,而在 Unix
上,它會搜索 gem
包的 bin
目錄和 PATH
環境變數中的路徑。
通過當前的實現可以看出在兩個方法實現中,find_spec_for_exe 方法會遍歷所有已安裝的 gem 包,查找其中包含指定可執行文件的 gem 包。如果找到了匹配的 gem 包,則會返回該 gem 包的 Gem::Specification 對象,並調用其 bin_file 方法獲取二進位文件路徑。而 bin_file
是在 Gem::Specification 類中定義的。它是一個實例方法,用於查找與指定的可執行文件 exec_name 相關聯的 gem 包的二進位文件路徑,定義實現如下:
def bin_dir
@bin_dir ||= File.join gem_dir, bindir
end
##
# Returns the full path to installed gem's bin directory.
#
# NOTE: do not confuse this with +bindir+, which is just 'bin', not
# a full path.
def bin_file(name)
File.join bin_dir, name
end
到這裡,可以看出,pod命令本質是執行了RubyGems 的 find_spec_for_exe 方法,用來查找並執行gems安裝目錄下的bin目錄,也即是 /Library/Ruby/Gems/2.6.0
目錄下的gem包下的bin目錄。而針對於pod的gem包,如下所示:
至此,可以發現,由系統執行環境 /usr/local/bin 中的可執行文件 pod 引導觸發,Ruby通過 Gem.bin_path("cocoapods", "pod", version) 與 Gem.activate_bin_path('cocoapods', 'pod', version) 進行轉發,再到gems包安裝目錄的gem查找方法 find_spec_for_exe,最終轉到gems安裝包下的bin目錄的執行文件進行命令的最終執行,流程大致如下:
而對於pod的命令又是如何進行識別區分的呢?剛剛的分析可以看出對於gems安裝包的bin下的執行文件才是最終的執行內容,打開cocoapod的bin目錄下的pod可執行文件,如下:
#!/usr/bin/env ruby
if Encoding.default_external != Encoding::UTF_8
if ARGV.include? '--no-ansi'
STDERR.puts <<-DOC
WARNING: CocoaPods requires your terminal to be using UTF-8 encoding.
Consider adding the following to ~/.profile:
export LANG=en_US.UTF-8
DOC
else
STDERR.puts <<-DOC
\e[33mWARNING: CocoaPods requires your terminal to be using UTF-8 encoding.
Consider adding the following to ~/.profile:
export LANG=en_US.UTF-8
\e[0m
DOC
end
end
if $PROGRAM_NAME == __FILE__ && !ENV['COCOAPODS_NO_BUNDLER']
ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__)
require 'rubygems'
require 'bundler/setup'
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
elsif ENV['COCOAPODS_NO_BUNDLER']
require 'rubygems'
gem 'cocoapods'
end
STDOUT.sync = true if ENV['CP_STDOUT_SYNC'] == 'TRUE'
require 'cocoapods'
# 環境變數判斷是否配置了profile_filename,如果配置了按照配置內容生成
if profile_filename = ENV['COCOAPODS_PROFILE']
require 'ruby-prof'
reporter =
case (profile_extname = File.extname(profile_filename))
when '.txt'
RubyProf::FlatPrinterWithLineNumbers
when '.html'
RubyProf::GraphHtmlPrinter
when '.callgrind'
RubyProf::CallTreePrinter
else
raise "Unknown profiler format indicated by extension: #{profile_extname}"
end
File.open(profile_filename, 'w') do |io|
reporter.new(RubyProf.profile { Pod::Command.run(ARGV) }).print(io)
end
else
Pod::Command.run(ARGV)
end
可以發現,pod命令參數的解析運行是通過 Pod::Command.run(ARGV) 實現的。通過該線索,我們接著查看Pod庫源碼的Command類的run方法都做了什麼?該類在官方源碼的lib/cocoapods/command.rb 定義的,摘取了部分內容如下:
class Command < CLAide::Command
def self.run(argv)
ensure_not_root_or_allowed! argv
verify_minimum_git_version!
verify_xcode_license_approved!
super(argv)
ensure
UI.print_warnings
end
end
源碼中在進行命令解析之前,進行了前置條件檢查判斷: 1、檢查當前用戶是否為 root 用戶或是否在允許的用戶列表中 2、檢查當前系統上安裝的 Git 版本是否符合最低要求 3、檢查當前系統上的 Xcode 許可是否已經授權
如果都沒有問題,則會調用父類的 run
方法,而命令的解析可以看出來應該是在其父類 CLAide::Command
進行的,CLAide
是 CocoaPods
的命令行解析庫,在 command.rb
文件中,可以找到如下 Command
類的實現:
def initialize(argv)
argv = ARGV.coerce(argv)
@verbose = argv.flag?('verbose')
@ansi_output = argv.flag?('ansi', Command.ansi_output?)
@argv = argv
@help_arg = argv.flag?('help')
end
def self.run(argv = [])
plugin_prefixes.each do |plugin_prefix|
PluginManager.load_plugins(plugin_prefix)
end
# 轉換成ARGV對象
argv = ARGV.coerce(argv)
# 處理有效命令行參數
command = parse(argv)
ANSI.disabled = !command.ansi_output?
unless command.handle_root_options(argv)
# 命令處理
command.validate!
# 運行命令(由子類進行繼承實現運行)
command.run
end
rescue Object => exception
handle_exception(command, exception)
end
def self.parse(argv)
argv = ARGV.coerce(argv)
cmd = argv.arguments.first
# 命令存在,且子命令存在,進行再次解析
if cmd && subcommand = find_subcommand(cmd)
# 移除第一個參數
argv.shift_argument
# 解析子命令
subcommand.parse(argv)
# 不能執行的命令直接載入預設命令
elsif abstract_command? && default_subcommand
load_default_subcommand(argv)
# 無內容則創建一個comand實例返回
else
new(argv)
end
end
# 抽象方法,由其子類進行實現
def run
raise 'A subclass should override the `CLAide::Command#run` method to ' \
'actually perform some work.'
end
# 返回 [CLAide::Command, nil]
def self.find_subcommand(name)
subcommands_for_command_lookup.find { |sc| sc.command == name }
end
通過將 argv
轉換為 ARGV
對象(ARGV 是一個 Ruby 內置的全局變數,它是一個數組,包含了從命令行傳遞給 Ruby 程式的參數。例如:ARGV[0] 表示第一個參數,ARGV[1] 表示第二個參數,以此類推),然後獲取第一個參數作為命令名稱 cmd
。如果 cmd
存在,並且能夠找到對應的子命令 subcommand
,則將 argv
中的第一個參數移除,並調用 subcommand.parse(argv)
方法解析剩餘的參數。如果沒有指定命令或者找不到對應的子命令,但當前命令是一個抽象命令(即不能直接執行),並且有預設的子命令,則載入預設子命令並解析參數。否則,創建一個新的實例,並將 argv
作為參數傳遞給它。
最終在轉換完成之後,通過調用抽象方法run
調用子類的實現來執行解析後的指令內容。到這裡,順其自然的就想到了Cocoapods的相關指令實現必然繼承自了CLAide::Command
類,並實現了其抽象方法 run
。為了驗證這個推斷,我們接著看Cocoapods的源碼,在文件 Install.rb
中,有這個 Install 類的定義與實現,摘取了核心內容:
module Pod
class Command
class Install < Command
include RepoUpdate
include ProjectDirectory
def self.options
[
['--repo-update', 'Force running `pod repo update` before install'],
['--deployment', 'Disallow any changes to the Podfile or the Podfile.lock during installation'],
['--clean-install', 'Ignore the contents of the project cache and force a full pod installation. This only ' \
'applies to projects that have enabled incremental installation'],
].concat(super).reject { |(name, _)| name == '--no-repo-update' }
end
def initialize(argv)
super
@deployment = argv.flag?('deployment', false)
@clean_install = argv.flag?('clean-install', false)
end
# 實現CLAide::Command 的抽象方法
def run
# 驗證工程目錄podfile 是否存在
verify_podfile_exists!
# 獲取installer對象
installer = installer_for_config
# 更新pods倉庫
installer.repo_update = repo_update?(:default => false)
# 設置更新標識為關閉
installer.update = false
# 透傳依賴設置
installer.deployment = @deployment
# 透傳設置
installer.clean_install = @clean_install
installer.install!
end
end
end
end
通過源碼可以看出,cocoaPods
的命令解析是通過自身的 CLAide::Command
進行解析處理的,而最終的命令實現則是通過繼承自 Command
的子類,通過實現抽象方法 run
來實現的具體命令功能的。到這裡,關於Pod 命令的識別以及Pod 命令的解析與運行是不是非常清晰了。
階段性小結一下,我們在Terminal中進行pod命令運行的過程中,背後都經歷了哪些過程?整個運行過程可以簡述如下: 1、通過Gem生成在系統環境目錄下的可執行文件 pod,通過該文件
引導 RubyGems 查找 gems包目錄下的sepc配置對象,也即是cocoaPods的sepc配置對象 2、查找到配置對象,通過bin_file方法查找cocoaPods包路徑中bin下的可執行文件 3、運行rubygems對應cocoaPods的gem安裝包目錄中bin下的二進位可執行文件pod 4、通過執行 Pod::Command.run(ARGV)
解析命令與參數並找出最終的 Command 對象執行其run方法 5、在繼承自Command的子類的run實現中完成各個命令行指令的實現
以上的13階段實際上是Ruby的指令轉發過程,最終將命令轉發給了對應的gems包進行最終的處理。而45則是整個的處理過程。同時在Cocoapods的源碼實現中,可以發現每個命令都對應一個 Ruby 類,該類繼承自 CLAide::Command 類。通過繼承當前類,可以定義該命令所支持的選項和參數,併在執行命令時解析這些選項和參數。
二、Ruby 是如何動態生成可執行文件並集成到系統環境變數中的?
剛剛在上一節賣了個關子,在安裝完成Ruby的gem包之後,在系統環境變數中就自動生成了相關的可執行文件命令。那麼Ruby在這個過程中又做了什麼呢?既然是在gem安裝的時候會動態生成,不如就以gem的安裝命令 sudo gem install xxx 作為切入點去看相關的處理過程。我們進入系統環境變數路徑 /usr/bin 找到 Gem 可執行二進位文件,如下:
打開gem,它的內容如下:
#!/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/bin/ruby
#--
# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others.
# All rights reserved.
# See LICENSE.txt for permissions.
#++
require 'rubygems'
require 'rubygems/gem_runner'
require 'rubygems/exceptions'
required_version = Gem::Requirement.new ">= 1.8.7"
unless required_version.satisfied_by? Gem.ruby_version then
abort "Expected Ruby Version #{required_version}, is #{Gem.ruby_version}"
end
args = ARGV.clone
begin
Gem::GemRunner.new.run args
rescue Gem::SystemExitException => e
exit e.exit_code
end
可以發現最終通過執行 Gem::GemRunner.new.run args 來完成安裝,顯然安裝的過程就在 Gem::GemRunner 類中。依舊查看RubyGems的源碼,在 gem_runner.rb 中,有著以下的定義:
def run(args)
build_args = extract_build_args args
do_configuration args
begin
Gem.load_env_plugins
rescue StandardError
nil
end
Gem.load_plugins
cmd = @command_manager_class.instance
cmd.command_names.each do |command_name|
config_args = Gem.configuration[command_name]
config_args = case config_args
when String
config_args.split " "
else
Array(config_args)
end
Gem::Command.add_specific_extra_args command_name, config_args
end
cmd.run Gem.configuration.args, build_args
end
可以看出來命令的執行最終轉到了 cmd.run Gem.configuration.args, build_args
的方法調用上,cmd是通過 @command_manager_class
進行裝飾的類,找到其裝飾的地方如下:
def initialize
@command_manager_class = Gem::CommandManager
@config_file_class = Gem::ConfigFile
end
發現是它其實 Gem::CommandManager 類,接著查看一下 CommandManager 的 run 方法實現,在文件 command_manager.rb 中 ,有以下的實現內容:
##
# Run the command specified by +args+.
def run(args, build_args=nil)
process_args(args, build_args)
# 異常處理
rescue StandardError, Timeout::Error => ex
if ex.respond_to?(:detailed_message)
msg = ex.detailed_message(highlight: false).sub(/\A(.*?)(?: \(.+?\))/) { $1 }
else
msg = ex.message
end
alert_error clean_text("While executing gem ... (#{ex.class})\n #{msg}")
ui.backtrace ex
terminate_interaction(1)
rescue Interrupt
alert_error clean_text("Interrupted")
terminate_interaction(1)
end
def process_args(args, build_args=nil)
# 空參數退出執行
if args.empty?
say Gem::Command::HELP
terminate_interaction 1
end
# 判斷第一個參數
case args.first
when "-h", "--help" then
say Gem::Command::HELP
terminate_interaction 0
when "-v", "--version" then
say Gem::VERSION
terminate_interaction 0
when "-C" then
args.shift
start_point = args.shift
if Dir.exist?(start_point)
Dir.chdir(start_point) { invoke_command(args, build_args) }
else
alert_error clean_text("#{start_point} isn't a directory.")
terminate_interaction 1
end
when /^-/ then
alert_error clean_text("Invalid option: #{args.first}. See 'gem --help'.")
terminate_interaction 1
else
# 執行命令
invoke_command(args, build_args)
end
end
def invoke_command(args, build_args)
cmd_name = args.shift.downcase
# 查找指令,並獲取繼承自 Gem::Commands的實體子類(實現了excute抽象方法)
cmd = find_command cmd_name
cmd.deprecation_warning if cmd.deprecated?
# 執行 invoke_with_build_args 方法(該方法來自基類 Gem::Commands)
cmd.invoke_with_build_args args, build_args
end
def find_command(cmd_name)
cmd_name = find_alias_command cmd_name
possibilities = find_command_possibilities cmd_name
if possibilities.size > 1
raise Gem::CommandLineError,
"Ambiguous command #{cmd_name} matches [#{possibilities.join(", ")}]"
elsif possibilities.empty?
raise Gem::UnknownCommandError.new(cmd_name)
end
# 這裡的[] 是方法調用,定義在下麵
self[possibilities.first]
end
##
# Returns a Command instance for +command_name+
def [](command_name)
command_name = command_name.intern
return nil if @commands[command_name].nil?
# 調用 `load_and_instantiate` 方法來完成這個過程,並將返回的對象存儲到 `@commands` 哈希表中,這裡 ||= 是預設值內容,類似於OC中的?:
@commands[command_name] ||= load_and_instantiate(command_name)
end
# 命令分發選擇以及動態實例
def load_and_instantiate(command_name)
command_name = command_name.to_s
const_name = command_name.capitalize.gsub(/_(.)/) { $1.upcase } << "Command"
load_error = nil
begin
begin
require "rubygems/commands/#{command_name}_command"
rescue LoadError => e
load_error = e
end
# 通過 Gem::Commands 獲取註冊的變數
Gem::Commands.const_get(const_name).new
rescue StandardError => e
e = load_error if load_error
alert_error clean_text("Loading command: #{command_name} (#{e.class})\n\t#{e}")
ui.backtrace e
end
end
通過以上的源碼,可以發現命令的執行,通過調用 process_args
執行,然後在 process_args
方法中進行判斷命令參數,接著通過 invoke_command
來執行命令。在 invoke_command
內部,首先通過find_command
查找命令,這裡find_command
主要負責查找命令相關的執行對象,需要註意的地方在以下這句:
@commands[command_name] ||= load_and_instantiate(command_name)
通過以上的操作,返回當前命令執行的實體對象,而對應的腳本匹配又是如何實現的呢(比如輸入的命令是 gem install 命令)?這裡的 load_and_instantiate(command_name)
的方法其實就是查找實體的具體操作,在實現中通過以下的語句來獲取最終的常量的命令指令實體:
Gem::Commands.const_get(const_name).new
上面的語句是通過 Gem::Commands
查找類中的常量,這裡的常量其實就是對應gem相關的一個個指令,在gem中聲明瞭很多命令的常量,他們繼承自 Gem::Command
基類,同時實現了抽象方法 execute
,這一點很重要。比如在 install_command.rb
中定義了命令 gem install
的具體的實現:
def execute
if options.include? :gemdeps
install_from_gemdeps
return # not reached
end
@installed_specs = []
ENV.delete "GEM_PATH" if options[:install_dir].nil?
check_install_dir
check_version
load_hooks
exit_code = install_gems
show_installed
say update_suggestion if eglible_for_update?
terminate_interaction exit_code
end
在 invoke_command
方法中,最終通過 invoke_with_build_args
來最終執行命令,該方法定義Gem::Command
中,在 command.rb
文件中,可以看到內容如下:
def invoke_with_build_args(args, build_args)
handle_options args
options[:build_args] = build_args
if options[:silent]
old_ui = ui
self.ui = ui = Gem::SilentUI.new
end
if options[:help]
show_help
elsif @when_invoked
@when_invoked.call options
else
execute
end
ensure
if ui
self.ui = old_ui
ui.close
end
end
# 子類實現該抽象完成命令的具體實現
def execute
raise Gem::Exception, "generic command has no actions"
end
可以看出來,最終基類中的 invoke_with_build_args
中調用了抽象方法 execute
來完成命令的運行調用。在rubyGems裡面聲明瞭很多變數,這些變數在 CommandManager
中通過 run
方法進行命令常量實體的查找,最終通過調用繼承自 Gem:Command
子類的 execute
完成相關指令的執行。在rubyGems中可以看到很多變數,一個變數對應一個命令,如下所示:
到這裡,我們基本可以知道整個gem命令的查找到調用的整個流程。那麼 gem install
的過程中又是如何自動生成並註冊相關的gem命令到系統環境變數中的呢?基於上面的命令查找調用流程,其實只需要在 install_command.rb
中查看 execute
具體的實現就清楚了,如下:
def execute
if options.include? :gemdeps
install_from_gemdeps
return # not reached
end
@installed_specs = []
ENV.delete "GEM_PATH" if options[:install_dir].nil?
check_install_dir
check_version
load_hooks
exit_code = install_gems
show_installed
say update_suggestion if eglible_for_update?
terminate_interaction exit_code
end
def install_from_gemdeps # :nodoc:
require_relative "../request_set"
rs = Gem::RequestSet.new
specs = rs.install_from_gemdeps options do |req, inst|
s = req.full_spec
if inst
say "Installing #{s.name} (#{s.version})"
else
say "Using #{s.name} (#{s.version})"
end
end
@installed_specs = specs
terminate_interaction
end
def install_gem(name, version) # :nodoc:
return if options[:conservative] &&
!Gem::Dependency.new(name, version).matching_specs.empty?
req = Gem::Requirement.create(version)
dinst = Gem::DependencyInstaller.new options
request_set = dinst.resolve_dependencies name, req
if options[:explain]
say "Gems to install:"
request_set.sorted_requests.each do |activation_request|
say " #{activation_request.full_name}"
end
else
@installed_specs.concat request_set.install options
end
show_install_errors dinst.errors
end
def install_gems # :nodoc:
exit_code = 0
get_all_gem_names_and_versions.each do |gem_name, gem_version|
gem_version ||= options[:version]
domain = options[:domain]
domain = :local unless options[:suggest_alternate]
suppress_suggestions = (domain == :local)
begin
install_gem gem_name, gem_version
rescue Gem::InstallError => e
alert_error "Error installing #{gem_name}:\n\t#{e.message}"
exit_code |= 1
rescue Gem::GemNotFoundException => e
show_lookup_failure e.name, e.version, e.errors, suppress_suggestions
exit_code |= 2
rescue Gem::UnsatisfiableDependencyError => e
show_lookup_failure e.name, e.version, e.errors, suppress_suggestions,
"'#{gem_name}' (#{gem_version})"
exit_code |= 2
end
end
exit_code
end
可以看出,最終通過request_set.install
來完成最終的gem安裝,而request_set
是Gem::RequestSet
的實例對象,接著在 request_set.rb
中查看相關的實現:
##
# Installs gems for this RequestSet using the Gem::Installer +options+.
#
# If a +block+ is given an activation +request+ and +installer+ are yielded.
# The +installer+ will be +nil+ if a gem matching the request was already
# installed.
def install(options, &block) # :yields: request, installer
if dir = options[:install_dir]
requests = install_into dir, false, options, &block
return requests
end
@prerelease = options[:prerelease]
requests = []
# 創建下載隊列
download_queue = Thread::Queue.new
# Create a thread-safe list of gems to download
sorted_requests.each do |req|
# 存儲下載實例
download_queue << req
end
# Create N threads in a pool, have them download all the gems
threads = Array.new(Gem.configuration.concurrent_downloads) do
# When a thread pops this item, it knows to stop running. The symbol
# is queued here so that there will be one symbol per thread.
download_queue << :stop
# 創建線程並執行下載
Thread.new do
# The pop method will block waiting for items, so the only way
# to stop a thread from running is to provide a final item that
# means the thread should stop.
while req = download_queue.pop
break if req == :stop
req.spec.download options unless req.installed?
end
end
end
# 等待所有線程都執行完畢,也就是gem下載完成
threads.each(&:value)
# 開始安裝已經下載的gem
sorted_requests.each do |req|
if req.installed?
req.spec.spec.build_extensions
if @always_install.none? {|spec| spec == req.spec.spec }
yield req, nil if block_given?
next
end
end
spec =
begin
req.spec.install options do |installer|
yield req, installer if block_given?
end
rescue Gem::RuntimeRequirementNotMetError => e
suggestion = "There are no versions of #{req.request} compatible with your Ruby & RubyGems"
suggestion += ". Maybe try installing an older version of the gem you're looking for?" unless @always_install.include?(req.spec.spec)
e.suggestion = suggestion
raise
end
requests << spec
end
return requests if options[:gemdeps]
install_hooks requests, options
requests
end
可以發現,整個過程先是執行完被加在隊列中的所有的線程任務,然後通過遍歷下載的實例對象,對下載的gem進行安裝,通過 req.sepc.install options
進行安裝,這塊的實現在 specification.rb
中的 Gem::Resolver::Specification
定義如下:
def install(options = {})
require_relative "../installer"
# 獲取下載的gem
gem = download options
# 獲取安裝實例
installer = Gem::Installer.at gem, options
# 回調輸出
yield installer if block_given?
# 執行安裝
@spec = installer.install
end
def download(options)
dir = options[:install_dir] || Gem.dir
Gem.ensure_gem_subdirectories dir
source.download spec, dir
end
從上面的源碼可以知道,最終安裝放在了 Gem::Installer
的 install
方法中執行的。它的執行過程如下:
def install
# 安裝檢查
pre_install_checks
# 運行執行前腳本hook
run_pre_install_hooks
# Set loaded_from to ensure extension_dir is correct
if @options[:install_as_default]
spec.loaded_from = default_spec_file
else
spec.loaded_from = spec_file
end
# Completely remove any previous gem files
FileUtils.rm_rf gem_dir
FileUtils.rm_rf spec.extension_dir
dir_mode = options[:dir_mode]
FileUtils.mkdir_p gem_dir, :mode => dir_mode && 0o755
# 預設設置安裝
if @options[:install_as_default]
extract_bin
write_default_spec
else
extract_files
build_extensions
write_build_info_file
run_post_build_hooks
end
# 生成bin目錄可執行文件
generate_bin
# 生成插件
generate_plugins
unless @options[:install_as_default]
write_spec
write_cache_file
end
File.chmod(dir_mode, gem_dir) if dir_mode
say spec.post_install_message if options[:post_install_message] && !spec.post_install_message.nil?
Gem::Specification.add_spec(spec)
# 運行install的hook腳本
run_post_install_hooks
spec
這段源碼中,我們清晰的看到在執行安裝的整個過程之後,又通過 generate_bin
與generate_plugins
動態生成了兩個文件,對於 generate_bin
的生成過程如下:
def generate_bin # :nodoc:
return if spec.executables.nil? || spec.executables.empty?
ensure_writable_dir @bin_dir
spec.executables.each do |filename|
filename.tap(&Gem::UNTAINT)
bin_path = File.join gem_dir, spec.bindir, filename
next unless File.exist? bin_path
mode = File.stat(bin_path).mode
dir_mode = options[:prog_mode] || (mode | 0o111)
unless dir_mode == mode
require "fileutils"
FileUtils.chmod dir_mode, bin_path
end
# 檢查是否存在同名文件被覆寫
check_executable_overwrite filename
if @wrappers
# 生成可執行腳本
generate_bin_script filename, @bin_dir
else
# 生成符號鏈接
generate_bin_symlink filename, @bin_dir
end
end
end
在經過一系列的路徑判斷與寫入環境判斷之後,通過 generate_bin_script
生成動態可執行腳本文件,到這裡,是不是對關於gem進行安裝的時候動態生成系統可識別的命令指令有了清晰的認識與解答。其實本質是Ruby在安裝gem之後,會通過 generate_bin_script
生成可執行腳本並動態註入到系統的環境變數中,進而能夠讓系統識別到gem安裝的相關指令,為gem的功能觸發提供入口。以下是generate_bin_script
的實現:
##
# Creates the scripts to run the applications in the gem.
#--
# The Windows script is generated in addition to the regular one due to a
# bug or misfeature in the Windows shell's pipe. See
# https://blade.ruby-lang.org/ruby-talk/193379
def generate_bin_script(filename, bindir)
bin_script_path = File.join bindir, formatted_program_filename(filename)
require "fileutils"
FileUtils.rm_f bin_script_path # prior install may have been --no-wrappers
File.open bin_script_path, "wb", 0o755 do |file|
file.print app_script_text(filename)
file.chmod(options[:prog_mode] || 0o755)
end
verbose bin_script_path
generate_windows_script filename, bindir
end
關於腳本具體內容的生成,這裡就不再細說了,感興趣的話可以去官方的源碼中的installer.rb
中查看細節,摘取了主要內容如下:
def app_script_text(bin_file_name)
# NOTE: that the `load` lines cannot be indented, as old RG versions match
# against the beginning of the line
<<-TEXT
#{shebang bin_file_name}
#
# This file was generated by RubyGems.
#
# The application '#{spec.name}' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
#{gemdeps_load(spec.name)}
version = "#{Gem::Requirement.default_prerelease}"
str = ARGV.first
if str
str = str.b[/\\A_(.*)_\\z/, 1]
if str and Gem::Version.correct?(str)
#{explicit_version_requirement(spec.name)}
ARGV.shift
end
end
if Gem.respond_to?(:activate_bin_path)
load Gem.activate_bin_path('#{spec.name}', '#{bin_file_name}', version)
else
gem #{spec.name.dump}, version
load Gem.bin_path(#{spec.name.dump}, #{bin_file_name.dump}, version)
end
TEXT
end
def gemdeps_load(name)
return "" if name == "bundler"
<<-TEXT
Gem.use_gemdeps
TEXT
end
小結一下:之所以系統能夠識別我們安裝的gems包命令,本質原因是RubyGems在進行包安裝的時候,通過 generate_bin_script 動態的生成了可執行的腳本文件,並將其註入到了系統的環境變數路徑Path中。我們通過系統的環境變數作為引導入口,再間接的調取gem安裝包的具體實現,進而完成整個gem的功能調用。
三、CocoaPods是如何在Ruby的基礎上都做了自己的領域型DSL?
想想日常使用cocoaPods引入三方組件的時候,通常都在Podfile中進行相關的配置就行了,而在Podfile中的配置規則其實就是Cocoapods在Ruby的基礎上提供給開發者的領域型DSL,該DSL主要針對與項目的依賴庫管理進行領域規則描述,由CocoaPods的DSL解析器完成規則解析,最終通過pods的相關命令來完成整個項目的庫的日常管理。這麼說沒有什麼問題,但是Cocoapods的底層邏輯到底是什麼?也是接下來想重點探討挖掘的。
繼續從簡單 pod install
命令來一探究竟,通過第一節的源碼分析,我們知道,該命令最終會轉發到 cocoaPods
源碼下的 install.rb
中,直接看它的 run
方法,如下:
class Install < Command
···
def run
# 是否存在podfile文件
verify_podfile_exists!
# 創建installer對象(installer_for_config定義在基類Command中)
installer = installer_for_config
# 更新倉庫
installer.repo_update = repo_update?(:default => false)
# 關閉更新
installer.update = false
# 屬性透傳
installer.deployment = @deployment
installer.clean_install = @clean_install
# 執行安裝
installer.install!
end
def installer_for_config
Installer.new(config.sandbox, config.podfile, config.lockfile)
end
···
end
執行安裝的操作是通過 installer_for_config
方法來完成的,在方法實現中,實例了 Installer
對象,入參包括 sandbox
、podfile
、lockfile
,而這些入參均是通過 config
對象方法獲取,而podfile的獲取過程正是我們想要瞭解的,所以知道 config
的定義地方至關重要。在 command.rb
中我發現有如下的內容:
include Config::Mixin
這段代碼引入了 Config::Mixin
類,而他在 Config
中的定義如下:
class Config
···
module Mixin
def config
Config.instance
end
end
def self.instance
@instance ||= new
end
def sandbox
@sandbox ||= Sandbox.new(sandbox_root)
end
def podfile
@podfile ||= Podfile.from_file(podfile_path) if podfile_path
end
attr_writer :podfile
def lockfile
@lockfile ||= Lockfile.from_file(lockfile_path) if lockfile_path
end
def podfile_path
@podfile_path ||= podfile_path_in_dir(installation_root)
end
···
end
定義了一個名為Mixin
的模塊,其中包含一個名為config
的方法,在該方法中實例了 Config
對象。這裡定義了剛剛實例 Installer
的時候的三個入參。重點看一下 podfile
,可以看出 podfile
的實現中通過 Podfile.from_file(podfile_path)
來拿到最終的配置內容,那麼關於Podfile
的讀取謎底也就在這個 from_file
方法實現中了,通過搜索發現在Cocoapods
中的源碼中並沒有該方法的定義,只有以下的內容:
require 'cocoapods-core/podfile'
module Pod
class Podfile
autoload :InstallationOptions, 'cocoapods/installer/installation_options'
# @return [Pod::Installer::InstallationOptions] the installation options specified in the Podfile
#
def installation_options
@installation_options ||= Pod::Installer::InstallationOptions.from_podfile(self)
end
end
end
可以看到這裡的class Podfile
定義的Podfile
的原始類,同時發現源碼中引用了 cocoapods-core/podfile
文件,這裡應該能猜想到,關於 from_file
的實現應該是在cocoapods-core/podfile
中完成的。這個資源引入是 Cocoapods
的一個核心庫的組件,通過對核心庫 cocoapods-core
,進行檢索,發現在文件 podfile.rb
中有如下的內容:
module Pod
class Podfile
# @!group DSL support
include Pod::Podfile::DSL
···
def self.from_file(path)
path = Pathname.new(path)
# 路徑是否有效
unless path.exist?
raise Informative, "No Podfile exists at path `#{path}`."
end
# 判斷擴展名文件
case path.extname
when '', '.podfile', '.rb'
# 按照Ruby格式解析
Podfile.from_ruby(path)
when '.yaml'
# 按照yaml格式進行解析
Podfile.from_yaml(path)
else
# 格式異常拋出
raise Informative, "Unsupported Podfile format `#{path}`."
end
end
def self.from_ruby(path, contents = nil)
# 以utf-8格式打開文件內容
contents ||= File.open(path, 'r:utf-8', &:read)
# Work around for Rubinius incomplete encoding in 1.9 mode
if contents.respond_to?(:encoding) && contents.encoding.name != 'UTF-8'
contents.encode!('UTF-8')
end
if contents.tr!('“”‘’‛', %(""'''))
# Changes have been made
CoreUI.warn "Smart quotes were detected and ignored in your #{path.basename}. " \
'To avoid issues in the future, you should not use ' \
'TextEdit for editing it. If you are not using TextEdit, ' \
'you should turn off smart quotes in your editor of choice.'
end
# 實例podfile對象
podfile = Podfile.new(path) do
# rubocop:disable Lint/RescueException
begin
# 執行podFile內容(執行之前會先執行Podfile初始化Block回調前的內容)
eval(contents, nil, path.to_s)
# DSL的異常拋出
rescue Exception => e
message = "Invalid `#{path.basename}` file: #{e.message}"
raise DSLError.new(message, path, e, contents)
end
# rubocop:enable Lint/RescueException
end
podfile
end
def self.from_yaml(path)
string = File.open(path, 'r:utf-8', &:read)
# Work around for Rubinius incomplete encoding in 1.9 mode
if string.respond_to?(:encoding) && string.encoding.name != 'UTF-8'
string.encode!('UTF-8')
end
hash = YAMLHelper.load_string(string)
from_hash(hash, path)
end
def initialize(defined_in_file = nil, internal_hash = {}, &block)
self.defined_in_file = defined_in_file
@internal_hash = internal_hash
if block
default_target_def = TargetDefinition.new('Pods', self)
default_target_def.abstract = true
@root_target_definitions = [default_target_def]
@current_target_definition = default_target_def
instance_eval(&block)
else
@root_target_definitions = []
end
end
從上面的源碼可以知道,整個的 Podfile
的讀取流程如下: 1. 判斷路徑是否合法,不合法拋出異常 2. 判斷擴展名類型,如果是 '', '.podfile', '.rb' 擴展按照 ruby
語法規則解析,如果是yaml
則按照 yaml
文件格式解析,以上兩者如果都不是,則拋出格式解析異常 3. 如果解析按照 Ruby
格式解析的話過程如下:
• 按照utf-8
格式讀取 Podfile
文件內容,並存儲到 contents
中
• 內容符號容錯處理,主要涉及" “”‘’‛" 等 符號,同時輸出警告信息
• 實例 Podfile
對象,同時在實例過程中初始化 TargetDefinition
對象並配置預設的Target
信息
• 最終通過 eval(contents, nil, path.to_s)
方法執行 Podfile
文件內容完成配置記錄
這裡或許有一個疑問:Podfile裡面定義了 Cocoapods
自己的一套DSL語法
,那麼執行過程中是如何解析DSL語法
的呢?上面的源碼文件中,如果仔細查看的話,會發現有下麵這一行內容:
include Pod::Podfile::DSL
不錯,這就是DSL解析
的本體,其實你可以將DSL語法
理解為基於Ruby
定義的一系列的領域型方法,DSL的解析的過程本質是定義的方法執行的過程
。在Cocoapods
中定義了很多DSL語法
,定義與實現均放在了 cocoapods-core
這個核心組件中,比如在dsl.rb
文件中的以下關於Podfile
的 DSL
定義(摘取部分):
module Pod
class Podfile
module DSL
def install!(installation_method, options = {})
unless current_target_definition.root?
raise Informative, 'The installation method can only be set at the root level of the Podfile.'
end
set_hash_value('installation_method', 'name' => installation_method, 'options' => options)
end
def pod(name = nil, *requirements)
unless name
raise StandardError, 'A dependency requires a name.'
end
current_target_definition.store_pod(name, *requirements)
end
def podspec(options = nil)
current_target_definition.store_podspec(options)
end
def target(name, options = nil)
if options
raise Informative, "Unsupported options `#{options}` for " \
"target `#{name}`."
end
parent = current_target_definition
definition = TargetDefinition.new(name, parent)
self.current_target_definition = definition
yield if block_given?
ensure
self.current_target_definition = parent
end
def inherit!(inheritance)
current_target_definition.inheritance = inheritance
end
def platform(name, target = nil)
# Support for deprecated options parameter
target = target[:deployment_target] if target.is_a?(Hash)
current_target_definition.set_platform!(name, target)
end
def project(path, build_configurations = {})
current_target_definition.user_project_path = path
current_target_definition.build_configurations = build_configurations
end
def xcodeproj(*args)
CoreUI.warn '`xcodeproj` was renamed to `project`. Please update your Podfile accordingly.'
project(*args)
end
.......
end
end
看完 DSL的定義實現
是不是有種熟悉的味道,對於使用Cocoapods
的使用者而言,在沒有接觸Ruby
的情況下,依舊能夠通過對Podfile
的簡單配置來實現三方庫的管理依賴,不僅使用的學習成本低,而且能夠很容易的上手,之所以能夠這麼便捷,就體現出了DSL
的魅力所在。
對於**領域型語言**
的方案選用在很多不同的業務領域中都有了相關的應用,它對特定的**業務領域場景**
能夠提供**高效簡潔**
的實現方案,對使用者友好的同時,也能提供高質量的領域能力。**cocoapods**
就是藉助Ruby強大的面向對象的腳本能力完成**Cocoa庫**
管理的實現,有種偷梁換柱的感覺,為使用者提供了領域性語言,讓其更簡單更高效,尤其是使用者並沒有感知到其本質是**Ruby**
。記得一開始使用Cocoapods
的時候,曾經一度以為它是一種新的語言,現在看來都是Cocoapods的DSL
所給我們的錯覺,畢竟使用起來實在是太香了。
作者:京東零售 李臣臣
來源:京東雲開發者社區 轉載請註明來源