思考并回答以下问题:
如果你用过Kubernetes、Istio、etcd,那你一定用过这些开源项目所提供的命令行工具:kubectl、istioctl、etcdctl。一个xxx项目,伴随着一个xxxctl命令行工具,这似乎已经成为一种趋势,在一些大型系统中更是常见。提供xxxctl命令行工具有这两个好处:
- 实现自动化:可以通过在脚本中调用xxxctl工具,实现自动化。
- 提高效率:通过将应用的功能封装成命令和参数,方便运维、开发人员在Linux服务器上调用。
其中,kubectl命令设计的功能最为复杂,也是非常优秀的命令行工具,IAM项目的iamctl客户端工具就是仿照kubectl来实现的。这一讲,我就通过剖析iamctl命令行工具的实现,来介绍下如何实现一个优秀的客户端工具。
大型系统客户端(xxxctl)的特点
通过学习kubectl、istioctl、etcdctl这些优秀的命令行工具,可以发现一个大型系统的命令行工具,通常具有下面这些特点:
- 支持命令和子命令,命令/子命名有自己独有的命令行参数。
- 支持一些特殊的命令。比如支持completion命令,completion命令可以输出bash/zsh自动补全脚本,实现命令行及参数的自动补全。还支持version命令,version命令不仅可以输出客户端的版本,还可以输出服务端的版本(如果有需要)。
- 支持全局option,全局option可以作为所有命令及子命令的命令行参数。
- 支持-h/help,-h/help可以打印xxxctl的帮助信息,例如:
1 | iamctl -h |
- 支持
xxxctl help [command | command subcommand] [command | command subcommand] -h
,打印命令/子命令的帮助信息,格式通常为命令描述+使用方法。例如:
1 | istioctl help register |
除此之外,一个大型系统的命令行工具还可以支持一些更高阶的功能,例如:支持命令分组,支持配置文件,支持命令的使用example,等等。
在Go生态中,如果我们要找一个符合上面所有特点的命令行工具,那非kubectl莫属。
iamctl的核心实现
接下来,我就来介绍IAM系统自带的iamctl客户端工具,它是仿照kubectl来实现的,能够满足一个大型系统客户端工具的需求。我会从iamctl的功能、代码结构、命令行选项和配置文件解析4个方面来介绍。
iamctl的功能
iamctl将命令进行了分类。这里,我也建议你对命令进行分类,因为通过分类,不仅可以协助你理解命令的用途,还能帮你快速定位某类命令。另外,当命令很多时,分类也可以使命令看起来更规整。
iamctl实现的命令如下:
更详细的功能,你可以参考iamctl-h。我建议你在实现xxxctl工具时,考虑实现下面这几个功能。
- API功能:平台具有的API功能,都能通过xxxctl方便地进行调用。
- 工具:一些使用IAM系统时有用的功能,比如签发JWTToken。
- version、completion、validate命令。
代码结构
iamctl工具的main函数位于iamctl.go文件中。命令的实现存放在internal/iamctl/cmd/cmd.go文件中。iamctl的命令统一存放在internal/iamctl/cmd目录下,每个命令都是一个Go包,包名即为命令名,具体实现存放在internal/iamctl/cmd/<命令>/<命令>.go
文件中。如果命令有子命令,则子命令的实现存放在internal/iamctl/cmd/<命令>/<命令>_<子命令>.go
文件中。使用这种代码组织方式,即使是在命令很多的情况下,也能让代码井然有序,方便定位和维护代码。
命令行选项
添加命令行选项的代码在NewIAMCtlCommand函数中,核心代码为:1
2
3
4
5
6flags := cmds.PersistentFlags()
...
iamConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag().WithDeprecatedSecretFlag()
iamConfigFlags.AddFlags(flags)
matchVersionIAMConfigFlags := cmdutil.NewMatchVersionFlags(iamConfigFlags)
matchVersionIAMConfigFlags.AddFlags(cmds.PersistentFlags())
NewConfigFlags(true)返回带有默认值的参数,并通过iamConfigFlags.AddFlags(flags)添加到cobra的命令行flag中。
NewConfigFlags(true)返回结构体类型的值都是指针类型,这样做的好处是:程序可以判断出是否指定了某个参数,从而可以根据需要添加参数。例如:可以通过WithDeprecatedPasswordFlag()和WithDeprecatedSecretFlag()添加密码和密钥认证参数。
NewMatchVersionFlags指定是否需要服务端版本和客户端版本一致。如果不一致,在调用服务接口时会报错。
配置文件解析
iamctl需要连接iam-apiserver,来完成用户、策略和密钥的增删改查,并且需要进行认证。要完成这些功能,需要有比较多的配置项。这些配置项如果每次都在命令行选项指定,会很麻烦,也容易出错。
最好的方式是保存到配置文件中,并加载配置文件。加载配置文件的代码位于NewIAMCtlCommand函数中,代码如下:1
2
3
4
5_ = viper.BindPFlags(cmds.PersistentFlags())
cobra.OnInitialize(func() {
genericapiserver.LoadConfig(viper.GetString(genericclioptions.FlagIAMConfig), "iamctl")
})
iamctl会按以下优先级加载配置文件:
1,命令行参—iamconfig指定的配置文件。
2,当前目录下的iamctl.yaml文件。
3,$HOME/.iam/iamctl.yaml文件。
这种加载方式具有两个好处。首先是可以手动指定不同的配置文件,这在多环境、多配置下尤为重要。其次是方便使用,可以把配置存放在默认的加载路径中,在执行命令时,就不用再指定—iamconfig参数。
加载完配置文件之后,就可以通过viper.Get<Type>()
函数来获取配置。例如,iamctl使用了以下viper.Get<Type>
方法:
iamctl中子命令是如何构建的?
讲完了iamctl命令行工具的核心实现,我们再来看看iamctl命令行工具中,子命令是如何构建的。
命令行工具的核心是命令,有很多种方法可以构建一个命令,但还是有一些比较好的构建方法,值得我们去参考。接下来,我来介绍下如何用比较好的方式去构建命令。
命令构建
命令行工具的核心能力是提供各类命令,来完成不同功能,每个命令构建的方式可以完全不同,但最好能按相同的方式去构建,并抽象成一个模型。如下图所示:
你可以将一个命令行工具提供的命令进行分组。每个分组包含多个命令,每个命令又可以具有多个子命令,子命令和父命令在构建方式上完全一致。
每个命令可以按下面的四种方式构建。具体代码你可以参考internal/iamctl/cmd/user/user_update.go。
- 通过NewCmdXyz函数创建命令框架。NewCmdXyz函数通过创建一个cobra.Command类型的变量来创建命令;
- 通过指定cobra.Command结构体类型的Short、Long、Example字段,来指定该命令的使用文档iamctl-h、详细使用文档iamctlxyz-h和使用示例。通过cmd.Flags().XxxxVar来给该命令添加命令行选项。
- 为了在不指定命令行参数时,能够按照默认的方式执行命令,可以通过NewXyzOptions函数返回一个设置了默认选项的XyzOptions类型的变量。
- XyzOptions选项具有Complete、Validate和Run三个方法,分别完成选项补全、选项验证和命令执行。命令的执行逻辑可以在
func(o *XyzOptions) Run(args []string) error
函数中编写。
按相同的方式去构建命令,抽象成一个通用模型,这种方式有下面四个好处。
- 减少理解成本:理解一个命令的构建方式,就可以理解其他命令的构建方式。
- 提高新命令的开发效率:可以复用其他命令的开发框架,新命令只需填写业务逻辑即可。
- 自动生成命令:可以按照规定的命令模型,自动生成新的命令。
- 易维护:因为所有的命令都来自于同一个命令模型,所以可以保持一致的代码风格,方便后期维护。
自动生成命令
上面讲到,自动生成命令模型的好处之一是可以自动生成命令,下面让我们来具体看下。
iamctl自带了命令生成工具,下面我们看看生成方法,一共可以分成5步。这里假设生成xyz命令。
第一步,新建一个xyz目录,用来存放xyz命令源码:1
mkdir internal/iamctl/cmd/xyz
xyz第二步,在xyz目录下,使用iamctlnew命令生成xyz命令源码:1
2
3cd internal/iamctl/cmd/xyz/
iamctl new xyz
Command file generated: xyz.go
第三步,将xyz命令添加到root命令中,假设xyz属于SettingsCommands命令分组。
在NewIAMCtlCommand函数中,找到SettingsCommands分组,将NewCmdXyz追加到Commands数组后面:1
2
3
4
5
6
7
8{
Message: "Settings Commands:",
Commands: []*cobra.Command{
set.NewCmdSet(f, ioStreams),
completion.NewCmdCompletion(ioStreams.Out, ""),
xyz.NewCmdXyz(f, ioStreams),
},
},
第四步,编译iamctl:1
make build BINS=iamctl
第五步,测试: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
31iamctl xyz -h
A longer description that spans multiple lines and likely contains examples and usage of using your command. For
example:
Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to
quickly create a Cobra application.
Examples:
Print all option values for xyz
iamctl xyz marmotedu marmotedupass
Options:
-b, --bool=false: Bool option.
-i, --int=0: Int option.
--slice=[]: String slice option.
--string='default': String option.
Usage:
iamctl xyz USERNAME PASSWORD [options]
Use "iamctl options" for a list of global command-line options (applies to all commands).
iamctl xyz marmotedu marmotedupass
The following is option values:
==> --string: default(complete)
==> --slice: []
==> --int: 0
==> --bool: false
The following is args values:
==> username: marmotedu
==> password: marmotedupass
你可以看到,经过短短的几步,就添加了一个新的命令xyz。iamctl new
命令不仅可以生成不带子命令的命令,还可以生成带有子命令的命令,生成方式如下:1
2
3
4iamctl new -g xyz
Command file generated: xyz.go
Command file generated: xyz_subcmd1.go
Command file generated: xyz_subcmd2.go
命令自动补全
cobra会根据注册的命令自动生成补全脚本,可以补全父命令、子命令和选项参数。在bash下,可以按下面的方式配置自动补全功能。
第一步,生成自动补全脚本:
第二步,登陆时加载bash,自动补全脚本:
第三步,测试自动补全功能:
更友好的输出
在开发命令时,可以通过一些技巧来提高使用体验。我经常会在输出中打印一些彩色输出,或者将一些输出以表格的形式输出,如下图所示:
这里,使用github.com/olekukonko/tablewriter包来实现表格功能,使用github.com/fatih/color包来打印带色彩的字符串。具体使用方法,你可以参考internal/iamctl/cmd/validate/validate.go文件。
github.com/fatih/color包可以给字符串标示颜色,字符串和颜色的对应关系可通过iamctlcolor来查看,如下图所示:
iamctl是如何进行API调用的?
上面我介绍了iamctl命令的构建方式,那么这里我们再来看下iamctl是如何请求服务端API接口的。
Go后端服务的功能通常通过API接口来对外暴露,一个后端服务可能供很多个终端使用,比如浏览器、命令行工具、手机等。为了保持功能的一致性,这些终端都会调用同一套API来完成相同的功能,如下图所示:
如果命令行工具需要用到后端服务的功能,也需要通过API调用的方式。理想情况下,Go后端服务对外暴露的所有API功能,都可以通过命令行工具来完成。一个API接口对应一个命令,API接口的参数映射到命令的参数。
要调用服务端的API接口,最便捷的方法是通过SDK来调用,对于一些没有实现SDK的接口,也可以直接调用。所以,在命令行工具中,需要支持以下两种调用方式:
- 通过SDK调用服务端API接口。
- 直接调用服务端的API接口(本专栏是RESTAPI接口)。
iamctl通过cmdutil.NewFactory创建一个Factory类型的变量f,Factory定义为:1
2
3
4
5type Factory interface {
genericclioptions.RESTClientGetter
IAMClientSet() (*marmotedu.Clientset, error)
RESTClient() (*restclient.RESTClient, error)
}
将变量f传入到命令中,在命令中使用Factory接口提供的RESTClient()和IAMClientSet()方法,分别返回RESTfulAPI客户端和SDK客户端,从而使用客户端提供的接口函数。代码可参考internal/iamctl/cmd/version/version.go。
客户端配置文件
如果要创建RESTfulAPI客户端和SDK的客户端,需要调用f.ToRESTConfig()
函数返回*github.com/marmotedu/marmotedu-sdk-go/rest.Config类型的配置变量,然后再基于rest.Config类型的配置变量创建客户端。
f.ToRESTConfig
函数最终是调用toRawIAMConfigLoad函数来生成配置的,代码如下:1
2
3
4
5
6
7
8func (f *ConfigFlags) toRawIAMConfigLoader() clientcmd.ClientConfig {
config := clientcmd.NewConfig()
if err := viper.Unmarshal(&config); err != nil {
panic(err)
}
return clientcmd.NewClientConfigFromConfig(config)
}
toRawIAMConfigLoader返回clientcmd.ClientConfig类型的变量,clientcmd.ClientConfig类型提供了ClientConfig方法,用来返回*rest.Config
类型的变量。
在toRawIAMConfigLoader函数内部,通过viper.Unmarshal将viper中存储的配置解析到clientcmd.Config类型的结构体变量中。viper中存储的配置,是在cobra命令启动时通过LoadConfig函数加载的,代码如下(位于NewIAMCtlCommand函数中):1
2
3cobra.OnInitialize(func() {
genericapiserver.LoadConfig(viper.GetString(genericclioptions.FlagIAMConfig), "config")
})
你可以通过—config选项,指定配置文件的路径。
SDK调用
通过IAMClient返回SDK客户端,代码如下:1
2
3
4
5
6
7func (f *factoryImpl) IAMClient() (*iam.IamClient, error) {
clientConfig, err := f.ToRESTConfig()
if err != nil {
return nil, err
}
return iam.NewForConfig(clientConfig)
}
marmotedu.Clientset提供了iam-apiserver的所有接口。
RESTAPI调用
通过RESTClient()返回RESTfulAPI客户端,代码如下:1
2
3
4
5
6
7
8func (f *factoryImpl) RESTClient() (*restclient.RESTClient, error) {
clientConfig, err := f.ToRESTConfig()
if err != nil {
return nil, err
}
setIAMDefaults(clientConfig)
return restclient.RESTClientFor(clientConfig)
}
可以通过下面的方式访问RESTfulAPI接口:1
2
3
4
5
6serverVersion *version.Info
client, _ := f.RESTClient()
if err := client.Get().AbsPath("/version").Do(context.TODO()).Into(&serverVersion); err != nil {
return err
}
上面的代码请求了iam-apiserver的/version接口,并将返回结果保存在serverVersion变量中。
总结
这一讲,我主要剖析了iamctl命令行工具的实现,进而向你介绍了如何实现一个优秀的客户端工具。
对于一个大型系统xxx来说,通常需要有一个xxxctl命令行工具,xxxctl命令行工具可以方便开发、运维使用系统功能,并能实现功能自动化。
IAM项目参考kubectl,实现了命令行工具iamctl。iamctl集成了很多功能,我们可以通过iamctl子命令来使用这些功能。例如,我们可以通过iamctl对用户、密钥和策略进行CURD操作;可以设置iamctl自动补全脚本;可以查看IAM系统的版本信息。甚至,你还可以使用iamctlnew命令,快速创建一个iamctl子命令模板。
iamctl使用了cobra、pflag、viper包来构建,每个子命令又包含了一些基本的功能,例如短描述、长描述、使用示例、命令行选项、选项校验等。iamctl命令可以加载不同的配置文件,来连接不同的客户端。iamctl通过SDK调用、RESTAPI调用两种方式来调用服务端API接口。
课后练习
1,尝试在iamctl中添加一个cliprint子命令,该子命令会读取并打印命令行选项。
2,思考下,还有哪些好的命令行工具构建方式。