如何使用 CLAide 开发命令行工具?

CLAide 一文中,我们了解到 CocoaPods 是基于 CLAide 开发的一款依赖管理工具,也是一款命令行工具。本文,我们将基于 CLAide 开发一款简易的命令行工具——饮料制作器(BeverageMaker)。

在本项目中,我们将使用 bundler 作为项目管理工具和依赖管理工具,其实本质上就是使用 bundle 开发一个 gem 工具。

本文代码传送门 https://github.com/baochuquan/BeverageMaker

目标

我们希望饮料制作器(BeverageMaker)能够制作两种类型的饮料:咖啡、茶。对此,我们需要分别实现两个子命令:coffeetea

对于咖啡,我们提供多种口味,如:BlackEye、Affogato、CaPheSuaDa、RedTux。对此,我们希望为 coffee 提供多个子命令,分别是:black-eyeaffogatoca-phe-sua-dared-tux

对于茶,我们提供多种口味,如:Black、Green、Oolong、White。这里,我们希望为 tea 提供多个参数,分别是:blackgreenoolongwhite

无论是咖啡还是茶,我们都希望两者支持选择是够添加牛奶、添加糖或蜂蜜作为甜味剂。对此,我们希望支持一个标志 --no-milk 表示是否添加牛奶,支持一个选项 --sweetener,其值为 superhoney

此外,对于茶,我们希望它支持一个额外的标志 --iced 表示是否加冰。

生成模版项目

首先,我们使用 bundle gem GEM_NAME 命令生成一个模版项目,项目名为:BeverageMaker

1
$ bundle gem BeverageMaker

上述命令会在当前目录下生成一个项目,项目的目录结构如下所示:

命令会自动生成一个脚手架模板项目,主要包含以下文件:

  • BeverageMaker.gemspec:Gem Specification 文件,定义 Rubygem 的基本信息,如:名称、描述信息、gem 主页、 所需要的依赖等等。
  • CODE_OF_CONDUCT.md:关于代码贡献者需要遵循的行为准则。
  • Gemfile:用于管理项目依赖。该文件中有一行代码是 gemspec,其会调用 BeverageMaker.gemspec,从而导入项目的依赖项。因此,最佳实践是在 gemspec 中指定项目所依赖的所有 gem。
  • LICENSE.txt:默认指定项目为 MIT 协议。
  • Rakefile:Ruby 中的构建脚本,类似于 C/C++ 中的 Makefile。通过 Bundler::GemHelper.install_tasks 可以添加 buildinstallrelease 等任务。
    • build 任务:构建当前版本的 gem,并将其存储在 pkg 目录下。
    • install 任务:构建 gem,并将其安装在我们的系统中。
    • release 任务:将 gem 推送到 Rubygems,从而对外公开发布。
  • lib/BeverageMaker.rb:定义 gem 代码的主文件。当 gem 被加载时,Bundler 会请求这个文件。该文件定义了一个 module,其可以作为 gem 代码的命名空间。因此,最佳实践是把代码定义在 module 中。
  • lib/BeverageMaker 目录:该目录下包含了 gem 的所有代码。lib/BeverageMaker.rb 文件用于设置 gem 的环境,而它的所有代码都在 lib/BeverageMaker 目录下。如果 gem 有多种功能,我们可以进一步对其拆分子目录。
  • lib/BeverageMaker/version.rb:内部通过一个 VERSION 常量定义 gem 的版本。文件由 BeverageMaker.gemspec 进行加载,从而为 gem 指定版本。当发布新版本时,可以修改 VERSION 的值来指定新的版本号。
  • spec 目录:用于存放测试文件。

修改 gemspec 配置

初始化模板项目后,我们需要对 BeverageMaker.gemspec 文件所包含的 TODO 字段进行替换。此外,我们还需要添加项目依赖:'claide', '>= 1.0.2', '< 2.0''colored2', '~> 3.1'

colored2 用于 banner 信息的 ANSI 转义,从而能够支持以富文本格式在终端输出。

修改后的 BeverageMaker.gemspec 配置如下所示:

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
require_relative 'lib/BeverageMaker/version'

Gem::Specification.new do |spec|
spec.name = "BeverageMaker"
spec.version = BeverageMaker::VERSION
spec.authors = ["baochuquan"]
spec.email = ["baochuquan@163.com"]

spec.summary = "Beverage Maker"
spec.description = "A Command Line Tool Example"
spec.homepage = "https://github.com/baochuquan/BeverageMaker"
spec.license = "MIT"
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")

spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/baochuquan/BeverageMaker"
spec.metadata["changelog_uri"] = "https://github.com/baochuquan/BeverageMaker"

# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
end
spec.bindir = "exe"
spec.executables = "beverage-maker"
spec.require_paths = ["lib"]

spec.add_dependency 'claide', '~> 1.0.3'
end

