微信公众号:浮槎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
要实现需求,我们只需要关注base和project两个目录即可。命令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源码,剔除交互部分代码,修改命令即可实现我们需要的功能。
创建项目gowebcli
├── go.mod
├── go.sum
├── internal
│ ├── base
│ │ ├── mod.go
│ │ ├── path.go
│ │ └── repo.go
│ └── project
│ ├── new.go
│ └── project.go
├── main.go
└── version.go
修改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())
}
}
}
修改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
}
修改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
}
修改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
}
修改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库提升交互体验或新增其他命令功能。欢迎评论区留言讨论,感谢阅读!