本层判断标准

一个新文件,评审能不能只看调用链就说出它该放哪一层,而不需要讨论「像不像标准布局」?

答「否」,说明模板里有职责重叠的目录,或者有目录还没有文件。要删掉重叠的那一个,而不是为它写一条使用说明。

社区模板为什么不能直接抄

那份社区模板读起来很完整: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,处理函数跟着功能包走)。

一个功能包的完整形态:internal/notes/

测试文件与被测代码同目录,不搬到某个统一的 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
}

internal/notes/notes_test.go

同目录、同包。黑盒测试才用 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)
	}
}

能自动拦住的一条

能自动拦住的只有一类错误:未登记的顶层目录。它的症状是「有人新建了顶层目录,评审没看见」,一条脚本就能挡下。

scripts/check-layout.sh

白名单是布局决议的机器可读版本。新增目录时改这个文件,并在 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

Makefile(并进 check)

脚本已经排除隐藏目录(./*/ 不匹配以点开头的名字)。本机装了 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,因为目录在那儿,看起来就该有东西。

必须死守的四条

main.go 行数
≤ 120 行
超过说明装配之外还有逻辑,把它移回 internal/。
空目录
0 个
git 不跟踪空目录,提交里出现等于没有内容。
每次改动新增顶层目录
≤ 1 个
超过说明这次改动在做两件不相关的事,把它拆成两个合并请求。
不提交空目录判据:git ls-files --directory | grep '/$' 应为空,且每个被跟踪的目录下至少有一个文件。违反后果:后来的人不知道 SQL 该放 internal 还是 pkg,助手会在空目录里继续造层。
不新增 src、pkg、utils、common、handlers判据:make layout 通过。违反后果:导入方的包名与本地变量名冲突,得自己改名,这是 Google 风格决定里列出的代价。
让 main.go 只做装配判据:里面不出现 SQL、不出现业务判断,行数 ≤ 120。违反后果:这段逻辑测不了,只能起进程手工验证。
保证依赖方向单向判据:go list -deps ./internal/... | grep -E '/cmd(/|$)' 无输出。违反后果:包之间成环,无法单独测试功能包。

可以放一马的地方:120 行的上限对测试文件不适用,写测试时不用数它。pkg 的例外只有一种:仓库真的拆成两个模块、且有第二个仓库在导入它。在那之前不建,这个判断不需要提前做。