本书的第五部分也是最后一部分,为 Cube 实现了一个命令行界面(CLI)。这个命令行界面取代了我们在本书前面部分一直用来操作 Cube 的 main.go
程序。
第 12 章实现了用于启动管理器和工作器、启动和停止任务以及获取任务状态的命令。
第 13 章总结了我们所取得的成果,并就接下来的方向给出了一些建议。
12 Building a command-line interface
本章涵盖内容如下:
-
命令行界面的核心组件
-
介绍 Cobra 框架
-
创建命令框架
-
开发一个命令行界面,以替代之前同时使用
main.go
和curl
程序的操作方式
在整本书中,我们一直使用一个较为简陋的主程序来操作我们的编排系统(图 12.1)。这个程序是一个单体程序,包揽了所有任务:启动工作器、启动工作器的 API 服务器、启动管理器以及启动管理器的 API 服务器。如果出于任何原因想要停止管理器,那么工作器也会随之停止,反之亦然。然而,当我们想要与编排器进行交互时,却需要通过 curl
命令单独进行操作。
图12.1 与我们的编排器交互的旧方式
12.1 CLI的核心组件
既然我们已经实现了编排系统,现在让我们把注意力转向如何操作它。像 Kubernetes 和 Nomad 这样流行的编排系统都是通过命令行界面(CLI)来操作的。Kubernetes 使用多个二进制文件来实现其命令行界面:
-
kubeadm
用于引导启动一个 Kubernetes 集群。 -
kubelet
执行与我们的工作器相同的功能(即它运行任务)。 -
kubectl
提供了一个与集群进行交互的接口。
另一方面,Nomad 使用一个名为 nomad
的单个二进制文件来实现其命令行界面,但在其他方面提供了类似的接口。
我们在本章的任务是为 Cube 编排器实现一个命令行界面。这个命令行界面将支持以下操作:
- 启动工作器
- 启动管理器
- 启动和停止任务
- 获取任务的状态
许多优秀的命令行界面都由一些常见元素构成。它们为每个命令使用一致的模式,并提供内置的帮助信息。我们将为自己的命令行界面使用的模式形式为 APPNAME COMMAND ARG --FLAG
。例如,本章后面将要实现的 worker
命令看起来会像这样:
$ cube worker --name worker-1 --dbtype memory
内置的帮助信息使得用户可以通过在命令中附带 --help
或 -h
标志轻松获取上下文相关的帮助。传递 --help
标志后输出的内容通常包括该命令的简短摘要、更详细的描述(提供更深入的解释和可能的示例)以及该命令接受的标志列表。对于我们的 worker
命令,传递 --help
标志将产生以下信息:
$ cube worker --help cube worker command.
The worker runs tasks and responds to the manager's requests about task ➥ state.
Usage:
cube worker [flags]
Flags:
-d, --dbtype string Type of datastore to use for tasks ("memory" or ➥ "persistent") (default "memory")
-h, --help help for worker
-H, --host string Hostname or IP address (default "0.0.0.0")
-n, --name string Name of the worker (default ➥ "worker-9a385171-f980-47c4-a250-75736d3eb6f0")
-p, --port int Port on which to listen (default 5556)
本章中我们将要实现的每个命令都会遵循这种模式,并提供类似的有用帮助信息(图 12.2)。
图12.2 与我们的编排器操作和交互的新方式
12.2 眼镜蛇框架介绍
我们打算使用一个框架来实现命令行界面(CLI),具体来说,会采用 Cobra 框架(github.com/spf13/cobra)。Cobra 为众多知名项目提供支持,像 Kubernetes、Helm、Hugo、Moby(Docker)以及 GitHub CLI 等(更多使用 Cobra 的项目列表可查看 mng.bz/XqBE)。那为什么要使用框架呢?从技术层面讲,我们并非必须使用框架,仅借助 Go 标准库也能从头开始构建 CLI。
一个最基础的 CLI 需实现以下功能:
-
解析命令行传入的参数和标志。
-
调用与命令匹配的处理函数,并将参数和标志作为参数传递给它。
-
把处理函数的输出呈现给用户。
尽管这个列表简短且看似简单,但实际涉及的工作可不少。比如,仅仅处理标志就可能需要编写数千行代码(可查看 Go 标准库中 flag
包的 flag.go
文件,或者 pflag
库的 flag.go
文件)。CLI 框架和 Web 框架类似,能提供标准的构建模块。
下面让我们开始安装 Cobra 吧。
go get -u github.com/spf13/cobra@latest
我们还需要安装 cobra-cli:
go install github.com/spf13/cobra-cli@latest
12.3 设置我们的Cobra应用程序
我们已经安装了 Cobra 库,它为我们的命令行界面(CLI)提供了框架,同时也安装了 cobra-cli
工具,该工具为我们初始化 CLI 应用程序和后续添加命令提供了一些便捷的快捷方式。
在开始之前,先对前面章节中的 main.go
程序进行备份。因为我们将用 cobra-cli
工具的输出覆盖它。然后,从项目目录的根目录开始,让我们初始化 Cobra CLI:
$ cobra-cli init
Your Cobra application is ready at /home/t/workspace/personal/manning/code/ch12
init
命令为我们执行了多项任务:
-
生成一个新的
main.go
文件。 -
在项目中创建一个
cmd
目录。 -
在新创建的
cmd
目录内创建一个root.go
文件。
新的 cmd
目录看起来应该是这样的:
$ tree cmd
cmd
└── root.go
此时,我们已经搭建好了构建命令行界面(CLI)所需的一切。我们可以运行主程序,看看初始状态是什么样的:
$ go run main.go
A longer description that spans multiple lines and likely contains examples and usage of using your application. 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.
12.4 Understanding the new main.go
code 12.1 The new main.go generated by cobra-cli
package main
import "cube/cmd"
func main() {
cmd.Execute()
}
12.5 Understanding root.go
在深入研究我们自己的命令之前,让我们先来探究一下 cmd/root.go
文件。在文件顶部,cobra-cli init
命令添加了一个版权声明。如果你打算将自己的命令行界面(CLI)作为开源软件发布,这会很方便,但就我们的需求而言并非必需。你可以随意删除它。接下来是包声明,将包命名为 cmd
。然后有一些导入语句,尤其值得注意的是对 Cobra 库 github.com/spf13/cobra
的导入。
/* Copyright © 2023 NAME HERE <EMAIL ADDRESS> */
package cmd
import (
"os"
"github.com/spf13/cobra"
)
接下来是文件的核心部分,其中包含 rootCmd
。顾名思义,这是我们命令行界面(CLI)的根命令。需要重点注意的是,在 Cobra 中是如何构建命令的。每个命令都是一个指向 cobra.Command
类型的指针,我们通过创建该类型的实例并将其赋值给一个变量。
在创建 cobra.Command
实例并赋值给 rootCmd
变量时,定义了几个字段。Use
字段提供一行使用说明信息;Short
字段给出 CLI 的简短描述;Long
字段则提供 CLI 更详细的描述。这三个字段都会在 CLI 的帮助系统中使用(也就是运行 go run main.go --help
时)。在生成的 root.go
文件中被注释掉的 Run
字段是最重要的,它被定义为一个函数类型,该函数接受两个参数:一个指向 Command
类型的指针和一个字符串切片。当用户调用命令时,这个函数会执行相应的操作。我们很快就会看到这个函数的实际运行情况。
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "cube",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and likely ➥ contains examples and usage of using your application. 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.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
生成的 root.go
文件的下一部分是 Execute
命令。正如该函数上方的注释所说,这个函数由 main.main()
调用,并且仅调用一次。这意味着当程序启动时,会执行这个 Execute
函数来处理命令行输入。它通常会调用根命令(也就是前面提到的 rootCmd
),以确保根据用户在命令行输入的内容来执行相应的操作。在后续的代码里,这个函数可能会对命令的执行结果进行处理,比如处理可能出现的错误等情况。
// Execute adds all child commands to the root command and sets flags ➥ appropriately.
// This is called by main.main(). It only needs to happen once to the ➥ rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
root.go
文件的最后一部分是 init
函数的定义。在 Go 语言里,init
函数很特殊,它是 Go 语言预定义的,用于执行配置操作。在 Cobra 应用程序中,标志(flags)的定义以及其他配置设置通常在 init
函数里完成。在 Cobra 框架中,有两种类型的标志:持久标志(persistent)和局部标志(local)。持久标志从定义它的父命令开始,会一直应用到所有子命令;而局部标志仅在定义它的命令中有效。生成的 root.go
文件中的 init
函数包含了这两种类型的标志各一个。
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cube.yaml)")
// Cobra also supports local flags, which will only run // when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
12.6 Implementing the worker command
既然我们对 Cobra 框架有了基本的了解,那就开始实现我们的命令吧。我们要构建的第一个命令是 worker
命令。为了开始这项工作,我们可以使用 cobra-cli
命令为我们创建一个框架:
cobra-cli add worker
此命令在cmd目录中创建worker.go文件。
由 cobra-cli add
命令创建的框架应该如清单 12.2 所示。在这个框架中需要注意的一点是,add
命令生成的代码包含了一个可执行的 Run
字段。该字段的值是一个函数,其功能仅仅是打印出 “worker called”。就像我们在根命令中看到的那样,这个框架包含一个 init
函数,我们可以在其中定义标志。此外,要注意 init
函数调用了 rootCmd.AddCommand
方法,并将 workerCmd
作为参数传递给它。顾名思义,这个调用将我们的 worker
命令添加到了根命令中,这样它就能在 CLI 应用程序中使用了。
code 12.2 The skeleton worker command created by the cobra-cli add command
...
// workerCmd represents the worker command
var workerCmd = &cobra.Command{
Use: "worker",
Short: "A breif description of your command",
Long: `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.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("worker called")
})
}
func init() {
rootCmd.AddCommand(workerCmd)
}
一旦我们搭建好这个框架,就可以运行它并验证其是否能正常工作。如果我们带着 --help
标志来运行它,就能看到使用说明信息:
这里通常会输出命令的简要描述、可用的参数、标志等帮助内容,帮助用户了解如何正确使用 worker
命令。这一功能是 Cobra 框架自动根据我们在代码中设置的信息生成的,比如 Use
、Short
、Long
这些字段的内容。后续我们可以进一步完善这个命令,实现具体的业务逻辑,例如启动工作器进程、配置工作器参数等。
$ go run main.go worker --help
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.
Usage:
cube worker [flags]
Flags:
-h, --help
help for worker
如果我们运行该命令,我们会看到输出:
$ go run main.go worker
worker called
code 12.3 Getting the worker's host and port from environment variables
whost := os.Getenv("CUBE_WORKER_HOST")
wport, _ := strconv.Atoi(os.Getenv("CUBE_WORKER_PORT"))
我们不再读取环境变量,而是使用标志。在清单 12.4 中,我们添加了 host
和 port
标志。我们使用 StringP
方法创建 host
标志,使用 IntP
方法创建 port
标志。这两个方法都接受以下参数(均为字符串类型):
- 标志名称:这是标志的完整名称,在命令行中使用时需要在前面加上双横线
--
,例如--host
和--port
。 - 缩写字母:可以在单横线之后使用的简写字母,比如
-h
可以作为--host
的简写,-p
可以作为--port
的简写。 - 默认值:当用户在命令行中没有指定该标志时使用的默认值。对于
host
标志,可能默认值是"localhost"
;对于port
标志,可能默认值是"8080"
。 - 标志用途描述:对该标志用途的简要说明,当用户使用
--help
标志查看命令帮助信息时,会显示这个描述。
code 12.4 Defining the host and port flags in the generated init method
func init() {
rootCmd.AddCommand(workerCmd)
workerCmd.Flags().StringP("host", "H", "0.0.0.0", "Hostname or IP address")
workerCmd.Flags().IntP("port", "p", 5556, "Port on which to listen")
}
注意:在底层,Cobra 使用了 pFlag 库(github.com/spf13/pflag)。该库和 Cobra 由同一作者编写,并且宣传为 Go 语言标准库中 flag
包的 “直接替代品”。
接下来,我们再添加两个标志。name
标志将用于指定工作器的名称。我们依旧使用 StringP
方法来创建它,并且会借助 fmt
包中的 Sprintf
函数构造一个字符串,以此生成一个唯一的名称作为默认值。dbtype
标志用于指定工作器采用何种类型的任务存储方式,存储方式可以是内存存储(memory
)或者持久化存储(persistent
),默认采用内存存储。name
标志既可以写成 --name
,也能简写成 -n
;dbtype
标志既可以写成 --dbtype
,也能简写成 -d
。
12.5 code The name flag
workerCmd.Flags().StringP("name", "n", fmt.Sprintf("worker-%s", uuid.New().String()), "Name of the worker")
workerCmd.Flags().StringP("dbtype", "d", "memory", "Type of datastore to use for tasks (\"memory\" or \"persistent\")")
至此,我们已经有了一个可用的 worker
命令。让我们查看一下帮助信息:
$ go run main.go worker --help
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.
Usage:
cube worker [flags]
Flags:
-d, --dbtype string Type of datastore to use for tasks ("memory" or ➥ "persistent") (default "memory")
-h, --help help for worker
-H, --host string Hostname or IP address (default "0.0.0.0")
-n, --name string Name of the worker
➥ (default "worker-ed9b23bf-3070-4da6-a163-ef16bdae8c22")
-p, --port int Port on which to listen (default 5556)
我们也可以调用这个命令:
$ go run main.go worker
worker called
调用 worker
命令目前并没有实际的有用功能。不过,它确实能展示一个可运行的命令,会打印出 “worker called”。
在定义好标志后,我们可以着手处理 workerCmd
了。当我们使用 --help
标志运行该命令时,会发现使用说明信息是关于 Cobra 本身的。让我们对帮助信息进行定制,使其更具针对性。为此,我们需要修改 workerCmd
的 Use
、Short
和 Long
字段,如下列代码所示。
code 12.6 Modifying the workerCmd for a more useful help message
var workerCmd = &cobra.Command{
Use: "worker",
Short: "Worker command to operate a Cube worker node.",
Long: `cube worker command.
The worker runs tasks and responds to the manager's requests about task state.`,
现在,如果我们使用 --help 标志运行命令,我们应该看到一个更有用的帮助信息:
$ go run main.go worker --help
cube worker command.
The worker runs tasks and responds to the manager's requests about task ➥ state.
Usage:
cube worker [flags]
Flags:
-d, --dbtype string Type of datastore to use for tasks ("memory" or ➥ "persistent") (default "memory")
-h, --help help for worker
-H, --host string Hostname or IP address (default "0.0.0.0")
-n, --name string Name of the worker
➥ (default "worker-bd7dc7dc-abf7-4cea-943b-11f13f128208")
-p, --port int Port on which to listen (default 5556)
code 12.7 The Run field, the core of a Cobra command
Run: func(cmd *cobra.Command, args []string) {
host, _ := cmd.Flags().GetString("host")
port, _ := cmd.Flags().GetInt("port")
name, _ := cmd.Flags().GetString("name")
dbType, _ := cmd.Flags().GetString("dbtype")
})
接下来,我们需要创建工作器及其 API,然后启动所有组件。在之前的 main.go
程序里执行这些任务时,我们是为三个工作器做的。如果你还记得的话,代码大概是这样的:
w1 := worker.New("worker-1", "persistent")
wapi1 := worker.Api{Address: whost, Port: wport, Worker: w1}
w2 := worker.New("worker-2", "persistent")
wapi2 := worker.Api{Address: whost, Port: wport + 1, Worker: w2}
w3 := worker.New("worker-3", "persistent")
wapi3 := worker.Api{Address: whost, Port: wport + 2, Worker: w3}
go w1.RunTasks()
go w1.UpdateTasks()
go wapi1.Start()
go w2.RunTasks()
go w2.UpdateTasks()
go wapi2.Start()
go w3.RunTasks()
go w3.UpdateTasks()
go wapi3.Start()
code 12.8 The workerCmd starting a worker
Run: func(cmd *cobran.Command, args []string) {
...
log.Println("Starting worker.")
w := worker.New(name, dbType)
api := worker.Ai{Address: host, Port: port, Worker: w}
go w.RunTasks()
go w.CollectStats()
go w.UpdateTasks()
log.Printf("Starting worker API on http://%s:%d", host, port)
api.Start()
}
现在,如果我们运行我们的命令,最终应该会得到一个正在运行且功能完备的工作器:
$ go run main.go worker
在继续进行下一步之前,尝试在不同的终端中再启动两个工作器。你需要使用 --port
标志为每个工作器指定不同的端口,但除此之外,其他默认设置应该就能正常工作。
code 12.7 Implementing the manager command
worker
命令完成后,让我们接着实现 manager
命令。我们将采用与 worker
命令类似的流程。首先,使用 cobra-cli add
命令创建框架:
$ cobra-cli add manager
manager created at /home/t/workspace/personal/manning/code/ch12
code 12.9 Customizing the init method of the managerCmd to define flags
func init() {
rootCmd.Addcomand(managerCmd)
managerCmd.Flags().StringP("host", "H", "0.0.0.0", "hostname or IP address")
managerCmd.Flags().IntP("port", "p", 5555, "Port on which to listen")
managerCmd.Flags().StringSliceP("workers", "w", []string{"localhost:5556"}, "List of workers on which the manager will schedule tasks.")
managerCmd.Flags().StringP("scheduler", "s", "epvm", "Name of scheduler to use.")
managerCmd.Flags().StringP("dbType", "d", "memory", "Type of datastore to use for events and tasks (\"memory\" or \"persistent\")")
}
修改 manager
命令框架的下一步是定制使用 --help
标志运行该命令时显示的使用说明信息。
var managerCmd = &cobra.Command{
Use: "manager",
Short: "Manager command to operate a Cube manager node.",
Long: `cube manager command.
The manager controls the orchestration system and is responsible for:
- Accepting tasks from users
- Scheduling tasks onto worker nodes
- Rescheduling tasks in the event of a node failure
- Periodically polling workers to get task updates`,
}
至此,我们已经有了一个可用的 manager
命令。如果我们现在使用 --help
标志运行 manager
命令,将会看到如下输出:
$ go run main.go manager --help
cube manager command.
The manager controls the orchestration system and is responsible for:
- Accepting tasks from users
- Scheduling tasks onto worker nodes
- Rescheduling tasks in the event of a node failure
- Periodically polling workers to get task updates
Usage:
cube manager [flags]
Flags:
-d, --dbType string Type of datastore to use for events and tasks ➥ ("memory" or "persistent") (default "memory")
-h, --help help for manager
-H, --host string Hostname or IP address (default "0.0.0.0")
-p, --port int Port on which to listen (default 5555)
-s, --scheduler string Name of scheduler to use. (default "epvm")
-w, --workers strings List of workers on which the manager will schedule ➥ tasks. (default [localhost:5556])
和 worker
命令一样,现在唯一有待实现的就是 managerCmd
的 Run
字段了。同样,我们先获取命令行传入的标志的值。而且,我们依旧不检查错误,因为我们已经为所有标志设置了默认值。注意,我们用来获取标志值的函数是由 Get
加上类型名称组合而成的。所以,通过 StringP
方法定义的标志要用 GetString()
函数来获取值,通过 StringSliceP
方法定义的标志要用 GetStringSlice()
函数来获取值,通过 IntP
方法定义的标志则要用 GetInt()
函数来获取值。
Run: func(cmd *cobra.Command, args []string) {
host, _ := cmd.Flags().GetString("host")
port, _ := cmd.Flags().GetInt("port")
workers, _ := cmd.Flags().GetStringSlice("workers")
scheduler, _ := cmd.Flags().GetString("scheduler")
dbType, _ := cmd.Flags().GetString("dbType")
})
最后,我们创建管理器及其 API,然后启动它。这应该看起来很眼熟,因为我们只是在执行与旧的 main.go
程序中相同的调用。
code 12.11 Starting up the manager with the same calls as the old main.go program
log.Println("Starting manager.")
m := manager.New(workers, scheduler, dbType)
api := manager.Api{Address: host, Port: port, Manager: m}
go m.ProcessTasks()
go m.UpdateTasks()
go m.DoHealthChecks()
go m.UpdateNodeStats()
log.Printf("Starting manager API on http://%s:%d", host, port)
api.Start()
现在让我们启动管理器:
$ go run main.go manager -w 'localhost:5556,localhost:5557,localhost:5558'
瞧!至此,我们已经用可以分别独立启动工作器和管理器的各个命令,取代了前几章里旧的 main.go
程序。
12.8 Implementing the run command
我们已经用独立的命令取代了 main.go
程序,用于启动管理器和工作器组件。但先别急着自我庆贺,我们还有更多工作可做,能让我们的编排系统更具实用性。
如果你还记得,在前面的章节里,我们通过使用 curl
命令手动调用管理器的 API 来启动和停止任务。万一你忘了,下面就是我们启动任务的方式:
$ curl -X POST localhost:5555/tasks -d @task1.json
$ curl -X DELETE localhost:5555/tasks/bb1d59ef-9fc1-4e4b-a44d-db571eeed203
到目前为止,我们启动和停止任务的方式在技术上没有任何问题,它能完成工作。然而,如果我们想想现有的编排系统,比如 Kubernetes 或 Nomad,我们知道不会通过使用 curl
调用它们的 API 来启动和停止任务。相反,我们会使用像 kubectl
或 nomad
这样的命令行工具。
所以,让我们创建一个可以启动任务的命令。为了方便,我们把这个命令叫做 run
。使用 cobra-cli add
命令为它创建框架:
$ cobra-cli add run
run created at /home/t/workspace/personal/manning/code/ch12-experimental
到现在,这个框架结构对我们来说应该已经很熟悉了,所以我们接下来会推进得快一些。
首先,让我们在 init
函数里定义两个标志。第一个标志名为 manager
,它能让用户指定要与之通信的管理器。我们将 localhost:5555
设为默认值,因为我们一直用的就是这个。第二个标志名为 filename
,该标志允许用户指定包含任务定义的文件的名称。
code 12.12 The Run command defines the manager and filename flags
func init() {
rootCmd.AddCommand(runCmd)
runCmd.Flags().StringP("manager", "m", "localhost:5555", "Manager to talk to")
runCmd.Flags().StringP("filename", "f", "task.json", "Task specification file")
}
所以我们允许用户指定一个包含任务定义的文件名。然而,如果这个文件不存在会怎样呢?让我们按照代码清单 12.13 来创建 fileExists
函数,在尝试读取文件之前先检查该文件是否确实存在。为了实现这一点,我们调用 os
包中的 Stat
函数,并将文件名作为参数传递给它。我们舍弃该函数返回的值,只存储可能出现的错误。然后使用 errors.Is
函数来检查调用 Stat
函数返回的 err
是否为 fs.ErrNotExist
类型。我们使用 !
(非)运算符,当 errors.Is
返回 false
时返回 true
,当 errors.Is
返回 true
时返回 false
。
code 12.13 The fileExists function
func fileExists(filename string) bool {
_, err := os.State(filename)
return !errors.Is(err, fs.ErrNotExist)
}
现在来看看我们新命令 runCmd
的关键部分。按照下面的代码清单,替换 Use
、Short
和 Long
字段的框架默认值。
code 12.14 Updating the text used in the command’s help message
var runCmd = &cobra.Command{
Use: "run",
Short: "Run a new task",
Long: `cube run command.
The run command starts a new task.`,
}
如果此时我们使用 --help
标志来运行这个命令,我们将会看到如下内容:
$ go run main.go run --help
cube run command.
The run command starts a new task.
Usage:
cube run [flags]
Flags:
-f, --filename string
-h, --help
-m, --manager string
Task specification file (default "task.json") help for run Manager to talk to (default "localhost:5555")
该命令最后要实现的部分是 Run
字段。和 worker
命令与 manager
命令一样,我们首先从命令行传入的 manager
和 filename
标志中获取值,并将它们存储到变量里。
code 12.15 Retrieving the values of the manager and filename flags
Run: func(cmd *cobra.Comamnd, args []string) {
manager, _ := cmd.Flags().GetString("manager")
filename, _ := cmd.Flags().GetString("filename")
}
一旦定义好了 manager
和 filename
变量,我们接着检查 filename
指定的文件是否确实存在。为此,我们先使用 filepath
包中的 Abs
函数获取 filename
的绝对路径。然后将完整的文件路径传递给我们的 fileExists
函数,如果文件不存在,我们就记录一条消息并退出。检查任务文件是否存在能让我们进行一些合理性检查,并为用户提供相关的错误信息。
code 12.16 Checking for the existence of the task file
fullFilePath, err := filepath.Abs(filename)
if err != nil {
log.Fatal(err)
}
if !fileExists(fullFilePath) {
log.Fatalf("File %s doest not exist", filename)
}
在确认传入命令的文件存在之后,我们就可以继续并启动任务了。我们将按以下流程操作:
-
记录有关管理器和文件绝对路径的有用信息。
-
读取任务定义文件的内容,并再记录一条日志消息。
-
调用管理器的 API(相当于执行
curl -X POST localhost:5555/tasks
)。 -
处理任何错误。
Run
字段函数的主要工作就是调用管理器的 API 并处理可能出现的错误。
code 12.17 The remainder of the Run field
log.Printf("Using manager: %v\n", manager)
log.Printf("Using file: %v\n", fullFilePath)
data, err := os.ReadFile(filename)
if err != nil {
log.Fatalf("Unable to read file: %v", filename)
}
url := fmt.Sprintf("http://%s/tasks", manager)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(data))
if err != nil {
log.Panic(err)
}
if resp.StatusCode != http.StatusCreated {
log.Printf("Error sending request: %v", resp.StatusCode)
}
defer resp.Body.Close()
log.Println("Successfully sent task request to manager")
有了这段代码,我们就可以启动任务了!如果你在前面的步骤中停止了工作器和管理器,现在把它们重新启动。然后运行 run
命令:
$ go run main.go run --filename task1.json
观察管理器的输出,你应该会看到它启动了任务。并且你可以通过运行 docker ps
命令来确认这一点:
$ docker ps
我们也可以直接查询管理器的 API 来验证任务是否正在运行:
$ curl localhost:5555/tasks|jq
太好了!但别停下来。既然我们已经创建了一个启动任务的命令,那我们就创建一个停止它们的命令。
12.9 Implementing the stop command
现在,你应该知道接下来要做什么了。我们先创建一个骨架:
cobra-cli add stop
接下来,我们要在 cmd/stop.go
文件的框架代码里添加标志。对于 Stop
命令,我们只需要一个标志,即 manager
。
code 12.18 Using the manager flag for the Stop command
package cmd
func init() {
rootCmd.AddCommand(stopCmd)
stopCmd.Flags().StringP("manager", "m", "localhost:5555", "Manager to talk to")
}
在定义好标志后,我们将 cobra-cli add
命令生成的样板使用说明字符串替换为更合适的内容。
var stopCmd = &cobra.Command{
Use: "stop",
Short: "Stop a running task.",
Long: `cube stop command.
The stop command stops a running task.`,
}
此时,我们可以使用 --help 标志运行我们的命令并验证我们的命令是否正常工作:
$ go run main.go stop --help
使用我们的 stop
命令时,注意到我们并未定义一个标志来指定想要停止的任务。相反,用户将把任务作为参数传递给该命令。以下是具体的样子:
$ go run main.go stop bb1d59ef-9fc1-4e4b-a44d-db571eeed203
为了读取参数,我们在 stopCmd
变量上定义 Args
字段。我们给这个变量赋值为 cobra.MinimumNArgs(1)
,以此表明我们期望用户给命令传递一个参数。到这一步,Run
字段应该看起来很眼熟了。我们所做的操作相当于调用 curl -X DELETE localhost:5555/tasks/{taskID}
。
code 12.19 The Stop command using arguments in addition to flags
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.command, args []string) {
manager, _ := cmd.Flags().GetString("manager")
url := fmt.Sprintf("http://%s/tasks/%s", manager, args[0])
client := &http.Client{}
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
log.Printf("Error creating request %v: %v", url, err)
}
resp, err := client.Do(req)
if err != nil {
log.Printf("Error connecting to %v: %v", url, err)
}
if resp.StatusCode != http.StatusNoContent {
log.Printf("Error sending request: %v", err)
return
}
log.Printf("Task %v has been stopped.", args[0])
})
让我们尝试使用新的停止命令来停止我们启动的任务:
$ go run main.go stop bb1d59ef-9fc1-4e4b-a44d-db571eeed203
现在,让我们通过检查 docker ps 的输出来验证它是否停止了我们的任务。
太棒了!有了我们的 stop
命令,我们成功地取代了前几章中一直使用的手动操作流程。现在,我们可以通过命令来完成所有操作:启动工作器(多个工作器的话)、启动管理器,以及启动和停止任务。
12.10 Implementing the status command
既然我们已经进入了开发命令行工具的状态,那就再创建两个命令吧。第一个是 status
命令。如果你还记得,在本书中,我们在启动任务后一直使用 docker ps
命令来获取正在运行的 Docker 容器列表。
就我们的需求而言,我们希望得到类似于 docker ps
的输出,但其中包含的是关于我们任务的信息,而非容器的信息。也就是说,我们希望输出信息来自于我们编排系统的真实数据源(即我们的管理器)。
那么,让我们使用 cobra-cli add
命令为我们的 status
命令创建框架吧:
$ cobra-cli add status
code 12.20 Defining the manager flag in the init method
func init() {
rootCmd.AddCommand(statusCmd)
statusCmd.Flags().StringP("manager", "m", "localhost:5555", "Manager to talk to")
}
var StatusCmd = &cobra.Command{
Use: "status",
Short: "Status command to list tasks.",
Long: `cube status command.
The status command allows a user to get the status of tasks from
the Cube manager.`,
}
正如我们之前看到的,我们现在可以使用 --help 标志运行命令:
$ go run main.go status --help
最后,也是很重要的一点,我们可以实现 Run
字段对应的函数。该函数将执行以下操作:
-
获取命令行中传入的管理器地址。
-
使用该管理器地址构建管理器 API 的 URL。
-
向管理器的 API 发送 GET 请求以获取所有任务。
-
对响应中发送的 JSON 主体进行反序列化,并将其转换为
task.Task
指针切片。 -
以格式化表格的形式(类似于
docker ps
的输出)打印出每个任务的部分信息。
Run
字段的函数负责调用管理器的 API 并以可读的格式打印出响应。需要注意的是,我们使用 Go 标准库中的 tabwriter
(pkg.go.dev/text/tabwri…)包来生成输出。
code 12.21 The Run function
Run: func(cmd *cobra.Command, args []string) {
manager, _ := cmd.Flags().GetString("manager")
url := fmt.Sprintf("http://%s/tasks", manager)
resp, _ := http.Get(url)
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var tasks []*task.Task
err = json.Unmarshal(body, &tasks)
if err != nil {
log.Fatal(err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 5, ' ', tabwriter.TabIndent)
fmt.Fprintln(w, "ID\tNAME\tCREATED\tSTATE\tCONTAINERNAME\tIMAGE\t")
for _, task := range tasks {
var start string
if task.StartTime.IsZero() {
start = fmt.Sprintf("%s ago", units.HumanDuration(time.Now().UTC().Sub(time.Now().UTC())))
} else {
start = fmt.Sprintf("%s ago", units.HumanDuration(time.Now().UTC().Sub(task.StartTime)))
}
state := task.State.String()[task.State]
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t\n", task.ID, task.Name, start, state, task.Name, task.Image)
}
}
这样,我们就可以运行图 12.3 中的状态命令了。
go run main.go status
12.11 Implementing the node command
我们要实现的最后一个命令是 node
命令。该命令的目的是提供我们编排系统中节点的概要信息。它将列出以下内容:
-
节点名称
-
内存
-
磁盘
-
角色
-
任务数量
从技术层面讲,我们并非真的需要这个命令。和 status
命令一样,我们可以使用管理器的 API 并直接查询 /nodes
端点。以下是使用 curl
命令并将输出通过管道传递给 jq
以使其更易读的示例:
$ curl localhost:5555/nodes|jq
[{
"Name": "localhost:5556",
"Ip": "",
"Api": "http://localhost:5556",
"Memory": 32793076,
"MemoryAllocated": 0,
"Disk": 20411170816,
"DiskAllocated": 0,
"Stats": { "MemStats": {...}, "DiskStats": {...}, "CpuStats": {...}, "LoadStats": {...}, "TaskCount": 0 },
"Role": "worker",
"TaskCount": 0
}]
这个输出你应该很熟悉。这是一个 Node
类型的列表,我们在第 10 章开发调度器时实现了这个类型。注意,节点列表中只有一个节点,并且它有 Stats
字段,这你应该也不陌生,因为这就是我们在第 6 章实现的 Stats
类型。
虽然我们可以使用 curl
命令查询管理器的 /nodes
端点来获取系统中节点的信息,但这种输出形式并不直观。这就是我们要实现 node
命令的原因。我们会将信息精简为最关键的部分,并以更易读的方式输出。
同样,我们将使用 cobra-cli add
命令来创建命令框架:
$ cobra-cli add node
和我们之前实现的 status
命令一样,node
命令也只会有一个标志。
code 12.22 The node command defining a single flag of type StringP
func init() {
rootCmd.AddCommand(nodeCmd)
nodeCmd.Flags().StringP("manager", "m", "localhost:5555", "Manager to talk to")
}
code 12.23 The nodeCmd struct
var Nodecmd = &cobra.Command{
Use: "node",
Short: "Node command to list nodes.",
Long: `cube node command.
The node command allows a user to get the information about the nodes in the ➥ cluster.`,
}
到这一步,我们应该已经有了一个可用的命令,我们可以再次通过使用 -help
标志运行该命令来进行确认:
$ go run main.go node --help
code 12.24 The node command
Run: run(cmd *cobra.Comamnd, args []string) {
manager, _ = cmd.Falgs().GetString("manager")
url := fmt.Sprintf("http://%s/nodes", manager)
resp, _ := http.Get(url)
defer resp.Body.Close()
var nodes []*node.Node
json.Unmarshal(body, &nodes)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 5, ' ',
tabwriter.TabIndent)
fmt.Fprintln(w, "NAME\tMEMORY (MiB)\tDISK (GiB)\tROLE\tTASKS\t")
for _, node := range nodes {
fmt.Fprintf(w, "%s\t%d\t%d\t%s\t%d\t\n", node.Name,
node.Memory/1000,
node.Disk/1000/1000/1000,
node.Role,
node.TaskCount)
}
w.Flush()
},
让我们试一试!
$ go run main.go node
很好!如你所见,我有三个节点正在运行。由于我在同一台机器上运行所有三个工作器,所以 “内存(MEMORY)” 和 “磁盘(DISK)” 字段的值将会是相同的。现在,让我们启动过去几章中使用过的相同的三个任务,看看 node
命令的输出会如何变化:
$ go run main.go node
正如预期的那样,在启动了三个任务后,node
命令的输出发生了变化,每个节点运行其中一个任务。至此,我们已经实现了所有的命令。
最后提供一个 docker 一键清理的命令 prune:
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"log"
"os/exec"
"strings"
"github.com/spf13/cobra"
)
// pruneCmd represents the prune command
var pruneCmd = &cobra.Command{
Use: "prune",
Short: "stop and remove container to avoid conflict",
Long: `the quick way to stop and remove defineded container:
since the container to be started for each pending task is the same:
smy-test:v1, and the port exposed by the program running in the container
is 7777, the container needs to be cleand up when the program is restarted
to prevent port conflict.`,
Run: func(cmd *cobra.Command, args []string) {
stopAndRemoveContainer()
},
}
func init() {
rootCmd.AddCommand(pruneCmd)
}
func stopAndRemoveContainer() {
// 1. Execute "docker ps -aq" to get all container IDs
cmd := exec.Command("docker", "ps", "-aq")
output, err := cmd.Output()
if err != nil {
log.Fatalf("Failed to list containers: %v", err)
}
// 2. Convert output to a slice of container IDs
containerIDs := strings.Fields(string(output))
if len(containerIDs) == 0 {
fmt.Println("No containers are currently running.")
return
}
// 3. Stop each container
for _, containerID := range containerIDs {
fmt.Printf("Stopping container %s...\n", containerID)
stopCmd := exec.Command("docker", "stop", containerID)
stopOutput, err := stopCmd.CombinedOutput() // output to stdout and stderr
if err != nil {
log.Printf("Failed to stop container %s: %v\n", containerID, err)
continue
}
log.Printf("Container %s stopped: %s", containerID, stopOutput)
removeCmd := exec.Command("docker", "rm", containerID)
removeOutput, err := removeCmd.CombinedOutput()
if err != nil {
log.Printf("Failed to remove container %s: %v\n", containerID, err)
continue
}
log.Printf("Container %s removed: %s", containerID, removeOutput)
}
}
验证
status
$ go run main.go status
ID NAME CREATED STATE CONTAINERNAME IMAGE
bb1d59ef-9fc1-4e4b-a44d-db571eeed203 test-chapter-9.1 5 hours ago Running test-chapter-9.1 docker.io/sun4965485/echo-smy:v1
stop
$ go run main.go stop bb1d59ef-9fc1-4e4b-a44d-db571eeed203
2025/03/27 19:58:42 Task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 has been stopped.
$ go run main.go status
ID NAME CREATED STATE CONTAINERNAME IMAGE
bb1d59ef-9fc1-4e4b-a44d-db571eeed203 test-chapter-9.1 5 hours ago Completed test-chapter-9.1 docker.io/sun4965485/echo-smy:v1
prune
$ go run main.go run --filename start2.json
2025/03/27 20:04:14 Using manager: 0.0.0.0:5555
2025/03/27 20:04:14 Using file: /Users/smy/projects/scratch/myorchestrator/start2.json
2025/03/27 20:04:14 Data: {
"ID": "b8aa1d44-08f6-443e-9378-f5864313419e",
"State": 2,
"Task": {
"State": 1,
"ID": "95fbe134-7f19-496a-acfc-c7753e5b4cd2",
"Name": "test-chapter-9.2",
"Image": "docker.io/sun4965485/echo-smy:v1",
"ExposedPorts": {
"7777/tcp": {}
},
"PortBindings": {
"7777/tcp": "7801"
},
"HealthCheck": "/health"
}
}
2025/03/27 20:04:14 Response: &{201 Created 201 HTTP/1.1 1 1 map[Content-Length:[384] Content-Type:[text/plain; charset=utf-8] Date:[Thu, 27 Mar 2025 12:04:14 GMT]] 0x140000be040 384 [] false false map[] 0x14000323400 <nil>}
2025/03/27 20:04:14 Successfully sent task request to manager
$ go run main.go status
ID NAME CREATED STATE CONTAINERNAME IMAGE
bb1d59ef-9fc1-4e4b-a44d-db571eeed203 test-chapter-9.1 5 hours ago Completed test-chapter-9.1 docker.io/sun4965485/echo-smy:v1
95fbe134-7f19-496a-acfc-c7753e5b4cd2 test-chapter-9.2 56 seconds ago Scheduled test-chapter-9.2 docker.io/sun4965485/echo-smy:v1
$ go run main.go prune
Stopping container 4d630d634fed...
2025/03/27 20:06:48 Container 4d630d634fed stopped: 4d630d634fed
2025/03/27 20:06:48 Container 4d630d634fed removed: 4d630d634fed
Stopping container 9e7c2c53079c...
2025/03/27 20:06:48 Container 9e7c2c53079c stopped: 9e7c2c53079c
2025/03/27 20:06:48 Container 9e7c2c53079c removed: 9e7c2c53079c
测试日志
Add Task
2025/03/27 20:07:09 Added task {95fbe134-7f19-496a-acfc-c7753e5b4cd2 test-chapter-9.2 1 0 0 0 docker.io/sun4965485/echo-smy:v1 map[7777/tcp:{}] map[] map[7777/tcp:7801] 2025-03-27 12:04:19.754958 +0000 UTC 0001-01-01 00:00:00 +0000 UTC /health 3}
{"status":"Pulling from sun4965485/echo-smy","id":"v1"}
{"status":"Digest: sha256:b3a6951a31ab9ba821c95815ccc16de992fd00019fab37ed607514e61cf6f6fe"}
{"status":"Status: Image is up to date for sun4965485/echo-smy:v1"}
2025/03/27 20:07:20 Container 9be7db68fb05218b1e003222ee22f2ac2fe295a1d4740483f6964765c3951394 started, waiting 2 seconds for initialization...
Task Update
2025/03/27 20:08:24 Checking status of tasks
2025/03/27 20:08:24 Successfully inspected container 9be7db68fb05218b1e003222ee22f2ac2fe295a1d4740483f6964765c3951394 for task 95fbe134-7f19-496a-acfc-c7753e5b4cd2
2025/03/27 20:08:24 Container 9be7db68fb05218b1e003222ee22f2ac2fe295a1d4740483f6964765c3951394 port mappings: map[7777/tcp:[{0.0.0.0 7801}]]
2025/03/27 20:08:24 updateTasks return resp Networking is nat.PortMap{"7777/tcp":[]nat.PortBinding{nat.PortBinding{HostIP:"0.0.0.0", HostPort:"7801"}}}
2025/03/27 20:08:24 Task updates completed
Collect Stats
2025/03/27 20:11:29 Collecting stats for node &{127.0.0.1:5556 http://127.0.0.1:5556 0 18874368 0 494384795648 0 worker 2 {{19327352832 2680258560 15413248 16647094272} {494384795648 11155230720 85994373120} {7.12 0 0 0 0 0 0 0 0} {3.91 4.13 4.61} 0}}
2025/03/27 20:11:29 Collecting stats for node &{localhost:5557 http://localhost:5557 0 18874368 0 494384795648 0 worker 1 {{19327352832 2680385536 15224832 16646967296} {494384795648 11155230720 85994373120} {7.12 0 0 0 0 0 0 0 0} {3.91 4.13 4.61} 0}}
2025/03/27 20:11:29 Collecting stats for node &{localhost:5558 http://localhost:5558 0 0 0 0 0 worker 1 {{0 0 0 0} {0 0 0} {0 0 0 0 0 0 0 0 0} {0 0 0} 0}}
Health Check
2025/03/27 20:06:09 Performing task health check
2025/03/27 20:06:09 Checking health for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 (Manager Current State: 2, RestartCount: 0)
2025/03/27 20:06:09 Calling health check for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203: /health
2025/03/27 20:06:09 Task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 port mappings: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/27 20:06:09 Available host ports: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/27 20:06:09 Checking port bindings for 7777/tcp: [{0.0.0.0 7777}]
2025/03/27 20:06:09 Found host port: 7777
2025/03/27 20:06:09 Attempting health check with URL: http://localhost:7777/health
2025/03/27 20:06:09 Health check response body: OK
总结
Cobra 框架通过提供标准化的组件来帮助构建命令行界面(CLI),这与 Web 框架类似。Cobra 提供了它自己的命令行工具 cobra-cli
,开发者可以使用它来初始化一个新的命令行项目,或者向现有的项目中添加命令。
命令遵循 “APPNAME COMMAND ARG --FLAG.” 的模式。
命令自带内置的帮助功能。我们用新的 worker
和 manager
命令取代了旧的 main.go
程序,这使我们能够更加灵活地独立启动工作器和管理器组件。
我们创建了更多的命令,使得与我们的编排系统进行交互变得更加容易。不再需要使用 curl
调用管理器的 API 来启动、停止任务以及获取任务状态,我们提供了 run
、stop
和 status
命令。我们还提供了 node
命令,该命令为用户提供了集群中节点当前状态的概览信息。