你好,我是轩脉刃。

上一节课,我们开始把框架向工业级迭代,重新规划了目录,这一节课将对框架做更大的改动,让框架支持命令行工具。

一个拥有命令行工具的框架会非常受欢迎。比如 Beego 框架提供了一个命令行工具 Bee、Vue 框架提供了 Vue-CLI,这些工具无疑给框架的使用者提供了不少便利。在使用框架过程中,命令行工具能将很多编码行为自动化。

而且退一步说,在实际工作中你会发现,即使用的框架没有提供任何命令行工具,在业务运营的过程中,我们也需要开发各种大大小小的命令行运营工具,作为业务运营的必要辅助。所以一个自带命令行工具,且很方便让用户自行开发命令行的框架,是非常有必要的。

这节课我们就研究一下如何将hade框架改造成为支持命令行交互的框架。

第三方命令行工具库 cobra

要让一个程序支持命令行,那么它的核心功能就是要能解析参数,比如 ./hade app start --address=:8888 其中的 ./hade 是我们要运行的程序,而后面的 app 和 start 两个字段以及–address=:8888 就是这个程序对应的参数了。

那么如何解析参数呢?

Golang 标准库提供了 flag 包能对参数进行解析。但是 flag 包只是一个命令行解析的类库,不支持组织,所以如果想基于 flag 包实现父子命令行工具,显然就不够了。出于不重复造轮子,站在巨人肩膀上的想法,我们将视线移向开源社区一个最出名的命令行工具库 cobra

cobra 是由大名鼎鼎的谷歌工程师 Steve Francia(spf13)开发并开源的一个项目。Steve Francia 是 Golang 开源界比较出名的一名程序员,是 Golang、Doctor、MongoDB 的开源贡献者,同时开源的 hugo、viper 等项目应用也非常广泛。而由他开发开源的 cobra 目前在 GitHub 上已经有了 23k 的 star。

cobra 不仅仅能让我们快速构建一个命令行,它更大的优势是能更快地组织起有许多命令行工具,因为从根命令行工具开始,cobra 把所有的命令按照树形结构组织起来了。

要使用 cobra 就要从源码上了解这个库。按照第一节课说的,按照 库函数 > 结构定义 > 结构函数的顺序读,你会发现,cobra 这个库最核心的内容是一个数据结构 Command

一个 Command 代表一个执行命令。这个 Command 包含很多可设置的字段,如何使用这个 Command,就取决于我们如何设置这些属性。下面是源码片段,我在注释中列出了这些属性的意义。

