前置知识: Go

Go 与 GraphQL:前端要什么字段,由前端说了算

6 min中级

以"移动端为三个字段打一个新端点"为主线学 gqlgen:Schema 先行与代码生成、Resolver 树、N+1 与 DataLoader、订阅与认证、错误结构与非空语义、适用边界判断,附坑点、自检与练习。

真实场景:三个端,三份”差不多”的响应

你维护着一个内容站的后端:Web 首页要用户的昵称加最新 3 篇文章标题,移动端只要昵称,小程序要多带头像。REST 的做法是铺端点——/users, /users/:id/posts, /me/summary——每来一个新终端就再铺一层。响应要么字段太多(过度获取,流量白花),要么凑不够(获取不足,前端再打一轮)。

GraphQL 换了个方向:服务端只声明”有哪些数据可取”(Schema),每个请求里由客户端写明”我要哪些字段”,服务端按请求拼装响应。Go 生态的主流实现是 gqlgen:schema-first 路线,先写 Schema 文件,工具生成类型安全的 Go 代码,业务只填 Resolver。

动手第一步:五分钟起一个能查的 GraphQL 服务

bash
mkdir mygraph && cd mygraph
go mod init mygraph
go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init

初始化后的结构:

text
mygraph/
  graph/
    schema.graphqls    # Schema:你唯一要手工维护的契约
    resolver.go        # 根解析器
    model/             # 自动生成的模型
    generated.go       # 自动生成(永不手改)
  server.go

编辑 graph/schema.graphqls:

graphql
type User {
  id: ID!
  name: String!
  email: String!
  age: Int
}

type Query {
  users: [User!]!
  user(id: ID!): User
}

input NewUser {
  name: String!
  email: String!
  age: Int
}

type Mutation {
  createUser(input: NewUser!): User!
}

每次改 Schema 后重新生成,然后填 Resolver:

bash
go run github.com/99designs/gqlgen generate
go
package graph

import (
    "context"
    "fmt"

    "mygraph/graph/model"
)

var users []*model.User
var nextID = 1

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    return users, nil
}

func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    for _, u := range users {
        if u.ID == id {
            return u, nil
        }
    }
    return nil, fmt.Errorf("用户不存在: %s", id)
}

func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
    user := &model.User{
        ID:    fmt.Sprintf("%d", nextID),
        Name:  input.Name,
        Email: input.Email,
        Age:   input.Age,
    }
    nextID++
    users = append(users, user)
    return user, nil
}
bash
go run server.go

打开 http://localhost:8080/ 的 Playground,先建一个用户再查询:

graphql
mutation {
  createUser(input: { name: "小明", email: "ming@example.com", age: 25 }) {
    id
    name
  }
}
json
{
  "data": {
    "createUser": {
      "id": "1",
      "name": "小明"
    }
  }
}

随后执行 query { users { name } } 与 query { users { id name email age } },对比两个响应:同一个 Resolver,响应形状由请求决定。这就是开头场景的答案——三个终端各查各的字段,服务端不再铺端点。代价也要看到:服务端失去了对响应形状与缓存键的控制(HTTP 缓存按 URL,GraphQL 全打同一个端点),公网 API 引入前要掂量。

动手第二步:关联字段与 N+1——GraphQL 的头号性能陷阱

给文章加作者关联:

graphql
type Post {
  id: ID!
  title: String!
  author: User!
}

gqlgen 会为关联字段生成独立的 Resolver:

go
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
    for _, u := range users {
        if u.ID == obj.AuthorID {
            return u, nil
        }
    }
    return nil, fmt.Errorf("作者不存在")
}

现在请求 query { posts { title author { name } } }:Resolver 树逐字段执行,每篇文章的 author 各触发一次 Author。10 篇文章就是 10 次用户查询;嵌套再深一层就是 100 次。这就是 N+1,根因是”每个字段独立解析”这个设计本身。

解法是 DataLoader:同一个请求周期内,把零散的 Load 攒成一个批量查询。它依赖 gqlgen 的 Resolver 在同一事件循环内并发执行,批处理函数通常在一次事件循环 tick 后被触发,用 IN (...) 一把查回全部 key:

go
import "github.com/graph-gophers/dataloader/v7"

userLoader := dataloader.NewBatchedLoader(
    func(ctx context.Context, keys []string) []*dataloader.Result[*model.User] {
        found := db.GetUsersByIDs(keys) // 一条 IN 查询
        results := make([]*dataloader.Result[*model.User], len(keys))
        for i, key := range keys {
            results[i] = &dataloader.Result[*model.User]{Data: found[key]}
        }
        return results
    },
)

func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
    thunk := userLoader.Load(ctx, obj.AuthorID) // 先登记,不立即执行
    return thunk()                              // 攒批后统一执行
}

接入后重放同一查询,数据库日志从 10 条 SELECT 变成 1 条 IN——生产 GraphQL 服务基本都需要这一层。

