You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

457 lines
23 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 35 | 效率神器:如何设计和实现一个命令行客户端工具?
你好,我是孔令飞。今天我们来聊聊,如何实现一个命令行客户端工具。
如果你用过Kubernetes、Istio、etcd那你一定用过这些开源项目所提供的命令行工具kubectl、istioctl、etcdctl。一个 `xxx` 项目,伴随着一个 `xxxctl` 命令行工具,这似乎已经成为一种趋势,在一些大型系统中更是常见。提供 `xxxctl` 命令行工具有这两个好处:
* 实现自动化:可以通过在脚本中调用 `xxxctl` 工具,实现自动化。
* 提高效率通过将应用的功能封装成命令和参数方便运维、开发人员在Linux服务器上调用。
其中kubectl命令设计的功能最为复杂也是非常优秀的命令行工具IAM项目的iamctl客户端工具就是仿照kubectl来实现的。这一讲我就通过剖析iamctl命令行工具的实现来介绍下如何实现一个优秀的客户端工具。
## 常见客户端介绍
在介绍iamctl命令行工具的实现之前我们先来看下常见的客户端。
客户端又叫用户端,与后端服务相对应,安装在客户机上,用户可以使用这些客户端访问后端服务。不同的客户端面向的人群不同,所能提供的访问能力也有差异。常见的客户端有下面这几种:
* 前端,包括浏览器、手机应用;
* SDK
* 命令行工具;
* 其他终端。
接下来,我就来分别介绍下。
浏览器和手机应用提供一个交互界面供用户访问后端服务使用体验最好面向的人群是最终的用户。这两类客户端也称为前端。前端由前端开发人员进行开发并通过API接口调用后端的服务。后端开发人员不需要关注这两类客户端只需要关注如何提供API接口即可。
SDKSoftware 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. 思考下,还有哪些好的命令行工具构建方式,欢迎在留言区分享。
欢迎你在留言区与我交流讨论,我们下一讲见。