一个新文件,评审能不能只看调用链就说出它该放哪一层,而不需要讨论「像不像标准布局」?
答「否」,说明模板里有职责重叠的目录,或者有目录还没有文件。要删掉重叠的那一个,而不是为它写一条使用说明。
那份社区模板读起来很完整:cmd 放程序,internal 放私有代码,pkg 放可被外部导入的库,api 放接口描述,configs 放配置模板。要看清它中文说明里自己写的两句:这不是官方标准;学习和很小的程序不必照搬,一个 main.go 就够;克隆之后删掉用不到的部分。
那次提交的同学把「完整」理解成「目录都要在」。官方文档给的是另一套:按项目类型分,不按完整目录树分。
| 项目形态 | 官方给的布局 | 本仓库属于哪一种 |
|---|---|---|
| 一个包 | go.mod、modname.go、modname_test.go 全在根目录 | 不是 |
| 一个很小的命令 | 只有一个 main.go,文件名只是习惯 | 不是 |
| 有对外包的仓库 | 可导入的包放在能被导入的位置 | 不是,本服务不导出包 |
| 服务:既有前端又有容器 | 命令集中在 cmd,实现放在 internal | 是。本页的模板按这一类写 |
哪天代码真要给另一个项目用,官方做法是拆成单独的模块,不是在原位建一个 pkg 对外承诺接口。本仓库现在没有外部导入方,所以不建 pkg。
抄走这份模板时只照搬已经有文件的目录。目录由文件产生,不预告将来。空目录在 git 里根本不存在,它只存在于克隆后的某个人的本机,然后被下一次提交补上。
api/、configs/、scripts/、deployments/ 不在里面——它们有了文件再建,建的时候同步改下一节检查脚本里的白名单。
. ├── cmd/ │ └── server/ │ └── main.go ├── internal/ │ ├── notes/ │ │ ├── notes.go │ │ └── notes_test.go │ ├── store/ │ └── session/ ├── frontend/ │ ├── src/ │ │ ├── pages/ │ │ └── api/ │ └── package.json ├── migrations/ ├── docs/ │ ├── decisions/ │ └── notes-check.md ├── .editorconfig ├── .env.example ├── Makefile ├── go.mod └── README.md
| 目录 | 职责 | 不该放什么 |
|---|---|---|
cmd/server | 进程入口:读配置、装配依赖、注册路由 | 业务规则、SQL、任何可以写测试的逻辑 |
internal/<功能> | 该功能的业务规则,测试文件与它同目录 | 跨功能共用的类型(放更下层的包);HTTP 状态码与请求体解析 |
internal/store | 唯一写 SQL 的地方 | 业务判断;HTTP 概念;拼字符串的查询 |
internal/session | 从会话取出用户与所属范围 | 从请求体读 scope_id 的兜底分支 |
frontend/src/pages | 页面 | 请求基地址、鉴权头 |
frontend/src/api | 唯一写基地址、请求头、错误映射的地方 | 业务规则;直接连数据库 |
migrations | 编号的建表与改表脚本,见数据 | 手改数据库之后补写的「同步脚本」;种子数据 |
docs/decisions | 决议 | 目录树的第二份副本 |
docs | 验收、留档、评审判据,见文档 | 字段手册、第二个 README |
不要建这几个:pkg(没有外部导入方)、src(Go Modules 已经在用,套一层只会让路径里多一个 src)、utils、common、helper(Google 的风格决定写明这类包会让导入方不得不改名)、handlers(路由注册在 cmd,处理函数跟着功能包走)。
测试文件与被测代码同目录,不搬到某个统一的 test/ 下。go test 会就近发现 *_test.go。包名是目录的最后一段。
package notes
type Note struct {
ID int64
Title string
ScopeID int64
}
func Visible(list []Note, scopeID int64) []Note {
out := make([]Note, 0)
for _, n := range list {
if n.ScopeID == scopeID {
out = append(out, n)
}
}
return out
}
同目录、同包。黑盒测试才用 package notes_test,那是 Go 对测试的约定,不是业务目录的划分方式。
package notes
import "testing"
func TestVisibleKeepsOwnScope(t *testing.T) {
list := []Note{
{ID: 1, Title: "a", ScopeID: 10},
{ID: 2, Title: "b", ScopeID: 20},
}
got := Visible(list, 10)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("got %#v", got)
}
}
能自动拦住的只有一类错误:未登记的顶层目录。它的症状是「有人新建了顶层目录,评审没看见」,一条脚本就能挡下。
白名单是布局决议的机器可读版本。新增目录时改这个文件,并在 docs/decisions/0035-layout.md 里写一句理由——这样「加目录」和「说明为什么加」就绑在同一次合并请求里。
#!/bin/sh
# 顶层目录白名单。新增目录时改这里,并在 docs/decisions/0035-layout.md 里写明理由。
allowed="cmd internal frontend migrations docs"
status=0
for d in ./*/; do
name=$(basename "$d")
case " $allowed " in
*" $name "*) ;;
*) echo "未登记的顶层目录: $name"; status=1 ;;
esac
done
exit $status
脚本已经排除隐藏目录(./*/ 不匹配以点开头的名字)。本机装了 node_modules 或 vendor 时把它们加进白名单,否则会误报。make check 的聚合入口只在质量门禁里定义一次,这一页只加子目标。
.PHONY: layout layout-check layout: sh scripts/check-layout.sh # 挂进 check:check: fmt-check lint type layout-check secret complexity test layout-check: layout ruff check --output-format=concise .
git 不跟踪空目录,所以这些目录只存在于某个人的本机。跑第一条找出它们,跑第二条列出所有顶层被跟踪文件,核对根目录有没有堆东西。
git status --porcelain --ignored | grep '^!!' | grep '/$' git ls-files | awk -F/ 'NF==1'
脚本管不到的地方:它只管顶层。internal/ 下新长出来的 utils 包,要靠评审的人看依赖方向(下一节第一条命令)。为 internal/ 再写一份白名单收益有限,它每周都在变。
评审的人看到新增文件,不要讨论「像不像标准布局」,只走调用链。
go list -deps ./internal/... | grep -E '/cmd(/|$)'。依赖必须是 cmd 引用 internal;有输出就是反向依赖,挡回。go build ./...。成环时把共用类型抽到已经存在的下层包,不为环新建 common。make layout。红了说明有人加了目录却没改白名单,按上一节处理。internal/<功能>;路由注册留在 cmd/server,注册处只调用功能包给出的处理函数;SQL 放进 internal/store 并带上会话里的 scope_id;前端页面放进现有页面目录,请求写进现有 API 客户端。
main.go;把 SQL 写在功能包里再顺手建一个 db 包;在空目录里先建 handlers 和 utils 再往里填。
可以放一马的地方:仓库里还没有集中写 SQL 的包时,可以先把 SQL 留在功能目录。第二次有别的功能也要访问数据库时再抽出去,这条叫「第二次才抽象」。时机由评审的人判断,判据是「同一段查询逻辑出现了第二份」。
app/ ├── utils/ │ ├── helpers.go ← 什么都往里放 │ └── common.go ├── handlers/ ← 空目录 ├── services/ ← 空目录 └── pkg/ ← 空目录
认它的办法:只看调用链,能不能说出这个文件该放哪一层。utils 不是一层,它只是「不知道放哪」的去处——任何文件都能进 utils,所以它说明不了任何依赖方向。三个空目录是照抄社区模板留下的,助手随后会往里造出一批 handlers 和 services,因为目录在那儿,看起来就该有东西。
internal/。git ls-files --directory | grep '/$' 应为空,且每个被跟踪的目录下至少有一个文件。违反后果:后来的人不知道 SQL 该放 internal 还是 pkg,助手会在空目录里继续造层。make layout 通过。违反后果:导入方的包名与本地变量名冲突,得自己改名,这是 Google 风格决定里列出的代价。go list -deps ./internal/... | grep -E '/cmd(/|$)' 无输出。违反后果:包之间成环,无法单独测试功能包。可以放一马的地方:120 行的上限对测试文件不适用,写测试时不用数它。pkg 的例外只有一种:仓库真的拆成两个模块、且有第二个仓库在导入它。在那之前不建,这个判断不需要提前做。