mcpField
G
go-graphql

go-graphql

github.com/eduardo-sl开发8368 次安装2 周前更新
代码仓库

关于 go-graphql

go-graphql 是面向使用 gqlgen 构建 GraphQL API 的 Go 开发者的指导技能,涵盖 schema 优先开发、resolver 与 dataloader 设计、查询限制、授权、错误处理和测试。

主要功能

  1. 01使用 gqlgen 进行 schema 优先开发
  2. 02通过依赖注入保持 resolver 精简
  3. 03使用按请求创建的 dataloader 批量查询
  4. 04限制查询复杂度、深度并要求分页
  5. 05处理字段级授权与错误
  6. 06基于 schema 测试并检查查询次数

适用场景

  1. 01在 Go 中构建 GraphQL API
  2. 02避免数据库 N+1 查询
  3. 03限制开销过大或嵌套过深的请求
  4. 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。

验证清单

  1. Schema 是唯一事实来源;生成的文件已提交,并由 CI 检查
  2. 通过 gqlgen.yml 将生成的类型绑定到领域模型
  3. 解析器不包含业务逻辑,并始终传递 ctx
  4. 每个按 ID 获取数据的列表字段解析器都通过数据加载器处理
  5. 数据加载器按请求构造,绝不在多个请求间共享
  6. 批处理函数按键的顺序为每个键返回一个结果
  7. 已配置并测试复杂度上限和深度限制
  8. 每个列表字段都支持分页
  9. 在生产环境中禁用内省或按角色限制内省
  10. 错误呈现器会移除内部错误;已安装恢复函数
  11. 根据上下文中的主体,对每个字段强制执行授权
  12. 至少有一个批处理字段的测试会断言查询次数

评论

支持 Markdown