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.

28 KiB

21自动化DRY如何自动化一切重复性劳动

你好,我是轩脉刃。

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

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

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

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

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

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

自动化创建服务工具

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

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

命令创建

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

  • ./hade provider 一级命令provider打印帮助信息
  • ./hade provider new 二级命令,创建一个服务;
  • ./hade provider list 二级命令,列出容器内的所有服务,列出它们的字符串凭证。

首先将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三个文件

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

自动化创建命令行工具

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

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

  • ./hade command 一级命令,显示帮助信息
  • ./hade command list 二级命令,列出所有控制台命令
  • ./hade command new 二级命令,创建一个控制台命令

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将模版写入文件中。

自动化中间件迁移工具

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

  • ./hade middleware 一级命令,显示帮助信息
  • ./hade middleware list 二级命令,列出所有的业务中间件
  • ./hade middleware new 二级命令,创建一个新的业务中间件
  • ./hade middleware migrate 二级命令迁移Gin已有的中间件

其中的前面三个命令基本上和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 来补充这个功能吧!

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