你好,我是轩脉刃。

不知道你有没有听过这种说法,优秀程序员应该有三大美德:懒惰、急躁和傲慢,这句话是Perl语言的发明者Larry Wall说的。其中懒惰这一点指的就是,程序员为了懒惰,不重复做同样的事情,会思考是否能把一切重复性的劳动自动化(don’t repeat yourself)。

而框架开发到这里,我们也需要思考,有哪些重复性劳动可以自动化么?

从第十章到现在我们一直在说,框架核心是服务提供者,在开发具体应用时,一定会有很多需求要创建各种各样的服务,毕竟“一切皆服务”;而每次创建服务的时候,我们都需要至少编写三个文件,服务接口、服务提供者、服务实例。如果能自动生成三个文件,提供一个“自动化创建服务的工具”,应该能节省不少的操作

说到创建工具,我们经常需要为了一个事情而创建一个命令行工具,而每次创建命令行工具,也都需要创建固定的Command.go文件,其中有固定的Command结构,这些代码我们能不能偷个懒,“自动化创建命令行工具”呢?

另外之前我们做过几次中间件的迁移,先将源码拷贝复制,再修改对应的Gin路径,这个操作也是颇为繁琐的。那么,我们是否可以写一个“自动化中间件迁移工具”,一个命令自动复制和替换呢?

这些命令都是可以实现的,这节课我们就来尝试完成这三项自动化,“自动化创建服务工具”, “自动化创建命令行工具”,以及“自动化中间件迁移工具”。

自动化创建服务工具

在创建各种各样的服务时,“自动化创建服务工具”能帮我们节省不少开发时间。我们先思考下这个工具应该如何实现。

既然之前已经引入cobra,将框架修改为可以支持命令行工具,创建命令并不是一个难事,我们来定义一套创建服务的provider 命令即可。照旧先设计好要创建的命令,再一一实现。

命令创建

“自动化创建服务工具”如何设计命令层级呢?我们设计一个一级命令和两个二级命令:

首先将provider的这两个二级命令,都存放在command/provider.go中。而对应的一级命令 providerCommand 是一个打印帮助信息的空实现。

// providerCommand 一级命令
var providerCommand = &cobra.Command{
   Use:   "provider",
   Short: "服务提供相关命令",
   RunE: func(c *cobra.Command, args []string) error {
      if len(args) == 0 {
         c.Help()
      }
      return nil
   },
}

预先将两个二级命令挂载到这个一级命令中,在 framework/command/provider.go:

// 初始化provider相关服务
func initProviderCommand() *cobra.Command {
   providerCommand.AddCommand(providerCreateCommand)
   providerCommand.AddCommand(providerListCommand)
   return providerCommand
}

并且在 framework/command/kernel.go,将这个一级命令挂载到一级命令rootCommand中:

func AddKernelCommands(root *cobra.Command) {
   // provider一级命令
   root.AddCommand(initProviderCommand()
}

下面来实现这两个二级命令new和list。

List命令的实现

先说 ./hade provider list 这个命令,因为列出容器内的所有服务是比较简单的。还记得吗,在十一章实现服务容器的时候,其中有一个providers,它存储所有的服务容器提供者,放在文件 framework/container.go 中:

// HadeContainer 是服务容器的具体实现
type HadeContainer struct {
	...
	// providers 存储注册的服务提供者,key 为字符串凭证
	providers map[string]ServiceProvider
    ...
}

我们只需要将这个providers进行遍历,根据其中每个ServiceProvider的Name() 方法,获取字符串凭证列表即可。

所以,在framework/container.go 的HadeContainer中,增加一个NameList方法,返回所有提供服务者的字符串凭证,方法也很简单,直接遍历这个providers 字段。

// NameList 列出容器中所有服务提供者的字符串凭证
func (hade *HadeContainer) NameList() []string {
   ret := []string{}
   for _, provider := range hade.providers {
      name := provider.Name()
      ret = append(ret, name)
   }
   return ret
}

而在 framework/command/provider.go 中的providerListCommand 命令中,我们调用这个命令并且打印出来。

// providerListCommand 列出容器内的所有服务
var providerListCommand = &cobra.Command{
   Use:   "list",
   Short: "列出容器内的所有服务",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      hadeContainer := container.(*framework.HadeContainer)
      // 获取字符串凭证
      list := hadeContainer.NameList()
      // 打印
      for _, line := range list {
         println(line)
      }
      return nil
   },
}

