Go 基础体系 · 第 48/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go GraphQL 与 gqlgen:Schema、Resolver、DataLoader 和复杂度控制

本文以 Go 1.26.4gqlgen v0.17.94 为基准。GraphQL 不是数据库查询语言或自动 ORM:服务端发布 Schema,执行器校验客户端选择集后按字段调用 resolver。gqlgen 生成 Go 模型、resolver 接口和执行代码;数据访问、授权、事务和成本控制仍由应用负责。

GraphQL 适合聚合领域和自选字段。资源简单时,REST/OpenAPI 更直接。

1. Schema 是公开契约

Schema Definition Language(SDL)描述对象、标量、枚举、输入和根操作。! 表示非空:Article! 保证对象存在,[Article!]! 同时保证列表和每个元素非空。输入类型不能复用输出对象,因为输入的兼容演进、默认值和授权规则不同。

scalar Time

type Article {
  id: ID!
  title: String!
  author: User!
  publishedAt: Time
}

type User { id: ID!, name: String! }

input CreateArticleInput { title: String!, authorID: ID! }

type Query {
  article(id: ID!): Article
  articles(first: Int! = 20, after: ID): [Article!]!
}

type Mutation { createArticle(input: CreateArticleInput!): Article! }

新增 nullable 字段通常对旧客户端兼容;删除字段、改名、改变类型或把 nullable 收紧为 non-null 都可能破坏客户端。废弃字段先标 @deprecated(reason: "use ..."),观察实际使用后再按版本策略移除。把业务错误塞进不断变化的字符串会形成隐形契约,稳定错误应有 extensions.code 或明确的 union/result 类型。

2. gqlgen 的生成边界与配置

典型项目保存 schema.graphqlsgqlgen.yml,生成执行代码、模型与 resolver 骨架。生成文件可提交或在构建时生成,但流程必须可重复;生成器固定版本,CI 检查差异,禁止直接编辑 generated 文件。