定义命令行入口

我们通过修改 BeverageMaker.gemspec 来定义命令行入口的位置和名称,如下所示:

1
2
spec.bindir         = "exe"
spec.executables = "beverage-maker"

模板自动生成的 BeverageMaker.gemspec 配置中,bindir 默认为 exe,表示二进制(binary)文件的存储目录为项目根目录下的 exe 目录;executables 默认为 spec.files.grep(%r{^exe/}) { |f| File.basename(f) },表示 exe 目录下的所有文件。我们可以将其改写成指定名称 beverage-maker,该文件也是命令行的入口文件。

定义好命令行的入口与名称后,我们需要在 exe 目录下创建一个同名的文件 beverage-maker,将其作为命令行入口,其定义如下:

1
2
3
4
5
#!/usr/bin/env ruby

require 'BeverageMaker'

BeverageMaker::Command.run(ARGV)

注意,由于 beverage-maker 是可执行文件,需要确保其具有可执行权限,我们通过执行 chmod +x beverage-maker 命令为其添加可执行权限。

实现根命令

上述命令入口中,BeverageMaker::CommandCLAide::Command 的子类,作为根命令,它也是一个抽象类。由于,coffeetea 子类都支持 --no-milk--sweetener 选项,我们可以将这些选项定义在根命令的类中。

对此,我们新建一个文件 command.rb,并定义 Beverage::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
51
# command.rb
module BeverageMaker
require 'claide'

class Command < CLAide::Command

require 'beveragemaker/command/coffee'
require 'beveragemaker/command/tea'

self.abstract_command = true

self.description = 'Make delicious beverages from the comfort of your' \
'terminal.'

# This would normally default to `beverage-maker`, based on the class’ name.
self.command = 'beverage-maker'

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
我们通过覆写 self.options 自定义支持的选项:--no-milk--sweetener。通过设置 self.description 指定命令的描述信息。通过设置 self.abstract_commandtrue 将其置为抽象命令。

在构造方法 initialize 中,我们通过 argvARGV 类的实例) 读取对应的标志和选项进行实例化。

在校验方法 validate! 中,我们需要对 --sweetener 选项进行校验,因为它只支持 sugarhoney 两个值,当传入的值不符合预定义时,则抛出帮助提示。

在运行方法 run 中,我们定义饮料生产的通用逻辑,并根据 --no-milk 标志和 --sweetener 选项决定是否指定特殊逻辑。

实现子命令

接下来,我们需要实现子命令 coffeetea,为了能够对功能进行分类,我们新建一个 lib/BeverageMaker/command 目录,并新建 coffee.rbtea.rb 文件用于实现对应的命令类。

Coffee 子命令

Coffee 子命令类的定义如下所示,其继承自 BeverageMaker::Command 抽象类,内部通过设置 self.abstract_commandself.summaryself.description 等属性进行自定义配置。通过覆写 run 方法,定义制作咖啡时的特定逻辑,注意内部会调用 super 以执行饮料制作的通用逻辑。

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
# coffee.rb
module BeverageMaker
# Unlike the Tea command, this command uses subcommands to specify the
# flavor.
#
# Which one makes more sense is up to you.
class Coffee < Command
self.abstract_command = true

self.summary = 'Drink brewed from roasted coffee beans'

self.description = <<-DESC
Coffee is a brewed beverage with a distinct aroma and flavor
prepared from the roasted seeds of the Coffea plant.
DESC

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

class Affogato < Coffee
self.summary = 'A coffee-based beverage (Italian for "drowned")'
end

class CaPheSuaDa < Coffee
self.summary = 'A unique Vietnamese coffee recipe'
end

class RedTux < Coffee
self.summary = 'A Zebra Mocha combined with raspberry flavoring'
end
end
end

此外,我们还定义了 Coffee 类的子类,包括:BlackEyeAffogatoCoPheSuaDaRedTux。基于 Ruby 的语言特性,在运行时,这些子类会自动被标记为 Coffee 的子命令(内部由 self.inherited 命令支持)。这些子类各自定义了不同的命令摘要。

Tea 子命令

Tea 子命令类的定义如下所示,其继承自 BeverageMaker::Command 抽象类,内部通过设置 self.summaryself.description 等属性进行自定义配置。

Tea 子命令类覆写了 self.arguments 属性,这个属性定义了命令的参数,在打印帮助信息时,会使用这里所定义的内容。

Tea 子命令类覆写了 self.options 属性,这个属性定义了 tea 子命令所特有的选项 --iced

Tea 通过覆写构造方法 initializeargv 中按顺序读取参数和标志。

validate! 方法中,它对口味参数进行校验,判断其是否属于预定义的几种类型之一,如果不符合,则打印帮助提示。

