Go 服务用 AI 写代码:工具链白送了半套 harness,你只是没拧紧
Posted on 四 11 6月 2026 in Tech
| Abstract | Go 服务用 AI 写代码:工具链白送了半套 harness,你只是没拧紧 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v0.1 |
| Updated | 2026-06-11 |
| License | CC-BY-NC-ND 4.0 |
Go 服务用 AI 写代码:工具链白送了半套 harness,你只是没拧紧
短大纲
- 同样用 AI,Go 后端比 Spring Boot 那套好伺候——因为 Go 工具链白送了半套 harness
- 但"白送"不等于"拧紧":多数团队连
go vet、-race、-cover都没接进闸门 - AI 在 Go 里真正爱翻的三块:吞错误、并发竞态、幻觉依赖
- 第一步:把工具链白送的那半套拧到 CI 里,一条红就不许合
- 第二步:补上缺的半套——AGENTS.md 上下文、internal 边界、depguard、表驱动测试、godog 行为契约
- 一个从止血到治本的渐进顺序 + 可抄的配置、行动清单、检查清单
一、为什么 Go 服务更好伺候
我在上一篇《传统 Java 项目用 AI 写代码总翻车?先把 harness 修好》里有一句话:Spring Boot + MyBatis + MySQL + Kafka 这套,比一个小巧的 Go 服务难伺候得多。有读者追问:那 Go 项目的 harness 到底怎么修?这篇就来还这个债。
先抛个观点,可能有点反直觉:Go 项目修 harness,比 Java 容易,因为 Go 的工具链已经白送了半套。
gofmt 让格式没得吵——AI 再怎么发挥,gofmt -l 一过,风格全归一,省掉了 Java 世界里 Checkstyle/Spotless 那一整套配置仗。go vet 白送基础静态检查,go test 是语言内置而不是第三方框架,go test -race 直接给你一个竞态检测器,go test -cover 顺手就出覆盖率。go build ./... 编不过就是编不过——这本身就是拦截"幻觉 API"的第一道墙。
这些在 Java 里都得额外攒:JUnit、JaCoCo、各种 lint 插件、还得在 Maven/Gradle 里拼半天。Go 把它们塞进了一个命令行工具里,开箱即用。
但白送的,不等于拧紧的。 我见过太多 Go 项目,工具链躺在那儿,CI 里却只跑了一句 go test ./...——-race 没开、覆盖率不卡、go vet 看心情、golangci-lint 没接。等于发动机和护栏都白送给你了,你却只拧上了一颗螺丝就上路。AI 一上来,那些没拧紧的地方就开始漏。
所以这篇的主线只有两句:先把白送的那半套拧紧,再补上缺的那半套。
harness 是什么,这个系列前两篇已经讲透了,这里只用一句话复述:harness 就是你给 AI 准备的工作环境和约束系统,让它即使失忆、看不到全局、不为线上故障负责,也能干出靠谱的活。 想看完整定义和 Java 落地,去翻那篇。
二、AI 在 Go 里真正爱翻的三块
Java 的难,难在隐性知识密度高(事务代理、XML 散落、Kafka 幂等)。Go 不一样,Go 的代码大多"上下文自足",隐性规矩少。但这不代表 AI 在 Go 里就不翻车,它只是翻在别处。按我的观察,AI 写 Go 最爱翻的是这三块——而且每一块,Go 工具链刚好都有对症的药。
1. 吞错误:Go 的命根子,AI 的盲区
Go 没有异常,错误就是个返回值,全靠 if err != nil 的纪律撑着。AI 偏偏最爱在这儿偷懒:
// AI 经常写出来的三种"吞错误"
data, _ := json.Marshal(req) // 忽略 err
resp, err := http.Get(url)
defer resp.Body.Close() // resp 可能是 nil,err 没判先 defer
if err != nil { panic(err) } // 在库代码里 panic,等于埋雷
更隐蔽的是丢掉错误链:本该 fmt.Errorf("query order: %w", err) 把底层错误包进去,AI 写成 errors.New("query failed"),上游再也 errors.Is 不到根因,排查时一脸懵。
2. 并发竞态:跑起来都对,压上去就崩
Go 把并发做得太顺手了,go func(){} 一写就是一个 goroutine。AI 也学得很快,但它对"共享内存要加锁""goroutine 要有退出路径""context 要一路传下去"这些没有切肤之痛:
// 典型翻车:闭包里并发写同一个 map
results := map[string]int{}
for _, id := range ids {
go func(id string) {
results[id] = fetch(id) // data race!map 并发写直接 panic 或脏数据
}(id)
}
// 还少了 WaitGroup,主协程根本没等它们完成
这种 bug 最毒的地方在于:单测跑一遍是绿的,本地点几下也正常,一上线在并发压力下才崩。人眼 review 经常看不出来。
3. 幻觉依赖:编一个看起来很像的包
AI 训练数据里见过太多库,于是它会自信地 import 一个根本不在你 go.mod 里的包,或者调一个标准库里压根不存在的函数(strings.ReverseString?没有这个东西)。再或者,为了实现一个本可以用标准库三行搞定的功能,它顺手给你引入一个庞大的第三方依赖。
Go 社区有很强的"能用标准库就别加依赖"的文化,AI 不懂这个分寸,容易把你的 go.mod 喂胖。
顺带一块:破坏 internal 边界
Go 用 internal/ 目录和包结构来表达边界。AI 改大功能时,最容易随手 import 了不该碰的内部包,把本该解耦的模块连成一团。
把这四点列张表,对应的药也就清楚了:
| AI 在 Go 爱翻的 | 后果 | 对症的工具 |
|---|---|---|
| 吞错误 / 丢错误链 | 故障无声,排查困难 | errcheck、errorlint、go vet |
| 并发竞态 / goroutine 泄漏 | 线上偶发,难复现 | go test -race、go vet |
| 幻觉依赖 / 幻觉 API | 编不过,或 go.mod 变胖 | go build ./...、depguard、code review |
| 破坏 internal 边界 | 模块缠绕、爆炸半径大 | depguard、go-arch-lint |
你会发现:前两块 Go 工具链白送的就能治,后两块需要你额外补。 这正好对应下面两节。
三、第一步:把白送的那半套拧紧
别急着上花活。修 Go 项目的 harness,性价比最高的第一步,是把工具链白送的东西接进 CI、变成会拦人的红灯。不接闸门,它们就只是躺在文档里的好意。
1. gofmt:格式不许吵架
gofmt 不是"建议格式化",是"必须格式化"。在 CI 里加一句,有未格式化的文件就失败:
# 列出所有未按 gofmt 格式化的文件;非空就让 CI 失败
test -z "$(gofmt -l .)"
好处不止是好看:格式统一后,AI 改动的 diff 才干净,review 时一眼能看出它到底改了什么逻辑,而不是被一堆缩进噪音淹没。
2. go vet:白送的基础静态检查
go vet ./... 能抓出一票低级错误:Printf 占位符对不上、struct tag 写错、复制了带锁的结构体、明显的不可达代码。这是零配置的,AI 写出来的低级毛病很多能在这儿拦住。
3. go build ./...:治幻觉 API 和幻觉依赖
听起来废话,但很多 CI 只跑测试不单独 build,而测试可能没覆盖到的文件里,AI 编的那个不存在的函数就溜过去了。go build ./... 强制全量编译——幻觉 API 在这一步必死。再配一句 go mod tidy 后看 git diff 有没有变化,能发现 AI 偷偷加的依赖。
4. go test -race:白送的竞态检测器
这是 Go 最被低估的宝贝。把测试用 -race 跑,竞态检测器会在运行时盯着内存访问,逮到并发读写同一块内存就报警,精确到哪个 goroutine、哪一行:
go test -race ./...
对前面说的"并发翻车",这几乎是唯一可靠的自动防线。代价是测试慢几倍——但 CI 里慢这几倍,换来线上不被偶发竞态半夜叫醒,太值了。前提是:你得有能触发并发路径的测试,否则 -race 也无从发现(这就接到了第四节的表驱动测试)。
5. go test -cover:覆盖率卡一条线
go test -race -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1 # 看 total 覆盖率
覆盖率不是越高越好,但"低于某条线就构建失败"能逼住底线。具体卡多少看项目,核心业务包可以单独卡高一点。
这一步的本质,是把"工具链能做但没人接"的东西,全部接进一道 CI 闸门。 一个最小可用的 Makefile 目标长这样:
.PHONY: verify
verify:
test -z "$$(gofmt -l .)" # 格式
go vet ./... # 基础静态
go build ./... # 幻觉 API/依赖必死
go test -race -cover ./... # 竞态 + 覆盖率
光这一个 make verify 接进 CI,就已经把 AI 在 Go 里最爱翻的"吞错误的一部分 + 并发竞态 + 幻觉 API"挡掉一大半了。而它几乎是零成本的——这些工具你早就装了,只是没拧紧。
四、第二步:补上缺的那半套
白送的拧紧之后,剩下的短板得自己补:上下文、边界、规约、行为契约,以及一个能管住错误处理 / 依赖 / 安全的 meta-linter。沿用这个系列的"拼图"视角,挨个落到 Go 里。
1. PKB / AGENTS.md:把 Go 的约定写给 AI 看
Go 的隐性知识比 Java 少,但不是没有:错误怎么包、context 怎么传、并发怎么收口、依赖什么时候才允许加——这些都是你团队的"不成文规矩"。在仓库根目录放一个 AGENTS.md(或 CLAUDE.md),别写正确的废话,专写踩过坑的规矩:
# AGENTS.md — order-service (Go)
## 系统地图
- cmd/server 程序入口,只做装配,不写业务
- internal/order 订单域:下单、查询
- internal/refund 退款域:本次要改的就是这块
- internal/platform 基础设施:db、kafka、http client 封装
## 必须遵守的约定(踩过坑的)
1. 错误一律 fmt.Errorf("...: %w", err) 往上包,禁止吞错(不许 _ = err)。
2. 库代码禁止 panic;panic 只允许出现在 main/初始化的"起不来就该死"场景。
3. 所有对外调用(DB/HTTP/Kafka)第一个参数必须是 context.Context,并真正传下去。
4. 并发写共享状态必须加锁或用 channel;每个 goroutine 必须有明确的退出路径。
5. 金额用 int64 以"分"为单位,禁止 float64。
6. 能用标准库就别加依赖;加任何第三方包要在 PR 里说明理由。
7. 跨域只能 import 对方的接口包,禁止 import 对方的 internal 实现。
## 标准样板
改写链路前先读 internal/refund/service.go 的 Refund(),照这个结构来:
入参带 ctx → 校验 → 业务 → 落库 → 发消息,每步错误都 %w 包好。
三件事最值得写:系统地图(哪个目录干什么)、踩过坑的约定(错误/panic/context/并发/金额/依赖)、一个可抄的样板函数。AI 不是检索引擎,改退款时直接把 internal/refund/ 下相关文件拍给它,比让它"自己去仓库找"靠谱得多。
2. 边界:用 internal/ + depguard 把分层焊死
Go 天生有个好东西:internal/ 目录下的包,只能被其父目录及其子树 import,编译器级别拦截外部依赖。先利用好它,按域分包,让目录结构本身就是边界:
order-service/
├── cmd/server/ # 入口
└── internal/
├── order/ # 订单域
├── refund/ # 退款域(本次要改)
│ ├── api/ # HTTP handler
│ ├── service/ # 业务
│ └── store/ # DB 访问
└── fulfillment/ # 履约域:退款只能通过接口通知它
但 internal/ 只能挡"包外访问包内",挡不住"同一个 module 内部,退款域偷偷 import 履约域的实现"。这时候用 depguard(golangci-lint 内置的一个 linter)把跨域、跨层依赖写成会失败的规则:
# .golangci.yml 片段(depguard 配置在不同版本语法略有差异,按你的版本微调)
linters-settings:
depguard:
rules:
refund-domain:
files:
- "**/internal/refund/**"
deny:
- pkg: "order-service/internal/fulfillment/store"
desc: "退款域不许直连履约实现,只能走 fulfillment 的接口"
service-layer:
files:
- "**/internal/*/service/**"
deny:
- pkg: "net/http"
desc: "service 层不许碰 http,HTTP 细节留在 api 层"
这相当于 Java 世界里 ArchUnit 干的活(参见那篇)。如果想要更完整的分层 / 依赖方向校验,可以上专门的 go-arch-lint,用一个 yaml 声明组件和允许的依赖方向:
# .go-arch-lint.yml(示意,按工具版本调整)
version: 3
workdir: internal
components:
api: { in: "*/api" }
service: { in: "*/service" }
store: { in: "*/store" }
deps:
api: { mayDependOn: [service] }
service: { mayDependOn: [store] }
store: { mayDependOn: [] } # store 不许反向依赖
跑 go-arch-lint check,反向调用、跨层依赖立刻红。边界一旦被测试 / linter 焊死,AI 即使犯错,爆炸半径也被关在一个房间里。
3. SDD + 表驱动测试:把大功能拆成"测试即题面"
Go 写大功能也会翻车,原因和 Java 一样:你给的是个大需求,AI 只能边猜边写。解法不变——先写一页规约,拆成小任务,再让 AI 逐个实现,每个任务配一个测试。
Go 的杀手锏是表驱动测试(table-driven test),它天然适合把"验收标准"一条条钉死。与其用自然语言反复跟 AI 描述边界 case,不如把它们摆成一张表,这就是给 AI 的最精确题面:
func TestRefund(t *testing.T) {
tests := []struct {
name string
order Order
amount int64
wantErr error
}{
{"全额退款成功", paidOrder(100_00), 100_00, nil},
{"未支付订单不可退", unpaidOrder(), 100_00, ErrNotRefundable},
{"退款额超过可退额", paidOrder(100_00), 200_00, ErrAmountExceeded},
{"重复退款应幂等", refundedOrder(100_00), 100_00, ErrAlreadyRefunded},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newRefundService(tt.order)
err := svc.Refund(context.Background(), tt.amount)
if !errors.Is(err, tt.wantErr) { // 注意 errors.Is,配合 %w 才管用
t.Fatalf("Refund() err = %v, want %v", err, tt.wantErr)
}
})
}
}
HTTP handler 用标准库 net/http/httptest 测,不用起真服务:
func TestRefundHandler(t *testing.T) {
body := strings.NewReader(`{"amount":10000}`)
req := httptest.NewRequest(http.MethodPost, "/orders/1001/refund", body)
rec := httptest.NewRecorder()
newRouter(stubRefundService{}).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
把这些测试先写好、丢给 AI:"让这些测试在 go test -race 下变绿。"它就有了客观的成功标准,不再自我感觉良好;改坏了别处,对应的红灯立刻亮。对老代码,则反过来用:让 AI 重构前,先给现有逻辑补一层"特征测试"(characterization test),把当前行为固化下来,重构后只要还绿,就说明行为没变。护栏跟着战线走,你让 AI 动哪块,就先给哪块织网。
4. BDD:给最怕错的业务流写 godog 契约
技术正确靠表驱动测试,业务正确有时还得一层。Go 里有 godog(Cucumber 的官方 Go 实现),用 Given/When/Then 把关键业务流写成几乎是大白话的场景,特别适合状态机、消息幂等这类"边界一堆、最容易扯皮"的地方:
# refund.feature
场景: 重复收到退款消息时不能退两次
假如 订单 1001 已经退款成功
当 系统再次收到订单 1001 的退款消息
那么 不应再发起一次退款
并且 账户余额保持不变
step 定义写一次,之后场景复用。这种场景对 AI 极友好:它把重复消息、乱序、超时这些容易被漏掉的边界显式摆上台面,AI 照着实现即可,不用猜业务语义。别全项目铺开——只给最怕错、最难讲清的那几条核心流写就够本。
5. 度量闸门:golangci-lint 一把梭
最后一块拼图,是把上面这些 + Go 特有的坑,统统接进一个会拦人的 meta-linter。Go 生态里这件事的标准答案就是 golangci-lint——它把几十个 linter 打包成一个命令,跑得快、配置集中。挑对症的几个开起来:
# .golangci.yml(最小可用版,linter 名称按你的版本核对)
run:
timeout: 5m
linters:
enable:
- errcheck # 没处理的 error 直接报错 —— 治"吞错误"
- errorlint # 强制正确用 %w / errors.Is
- govet # 基础静态
- staticcheck # 强大的静态分析
- gosec # 安全扫描
- bodyclose # http resp.Body 忘了 Close
- sqlclosecheck # sql.Rows 忘了 Close
- noctx # 发请求没带 context
- contextcheck # context 没一路传下去
- depguard # 依赖 / 分层约束(见上)
- revive # 风格 / 命名约定
这里面每一个,几乎都精准对着 AI 在 Go 里的某个毛病:errcheck/errorlint 治吞错误和丢错误链,bodyclose/sqlclosecheck 治资源泄漏,noctx/contextcheck 治 context 断链,depguard 治越界依赖,gosec 治安全坑。把脑子里的约定,变成 AI 绕不过去的红灯。
老项目别慌一次性全红:golangci-lint 支持 --new-from-rev,只检查相对某个 commit 的新增改动——老债先冻住,新债一分不许欠。和 ArchUnit 的 FreezingArchRule 是同一个思路。
五、把闸门串起来:一份能抄的 CI
前面所有努力,最后都要落到一道任意一条红就不许合并的闸门上。否则约定再多,AI(和赶工期的人)总能绕过去偷偷上线。
诀窍是:先把所有检查收敛进一个 Makefile,本地和 CI 共用同一套命令。 这样"我本地是绿的,怎么 CI 红了"这类扯皮就没了。一份能直接抄的 Makefile:
# Makefile —— 把前面的方法串成可执行的闸门
COVER_MIN ?= 70 # 覆盖率阈值
NEW_FROM ?= origin/main # 老项目增量 lint 的基线
.DEFAULT_GOAL := verify
# ---- 第一步:拧紧白送的那半套 ----
fmt: ## 检查 gofmt,有未格式化文件就失败
@u="$$(gofmt -l .)"; [ -z "$$u" ] || { echo "未格式化: $$u"; exit 1; }
vet: ## 基础静态检查
go vet ./...
build: ## 全量编译,幻觉 API/缺失依赖必死
go build ./...
tidy-check: ## go.mod/go.sum 不许被偷偷改,揪出多余依赖
go mod tidy
@git diff --exit-code go.mod go.sum
test: ## 竞态检测 + 生成覆盖率
go test -race -coverprofile=cover.out -covermode=atomic ./...
cover: test ## 覆盖率低于阈值就失败
@t=$$(go tool cover -func=cover.out | awk '/^total:/{gsub(/%/,"",$$3);print $$3}'); \
echo "coverage: $$t%"; \
awk -v t="$$t" -v m="$(COVER_MIN)" 'BEGIN{exit (t+0<m+0)}' || \
{ echo "覆盖率 $$t% 低于 $(COVER_MIN)%"; exit 1; }
# ---- 第二步:补缺的那半套 ----
lint: ## golangci-lint 总闸门
golangci-lint run
lint-new: ## 老项目只拦新增违规
golangci-lint run --new-from-rev=$(NEW_FROM)
arch: ## 架构/分层边界校验
go run github.com/fe3dback/go-arch-lint@latest check
# ---- 聚合闸门:CI 直接调它 ----
verify: fmt vet build tidy-check lint arch cover ## 合并前过一遍
@echo "all checks passed"
verify-new: fmt vet build tidy-check lint-new arch cover ## 老项目专用
.PHONY: fmt vet build tidy-check test cover lint lint-new arch verify verify-new
平时本地随手跑 make test / make lint,提交前跑 make verify;老项目先用 make verify-new,lint 只拦新增、老债慢慢还。可调参数:make verify COVER_MIN=80。
CI 这边就薄薄一层,把环境装好然后调 make verify:
# .github/workflows/verify.yml
name: verify
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- run: make verify
GitLab CI 同理,一个 verify job 里跑 make verify 即可。如果你不想用 make 当中间层,也可以把每步拆成独立的 CI step,本质一样——一份完全展开的 GitHub Actions 长这样:
# .github/workflows/verify.yml
name: verify
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- name: 格式
run: test -z "$(gofmt -l .)"
- name: 依赖没被偷偷改
run: go mod tidy && git diff --exit-code go.mod go.sum
- name: 编译(幻觉 API 必死)
run: go build ./...
- name: 静态 + 安全 + 依赖约束
uses: golangci/golangci-lint-action@v6
- name: 竞态 + 覆盖率
run: go test -race -cover ./...
- name: 架构边界
run: go run github.com/fe3dback/go-arch-lint@latest check
关键不在用 make 还是裸 CI、用 GitHub 还是 GitLab,而在这几道检查全绿才允许合并——这是整套 harness 真正生效的开关。
六、别一口气全上:渐进落地顺序
看到一堆工具就想全装,是最容易劝退团队的做法。给一个从止血到治本的顺序,每一步都该让 AI 干活的成功率肉眼可见地往上走:
- 拧紧白送的(半天,ROI 最高):
gofmt -l+go vet+go build ./...+go test -race -cover接进 CI。先把这半套白嫖到位。 - golangci-lint 上场:先开
errcheck、errorlint、bodyclose、gosec这几个最对症的,老项目用--new-from-rev只拦新增。 - 写
AGENTS.md:系统地图 + 错误/context/并发/依赖的约定 + 一个样板函数。 - 理
internal/边界:按域分包,用depguard把跨域、跨层依赖焊死。 - SDD + 表驱动测试:从此大功能先写规约、拆任务,每个任务配表驱动测试再交给 AI。
- godog 补关键业务流:只给最怕错的那几条(消息幂等、状态机)写 Given/When/Then。
前两步是纯白嫖,今天就能做完;后四步按项目节奏补。
总结
回到开头那个反差:同样用 AI,为什么 Go 服务比 Spring Boot 那套省心?因为 Go 的工具链把测试、竞态检测、格式化、静态检查全塞进了一个命令行里,白送了半套 harness。
可白送不等于拧紧。多数 Go 项目的问题不是缺工具,而是工具躺在原地没接进闸门——-race 没开、覆盖率不卡、golangci-lint 没装、internal 边界没人守。AI 一来,这些松动的螺丝就开始漏。
所以这篇就两句话:第一步,把白送的那半套拧紧——gofmt、go vet、go build、go test -race -cover,全接进 CI;第二步,补上缺的那半套——AGENTS.md 给上下文、internal + depguard 给边界、表驱动测试给红绿灯、godog 给业务契约、golangci-lint 当总闸门。
说到底,这和 Java 篇是同一份功夫:让 AI 写好 Go 代码的功夫,和让一个 Go 团队写好代码的功夫,是同一份功夫。 你为 AI 拧紧的每一颗螺丝,最后受益的也是每一个活人。
思维导图
@startmindmap
* Go 服务的 AI harness
** 为什么好伺候
*** 工具链白送半套
*** gofmt/vet/race/cover 开箱即用
*** 白送≠拧紧
** AI 爱翻的三块
*** 吞错误/丢错误链
*** 并发竞态/goroutine 泄漏
*** 幻觉依赖/幻觉 API
*** (顺带) 破坏 internal 边界
** 第一步 拧紧白送的
*** gofmt -l 格式
*** go vet 静态
*** go build 治幻觉API
*** go test -race 竞态
*** go test -cover 覆盖率
** 第二步 补缺的半套
*** AGENTS.md 上下文
*** internal + depguard 边界
*** SDD + 表驱动测试
*** godog 行为契约
*** golangci-lint 总闸门
** 落地顺序
*** 1 拧紧白送
*** 2 golangci-lint
*** 3 AGENTS.md
*** 4 internal 边界
*** 5 SDD+表驱动
*** 6 godog 补业务
@endmindmap

行动清单(今天就能做前两条)
- 在 CI 里加一道
make verify:gofmt -l非空即失败、go vet ./...、go build ./...、go test -race -cover ./...。 - 加
go mod tidy && git diff --exit-code go.mod go.sum,揪出 AI 偷偷加的依赖。 - 装
golangci-lint,先开errcheck、errorlint、bodyclose、gosec,老项目用--new-from-rev只拦新增。 - 写一个
AGENTS.md:系统地图 + 错误/context/并发/依赖约定 + 一个样板函数。 - 用
depguard或go-arch-lint加一条会失败的边界规则(如"service 层不许 import net/http")。 - 把下一个大功能先写成一页规约 + 表驱动测试,再分小任务交给 AI。
检查清单(合并前过一遍)
- [ ]
gofmt -l .输出为空 - [ ]
go vet ./...通过 - [ ]
go build ./...通过(无幻觉 API / 缺失依赖) - [ ]
go mod tidy后go.mod/go.sum无变化(无偷加依赖) - [ ]
go test -race ./...通过(无竞态) - [ ] 覆盖率不低于约定阈值
- [ ]
golangci-lint run通过(errcheck/errorlint/bodyclose/gosec 等) - [ ] 边界规则(depguard / go-arch-lint)通过
- [ ] 所有错误都用
%w包装,库代码无panic、无_ = err - [ ] 对外调用都带
context.Context并真正传递 - [ ] 关键业务流有表驱动测试或 godog 场景覆盖边界 case
扩展阅读
- 传统 Java 项目用 AI 写代码总翻车?先把 harness 修好
- ArchUnit:用一个单元测试库,把架构纪律变成 AI 也绕不过的红绿灯
- golangci-lint 官方文档
- Go 官方:Table-driven tests
- 微服务之道:度量驱动开发
附录:一份完整的 AGENTS.md 示例
正文「第二步 → PKB」那节给的是骨架版,这里补一份贴着上面 order-service 的完整版,可直接抄去改。它和本文的目录结构、make verify、Go 约定一一对应,刻意压在 100 行以内——AGENTS.md 短到能一口气读完,AI 才会真读。
# AGENTS.md — order-service
Go 后端服务,负责订单与退款。人看的总览在 README.md;
深层架构与 runbook 在 man/ 和 docs/adr/,本文件不复述,只给链接。
## Context Map
- `cmd/server/` — 程序入口,只做装配,禁止写业务逻辑
- `internal/order/` — 订单域:下单、查询
- `internal/refund/` — 退款域:`api/`(HTTP)、`service/`(业务)、`store/`(DB)
- `internal/fulfillment/` — 履约域:退款只能通过其接口通知,禁止直连实现
- `internal/platform/` — 基础设施:db、kafka、http client 封装
- 深入阅读:架构见 `man/index.md`,关键决策见 `docs/adr/`
## Commands
- 拉依赖:`go mod download`
- 构建:`make build`(= `go build ./...`,幻觉 API/缺依赖必死)
- 测试:`make test`(= `go test -race -cover ./...`)
- 静态/安全/依赖闸门:`make lint`(= `golangci-lint run`)
- 提交前全量闸门:`make verify`
- 跑单个测试:`go test -race -run TestRefund ./internal/refund/...`
## Harness Rules
- 不许编造:不臆造包、函数、文件、命令或运行结果;不确定就说不确定。
- 重大歧义先问:当某个选择会改变行为/接口/数据时,先问再写。
- 先想后写:多步改动先说清假设和简短计划。
- 简单优先:用解决问题的最小代码,不做没要求的抽象与配置项。
- 外科手术式改动:只动任务需要的地方,沿用现有风格,不顺手重构无关代码。
- 完工先自证:跑 `make verify` 并报告结果;没绿不许声称"做完了"。
## Project Rules(Go 专属,踩过坑的)
- 错误:一律 `fmt.Errorf("...: %w", err)` 往上包;禁止 `_ = err`。库代码禁止 `panic`(只允许 main/init)。
- context:所有 DB/HTTP/Kafka 调用第一参数必须是 `ctx context.Context`,且真正传下去。
- 并发:共享状态必须加锁或走 channel;每个 goroutine 必须有退出路径。测试一律 `-race` 跑。
- 金额:`int64` 以"分"为单位,禁止 `float64`。
- 依赖:能用标准库就别加第三方;新增依赖需在 PR 说明理由。改完跑 `go mod tidy`,`go.mod`/`go.sum` 的 diff 必须是有意的。
- 边界:跨域只走接口,禁止 import 别的域的 `store`/内部实现;由 depguard 强制。
- 分层:HTTP 细节留在 `api`,业务在 `service`,DB 在 `store`;`service` 不许 import `net/http`。
- 日志:用结构化日志;禁止打手机号/身份证/卡号等 PII。
## 标准样板
改写链路前先读 `internal/refund/service.go` 的 `Refund()`,照它的结构来:
入参带 `ctx` → 校验 → 业务 → 落库 → 发消息,每步错误都 `%w` 包好。
## AI Tooling
- 主要面向:Codex / Claude Code / Cursor。
- Cursor 规则放在 `.cursor/rules/`。
- 可选:把 `CLAUDE.md`、`GEMINI.md` 软链到本文件(`ln -s AGENTS.md CLAUDE.md`)。
## Keeping Current
- 触发更新:新增域/包、命令变化、或出现需要新护栏的 AI 反复犯错时。
- 学习闭环:同一个问题纠正 AI 两次以上,就在这里加一条规则;同时删掉过时规则,保持文件短到能读完。
附录:一份完整的 .golangci.yml 示例
正文「第二步 → 度量闸门」给的是最小可用版,这里补一份带 linters-settings、depguard 分层规则和测试豁免的完整版。
提醒一句:
golangci-lint的配置 schema 跨版本有调整(尤其depguard的规则语法、以及 v2 引入的顶层version、linters.settings等)。下面这份按当下主流的 v1 风格写,落地前请用golangci-lint --version对应的官方文档核对一遍,别照抄。
# .golangci.yml — order-service
run:
timeout: 5m
tests: true # 测试文件也检查
linters:
# 不用默认集,显式开启,避免版本升级时行为漂移
disable-all: true
enable:
- errcheck # 没处理的 error 直接报错 —— 治"吞错误"
- errorlint # 强制正确用 %w / errors.Is / errors.As
- govet # 基础静态检查
- staticcheck # 强大的静态分析
- ineffassign # 无效赋值
- unused # 未使用的代码
- gosec # 安全扫描
- bodyclose # http resp.Body 忘了 Close
- sqlclosecheck # sql.Rows/Stmt 忘了 Close
- rowserrcheck # 忘了检查 rows.Err()
- noctx # 发 HTTP 请求没带 context
- contextcheck # context 没一路传下去
- depguard # 依赖 / 分层约束
- gocritic # 一批有用的代码风格/性能检查
- revive # 可配置的风格 / 命名约定
- misspell # 拼写错误
linters-settings:
errcheck:
check-type-assertions: true # v, ok := x.(T) 也要检查
check-blank: true # 禁止用 _ 吞掉 error
govet:
enable-all: true
disable:
- fieldalignment # 结构体字段对齐太吵,关掉
gosec:
excludes:
- G104 # 和 errcheck 重复,避免双重报错
revive:
rules:
- name: error-return # error 必须是最后一个返回值
- name: error-naming # error 变量命名规范
- name: context-as-argument # context 必须是第一个参数
- name: unreachable-code
depguard:
rules:
# 退款域不许直连履约实现,只能走接口
refund-domain:
files:
- "**/internal/refund/**"
deny:
- pkg: "order-service/internal/fulfillment/store"
desc: "退款域不许直连履约实现,只能走 fulfillment 的对外接口"
# service 层不许碰 HTTP,HTTP 细节留在 api 层
service-layer:
files:
- "**/internal/*/service/**"
deny:
- pkg: "net/http"
desc: "service 层不许依赖 net/http,HTTP 处理留在 api 层"
# 全局禁用项:金额禁用浮点、日期统一用 time
global:
deny:
- pkg: "math/big"
desc: "金额请用 int64(分),不要引入 big 计算"
issues:
max-issues-per-linter: 0 # 不限制,全部暴露
max-same-issues: 0
exclude-rules:
# 测试文件放宽:允许忽略错误、允许 gosec 的部分告警
- path: _test\.go
linters:
- errcheck
- gosec
- bodyclose
# 老项目落地用:只拦相对基线的新增违规,老债先冻住
# 命令行用 golangci-lint run --new-from-rev=origin/main 也可,二选一
# new-from-rev: origin/main
几个落地要点:
disable-all: true+ 显式enable,是为了让规则集稳定——升级golangci-lint时不会因为默认集变化而突然多出一堆红,这对"规则即纪律"很重要。errcheck开check-blank,专门治 AI 爱写的_ = err。depguard的desc会出现在报错里,等于拦人时顺手告诉 AI 该怎么改。- 测试文件用
exclude-rules放宽,避免为了过 lint 把测试写得别扭。 - 老项目优先用
--new-from-rev(或new-from-rev)只拦新增,配合make lint-new。
附录索引
三份可直接复用的示例,方便你提取出来建 gist 或拷进项目:
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。