schema:
  - graph/*.graphqls
exec:
  filename: graph/generated.go
  package: graph
model:
  filename: graph/model/models_gen.go
  package: model
resolver:
  layout: follow-schema
  dir: graph
  package: graph
models:
  ID:
    model: github.com/99designs/gqlgen/graphql.ID
  Time:
    model: github.com/99designs/gqlgen/graphql.Time
go run github.com/99designs/gqlgen@v0.17.94 generate
go test ./...
git diff --exit-code

已有领域结构体可以在 models 中绑定,但不要为了省一次映射让数据库模型直接成为 API 契约。数据库 nullable、内部字段和权限字段经常与 GraphQL 不同。生成的 resolver 根对象适合持有接口依赖,而不是全局数据库句柄。

3. 一次请求怎样执行

HTTP handler 解析包含 query、可选 operationNamevariables 的 JSON。执行器解析文档、选择 operation、校验字段与变量,再从根开始解析字段。同层字段可能并发执行,代码不能依赖书写顺序。

HTTP 响应通常为 200,即使字段执行失败;网关不能仅以状态判断成功。响应可同时包含 dataerrors:nullable 字段失败时为 null;non-null 字段失败会把 null 向父级传播,最坏令整个 data 为 null。

{
  "data": {"article": {"id": "a1", "author": null}},
  "errors": [{
    "message": "author unavailable",
    "path": ["article", "author"],
    "extensions": {"code": "UPSTREAM_TIMEOUT"}
  }]
}

这意味着 non-null 不只是文档承诺,还决定故障爆炸半径。只有真正能保证的数据才声明非空,不能用 ! 追求“类型更漂亮”。

4. Resolver 应做什么

Resolver 是协议适配层:读取已验证参数与身份,调用领域服务,把领域结果映射为 GraphQL 模型,并分类错误。它不应散落 SQL、远程客户端创建、权限字符串和日志格式。context 必须原样传给仓储与下游,禁止用 context.Background() 逃离请求取消。

type ArticleService interface {
    Get(context.Context, string) (*model.Article, error)
    Create(context.Context, model.CreateArticleInput) (*model.Article, error)
}

type Resolver struct { Articles ArticleService }

func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, error) {
    article, err := r.Articles.Get(ctx, id)
    switch {
    case errors.Is(err, domain.ErrNotFound):
        return nil, nil // Schema 允许 article 为 null
    case err != nil:
        return nil, fmt.Errorf("get article: %w", err)
    default:
        return article, nil
    }
}

Mutation 中涉及多个写操作时,事务应由 service/use case 管理,不能让子字段 resolver 各自提交。resolver 可能被并发调用,根对象中的可变 map 必须同步;更好的方式是把请求级状态放进明确的 request scope。

5. N+1 与请求级 DataLoader

查询 100 篇文章及作者时,朴素 resolver 会执行 100 次查询。DataLoader 收集 key 后批量查询,再按输入顺序返回。批处理必须处理重复 key、缺失值和逐项错误。

func batchUsers(ctx context.Context, ids []string) ([]*User, []error) {
    users, err := repo.GetByIDs(ctx, deduplicate(ids))
    if err != nil {
        errs := make([]error, len(ids))
        for i := range errs { errs[i] = err }
        return nil, errs
    }
    byID := make(map[string]*User, len(users))
    for _, user := range users { byID[user.ID] = user }
    out, errs := make([]*User, len(ids)), make([]error, len(ids))
    for i, id := range ids {
        out[i] = byID[id]
        if out[i] == nil { errs[i] = domain.ErrNotFound }
    }
    return out, errs
}

Loader 在每个请求开始时创建并通过类型安全 context key 注入,请求结束即释放。跨请求缓存会混淆租户、授权和新鲜度。若同一实体随 viewer 展示不同内容,key 必须包含授权维度。批量大小和等待时间都要有上限。

6. 分页、排序和一致性

offset 分页简单,但并发插入会导致重复或跳过,深 offset 也可能昂贵。GraphQL 常用 connection/cursor:cursor 编码稳定排序键,例如 (published_at, id),查询条件按同一组合键继续。cursor 是不透明令牌,不应让客户端解析,也不能只用可重复的时间戳。

限制 first 的上下界并指定稳定排序。总数 totalCount 可能触发昂贵 COUNT,只有客户端选择它时才计算;这是 GraphQL 按字段执行的价值。分页过程中若要求一致快照,需要数据库快照或业务版本,cursor 本身不保证跨请求一致性。

7. 错误分类、取消与超时

gqlgen 可通过错误 presenter 将内部错误转换为安全消息和稳定 code,通过 recovery 捕获 panic 并记录关联 ID。不要把 SQL、栈、下游 URL 或用户隐私放进 message。认证失败、禁止访问、输入冲突、限流和内部故障应有不同 code;日志保留原始 wrapped error,响应只给最小必要信息。

server.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
    presented := graphql.DefaultErrorPresenter(ctx, err)
    presented.Message = "internal error"
    presented.Extensions = map[string]any{"code": "INTERNAL"}
    if errors.Is(err, domain.ErrForbidden) {
        presented.Message = "forbidden"
        presented.Extensions["code"] = "FORBIDDEN"
    }
    return presented
})

HTTP server设置整体请求 deadline,中间件还可按 operation 设置执行预算。下游 timeout 必须小于剩余请求时间并保留返回响应的余量。context.Canceled 多数表示客户端离开,通常不告警;DeadlineExceeded 要记录 operation、主要字段和下游阶段。resolver 启动 goroutine 时必须加入等待与取消,不能在响应后继续写共享请求对象。

8. 查询复杂度不是只限深度

深度限制挡不住同层大量别名,简单字段数也挡不住嵌套列表乘法。成本模型应给字段基础成本,并让列表字段成本乘以受限的 first。同时限制 HTTP body、文档长度、变量大小、fragment 数、别名数和批量 operation。验证应发生在访问数据库之前。

query Expensive {
  a: articles(first: 100) { author { name } }
  b: articles(first: 100) { author { name } }
  c: articles(first: 100) { author { name } }
}

复杂度上限可按认证等级设定。生产可用 persisted query:客户端发送已登记的文档哈希,服务端只执行白名单文档,便于按 operation 限流。它不能替代授权,变量仍不可信。

9. 字段级认证与安全

顶层 article 通过授权,不代表 Article.privateNotes 可以返回。授权应靠领域服务或 directive 在每个敏感字段/对象边界执行。列表查询必须在数据库过滤租户和可见范围,不能先取全量再由 resolver 丢弃,否则既泄漏计数/时序,也浪费资源。

浏览器 GraphQL endpoint 仍受 CSRF、CORS 和 cookie 安全约束。只接受预期 Content-Type,cookie 认证的 mutation 配 CSRF 防护,限制 GET 只执行 Query,禁止 GET mutation 被缓存或预取。Introspection 是否开放取决于产品需求;关闭它只减少信息暴露,不会修复越权。GraphiQL/Playground 在生产必须关闭或单独鉴权,示例中绝不嵌真实 token。

10. 可运行的综合示例

下面的 Schema、resolver 和启动代码组成一个最小 gqlgen 服务。先用固定版本生成代码;生成后实现文件中的接口名以实际生成骨架为准。内存 store 用锁保证并发安全,实际项目替换为领域服务。

# graph/schema.graphqls
type Article { id: ID!, title: String! }
input CreateArticleInput { title: String! }
type Query { article(id: ID!): Article }
type Mutation { createArticle(input: CreateArticleInput!): Article! }
type Store struct {
    mu   sync.RWMutex
    next uint64
    data map[string]*model.Article
}

func (s *Store) Get(ctx context.Context, id string) (*model.Article, error) {
    if err := ctx.Err(); err != nil { return nil, err }
    s.mu.RLock()
    defer s.mu.RUnlock()
    article := s.data[id]
    if article == nil { return nil, nil }
    copyOfArticle := *article
    return &copyOfArticle, nil
}

func (s *Store) Create(ctx context.Context, input model.CreateArticleInput) (*model.Article, error) {
    if err := ctx.Err(); err != nil { return nil, err }
    title := strings.TrimSpace(input.Title)
    if title == "" || utf8.RuneCountInString(title) > 120 {
        return nil, ErrInvalidTitle
    }
    s.mu.Lock()
    defer s.mu.Unlock()
    s.next++
    article := &model.Article{ID: strconv.FormatUint(s.next, 10), Title: title}
    s.data[article.ID] = article
    copyOfArticle := *article
    return &copyOfArticle, nil
}

func main() {
    store := &Store{data: make(map[string]*model.Article)}
    executable := generated.NewExecutableSchema(generated.Config{
        Resolvers: &Resolver{Store: store},
    })
    gql := handler.New(executable)
    gql.AddTransport(transport.Options{})
    gql.AddTransport(transport.POST{})
    gql.SetQueryCache(lru.New[*ast.QueryDocument](1000))

    mux := http.NewServeMux()
    mux.Handle("POST /query", http.MaxBytesHandler(gql, 1<<20))
    server := &http.Server{
        Addr:              ":8080",
        Handler:           withRequestTimeout(3*time.Second, mux),
        ReadHeaderTimeout: 3 * time.Second,
        IdleTimeout:       60 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

Resolver 实现只委托 store:

func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, error) {
    return r.Store.Get(ctx, id)
}

func (r *mutationResolver) CreateArticle(ctx context.Context, input model.CreateArticleInput) (*model.Article, error) {
    return r.Store.Create(ctx, input)
}

生产启动还应处理 Server.Shutdown,注入认证身份,并配置 complexity、错误 presenter、恢复器和观测扩展。示例不启用 GET query,避免扩大缓存与 CSRF 风险。

11. 测试与契约回归

业务 service 用普通单元测试覆盖不存在、取消、并发创建和权限。resolver 测试关注映射与错误分类;HTTP 集成测试通过 gqlgen client 或 httptest.Server 提交真实 GraphQL 文档,断言 dataerrors[].pathextensions.code。每次 Schema 变更运行兼容性检查,并保留关键客户端 operation 作为回归样本。

func TestCreateAndReadArticle(t *testing.T) {
    client := client.New(handler.New(generated.NewExecutableSchema(
        generated.Config{Resolvers: testResolver()},
    )))
    var created struct { CreateArticle struct { ID, Title string } }
    client.MustPost(`mutation($title:String!){createArticle(input:{title:$title}){id title}}`,
        &created, client.Var("title", "Go execution"))
    if created.CreateArticle.Title != "Go execution" { t.Fatal("unexpected title") }
}

DataLoader 需要测试输入顺序、重复 ID、缺失 ID、批处理错误和请求隔离。并发 resolver 与 loader 在 go test -race 下运行。性能测试使用真实 operation 和变量,分别观察解析/验证、resolver、SQL 次数、响应编码与分配;只压一个 {__typename} 得不到业务容量。

12. 观测、性能与部署

以 operationName 或 persisted-query ID 作为低基数指标维度,记录总耗时、解析验证耗时、resolver/下游耗时、复杂度、错误 code、响应字节和取消数。匿名 operation 应限制或归入固定标签,绝不能把完整 query、变量、用户 ID 当指标 label。Trace 可以记录字段路径,但高频标量字段逐个 span 会制造巨大开销,应采样或只追踪慢字段。

查询缓存保存解析后的文档,不是业务数据缓存;容量必须有限,key 应考虑 Schema 版本。响应缓存只有在身份、变量、选择集和数据版本都进入 key 时才安全。gqlgen 生成代码升级后运行全部 operation 回归和 benchmark,因为可编译不代表执行顺序、错误格式或默认 transport 没变化。

部署时先保证旧服务能接受新 Schema 流量:新增字段先上线服务,再发布客户端;删除则先迁移客户端并观察,最后删服务字段。网关设置 body 与并发限制,服务端优雅停止新请求并在 deadline 内完成在途 resolver。GraphQL 的生产边界最终落在三点:Schema 控制兼容性,resolver 控制授权与生命周期,复杂度模型控制客户端可支配的计算量。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。