微信公众号:浮槎c
专注编程技术、个人学习分享。问题或建议,请公众号留言;

应用场景

之前开发go-web项目是打算打造成脚手架的,恰逢最近打算把go-web升级重构,查看项目过程中,发现缺少基本的命令行工具,就打算实现一个简易的。需求如下:

  • 可通过命令行直接下载go-web项目

  • 下载后直接更新项目中模块路径为用户自定义的项目名称

预期效果:

# 终端输入以下命令,以go-web为模板生成helloworld项目
goweb new helloworld

方案设计

在网上阅读了实现脚手架命令工具的资料,想着github上的微服务开源项目应该有命令行工具的实现,之前看kratos也确实有命令工具,直接参考kratos源码(https://github.com/go-kratos/kratos/tree/main/cmd/kratos),抽出自己需要的部分写就好了(又往CV工程师的道路上狂奔~~~)。

kratos命令行工具项目结构如下:

├── go.mod
├── go.sum
├── internal
│   ├── base
│   │   ├── install_compatible.go
│   │   ├── install.go
│   │   ├── mod.go
│   │   ├── mod_test.go
│   │   ├── path.go
│   │   ├── repo.go
│   │   └── repo_test.go
│   ├── change
│   │   ├── change.go
│   │   └── get.go
│   ├── project 
│   │   ├── add.go
│   │   ├── new.go
│   │   └── project.go
│   ├── proto
│   │   ├── add
│   │   │   ├── add.go
│   │   │   ├── add_test.go
│   │   │   ├── proto.go
│   │   │   └── template.go
│   │   ├── client
│   │   │   └── client.go
│   │   ├── proto.go
│   │   └── server
│   │       ├── server.go
│   │       ├── server_test.go
│   │       └── template.go
│   ├── run
│   │   └── run.go
│   └── upgrade
│       └── upgrade.go
├── LICENSE
├── main.go
└── version.go

要实现需求,我们只需要关注baseproject两个目录即可。命令kratos new helloworld的整体流程如下:

main.go -> project.go -> new.go -> repo.go path.go mod.go

核心思路:

  • 通过标准库os/exec执行外部命令git clone下载模板项目。

  • 通过golang.org/x/mod/modfile库获取模板项目的module path,再遍历项目文件替换module path为project name 。

源码中夹杂了终端交互等提升用户体验的代码,阅读的时候可以略过不看,方便提取核心实现思路。

代码实现

目前而言,基于kratos源码,剔除交互部分代码,修改命令即可实现我们需要的功能。

  1. 创建项目gowebcli

├── go.mod
├── go.sum
├── internal
│   ├── base
│   │   ├── mod.go
│   │   ├── path.go
│   │   └── repo.go
│   └── project
│       ├── new.go
│       └── project.go
├── main.go
└── version.go

  1. 修改project.go

package project

import (
    "context"
    "errors"
    "fmt"
    "github.com/spf13/cobra"
    "os"
    "path"
    "time"
)

/**
  1. clone模板项目
  2. 修改所有文件的go module path
*/

var CmdNew = &cobra.Command{
    Use:   "new",
    Short: "Create a service template",
    Long:  "Create a service project using the repository template. Example: goweb new helloworld",
    Run:   run,
}

var (
    repoURL string
    branch  string
    timeout string
)

func init() {

    repoURL = "https://github.com/cyj19/go-web.git"
    timeout = "60s"
    CmdNew.Flags().StringVarP(&repoURL, "repo-url", "r", repoURL, "layout repo")
    CmdNew.Flags().StringVarP(&branch, "branch", "b", branch, "repo branch")
    CmdNew.Flags().StringVarP(&timeout, "timeout", "t", timeout, "time out")
}

func run(cmd *cobra.Command, args []string) {
    // 获取当前工作目录的根路径
    wd, err := os.Getwd()
    if err != nil {
        panic(err)
    }
    t, err := time.ParseDuration(timeout)
    if err != nil {
        panic(err)
    }
    ctx, cancel := context.WithTimeout(context.Background(), t)
    defer cancel()
    name := ""
    if len(args) == 0 {
        // 用户没有输入项目名称
        name = "go-web-layout"
    } else {
        name = args[0]
    }
    p := &Project{Name: path.Base(name), Path: name}
    // 通知项目创建结束
    done := make(chan error, 1)
    go func() {
        done <- p.New(ctx, wd, repoURL, branch)
        return
    }()

    select {
    case <-ctx.Done():
        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            fmt.Fprint(os.Stderr, "\033[31mERROR: project creation timed out\033[m\n")
            return
        }
        fmt.Fprintf(os.Stderr, "\033[31mERROR: failed to create project(%s)\033[m\n", ctx.Err().Error())
    case err = <-done:
        if err != nil {
            fmt.Fprintf(os.Stderr, "\033[31mERROR: Failed to create project(%s)\033[m\n", err.Error())
        }
    }
}

  1. 修改new.go

