0. 这篇是给"想照抄这套结构"的人写的
前面 24 篇讲的是"我为什么这么做"。这一篇不一样——它讲具体怎么做。
因为我发现一件事:这套结构里真正需要照抄的东西不多,但如果不照抄那几样,后面就很难改回来。而另外很多东西是"可以后补"的,一开始就上只会拖慢你。
所以这篇分成两部分:
- 第一天必须有的(不做后面会痛)
- 可以后补的(做早了是浪费)
然后我会走一遍"加一个资源"的完整步骤。
1. 第一天必须有的四样
1.1 三层模型:DTO / DO / PO
这是整套结构的地基。
client ──► DTO ──► service ──► DO ──► biz ──► DO ──► data ──► PO ──► storage
▲ ▲
│ declares │ implements
└─── repo IF ────┘
| 层 | 拥有什么 | 边界上说 | 绝不说 |
|---|---|---|---|
service |
—— | DTO ↔ DO | PO、存储客户端 |
biz |
DO | DO | DTO、PO、存储客户端 |
data |
PO | DO ↔ PO | DTO |
为什么第一天就要立起来? 因为这三层的价值在于依赖方向单向,而依赖方向一旦乱了,后面纠正的成本是逐渐升高的:
- 第一个月:一个
biz里 importdata拿个 concrete type,改起来五分钟; - 半年后:二十个文件都这么干,而且测试全都依赖真实数据库,改起来是几周,还要重写一堆测试。
而这三层其实很便宜:就是在包里放不同的结构体,转换函数写两遍。第一天做,成本接近零。
有两个推论我觉得值得单独说:
推论一:data 层的构造函数返回接口,不返回具体类型。
func New<Resource>Repo(d *Data) biz.<Resource>Repo
这不是风格问题。返回接口意味着调用方拿不到具体类型,也就无法绕过接口去用底层方法。 依赖倒置的接缝如果不这样收口,很快就有人直接调 repo.db 了。
推论二:PO 只在存储形状和 DO 不同的时候才定义。
如果一张表的列和 DO 一一对应,那就直接用 DO(或者一个薄的别名)。为了"完整"给每个资源都写一套 PO 是浪费——而且它会引入两个必须同步的结构体。
判断标准是:什么时候它们的字段开始不一样(比如存储用 JSON 存一个复杂结构、或者列名和字段名映射不同),什么时候再拆。
1.2 repo 接口在 biz 声明
这一条是三层模型里最关键的一半:
// biz 里
type <Resource>Repo interface {
Get(ctx context.Context, id int64) (*<Resource>, error)
List(ctx context.Context, opts ...ListOption) ([]*<Resource>, error)
Create(ctx context.Context, r *<Resource>) error
// ...
}
// data 里
func New<Resource>Repo(d *Data) biz.<Resource>Repo {
return &<resource>Repo{data: d}
}
接口的声明在消费方(biz),实现的提供在 data。 所以依赖箭头是 data → biz,不是反过来。
这个方向的价值在测试上立刻体现:biz 的测试可以给一个纯内存的 fake repo,跑全部用例,不需要数据库。
而如果反过来(biz import data),biz 的测试就必须起数据库——这不是"麻烦一点",而是"跑得慢、容易 flaky、最后没人跑"。
1.3 分层约束要有一个自动化检查
我在第 1 篇详细讲过 scripts/check-architecture.sh,这里只讲最小版本该做什么。
第一天你不需要十条规则,四条就够:
| 规则 | 为什么 |
|---|---|
| 服务之间不能互相 import 实现 | 一旦互相 import,你就没有服务边界了 |
platform / pkg / domain 不能反向 import 业务 |
共享层一旦依赖业务,就再也不是共享层 |
service 不能 import 自己的 data |
保证 service 只依赖 biz |
biz 不能 import 自己的 data |
保证依赖倒置的箭头方向 |
前两条是最容易发生、也最难回退的。第三条第四条是我实际做错过的(第 1 篇讲过:biz 直接 import data 拿 concrete type,结果测试必须起数据库)。
关键不是规则的数量,是"有这条检查"。 因为人的记忆不可靠——我自己在同一个项目里重复犯过同一类错误。
一个具体的写法建议:用 go list 拿 import 关系,不要用手写正则去扫源码。
go list -e -f '{{.ImportPath}}|{{join .Imports " "}}' ./app/... ./internal/... ./platform/... ./pkg/...
这样得到的是编译器眼里的依赖,而不是文本上的 import 字符串。 两者的区别包括:build tag 排除的文件、生成代码、条件编译——手写正则会在这些地方出错。
1.4 错误要在 biz 层类型化
不要在 service 层或 data 层拼错误字符串:
// biz 里
var (
Err<Resource>NotFound = errors.New("<resource> not found")
Err<Resource>Conflict = errors.New("<resource> already exists")
)
func (uc *<Resource>Usecase) Get(ctx context.Context, id int64) (*<Resource>, error) {
r, err := uc.repo.Get(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, Err<Resource>NotFound
}
return nil, err
}
return r, nil
}
而 data 层负责把 driver 的错误翻译成语义错误:
// data 里
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrNotFound
}
为什么要第一天做? 因为"错误是字符串"这件事会扩散。一旦 service 层开始用 strings.Contains(err.Error(), "not found") 来判断,这个模式就会被复制到每一个新接口里。
而类型化错误在编译期就有约束——拼错一个变量名编译不过,拼错一个字符串永远发现不了。
另外一个实际的收益:字段级校验和业务校验要分层。请求格式错误(缺字段、类型不对)在 service 层挡掉;业务规则错误(余额不足、模型无权)在 biz 层产生。 两类的 HTTP 状态码通常不同,混在一起会很难映射。
2. 可以后补的五样
这一节可能比上一节更有用,因为它说的是"先别做"。
2.1 不用一上来就拆多服务
从一个服务开始。 把 relay、user、billing 放在同一个进程里,但保持包的边界(internal/biz 里有几个 usecase,每个有独立的 repo 接口)。
理由:
- 拆服务的成本不只是"多写几个 main 函数",还有跨服务的一致性(第 1 篇那个共享库的决定就是绕这个);
- 而包边界可以在第一天就立起来,且不需要任何基础设施。
等你需要独立扩容某个部分时再拆。 那时候你的包边界已经把依赖关系理清楚了,拆出来是机械操作。
第 1 篇里我讲了自己"先跳到目录结构、绕一圈才回来做分层,顺序反了"。正确的顺序是先有清晰的分层,再有服务边界。
2.2 不用第一天就接 Redis
并发限流、分布式缓存、幂等键这些都可以先用进程内实现。
但要在代码里留下"这里是单进程"的注释,并且把接口抽出来:
type <X>Limiter interface {
TryAcquire(ctx context.Context, key string, limit int32) (func(), bool)
}
第 4 篇讲的账号并发槽就是一个例子:内存版和 Redis 版实现同一个接口,换的时候业务代码一行不用改。
而那个"单进程"的注释很重要——因为单进程的限制在多副本时会静默失效,不会报错。
2.3 不用第一天就上消息队列
第 13 篇讲的那套(一张 outbox 表 + Redis Streams)就是"不想引消息队列"的产物。它的量级上限是"每秒几十次事件",超过之后该上 Kafka。
但第一天你连事件都没有。 先用同事务的直接调用,等真的需要异步时再考虑。
2.4 不用第一天就做完整可观测性
第一天需要的是:
- 一个结构化 logger;
/healthz;/metrics(Prometheus 端点)。
不需要的是:完整的指标设计、追踪接入、审计日志、看板。
第 17 篇讲过一个教训:我加了从来没看过的指标。 一个没人看的指标和没有它的区别只是多占存储,而且它会让人以为"这块有监控"。
我现在给"加一个指标"设的门槛是:先想清楚它的告警规则或者它在看板上的位置。 想不出来就先不加。
2.5 不用第一天就写 E2E
第 22 篇那套(compose + Playwright + 每夜跑 + 连续 5 次准入)是在有真实故障之后才建起来的。
第一天该写的是:
- biz 的单元测试(fake repo,跑得快);
- data 的存储层测试(打真实数据库,验证查询正确)。
因为这两层的测试是随代码增长的,而 E2E 是补起来代价高、维护成本也高的。
顺序是:单元测试 → 集成测试 → E2E。 反过来的话,你会有一堆慢而脆的 E2E,但没有快的单元测试来定位问题。
3. 走一遍"加一个资源"
现在按 AGENTS.md 里那份清单实际走一遍。假设要加一个 Widget 资源。
步骤 1:定义 DTO(proto)
在 api/<domain>/v1/ 下加:
service WidgetService {
rpc CreateWidget(CreateWidgetRequest) returns (Widget);
rpc GetWidget(GetWidgetRequest) returns (Widget);
rpc ListWidgets(ListWidgetsRequest) returns (ListWidgetsReply);
rpc UpdateWidget(UpdateWidgetRequest) returns (Widget);
rpc DeleteWidget(DeleteWidgetRequest) returns (google.protobuf.Empty);
}
命名约定是固定的:
| 操作 | RPC 名 | 返回 |
|---|---|---|
| 创建 | Create<Resource> |
资源本身 |
| 读取 | Get<Resource> |
资源本身 |
| 列表 | List<Resources>(集合用复数) |
List<Resources>Reply |
| 更新 | Update<Resource> |
资源本身 |
| 删除 | Delete<Resource> |
google.protobuf.Empty |
返回类型跟着 proto 声明走,不要在 service 层自创一个包装。
然后生成:
make api
生成会产出 *.pb.go、*_grpc.pb.go、*_http.pb.go,以及更新 openapi.yaml。
这些文件不要手改。 AGENTS.md 里把这条写成了硬规则,因为手改生成文件是那种"看起来能跑、下次生成就丢"的操作。
步骤 2:DO + repo 接口(biz)
// internal/biz/widget.go
type Widget struct {
ID int64
Name string
Status int32
CreatedAt int64
}
注意这个结构体没有任何 proto 或存储标签。 它是纯 Go。
type WidgetRepo interface {
Get(ctx context.Context, id int64) (*Widget, error)
List(ctx context.Context, opts ...ListOption) ([]*Widget, error)
Create(ctx context.Context, w *Widget) error
Update(ctx context.Context, w *Widget) error
Delete(ctx context.Context, id int64) error
}
ListOption 是 biz 自己提供的组合器(ListFilter / ListOrderBy / ListOffset / ListLimit),让调用方不需要知道存储的查询语言:
widgets, err := uc.repo.List(ctx,
biz.ListFilter("status = 1"),
biz.ListOrderBy("created_at desc"),
biz.ListLimit(20),
)
这个"过滤表达式是字符串"的设计有一个取舍:它对调用方很友好(不用学一个查询 DSL),但它把表达式语法变成了一份隐式契约——data 层必须理解 biz.ListFilter 的语法。
第 1 篇提过这个我做错过的例子:routingclient.FindRoutingGroup 拼了一个 key = "..." 的过滤字符串发给 channel-service。那个语法是 AIP 的过滤语法,所以两边的实现要对齐。 如果你的项目不用 AIP,第一天就该想清楚这个字符串的语法是什么。
步骤 3:repo 实现(data)
// internal/data/widget.go
type widgetPO struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"column:name"`
Status int32 `gorm:"column:status"`
CreatedAt int64 `gorm:"column:created_at"`
}
func (widgetPO) TableName() string { return "widgets" }
func newWidget(w *biz.Widget) *widgetPO { /* DO → PO */ }
func toBizWidget(p *widgetPO) *biz.Widget { /* PO → DO */ }
构造函数返回接口:
func NewWidgetRepo(d *Data) biz.WidgetRepo {
return &widgetRepo{data: d}
}
repo 从 *Data 拿客户端,不自己建连接。 *Data 是长生命周期存储客户端的持有者。
driver 错误在这里翻译:
func (r *widgetRepo) Get(ctx context.Context, id int64) (*biz.Widget, error) {
var po widgetPO
if err := r.data.db.WithContext(ctx).First(&po, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrNotFound
}
return nil, err
}
return toBizWidget(&po), nil
}
步骤 4:service(DTO ↔ DO)
type WidgetService struct {
v1.UnimplementedWidgetServiceServer
uc *biz.WidgetUsecase
}
先嵌 Unimplemented<Resource>ServiceServer。 这样以后 proto 加了新方法,老代码不会编译失败——它会在运行时返回"未实现",而不是在编译期炸掉。
然后用 convert<Resource> 把请求转成 DO:
func (s *WidgetService) CreateWidget(ctx context.Context, req *v1.CreateWidgetRequest) (*v1.Widget, error) {
if req.GetName() == "" {
return nil, errors.BadRequest("INVALID_ARGUMENT", "name is required")
}
w, err := s.uc.Create(ctx, &biz.Widget{Name: req.GetName()})
if err != nil {
return nil, err
}
return &v1.Widget{Id: w.ID, Name: w.Name, Status: w.Status}, nil
}
两条约定:
- 入参在 service 边界校验("name is required" 是格式问题,不是业务规则);
- 返回方向内联构造,因为返回类型是 proto 声明的那个,不需要单独一个转换函数。
列表请求要按 AIP 解析。 这是 pkg/filtering / ordering / pagination 那三个包存在的原因。
部分更新用 fieldmask.Update。 因为它要区分"这个字段没传"和"这个字段传了空值"——而 proto3 的标量字段默认值无法区分这两者。
步骤 5:Wire 装配
三处注册:
// data.ProviderSet 加
NewWidgetRepo,
// biz.ProviderSet 加
NewWidgetUsecase,
// service.ProviderSet 加
NewWidgetService,
然后在 internal/server 里注册 HTTP/gRPC 服务。
cmd 是唯一 import 所有层的地方。 这个约束的意义是:装配关系集中在一个地方,而各层之间互不认识。
然后生成:
make wire
步骤 6:make all 收尾
make all # api + config + generate
AGENTS.md 里的清单是六步,最后一步是"regenerate"。
我建议把 make verify 加到最后一步(第 1 篇讲过它串了架构检查、迁移检查、单元测试、race、前端)。因为架构检查会立刻告诉你"你是不是不小心让 biz import 了 data"。
4. 几个具体的坑
4.1 make config 和 make api 要分开
配置 proto 的生成不能带 OpenAPI 插件:
# buf.gen.config.yaml
# Config generation must not invoke protoc-gen-openapi: the config-only
# target has no API paths and would otherwise overwrite the root spec.
plugins:
- local: protoc-gen-go
- local: protoc-gen-go-grpc
- local: protoc-gen-go-http
如果两个 target 用同一份 buf 模板,跑 make config 会把 openapi.yaml 覆盖成空的。 我踩过这个。
4.2 生成文件的 proto 依赖要管住
buf.gen.yaml 的注释里有一句:
插件二进制由 make init / Dockerfile builder 预装到 PATH,与 go.mod 版本对齐。
"与 go.mod 版本对齐"是关键。 如果本地的 protoc-gen-go-http 版本和 CI 的不一样,生成的代码就会不同——而生成文件是提交进仓库的,于是每次换机器提交都会产生大量 diff。
做法是固定工具版本(scripts/tool-versions.env 就是这个用途),并且让 CI 用同一份。
4.3 wireinject build tag 的坑
Wire 的注入器文件带 build tag:
//go:build wireinject
所以普通的 go build 不会编译它。
后果是:你在 wire.go 里引用了只在非 wireinject 下可见的 helper,本地 go build 全绿,跑 wire 生成时才炸。
第 1 篇讲的 scripts/check-architecture.sh 规则 10 就是为这个:
go test -tags wireinject ${wire_pkgs}
第一天就该有这条检查,因为它是那种"只有在跑生成命令时才发现"的错误。
4.4 JSON 序列化要统一
AGENTS.md 里那条规则值得第一天就立:
所有 JSON 序列化在业务代码里都走
pkg/jsonx。
为什么? 第 24 篇讲过:sonic 的默认配置和 encoding/json 有三处行为差异,其中"解码字符串共享输入缓冲区"那一条会导致字符串在之后静默变化的 bug。
统一到一个 wrapper 之后,这个风险就消失了。而统一的成本很低——就是多一层 import。
反过来说,如果不统一,后面你会发现仓库里两种 JSON 行为并存,而且没人知道哪处是哪处。
5. 我建议的顺序
如果把这篇压缩成一个开工顺序:
第一天:
- 一个服务、一个
go.mod、一个biz/data/service目录结构 - 一个资源走通全链路(proto → biz → data → service → wire)
- 四条分层规则的检查脚本
make api/make config/make wire/make verify- 一个结构化 logger +
/healthz+/metrics
第一个月内:
- 第二个资源(验证这套结构是可复制的)
- biz 的单元测试 + data 的存储层测试
- 错误类型化收口
需要的时候再加:
- Redis(记住留"单进程"注释)
- 事件/Outbox
- 多服务拆分(此时包边界已经清楚)
- E2E、完整的可观测性、审计
6. 这套结构我愿意背的代价
诚实说一下这个结构的成本。
第一,转换代码是重复的。 每个资源要写 DTO↔DO 和 DO↔PO 的转换。一个 CRUD 资源大概多出几十行。
第二,文件数变多。 一个资源散在四个包里,找东西要跳几次。
第三,小服务不值得。 第 1 篇里我提过 config-service 只有 1619 行,给它一整套 biz/data/service/server/conf 加 Dockerfile 加 Makefile,维护成本相对体量偏高。
我仍然选择这套结构,是因为第一个和第二个成本是"每次加资源固定付",而收益是"每次改需求都在收"。
具体说,收益体现在这些时刻:
- 换存储(加一个 repo 实现就行,biz 不动);
- 写测试(fake repo,不用起数据库);
- 加缓存(在 data 层包一层,biz 不知道);
- 查一个行为(
data里没有业务规则,biz里没有 SQL)。
这四件事在项目的第二年会反复发生,而"多写几十行转换"只在第一天发生一次。
7. 系列收尾
这是「micro-one-api 拆解」这一批 24 篇的最后一篇。
如果只留一句给未来的自己:
先把依赖方向理清楚,再考虑拆服务、加中间件、做可观测性。 因为依赖方向是唯一"越晚改越贵"的东西,而其他所有东西都是"需要的时候再加"。