可以验证一下。编译 ./hade build self 并且执行 ./hade provider list ,可以看到如下信息:

你可以很清晰看到容器中绑定了哪些服务提供者,它们的字符串凭证是什么。这样我们在定义一个新的服务的时候,可以很方便看到哪些服务提供者的关键字已经被使用了,避免使用已有的服务关键字。

下面我们来说稍微复杂一点的创建服务的命令 ./hade provider new

new命令的实现

在实际业务开发过程中,我们一想到一个服务,比如去某个用户系统获取信息,一定会想到创建服务的三步骤:创建一个用户系统的交互协议contract.go、再创建一个提供协议的用户服务提供者 provider.go、最后才实现具体的用户服务实例 service.go。

每次都需要创建这三个文件,且这三个文件的文件大框架都有套路可言。那我们如何将这些重复的套路性的代码自动化生成呢?

首先这里有一个增加参数的过程,我们需要知道要创建服务的服务名是什么?创建这个服务的文件夹名字是什么?当然了,这些参数也可以使用在命令后面增加flag参数的方式来表示。但是其实还有一种更便捷的方式:交互。

交互的表现形式如:

输入:./hade provider new (我想创建一个服务)
输出:请输入服务名称(服务凭证):
输入:demo
输出:请求输入服务目录名称(默认和服务名称相同):
输入:demo
输出:创建服务成功, 文件夹地址:xxxxx
输出:请不要忘记挂载新创建的服务

这种命令行交互的方式是不是更智能化?但是如何实现呢?

这里我们借助一个第三方库 survey。这个库目前在GitHub上已经有2.7k个star,最新版本是v2版本,使用的是MIT License协议,可以放心使用。这个survey库支持多种交互模式:单行输入、多行输入、单选、多选、y/n 确认选择,在项目GitHub首页上就能很清晰看到这个库的使用方式。

name := false
// 使用survey.XXX 的方式来选择交互形式
prompt := &survey.Confirm{
    Message: "Do you like pie?",
}
// 使用&将最终的选择存储进入变量
survey.AskOne(prompt, &name)

在provider new命令中,我们也可以用survey 来增加交互性。通过交互,我们可以确认用户想创建的服务凭证,以及想把这个服务创建在 app/provider/ 下的哪个目录中。

当然,在用户通过交互输入了服务凭证和服务目录之后,是需要进行参数判断的。服务凭证需要和容器中已注册服务的字符串凭证进行比较,如果已经存在了,应该报错;而服务目录如果已经存在,也应该直接报错。

如果都验证ok了,最后一步就是在 app/provider/ 下创建对应的服务目录,在目录下创建contract.go、provider.go、service.go 三个文件,并且在三个文件中根据预先定义好的模版填充内容。这里我们如何实现呢?使用模版、变更模版中的某些字段、形成新的文本,这个你应该能联想到 Golang 标准库中的 text/template 库。

这个库的使用方法比较多,我这里把我们用得到的方法解说一下,解析contract.go文件的生成过程,就可以了解其使用方法了。

// 创建title这个模版方法
funcs := template.FuncMap{"title": strings.Title}
{
   //  创建contract.go
   file := filepath.Join(pFolder, folder, "contract.go")
   f, err := os.Create(file)
   if err != nil {
      return errors.Cause(err)
   }
   // 使用contractTmp模版来初始化template,并且让这个模版支持title方法,即支持{{.|title}}
   t := template.Must(template.New("contract").Funcs(funcs).Parse(contractTmp))
   // 将name传递进入到template中渲染,并且输出到contract.go 中
   if err := t.Execute(f, name); err != nil {
      return errors.Cause(err)
   }
}

上面代码的逻辑最核心的就是创建模版的template.Must 和渲染模版的t.Execute方法。

