《Go 语言高级编程》9.2 mockgen 与 sqlc/ent

stringer 是代码生成的小玩具,mockgen、sqlc、ent 才是工程里的主力。本节实测三者:mockgen 从接口生成 mock、sqlc 从 SQL 生成类型安全的数据访问层、ent 从 schema 生成 ORM,并如实记录一个踩坑——ent v0.14.6 在 Go 1.27.0 工具链下生成失败,需要换用旧工具链才能跑通。

9.2 mockgen 与 sqlc/ent

9.1 的 stringer 只生成一个方法,是代码生成的「最小演示」。工程里真正消耗人力的重复代码在另外两个地方:测试里的 mock 和 数据访问层(DAO/Repository)。这两块都有成熟的生成器。

这一节实测三个主流工具:mockgen(接口 mock)、sqlc(SQL → 类型安全代码)、ent(schema → ORM)。它们覆盖了「测试」与「数据访问」这两个最值得生成的领域。

本节要回答的问题是:三类重型生成器各自解决什么问题、怎么接入工程、有哪些实测坑。结论先行:mockgen(go.uber.org/mock v0.6.0)从接口生成 mock,用 go get -tool 锁定版本即可;sqlc(v1.31.1)从 SQL 查询生成类型安全的 Go 代码,是「SQL 优先」路线的代表;ent(v0.14.6)从 schema 生成 ORM,但实测在 Go 1.27.0 工具链下生成失败(报 internal error: package "context" without types),需要换用更旧的 Go 工具链(把 go 指令降到 1.24.0 后以 GOTOOLCHAIN=local 运行)才能跑通——这是本节最重要的诚实记录。

9.2.1 三类生成器的定位

先把三个工具放回它们各自的位置:

工具输入输出解决的问题
mockgen一个 Go 接口该接口的 mock 实现测试时替换依赖
sqlcSQL schema + query类型安全的 Go 函数手写 DAO 的重复
entGo 写的 schema 定义完整的 ORM 客户端实体关系与查询构建

它们的共同点是:都有一个「单一事实来源」——mockgen 的事实来源是接口,sqlc 是 SQL,ent 是 schema。生成器把这份事实翻译成 Go 代码,从而消除手工同步。

9.2.2 mockgen:接口的 mock 实测

先定义一个接口,并在它上方写 go:generate:

package store

//go:generate go tool mockgen -source=store.go -destination=mock_store.go -package=store

import "context"

type Store interface {
	Get(ctx context.Context, key string) (string, error)
	Put(ctx context.Context, key, val string) error
}

安装并锁定版本:

$ GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
    go get -tool go.uber.org/mock/mockgen@latest
go: added go.uber.org/mock v0.6.0

生成并验证:

$ GOTOOLCHAIN=go1.27.0 go generate ./store/
$ ls store/
mock_store.go  store.go
$ GOTOOLCHAIN=go1.27.0 go build ./... && GOTOOLCHAIN=go1.27.0 go vet ./...
build+vet OK

生成物开头:

// Code generated by MockGen. DO NOT EDIT.
// Source: store.go
//
// Generated by this command:
//
//	mockgen -source=store.go -destination=mock_store.go -package=store
//

// Package store is a generated GoMock package.
package store

import (
	context "context"
	reflect "reflect"

	gomock "go.uber.org/mock/gomock"
)

// MockStore is a mock of Store interface.
type MockStore struct {
	ctrl     *gomock.Controller
	recorder *MockStoreMockRecorder
	isgomock struct{}
}

注意生成物把生成命令原样记在了文件头——这是好习惯,任何人看到文件就知道它怎么来的。isgomock struct{} 是一个编译期标记字段,用于防止 mock 类型被误当作真实实现传递。

9.2.3 mockgen 的两种模式

mockgen 有两种常用工作模式,很多人只用过一种:

模式命令原理适用
source-source=store.go解析 Go 源文件接口在本地文件
package-destination=... example.com/pkg Store编译期反射接口来自依赖包

source 模式(上面用的)直接读 .go 文件,简单直接,但要求接口源码在本地。package 模式会真正编译并加载那个包,用反射拿到接口的完整方法集,能处理来自第三方依赖的接口——代价是更慢,且要求那个包能被编译。

生成 mock 后的典型用法:

ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockStore(ctrl)
m.EXPECT().Get(gomock.Any(), "k").Return("v", nil)

go.uber.org/mock 是 github.com/golang/mock 的官方继任者——后者已归档,新项目应当用前者。这是本节要提醒的一个易错点:网上大量教程还在用已停止维护的旧路径。

9.2.4 sqlc:从 SQL 生成类型安全代码

sqlc 走的是「SQL 优先」路线:你写 SQL,它生成与之对应的类型安全 Go 函数。

安装:

$ GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
    go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
$ $(go env GOPATH)/bin/sqlc version
v1.31.1

配置 sqlc.yaml:

version: "2"
sql:
  - engine: "postgresql"
    queries: "query.sql"
    schema: "schema.sql"
    gen:
      go:
        package: "db"
        out: "db"

schema 与 query 各一个文件:

-- schema.sql
CREATE TABLE authors (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  bio  TEXT
);
-- query.sql
-- name: GetAuthor :one
SELECT * FROM authors WHERE id = $1;

-- name: ListAuthors :many
SELECT * FROM authors ORDER BY name;

关键在查询上方的 -- name: GetAuthor :one 注释:sqlc 靠它识别「这是一个要生成的查询」,:one/:many 决定返回单条还是切片。生成:

$ $(go env GOPATH)/bin/sqlc generate
$ ls db/
db.go  models.go  query.sql.go

生成的 db.go 开头:

// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.31.1

package db

import (
	"context"
	"database/sql"
)

type DBTX interface {
	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
	PrepareContext(context.Context, string) (*sql.Stmt, error)
	QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
	QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}

sqlc 生成的东西有三个特点值得注意:

  1. 它不生成 ORM,生成的代码直接用 database/sql。这让它很轻,也容易理解。
  2. 参数类型是静态的:GetAuthor(ctx, id int64) 的参数类型来自 schema 里 id BIGSERIAL 的推导,写错类型编译期就报错。
  3. 它把「SQL 是事实来源」落到实处:改 SQL → 重跑 sqlc generate → Go 代码自动跟上。不存在「改了 SQL 忘了改 Go 结构体」的问题。

9.2.5 ent:从 schema 生成 ORM

ent 的路线是「schema 即代码」:你用 Go 写实体定义,它生成整套 ORM 客户端。

安装并脚手架:

$ GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
    go install entgo.io/ent/cmd/ent@latest
$ $(go env GOPATH)/bin/ent new User
$ ls ent/schema/
user.go

注意 ent new 的实体名必须以大写字母开头,否则报错 schema names must begin with uppercase(实测踩到)。

编辑 schema,加上字段:

package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/field"
)

type User struct {
	ent.Schema
}

func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("name"),
		field.Int("age"),
	}
}

func (User) Edges() []ent.Edge { return nil }

9.2.6 实测踩坑:ent 与 Go 1.27 的 go.mod

这是本节最需要如实记录的一段。在 go.mod 写着 go 1.27.0 的项目里直接生成:

$ GOTOOLCHAIN=go1.27.0 ent generate ./ent/schema
internal error: package "context" without types was imported from "entgo.io/ent"

生成失败,报的是一句含义模糊的 internal error: package "context" without types。这是 ent 内部的 go/packages 加载器与新版 Go 工具链不兼容导致的——它无法正确加载标准库包的类型信息。

把 go.mod 的 go 指令降到 1.24.0,让本地更旧的 Go 工具链(go1.26.0)被选中后重试:

$ sed -i '' 's/^go 1.27.0/go 1.24.0/' go.mod
$ GOTOOLCHAIN=local ent generate ./ent/schema
$ find ent -name '*.go' | wc -l
      20

成功了,生成了 20 个 .go 文件,包括 user.go、user_create.go、user_query.go、user_update.go、user_delete.go 以及 migrate/、predicate/、hook/ 等子包。构建:

$ GOTOOLCHAIN=local go build ./ent/...
ent build OK

结论与提醒:ent v0.14.6 在本机用 Go 1.27.0 工具链会生成失败,换用旧工具链(go1.26.0)即可;把 go 指令降到 1.24 只是为了让本地旧工具链被选中。这不代表 ent 不能用,而是提醒你——生成器自身的工具链兼容性也是依赖治理的一部分。遇到这种 internal error,第一反应应该是「换一个 Go 工具链版本」,而不是怀疑自己的 schema 写错了。同时,ent 生成的代码量远大于 sqlc(20 个文件 vs 3 个),这是「全功能 ORM」的代价。

9.2.7 三者对照表

维度mockgensqlcent
事实来源Go 接口SQLGo schema
生成量1 个文件3 个文件20 个文件
运行期依赖go.uber.org/mock仅 database/sqlentgo.io/ent
学习成本低低中高
实测版本v0.6.0v1.31.1v0.14.6
本机实测坑无无Go 工具链须 ≤ 1.26

选型建议:

  • 测试用 mock → mockgen,几乎无争议。
  • 喜欢手写 SQL、要轻量 → sqlc。
  • 需要实体关系、迁移、复杂查询构建 → ent。

9.2.8 生成器接入工程的通用姿势

三个工具虽然不同,但接入工程的姿势是统一的,可以总结成四条:

  1. 用 //go:generate 收口:生成命令写在源文件里,go generate ./... 一把梭。
  2. 用 go get -tool 锁版本:别依赖「我本机装了什么」。
  3. 提交生成物 + CI 校验一致:go generate ./... && git diff --exit-code。
  4. 生成物只读:文件头的 DO NOT EDIT 是契约,要改就改事实来源。

stringer、mockgen、sqlc、ent 都是「现成的生成器」。当你需要的东西没有现成工具时,就得自己写一个——这正是下一节的主题:用 go/ast 造一个属于自己的生成器。

阅读导航:上一节:9.1 go generate 与 stringer · 下一节:9.3 自研代码生成器与 go/ast 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练