思考并回答以下问题:
框架开发到这里,我们也需要思考,有哪些重复性劳动可以自动化么?
从第十章到现在我们一直在说,框架核心是服务提供者,在开发具体应用时,一定会有很多需求要创建各种各样的服务,毕竟“一切皆服务”;而每次创建服务的时候,我们都需要至少编写三个文件,服务接口、服务提供者、服务实例。如果能自动生成三个文件,提供一个“自动化创建服务的工具”,应该能节省不少的操作。
说到创建工具,我们经常需要为了一个事情而创建一个命令行工具,而每次创建命令行工具,也都需要创建固定的Command.go文件,其中有固定的Command结构,这些代码我们能不能偷个懒,“自动化创建命令行工具”呢?
另外之前我们做过几次中间件的迁移,先将源码拷贝复制,再修改对应的Gin路径,这个操作也是颇为繁琐的。那么,我们是否可以写一个“自动化中间件迁移工具”,一个命令自动复制和替换呢?
这些命令都是可以实现的,这节课我们就来尝试完成这三项自动化,“自动化创建服务工具”,“自动化创建命令行工具”,以及“自动化中间件迁移工具”。
自动化创建服务工具
在创建各种各样的服务时,“自动化创建服务工具”能帮我们节省不少开发时间。我们先思考下这个工具应该如何实现。
既然之前已经引入cobra,将框架修改为可以支持命令行工具,创建命令并不是一个难事,我们来定义一套创建服务的provider命令即可。照旧先设计好要创建的命令,再一一实现。
命令创建
“自动化创建服务工具”如何设计命令层级呢?我们设计一个一级命令和两个二级命令:
./hade provider
一级命令,provider,打印帮助信息;./hade provider new
二级命令,创建一个服务;./hade provider list
二级命令,列出容器内的所有服务,列出它们的字符串凭证。
首先将provider的这两个二级命令,都存放在command/provider.go中。而对应的一级命令providerCommand是一个打印帮助信息的空实现。1
2
3
4
5
6
7
8
9
10
11// 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:1
2
3
4
5
6// 初始化provider相关服务
func initProviderCommand() *cobra.Command {
providerCommand.AddCommand(providerCreateCommand)
providerCommand.AddCommand(providerListCommand)
return providerCommand
}
并且在framework/command/kernel.go,将这个一级命令挂载到一级命令rootCommand中:1
2
3
4func AddKernelCommands(root *cobra.Command) {
// provider一级命令
root.AddCommand(initProviderCommand())
}
下面来实现这两个二级命令new和list。
list命令的实现
先说./hade provider list
这个命令,因为列出容器内的所有服务是比较简单的。还记得吗,在十一章实现服务容器的时候,其中有一个providers,它存储所有的服务容器提供者,放在文件framework/container.go中:1
2
3
4
5
6
7// HadeContainer 是服务容器的具体实现
type HadeContainer struct {
...
// providers 存储注册的服务提供者,key 为字符串凭证
providers map[string]ServiceProvider
...
}
我们只需要将这个providers进行遍历,根据其中每个ServiceProvider的Name()方法,获取字符串凭证列表即可。
所以,在framework/container.go的HadeContainer中,增加一个NameList方法,返回所有提供服务者的字符串凭证,方法也很简单,直接遍历这个providers字段。1
2
3
4
5
6
7
8
9// 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命令中,我们调用这个命令并且打印出来。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 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参数的方式来表示。但是其实还有一种更便捷的方式:交互。
交互的表现形式如:1
2
3
4
5
6
7输入:./hade provider new (我想创建一个服务)
输出:请输入服务名称(服务凭证):
输入:demo
输出:请求输入服务目录名称(默认和服务名称相同):
输入:demo
输出:创建服务成功, 文件夹地址:xxxxx
输出:请不要忘记挂载新创建的服务
这种命令行交互的方式是不是更智能化?但是如何实现呢?
这里我们借助一个第三方库survey。这个survey库支持多种交互模式:单行输入、多行输入、单选、多选、y/n确认选择,在项目GitHub首页上就能很清晰看到这个库的使用方式。1
2
3
4
5
6
7name := 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文件的生成过程,就可以了解其使用方法了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 创建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:1
2
3
4
5
6
7
8
9var contractTmp string = `package {{.}}
const {{.|title}}Key = "{{.}}"
type Service interface {
// 请在这里定义你的方法
Foo() string
}
`
注意到了么,其中的{{.|title}}实际上是相当于调用了strings.Title(name)
的方法填充,能将字符串name替换为字符串Name。
而定义好了FuncMap之后,我们随后使用了os.Create
创建contract.go文件,然后初始化template:1
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之后,使用代码:1
t.Execute(f, name)
来将变量name注册进入模版t,并且输出到f。这里的f,是我们之前创建的contract.go文件。也就是使用变量name解析模版t,输出到contract.go文件中。
这里的变量可以是一个struct结构,也可以是基础变量,比如我们这里定义的字符串。在模版{{.}}中就代表这个结构。所以再回顾前面定义的contractTmp模版,你会看出其中变量name为字符串user的时候,最终的显示是什么吗?
好,创建服务命令的所有思路我们就梳理清楚了,最后也贴出完整的代码供你参考,关键步骤都在注释中详细说明了,实现并不难:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142// 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()
方法遍历一级跟命令的所有一级命令。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 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文件中输入模版:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 命令行工具模版
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 middlewarem igrate
二级命令,迁移Gin已有的中间件
其中的前面三个命令基本上和provider、command命令如出一辙,我们就不赘述了,同样你可以通过GitHub上的framework/command/middleware.go文件参考其具体实现,相信你可以顺利写出来。
这里重点说一下./hade middleware migrate
命令。
不知道你有没有好奇,为什么迁移也要写一个命令?当时在将Gin迁移进入hade框架的时候我们说,Gin作为一个成熟的开源作品,有丰富的中间件库,存放GitHub的一个项目gin-contrib中。那么在开发过程中,我们一定会经常需要使用到这些中间件。
但是由于这些中间件使用到的Gin框架的地址为:1
github.com/gin-gonic/gin
而我们的Gin框架地址为:1
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,这个第三方库是可以直接import使用的。目前这个库最新的版本为v5。
它的使用方式如下:1
2
3
4_, err := git.PlainClone("/tmp/foo", false, &git.CloneOptions{
URL: "https://github.com/go-git/go-git",
Progress: os.Stdout,
})
将某个Git的URL地址使用git clone
,下载到/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中,对应的代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71// 从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中间件:1
2
3
4
5
6
7
8
9...
// 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来补充这个功能吧!