# 21|自动化(上):DRY,如何自动化一切重复性劳动? 你好,我是轩脉刃。 不知道你有没有听过这种说法,优秀程序员应该有三大美德:懒惰、急躁和傲慢,这句话是Perl语言的发明者Larry Wall说的。其中懒惰这一点指的就是,程序员为了懒惰,不重复做同样的事情,会思考是否能把一切重复性的劳动自动化(don’t repeat yourself)。 而框架开发到这里,我们也需要思考,有哪些重复性劳动可以自动化么? 从第十章到现在我们一直在说,框架核心是服务提供者,在开发具体应用时,一定会有很多需求要创建各种各样的服务,毕竟“一切皆服务”;而每次创建服务的时候,我们都需要至少编写三个文件,服务接口、服务提供者、服务实例。**如果能自动生成三个文件,提供一个“自动化创建服务的工具”,应该能节省不少的操作**。 说到创建工具,我们经常需要为了一个事情而创建一个命令行工具,而每次创建命令行工具,也都需要创建固定的Command.go文件,其中有固定的Command结构,这些代码我们能不能偷个懒,“**自动化创建命令行工具**”呢? 另外之前我们做过几次中间件的迁移,先将源码拷贝复制,再修改对应的Gin路径,这个操作也是颇为繁琐的。那么,我们是否可以写一个“**自动化中间件迁移工具**”,一个命令自动复制和替换呢? 这些命令都是可以实现的,这节课我们就来尝试完成这三项自动化,“自动化创建服务工具”, “自动化创建命令行工具”,以及“自动化中间件迁移工具”。 ## 自动化创建服务工具 在创建各种各样的服务时,“自动化创建服务工具”能帮我们节省不少开发时间。我们先思考下这个工具应该如何实现。 既然之前已经引入cobra,将框架修改为可以支持命令行工具,创建命令并不是一个难事,我们来定义一套创建服务的provider 命令即可。照旧先设计好要创建的命令,再一一实现。 ### 命令创建 “自动化创建服务工具”如何设计命令层级呢?我们设计一个一级命令和两个二级命令: * `./hade provider` 一级命令,provider,打印帮助信息; * `./hade provider new` 二级命令,创建一个服务; * `./hade provider list` 二级命令,列出容器内的所有服务,列出它们的字符串凭证。 首先将provider的这两个二级命令,都存放在command/provider.go中。而对应的一级命令 providerCommand 是一个打印帮助信息的空实现。 ```go // 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: ```go // 初始化provider相关服务 func initProviderCommand() *cobra.Command { providerCommand.AddCommand(providerCreateCommand) providerCommand.AddCommand(providerListCommand) return providerCommand } ``` 并且在 framework/command/kernel.go,将这个一级命令挂载到一级命令rootCommand中: ```go func AddKernelCommands(root *cobra.Command) { // provider一级命令 root.AddCommand(initProviderCommand() } ``` 下面来实现这两个二级命令new和list。 ### List命令的实现 先说 `./hade provider list` 这个命令,因为列出容器内的所有服务是比较简单的。还记得吗,在十一章实现服务容器的时候,其中有一个providers,它存储所有的服务容器提供者,放在文件 framework/container.go 中: ```go // HadeContainer 是服务容器的具体实现 type HadeContainer struct { ... // providers 存储注册的服务提供者,key 为字符串凭证 providers map[string]ServiceProvider ... } ``` 我们只需要将这个providers进行遍历,根据其中每个ServiceProvider的Name() 方法,获取字符串凭证列表即可。 所以,在framework/container.go 的HadeContainer中,增加一个NameList方法,返回所有提供服务者的字符串凭证,方法也很简单,直接遍历这个providers 字段。 ```go // 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 命令中,我们调用这个命令并且打印出来。 ```go // 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` ,可以看到如下信息: ![](https://static001.geekbang.org/resource/image/0c/35/0c4d1fb73cd17aabc02fe7c3b5f2f335.png?wh=481x215) 你可以很清晰看到容器中绑定了哪些服务提供者,它们的字符串凭证是什么。这样我们在定义一个新的服务的时候,可以很方便看到哪些服务提供者的关键字已经被使用了,避免使用已有的服务关键字。 下面我们来说稍微复杂一点的创建服务的命令 `./hade provider new` 。 ### new命令的实现 在实际业务开发过程中,我们一想到一个服务,比如去某个用户系统获取信息,一定会想到创建服务的三步骤:创建一个用户系统的交互协议contract.go、再创建一个提供协议的用户服务提供者 provider.go、最后才实现具体的用户服务实例 service.go。 每次都需要创建这三个文件,且这三个文件的文件大框架都有套路可言。那我们如何将这些重复的套路性的代码自动化生成呢? 首先这里有一个增加参数的过程,我们需要知道要创建服务的服务名是什么?创建这个服务的文件夹名字是什么?当然了,这些参数也可以使用在命令后面增加flag参数的方式来表示。但是其实还有一种更便捷的方式:交互。 交互的表现形式如: ```go 输入:./hade provider new (我想创建一个服务) 输出:请输入服务名称(服务凭证): 输入:demo 输出:请求输入服务目录名称(默认和服务名称相同): 输入:demo 输出:创建服务成功, 文件夹地址:xxxxx 输出:请不要忘记挂载新创建的服务 ``` 这种命令行交互的方式是不是更智能化?但是如何实现呢? 这里我们借助一个第三方库 [survey](https://github.com/AlecAivazis/survey)。这个库目前在GitHub上已经有2.7k个star,最新版本是v2版本,使用的是MIT License协议,可以放心使用。这个survey库支持多种交互模式:单行输入、多行输入、单选、多选、y/n 确认选择,在[项目GitHub首页](https://github.com/AlecAivazis/survey)上就能很清晰看到这个库的使用方式。 ```go 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](https://pkg.go.dev/text/template) 库。 这个库的使用方法比较多,我这里把我们用得到的方法解说一下,解析contract.go文件的生成过程,就可以了解其使用方法了。 ```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: ```go var contractTmp string = `package {{.}} const {{.|title}}Key = "{{.}}" type Service interface { // 请在这里定义你的方法 Foo() string } ` ``` 注意到了么,其中的{{.|title}} 实际上是相当于调用了strings.Title(name) 的方法填充,能将字符串name替换为字符串Name。 而定义好了FuncMap之后,我们随后使用了os.Create创建contract.go文件,然后初始化template: ```plain 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之后,使用代码: ```go t.Execute(f, name) ``` 来将变量name 注册进入模版t,并且输出到f。这里的f,是我们之前创建的contract.go文件。也就是使用变量name解析模版t,输出到contract.go文件中。 这里的变量可以是一个struct结构,也可以是基础变量,比如我们这里定义的字符串。在模版中{{.}} 就代表这个结构。所以再回顾前面定义的contractTmp模版,你会看出其中变量name为字符串user的时候,最终的显示是什么吗? 好,创建服务命令的所有思路我们就梳理清楚了,最后也贴出完整的代码供你参考,关键步骤都在注释中详细说明了,实现并不难: ```go // 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。 ![](https://static001.geekbang.org/resource/image/b7/3c/b7ae3ba7edee1ce7a216e891d1b8e23c.png?wh=620x193) 能看到 app/provider/ 目录下创建了user文件夹,其中有contract.go、provider.go、service.go三个文件: ![](https://static001.geekbang.org/resource/image/46/e6/46900bb2ea53135b890763687f4cc0e6.png?wh=376x276) 其中每个文件的定义都完整,且可以直接再次编译通过,验证完成! ## 自动化创建命令行工具 到这里我们就完成了创建服务工具的自动化。开头提到具体运营一个应用的时候,我们也会经常需要创建一个自定义的命令行。比如运营一个网站,可能会创建一个命令来统计网站注册人数,也可能要创建一个命令来定期检查是否有违禁的文章需要封禁等。所以自动创建命令行工具在实际工作中是非常有必要的。 同服务命令一样,我们可以有一套创建命令行工具的命令。 * `./hade command` 一级命令,显示帮助信息 * `./hade command list` 二级命令,列出所有控制台命令 * `./hade command new` 二级命令,创建一个控制台命令 command相关的命令和provider的命令的实现基本是一致的。这里我们简要解说下重点,具体对应的代码详情可以参考GitHub上的[framework/command/cmd.go](https://github.com/gohade/coredemo/blob/geekbang/21/framework/command/cmd.go) 文件。 一级命令./hade command 我们就不说了,是简单地显示帮助信息。 二级命令 ./hade command list。功能是列出所有的控制台命令。这个功能实际上和直接调用 ./hade 显示的帮助信息差不多,把一级根命令全部列了出来,只不过我们使用了一个更为语义化的 ./hade command list 来显示。 ![](https://static001.geekbang.org/resource/image/85/9e/85fd992ca12552e3d5fef51994b1079e.png?wh=946x722) 它的实现也并不复杂,具体就是使用Root().Commands() 方法遍历一级跟命令的所有一级命令。 ```go // 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 文件中输入模版: ```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 文件](https://github.com/gohade/coredemo/blob/geekbang/21/framework/command/middleware.go)参考其具体实现,相信你可以顺利写出来。 这里重点说一下 `./hade middleware migrate` 命令。 不知道你有没有好奇,为什么迁移也要写一个命令?当时在将Gin迁移进入hade框架的时候我们说,Gin作为一个成熟的开源作品,有丰富的中间件库,存放GitHub的一个项目 [gin-contrib](https://github.com/gin-contrib/) 中。那么在开发过程中,我们一定会经常需要使用到这些中间件。 但是由于这些中间件使用到的Gin框架的地址为 : ```plain github.com/gin-gonic/gin ``` 而我们的Gin框架地址为: ```plain 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项目](https://github.com/gin-contrib/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](https://github.com/go-git/go-git),这个第三方库已经有2.7k 个star,且基于Apache 的Licence,是可以直接import使用的。目前这个库最新的版本为v5。 它的使用方式如下: ```go _, 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中,对应的代码如下: ```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。你会在控制台看到这些信息: ![](https://static001.geekbang.org/resource/image/6f/5f/6fd8197207c92b3b362a89cc4676015f.png?wh=682x384) 并且在目录中看到cors中间件已经完整下载下来了。 ![](https://static001.geekbang.org/resource/image/70/09/709dbbc8c2140byy56459c202aa66909.png?wh=343x610) 然后,可以直接在app/http/route.go中直接使用这个cors中间件: ```go ... // Routes 绑定业务层路由 func Routes(r *gin.Engine) { ... // 使用cors中间件 r.Use(cors.Default()) ... } ``` 验证完成! 今天所有代码都保存在GitHub上的[geekbang/21](https://github.com/gohade/coredemo/tree/geekbang/21)分支了。附上目录结构供你对比查看,只修改了framework/command/目录下的cmd.go、provider.go、middleware.go文件。 ![](https://static001.geekbang.org/resource/image/78/89/7889877151dc4d4e306554a64c550c89.png?wh=476x936) ## 小结 今天增加的命令不少,自动化创建服务工具、命令行工具,以及中间件迁移工具,这些命令都为我们后续开发应用提供了不少便利。 其实每个自动化命令行工具实现的思路都是差不多的,先思考清楚对于这个工具我们要自动化生成什么,然后使用代码和对应的模版生成对应的文件,并且替换其中特有的单词。原理不复杂,但是对于实际的工作,是非常有帮助的。 这一节课你应该可以感受到之前将cobra引入我们的框架是一个多么正确的决定,在cobra之上,我们才能实现这些方便的自动化工具。 ### 思考题 我们实现的自动化服务./hade command list命令,目前只展示了一级命令,在写这篇文章的时候我反思了一下,其实可以扩展成为树形结构展示,同时展示一级/二级/三级/命令。你可以想想如何实现,如果可以的话,可以去github.com/gohade/hade 项目中提交一个merge request 来补充这个功能吧! 欢迎在留言区分享你的思考。我们下节课见。