gitbook/Go 语言项目开发实战/docs/407922.md
2022-09-03 22:05:03 +08:00

457 lines
23 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <command> --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 <svcname> <ip> [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<Type>()` 函数来获取配置。例如,iamctl使用了以下 `viper.Get<Type>` 方法:
![图片](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> # 按TAB键,自动补全为:iamctl xyz
$ iamctl xyz --b<TAB> # 按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. 思考下,还有哪些好的命令行工具构建方式,欢迎在留言区分享。
欢迎你在留言区与我交流讨论,我们下一讲见。