13|交互:可以执行命令行的框架才是好框架

思考并回答以下问题:

使用cobra增加框架的交互性

首先是源码引入cobra库。直接拷贝最新的cobra源码,用cobra v1.2.1版本,将它放在framework/cobra目录下。

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

1
2
3
4
5
type Command struct {
// 服务容器
container framework.Container
...
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package cobra

import "github.com/gohade/hade/framework"

// 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服务引擎的方法作为一个服务封装在服务容器中,完成准备工作。
  • 开始main函数的改造。首先要做的必然是初始化一个服务容器,然后将各个服务绑定到这个服务容器中,有一个就是刚才定义的提供Web引擎的服务。
  • 在业务代码中将业务需要的路由绑定到Web引擎中去。
  • 完成服务的绑定之后,最后要创建一个根Command,并且创建一个Web启动的Command,这两个Command会形成一个树形结构。

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

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

1
2
3
4
5
6
7
8
// 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。

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
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引擎返回即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 引擎服务
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函数中,我们第一步要做的,必然是初始化一个服务容器。

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

接着,要将各个服务绑定到这个服务容器中。目前要绑定的服务容器有两个,一个是上一节课我们定义的目录结构服务HadeAppProvider,第二个是这节课定义的提供Web引擎的服务。
1
2
3
4
5
6
7
8
// 绑定 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文件中:

1
2
3
4
5
6
7
8
9
10
11
// 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中:
1
2
3
4
5
6
7
// Routes 绑定业务层路由
func Routes(r *gin.Engine) {

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

demo.Register(r)
}

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

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

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

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

1,创建根Command,并且将容器设置进根Command中。

2,绑定框架和业务的Command命令。

3,调用Execute启动命令结构。

具体的代码实现放在业务目录的app/console/kernel.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
// 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:

1
2
3
4
5
6
7
8
9
10
// 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引擎:

1
2
3
4
5
6
// 从 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中:
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
// 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的子命令:
1
2
3
4
5
// initAppCommand 初始化app命令和其子命令
func initAppCommand() *cobra.Command {
appCommand.AddCommand(appStartCommand)
return appCommand
}

在framework/command/kernel.go中,挂载对应的appCommand的命令:
1
2
3
4
5
// 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分支了,文中未展示的代码直接参考这个分支。本节课结束对应的目录结构如下:

如何使用命令行cobra

首先,要把cobra库引入到框架中

引入后要对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进行监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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结构呢?这个问题我提供一个解决思路,是否可以将“提供服务引擎”作为一个接口,通过服务提供者注入进服务容器?这样就能在命令行中就能获取服务容器了。

思考题

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

1
2
3
4
5
6
7
8
9
package contract

const KernelKey = "hade:kernel"

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

在provider/kernel/service.go中是这么实现接口的:
1
2
3
4
// 返回web引擎
func (s *HadeKernelService) HttpEngine() *gin.Engine {
return s.engine
}

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

0%