先承认一件事我在写 gogen 之前每次新建 Go 项目都靠手工。打开终端先mkdir cmd internal pkg再touch main.go go.mod Makefile README.md顺手补一个.gitignore有时候还要加 Dockerfile然后打开 go.mod 改 module 名称……这套操作看起来五分钟能搞定但重复几十次之后我开始怀疑人生这么基础的事情为什么不能一条命令解决gogen 就是为这个痛点写的。它是一个用 Go 写的命令行工具你只需要输入项目名和 module 路径它就能在几秒内生成一套符合标准布局的 Go 项目骨架包括目录结构、入口文件、Makefile、README、.gitignore甚至可以选择性地帮你执行go mod init、gofmt、git init。这篇文章既是我做这个工具的完整复盘也是写给所有想省去重复劳动的人的一份实操指南。适合三类人刚学 Go 想搞清楚标准项目结构的新手每次新建项目都要手动敲命令的老手想在团队里统一项目模板的负责人。1. 为什么要做 gogen这五分钟到底值不值1.1 手动新建 Go 项目骨架的真实流程很多人对“项目骨架”的理解停留在“有目录就行”实际上一个能直接开始写业务的 Go 服务通常需要这些东西cmd/可执行程序入口一个入口一个子目录服务类项目一般叫cmd/api里面放main.gointernal/私有代码外部包无法导入适合放handler、service、repo这类业务分层pkg/可以被外部复用的公共代码比如工具函数、请求校验configs/配置文件Makefile统一封装 build、run、test、lint 命令README.md、.gitignore可选Dockerfile、.golangci.yml、docs/手动创建时哪怕只搞最小集合也要敲十几条命令。我最初经常漏的就是internal和pkg的区别——internal下的包外人导不进去pkg下的是可以开放给外部引用的。这个规则如果没人提醒新人在项目刚开始时不觉得有问题等项目膨胀到几十个包再想调整目录层级成本就很高了。所以骨架的价值不只是“省几秒钟”它本质上是把项目结构约定固化下来。结构统一了代码风格才有统一的容器团队协作才会顺。1.2 手动操作到底浪费了什么先说时间。五分钟听起来不长但你要知道你在一个长期维护的 Go 项目上可能一年只新建三五个项目可如果你是做脚手架、做 POC、写技术分享 Demo一周能建两三个项目。这种高频重复劳动积累下来是非常可观的。再说心智负担。每次新建项目都要回忆一遍“标准目录到底是什么样”尤其是隔了几个月不建新项目很容易记混。我试过照着某个开源项目的目录结构手动复制结果复制到一半发现那个项目用了很多我用不到的目录又得停下来删。更头疼的是错误。手动go mod init的时候module 路径一旦写错整个项目的 import 前缀就全错了后面所有包的路径都要跟着改。如果你的项目名里带了连字符生成 package 名时还会踩坑——package my-project直接编译不过。这些错误一次踩坑就够让人烦了而它们本可以被一个工具挡在创建项目之前。还有团队一致性问题。A 把处理 HTTP 请求的代码放在handler/B 放在api/handler/C 又习惯叫handlers/。单个看都没问题但混在一起新人根本不知道新代码该放哪。统一骨架的直接收益就在这里它让“约定”变成“默认”。1.3 现成方案为什么让我不痛快可能有人会说用别人现成的脚手架不就行了我自己也试过几个方案最后都不太满意。go mod init只会生成一个 go.mod 文件目录还是要自己建等于没解决核心问题。第三方脚手架功能很全但往往伴随两个问题一是模板太重默认生成一堆你根本用不到的代码和依赖删起来比创建还累二是模板更新跟不上你的技术选型比如你想用 Go 1.22 的net/http新特性模板里还停留在老版本的第三方路由。我也考虑过直接用 GitHub 上的项目模板仓库但每次都要 clone、改模块名、删多余文件流程上和手动 mkdir 半斤八两。所以最终答案是自己写一个。不需要做得特别复杂只需要把“创建目录 写基础文件 初始化项目”这三件事固化下来同时让我能随时调整模板。这就是 gogen 的由来。2. gogen 的整体设计思路2.1 技术选型标准库优先写 gogen 这件事技术选型几乎没有悬念就用 Go 本身。原因很简单使用 gogen 的用户必然是 Go 开发者用 Go 写出来的工具天然拥有跨平台单二进制的优势go install一下就能用不依赖 Node、Python 这类运行时。在功能层面我刻意只使用标准库参数解析用flag因为 gogen 的命令就那么几个参数不值得引入 cobra模板渲染用text/template它是标准库能力很强的模板引擎文件操作和路径处理用os、path/filepath收尾命令用os/exec有人可能会质疑为什么不用 cobracobra 在子命令很多、参数复杂时确实香但 gogen 的场景是“一条命令生成项目”没有子命令用flag更轻、更可控还能保证go install时零额外依赖。这不是说 cobra 不好而是“够用就好”的原则在小工具里非常重要。2.2 gogen 源码目录设计写工具的人最容易犯的毛病是自己做脚手架结果自己的代码结构一塌糊涂。所以我给 gogen 本身也设计了一个清晰的分层核心代码如下gogen/ main.go internal/ config/ config.go # 参数定义与校验 generator/ generator.go # 核心生成逻辑 templates.go # 模板加载与渲染 templates/ cmd/api/main.go.tmpl internal/handler/hello.go.tmpl internal/service/ internal/repo/ pkg/utils/ configs/app.yaml.tmpl Makefile.tmpl README.md.tmpl .gitignore.tmpl Dockerfile.tmpl模板集中放在templates/目录里而不是散落在各个 Go 文件中这是刻意的设计。模板本身是文本文件单独管理方便单独修改、格式化、对比如果写死在代码里每次改模板都要重新编译而且模板多起来以后代码会变得很臃肿。我用的方案是//go:embed把模板目录嵌入到二进制里这样最终发布的 gogen 只有一个可执行文件不需要携带外部模板文件用户拿到就能用。2.3 核心工作流从参数到落盘再到收尾gogen 的执行流程可以概括为一条单向流水线解析参数项目名、module 路径、输出目录、是否生成 Dockerfile、是否执行 git init 等校验参数项目名不能为空module 路径不能非法批量创建目录树遍历模板目录逐个渲染文件并写入目标目录执行可选的收尾命令go mod init、代码格式化、git init打印最终生成的文件树设计这个流程时有几个顺序问题值得说。目录创建必须在文件生成之前这是常识但收尾命令为什么放在文件生成之后两个原因第一有些模板文件里包含 import 包的代码虽然 gofmt 不检查依赖是否存在但go build会检查所以必须先生成文件、再初始化 go.mod才能保证一条龙之后项目可以直接编译第二git init 放在最后可以确保第一次 commit 就能把整个骨架纳入版本控制中间不会穿插一些奇奇怪怪的临时文件。3. 核心代码实现拆解3.1 参数解析与校验gogen 的参数设计原则是“常用参数走 flag非常用参数走默认值”。我用一个 Config 结构体承载所有配置type Config struct { Name string // 项目名会作为目录名 Module string // go.mod 里的 module 路径 Docker bool // 是否生成 Dockerfile InitGit bool // 是否执行 git init Force bool // 已存在文件时是否覆盖 }参数解析部分用flag包就足够了。真正需要小心的不是解析是校验。项目名和 module 路径可以分开传这是很重要的设计项目名只是目录名可以带连字符、可以有大写module 路径是 Go 模块的唯一标识必须符合 go mod 的规则。if cfg.Name { return errors.New(项目名不能为空) } if strings.ContainsAny(cfg.Module, \t\n) { return errors.New(module 路径不能包含空白字符) } if cfg.Module { cfg.Module cfg.Name }注意我在默认情况下让Module等于Name这样即使用户只传一个项目名也能用。但对于正式项目我强烈建议显式传 module 路径比如github.com/you/project这样后续所有 import 路径都是完整的。3.2 批量创建目录树创建目录树本身很简单但有一个细节值得注意使用os.MkdirAll而不是os.Mkdir。MkdirAll的好处是它会自动创建所有父目录而且目标目录已存在时不会报错这让工具天然具备一定的幂等性。var dirs []string{ cmd/api, internal/handler, internal/service, internal/repo, pkg/utils, configs, } func createDirs(base string) error { for _, dir : range dirs { if err : os.MkdirAll(filepath.Join(base, dir), 0o755); err ! nil { return fmt.Errorf(创建目录 %s 失败: %w, dir, err) } } return nil }这里我把目录权限设为0755即所有者有全部权限其他用户可读可执行。这里不要为了“安全”去设置成0700因为生成的代码目录往往需要被多个工具读取比如 CI 构建、编辑器索引、容器打包权限太死会莫名其妙地碰壁。3.3 模板渲染与文件落盘这是整个工具的核心环节。我的做法是用fs.WalkDir遍历embed.FS中的模板目录对每个文件类型做不同处理以.tmpl结尾的文件作为 Go template 渲染输出时去掉.tmpl后缀其他文件原样复制为什么用embed.FS而不是直接读文件系统因为模板被打进二进制后gogen 就是一个独立可用的工具不管用户从哪个目录运行都能找到模板。渲染文件的函数核心逻辑如下func renderFile(fsys fs.FS, src, dest string, data Data, force bool) error { content, err : fs.ReadFile(fsys, src) if err ! nil { return err } if filepath.Ext(src) .tmpl { tmpl : template.New(filepath.Base(src)) tmpl tmpl.Delims([[, ]]) tmpl, err tmpl.Parse(string(content)) if err ! nil { return err } var buf bytes.Buffer if err : tmpl.Execute(buf, data); err ! nil { return err } content buf.Bytes() dest strings.TrimSuffix(dest, .tmpl) } if _, err : os.Stat(dest); err nil !force { fmt.Printf(跳过已存在文件: %s\n, dest) return nil } if err : os.MkdirAll(filepath.Dir(dest), 0o755); err ! nil { return err } return os.WriteFile(dest, content, 0o644) }这里有两个经验之谈。第一我用了Delims([[, ]])重新定义了模板分隔符为什么不直接用默认的{{ }}因为要生成的项目里可能包含 Go template、Helm Chart、Vue 组件等文件它们本身就大量使用{{ }}如果用默认分隔符模板解析器会把目标文件里的合法内容当成模板指令轻则渲染错乱重则直接报错。改成分隔符后不仅没冲突模板读起来也更清晰。第二已存在文件时默认跳过只有显式传--force才覆盖。这个防护非常关键我后面专门讲这个问题。3.4 可选的收尾命令模板文件落盘之后gogen 可以根据参数决定是否执行收尾命令。核心是一个runCommand函数func runCommand(dir, name string, args ...string) error { cmd : exec.Command(name, args...) cmd.Dir dir cmd.Stdout os.Stdout cmd.Stderr os.Stderr return cmd.Run() }然后按固定顺序调用if cfg.InitMod { runCommand(outDir, go, mod, init, cfg.Module) } runCommand(outDir, gofmt, -w, .) if cfg.InitGit { runCommand(outDir, git, init) }顺序上go mod init放在文件生成之后因为模板里如果有 import 语句必须等 go.mod 存在后go build才能通过gofmt放在go mod init之后是为了避免 gofmt 在缺少 go.mod 时误判某些导入路径git init永远放最后保证不会干扰前面的文件操作。有一点得提醒gofmt是 Go 自带命令不一定在所有开发环境的 PATH 里。稳妥的做法是在执行前先检查命令是否存在不存在就跳过并给出提示而不是让整个生成流程报错中断。3.5 一次完整的生成现场写再多设计不如看一次实际运行效果。假设我要新建一个博客后端服务$ gogen -name myblog -module github.com/me/myblog -docker -git生成完成后gogen 会打印出类似这样的目录树myblog/ cmd/ api/ main.go internal/ handler/ hello.go service/ hello_service.go repo/ pkg/ utils/ configs/ app.yaml Makefile README.md .gitignore Dockerfile go.mod整个过程不到一秒。如果你手动敲的话哪怕最快的键盘手也得一两分钟而且未必能保证目录一个不漏。4. 从能用变好用我踩过的坑和对应的改法4.1 模板语法冲突不是所有文件都适合塞进模板第一次写 gogen 时我把所有模板文件都统一用{{ }}分隔符结果在生成一个包含 Go 标准库text/template用法示例的项目时模板解析直接报错了。原因很直白模板文件里包含的{{.Title}}这段内容被外层模板引擎当成自己的指令解析了。这个问题有几种解法。最简单的是修改分隔符也就是我在 3.3 里展示的Delims([[, ]])但只要目标文件里也用到[[ ]]依然有概率冲突。另一种思路是引入“raw 文件”概念在模板目录里约定只有.tmpl后缀的文件才经过渲染其他文件一律原样复制。这样像 Dockerfile、.gitignore 这类不太需要变量替换的文件可以直接保留原样不参与模板处理。我的最终方案是两种办法结合模板目录里既有.tmpl文件也有普通文件.tmpl文件内部统一使用[[ ]]分隔符。这套组合拳让冲突概率降到了极低。4.2 项目名和模块名要“清洗”这个坑几乎每个写 Go 脚手架的人都会遇到。用户传进来的项目名五花八门有叫MyBlog的有叫my-blog的还有带空格的。这些名字作为目录名没问题但如果你直接把它当成 package 名写进模板必然踩坑。Go 的 package 名字符集有限不能包含连字符而且按惯例全小写。所以我在 Data 数据模型里增加了一个派生字段PackageNamepackageName : strings.ToLower(cfg.Name) // 把非法字符替换为下划线多个连续非法字符合并为一个 reg : regexp.MustCompile([^a-z0-9_]) packageName reg.ReplaceAllString(packageName, _) // 去掉首尾下划线 packageName strings.Trim(packageName, _)比如my-blog会变成my_blogMyBlog会变成myblog。在模板里所有 package 声明处都用[[.PackageName]]而不是直接用[[.Name]]这样生成出来一定是可以编译的。module 路径也同理如果用户在 Windows 上误传了带反斜杠的路径我会在校验时把它转成正斜杠。4.3 Windows 兼容性路径和换行都能让人翻车写 gogen 时我主要在 macOS 和 Linux 上验证第一次放到 Windows 上跑就收到了问题反馈。根源是路径分隔符。Go 的filepath.Join在 Windows 上会生成反斜杠路径这本身没问题但如果你在模板里生成的 import 路径也用了反斜杠那 Go 编译器就会不认。处理办法是分清楚两种路径文件系统路径统一用filepath.Join代码里出现的 import 路径统一用/。我在模板数据里设置了一个ImportPrefix字段专门存放用/连接的导入前缀模板里所有 import 都基于它拼接。还有一个隐藏的坑是换行符。在 Windows 上如果模板文件在写入时被转换成 CRLF生成的 Makefile 和 Dockerfile 在容器里执行时可能直接报错比如出现/bin/sh: not found这类诡异的提示。我的解决方案是写入文件时统一使用os.WriteFile写字节序列保证\n就是\n同时在生成的.gitattributes里写上* textauto eollf防止后续协作编辑时被再次转换。4.4 幂等性和覆盖策略别把已有代码搞没工具做得越顺就越要小心它被误用。gogen 默认是往新目录里生成但用户完全可能把输出目录指定到已有项目上。如果无脑覆盖后果不堪设想。我的策略是三层防护目标目录里已存在的文件默认跳过并打印提示不会覆盖只有显式传--force才覆盖生成完成后会在项目根目录写入一个隐藏标记文件.gogen-marker内容是 gogen 的版本号第三点看起来不起眼但它非常有用。后续如果要做“更新骨架”“合并模板变更”这类功能就可以通过检查这个标记文件来确认目标目录是不是由 gogen 创建的而不是把别人的项目误改了。这个思路我强烈推荐给所有写代码生成器的人相当于给自己的工具加了一个“领地识别”机制。4.5 生成完记得格式化模板渲染出来的 Go 代码缩进和空行通常不规范。template 中的条件分支语句在渲染后可能留下多余空行比如package main func main() { fmt.Println(hello) fmt.Println(world) }这种代码虽然能编译但打开编辑器的第一眼体验很差。所以 gogen 在流程最后会统一执行gofmt -w .把生成的 Go 文件全部格式化。如果用户安装了 goimports我也会尝试执行它它会自动整理 import 分组、补全缺失的导入路径。这个收尾动作成本极低但对使用体验的提升是立竿见影的。生成的代码看起来像人写的、不像机器生成的用户对工具的信心会高一大截。5. 常见问题速查与使用建议5.1 常见问题速查表我整理了 gogen 使用过程中最常见的几类问题对应排查思路如下现象可能原因解决办法命令行找不到 gogengo install 后 PATH 未包含 GOPATH/bin确认go env GOPATH后把$GOPATH/bin加入 PATH执行which gogen项目名带连字符导致编译失败package 名直接用了项目名改用清洗后的 PackageName 字段生成 package 声明模板解析报错模板文件里有[[ ]]或{{ }}冲突检查模板文件是否误用了分隔符非模板文件不要加 .tmpl 后缀生成一半失败目录残留网络、磁盘或模板错误先删除半成品目录修复后重跑下次生成前先检查校验步骤Windows 下 Makefile 报错换行符变成 CRLF在 .gitattributes 中固定eollf重新生成文件go mod init 失败module 路径含空格或非法字符清洗 module 参数建议使用完整的仓库路径我不想生成 Dockerfile默认参数问题使用-dockerfalse显式关闭5.2 几条实在的使用建议生成后第一时间执行go build ./...确认骨架本身没问题再开始写业务代码。这个习惯能帮你区分“项目问题”和“骨架问题”。第一版生成完毕后立刻做一次 git commit。后续写业务代码时git diff 永远只显示你的业务提交不会混入骨架的调整。如果团队要统一模板建议 gogen 的仓库单独维护模板变更走 PR 评审。不要让每个人在本地私有地改模板否则时间一长团队的项目骨架又会变得五花八门。go.mod 里的 Go 版本号不要写死一个具体版本。在模板里用[[.GoVersion]]这类变量gogen 自动读取本机go env GOVERSION填进去避免团队机器版本不一致产生困扰。5.3 如果你只想做一个最小版本如果你读完也想写一个类似的工具我的建议是别一开始就追求完整功能。先把最小闭环跑通——用 flag 解析项目名和 module 路径固定创建几个核心目录写死两三个模板文件生成后调用go mod init。整个核心代码控制在两三百行以内。跑通之后你再根据自己日常项目的实际需求逐步增加模板文件、Dockerfile、git init 这些选项。这样做的好处是你每次新增一个功能都能清晰地看到它对整体流程的影响而不是一开始把整个系统设计得过于抽象结果连自己都用不顺手。工具是为自己的效率服务的不是拿来炫技的。6. 让你的 gogen 变成“团队基建”的扩展方向6.1 多模板与模板仓库化gogen 目前内置了一套通用模板但真实场景里Web API、gRPC 服务、CLI 工具、纯库项目需要的骨架差异很大。所以一个非常自然的扩展是支持多套模板。比如通过-template web-api指定生成 HTTP 服务骨架通过-template cli生成命令行工具骨架。进一步的想法是把模板做成独立仓库。用户可以通过-template-url https://github.com/team/go-template拉取远程模板gogen 把模板克隆到本地缓存目录后再走同样的渲染流程。这样做最大的价值是团队可以把模板当作代码库维护版本涨跌、变更记录一目了然。相比每个人在本地默默改模板这显然更可控。6.2 交互式问答与配置持久化纯命令行参数虽然简洁但可发现性弱。用户不跑--help就不知道有哪些参数。一个折中的方案是增加交互模式如果不传项目名gogen 启动后通过终端逐个提问项目名是什么、module 路径怎么写、要不要 Dockerfile。这个功能用标准库的bufio就能实现不需要引入 survey 之类的依赖。更进一步可以把用户最后一次填写的配置保存到~/.gogen/config.json下次运行时作为默认值展示用户直接回车即可确认。这个细节很能提升工具的温度因为它让重复使用时的手感接近“顺手填一下”。6.3 与编辑器、CI、AI 工具联动gogen 不该只是一个孤立的命令行工具它能融入日常开发链路的地方非常多。在编辑器里可以在 VS Code 的 tasks.json 或 GoLand 的 External Tools 里配置 gogen 命令需要新建服务时直接在 IDE 里触发不用切到终端。在 CI 里可以做人肉触发或机器人自动执行当某个新仓库被创建时自动跑一遍 gogen把骨架作为第一个 commit后续所有代码都构建在这个统一的基线上。还有一个现在很流行的用法先把骨架生成好了再让 AI 编程工具在这个骨架内填充业务代码。因为很多 AI 工具在生成多文件项目时对目录结构的把握是随机的你可能让它写一个 handler它顺手就建了一个根本不存在于当前工程的路径。先有一个标准的、可编译的工程骨架再让 AI 在约束范围内做事能省掉很多来回纠错的成本。最后分享一个我一直在用的小技巧。gogen 装好之后我在 shell 配置里加了一个简单的函数newgo() { gogen -name $1 -module $2 -docker -git cd $1 }这样每次新建项目只需要一条命令就能直接进入新项目的目录$ newgo myblog github.com/me/myblog工具这东西最理想的状态就是用到最后感觉不到它存在。gogen 对我而言就是这样的存在——它把新建项目时那些毫无技术含量又不得不做的破事全部收走了让我能把注意力留给真正的问题。你也可以试试从这个最小的问题出发给自己写一个顺手的小工具。
阅读完成 · 觉得有帮助?