# 35 | 效率神器:如何设计和实现一个命令行客户端工具? 你好,我是孔令飞。今天我们来聊聊,如何实现一个命令行客户端工具。 如果你用过Kubernetes、Istio、etcd,那你一定用过这些开源项目所提供的命令行工具:kubectl、istioctl、etcdctl。一个 `xxx` 项目,伴随着一个 `xxxctl` 命令行工具,这似乎已经成为一种趋势,在一些大型系统中更是常见。提供 `xxxctl` 命令行工具有这两个好处: * 实现自动化:可以通过在脚本中调用 `xxxctl` 工具,实现自动化。 * 提高效率:通过将应用的功能封装成命令和参数,方便运维、开发人员在Linux服务器上调用。 其中,kubectl命令设计的功能最为复杂,也是非常优秀的命令行工具,IAM项目的iamctl客户端工具就是仿照kubectl来实现的。这一讲,我就通过剖析iamctl命令行工具的实现,来介绍下如何实现一个优秀的客户端工具。 ## 常见客户端介绍 在介绍iamctl命令行工具的实现之前,我们先来看下常见的客户端。 客户端又叫用户端,与后端服务相对应,安装在客户机上,用户可以使用这些客户端访问后端服务。不同的客户端面向的人群不同,所能提供的访问能力也有差异。常见的客户端有下面这几种: * 前端,包括浏览器、手机应用; * SDK; * 命令行工具; * 其他终端。 接下来,我就来分别介绍下。 浏览器和手机应用提供一个交互界面供用户访问后端服务,使用体验最好,面向的人群是最终的用户。这两类客户端也称为前端。前端由前端开发人员进行开发,并通过API接口,调用后端的服务。后端开发人员不需要关注这两类客户端,只需要关注如何提供API接口即可。 SDK(Software Development Kit)也是一个客户端,供开发者调用。开发者调用API时,如果是通过HTTP协议,需要编写HTTP的调用代码、HTTP请求包的封装和返回包的解封,还要处理HTTP的状态码,使用起来不是很方便。SDK其实是封装了API接口的一系列函数集合,开发者通过调用SDK中的函数调用API接口,提供SDK主要是方便开发者调用,减少工作量。 命令行工具是可以在操作系统上执行的一个二进制程序,提供了一种比SDK和API接口更方便快捷的访问后端服务的途径,供运维或者开发人员在服务器上直接执行使用,或者在自动化脚本中调用。 还有其他各类客户端,这里我列举一些常见的。 * 终端设备:POS机、学习机、智能音箱等。 * 第三方应用程序:通过调用API接口或者SDK,调用我们提供的后端服务,从而实现自身的功能。 * 脚本:脚本中通过API接口或者命令行工具,调用我们提供的后端服务,实现自动化。 这些其他的各类客户端,都是通过调用API接口使用后端服务的,它们跟前端一样,也不需要后台开发人员开发。 需要后台开发人员投入工作量进行研发的客户端是SDK和命令行工具。这两类客户端工具有个调用和被调用的顺序,如下图所示: ![图片](https://static001.geekbang.org/resource/image/e9/91/e97e547bec77dc7129615b11792f1291.jpg?wh=1920x568) 你可以看到,命令行工具和SDK最终都是通过API接口调用后端服务的,通过这种方式可以保证服务的一致性,并减少为适配多个客户端所带来的额外开发工作量。 ## 大型系统客户端(xxxctl)的特点 通过学习kubectl、istioctl、etcdctl这些优秀的命令行工具,可以发现一个大型系统的命令行工具,通常具有下面这些特点: * 支持命令和子命令,命令/子命名有自己独有的命令行参数。 * 支持一些特殊的命令。比如支持completion命令,completion命令可以输出bash/zsh自动补全脚本,实现命令行及参数的自动补全。还支持 version命令,version命令不仅可以输出客户端的版本,还可以输出服务端的版本(如果有需要)。 * 支持全局option,全局option可以作为所有命令及子命令的命令行参数。 * 支持-h/help,-h/help可以打印xxxctl的帮助信息,例如: ```bash $ iamctl -h iamctl controls the iam platform, is the client side tool for iam platform.  Find more information at: https://github.com/marmotedu/iam/blob/master/docs/guide/en-US/cmd/iamctl/iamctl.md Basic Commands:   info        Print the host information   color       Print colors supported by the current terminal   new         Generate demo command code   jwt         JWT command-line tool Identity and Access Management Commands:   user        Manage users on iam platform   secret      Manage secrets on iam platform   policy      Manage authorization policies on iam platform Troubleshooting and Debugging Commands:   validate    Validate the basic environment for iamctl to run Settings Commands:   set         Set specific features on objects   completion  Output shell completion code for the specified shell (bash or zsh) Other Commands:   version     Print the client and server version information Usage:   iamctl [flags] [options] Use "iamctl --help" for more information about a given command. Use "iamctl options" for a list of global command-line options (applies to all commands). ``` * 支持 `xxxctl help [command | command subcommand] [command | command subcommand] -h` ,打印命令/子命令的帮助信息,格式通常为 `命令描述 + 使用方法` 。例如: ```bash $ istioctl help register Registers a service instance (e.g. VM) joining the mesh   Usage:   istioctl register [name1:]port1 [name2:]port2 ... [flags] ``` 除此之外,一个大型系统的命令行工具还可以支持一些更高阶的功能,例如:支持命令分组,支持配置文件,支持命令的使用example,等等。 在Go生态中,如果我们要找一个符合上面所有特点的命令行工具,那非[kubectl](https://github.com/kubernetes/kubernetes/blob/master/cmd/kubectl/)莫属。因为我今天要重点讲的iamctl客户端工具,就是仿照它来实现的,所以这里就不展开介绍kubectl了,不过还是建议你认真研究下kubectl的实现。 ## iamctl的核心实现 接下来,我就来介绍IAM系统自带的iamctl客户端工具,它是仿照kubectl来实现的,能够满足一个大型系统客户端工具的需求。我会从iamctl的功能、代码结构、命令行选项和配置文件解析4个方面来介绍。 ### iamctl的功能 iamctl将命令进行了分类。这里,我也建议你对命令进行分类,因为通过分类,不仅可以协助你理解命令的用途,还能帮你快速定位某类命令。另外,当命令很多时,分类也可以使命令看起来更规整。 iamctl实现的命令如下: ![图片](https://static001.geekbang.org/resource/image/1d/da/1dee217f8be94ae1c3c1d9b29d627eda.jpg?wh=1920x1696) 更详细的功能,你可以参考 `iamctl -h` 。我建议你在实现xxxctl工具时,考虑实现下面这几个功能。 * API功能:平台具有的API功能,都能通过xxxctl方便地进行调用。 * 工具:一些使用IAM系统时有用的功能,比如签发JWT Token。 * version、completion、validate命令。 ### 代码结构 iamctl工具的main函数位于[iamctl.go](https://github.com/marmotedu/iam/blob/v1.0.6/cmd/iamctl/iamctl.go)文件中。命令的实现存放在[internal/iamctl/cmd/cmd.go](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/cmd.go)文件中。iamctl的命令统一存放在[internal/iamctl/cmd](https://github.com/marmotedu/iam/tree/v1.0.6/internal/iamctl/cmd)目录下,每个命令都是一个Go包,包名即为命令名,具体实现存放在 `internal/iamctl/cmd/<命令>/<命令>.go` 文件中。如果命令有子命令,则子命令的实现存放在 `internal/iamctl/cmd/<命令>/<命令>_<子命令>.go` 文件中。 使用这种代码组织方式,即使是在命令很多的情况下,也能让代码井然有序,方便定位和维护代码。 ### 命令行选项 添加命令行选项的代码在[NewIAMCtlCommand](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/cmd.go#L41-L130)函数中,核心代码为: ```go flags := 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函数中,代码如下: ``` _ = 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()` 函数来获取配置。例如,iamctl使用了以下 `viper.Get` 方法: ![图片](https://static001.geekbang.org/resource/image/8b/42/8bce5d0b9ab45b5238d70b73175cf642.png?wh=1920x813) ## iamctl中子命令是如何构建的? 讲完了iamctl命令行工具的核心实现,我们再来看看iamctl命令行工具中,子命令是如何构建的。 命令行工具的核心是命令,有很多种方法可以构建一个命令,但还是有一些比较好的构建方法,值得我们去参考。接下来,我来介绍下如何用比较好的方式去构建命令。 ### 命令构建 命令行工具的核心能力是提供各类命令,来完成不同功能,每个命令构建的方式可以完全不同,但最好能按相同的方式去构建,并抽象成一个模型。如下图所示: ![图片](https://static001.geekbang.org/resource/image/1e/93/1e78d2f387be0bcbae573d486e391e93.jpg?wh=1920x916) 你可以将一个命令行工具提供的命令进行分组。每个分组包含多个命令,每个命令又可以具有多个子命令,子命令和父命令在构建方式上完全一致。 每个命令可以按下面的四种方式构建。具体代码你可以参考[internal/iamctl/cmd/user/user\_update.go](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/user/user_update.go)。 * 通过 `NewCmdXyz` 函数创建命令框架。 `NewCmdXyz` 函数通过创建一个 `cobra.Command` 类型的变量来创建命令;通过指定 `cobra.Command` 结构体类型的Short、Long、Example字段,来指定该命令的使用文档`iamctl -h` 、详细使用文档`iamctl xyz -h` 和使用示例。 * 通过 `cmd.Flags().XxxxVar` 来给该命令添加命令行选项。 * 为了在不指定命令行参数时,能够按照默认的方式执行命令,可以通过 `NewXyzOptions` 函数返回一个设置了默认选项的 `XyzOptions` 类型的变量。 * `XyzOptions` 选项具有 Complete 、Validate 和 Run 三个方法,分别完成选项补全、选项验证和命令执行。命令的执行逻辑可以在 `func (o *XyzOptions) Run(args []string) error` 函数中编写。 按相同的方式去构建命令,抽象成一个通用模型,这种方式有下面四个好处。 * 减少理解成本:理解一个命令的构建方式,就可以理解其他命令的构建方式。 * 提高新命令的开发效率:可以复用其他命令的开发框架,新命令只需填写业务逻辑即可。 * 自动生成命令:可以按照规定的命令模型,自动生成新的命令。 * 易维护:因为所有的命令都来自于同一个命令模型,所以可以保持一致的代码风格,方便后期维护。 ### 自动生成命令 上面讲到,自动生成命令模型的好处之一是可以自动生成命令,下面让我们来具体看下。 iamctl自带了命令生成工具,下面我们看看生成方法,一共可以分成5步。这里假设生成 `xyz` 命令。 第一步,新建一个 `xyz` 目录,用来存放 `xyz` 命令源码: ```bash $ mkdir internal/iamctl/cmd/xyz ``` 第二步,在xyz目录下,使用 `iamctl new` 命令生成 `xyz` 命令源码: ```bash $ cd internal/iamctl/cmd/xyz/ $ iamctl new xyz Command file generated: xyz.go ``` 第三步,将 `xyz` 命令添加到root命令中,假设 `xyz` 属于 `Settings Commands` 命令分组。 在 `NewIAMCtlCommand` 函数中,找到 `Settings Commands` 分组,将 `NewCmdXyz` 追加到Commands数组后面: ```go        {             Message: "Settings Commands:",             Commands: []*cobra.Command{                 set.NewCmdSet(f, ioStreams),                 completion.NewCmdCompletion(ioStreams.Out, ""),                 xyz.NewCmdXyz(f, ioStreams),             },         },  ``` 第四步,编译iamctl: ```bash $ make build BINS=iamctl ``` 第五步,测试: ```bash $ iamctl 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` 命令不仅可以生成不带子命令的命令,还可以生成带有子命令的命令,生成方式如下: ```bash $ iamctl new -g xyz Command file generated: xyz.go Command file generated: xyz_subcmd1.go Command file generated: xyz_subcmd2.go ``` ### 命令自动补全 cobra会根据注册的命令自动生成补全脚本,可以补全父命令、子命令和选项参数。在bash下,可以按下面的方式配置自动补全功能。 第一步,生成自动补全脚本: ```bash $ iamctl completion bash > ~/.iam/completion.bash.inc ``` 第二步,登陆时加载bash,自动补全脚本: ```bash $ echo "source '$HOME/.iam/completion.bash.inc'" >> $HOME/.bash_profile $ source $HOME/.bash_profile ``` 第三步,测试自动补全功能: ```bash $ iamctl xy # 按TAB键,自动补全为:iamctl xyz $ iamctl xyz --b # 按TAB键,自动补全为:iamctl xyz --bool ``` ### 更友好的输出 在开发命令时,可以通过一些技巧来提高使用体验。我经常会在输出中打印一些彩色输出,或者将一些输出以表格的形式输出,如下图所示: ![图片](https://static001.geekbang.org/resource/image/74/42/74ef80708c853c20811e1e7bed7bde42.png?wh=651x226) 这里,使用 `github.com/olekukonko/tablewriter` 包来实现表格功能,使用 `github.com/fatih/color` 包来打印带色彩的字符串。具体使用方法,你可以参考[internal/iamctl/cmd/validate/validate.go](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/validate/validate.go)文件。 `github.com/fatih/color` 包可以给字符串标示颜色,字符串和颜色的对应关系可通过 `iamctl color` 来查看,如下图所示: ![图片](https://static001.geekbang.org/resource/image/47/b9/47593869e1b10b15a35e16c661d818b9.png?wh=991x672) ## iamctl是如何进行API调用的? 上面我介绍了iamctl命令的构建方式,那么这里我们再来看下iamctl是如何请求服务端API接口的。 Go后端服务的功能通常通过API接口来对外暴露,一个后端服务可能供很多个终端使用,比如浏览器、命令行工具、手机等。为了保持功能的一致性,这些终端都会调用同一套API来完成相同的功能,如下图所示: ![图片](https://static001.geekbang.org/resource/image/fb/bb/fb6de4f63454dd6471e023d73b8548bb.jpg?wh=1920x742) 如果命令行工具需要用到后端服务的功能,也需要通过API调用的方式。理想情况下,Go后端服务对外暴露的所有API功能,都可以通过命令行工具来完成。一个API接口对应一个命令,API接口的参数映射到命令的参数。 要调用服务端的API接口,最便捷的方法是通过SDK来调用,对于一些没有实现SDK的接口,也可以直接调用。所以,在命令行工具中,需要支持以下两种调用方式: * 通过SDK调用服务端 API 接口。 * 直接调用服务端的API接口(本专栏是REST API接口)。 iamctl通过[cmdutil.NewFactory](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/cmd.go#L82)创建一个 `Factory` 类型的变量 `f` , `Factory` 定义为: ```go type Factory interface {     genericclioptions.RESTClientGetter     IAMClientSet() (*marmotedu.Clientset, error)     RESTClient() (*restclient.RESTClient, error) } ``` 将变量 `f` 传入到命令中,在命令中使用Factory接口提供的 `RESTClient()` 和 `IAMClientSet()` 方法,分别返回RESTful API客户端和SDK客户端,从而使用客户端提供的接口函数。代码可参考[internal/iamctl/cmd/version/version.go](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/version/version.go)。 ### 客户端配置文件 如果要创建RESTful API客户端和SDK的客户端,需要调用 `f.ToRESTConfig()` 函数返回 `*github.com/marmotedu/marmotedu-sdk-go/rest.Config` 类型的配置变量,然后再基于 `rest.Config` 类型的配置变量创建客户端。 `f.ToRESTConfig` 函数最终是调用[toRawIAMConfigLoader](https://github.com/marmotedu/iam/blob/v1.0.6/pkg/cli/genericclioptions/config_flags.go#L92-L98)函数来生成配置的,代码如下: ```go func (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` 函数中): ```go cobra.OnInitialize(func() {                          genericapiserver.LoadConfig(viper.GetString(genericclioptions.FlagIAMConfig), "config") })  ``` 你可以通过 `--config` 选项,指定配置文件的路径。 ### SDK调用 通过[IAMClient](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/util/factory_client_access.go#L41-L47)返回SDK客户端,代码如下: ```go func (f *factoryImpl) IAMClient() (*iam.IamClient, error) { clientConfig, err := f.ToRESTConfig() if err != nil { return nil, err } return iam.NewForConfig(clientConfig) } ``` `marmotedu.Clientset` 提供了iam-apiserver的所有接口。 ### REST API调用 通过[RESTClient()](https://github.com/marmotedu/iam/blob/v1.0.6/internal/iamctl/cmd/util/factory_client_access.go#L49-L56)返回RESTful API客户端,代码如下: ```go func (f *factoryImpl) RESTClient() (*restclient.RESTClient, error) { clientConfig, err := f.ToRESTConfig() if err != nil { return nil, err } setIAMDefaults(clientConfig) return restclient.RESTClientFor(clientConfig) } ``` 可以通过下面的方式访问RESTful API接口: ```go serverVersion *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系统的版本信息。甚至,你还可以使用 `iamctl new` 命令,快速创建一个iamctl子命令模板。 iamctl使用了cobra、pflag、viper包来构建,每个子命令又包含了一些基本的功能,例如短描述、长描述、使用示例、命令行选项、选项校验等。iamctl命令可以加载不同的配置文件,来连接不同的客户端。iamctl通过SDK调用、REST API调用两种方式来调用服务端API接口。 ## 课后练习 1. 尝试在 `iamctl` 中添加一个 `cliprint` 子命令,该子命令会读取并打印命令行选项。 2. 思考下,还有哪些好的命令行工具构建方式,欢迎在留言区分享。 欢迎你在留言区与我交流讨论,我们下一讲见。