但是在创建模版之前,我们使用了一个template.FuncMap方法,它比较不好理解,主要作用就是在模版中,让我们可以使用定义的模版方法来控制渲染效果。这个FuncMap结构定义了模版中支持的模版方法,比如我支持title这个方法,这个方法实际调用的是string.Title 函数,把字符串首字母大写。

在刚才的代码中,我们使用contractTmp来创建模版,在渲染contractTmp的时候,传递了一个name变量。假设这个name变量代表的是字符串user,而我希望创建一个字符串“NameKey”的变量,可以这么定义contractTmp:

var contractTmp string = `package {{.}}

const {{.|title}}Key = "{{.}}"

type Service interface {
   // 请在这里定义你的方法
    Foo() string
}
`

注意到了么,其中的{{.|title}} 实际上是相当于调用了strings.Title(name) 的方法填充,能将字符串name替换为字符串Name。

而定义好了FuncMap之后,我们随后使用了os.Create创建contract.go文件,然后初始化template:

t := template.Must(template.New("contract").Funcs(funcs).Parse(contractTmp))

这行代码的几个函数我们来看看。

template.Must 表示后面的template创建必须成功,否则会panic。这种Must的方法来简化代码的error处理逻辑,在标准库中经常使用。我们的hade框架的MustMake也是同样的原理。

template.New() 方法,创建一个text/template 的 Template结构,其中的参数contract字符串是为这个Template结构命名的,后面的Funcs() 方法是将签名定义的模版函数注册到这个Template结构中,最后的Parse()是使用这个Template结构解析具体的模版文本。

定义好了模版t之后,使用代码:

t.Execute(f, name)

来将变量name 注册进入模版t,并且输出到f。这里的f,是我们之前创建的contract.go文件。也就是使用变量name解析模版t,输出到contract.go文件中。

这里的变量可以是一个struct结构,也可以是基础变量,比如我们这里定义的字符串。在模版中{{.}} 就代表这个结构。所以再回顾前面定义的contractTmp模版,你会看出其中变量name为字符串user的时候,最终的显示是什么吗?

好,创建服务命令的所有思路我们就梳理清楚了,最后也贴出完整的代码供你参考,关键步骤都在注释中详细说明了,实现并不难:

// providerCreateCommand 创建一个新的服务,包括服务提供者,服务接口协议,服务实例
var providerCreateCommand = &cobra.Command{
   Use:     "new",
   Aliases: []string{"create", "init"},
   Short:   "创建一个服务",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      fmt.Println("创建一个服务")
      var name string
      var folder string
      {
         prompt := &survey.Input{
            Message: "请输入服务名称(服务凭证):",
         }
         err := survey.AskOne(prompt, &name)
         if err != nil {
            return err
         }
      }
      {
         prompt := &survey.Input{
            Message: "请输入服务所在目录名称(默认: 同服务名称):",
         }
         err := survey.AskOne(prompt, &folder)
         if err != nil {
            return err
         }
      }
      // 检查服务是否存在
      providers := container.(*framework.HadeContainer).NameList()
      providerColl := collection.NewStrCollection(providers)
      if providerColl.Contains(name) {
         fmt.Println("服务名称已经存在")
         return nil
      }
      if folder == "" {
         folder = name
      }
      app := container.MustMake(contract.AppKey).(contract.App)
      pFolder := app.ProviderFolder()
      subFolders, err := util.SubDir(pFolder)
      if err != nil {
         return err
      }
      subColl := collection.NewStrCollection(subFolders)
      if subColl.Contains(folder) {
         fmt.Println("目录名称已经存在")
         return nil
      }
      // 开始创建文件
      if err := os.Mkdir(filepath.Join(pFolder, folder), 0700); err != nil {
         return err
      }
      // 创建title这个模版方法
      funcs := template.FuncMap{"title": strings.Title}
      {
         //  创建contract.go
         file := filepath.Join(pFolder, folder, "contract.go")
         f, err := os.Create(file)
         if err != nil {
            return errors.Cause(err)
         }
         // 使用contractTmp模版来初始化template,并且让这个模版支持title方法,即支持{{.|title}}
         t := template.Must(template.New("contract").Funcs(funcs).Parse(contractTmp))
         // 将name传递进入到template中渲染,并且输出到contract.go 中
         if err := t.Execute(f, name); err != nil {
            return errors.Cause(err)
         }
      }
      {
         // 创建provider.go
         file := filepath.Join(pFolder, folder, "provider.go")
         f, err := os.Create(file)
         if err != nil {
            return err
         }
         t := template.Must(template.New("provider").Funcs(funcs).Parse(providerTmp))
         if err := t.Execute(f, name); err != nil {
            return err
         }
      }
      {
         //  创建service.go
         file := filepath.Join(pFolder, folder, "service.go")
         f, err := os.Create(file)
         if err != nil {
            return err
         }
         t := template.Must(template.New("service").Funcs(funcs).Parse(serviceTmp))
         if err := t.Execute(f, name); err != nil {
            return err
         }
      }
      fmt.Println("创建服务成功, 文件夹地址:", filepath.Join(pFolder, folder))
      fmt.Println("请不要忘记挂载新创建的服务")
      return nil
   },
}
var contractTmp string = `package {{.}}
const {{.|title}}Key = "{{.}}"
type Service interface {
   // 请在这里定义你的方法
    Foo() string
}
`
var providerTmp string = `package {{.}}
import (
   "github.com/gohade/hade/framework"
)
type {{.|title}}Provider struct {
   framework.ServiceProvider
   c framework.Container
}
func (sp *{{.|title}}Provider) Name() string {
   return {{.|title}}Key
}
func (sp *{{.|title}}Provider) Register(c framework.Container) framework.NewInstance {
   return New{{.|title}}Service
}
func (sp *{{.|title}}Provider) IsDefer() bool {
   return false
}
func (sp *{{.|title}}Provider) Params(c framework.Container) []interface{} {
   return []interface{}{c}
}
func (sp *{{.|title}}Provider) Boot(c framework.Container) error {
   return nil
}
`
var serviceTmp string = `package {{.}}
import "github.com/gohade/hade/framework"
type {{.|title}}Service struct {
   container framework.Container
}
func New{{.|title}}Service(params ...interface{}) (interface{}, error) {
   container := params[0].(framework.Container)
   return &{{.|title}}Service{container: container}, nil
}
func (s *{{.|title}}Service) Foo() string {
    return ""
}
`

最后我们验证一下这个创建服务命令。同样编译./hade 命令之后,执行 ./hade provider new , 定义服务凭证为user,目录名称同样为user。

能看到 app/provider/ 目录下创建了user文件夹,其中有contract.go、provider.go、service.go三个文件:

其中每个文件的定义都完整,且可以直接再次编译通过,验证完成!

自动化创建命令行工具

到这里我们就完成了创建服务工具的自动化。开头提到具体运营一个应用的时候,我们也会经常需要创建一个自定义的命令行。比如运营一个网站,可能会创建一个命令来统计网站注册人数,也可能要创建一个命令来定期检查是否有违禁的文章需要封禁等。所以自动创建命令行工具在实际工作中是非常有必要的。

同服务命令一样,我们可以有一套创建命令行工具的命令。

command相关的命令和provider的命令的实现基本是一致的。这里我们简要解说下重点,具体对应的代码详情可以参考GitHub上的framework/command/cmd.go 文件。

一级命令./hade command 我们就不说了,是简单地显示帮助信息。

二级命令 ./hade command list。功能是列出所有的控制台命令。这个功能实际上和直接调用 ./hade 显示的帮助信息差不多,把一级根命令全部列了出来,只不过我们使用了一个更为语义化的 ./hade command list 来显示。

它的实现也并不复杂,具体就是使用Root().Commands() 方法遍历一级跟命令的所有一级命令。

// cmdListCommand 列出所有的控制台命令
var cmdListCommand = &cobra.Command{
   Use:   "list",
   Short: "列出所有控制台命令",
   RunE: func(c *cobra.Command, args []string) error {
      cmds := c.Root().Commands()
      ps := [][]string{}
      for _, cmd := range cmds {
         line := []string{cmd.Name(), cmd.Short}
         ps = append(ps, line)
      }
      util.PrettyPrint(ps)
      return nil
   },
}

