关于 go-graphql
go-graphql 是面向使用 gqlgen 构建 GraphQL API 的 Go 开发者的指导技能,涵盖 schema 优先开发、resolver 与 dataloader 设计、查询限制、授权、错误处理和测试。
主要功能
- 01使用 gqlgen 进行 schema 优先开发
- 02通过依赖注入保持 resolver 精简
- 03使用按请求创建的 dataloader 批量查询
- 04限制查询复杂度、深度并要求分页
- 05处理字段级授权与错误
- 06基于 schema 测试并检查查询次数
适用场景
- 01在 Go 中构建 GraphQL API
- 02避免数据库 N+1 查询
- 03限制开销过大或嵌套过深的请求
- 04测试 resolver 授权与批处理
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-graphql安装后在 Claude Code 中输入/go-graphql确认已加载。
附带文件references/errors-auth-testing.md
详细介绍
Go GraphQL
GraphQL 将查询规划交给客户端。这既是它的特性,也是它的风险:一个看似无害的查询可能导致一万次数据库往返,而一个权限过宽的字段可能泄露其他租户的数据。这两个问题都要在服务端解决,而不是在审查 schema 时解决。
按需加载的详细参考资料:
references/errors-auth-testing.md— 错误呈现器、字段授权指令、带查询次数断言的客户端驱动测试。
只有在下方摘要不足以解答问题时,才阅读参考文件。
1. Schema 优先的 gqlgen
.graphql schema 是唯一事实来源。gqlgen 根据它生成模型、解析器存根和执行层。
go get -tool github.com/99designs/gqlgen
go tool gqlgen init # once
go tool gqlgen generate # after every schema change
# gqlgen.yml — bind generated types to your own models
models:
User:
model: github.com/myorg/app/internal/domain.User
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int64
显式绑定领域类型。如果不加干预,gqlgen 会生成一套平行的贫血结构体,每个解析器最终都会变成映射函数。
提交生成的代码,并在 CI 中检查代码是否过时:
go tool gqlgen generate && git diff --exit-code
绝不要编辑 generated.go 或 models_gen.go。resolver.go 和 *.resolvers.go 文件由你维护。
2. 保持解析器精简
解析器将 GraphQL 请求转换为服务调用。它不包含业务逻辑,也不包含 SQL。
func (r *queryResolver) User(ctx context.Context, id string) (*domain.User, error) {
u, err := r.users.Find(ctx, id)
if errors.Is(err, domain.ErrNotFound) {
return nil, nil // nullable field: absent, not an error
}
if err != nil {
return nil, fmt.Errorf("find user %s: %w", id, err)
}
return u, nil
}
通过 Resolver 结构体注入依赖,绝不要通过包级全局变量注入:
type Resolver struct {
users UserService
orders OrderService
loader *Loaders
}
始终传递 ctx。它携带请求截止时间、经过身份验证的主体,以及每个请求的数据加载器。
3. N+1 问题——最关键的问题
列表类型上的字段解析器会对每个元素运行一次。
// ❌ 1 query for the orders, then N queries for the users
func (r *orderResolver) Customer(ctx context.Context, obj *domain.Order) (*domain.User, error) {
return r.users.Find(ctx, obj.CustomerID)
}
使用数据加载器进行批处理。它会收集短时间窗口内请求的键,并发出一次查询。
import "github.com/vikstrous/dataloadgen"
type Loaders struct {
UserByID *dataloadgen.Loader[string, *domain.User]
}
func NewLoaders(s UserService) *Loaders {
return &Loaders{
UserByID: dataloadgen.NewLoader(func(ctx context.Context, ids []string) ([]*domain.User, []error) {
return s.FindMany(ctx, ids) // ONE query for all ids
}, dataloadgen.WithWait(time.Millisecond)),
}
}
// ✅ 1 query for the orders, 1 for all customers
func (r *orderResolver) Customer(ctx context.Context, obj *domain.Order) (*domain.User, error) {
return loadersFrom(ctx).UserByID.Load(ctx, obj.CustomerID)
}
加载器是每个请求独立的,由中间件安装。进程范围的加载器会跨用户缓存数据,导致用户之间的数据泄露。
func withLoaders(svc UserService, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), loadersKey{}, NewLoaders(svc))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
批处理函数必须按收到的键的顺序返回结果,每个缺失的键都要对应一个 nil 条目和一个错误。返回更短的切片会导致所有结果悄然错位。
4. 限制每个查询
没有限制的公共 GraphQL 端点就是一个拒绝服务端点。
srv := handler.New(generated.NewExecutableSchema(cfg))
srv.AddTransport(transport.POST{})
srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
srv.Use(extension.FixedComplexityLimit(300))
srv.Use(extension.AutomaticPersistedQuery{Cache: lru.New[string](100)})
- 复杂度限制 — 为每个字段分配成本,列表字段的成本要随较大的
first值提高。先设定一个上限,让最慢的合法查询也能低于该值,然后再进行测量。 - 深度 — 必须限制递归类型(
user { orders { customer { orders ... } } })。gqlgen 没有内置深度限制;请在操作中间件中实施限制。 - 分页是每个列表字段的强制要求。 返回无界列表的字段属于 schema 错误。
- 仅当安装
extension.Introspection时,才会启用 introspection。不要在生产环境中安装它,或将其限制为经过身份验证的角色。 - 持久化查询允许公共客户端发送哈希而不是文档,因此服务端只会执行你发布的查询。
将 srv.AroundOperations 设为强制实施每个操作的超时,并始终在设置了 ReadTimeout 和 WriteTimeout 的 http.Server 后运行。
5. 错误
GraphQL 会返回状态码 200 和一个 errors 数组。绝不要在其中泄露内部信息。
安装 srv.SetErrorPresenter:将已知的领域错误(验证失败、未找到)映射为其消息和稳定的 extensions.code;其他所有错误都在服务端记录日志,同时返回消息 internal server error 和代码 INTERNAL。示例:references/errors-auth-testing.md。
使用 srv.SetRecoverFunc 将解析器 panic 转换为错误,避免连接中断,并连同堆栈一起记录日志。
记住可空性规则:非空字段发生错误时,会将其最近的可空祖先置为 null。只有在字段绝不可能合理地缺失时,才将其设为非空。
6. 授权应落实到字段
仅进行对象级检查还不够——客户端可以通过多条路径访问对象。
在敏感字段上声明 @hasRole(或类似)指令,并将其实现为针对上下文中主体的检查;返回 access denied,不提供详细信息。示例:references/errors-auth-testing.md。
在 GraphQL 处理程序运行前,于 HTTP 中间件中进行身份验证。在指令或解析器中,使用上下文中的主体进行授权——绝不要使用查询参数中的主体。
7. 测试
通过 gqlgen 的测试 client 调用真实的 schema,并断言解码后的响应。对于每个由数据加载器处理的解析器,还要断言查询次数——只有这样,N+1 回归问题才能导致构建失败,而不只是出现在仪表板上。示例:references/errors-auth-testing.md。
验证清单
- Schema 是唯一事实来源;生成的文件已提交,并由 CI 检查
- 通过
gqlgen.yml将生成的类型绑定到领域模型 - 解析器不包含业务逻辑,并始终传递
ctx - 每个按 ID 获取数据的列表字段解析器都通过数据加载器处理
- 数据加载器按请求构造,绝不在多个请求间共享
- 批处理函数按键的顺序为每个键返回一个结果
- 已配置并测试复杂度上限和深度限制
- 每个列表字段都支持分页
- 在生产环境中禁用内省或按角色限制内省
- 错误呈现器会移除内部错误;已安装恢复函数
- 根据上下文中的主体,对每个字段强制执行授权
- 至少有一个批处理字段的测试会断言查询次数