源码解读——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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# command.rb
module CLAide
class Command
class << self
# 命令是否为抽象命令(非叶子命令)
attr_accessor :abstract_command
# 为抽象命令进行代理的命令
attr_accessor :default_subcommand
# 命令功能摘要
attr_accessor :summary
# 命令功能描述
attr_accessor :description

# ...

# 命令的参数
def arguments
@arguments ||= []
end

# 命令的名字
def command
@command ||= name.split('::').last.gsub(/[A-Z]+[a-z]*/) do |part|
part.downcase << '-'
end[0..-2]
end

# 命令的选项(包括标志、选项)
def self.options
if root_command?
DEFAULT_ROOT_OPTIONS + DEFAULT_OPTIONS
else
DEFAULT_OPTIONS
end
end

DEFAULT_ROOT_OPTIONS = [
['--version', 'Show the version of the tool'],
]

DEFAULT_OPTIONS = [
['--verbose', 'Show more debugging information'],
['--no-ansi', 'Show output without ANSI codes'],
['--help', 'Show help banner of specified command'],
]

# ...
end
end
end

Command 抽象类描述了如何定义一个命令的组成。arguments 方法定义了命令包含哪些参数;self.options 方法定义了命令包含哪些选项, 由于标志是一种特殊的选项,这里将使用 self.options 统一描述标志和选项。除此之外,还描述了命令是否为抽象命令(即是否为非叶子命令)、命令的名称等。

下面,我们来看一下 pod update 命令所对应的 Command 具体类,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module Pod
class Command
class Update < Command

self.summary = 'Update outdated project dependencies and create new ' \
'Podfile.lock'

self.description = <<-DESC
Updates the Pods identified by the specified `POD_NAMES`, which is a
space-delimited list of pod names. If no `POD_NAMES` are specified, it
updates all the Pods, ignoring the contents of the Podfile.lock. This
command is reserved for the update of dependencies; pod install should
be used to install changes to the Podfile.
DESC

self.arguments = [
CLAide::Argument.new('POD_NAMES', false, true),
]

def self.options
[
["--sources=#{Pod::TrunkSource::TRUNK_REPO_URL}", 'The sources from which to update dependent pods. ' \
'Multiple sources must be comma-delimited'],
['--exclude-pods=podName', 'Pods to exclude during update. Multiple pods must be comma-delimited'],
['--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)
end

# ...
end
end
end

上述例子中,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 通过覆写 optionsarguments 等方法和属性,声明了一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# command.rb
def self.parse(argv)
# 通过解析 argv 获取到与 cmd 名称
argv = ARGV.coerce(argv)
cmd = argv.arguments.first
if cmd && subcommand = find_subcommand(cmd)
# 如果 cmd 存在对应的 Command 类,则更新 argv,继续解析命令
argv.shift_argument
subcommand.parse(argv)
elsif abstract_command? && default_subcommand
# 如果 cmd 为抽象命令且指定了默认命令,则返回默认命令继续解析参数
load_default_subcommand(argv)
else
# 初始化真正的 cmd 实例

new(argv)
end
end

命令查找的过程是一个 多叉树遍历 的过程,整个过程如下所示:

在命令查找过程中,首先查找命令的子命令,那么这里的子命令是从哪里来的呢?命令和子命令是如何关联的呢?这里其实巧妙利用了 Ruby 的语言特性,通过 Hook Method self.inherited 获取继承它的子类,并将其保存在 subcommands 数组中,如下所示:

1
2
3
4
# command.rb
def self.inherited(subcommand)
subcommands << subcommand
end

最终,命令查找会根据 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
2
3
4
# command.rb
def help!(error_message = nil)
invoked_command_class.help!(error_message)
end

help! 类方法定义如下,它会抛出一个标准错误对象 Help

1
2
3
4
# command.rb
def self.help!(error_message = nil, help_class = Help)
raise help_class.new(banner, error_message)
end

标准错误会被 Command 捕获,并调用如下所示的异常处理方法,它最终会在控制台打印错误信息,也可以选择打印出错堆栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
# command.rb
def self.handle_exception(command, exception)
if exception.is_a?(InformativeError)
puts exception.message
if command.nil? || command.verbose?
puts
puts(*exception.backtrace)
end
exit exception.exit_status
else
report_error(exception)
end
end

在调用 self.help! 方法时,我们会传入一个 banner 参数,事实上,这是一个 Banner 对象,banner 的定义如下:

1
2
3
def self.banner(banner_class = Banner)
banner_class.new(self).formatted_banner
end

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# command.rb
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!
# 命令运行
command.run
end
rescue Object => exception
handle_exception(command, exception)
end
end

PluginManager

整体流程中的第一个步骤就是加载插件,该任务由 PluginManager 负责完成,其仅加载命令类中指定前缀标识的文件下的命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# plugin_manager.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 进行查找匹配
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 # rubocop:disable RescueException
message = "\n---------------------------------------------"
message << "\nError loading the plugin `#{spec.full_name}`.\n"
message << "\n#{exception.class} - #{exception.message}"
message << "\n#{exception.backtrace.join("\n")}"
message << "\n---------------------------------------------\n"
warn message.ansi.yellow
false
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 的文件。

在 CocoaPods 中,其配置如下。它会加载所有包含 claide_plugin.rbcocoapods_plugins.rb 文件的 gem。通过在运行时的文件检查加载符合要求的相关命令。

1
self.plugin_prefixes = %w(claide cocoapods)

总结

本文,梳理了 CLAide 的工作流程,主要包含四个步骤:

  • 命令解析
  • 命令查找
  • 命令校验
  • 命令执行

这四个步骤各自由一些类负责实现。此外,在整体流程中,CLAide 会加载它所依赖的插件,由 PluginManager 实现,这也是插件功能的核心部分。

CLAide 采用的设计模式是模板方法模式,开发者实现具体的命令类,只需要覆写相关方法即可,CocoaPods 就是基于 CLAide 设计实现的,具有非常好的扩展性,并且支持插件机制。

在了解了 CLAide 的原理后,我们再去解读 CocoaPods 的源码,思路就会清晰很多。后续有时间,我们再来深入分析一下 CocoaPods 中的设计细节。

参考

  1. CLAide
  2. CocoaPods 命令解析 - CLAide