// Command代表执行命令的结构
type Command struct {
        // 代表当前命令的,如何执行,root 最好和生成的命令工具名称一致
        Use string
        
        // 代表这个工具的别名,在 subCommand 中有用,比如 root cmd1 和 root cmd_1 想要都执行一个 subCommand 就需要这样
        Aliases []string

        // 由于不强制设置,用于输入错误的时候建议字段
        SuggestFor []string

        // 这个就是在 help 的时候一句话描述这个命令的功能
        Short string

        // 详细描述这个命令的功能
        Long string

        // 例子
        Example string

        // 需要验证的参数
        ValidArgs []string

        // 有多少个参数,这里放了一个验证函数,可以是 ExactArgs,MaximumNArgs 等,验证有多少个参数
        Args PositionalArgs

        // 参数别名
        ArgAliases []string

        // 自动生成的命令设置
        BashCompletionFunction string

        // 如果这个命令已经废弃了,那么就这里写上废弃信息
        Deprecated string

        // 如果这个命令要被隐藏,设置这个字段
        Hidden bool

        // Annotations are key/value pairs that can be used by applications to identify or
        // group commands.
        Annotations map[string]string

        // 这个命令的版本
        Version string

        // 是否要打印错误信息
        SilenceErrors bool

        // 是否要打印如何使用
        SilenceUsage bool

        // 是否有 flag,如果这个命令没有 flag,设置为 true,那么所有的命令后面的参数都会是 arguments
        DisableFlagParsing bool

        // 是否打印自动生成字样: ("Auto generated by spf13/cobra...")
        DisableAutoGenTag bool

        // 是否显示[flags]字样
        DisableFlagsInUseLine bool

        // 是否打印建议
        DisableSuggestions bool

        // 两个字符串的差距多少会进入 suggest
        SuggestionsMinimumDistance int

        // 是否使用 Traverse 的方式来解析参数
        TraverseChildren bool

        // 解析错误白名单, 比如像未知参数
        FParseErrWhitelist FParseErrWhitelist
        
        // The *Run 函数运行顺序:
        //   * PersistentPreRun()
        //   * PreRun()
        //   * Run()
        //   * PostRun()
        //   * PersistentPostRun()
        // 会被继承的前置 Run
        PersistentPreRun func(cmd *Command, args []string)

        // 会被继承的前置 Run, 带 error
        PersistentPreRunE func(cmd *Command, args []string) error

        // 当前这个命令的前置 Run
        PreRun func(cmd *Command, args []string)
        // 当前这个命令的前置 Run,带 Error
        PreRunE func(cmd *Command, args []string) error
        // zh: 实际跑的时候运行的函数
        Run func(cmd *Command, args []string)
        // zh: Run 执行错误了之后
        RunE func(cmd *Command, args []string) error
        // 后置运行
        PostRun func(cmd *Command, args []string)
        // 后置运行,带 error
        PostRunE func(cmd *Command, args []string) error
        // 会被继承的后置运行
        PersistentPostRun func(cmd *Command, args []string)
        // 会被继承的后置运行,带 error
        PersistentPostRunE func(cmd *Command, args []string) error

        
}

这里属性非常多,你也不需要都记住是啥。来看一些常用属性,我们用一个设置好的输出结果图就能很好理解。

它对应的代码如下,后面会解释每一行都是怎么实现的:

// InitFoo 初始化 Foo 命令
func InitFoo() *cobra.Command {
   FooCommand.AddCommand(Foo1Command)
   return FooCommand
}
// FooCommand 代表 Foo 命令
var FooCommand = &cobra.Command{
   Use:     "foo",
   Short:   "foo 的简要说明",
   Long:    "foo 的长说明",
   Aliases: []string{"fo", "f"},
   Example: "foo 命令的例子",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      log.Println(container)
      return nil
   },
}
// Foo1Command 代表 Foo 命令的子命令 Foo1
var Foo1Command = &cobra.Command{
   Use:     "foo1",
   Short:   "foo1 的简要说明",
   Long:    "foo1 的长说明",
   Aliases: []string{"fo1", "f1"},
   Example: "foo1 命令的例子",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      log.Println(container)
      return nil
   },
}

对照代码和输出结果图,能看出 Command 中最常用的一些字段设置。

RunE 代表当前命令的真正执行函数

RunE: func(c *cobra.Command, args []string) error 

这个执行函数的参数有两个:一个是 cobra.Command,表示当前的这个命令;而第二个参数是 args,表示当前这个命令的参数,返回值是一个 error,代表命令的执行成功或者失败。

如何使用命令行 cobra

现在大致了解 cobra 这个库的使用方法和最核心的 Command 结构,就要想想接下来我们要用它来做些什么事情了。

首先,要把 cobra 库引入到框架中。由于希望后续能修改 Command 的数据,并且在后面的章节中会在 Command 结构中,继续加入一些字段来支持定时的命令行,所以和 Gin 框架的引入一样,我们采用源码引入的方式。

引入后要对 Command 结构进行修改。我们希望把服务容器嵌入到 Command 结构中,让 Command 在调用执行函数 RunE 时,能从参数中获取到服务容器,这样就能从服务容器中使用之前定义的 Make 系列方法获取出具体的服务实例了。

那服务容器嵌到哪里合适呢?因为刚才说,在 cobra 中 Command 结构是一个树形结构,所有的命令都是由一个根 Command 衍生来的。所以我们可以在根 Command 中设置服务容器,让所有的子 Command 都可以根据 Root 方法来找到树的根 Command,最终找到服务容器。