package project

import (
    "context"
    "fmt"
    "mycmd/internal/base"
    "os"
    "path"
)

type Project struct {
    Name string
    Path string
}

// New 根据远程仓库模板创建项目
func (p *Project) New(ctx context.Context, dir, layout, branch string) error {
    to := path.Join(dir, p.Name)
    if _, err := os.Stat(to); !os.IsNotExist(err) {
        // 路径dir下存在相同项目,处理重复问题
        // TODO
    }

    fmt.Printf("Create service %s, layout reps is %s, please wait a moment \n", p.Name, layout)

    repo := base.NewRepo(layout, branch)
    if err := repo.CopyTo(ctx, to, p.Path, []string{".git", ".github"}); err != nil {
        return err
    }

    return nil
}

  1. 修改mod.go

package base

import (
    "golang.org/x/mod/modfile"
    "os"
)

// ModulePath 从go.mod文件中返回模块路径
func ModulePath(filename string) (string, error) {
    modBytes, err := os.ReadFile(filename)
    if err != nil {
        return "", err
    }
    return modfile.ModulePath(modBytes), nil
}

  1. 修改path.go

package base

import (
    "bytes"
    "log"
    "os"
    "path"
)

func goWebHome() string {
    dir, err := os.UserHomeDir()
    if err != nil {
        log.Fatal(err)
    }
    home := path.Join(dir, ".goweb")
    if _, err := os.Stat(home); os.IsNotExist(err) {
        if err := os.MkdirAll(home, 0o700); err != nil {
            log.Fatal(err)
        }
    }
    return home
}

// 创建本地缓存目录
func goWebHomeWithDir(dir string) string {
    home := path.Join(goWebHome(), dir)
    if _, err := os.Stat(home); os.IsNotExist(err) {
        if err := os.MkdirAll(home, 0o700); err != nil {
            log.Fatal(err)
        }
    }
    return home
}

func copyFile(src, dst string, replaces []string) error {
    var err error
    var srcInfo os.FileInfo
    // 根据路径获取文件信息
    srcInfo, err = os.Stat(src)
    if err != nil {
        return err
    }
    // 读取源文件
    buf, err := os.ReadFile(src)
    if err != nil {
        return err
    }
    var old string
    // 替换文件中的模块名称,直接用replaces[index]存在数组越界风险,使用循环来避免
    for i, next := range replaces {
        if i%2 == 0 {
            old = next
            continue
        }
        buf = bytes.ReplaceAll(buf, []byte(old), []byte(next))
    }
    // 写入目标文件
    return os.WriteFile(dst, buf, srcInfo.Mode())
}

func copyDir(src, dst string, replaces, ignores []string) error {
    var err error
    var fds []os.DirEntry
    var srcInfo os.FileInfo

    srcInfo, err = os.Stat(src)
    if err != nil {
        return err
    }
    // 创建所有目录
    err = os.MkdirAll(dst, srcInfo.Mode())
    if err != nil {
        return err
    }
    // 获取目录信息
    fds, err = os.ReadDir(src)
    if err != nil {
        return err
    }
    // 遍历复制目录下的所有文件
    for _, fd := range fds {
        // 忽略指定文件
        if hasSets(fd.Name(), ignores) {
            continue
        }
        // 源文件路径
        srcFp := path.Join(src, fd.Name())
        // 目标文件路径
        dstFp := path.Join(dst, fd.Name())
        var e error
        if fd.IsDir() {
            // 递归复制目录
            e = copyDir(srcFp, dstFp, replaces, ignores)
        } else {
            // 复制文件
            e = copyFile(srcFp, dstFp, replaces)
        }
        if e != nil {
            return e
        }
    }

    return nil
}

