本文作者:Edmond
校对:冬瓜
CocoaPods 历险记 这个专题是 Edmond 和 冬瓜 共同撰写,对于 iOS / macOS 工程中版本管理工具 CocoaPods 的实现细节、原理、源码、实践与经验的分享记录,旨在帮助大家能够更加了解这个依赖管理工具,而不仅局限于 pod install 和 pod update。
在上文 整体把握 CocoaPods 核心组件 中,我们通过对 pod install 的流程的介绍,引出 CocoaPods 的各个核心组件的角色分工和其主要作用,希望通过对这些组件的使用和介绍来帮助大家更好的了解 CocoaPods 的完整工作流以及背后的原理。
今天我们主要聊一聊为 CocoaPods 提供的命令行解析的工具 CLAide,它是如何来解析 pod 命令以及 CocoaPods 的插件机制。
开始之前,我们需要了解一个 Ruby 的语言特性:Open Classes
在 Ruby 中,类永远是开放的,你总是可以将新的方法加入到已有的类中,除了在你自己的代码中,还可以用在标准库和内置类中,这个特性被称为 Open Classes。说到这里作为 iOS 工程师,脑中基本能闪现出 Objective-C 的 Category 或者 Swift 的 Extensions 特性。不过,这种动态替换方法的功能也称作 Monkeypatch。(???? 到底招谁惹谁了)
下面,我们通过在 Monkey.rb 文件中添加一个自定义类 Monkey 来简单看一下该特性,
class Monkey def eat puts "i have banana" end end monkey = Monkey.new class Monkey def eat puts "I have apple" end end monkey.eat直接在 VSCode 中运行,效果如下:
[Running] ruby "/Users/edmond/Desktop/Monkey.rb" I have apple可以看到,Monkey 类的实例输出已经改为 I have apple。
需要注意,即使是已经创建好的实例,方法替换同样是生效的。另外 ⚠️ Open Class 可以跨文件、跨模块进行访问的,甚至对 Ruby 内置方法的也同样适用 (谨慎)。
这强大的功能让我们可以很容易的对三方模块进行扩展,这也是 CocoaPods 的插件体系所依赖的基础。
举个例子,在 CocoaPods 主仓库 cocoapods/downloader.rb 中定义了一些 download 方法:
module Pod module Downloader # ... end end但是在 cocoapods-downloader 模块中,Downloader 这个 module 的方法并不能满足全部需求,于是在 cocoapods-downloader/api.rbapi.rb 中就对其进行了扩展:
module Pod module Downloader module API def execute_command(executable, command, raise_on_failure = false) # ... end # ... end endCLAide 虽然是一个简单的命令行解释器,但它提供了功能齐全的命令行界面和 API。它不仅负责解析我们使用到的 Pods 命令,例如:pod install, pod update 等,还可用于封装常用的一些脚本,将其打包成简单的命令行小工具。
备注:所谓命令行解释器就是从标准输入或者文件中读取命令并执行的程序。详见 Wikiwand 上的解释。
我们先通过 pod --help 来查看 CLAide 的真实输出效果:
$ pod Usage: $ pod COMMAND CocoaPods, the Cocoa library package manager. Commands: + cache Manipulate the CocoaPods cache + deintegrate Deintegrate CocoaPods from your project + env Display pod environment + init Generate a Podfile for the current directory ... Options: --allow-root Allows CocoaPods to run as root --silent Show nothing --version Show the version of the tool ...上面所展示的 Usage、Commands、Options p 及其内容均是由 CALide 的输出模版 Banner 来完成的。
CALide 提供了 Command 基类帮助我们快速定义出标准且美观的命令。除了 pod 命令之外,例如:Xcodeproj 所提供的命令也是由 CALide 来实现的。
CALide 还提供了一套插件加载机制在命令执行前获取所有插件中的命令,例如:cocoapods-packeger 提供的 pod package NAME [SOURCE] 就是从其 source code 中的 lib/pod/commnad/package.rb 读取出来的,它令我们仅需一份 podspec 信息,即可完成 CocoaPods 依赖库的封装。
对于 Ruby 的项目结构,在 Rubygems.org 中有 文件结构手册 这个标准供大家参考学习。
首先来看 CALide 项目的文件入口 lib/calide.rb :
module CLAide VERSION = '1.0.3'.freeze require 'claide/ansi' require 'claide/argument' require 'claide/argv' require 'claide/command' require 'claide/help' require 'claide/informative_error' end我们接下来分析一下 lib/cladie/ 目录下的相关代码。
Command 是用于构建命令行界面的基础抽象类。所有我们添加的命令都需要继承自 Command,这些子类可以嵌套组合成更加精细的命令。
pod 命令正是由多个 Pod::Command < CLAide::Command 的子类组合而成的 abstract command。
当然 pod 的 subcommand 同样也能声明为 abstact command,通过这样的方式我们就能达到多级嵌套命令的效果。有抽象命令当然也需要有具体执行任务的 normal command。
举个例子:
$ pod update --help Usage: $ pod update [POD_NAMES ...] Updates the Pods identified by the specified `POD_NAMES` Options: --verbose Show more debugging information --no-ansi Show output without ANSI codes --help Show help banner of specified command对应的, pod update 这个命令的逻辑在 CLAide 中就是如下描述:
module Pod class Command class Update < Command self.arguments = [ CLAide::Argument.new('POD_NAMES', false, true), ] self.description = <<-DESC Updates the Pods identified by the specified `POD_NAMES`. DESC def self.options [ ["--sources", 'The sources from which to update dependent pods'], ['--exclude-pods', 'Pods to exclude during update'], ['--clean-install', 'Ignore the contents of the project cache and force a full pod installation'] ].concat(super) end end end end当我们如此描述后,CLAide 会对这个类进行以下方式的解析:
此外,Command class 提供了大量基础功能,其中最核心的方法为 run,会在 normal command 小节会介绍。对于任何命令类型都可以设置以下几个属性和方法:
summary: 用于简单描述该命令的作用
options: 用于返回该命令的可选项及对应的描述,返回的 options 需要通过调用 super 插入到父类的可选项前
initialize: 如果需要获取命令行传递的实参,需要通过重载 initialize 方法来获取
validate!: 用于检查输入实参的有效性,如果校验失败,会通过调用 help! 方法来输出帮助信息
help!:用于错误信息的处理和展示
Tips:这里我们说的 abstract command 和 normal command 均是通过 Command 来实现的,只是它们的配置不同。
abstract command 为不提供具体命令实现的抽象容器命令类,不过它可以包含一个或多个的 subcommands。
我们可以指定 subcommands 中的 normal command 为默认命令,就能将 abstract command 作为作为普通命令直接执行了。
抽象命令的现实比较简单:
self.abstract_command = true仅需设置 abstract_command,然后就可以继承它来实现普通命令或者多级嵌套的抽象命令。
以 pod 命令的实现为例:
module Pod class Command < CLAide::Command require 'cocoapods/command/install' # 1 require 'cocoapods/command/update' # ... self.abstract_command = true self.command = 'pod' # ... end上述通过 require 引入的 update、install 等子命令都是继承自 Pod::Command 的 normal command。
相对于抽象命令,普通命令就需要设置传递实参的名称和描述,以及重载 run 方法。
Arguments
arguments 用于配置该命令支持的参数列表的 banner 输出,类型为 Array<Argument>,它最终会格式化成对应的信息展示在 Usage banner 中。
我们来看 pod update 的 arguments 是如何配置的:
self.arguments = [ CLAide::Argument.new('POD_NAMES', false, true), ]其中 Argument 的构造方法如下:
module CLAide class Argument def initialize(names, required, repeatable = false) @names = Array(names) @required = required @repeatable = repeatable end end这里传入的 names 就是在 Usage banner 中输出的 [POD_NAMES ...] 。
require 表示该 Argument 是否为必传参数,可选参数会用 [ ] 将其包裹起来。也就是说 pod update 命令默认是不需要传 POD_NAMES
repeatable 表示该 Argument 是否可以重复多次出现。如果设置为可重复,那么会在 names 的输出信息后面会添加 ... 表示该参数为复数参数。
举个例子:
$ pod update Alamofire, SwiftyJSON我们可以指定 pod update 仅更新特定的依赖库,如果不传 POD_NAMES 将进行全量更新。
在 Command 类中定义了两个 run 方法:
def self.run(argv = []) # 根据文件前缀来匹配对应的插件 plugin_prefixes.each do |plugin_prefix| PluginManager.load_plugins(plugin_prefix) end argv = ARGV.coerce(argv) # 解析 argument 生成对应的 command instance 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 run raise 'A subclass should override the `CLAide::Command#run` method to ' \ 'actually perform some work.' end这里的 self.run 方法是 class method,而 run 是 instanced method。
对于 Ruby 不太熟悉的同学可以看看这个:What does def self.function name mean?
作为 Command 类的核心方法,类方法 self.run 将终端传入的参数解析成对应的 command 和 argv,并最终调用 command 的实例方法 run 来触发真正的命令逻辑。因此,子类需要通过重载 run 方法来完成对应命令的实现。
那么问题来了,方法 Command::parse 是如何将 run 的类方法转换为实例方法的呢?
def self.parse(argv) # 通过解析 argv 获取到与 cmd 名称 argv = ARGV.coerce(argv) cmd = argv.arguments.first # 如果 cmd 对应的 Command 类,则更新 argv,继续解析命令 if cmd && subcommand = find_subcommand(cmd) argv.shift_argument subcommand.parse(argv) # 如果 cmd 为抽象命令且指定了默认命令,则返回默认命令继续解析参数 elsif abstract_command? && default_subcommand load_default_subcommand(argv) else # 初始化真正的 cmd 实例 new(argv) end end可以说,CLAide 的命令解析就是一个多叉树遍历,通过分割参数及遍历 CLAide::Command 的子类,最终找到用户输入的 normal command 并初始化返回。这里还有一个知识点就是,CLAide::Command 是如何知道有哪些子类集成它的呢?
def self.inherited(subcommand) subcommands << subcommand end这里利用了 Ruby 提供的 Hook Method self.inherited 来获取它所继承的子类,并将其保存在 subcommands。
另外,这里在真正执行 self.run 方法之前会遍历当前项目所引入的 Gems 包中的指定目录下的命令插件文件,并进行插件加载,具体内容将在 PluginManager 中展开。
CLAide 提供了专门的类 ARGV 用于解析命令行传入的参数。主要功能是对 Parse 解析后的 tuple 列表进行各种过滤、CURD 等操作。
按照 CALide 的定义参数分三种类型:
arg: 普通的实参,所谓的实参就是直接跟在命令后面的,且不带任何 -- 修饰的字符
flag: 简单理解 flag 就是限定为 bool 变量的 option 类型参数,如果 flag 前面添加带 --no- 则值为 false,否则为 true
option: 可选项参数,以 -- 为前缀且以 = 作为分割符来区分 key 和 value
而在 ARGV 内部又提供了私有工具类 Parser 来解析终端的输入,其核心方法为 parse:
module Parser def self.parse(argv) entries = [] copy = argv.map(&:to_s) double_dash = false while argument = copy.shift next if !double_dash && double_dash = (argument == '--') type = double_dash ? :arg : argument_type(argument) parsed_argument = parse_argument(type, argument) entries << [type, parsed_argument] end entries end # ,,, endparse 的返回值为 [Array<Array<Symbol, String, Array>>] 类型的 tuple,其中 tuple 的第一个变量为实参的类型,第二个才是对应的实参。
依旧以 pod update 为例:
pod update Alamofire --no-repo-update --exclude-pods=SwiftyJSON解析后,输出的 tuple 列表如下:
[ [:arg, "Alamofire"], [:flag, ["repo-update", false]], [:option, ["exclude-pods", "SwiftyJSON"]] ]接下来,我们再来聊聊 CLAide 提供的格式化效果的 banner。
那什么是 banner 呢?回看第一个例子 pod --help 所输出的帮助信息,它分为三个 Section:
Usage:用于描述该命令的用法
Commands:用于描述该命令所包含的子命令,没有则不显示。在子命令前面存在两种类型的标识
+ :用于强调该 command 是单独添加的子命令
> :用于表示指引的意思,表示该 command 是当前命令的默认实现
Options:用于描述该命令的可选项
这三段帮助信息就是对应的不同的 banner。
CLAide 对于输出的 banner 信息提供了 ANSI 转义,用于在不同的终端里显示富文本的效果。banner 的主要格式化效果如下:
对于 Section 标题:Usage、Commands、Options 添加了下划线且加粗处理
Command 配置为绿色
Options 配置为蓝色
提示警告信息配置为黄色
错误信息则是红色
对于这些配色方案,CLAide 提供了 String 的 convince method 来完成 ANSI 转义:
class String def ansi CLAide::ANSI::StringEscaper.new(self) end end例如:
"example".ansi.yellow #=> "\e[33mexample\e[39m" "example".ansi.on_red #=> "\e[41mexample\e[49m" "example".ansi.bold #=> "\e[1mexample\e[21m"对于 Banner 的一些高亮效果也提供了 convince method:
def prettify_title(title) title.ansi.underline end def prettify_subcommand(name) name.chomp.ansi.green end def prettify_option_name(name) name.chomp.ansi.blue endPluginManager 是 Command 的管理类,会在第一次运行命令 self.run 时进行加载,且仅加载命令类中指定前缀标识的文件下的命令。让我们先看 PluginManager.rb 的核心实现:
def self.load_plugins(plugin_prefix) loaded_plugins[plugin_prefix] ||= plugin_gems_for_prefix(plugin_prefix).map do |spec, paths| spec if safe_activate_and_require(spec, paths) end.compact end def self.plugin_gems_for_prefix(prefix) glob = "#{prefix}_plugin#{Gem.suffix_pattern}" Gem::Specification.latest_specs(true).map do |spec| matches = spec.matches_for_glob(glob) [spec, matches] unless matches.empty? end.compact end def self.safe_activate_and_require(spec, paths) spec.activate paths.each { |path| require(path) } true rescue Exception => exception # ... end整体的流程大致是:
调用 load_plugins 并传入 plugin_prefix
plugin_gems_for_prefix 对插件名进行处理,取出我们需要加载的文件
调用 safe_activate_and_require 进行对应的 gem spec 检验并对每个文件进行加载
CocoaPods 的插件加载正是依托于 CLAide 的 load_plugins,它会遍历所有的 RubyGem,并搜索这些 Gem 中是否包含名为 #{plugin_prefix}_plugin.rb 的文件。例如,在 Pod 命令的实现中有如下配置:
self.plugin_prefixes = %w(claide cocoapods)也就是说在 Pod 命令执行前,它会加载所有包含 claide_plugin.rb 或 cocoapods_plugin.rb 文件的 Gem。通过在运行时的文件检查来加载符合要求的相关命令。
最后一节让我们一起来创建一个 CLAide 命令。需求是希望实现一个自动 ???? 贩卖机,它有如下功能:主要售卖 ☕️ 和 ????,这两种 ???? 都可以按需选择是否添加 ???? 和 ????,对于 ???? 还可以选择不同的甜度。
☕️:对于咖啡,我们提供了:BlackEye、Affogato、CaPheSuaDa、RedTux 的口味
????:对于茶,你可以选择不同的品种,有黑茶、绿茶、乌龙茶和白茶,同时茶还提供了加 ???? 的选项
基于上述构想,我们最终的 BeverageMaker 目录将由以下文件组成:
. ├── BeverageMaker.gemspec │ # ... ├── exe │ └── beverage-maker ├── lib │ ├── beveragemaker │ │ ├── command │ │ │ ├── coffee.rb # 包含 abstract command 以及用于制作不同咖啡的 normal command │ │ │ ├── maker.rb # Command 抽象类 │ │ │ └── tea.rb # normal command, 不同种类的 ???? 通过参数配置来完成 │ │ ├── command.rb │ │ └── version.rb │ └── beveragemaker.rb └── spec ├── BeverageMaker_spec.rb └── spec_helper.rb首先,我们使用 bundler gem GEM_NAME 命令生成一个模版项目,项目取名为 BeverageMaker。
$ bundle gem BeverageMaker Creating gem 'BeverageMaker'... MIT License enabled in config Code of conduct enabled in config create BeverageMaker/Gemfile create BeverageMaker/lib/BeverageMaker.rb create BeverageMaker/lib/BeverageMaker/version.rb create BeverageMaker/BeverageMaker.gemspec create BeverageMaker/Rakefile # ... Initializing git repo in ~/$HOME/Desktop/BeverageMaker Gem 'BeverageMaker' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.html生成的项目中需要将 BeverageMaker.gemspec 文件所包含 TODO 的字段进行替换,作为示例项目相关链接都替换为个人主页了 ????。
另外,需要添加我们的依赖 'claide', '>= 1.0.2', '< 2.0' 和 'colored2', '~> 3.1'。
colored2 用于 banner 信息的 ANSI 转义并使其能在终端以富文本格式输出。
最终 .gempsc 配置如下:
require_relative 'lib/BeverageMaker/version' Gem::Specification.new do |spec| spec.name = "BeverageMaker" spec.version = BeverageMaker::VERSION spec.authors = ["Edmond"] spec.email = ["chun574271939@gmail.com"] spec.summary = "BeverageMaker" spec.description = "BeverageMaker" spec.homepage = "https://looseyi.github.io" spec.license = "MIT" spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") spec.metadata["allowed_push_host"] = "https://looseyi.github.io" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://looseyi.github.io" spec.metadata["changelog_uri"] = "https://looseyi.github.io" spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } end # 1 spec.bindir = "exe" spec.executables = "beverage-maker" spec.require_paths = ["lib"] spec.add_runtime_dependency 'claide', '>= 1.0.2', '< 2.0' spec.add_runtime_dependency 'colored2', '~> 3.1' end通过修改 .gemspec 的 bindir 和 executables 字段,把最终的 binary 执行文件暴露给用户,使其成为一个真正的 CLI:
spec.bindir = "exe" spec.executables = "beverage-maker"在默认生成的模版中指定的 bindir 为 /bin 目录,这里我们替换为新建的 exe 目录,并在 exe 目录下创建一个名为 beverage-maker 的文件,它将作为 CLI 的入口,其内容如下:
#!/usr/bin/env ruby require 'beveragemaker' BeverageMaker::Command.run(ARGV)为了让 Demo 结构清晰,我们将不能类型的饮料制作分到了不同的文件和命令类中。
先来实现 beverage-maker 命令,它是一个 abstract command,其内容如下:
require 'claide' require 'colored2' module BeverageMaker # 引入具体的 coffee & tea maker require 'beveragemaker/command/coffee' require 'beveragemaker/command/tea' class Command < CLAide::Command self.command = 'beverage-maker' self.abstract_command = true self.description = 'Make delicious beverages from the comfort of your terminal.' def self.options [ ['--no-milk', 'Don’t add milk to the beverage'], ['--sweetener=[sugar|honey]', 'Use one of the available sweeteners'], ].concat(super) end def initialize(argv) @add_milk = argv.flag?('milk', true) @sweetener = argv.option('sweetener') super end def validate! super if @sweetener && !%w(sugar honey).include?(@sweetener) help! "`#{@sweetener}' is not a valid sweetener." end end def run puts '* Boiling water…' sleep 1 if @add_milk puts '* Adding milk…' sleep 1 end if @sweetener puts "* Adding #{@sweetener}…" sleep 1 end end end end正常来说,对于不同口味的咖啡和茶是可以用相同的命令模式来实现的,不过为了更好的展示 CLAide 的效果,我们将咖啡的生产配置为 abstact command,对于不同口味的咖啡,需要实现不同的 normal command。而茶的生产直接通过 normal command 实现,不同品种的茶叶会以参数的形式来配置。
接着添加 ☕️ 的代码
class Coffee < Command # ... self.abstract_command = true def run super puts "* Grinding #{self.class.command} beans…" sleep 1 puts '* Brewing coffee…' sleep 1 puts '* Enjoy!' end class BlackEye < Coffee self.summary = 'A Black Eye is dripped coffee with a double shot of ' \ 'espresso' end # ... end我们知道,对于正常发布的 gem 包,可以直接通过 gem install GEM_NAME 安装。
而我们的 Demo 程序并未发布,那要如何安装使用呢?幸好 Gem 提供了源码安装的方式:
gem build *.gemspec gem install *.gemgem build 可以根据一个 .gemspec 生成一个 .gem 文件供 gem 安装,所以在拥有源码的情况下,执行上面命令就可以安装了。
执行结果如下:
$ gem build *.gemspec WARNING: description and summary are identical WARNING: See http://guides.rubygems.org/specification-reference/ for help Successfully built RubyGem Name: BeverageMaker Version: 0.1.0 File: BeverageMaker-0.1.0.gem $ gem install *.gem Successfully installed BeverageMaker-0.1.0 Parsing documentation for BeverageMaker-0.1.0 Done installing documentation for BeverageMaker after 0 seconds 1 gem installed编译通过!
现在可以开始我们的 ???? 制作啦!
$ beverage-maker Usage: $ beverage-maker COMMAND Make delicious beverages from the comfort of yourterminal. Commands: + coffee Drink brewed from roasted coffee beans Options: --no-milk Don’t add milk to the beverage --sweetener=[sugar|honey] Use one of the available sweeteners --version Show the version of the tool --verbose Show more debugging information --no-ansi Show output without ANSI codes --help Show help banner of specified command来一杯 black-eye ☕️,休息一下吧!
$ beverage-maker coffee black-eye * Boiling water… * Adding milk… * Grinding black-eye beans… * Brewing coffee… * Enjoy!如需本文的 Demo 代码,请访问:https://github.com/looseyi/BeverageMaker
本文简单分析来 CLAide 的实现,并手动制作了一款 ???? 贩卖机来展示 CALide 的命令配置。主要感受如下:
通过对源码对阅读,终于了解了对 pod 命令的的正确使用姿势
仅需简单配置 Command banner,就能有比较精美的终端输出效果和帮助提示等
提供的抽象命令功能,方便的将相关逻辑收口到统一到命令中,方便查阅
从侧面简单了解了,如何在终端输出带富文本效果的提示信息
这里罗列了四个问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入 收藏 再次阅读:
CLAide 预设的 banner 有哪些,其作用分别是什么 ?
CALide 中设定的 Argument 有几种类型,区别是什么 ?
CALide 中抽象命令的和普通命令的区别 ?
要实现 CLI 需要修改 .gemspec 中的哪些配置 ?