不要忘记了,最终目的是完善 Web 框架,所以之前存放在 main 函数中的启动 Web 服务的一些方法我们也要做修改,让它们能通过一个命令启动。main 函数不再是启动一个 Web 服务了,而是启动一个 cobra 的命令。

也就是说,我们将Web服务的启动逻辑封装为一个Command命令,将这个Command挂载到根Command中,然后根据参数获取到这个 Command 节点,执行这个节点中的 RunE 方法,就能启动Web服务了。

但是在调用Web服务所在节点的RunE方法的时候,存在一个Engine结构的传递问题

在main函数中,我们使用gin.New创建了一个Engine结构,在业务中对这个Engine结构进行路由设置,这些都应该在业务代码中。而后,我们就进入了框架代码中,调用Web服务所在Command节点的RunE方法,在这个方法里进行初始化http.Server,并且启动Goroutine进行监听:

func main() {
   // 创建engine结构
   core := gin.New()
   ...

   hadeHttp.Routes(core)

   server := &http.Server{
      Handler: core,
      Addr:    ":8888",
   }

   // 这个goroutine是启动服务的goroutine
   go func() {
      server.ListenAndServe()
   }()
   ...
}

也就是说,我们只能根据 Command 拿到服务容器,那怎么拿到 Gin 函数创建的 Engine 结构呢?这个问题我提供一个解决思路,是否可以将“提供服务引擎”作为一个接口,通过服务提供者注入进服务容器?这样就能在命令行中就能获取服务容器了。

使用 cobra 增加框架的交互性

现在思路有了,可能发生的问题也想到了,下面进入实操。

首先是源码引入 cobra 库。引入的方式基本上和 Gin 框架引入的方式一样,先看下 cobra 源码的许可证,是 Apache License。这种许可证允许修改、商用、私有化等,只要求保留著作声明。所以我们直接拷贝最新的 cobra 源码,用 cobra v1.2.1 版本,将它放在 framework/cobra 目录下。

然后,对 Command 结构进行修改。要在 Command 结构中加入服务容器,由于刚才是源码引入的,很容易为 Command 增加一个container字段,在framework/cobra/command.go中修改Command结构:

type Command struct {
   // 服务容器
   container framework.Container
   ...
}

再为 Command 提供两个方法:设置服务容器、获取服务容器。设置服务容器的方法是为了在创建根 Command 之后,能将服务容器设置到里面去;而获取服务容器的方法,是为了在执行命令的 RunE 函数的时候,能从参数 Command 中获取到服务容器。

将定义的方法放在单独的一个文件framework/cobra/hade_command.go中。

// SetContainer 设置服务容器
func (c *Command) SetContainer(container framework.Container) {
   c.container = container
}
// GetContainer 获取容器
func (c *Command) GetContainer() framework.Container {
   return c.Root().container
}

做到这里,前面两步cobra的引入和Command结构的修改就都完成了。

将 Web 启动改成一个命令

第三步,如何改造 Web 启动服务是最繁琐的,先简单梳理一下。

我们先要将创建 Web 服务引擎的方法作为一个服务封装在服务容器中,按照第十节课封装服务的三个步骤:封装接口协议、定义一个服务提供者、初始化服务实例。

在framework/contract/kernel.go中,把创建 Engine 的过程封装为一个服务接口协议:

// KernelKey 提供 kenel 服务凭证
const KernelKey = "hade:kernel"

// Kernel 接口提供框架最核心的结构
type Kernel interface {
   // HttpEngine http.Handler结构,作为net/http框架使用, 实际上是gin.Engine
   HttpEngine() http.Handler
}

在定义的 Kernel 接口,提供了 HttpEngine 的方法,返回了net/http 启动的时候需要的 http.Handler接口,并且设置它在服务容器中的字符串凭证为"hade:kernel"。
然后为这个服务定义一个服务提供者。这个服务提供者可以在初始化服务的时候传递 Web 引擎,如果初始化的时候没有传递,则需要在启动的时候默认初始化。

