# 13|交互:可以执行命令行的框架才是好框架 你好,我是轩脉刃。 上一节课,我们开始把框架向工业级迭代,重新规划了目录,这一节课将对框架做更大的改动,让框架支持命令行工具。 一个拥有命令行工具的框架会非常受欢迎。比如 Beego 框架提供了一个命令行工具 Bee、Vue 框架提供了 Vue-CLI,这些工具无疑给框架的使用者提供了不少便利。在使用框架过程中,命令行工具能将很多编码行为自动化。 而且退一步说,在实际工作中你会发现,即使用的框架没有提供任何命令行工具,在业务运营的过程中,我们也需要开发各种大大小小的命令行运营工具,作为业务运营的必要辅助。所以一个自带命令行工具,且很方便让用户自行开发命令行的框架,是非常有必要的。 这节课我们就研究一下如何将hade框架改造成为支持命令行交互的框架。 ## 第三方命令行工具库 cobra 要让一个程序支持命令行,那么它的核心功能就是要能解析参数,比如 `./hade app start --address=:8888` 其中的 ./hade 是我们要运行的程序,而后面的 app 和 start 两个字段以及–address=:8888 就是这个程序对应的参数了。 那么如何解析参数呢? Golang 标准库提供了 flag 包能对参数进行解析。但是 flag 包**只是一个命令行解析的类库,不支持组织,所以如果想基于 flag 包实现父子命令行工具,显然就不够了**。出于不重复造轮子,站在巨人肩膀上的想法,我们将视线移向开源社区一个最出名的命令行工具库 [cobra](https://github.com/spf13/cobra)。 cobra 是由大名鼎鼎的谷歌工程师 Steve Francia(spf13)开发并开源的一个项目。Steve Francia 是 Golang 开源界比较出名的一名程序员,是 Golang、Doctor、MongoDB 的开源贡献者,同时开源的 hugo、viper 等项目应用也非常广泛。而由他开发开源的 cobra 目前在 GitHub 上已经有了 23k 的 star。 cobra 不仅仅能让我们快速构建一个命令行,它更大的优势是能更快地组织起有许多命令行工具,因为从根命令行工具开始,cobra 把所有的命令按照树形结构组织起来了。 ![](https://static001.geekbang.org/resource/image/68/33/6812f5845567d0c771dea8190eb35e33.jpg?wh=1920x1080) 要使用 cobra 就要从源码上了解这个库。按照第一节课说的,按照 **库函数 > 结构定义 > 结构函数**的顺序读,你会发现,cobra 这个库最核心的内容是一个数据结构 [Command](https://github.com/spf13/cobra/blob/master/command.go) 。 一个 Command 代表一个执行命令。这个 Command 包含很多可设置的字段,如何使用这个 Command,就取决于我们如何设置这些属性。下面是源码片段,我在注释中列出了这些属性的意义。 ```go // 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         } ``` 这里属性非常多,你也不需要都记住是啥。来看一些常用属性,我们用一个设置好的输出结果图就能很好理解。 ![](https://static001.geekbang.org/resource/image/15/ed/159f41e9792178f9ca60950192fee4ed.png?wh=1186x956) 它对应的代码如下,后面会解释每一行都是怎么实现的: ```go // 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 中最常用的一些字段设置。 * Use 代表这个命令的调用关键字,比如要调用 Foo1 命令,我们就要用 `./hade foo foo1` 。Short 代表这个命令的简短说明,它会出现在上级命令的使用文档中。 * Long 代表这个命令的长说明,它会出现在当前命令的使用文档中。 * Aliases 是当前命令的别名,等同于 Use 字段; * Example 是当前命令的例子,也是显示在当前命令的使用文档中。 而 **RunE 代表当前命令的真正执行函数**: ```go 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进行监听: ```go 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 版本](https://github.com/spf13/cobra/tree/v1.2.1),将它放在 framework/cobra 目录下。 ![](https://static001.geekbang.org/resource/image/1e/1e/1e64756d99eeecbb99034d99682be71e.png?wh=456x556) 然后,对 Command 结构进行修改。要在 Command 结构中加入服务容器,由于刚才是源码引入的,很容易为 Command 增加一个container字段,在framework/cobra/command.go中修改Command结构: ```go type Command struct { // 服务容器 container framework.Container ... } ``` **再为 Command 提供两个方法:设置服务容器、获取服务容器**。设置服务容器的方法是为了在创建根 Command 之后,能将服务容器设置到里面去;而获取服务容器的方法,是为了在执行命令的 RunE 函数的时候,能从参数 Command 中获取到服务容器。 将定义的方法放在单独的一个文件framework/cobra/hade\_command.go中。 ```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 服务引擎的方法作为一个服务封装在服务容器中,完成准备工作。 * 开始 main 函数的改造。首先要做的必然是初始化一个服务容器,然后将各个服务绑定到这个服务容器中,有一个就是刚才定义的提供 Web 引擎的服务。 * 在业务代码中将业务需要的路由绑定到 Web 引擎中去。 * 完成服务的绑定之后,最后要创建一个根Command,并且创建一个Web启动的Command,这两个Command会形成一个树形结构。 我们先要将创建 Web 服务引擎的方法作为一个服务封装在服务容器中,按照[第十节课](https://time.geekbang.org/column/article/424529)封装服务的三个步骤:封装接口协议、定义一个服务提供者、初始化服务实例。 在framework/contract/kernel.go中,把创建 Engine 的过程封装为一个服务接口协议: ```go // 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。 ```go 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 引擎返回即可。 ```go // 引擎服务 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 函数中,我们第一步要做的,必然是初始化一个服务容器。 ```go // 初始化服务容器 container := framework.NewHadeContainer() ``` 接着,要将各个服务绑定到这个服务容器中。目前要绑定的服务容器有两个,一个是上一节课我们定义的目录结构服务HadeAppProvider,第二个是这节课定义的提供 Web 引擎的服务。 ```go // 绑定 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 文件中: ```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中: ```plain // Routes 绑定业务层路由 func Routes(r *gin.Engine) { r.Static("/dist/", "./dist/") demo.Register(r) } ``` 完成服务提供者的绑定和路由设置之后,**最后要创建一个根 Command,并且将业务的 Command 和框架定义的 Command 都加载到根 Command 中,形成一个树形结构**。 在 main 中,我们用 console.RunCommand 来创建和运行根 Command。 ```go // 运行 root 命令 console.RunCommand(container) ``` 而这里RunCommand 的方法简要来说做了三个事情: 1. 创建根 Command,并且将容器设置进根 Command 中。 2. 绑定框架和业务的 Command 命令。 3. 调用 Execute 启动命令结构。 具体的代码实现放在业务目录的app/console/kernel.go文件中,如下: ```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: ```go // 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 引擎: ```go // 从 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中: ```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的子命令: ```go // initAppCommand 初始化app命令和其子命令 func initAppCommand() *cobra.Command { appCommand.AddCommand(appStartCommand) return appCommand } ``` 在framework/command/kernel.go中,挂载对应的appCommand的命令: ```go // AddKernelCommands will add all command/* to root command func AddKernelCommands(root *cobra.Command) { // 挂载AppCommand命令 root.AddCommand(initAppCommand()) } ``` 我们就完成了Web启动的改造工作了。 ## 验证 好了到这里,整个命令行工具就引入成功,并且Web 框架也改造完成了。下面做一下验证。编译后调用./hade ,我们获取到根 Command 命令行工具的帮助信息: ![](https://static001.geekbang.org/resource/image/yy/cf/yy95ac2233fd4145a311f80422422dcf.png?wh=1418x824) 提示可以通过一级关键字 app 获取下一级命令: ![](https://static001.geekbang.org/resource/image/29/c2/2969de0887cbf2b918374279f9d031c2.png?wh=1034x676) 而./hade app 提醒我们可以通过二级关键字 start 来启动一个 Web 服务,调用 `./hade app start` 。 Web 服务启动成功,通过浏览器可以访问到业务定义的/demo/demo 路径。 ![](https://static001.geekbang.org/resource/image/52/3b/52d5f4d8fc431ab7853def837b78e73b.png?wh=1390x804) 今天所有代码都存放在GitHub 的 [geekbang/13](https://github.com/gohade/coredemo/tree/geekbang/13) 分支了,文中未展示的代码直接参考这个分支。本节课结束对应的目录结构如下: ![](https://static001.geekbang.org/resource/image/3f/5e/3f0b7e94b7f7d10bc0e2b23aca54145e.png?wh=377x1279) ## 总结 今天我们把之前的 Web 框架改造成了一个命令行工具,引入了 cobra 库,并且将原本的进程启动,也就是启动 Web 服务的方式,改成了调用一个命令来启动 Web 服务。 不知道你有没有感觉,将框架的入口改造成命令行,这个设计**不仅仅是简单换了一种 Web 服务的启动方式,而且是扩展了框架的另外一种可能性——设计命令行工具**。改造后,这个框架可以用来开发业务需要的各种命令行工具,同时也允许我们后续为框架增加多种多样易用性高的工具。 ## 思考题 其实在之前的版本,我在framework/contract/kernel.go是这么设计kernel服务接口的: ```go package contract const KernelKey = "hade:kernel" // Kernel 接口提供框架最核心的结构 type Kernel interface { // HttpEngine 提供gin的Engine结构 HttpEngine() *gin.Engine } ``` 在provider/kernel/service.go中是这么实现接口的: ```go // 返回web引擎 func (s *HadeKernelService) HttpEngine() *gin.Engine { return s.engine } ``` 和现在实现最大的不同是返回值。之前的返回值是返回了 \*gin.Engine。而现在的返回值是返回了http.Handler,其他的实现没有任何变化。你能看出这样的改动相较之前有什么好处么?为什么这么改? 欢迎在留言区分享你的思考。感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见。