# 22|自动化(下):DRY,如何自动化一切重复性劳动? 你好,我是轩脉刃。 上一节课我们增加了自动化创建服务工具、命令行工具,以及中间件迁移工具。你会发现,这些工具实现起来并不复杂,但是在实际工作中却非常有用。今天我们继续思考还能做点什么。 我们的框架是定义了业务的目录结构的,每次创建一个新的应用,都需要将AppService中定义的目录结构创建好,如果这个行为能自动化,实现**一个命令就能创建一个定义好所有目录结构,甚至有demo示例的新应用**呢?是不是有点心动,这就是我们今天要实现的工具了,听起来功能有点庞大,所以我们还是慢慢来,先设计再实现。 ## 初始化脚手架设计 这个功能倒不是什么新想法,有用过Vue的同学就知道,Vue官网有介绍一个 `vue create` [命令](https://cli.vuejs.org/zh/guide/creating-a-project.html),可以从零开始创建一个包含基本Vue结构的目录,这个目录可以直接编译运行。 在初始化一个Vue项目的时候,大多数刚接触Vue的同学对框架的若干文件还不熟悉,很容易建立错误vue的目录结构,而这个工具能帮Vue新手们有效规避这种错误。 同理,我们的框架也有基本的hade结构的目录,初学者在创建hade应用的时候,也大概率容易建立错误目录。所以参考这一点,让自己的框架也有这么一个命令,能直接创建一个新的包含hade框架业务脚手架目录的命令。这样,能很大程度方便使用者就在这个脚手架目录上不断开发,完成所需的业务功能。 我们要设计的命令是一个一级命令`./hade new` 。一般来说,新建命令创建一个脚手架,要做的事情就是: * 确定目标目录,如果没有就创建目录 * 创建业务模块目录 * 初始化go module 模块,补充模块名称、框架版本号 * 在业务模块目录中创建对应的文件代码 我们跟着这个思路走。先梳理一下在这个命令中,要传入的参数有哪些? 首先是目录,在控制台目录之下要创建一个子目录,这个**子目录的名称,是需要用户传递进入的**。不过,这个参数记得做一下验证,如果子目录已经存在了,给用户一个提示,是直接删除原先的子目录?还是停止操作?如果用户需要删除原先的子目录,我们就直接删除。 其次是**需要用户传入新应用的模块名称**,也就是go.mod中的module后面的名称,一般会设置为应用的项目地址,比如github.com/jianfengye/testdemo。关于模块名称,我们要详细做一下解说。 ### 业务、框架模块地址 一直到这一节课的GitHub地址,不知道你有没有疑惑,别的框架,比如Gin、Echo,都是把框架代码放在GitHub上,比如github/gin-gonic/gin,而业务代码是单独存放的。但我们这个项目github.com/gohade/coredemo,却是把业务代码和框架代码都放在一个项目中? 其实是这样,这个项目github.com/gohade/coredemo,是我为geekbang这个课程单独设置的项目,将hade框架的每个实现步骤,重新在这个项目做了一次还原。而 github.com/gohade/hade 才是我们最终的项目地址。所以不管在 coredemo 这个项目还是 hade这个项目,go.mod 中的module 都是叫做 github.com/gohade/hade。 **但是即使是最终的github.com/gohade/hade项目,我们的业务代码app目录和框架目录 framework目录也是在一个项目里的**,按道理说在这个hade项目中,应该只有framework目录的内容即可啊? ![](https://static001.geekbang.org/resource/image/15/54/15d2de9d05e7f68dc072708945beaa54.png?wh=400x562) 这里我是这么设计的,将framework目录和其他的业务目录都同时放在github.com/gohade/hade项目中,这样这个项目也同时就是我们hade框架的一个示例项目。只是这个项目带着framework目录而已。 后续如果要创建一个新的业务项目,比如github.com/jianfengye/testdemo。我们**不是做加法把业务文件夹一点点复制过来,而是做依赖这个github.com/gohade/hade项目做减法**,把不必要的文件夹(比如框架文件夹)删掉。 即我们只需要直接拷贝这个github.com/gohade/hade 项目,并且将其中的framework目录删除,保留业务目录,同时把go.mod中的原先的“github.com/gohade/hade”模块名修改为github.com/jianfengye/testdemo这个模块名,用到hade框架的部分直接引用“github.com/gohade/hade/framework” 即可。 这就是说,如果你要创建的项目的模块名为github.com/jianfengye/testdemo,go.mod应该如下: ```go // 这里是你的模块地址 module github.com/jianfengye/testdemo go 1.15 require ( // 这里引用github.com/gohade/hade github.com/gohade/hade v0.0.2 ... ) ``` 目录应该和github.com/gohade/hade 只有一处不同:没有framework目录。 ![](https://static001.geekbang.org/resource/image/d4/63/d4208f4ea094ba3404da935a1bf21263.png?wh=254x414) 而在你自己的github.com/jianfengye/testdemo 项目中的所有文件,如果是框架中的,也就是要使用hade已有的服务提供者、中间件、命令行的时候,是使用`import github.com/gohade/hade/framework`;而在使用自己的服务提供者、中间件、命令行,所有在业务目录内的结构的时候,是使用 `import github.com/jianfengye/testdemo/xxx`。 比如main.go 就形如: ```go package main import ( // 业务的目录app内的文件 "github.com/jianfengye/testdemo/app/console" "github.com/jianfengye/testdemo/app/http" // 框架目录的文件 "github.com/gohade/hade/framework" "github.com/gohade/hade/framework/provider/app" "github.com/gohade/hade/framework/provider/config" "github.com/gohade/hade/framework/provider/distributed" "github.com/gohade/hade/framework/provider/env" "github.com/gohade/hade/framework/provider/id" "github.com/gohade/hade/framework/provider/kernel" "github.com/gohade/hade/framework/provider/log" "github.com/gohade/hade/framework/provider/trace" ) func main() { // 初始化服务容器 container := framework.NewHadeContainer() // 绑定App服务提供者 container.Bind(&app.HadeAppProvider{}) // 后续初始化需要绑定的服务提供者... container.Bind(&env.HadeEnvProvider{}) container.Bind(&distributed.LocalDistributedProvider{}) container.Bind(&config.HadeConfigProvider{}) container.Bind(&id.HadeIDProvider{}) container.Bind(&trace.HadeTraceProvider{}) container.Bind(&log.HadeLogServiceProvider{}) // 将HTTP引擎初始化,并且作为服务提供者绑定到服务容器中 if engine, err := http.NewHttpEngine(); err == nil { container.Bind(&kernel.HadeKernelProvider{HttpEngine: engine}) } // 运行root命令 console.RunCommand(container) } ``` 说到这里相信你应该理解了,最终我们这个框架只维护 github.com/gohade/hade 这么一个项目,**这个项目中的framework目录,存放的是框架所有的代码,而framework之外的目录和文件都是示例代码**。 所以,回到今天的主题,让 `./hade new` 命令创建一个脚手架,要做的事情现在就变成了: * 下载github.com/gohade/hade项目到目标文件夹 * 删除framework目录 * 修改go.mod中的模块名称 * 修改go.mod中的require信息,增加require github.com/gohade/hade * 修改所有文件使用业务目录的地方,将原本使用“github.com/gohade/hade/app” 的所有引用改成 “\[模块名称\]/app” 也就是说第二个输入,我们需要用户确切输入一个模块名称。 ### 框架的版本号信息 除了新建时必须的子目录的名称和新建模块的名称,第三个需要用户输入的是hade的版本号。 我们的hade框架是会不断变化的,和Golang语言一样,使用形如v1.2.3这样的版本号进行迭代,v代表版本的英文缩写,1代表的是大版本,只有非常大变更的时候我们才会更新这个版本;2代表的是小版本,有接口变更或者类库变更之类的时候我们会迭代这个版本;3代表的是补丁版本,如果发现有需要补丁修复的地方,就会使用这个版本。 而每个hade框架版本对应的脚手架,也有可能有一定变化的。因为在脚手架中,我们会把框架的使用示例等放在应用代码中。 hade框架的每个版本发布时,都会打对应的tag,每个tag我们都会在GitHub上发布一个release版本与之对应,比如截止到10/7日,已经发布了v0.0.1和v0.0.2两个tag和release版本,你可以直接通过[GitHub地址](https://github.com/gohade/hade/releases)来进行查看。 ![](https://static001.geekbang.org/resource/image/ba/a4/ba9e3152dbc9469327a820d1cac205a4.png?wh=1323x331)![](https://static001.geekbang.org/resource/image/a7/dc/a77a6affec75c2051df984fcff89fbdc.png?wh=1284x886) 所以回到 `./hade new` 命令,第三个需要用户输入的就是这个版本号,如果用户需要创建一个v0.0.1版本的hade脚手架,则需要输入v0.0.1,如果用户没有输入,我们默认使用最新的版本。 好了,简单总结一下,用户目前输入的三个信息: * 目录名,最终是“当前执行目录+目录名” * 模块名,最终创建应用的module * 版本号,对应的hade的release版本号 用户输入相关的代码如下,在我们的 framework/command/new.go中: ```go var name string var folder string var mod string var version string var release *github.RepositoryRelease { prompt := &survey.Input{ Message: "请输入目录名称:", } err := survey.AskOne(prompt, &name) if err != nil { return err } folder = filepath.Join(currentPath, name) if util.Exists(folder) { isForce := false prompt2 := &survey.Confirm{ Message: "目录" + folder + "已经存在,是否删除重新创建?(确认后立刻执行删除操作!)", Default: false, } err := survey.AskOne(prompt2, &isForce) if err != nil { return err } if isForce { if err := os.RemoveAll(folder); err != nil { return err } } else { fmt.Println("目录已存在,创建应用失败") return nil } } } { prompt := &survey.Input{ Message: "请输入模块名称(go.mod中的module, 默认为文件夹名称):", } err := survey.AskOne(prompt, &mod) if err != nil { return err } if mod == "" { mod = name } } { // 获取hade的版本 client := github.NewClient(nil) prompt := &survey.Input{ Message: "请输入版本名称(参考 https://github.com/gohade/hade/releases,默认为最新版本):", } err := survey.AskOne(prompt, &version) if err != nil { return err } if version != "" { // 确认版本是否正确 release, _, err = client.Repositories.GetReleaseByTag(context.Background(), "gohade", "hade", version) if err != nil || release == nil { fmt.Println("版本不存在,创建应用失败,请参考 https://github.com/gohade/hade/releases") return nil } } if version == "" { release, _, err = client.Repositories.GetLatestRelease(context.Background(), "gohade", "hade") version = release.GetTagName() } } ``` ## 初始化脚手架具体实现 有了这三个信息,我们将之前讨论的 hade new 命令的步骤再详细展开讨论: * 下载github.com/gohade/hade项目到目标文件夹 * 删除framework目录 * 修改go.mod中的模块名称 * 修改go.mod中的require信息,增加require github.com/gohade/hade * 修改所有文件使用业务目录的地方,将原本使用“github.com/gohade/hade/app” 的所有引用改成 “\[模块名称\]/app” 第一步下载稍微复杂一点,我们重点说,剩下四步就是简单的按部就班了。 ### 项目下载 因为有版本号更新的可能,其中的第一步“复制github.com/gohade/hade项目到目标文件夹” ,我们就要变化为“下载github.com/gohade/hade 的某个release版本到目标文件夹”。 这个能怎么做呢?可以想到GitHub有提供对外的开放平台接口 api.github.com,你可以看它的[官方文档地址](https://docs.github.com/cn/rest/reference/repos)。 我们可以通过开放平台接口,对公共的GitHub仓库进行信息查询。比如要查看某个GitHub仓库的release分支,可以通过调用“[/repos/{owner}/{repo}/releases](https://docs.github.com/cn/rest/reference/repos#list-releases)”,而获取某个GitHub仓库的最新release分支,可以通过调用“[/repos/{owner}/{repo}/releases/latest](https://docs.github.com/cn/rest/reference/repos#get-the-latest-release)”。 使用GitHub的开放平台接口,是可以直接调用,但是这个方法有个明显的问题,我们还要手动封装这个接口调用。 其实更简单的方式是,使用Google给我们提供好的Golang语言的SDK,[go-github](https://github.com/google/go-github)。这个库本质就是封装了GitHub的调用接口。比如获取仓库github.com/gohade/hade的release分支: ```go client := github.NewClient(nil) releases, _, err = client.Repositories.GetReleases(context.Background(), "gohade", "hade") ``` 而获取它最新release分支也很简单: ```go client := github.NewClient(nil) release, _, err = client.Repositories.GetLatestRelease(context.Background(), "gohade", "hade") ``` 在返回的RepositoryRelease结构中,我们可以找到下载这个release版本的各种信息。其中包括release版本对应的版本号信息和zip下载地址: ```go // RepositoryRelease represents a GitHub release in a repository. type RepositoryRelease struct { // 对应的版本号信息 TagName *string `json:"tag_name,omitempty"` ... // release版本的zip下载地址 ZipballURL *string `json:"zipball_url,omitempty"` ... } ``` 库信息了解到这里,我们回到刚才要执行的第一步“下载github.com/gohade/hade 的某个release版本到目标文件夹”,就可以使用这个zip下载地址,下载对应的zip包,并且使用unzip解压这个zip目录。 对于下载zip包,直接使用http.Get就能下载了。这个函数我们封装在framework/util/file.go中: ```go // DownloadFile 下载url中的内容保存到本地的filepath中 func DownloadFile(filepath string, url string) error { // 获取 resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() // 创建目标文件 out, err := os.Create(filepath) if err != nil { return err } defer out.Close() // 拷贝内容 _, err = io.Copy(out, resp.Body) return err } ``` 而unzip解压,我们可以使用Golang标准库的 archive/zip,来读取zip包中的内容,然后将每个文件都复制到目标目录中。unzip的基本逻辑就是使用zip包读取压缩文件,然后遍历压缩文件中的文件夹,将对应的文件和文件夹都复制到目标目录中。 具体代码存放在framework/util/zip.go中,代码中也做了对应注释: ```go // Unzip 解压缩zip文件,复制文件和目录都到目标目录中 func Unzip(src string, dest string) ([]string, error) { var filenames []string // 使用archive/zip读取 r, err := zip.OpenReader(src) if err != nil { return filenames, err } defer r.Close() // 所有内部文件都读取 for _, f := range r.File { // 目标路径 fpath := filepath.Join(dest, f.Name) if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return filenames, fmt.Errorf("%s: illegal file path", fpath) } filenames = append(filenames, fpath) if f.FileInfo().IsDir() { // 如果是目录,则创建目录 os.MkdirAll(fpath, os.ModePerm) continue } //否则创建文件 if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { return filenames, err } outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return filenames, err } rc, err := f.Open() if err != nil { return filenames, err } // 复制内容 _, err = io.Copy(outFile, rc) outFile.Close() rc.Close() if err != nil { return filenames, err } } return filenames, nil } ``` 但是你在调试的过程中就会发现,下载的zip包中带有一层目录,gohade-hade-xxxx,目录下面才是我们需要的hade库的真实代码。如果直接复制zip包,就会在目标文件夹下创建gohade-hade-xxx目录,但是这个目录层级并不是我们想要的。 所以这里要修改“下载github.com/gohade/hade 的某个release版本到目标文件夹”的实现步骤,大致思路就是**通过创建和删除一个临时目录,来达到把zip包解压的目的**。 具体操作就是,先创建临时目录 template-hade-version-\[timestamp\],然后下载release的zip包地址临时目录,并命名为template.zip,在临时目录中解压zip包 template.zip,生成gohade-hade-xxxx目录。这个时候就完成了一半,拿到了需要的hade库真实代码。 之后,查找临时目录中名为 gohade-hade-开头的目录,定位到gohade-hade-xxx目录,将这个目录使用os.rename 移动成为目标文件夹。最后收尾删除临时目录。 对应代码在framework/command/new.go中: ```go templateFolder := filepath.Join(currentPath, "template-hade-"+version+"-"+cast.ToString(time.Now().Unix())) os.Mkdir(templateFolder, os.ModePerm) fmt.Println("创建临时目录", templateFolder) // 拷贝template项目 url := release.GetZipballURL() err := util.DownloadFile(filepath.Join(templateFolder, "template.zip"), url) if err != nil { return err } fmt.Println("下载zip包到template.zip") _, err = util.Unzip(filepath.Join(templateFolder, "template.zip"), templateFolder) if err != nil { return err } // 获取folder下的gohade-hade-xxx相关解压目录 fInfos, err := ioutil.ReadDir(templateFolder) if err != nil { return err } for _, fInfo := range fInfos { // 找到解压后的文件夹 if fInfo.IsDir() && strings.Contains(fInfo.Name(), "gohade-hade-") { if err := os.Rename(filepath.Join(templateFolder, fInfo.Name()), folder); err != nil { return err } } } fmt.Println("解压zip包") if err := os.RemoveAll(templateFolder); err != nil { return err } fmt.Println("删除临时文件夹", templateFolder) ``` 第一步的源码复制完成之后,就是后面很简单的四步了,我直接把顺序写在注释中了,你可以对照代码看,同样在framework/command/new.go中: ```go os.RemoveAll(path.Join(folder, ".git")) fmt.Println("删除.git目录") // 删除framework 目录 os.RemoveAll(path.Join(folder, "framework")) fmt.Println("删除framework目录") filepath.Walk(folder, func(path string, info os.FileInfo, err error) error { if info.IsDir() { return nil } c, err := ioutil.ReadFile(path) if err != nil { return err } // 修改go.mod中的模块名称、修改go.mod中的require信息 // 增加require github.com/gohade/hade if path == filepath.Join(folder, "go.mod") { fmt.Println("更新文件:" + path) c = bytes.ReplaceAll(c, []byte("module github.com/gohade/hade"), []byte("module "+mod)) c = bytes.ReplaceAll(c, []byte("require ("), []byte("require (\n\tgithub.com/gohade/hade "+version)) err = ioutil.WriteFile(path, c, 0644) if err != nil { return err } return nil } // 最后修改所有文件使用业务目录的地方, // 将原本使用“github.com/gohade/hade/app” 的所有引用 // 改成 “[模块名称]/app” isContain := bytes.Contains(c, []byte("github.com/gohade/hade/app")) if isContain { fmt.Println("更新文件:" + path) c = bytes.ReplaceAll(c, []byte("github.com/gohade/hade/app"), []byte(mod+"/app")) err = ioutil.WriteFile(path, c, 0644) if err != nil { return err } } return nil }) fmt.Println("创建应用结束") fmt.Println("目录:", folder) fmt.Println("====================================================") return nil ``` ### 验证 最后我们验证下。使用 `./hade new` 创建一个目录名称为testdemo、模块名为 github.com/jianfengye/testdemo、版本为最新版本v0.0.2的脚手架。 ![](https://static001.geekbang.org/resource/image/82/c5/8211f96faf4ff3a4b86e2213b0678ec5.png?wh=691x574) 进入testdemo目录,执行 `go build` 命令可直接编译,并且生成了可运行的二进制文件。 ![](https://static001.geekbang.org/resource/image/44/ff/446ac21ea8f351989778fa83437be2ff.png?wh=1025x646) 自动化初始化脚手架命令完成! 今天所有代码都保存在GitHub上的[geekbang/22](https://github.com/gohade/coredemo/tree/geekbang/22)分支了。附上目录结构供你对比查看,只修改了framework/command/目录下的new.go代码。 ![](https://static001.geekbang.org/resource/image/f4/95/f4e3c5e4f359b3a005854e9451b32395.png?wh=392x996) ## 小结 今天我们增加了一个新的命令,自动化初始化脚手架的命令设计,让hade框架也可以像Vue框架一样,直接使用一个二进制命令 `./hade new` 创建一个脚手架。我们把框架和脚手架示例代码同时放在github.com/gohade/hade仓库中,实现了框架和脚手架示例代码版本的关联。 在创建脚手架的时候,我们是**基于这个仓库的某个tag版本做减法**,而不是费劲地做加法来进行创建。 同时在每次更新框架的时候,我们也会自然而然更新这个示例代码,**框架和示例代码永远是一一对应的,而下载的时候会保留这种一一对应的关系**。这种设计让hade版本的框架设计更为方便了。 这两节课的四个工具的自动化,是我们目前能想到的比较常用的“重复性”劳动了。当然随着框架使用的深入,还可能有更多的自动化需求,但是基本上都和这几个自动化命令是同样的套路,所以掌握这两节课的内容和方法,你已经可以自行简化这些“重复性”劳动了。 ## 思考题 这节课的代码比较多,希望你能仔细对比GitHub上的代码。经过这两节课的练习,你可以思考一下,作为一个“懒惰”的程序员,在hade框架中,我们还有哪些工作还可以自动化么? 欢迎在留言区分享你的思考。如果你觉得有收获,也欢迎把今天的内容分享给身边的朋友,邀他一起学习。我们下节课见。