源码解读——CLAide
CLAide 是 CocoaPods 社区开发的一款命令行解析工具,其提供了一系列 API 允许我们快速创建自定义的命令行工具。iOS 开发者常用的依赖管理工具 CocoaPods 就是基于 CLAide 开发实现的。CocoaPods 通过各种子命令、参数、选项提供了诸多依赖管理相关功能。那么,CocoaPods 的各种命令、参数、选项如何映射到对应的功能实现呢?答案就是 CLAide。CLAide 的核心功能就是 解析不同的命令、参数、选项,并调用对应的功能实现。
下面,我们来看看 CLAide 究竟是如何实现的。
注:本文分析的 CLAide 源码版本是 1.0.3。
整体结构
如下所示为 CLAide 核心部分的类图。
CLAide 是使用 Ruby 编写的,Ruby 采用的是不允许具有多个父类的
单一继承 模型,但是通过利用
mixin,可以既保持单一继承的关系,又可以同时让多个类共享功能。CLAide
也大量使用了 mixin,比如:Help
类通过 mixin
InformativeError
,共享了其功能。Mixin 有点类似于 OC、swift
中的 protocol,因此在本文的类图中,我们将它等同为协议(接口)。
CLAide 以 Command
抽象类为核心,它使用 ARGV
类对命令进行解析,解析成多个分解项,包括:子命令、选项、参数
等。根据 Ruby 的语言特性,通过 子命令 查找到同名的
Command
具体类,同时,将
选项、参数 传递给对应的
Command
具体类。Command
具体类则覆写
run
方法实现具体的功能。
Command
抽象类在调用 run
之前,会先调用
validate
方法进行校验。校验的过程中会根据命令分解项,是否存在对应的
Command
具体类、参数是否符合预期等等。如果校验失败,则给出相似命令提示、命令使用的提示等等。
下面,我们将按照上述类图,结合 CocoaPods 的一些命令,对 CLAide 中的各个类进行介绍。
命令解析
CLAide 的核心功能之一是 命令解析,我们以
pod update
命令为例,如下所示。
在 CLAide 的定义中,一个完备的命令由以下 4 个部分组成:
- 一个或多个 命令(command):最左侧的命令称为 根命令(root command),其余的命令称为 子命令(subcommand)。在命令行中,一个命令必须紧跟另一个命令。
- 零个或多个 参数(argument):参数在命令中的位置是固定的,一般紧跟在最后一个子命令(或称叶子命令)。
- 零个或多个 标志(flag):以
--
为前缀的特定描述,用于控制功能的开关。标志在命令中的位置不固定。 - 零个或多个
选项(option):与标志的定义相同,区别在于选项会通过
=
拼接一个值。选项在命令中的位置不固定。
Command
CLAide 定义了一个 Command
抽象类,其中命令组成部分的相关定义如下。
1 | # command.rb |
Command
抽象类描述了如何定义一个命令的组成。arguments
方法定义了命令包含哪些参数;self.options
方法定义了命令包含哪些选项, 由于标志是一种特殊的选项,这里将使用
self.options
统一描述标志和选项。除此之外,还描述了命令是否为抽象命令(即是否为非叶子命令)、命令的名称等。
下面,我们来看一下 pod update
命令所对应的
Command
具体类,如下所示。
1 | module Pod |
上述例子中,Command
具体类
Pod::Command::Update
覆写了部分方法和属性,这些方法和属性也为后续的命令解析提供了数据源。当然,命令的帮助描述也是基于这些方法和属性实现的,如下所示为
pod update
命令的描述信息与定义的映射关系。
Argument
在上述例子中,Pod::Command::Update
使用通过覆写
arguments
属性来配置该命令支持的参数列表,类型为
Array<Argument>
,它最终会格式化成对应的信息,展示在
Usage
banner 中。
Argument
的构造方法如下: 1
2
3
4
5
6
7
8
9
10# argument.rb
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
输出的信息后面添加 ...
表示该参数为可重复参数,比如:
1
pod update Alamofire, RxSwift
ARGV
在上述例子中,Pod::Command::Update
通过覆写
options
、arguments
等方法和属性,声明了一个
pod update
命令的组成标准。在实际使用时,我们会输入命令、参数、选项。其中,这些输入内容由
ARGV
负责解析。
ARGV
使用
:arg
、:flag
、:option
分别表示参数(和命令)、标志、选项,并在内部调用一个私有模块
Parser
进行解析,解析方法如下所示: 1
2
3
4
5
6
7
8
9
10
11
12
13# argv.rb
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
ARGV
将解析结果存储在一个内部数组 @entries
中,以
pod update Alamofire --no-repo-update --exclude-pods=RxSwift
为例,其存储的内容如下: 1
2
3
4
5
6@entries = [
[:arg, "update"],
[:arg, "Alamofire"],
[:flag, ["repo-update", false]],
[:option, ["exclude-pods", "RxSwift"]]
]
此外,ARGV
还提供了一系列遍历方法,用于读取/操作参数、标志、选项等。
命令查找
我们注意到 ARGV
使用 :argv
同时表示参数和命令,这里为什么这么做?
我们知道选项和标志都是使用 --
作为前缀,选项使用
=
对 key 和 value
进行拆分,两者都是有明显的特征的,而命令和参数在表示上并没有明显区别。因此,ARGV
暂时将命令和参数都标记为 :arg
,具体的解析和区分交给
Command
中的 parse
方法来处理,该方法如下所示。
1 | # command.rb |
命令查找的过程是一个 多叉树遍历 的过程,整个过程如下所示:
在命令查找过程中,首先查找命令的子命令,那么这里的子命令是从哪里来的呢?命令和子命令是如何关联的呢?这里其实巧妙利用了
Ruby 的语言特性,通过 Hook Method self.inherited
获取继承它的子类,并将其保存在 subcommands
数组中,如下所示:
1 | # command.rb |
最终,命令查找会根据 ARGV
所解析的 :arg
找到对应的 叶子命令 或
抽象命令的代理命令。找到对应的命令后,使用
ARGV
中剩余的参数实例化命令。注意,在整个过程中,会逐步消耗
ARGV
中的解析结果。
命令校验
命令查找结果是创建一个 Command
对象,该命令对象会使用
ARGV
中的解析结果设置其所需要的参数、选项、标志。
之后,会进一步进行命令校验,校验的目的是判断命令行中是否有多余或错误的参数,具体如下:
1
2
3
4
5
6
7
8
9
10
11
12
13# command.rb
def validate!
# 如果有 --help 标志,则打印使用帮助
banner! if help?
unless @argv.empty?
# 如果 argv 存在多余的解析结果,则根据第一个多余的解析结果,决定打印哪项使用帮助
argument = @argv.remainder.first
help! ArgumentSuggester.new(argument, self.class).suggestion
end
# 如果命令是抽象命令,则打印使用帮助
help! if self.class.abstract_command?
end
首先,判断命令行中是够存在 --help
标志,如果存在,则打印使用帮助。
其次,判断是够存在剩余未解析的参数或选项,如果有,则取出第一个多余的参数或选项作为参数,初始化一个
ArgumentSuggester
对象,ArgumentSuggester
或给出一个建议性的提示信息,并将该信息作为参数传递给 help!
方法。
最后,判断命令是否是抽象命令,如果是,则直接调用 help!
方法。
Help
help!
方法的定义如下,其内部会调用一个
help!
类方法。
1 | # command.rb |
help!
类方法定义如下,它会抛出一个标准错误对象
Help
。
1 | # command.rb |
标准错误会被 Command
捕获,并调用如下所示的异常处理方法,它最终会在控制台打印错误信息,也可以选择打印出错堆栈。
1 | # command.rb |
Banner
在调用 self.help!
方法时,我们会传入一个
banner
参数,事实上,这是一个 Banner
对象,banner
的定义如下:
1 | def self.banner(banner_class = Banner) |
Banner
类基于 Command
类的相关信息,将命令的摘要、描述、参数、选项、标志进行了格式化封装。Banner
内部基于 ANSI
转义码对字符串进行封装,从而能够支持不同的颜色、字体等,如下所示是
pod update --help
的格式化内容输出。
命令运行
当命令校验通过后,会调用命令对象的 run
方法,Command
具体类通过覆写 run
方法实现具体逻辑。
整体流程
我们以
pod update Alamofire --no-repo-update --exclude-pods=RxSwift
为例, 当我们在命令行中输入命令后,会转换成如下所示的方法调用。
1
2# bin/pod
$ Pod::Command.run(["update", "Alamofire", "--no-repo-update", "--exclude-pods=RxSwift"])
这里的 run
方法是一个类方法,其定义如下:
1 | # command.rb |
PluginManager
整体流程中的第一个步骤就是加载插件,该任务由
PluginManager
负责完成,其仅加载命令类中指定前缀标识的文件下的命令。
1 | # plugin_manager.rb |
插件加载流程大体如下:
- 调用
load_plugins
并传入plugin_prefix
。 plugin_gems_for_prefix
对插件名进行处理,查找对应的文件。- 调用
safe_activate_and_require
对相应的 gem spec 进行校验,并对加载每个文件。
CocoaPods 的插件加载正是依托于 CLAide 的
load_plugins
,它会遍历所有的 RubyGem,并搜索这些 Gem
中是否包含名为 #{plugin_prefix}_plugin.rb
的文件。
在 CocoaPods 中,其配置如下。它会加载所有包含
claide_plugin.rb
或 cocoapods_plugins.rb
文件的 gem。通过在运行时的文件检查加载符合要求的相关命令。
1 | self.plugin_prefixes = %w(claide cocoapods) |
总结
本文,梳理了 CLAide 的工作流程,主要包含四个步骤:
- 命令解析
- 命令查找
- 命令校验
- 命令执行
这四个步骤各自由一些类负责实现。此外,在整体流程中,CLAide
会加载它所依赖的插件,由 PluginManager
实现,这也是插件功能的核心部分。
CLAide 采用的设计模式是模板方法模式,开发者实现具体的命令类,只需要覆写相关方法即可,CocoaPods 就是基于 CLAide 设计实现的,具有非常好的扩展性,并且支持插件机制。
在了解了 CLAide 的原理后,我们再去解读 CocoaPods 的源码,思路就会清晰很多。后续有时间,我们再来深入分析一下 CocoaPods 中的设计细节。