func hasSets(name string, sets []string) bool {
    for _, ig := range sets {
        if name == ig {
            return true
        }
    }
    return false
}

  1. 修改repo.go (核心

package base

import (
    "context"
    "fmt"
    stdurl "net/url"
    "os"
    "os/exec"
    "path"
    "strings"
)

type Repo struct {
    url    string // 仓库地址
    home   string // 仓库目录
    branch string // 分支
}

func NewRepo(url, branch string) *Repo {
    return &Repo{
        url:    url,
        home:   goWebHomeWithDir("repo/" + repoDir(url)),
        branch: branch,
    }
}

// 根据仓库地址https://github.com/cyj19/xxx.git
// 或者git@github.com/cyj19/xxx.git 获取路径 github.com/cyj19
func repoDir(url string) string {
    if !strings.Contains(url, "//") {
        url = "//" + url
    }
    if strings.HasPrefix(url, "//git@") {
        url = "ssh:" + url
    } else if strings.HasPrefix(url, "//") {
        url = "https:" + url
    }
    u, err := stdurl.Parse(url)
    if err == nil {
        url = fmt.Sprintf("%s://%s%s", u.Scheme, u.Hostname(), u.Path)
    }
    var start int
    start = strings.Index(url, "//")
    if start == -1 {
        start = strings.Index(url, ":") + 1
    } else {
        start += 2
    }
    end := strings.LastIndex(url, "/")
    return url[start:end]
}

// Path 获取本地保存仓库的缓存路径
func (r *Repo) Path() string {
    start := strings.LastIndex(r.url, "/")
    end := strings.LastIndex(r.url, ".git")
    if end == -1 {
        end = len(r.url)
    }
    var branch string
    if r.branch == "" {
        branch = "@main"
    } else {
        branch = "@" + r.branch
    }
    // 根据仓库名和分支拼接出缓存路径
    return path.Join(r.home, r.url[start+1:end]+branch)
}

// Pull 从远程模板仓库获取最新代码
func (r *Repo) Pull(ctx context.Context) error {
    cmd := exec.CommandContext(ctx, "git", "symbolic-ref", "HEAD")
    cmd.Dir = r.Path()
    _, err := cmd.CombinedOutput()
    if err != nil {
        return nil
    }
    cmd = exec.CommandContext(ctx, "git", "pull")
    cmd.Dir = r.Path()
    out, err := cmd.CombinedOutput()
    fmt.Println(string(out))
    if err != nil {
        return err
    }
    return err
}

// Clone clone项目到缓存路径下
func (r *Repo) Clone(ctx context.Context) error {
    // 判断缓存路径下有无相同仓库,有则更新仓库
    if _, err := os.Stat(r.Path()); !os.IsNotExist(err) {
        return r.Pull(ctx)
    }
    var cmd *exec.Cmd
    if r.branch == "" {
        cmd = exec.CommandContext(ctx, "git", "clone", r.url, r.Path())
    } else {
        cmd = exec.CommandContext(ctx, "git", "clone", "-b", r.branch, r.url, r.Path())
    }
    out, err := cmd.CombinedOutput()
    fmt.Println(string(out))
    if err != nil {
        return err
    }
    return nil
}

func (r *Repo) CopyTo(ctx context.Context, to, modPath string, ignores []string) error {
    if err := r.Clone(ctx); err != nil {
        return err
    }

    mod, err := ModulePath(path.Join(r.Path(), "go.mod"))
    if err != nil {
        return err
    }
    return copyDir(r.Path(), to, []string{mod, modPath}, ignores)
}

踩坑

一如往常,我们使用go build命令编译出gowebcli文件,终端执行goweb new helloworld报错找不到命令。排除环境问题,发现是编译后的文件名和我们定义的命令名称不一致导致的错误。使用go build -o goweb .编译后成功执行,效果如下:

总结

以上我们通过阅读kratos源码,学会了如何制作简脚手架命令行工具,后续还可以通过github.com/AlecAivazis/survey库提升交互体验或新增其他命令功能。欢迎评论区留言讨论,感谢阅读!