GraphQL 与 OpenAPI(及其前身 Swagger)面向不同的 API 设计需求。选型依据可归结为三个问题:客户端有几端?数据是否关联成图、取数形状是否频繁变化?契约需要交付给外部第三方,还是仅面向内部前端?客户端形态多、数据成图、查询形状频繁变化时,GraphQL 的按需取数更有优势;面向外部消费者、优先考虑契约稳定性与工具链成熟度时,OpenAPI 是默认选择。
两条路线的底层立场一致:schema 即契约。GraphQL 的 SDL 是契约,schema 每次变更都必须重新生成代码并重新通过类型检查;swag 的注解也是契约,注解与实现不一致时文档即开始失真,而失真的文档比缺失的文档危害更大。以下分别讨论两条路线,最后给出选型建议。
gqlgen:schema-first 工作流
Go 生态的 GraphQL 库大多采用 schema-first 流程:先编写 SDL(.graphql 文件),再绑定 Go 解析器(resolver)。主要候选如下:
| 库 | 方式 | 类型安全 | 构建步骤 | 适合 |
|---|---|---|---|---|
99designs/gqlgen | 代码生成 | 编译期 | go generate | 大型 schema、Federation、严格类型 |
graph-gophers/graphql-go | 反射 | 解析期 | 无 | 小中型 schema、快速迭代 |
graphql-go/graphql | code-first | 运行时 | 无 | 避免:冗长、没有 SDL |
新项目推荐默认使用 gqlgen。生成的 generated.go、models_gen.go 不应手工修改,下一次 go generate 会覆盖全部手工改动。需要让生成模型对齐领域类型时,应在 gqlgen.yml 中用 autobind 或 models.<T>.model 建立映射,而不是修改生成文件:
schema:
- graph/schema/*.graphql
exec:
filename: graph/generated.go
model:
filename: graph/model/models_gen.go
resolver:
layout: follow-schema # 每个 schema 文件对应一个 resolvers 文件
filename_template: "{name}.resolvers.go"
autobind:
- github.com/me/app/internal/domain # 复用已有领域结构体
models:
User:
model: github.com/me/app/internal/domain.User
fields:
posts:
resolver: true # 强制自定义 resolver,DataLoader 字段必备
schema 变更后应立即重新生成。遗漏 go tool gqlgen generate 会使生成产物与源 schema 脱节——服务端继续使用嵌入的旧 schema,不一致问题被推迟到编译期或联调阶段才暴露。gqlgen 版本必须固定在 go.mod 中,Go 1.24 及以上可用 go get -tool github.com/99designs/gqlgen@latest 固定工具版本,保证生成结果可复现。
Schema 设计应遵守以下规则:
ID 一律使用 ID 标量(不透明字符串),不应使用 Int。 Int! 类型的 ID 泄漏存储实现,并破坏客户端缓存。
可空性应按"服务端是否总能返回值"判定,不应照搬数据库列约束。总有值的字段标 !;反之,非空字段一旦解析器报错,整个父对象会被置为 null 并引发级联失败——可空字段只使自身为 null,失败代价小得多。
列表分页应使用 Relay cursor connection(Connection/Edge/PageInfo),避免 offset 分页:并发写入下 offset 结果不稳定。Mutation 结果应使用 Payload 信封,业务错误随部分结果一起返回,而不是混入全局 errors 数组:
type CreateUserPayload {
user: User
errors: [UserError!]!
}
解析器(resolver)的职责边界
解析器只承担翻译职责:把 GraphQL 输入转换为领域调用,把领域结果转换为 GraphQL 输出。业务逻辑应放在 service 层,解析器内出现 SQL 即意味着分层失效:
// ✅ resolver 委托 service 层
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.CreateUserPayload, error) {
user, err := r.userService.Create(ctx, input.Email, input.Name)
if err != nil {
return nil, formatError(err)
}
return &model.CreateUserPayload{User: toGQLUser(user)}, nil
}
// ❌ resolver 内写 SQL,分层与可测试性同时失效
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
// ...
}
解析器结构体应按 GraphQL 类型拆分(queryResolver、mutationResolver、userResolver),避免单一巨型结构体。gqlgen 生成的接口本身按类型切分,遵循该划分即可;userResolver.Posts 的 obj 参数是沿图向下遍历时的父对象。
N+1 问题集中在子字段上:User.posts 直接查询数据库时,一个用户一条 SQL,n 个用户即 O(n) 次调用。批处理字段必须在 gqlgen.yml 中标记 resolver: true,强制走数据加载器(DataLoader),把同一字段的并发加载合并为一次批量查询:
// ✅ resolver 只从 loader 取数,不直接访问 DB
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
return loaders.For(ctx).PostsByUserID.Load(ctx, obj.ID)
}
数据加载器必须在 HTTP 中间件内按请求创建,不应实现为全局变量。 全局加载器的缓存跨请求共享,轻则读到陈旧数据,重则把 A 用户的查询结果返回给 B 用户——属于数据泄漏,而非普通缺陷:
// ✅ 每个请求新建 loader,放进 context
func DataLoaderMiddleware(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loaders := &Loaders{PostsByUserID: newPostsByUserIDLoader(r.Context(), db)}
ctx := context.WithValue(r.Context(), loadersKey, loaders)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
实现可选用 github.com/vikstrous/dataloadgen(泛型实现,性能较好)或 github.com/graph-gophers/dataloader,wait 参数建议设为 1~2ms,使并发解析器的 key 能合并进同一批。批量 SQL 的编写规范见数据库访问规范。
订阅同理:解析器必须响应 context 取消,退出时调用 close(ch)。客户端断开后,goroutine 若仍阻塞在 ch <- msg,每个断连都会泄漏一个 goroutine,资源会被逐步耗尽:
// ✅ 断连时退出循环:select ctx.Done + defer close
sub := r.pubsub.Subscribe(room)
go func() {
defer close(ch) // 通知迭代方停止
for {
select {
case <-ctx.Done():
return
case msg := <-sub:
select {
case ch <- msg:
case <-ctx.Done():
return
}
}
}
}()
错误处理与生产防护
内部 error 不应原样返回给客户端,必须统一经过 ErrorPresenter 处理。原始错误包含 SQL 报文、堆栈与内部结构,直接外泄等同于向攻击者暴露实现细节。已格式化的 *gqlerror.Error 原样放行,其余错误记录日志后替换为通用消息:
srv.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
var gqlErr *gqlerror.Error
if errors.As(err, &gqlErr) {
return gqlErr // 已是面向客户端的错误
}
log.Ctx(ctx).Error("resolver error", "err", err)
return gqlerror.Errorf("internal server error") // 安全默认值,作为最终防线
})
业务错误应携带 extensions.code,客户端按 code 分支处理,不应解析 message——message 面向人类阅读,code 面向程序判断。panic 交给 SetRecoverFunc 转换为通用错误,作为最终防线;非致命错误(单个字段报错,整体仍可返回部分数据)使用 graphql.AddError(ctx, err) 追加:
return nil, &gqlerror.Error{
Message: "user not found",
Extensions: map[string]any{"code": "NOT_FOUND"},
}
生产环境必须开启复杂度上限并关闭 introspection。 无上限的深层嵌套查询可在单次请求内耗尽 CPU 与内存;introspection 则向访问者暴露完整 schema:
srv.Use(extension.FixedComplexityLimit(200)) // 单查询成本上限
if os.Getenv("ENV") != "production" {
srv.Use(extension.Introspection{}) // 仅非生产开放
}
更严格的场景可启用 AutomaticPersistedQuery(APQ),直接拒绝任意查询字符串。鉴权分两层:认证放在 HTTP 中间件(校验 token、把身份写入 context);字段级授权使用 @hasRole 这类 schema directive,授权策略声明在 schema 中,避免分散在各解析器内。graph-gophers 不支持 directive,应在解析器内显式检查。
graphql-go 的取舍
graph-gophers/graphql-go 采用 schema-first + 反射:没有代码生成步骤,MustParseSchema 在启动时校验 SDL 与解析器的匹配关系,签名不符立即 panic,以 fail-fast 获得早期反馈。
类型映射必须严格:Int 对应 int32 而非 int,可空字段用指针、非空字段用值。Int! 字段返回 int,解析 schema 时直接报错;可空参数漏写 *,客户端传入 null 时反序列化即失败。另有两项惯例:解析器返回包装结构(如 UserResolver)而非领域模型,使投影与持久化分离;错误类型实现 ResolverError 接口,即可携带 Extensions() map[string]any。
与 gqlgen 的分工:编译期类型安全、静态分发性能、Federation v2、内置文件上传归 gqlgen;零构建步骤、解析期校验、小中型 schema 归 graph-gophers。需要动态 schema,或构建管线必须保持简单时,反射方案是合理选择;schema 类型过百、需接入 Apollo Router 时,应选择 gqlgen。
测试
推荐优先使用 gqlgen/client 包驱动完整 handler,使指令、中间件、DataLoader 均被测试覆盖。直接调用解析器方法可以做单元测试,但指令与批处理路径只有 client harness 能覆盖:
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{DB: testDB},
}))
h := dataloaders.Middleware(testDB, srv) // 挂载 DataLoader 中间件,覆盖批处理路径
c := client.New(h)
var resp struct {
CreateUser struct {
User struct{ ID, Email string }
Errors []struct{ Message string }
}
}
c.MustPost(`mutation($email:String!,$name:String!){
createUser(input:{email:$email,name:$name}){
user { id email }
errors { message }
}
}`, &resp, client.Var("email", "alice@example.com"), client.Var("name", "Alice"))
require.Empty(t, resp.CreateUser.Errors)
错误断言应落到 extensions.code;订阅使用 client.Subscription 测试,结束时 defer Close。graph-gophers 用 gqltesting.RunTests 做表驱动断言(可同时断言 ExpectedErrors),HTTP 层用 httptest.NewRecorder() 驱动 relay.Handler。测试组织方式见测试规范。
swag 与 OpenAPI
REST 一侧,swag 的方案是 handler 注释即文档:swag init 解析注解生成 docs/(docs.go、swagger.json、swagger.yaml),再挂载 Swagger UI。这一机制即注解生成:文档由源码注解派生,注解本身构成契约。
文档应作为契约维护:每次注解变更后重新执行 swag init,并在 CI 中校验,防止文档与实现脱节。swag fmt 可像 go fmt 一样对齐注解,但它要求每个 handler 注释以标准 godoc(// FuncName godoc)开头,否则无法确定缩进:
// ShowAccount godoc
// @Summary Get account by ID
// @Tags accounts
// @Accept json
// @Produce json
// @Param id path int true "Account ID"
// @Success 200 {object} model.Account
// @Failure 404 {object} api.ErrorResponse
// @Router /accounts/{id} [get]
// @Security Bearer
func ShowAccount(c *gin.Context) {}
@Param body 的类型必须是命名结构体,写 string 这类原始类型会直接导致生成失败。 @Param <name> <in> <type> <required> "<描述>" 五段式中,in 取 path/query/body/header/formData;@Success/@Failure 的格式为 @Success <code> {object|array} <type> "描述"。swag v2 起支持泛型与嵌套组合:@Success 200 {object} api.Envelope{data=model.User,meta=api.Pagination}。
通用信息(@title、@version、@host、@BasePath)应放在 main.go——放在其他文件时 swag 会静默跳过,生成的 spec 连 title 都没有。必要时使用 swag init -g cmd/api/main.go 指定入口。
securityDefinitions 在 main.go 定义一次,每个受保护端点必须显式标注 @Security。 漏写的接口在 Swagger UI 中没有锁图标,联调方会发出未认证请求:
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
模型字段的文档信息通过 struct tag 补充,无需修改 Go 类型:example 提供 UI 示例值,enums 列举合法值,swaggertype 纠正类型推断,swaggerignore 把内部字段排除出 schema:
type CreateUserRequest struct {
Name string `json:"name" example:"Jane Doe" minLength:"2"`
Role string `json:"role" enums:"admin,user,guest" example:"user"`
Avatar []byte `json:"avatar" swaggertype:"string" format:"base64"`
Secret string `json:"-" swaggerignore:"true"` // 内部字段,不纳入文档
}
docs 包必须被导入才生效。空导入 _ "yourmodule/docs" 只注册 spec;需要修改 docs.SwaggerInfo.Host 时必须使用命名导入,多环境的 host/basepath 在启动时覆盖:
import docs "yourmodule/docs"
docs.SwaggerInfo.Host = os.Getenv("API_HOST")
docs.SwaggerInfo.BasePath = "/api/v1"
Swagger UI 可通过单条路由接入:r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))(Gin;Echo、Fiber、chi/net/http 各有对应包),访问 /swagger/index.html。
生产环境应禁用 /swagger/,或对其施加认证。 该页面把全部端点结构、参数与示例暴露给任何可访问者,必须通过环境开关或认证中间件加以控制。
选型建议
- 客户端多样、数据图状、取数形状由前端决定,选 GraphQL;面向第三方开放、契约稳定优先、需要 HTTP 缓存与成熟网关生态,选 OpenAPI(REST)。两者也可并存:对外 REST,对内 BFF 使用 GraphQL。
- GraphQL 内部:gqlgen 是默认选择;graph-gophers 适合小中型 schema 与不需要代码生成的团队;graphql-go/graphql(code-first)不建议使用。
- gRPC 服务的 OpenAPI 由 grpc-gateway 自带生成器产出,不应使用 swag——其注解模型是为 HTTP handler 设计的。gRPC 规范见gRPC 服务开发规范。
速查清单
GraphQL(gqlgen / graph-gophers)
- 生成文件不手工修改;领域类型通过 autobind / models 映射
- schema 变更后立即执行
go tool gqlgen generate;工具版本固定于 go.mod(go get -tool) - ID 使用 ID 标量;可空性按服务端能否总是返回值判定
- 列表使用 cursor 分页;Mutation 使用 Payload 携带业务错误
- 解析器只做翻译;业务逻辑置于 service 层;结构体按 GraphQL 类型拆分
- 批处理字段标记
resolver: true并接入数据加载器;数据加载器按请求创建,不应全局共享 - 订阅响应 ctx 取消;退出时调用 close(ch)
- 错误统一经过 ErrorPresenter;业务错误携带 extensions.code
- 生产环境:开启复杂度上限、关闭 introspection;更严格场景启用 APQ
- 认证放在中间件;字段级授权使用 directive 或在解析器内显式检查
- graph-gophers:Int↔int32、可空↔指针、启动时 fail-fast
- 测试使用 gqlgen/client 或 gqltesting;断言落到 code
OpenAPI(swag)
- 注解变更后执行
swag init;CI 中校验 - 注释以
// FuncName godoc开头,swag fmt方可使用 - body 参数使用命名结构体;@title 等通用信息放在 main.go(或以
-g指定) - securityDefinitions 定义一次;受保护端点全部标注 @Security
- 文档信息通过 struct tag 补充,不修改 Go 类型
- docs 包空导入;需要覆盖 SwaggerInfo 时使用命名导入
- 生产环境禁用 /swagger/ 或施加认证
- gRPC 文档由 grpc-gateway 生成,不使用 swag