You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

500 lines
23 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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/testdemogo.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框架中我们还有哪些工作还可以自动化么
欢迎在留言区分享你的思考。如果你觉得有收获,也欢迎把今天的内容分享给身边的朋友,邀他一起学习。我们下节课见。