动手第三步:认证、错误与订阅

认证走 HTTP 中间件注入 context,Resolver 里取用。键必须用未导出自定义类型,避免与第三方包冲突(go vet 也会对 string 键报警):

go
type contextKey string

const userKey contextKey = "user"

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if token := r.Header.Get("Authorization"); token != "" {
            ctx := context.WithValue(r.Context(), userKey, validateToken(token))
            r = r.WithContext(ctx)
        }
        next.ServeHTTP(w, r)
    })
}

// Resolver 内
user, ok := ctx.Value(userKey).(*AuthUser)
if !ok || user == nil {
    return nil, fmt.Errorf("未认证")
}

GraphQL 的错误是结构化的 errors 数组(HTTP 状态码恒为 200),带路径信息:

go
import "github.com/99designs/gqlgen/graphql"

return nil, graphql.ErrorOnPath(ctx, fmt.Errorf("用户名不能为空"))

实时推送用 Subscription,Resolver 返回一个 channel,ctx 结束时收尾:

go
func (r *subscriptionResolver) UserCreated(ctx context.Context) (<-chan *model.User, error) {
    ch := make(chan *model.User)
    r.userCreatedCh <- ch
    go func() {
        <-ctx.Done()
        close(ch)
    }()
    return ch, nil
}

讲为什么:非空语义与适用边界

Schema 里 ! 的含义要精确理解:User! 表示这个位置必须有值。反过来的推论常被忽略——出错会沿非空链向上传播:若列表声明为 [User!]!,任何一个元素解析失败,整个列表都变成 error。所以字段级 API 设计的经验法则是”可能失败的字段留可空”:把 age: Int(不带 !)设计成可空,单个字段失败只影响那一个位置。

自定义标量与横切逻辑也都在 Schema 上声明。时间类型在 gqlgen.yml 里映射到现成实现;字段级授权用指令声明,比在 Resolver 里散落 if 更可审:

graphql
scalar Time

directive @auth(role: String!) on FIELD_DEFINITION

type Query {
  adminData: String! @auth(role: "admin")
}
yaml
models:
  Time:
    model: github.com/99designs/gqlgen/graphql.Time

最后回答”什么时候不用 GraphQL”:内部服务间同步调用要的是低延迟与强契约,选 gRPC;简单 CRUD、强 HTTP 缓存、公开只读 API,REST 更简单。GraphQL 的主场是面向多变客户端的聚合层与 BFF——正是开头”三端三份响应”的那类场景。已有 REST 资产的迁移路线也平滑:Resolver 内部先调用现有 REST 接口,外部契约先 GraphQL 化,内部实现再逐步重写。

坑点与自检

坑 1:手改 generated.go。 下次 generate 全部覆盖。业务逻辑只进 resolver 文件;模型要换自己的类型时在 gqlgen.yml 的 models 段配置映射。

坑 2:改了 Schema 忘了 generate。 编译报错一大片”未实现”,提示你重新生成;CI 里可以加一步”generate 后 git diff —exit-code”防止 Schema 与代码漂移。

坑 3:N+1 无感知上线。 本地三条数据看不出问题,上线后一条深查询放大成上千次数据库往返。给 Resolver 层加查询计数日志,接入 DataLoader 后对比确认。

坑 4:context 键用 string。 与其他包冲突且 go vet 报警,用未导出自定义类型。

坑 5:把 GraphQL 端点直接暴露给公网还开着 introspection。 Schema 全貌等于交给攻击者;生产环境关闭 introspection,配深度与复杂度限制,防止 users { posts { author { posts { ... } } } } 这类深嵌套查询打爆服务。

自检——能不看文档回答这些吗:

  1. GraphQL 与 REST 在”响应形状由谁决定”上的本质差异?各自牺牲了什么?
  2. gqlgen 的 schema-first 流程中,哪些文件手改、哪些生成?
  3. N+1 的根因是什么?DataLoader 攒批依赖什么执行机制?
  4. [User!]! 里某元素解析失败会发生什么?为什么可能失败的字段应设计为可空?
  5. GraphQL 错误的传输形态与 HTTP 状态码的关系?
  6. 什么场景应该选 gRPC/REST 而不是 GraphQL?

练习

  1. 跑通用户示例后给 Schema 加 posts: [Post!]! 关联,先直接实现 users { posts { author { posts } } } 触发 N+1,在数据访问层打日志数查询次数;接入 DataLoader 后复测,写下前后对比。
  2. 实现 @auth 指令:无 admin 角色访问 adminData 返回 GraphQL 错误,用 Playground 分别带与不带 token 观察响应 JSON 的 errors 结构。
  3. 把你手头某个 REST 端点包装成 GraphQL Query(Resolver 内部仍调 REST),对比两种方式取”用户 + 前 3 篇文章”的请求次数与响应字节数,写一段不超过 200 字的结论。

下一步