run 方法,定义制作茶饮的特定逻辑,注意内部会调用 super 以执行饮料制作的通用逻辑。

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
# tea.rb
module BeverageMaker
# This command uses an argument for the extra parameter, instead of
# subcommands for each of the flavor.

class Tea < Command
self.summary = 'Drink based on cured leaves'

self.description = <<-DESC
An aromatic beverage commonly prepared by pouring boiling hot
water over cured leaves of the Camellia sinensis plant.
The following flavors are available: black, green, oolong, and white.
DESC

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

def self.options
[['--iced', 'the ice-tea version']].concat(super)
end

def initialize(argv)
@flavor = argv.shift_argument
@iced = argv.flag?('iced')
super
end

def validate!
super
if @flavor.nil?
help! 'A flavor argument is required.'
end
unless %w(black green oolong white).include?(@flavor)
help! "`#{@flavor}' is not a valid flavor."
end
end

def run
super
puts "* Infuse #{@flavor} tea…"
sleep 1
if @iced
puts '* Cool off…'
sleep 1
end
puts '* Enjoy!'
end
end
end

加载根命令

exe/beverage-maker 中,我们通过 require 'BeverageMaker' 导入 gem,此时 Bundler 会请求同名的 BeverageMaker.rb 文件,而 BeverageMaker.rb 中并没有 exe/beverage-maker 所需的 BeverageMaker::Command 类。因此我们需要加载 BeverageMaker::Command 类,我们可以通过 autoload 方法加载,如下所示。

1
2
3
4
5
6
7
8
# BeverageMaker.rb
require "BeverageMaker/version"

module BeverageMaker
class Error < StandardError; end
# Your code goes here...
autoload :Command, 'BeverageMaker/command'
end

调试

本文使用 RubyMine 进行开发,RubyMine 提供了一系列调试功能,我们可以选中 exe/beverage-maker 文件,右击选择【Debug 'beverage-maker'】,RubyMine 将自动以调试模式运行 beverage-maker

运行 beverage-maker 后,控制台将输出运行结果,如下所示:

由于 beverage-maker 是入口程序,我们可以修改其代码,传入不同的参数进行调试,如下所示:

1
2
3
4
5
6
7
8
#!/usr/bin/env ruby

require 'BeverageMaker'

# BeverageMaker::Command.run(ARGV)
BeverageMaker::Command.run(["tea", "oolong", "--iced", "--no-milk"])
print"\n"
BeverageMaker::Command.run(["coffee", "ca-phe-sua-da", "--sweetener=sugar"])

其调试运行结果如下所示:

构建

在调试通过后,我们可以对 gem 进行构建,构建命令如下:

1
$ rake build

构建命令会根据 BeverageMaker.gemspec 生成一个对应版本的 gem,这里生成的是 BeverageMaker-0.1.0.gem,并存放在 pkg 目录下。

注:也可以使用 gem build BeverageMaker.gemspec 进行构建。

安装

在构建生成 gem 后,我们可以对它进行安装,安装命令如下:

1
$ rake install

安装命令默认将 gem 安装在当前使用的 ruby 版本的目录下,我当前的 ruby 版本是 2.6.5。对此,安装结果如下。

  • 可执行文件 beverage-maker 安装在 ~/.rvm/gems/ruby-2.6.5/bin/ 目录下
  • BeverageMaker gem 包安装在 ~/.rvm/gems/ruby-2.6.5/gems/ 目录下
  • BeverageMaker 的 gemspec 安装在 ~/.rvm/gems/ruby-2.6.5/specifications/ 目录下。

注:也可以使用 gem install pkg/BeverageMaker-0.1.0.gem 命令进行安装。

安装完毕,我们就可以在控制台中使用 beverage-maker 命令行工具了。

发布

一切就绪后,我们可以对 gem 进行发布,发布命令如下:

1
$ rake release

rake release 发布命令包含几个步骤:

  • 构建 gem,并存放至 pkg 目录下,准备推送至 Rubygems.org。
  • 在当前的 commit 上打上 tag,指定为当前版本号。
  • 将代码推送至远程的 git 仓库。

执行结果如下所示:

注:也可以使用 gem push pkg/BeverageMaker-0.1.0.gem 命令进行发布。

在发布前,我们需要注册一个 rubygems.org 的账户,否则发布命令会报错。

总结

本文通过一个具体的需求,基于 CLAide 开发了一个 gem 命令行工具。在这个过程中,我们介绍了模板项目中各个文件的作用,了解了 gem 开发的一般步骤:开发、调试、构建、安装、发布。

另一方面,通过这样一个项目,我们大致也能够理解 cocoapods 的整个项目结构、设计理念。

参考

  1. How to create a Ruby gem with Bundler
  2. CLAide
  3. 源码解析——CLAide
  4. CocoaPods 命令解析 - CLAide
  5. rake release fail (using gem bundler)