在对应的Kernel的服务提供者代码framework/provider/kernel/provider.go中,我们实现了服务提供者需要实现的五个函数Register、Boot、isDefer、Params、Name。

package kernel
import (
   "github.com/gohade/hade/framework"
   "github.com/gohade/hade/framework/contract"
   "github.com/gohade/hade/framework/gin"
)

// HadeKernelProvider 提供web引擎
type HadeKernelProvider struct {
   HttpEngine *gin.Engine
}

// Register 注册服务提供者
func (provider *HadeKernelProvider) Register(c framework.Container) framework.NewInstance {
   return NewHadeKernelService
}

// Boot 启动的时候判断是否由外界注入了Engine,如果注入的化,用注入的,如果没有,重新实例化
func (provider *HadeKernelProvider) Boot(c framework.Container) error {
   if provider.HttpEngine == nil {
      provider.HttpEngine = gin.Default()
   }
   provider.HttpEngine.SetContainer(c)
   return nil
}

// IsDefer 引擎的初始化我们希望开始就进行初始化
func (provider *HadeKernelProvider) IsDefer() bool {
   return false
}

// Params 参数就是一个HttpEngine
func (provider *HadeKernelProvider) Params(c framework.Container) []interface{} {
   return []interface{}{provider.HttpEngine}
}

// Name 提供凭证
func (provider *HadeKernelProvider) Name() string {
   return contract.KernelKey
}

创建服务的第三步就是初始化实例了。这个服务实例比较简单,就是一个包含着 Web 引擎的服务结构。在刚才实现的 HttpEngine()接口中,把服务结构中包含的 Web 引擎返回即可。

// 引擎服务
type HadeKernelService struct {
   engine *gin.Engine
}

// 初始化 web 引擎服务实例
func NewHadeKernelService(params ...interface{}) (interface{}, error) {
   httpEngine := params[0].(*gin.Engine)
   return &HadeKernelService{engine: httpEngine}, nil
}

// 返回 web 引擎
func (s *HadeKernelService) HttpEngine() http.Handler {
   return s.engine
}

现在我们完成了Web服务Kernel的设计,转而我们改造一下入口函数。 main 函数是我们的入口,但是现在,入口函数就不再是启动一个 HTTP 服务了,而是执行一个命令。那么这个 main 函数要做些什么呢?

整个框架目前都是围绕服务容器进行设计的了。所以在业务目录的main.go的 main 函数中,我们第一步要做的,必然是初始化一个服务容器。

// 初始化服务容器
container := framework.NewHadeContainer()

接着,要将各个服务绑定到这个服务容器中。目前要绑定的服务容器有两个,一个是上一节课我们定义的目录结构服务HadeAppProvider,第二个是这节课定义的提供 Web 引擎的服务。

// 绑定 App 服务提供者
container.Bind(&app.HadeAppProvider{})

// 后续初始化需要绑定的服务提供者...
// 将 HTTP 引擎初始化,并且作为服务提供者绑定到服务容器中
if engine, err := http.NewHttpEngine(); err == nil {
   container.Bind(&kernel.HadeKernelProvider{HttpEngine: engine})
}

http.NewHttpEngine 这个创建 Web 引擎的方法必须放在业务层,因为这个 Web 引擎不仅仅是调用了 Gin 创建 Web 引擎的方法,更重要的是调用了业务需要的绑定路由的功能。

将业务需要的路由绑定到 Web 引擎中去。因为这个是业务逻辑,我们放在业务目录的app/kernel.go 文件中:

// NewHttpEngine 创建了一个绑定了路由的 Web 引擎
func NewHttpEngine() (*gin.Engine, error) {
   // 设置为 Release,为的是默认在启动中不输出调试信息
   gin.SetMode(gin.ReleaseMode)
   // 默认启动一个 Web 引擎
   r := gin.Default()
   // 业务绑定路由操作
   Routes(r)
   // 返回绑定路由后的 Web 引擎
   return r, nil
}

而对应的业务绑定路由操作,还是放在业务代码的app/http/route.go中:


// Routes 绑定业务层路由
func Routes(r *gin.Engine) {

   r.Static("/dist/", "./dist/")

   demo.Register(r)
}