二级命令 ./hade command new创建命令行工具,就是在app/console/command/ 文件夹下增加一个目录,然后在这个目录中存放命令的相关代码。

比如要创建一个foo命令,就是要在app/console/command/ 目录下创建一个foo目录,其中创建一个foo.go 文件名,这个文件名可以随意起,这里我们就和目录名保持一致。然后在 app/console/command/foo.go 文件中输入模版:

// 命令行工具模版
var cmdTmpl string = `package {{.}}

import (
   "fmt"

   "github.com/gohade/hade/framework/cobra"
)

var {{.|title}}Command = &cobra.Command{
   Use:   "{{.}}",
   Short: "{{.}}",
   RunE: func(c *cobra.Command, args []string) error {
        container := c.GetContainer()
      fmt.Println(container)
      return nil
   },
}

实现步骤也很简单:survery 交互先要求用户输入命令名称;然后要求用户输入文件夹名称,记得检查命令名称和文件夹名称是否合理;之后创建文件夹 app/console/command/xxx 和文件 app/console/command/xxx/xxx.go;最后使用template将模版写入文件中。

自动化中间件迁移工具

除了服务工具和命令行工具的创建,对于中间件,我们在开发过程中也是经常会使用创建的,同样的,可以为中间件定义一系列的命令来自动化。

其中的前面三个命令基本上和provider、command 命令如出一辙,我们就不赘述了,同样你可以通过GitHub 上的framework/command/middleware.go 文件参考其具体实现,相信你可以顺利写出来。

这里重点说一下 ./hade middleware migrate 命令。

不知道你有没有好奇,为什么迁移也要写一个命令?当时在将Gin迁移进入hade框架的时候我们说,Gin作为一个成熟的开源作品,有丰富的中间件库,存放GitHub的一个项目 gin-contrib 中。那么在开发过程中,我们一定会经常需要使用到这些中间件。

但是由于这些中间件使用到的Gin框架的地址为 :

github.com/gin-gonic/gin

而我们的Gin框架地址为:

github.com/gohade/hade/framework/gin

所以我们不能使用import直接使用这些中间件,那么有没有一个办法,能直接一键迁移gin-contrib下的某个中间件呢?比如 git@github.com:gin-contrib/cors.git ,直接拷贝并且自动修改好Gin框架引用地址,放到我们的 app/http/middleware/ 目录中。

于是就有了这个 ./hade middleware migragte 命令。下面就梳理一下这个命令的逻辑步骤。以下载cors中间件为例,我们的思路是从GitHub上将这个cors项目复制下来,并且删除这个项目的一些不必要的文件

什么是不必要的文件呢?.git目录、go.mod、go.sum,这些都是作为一个“项目”才会需要的,而我们要把项目中的这些删掉,让它成为一个文件,存放在我们的app/http/middleware/cors目录下。最后再遍历这个目录的所有文件,将所有出现“github.com/gin-gonic/gin” 的地方替换为“github.com/gohade/hade/framework/gin”就可以了。

从git上复制一个项目,在Golang中可以使用一个第三方库 go-git,这个第三方库已经有2.7k 个star,且基于Apache 的Licence,是可以直接import使用的。目前这个库最新的版本为v5。

它的使用方式如下:

_, err := git.PlainClone("/tmp/foo", false, &git.CloneOptions{
    URL:      "https://github.com/go-git/go-git",
    Progress: os.Stdout,
})

将某个Git的URL地址使用gitclone,下载到/tmp/foo目录,并且把输出也输出到控制台。

我们也可以使用这样的方式进行复制。具体的代码逻辑也不难,归纳一下,migrate的实现步骤如下:

  1. 参数中获取中间件名称;
  2. 使用go-git,将对应的gin-contrib的项目clone到目录/app/http/middleware;
  3. 删除不必要的文件go.mod、go.sum、.git;
  4. 替换关键字 “github.com/gin-gonic/gin”。

在framework/command/middleware.go中,对应的代码如下:

// 从gin-contrib中迁移中间件
var middlewareMigrateCommand = &cobra.Command{
   Use:   "migrate",
   Short: "迁移gin-contrib中间件, 迁移地址:https://github.com/gin-contrib/[middleware].git",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      fmt.Println("迁移一个Gin中间件")
      // step1: 获取参数
      var repo string
      {
         prompt := &survey.Input{
            Message: "请输入中间件名称:",
         }
         err := survey.AskOne(prompt, &repo)
         if err != nil {
            return err
         }
      }
      // step2 : 下载git到一个目录中
      appService := container.MustMake(contract.AppKey).(contract.App)

      middlewarePath := appService.MiddlewareFolder()
      url := "https://github.com/gin-contrib/" + repo + ".git"
      fmt.Println("下载中间件 gin-contrib:")
      fmt.Println(url)
      _, err := git.PlainClone(path.Join(middlewarePath, repo), false, &git.CloneOptions{
         URL:      url,
         Progress: os.Stdout,
      })
      if err != nil {
         return err
      }

      // step3:删除不必要的文件 go.mod, go.sum, .git
      repoFolder := path.Join(middlewarePath, repo)
      fmt.Println("remove " + path.Join(repoFolder, "go.mod"))
      os.Remove(path.Join(repoFolder, "go.mod"))
      fmt.Println("remove " + path.Join(repoFolder, "go.sum"))
      os.Remove(path.Join(repoFolder, "go.sum"))
      fmt.Println("remove " + path.Join(repoFolder, ".git"))
      os.RemoveAll(path.Join(repoFolder, ".git"))

      // step4 : 替换关键词
      filepath.Walk(repoFolder, func(path string, info os.FileInfo, err error) error {
         if info.IsDir() {
            return nil
         }

         if filepath.Ext(path) != ".go" {
            return nil
         }

         c, err := ioutil.ReadFile(path)
         if err != nil {
            return err
         }
         isContain := bytes.Contains(c, []byte("github.com/gin-gonic/gin"))
         if isContain {
            fmt.Println("更新文件:" + path)
            c = bytes.ReplaceAll(c, []byte("github.com/gin-gonic/gin"), []byte("github.com/gohade/hade/framework/gin"))
            err = ioutil.WriteFile(path, c, 0644)
            if err != nil {
               return err
            }
         }

         return nil
      })
      return nil
   },
}

我们可以下载cors项目做一下验证,运行 ./hade middleware migrate 命令,并且输入cors。你会在控制台看到这些信息:

并且在目录中看到cors中间件已经完整下载下来了。

然后,可以直接在app/http/route.go中直接使用这个cors中间件:

...

// Routes 绑定业务层路由
func Routes(r *gin.Engine) {
   ...
   // 使用cors中间件
   r.Use(cors.Default())
   ...
 }

验证完成!

今天所有代码都保存在GitHub上的geekbang/21分支了。附上目录结构供你对比查看,只修改了framework/command/目录下的cmd.go、provider.go、middleware.go文件。

小结

今天增加的命令不少,自动化创建服务工具、命令行工具,以及中间件迁移工具,这些命令都为我们后续开发应用提供了不少便利。

其实每个自动化命令行工具实现的思路都是差不多的,先思考清楚对于这个工具我们要自动化生成什么,然后使用代码和对应的模版生成对应的文件,并且替换其中特有的单词。原理不复杂,但是对于实际的工作,是非常有帮助的。

这一节课你应该可以感受到之前将cobra引入我们的框架是一个多么正确的决定,在cobra之上,我们才能实现这些方便的自动化工具。

思考题

我们实现的自动化服务./hade command list命令,目前只展示了一级命令,在写这篇文章的时候我反思了一下,其实可以扩展成为树形结构展示,同时展示一级/二级/三级/命令。你可以想想如何实现,如果可以的话,可以去github.com/gohade/hade 项目中提交一个merge request 来补充这个功能吧!

欢迎在留言区分享你的思考。我们下节课见。