你好,我是轩脉刃。

上一节课我们增加了自动化创建服务工具、命令行工具,以及中间件迁移工具。你会发现,这些工具实现起来并不复杂,但是在实际工作中却非常有用。今天我们继续思考还能做点什么。

我们的框架是定义了业务的目录结构的,每次创建一个新的应用,都需要将AppService中定义的目录结构创建好,如果这个行为能自动化,实现一个命令就能创建一个定义好所有目录结构,甚至有demo示例的新应用呢?是不是有点心动,这就是我们今天要实现的工具了,听起来功能有点庞大,所以我们还是慢慢来,先设计再实现。

初始化脚手架设计

这个功能倒不是什么新想法,有用过Vue的同学就知道,Vue官网有介绍一个 vue create 命令,可以从零开始创建一个包含基本Vue结构的目录,这个目录可以直接编译运行。

在初始化一个Vue项目的时候,大多数刚接触Vue的同学对框架的若干文件还不熟悉,很容易建立错误vue的目录结构,而这个工具能帮Vue新手们有效规避这种错误。

同理,我们的框架也有基本的hade结构的目录,初学者在创建hade应用的时候,也大概率容易建立错误目录。所以参考这一点,让自己的框架也有这么一个命令,能直接创建一个新的包含hade框架业务脚手架目录的命令。这样,能很大程度方便使用者就在这个脚手架目录上不断开发,完成所需的业务功能。

我们要设计的命令是一个一级命令./hade new 。一般来说,新建命令创建一个脚手架,要做的事情就是:

我们跟着这个思路走。先梳理一下在这个命令中,要传入的参数有哪些?

首先是目录,在控制台目录之下要创建一个子目录,这个子目录的名称,是需要用户传递进入的。不过,这个参数记得做一下验证,如果子目录已经存在了,给用户一个提示,是直接删除原先的子目录?还是停止操作?如果用户需要删除原先的子目录,我们就直接删除。

其次是需要用户传入新应用的模块名称,也就是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目录的内容即可啊?

这里我是这么设计的,将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应该如下:

// 这里是你的模块地址
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目录。

而在你自己的github.com/jianfengye/testdemo 项目中的所有文件,如果是框架中的,也就是要使用hade已有的服务提供者、中间件、命令行的时候,是使用import github.com/gohade/hade/framework;而在使用自己的服务提供者、中间件、命令行,所有在业务目录内的结构的时候,是使用 import github.com/jianfengye/testdemo/xxx

比如main.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 命令创建一个脚手架,要做的事情现在就变成了:

也就是说第二个输入,我们需要用户确切输入一个模块名称。

框架的版本号信息

除了新建时必须的子目录的名称和新建模块的名称,第三个需要用户输入的是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地址来进行查看。

所以回到 ./hade new 命令,第三个需要用户输入的就是这个版本号,如果用户需要创建一个v0.0.1版本的hade脚手架,则需要输入v0.0.1,如果用户没有输入,我们默认使用最新的版本。

好了,简单总结一下,用户目前输入的三个信息:

用户输入相关的代码如下,在我们的 framework/command/new.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项目到目标文件夹” ,我们就要变化为“下载github.com/gohade/hade 的某个release版本到目标文件夹”。

这个能怎么做呢?可以想到GitHub有提供对外的开放平台接口 api.github.com,你可以看它的官方文档地址

我们可以通过开放平台接口,对公共的GitHub仓库进行信息查询。比如要查看某个GitHub仓库的release分支,可以通过调用“/repos/{owner}/{repo}/releases”,而获取某个GitHub仓库的最新release分支,可以通过调用“/repos/{owner}/{repo}/releases/latest”。

使用GitHub的开放平台接口,是可以直接调用,但是这个方法有个明显的问题,我们还要手动封装这个接口调用。

其实更简单的方式是,使用Google给我们提供好的Golang语言的SDK,go-github。这个库本质就是封装了GitHub的调用接口。比如获取仓库github.com/gohade/hade的release分支:

client := github.NewClient(nil)
releases, _, err = client.Repositories.GetReleases(context.Background(), "gohade", "hade")

而获取它最新release分支也很简单:

client := github.NewClient(nil)
release, _, err = client.Repositories.GetLatestRelease(context.Background(), "gohade", "hade")

在返回的RepositoryRelease结构中,我们可以找到下载这个release版本的各种信息。其中包括release版本对应的版本号信息和zip下载地址:

// 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中:

// 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中,代码中也做了对应注释:

// 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中:

    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中:

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的脚手架。

进入testdemo目录,执行 go build 命令可直接编译,并且生成了可运行的二进制文件。

自动化初始化脚手架命令完成!

今天所有代码都保存在GitHub上的geekbang/22分支了。附上目录结构供你对比查看,只修改了framework/command/目录下的new.go代码。

小结

今天我们增加了一个新的命令,自动化初始化脚手架的命令设计,让hade框架也可以像Vue框架一样,直接使用一个二进制命令 ./hade new 创建一个脚手架。我们把框架和脚手架示例代码同时放在github.com/gohade/hade仓库中,实现了框架和脚手架示例代码版本的关联。

在创建脚手架的时候,我们是基于这个仓库的某个tag版本做减法,而不是费劲地做加法来进行创建。

同时在每次更新框架的时候,我们也会自然而然更新这个示例代码,框架和示例代码永远是一一对应的,而下载的时候会保留这种一一对应的关系。这种设计让hade版本的框架设计更为方便了。

这两节课的四个工具的自动化,是我们目前能想到的比较常用的“重复性”劳动了。当然随着框架使用的深入,还可能有更多的自动化需求,但是基本上都和这几个自动化命令是同样的套路,所以掌握这两节课的内容和方法,你已经可以自行简化这些“重复性”劳动了。

思考题

这节课的代码比较多,希望你能仔细对比GitHub上的代码。经过这两节课的练习,你可以思考一下,作为一个“懒惰”的程序员,在hade框架中,我们还有哪些工作还可以自动化么?

欢迎在留言区分享你的思考。如果你觉得有收获,也欢迎把今天的内容分享给身边的朋友,邀他一起学习。我们下节课见。