完成服务提供者的绑定和路由设置之后,最后要创建一个根 Command,并且将业务的 Command 和框架定义的 Command 都加载到根 Command 中,形成一个树形结构

在 main 中,我们用 console.RunCommand 来创建和运行根 Command。

// 运行 root 命令
console.RunCommand(container)

而这里RunCommand 的方法简要来说做了三个事情:

  1. 创建根 Command,并且将容器设置进根 Command 中。
  2. 绑定框架和业务的 Command 命令。
  3. 调用 Execute 启动命令结构。

具体的代码实现放在业务目录的app/console/kernel.go文件中,如下:

// RunCommand  初始化根 Command 并运行
func RunCommand(container framework.Container) error {
   // 根 Command
   var rootCmd = &cobra.Command{
      // 定义根命令的关键字
      Use: "hade",
      // 简短介绍
      Short: "hade 命令",
      // 根命令的详细介绍
      Long: "hade 框架提供的命令行工具,使用这个命令行工具能很方便执行框架自带命令,也能很方便编写业务命令",
      // 根命令的执行函数
      RunE: func(cmd *cobra.Command, args []string) error {
         cmd.InitDefaultHelpFlag()
         return cmd.Help()
      },
      // 不需要出现 cobra 默认的 completion 子命令
      CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
   }
   // 为根 Command 设置服务容器
   rootCmd.SetContainer(container)
   // 绑定框架的命令
   command.AddKernelCommands(rootCmd)
   // 绑定业务的命令
   AddAppCommand(rootCmd)
   // 执行 RootCommand
   return rootCmd.Execute()

仔细看这段代码,我们这一节课前面说的内容都在这里得到了体现。

首先,根 Command 的各个属性设置是基于我们对 cobra 的 Command 结构比较熟悉才能进行的;而为根 Command 设置服务容器,我们用之前为服务容器扩展的 SetContainer 方法设置的;最后运行 cobra 的命令是调用 Execute 方法来实现的。

这里额外注意下, 这里有两个函数 AddKernelCommands 和 AddAppCommand,分别是将框架定义的命令和业务定义的命令挂载到根Command下

框架定义的命令我们使用framework/command/kernel.go 中的 AddKernelCommands 进行挂载。而业务定义的命令我们使用 app/console/kernel.go 中的 AddAppCommand进行挂载。比如下面要定义的启动服务的命令 appCommand 是所有业务通用的一个框架命令,最终会在 framework/command/kernel.go 的 AddKernelCommands 中进行挂载。

启动服务

现在已经将 main 函数改造成根据命令行参数定位 Command 树并执行,且在执行函数的参数 Command 中已经放入了服务容器,在服务容器中我们也已经注入了 Web 引擎。那么下面就来创建一个命令 ./hade app start 启动 Web 服务。

这个命令和业务无关,是框架自带的,所以它的实现应该放在 frame/command 下,而启动 Web 服务的命令是一个二级命令,其一级命令关键字为 app,二级命令关键字为 start。

那么我们先创建一级命令,这个一级命令 app 没有具体的功能,只是打印帮助信息。在framework/command/app.go中定义appCommand:

// AppCommand 是命令行参数第一级为 app 的命令,它没有实际功能,只是打印帮助文档
var appCommand = &cobra.Command{
   Use:   "app",
   Short: "业务应用控制命令",
   RunE: func(c *cobra.Command, args []string) error {
      // 打印帮助文档
      c.Help()
      return nil
   },
}

而二级命令关键字为 start,它是真正启动 Web 服务的命令。这个命令的启动执行函数有哪些逻辑呢?

首先,它需要获取 Web 引擎。具体方法根据前面讲的,要从参数 Command 中获取服务容器,从服务容器中获取引擎服务实例,从引擎服务实例中获取 Web 引擎:

// 从 Command 中获取服务容器
container := c.GetContainer()
// 从服务容器中获取 kernel 的服务实例
kernelService := container.MustMake(contract.KernelKey).(contract.Kernel)
// 从 kernel 服务实例中获取引擎
core := kernelService.HttpEngine()

获取到了 Web 引擎之后如何启动 Web 服务,就和第一节课描述的一样,通过创建 http.Server,并且调用其 ListenAndServe 方法。这里贴一下具体的appStartCommand命令的实现,供你参考思路,在framework/command/app.go中:

// appStartCommand 启动一个Web服务
var appStartCommand = &cobra.Command{
   Use:   "start",
   Short: "启动一个Web服务",
   RunE: func(c *cobra.Command, args []string) error {
      // 从Command中获取服务容器
      container := c.GetContainer()
      // 从服务容器中获取kernel的服务实例
      kernelService := container.MustMake(contract.KernelKey).(contract.Kernel)
      // 从kernel服务实例中获取引擎
      core := kernelService.HttpEngine()

      // 创建一个Server服务
      server := &http.Server{
         Handler: core,
         Addr:    ":8888",
      }

      // 这个goroutine是启动服务的goroutine
      go func() {
         server.ListenAndServe()
      }()

      // 当前的goroutine等待信号量
      quit := make(chan os.Signal)
      // 监控信号:SIGINT, SIGTERM, SIGQUIT
      signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
      // 这里会阻塞当前goroutine等待信号
      <-quit

      // 调用Server.Shutdown graceful结束
      timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
      defer cancel()

      if err := server.Shutdown(timeoutCtx); err != nil {
         log.Fatal("Server Shutdown:", err)
      }

      return nil
   },
}

最后将RootCommand和AppCommand进行关联。在framework/command/app.go中定义initAppCommand()方法,将appStartCommand作为appCommand的子命令:

// initAppCommand 初始化app命令和其子命令
func initAppCommand() *cobra.Command {
   appCommand.AddCommand(appStartCommand)
   return appCommand
}

在framework/command/kernel.go中,挂载对应的appCommand的命令:

// AddKernelCommands will add all command/* to root command
func AddKernelCommands(root *cobra.Command) {
   // 挂载AppCommand命令
   root.AddCommand(initAppCommand())
}

我们就完成了Web启动的改造工作了。

验证

好了到这里,整个命令行工具就引入成功,并且Web 框架也改造完成了。下面做一下验证。编译后调用./hade ,我们获取到根 Command 命令行工具的帮助信息:

提示可以通过一级关键字 app 获取下一级命令:

而./hade app 提醒我们可以通过二级关键字 start 来启动一个 Web 服务,调用 ./hade app start

Web 服务启动成功,通过浏览器可以访问到业务定义的/demo/demo 路径。

今天所有代码都存放在GitHub 的 geekbang/13 分支了,文中未展示的代码直接参考这个分支。本节课结束对应的目录结构如下:

总结

今天我们把之前的 Web 框架改造成了一个命令行工具,引入了 cobra 库,并且将原本的进程启动,也就是启动 Web 服务的方式,改成了调用一个命令来启动 Web 服务。

不知道你有没有感觉,将框架的入口改造成命令行,这个设计不仅仅是简单换了一种 Web 服务的启动方式,而且是扩展了框架的另外一种可能性——设计命令行工具。改造后,这个框架可以用来开发业务需要的各种命令行工具,同时也允许我们后续为框架增加多种多样易用性高的工具。

思考题

其实在之前的版本,我在framework/contract/kernel.go是这么设计kernel服务接口的:

package contract

const KernelKey = "hade:kernel"

// Kernel 接口提供框架最核心的结构
type Kernel interface {
   // HttpEngine 提供gin的Engine结构
   HttpEngine() *gin.Engine
}

在provider/kernel/service.go中是这么实现接口的:

// 返回web引擎
func (s *HadeKernelService) HttpEngine() *gin.Engine {
   return s.engine
}

和现在实现最大的不同是返回值。之前的返回值是返回了 *gin.Engine。而现在的返回值是返回了http.Handler,其他的实现没有任何变化。你能看出这样的改动相较之前有什么好处么?为什么这么改?

欢迎在留言区分享你的思考。感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见。