前置知识: Go

Go 与 GraphQL

6 min中级

gqlgen 实战:Schema 定义、代码生成、Resolver、N+1 与 DataLoader、订阅、认证与联邦,REST 之外的 API 选择。

概述

GraphQL 是一种 API 查询语言,由 Facebook 开发。与 REST API 不同,GraphQL 允许客户端精确指定需要的数据字段,避免过度获取或获取不足。Go 社区中最成熟的 GraphQL 框架是 gqlgen,它采用代码优先(schema-first)的方式,先定义 GraphQL Schema,然后自动生成类型安全的 Go 代码。

基础概念

在开始编码之前,需要理解 GraphQL 的几个核心概念:

  • Schema:GraphQL 的类型定义文件,描述了 API 支持哪些查询、变更和类型。
  • Query:读取数据的操作,类似 REST 的 GET。
  • Mutation:修改数据的操作,类似 REST 的 POST/PUT/DELETE。
  • Resolver:解析器函数,负责为 Schema 中的每个字段提供实际数据。
  • 类型(Type):GraphQL 中的数据模型,类似 Go 的结构体。
  • 输入类型(Input):用于 Mutation 参数的特殊类型。

GraphQL 的优势在于:客户端按需获取字段、一次请求获取多个资源、强类型系统自动生成文档。

快速上手

初始化项目

# 创建项目目录
mkdir mygraph && cd mygraph
go mod init mygraph

# 安装 gqlgen
go get github.com/99designs/gqlgen

# 初始化 gqlgen 项目
go run github.com/99designs/gqlgen init

初始化后,项目结构如下:

mygraph/
  graph/
    schema.graphqls    # GraphQL Schema 定义
    resolver.go        # 根解析器
    model/             # 自动生成的模型
    generated.go       # 自动生成的代码(不要手动修改)
  server.go            # HTTP 服务器入口

定义 Schema

编辑 graph/schema.graphqls:

# 定义数据类型
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 后,需要重新生成代码:

go run github.com/99designs/gqlgen generate

实现 Resolver

编辑 graph/resolver.go,实现具体的业务逻辑:

package graph

import (
    "context"
    "fmt"
    "mygraph/graph/model"
)

// 用户数据存储(实际项目中使用数据库)
var users []*model.User
var nextID int = 1

// 查询所有用户
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    return users, nil
}

// 根据 ID 查询用户
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
}

运行服务器

go run server.go

访问 http://localhost:8080/ 可以打开 GraphQL Playground,在其中执行查询:

# 创建用户
mutation {
  createUser(input: { name: "小明", email: "ming@example.com", age: 25 }) {
    id
    name
    email
  }
}

# 查询所有用户
query {
  users {
    id
    name
    email
    age
  }
}

# 只查询名字(按需获取字段)
query {
  users {
    name
  }
}

第一个 mutation 的预期响应:

{
  "data": {
    "createUser": {
      "id": "1",
      "name": "小明",
      "email": "ming@example.com"
    }
  }
}

关键观察:第二个查询返回四个字段、第三个只返回 name——同一个 Resolver,响应形状由客户端在请求里决定,这正是 GraphQL 与固定形状的 REST 端点的本质差异,也是它”避免过度获取”的由来。代价是服务端失去了对响应形状与缓存键的控制,公网 API 要慎重。

详细用法

1. 关联类型

GraphQL 支持类型之间的关联关系:

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

type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Query {
  posts: [Post!]!
}

对应的 Resolver:

// 查询文章列表
func (r *queryResolver) Posts(ctx context.Context) ([]*model.Post, error) {
    return posts, nil
}

// 查询文章的作者(关联查询)
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("作者不存在")
}

// 查询用户的文章列表
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
    var result []*model.Post
    for _, p := range posts {
        if p.AuthorID == obj.ID {
            result = append(result, p)
        }
    }
    return result, nil
}

2. 分页查询

使用 GraphQL 的 Connection 模式实现分页:

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type Query {
  users(first: Int, after: String): UserConnection!
}

3. 订阅(Subscription)

Subscription 用于实时推送数据更新:

type Subscription {
  userCreated: User!
}

Resolver 实现:

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
}

4. 认证中间件

在 Resolver 中获取请求上下文进行认证:

// contextKey 是未导出的私有类型:避免与第三方包的键冲突,
// 这是 context.WithValue 的标准姿势(不要直接用 string 作键)
type contextKey string

const userKey contextKey = "user"

// 在 HTTP 中间件中设置用户信息
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token != "" {
            user := validateToken(token)
            ctx := context.WithValue(r.Context(), userKey, user)
            r = r.WithContext(ctx)
        }
        next.ServeHTTP(w, r)
    })
}

// 在 Resolver 中获取用户
func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
    user, ok := ctx.Value(userKey).(*AuthUser)
    if !ok || user == nil {
        return nil, fmt.Errorf("未认证")
    }
    // ... 创建用户逻辑
}

5. 错误处理

GraphQL 有自己的错误格式:

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

func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
    if input.Name == "" {
        // 返回带错误码的 GraphQL 错误
        return nil, graphql.ErrorOnPath(ctx, fmt.Errorf("用户名不能为空"))
    }
    // ...
}

6. 自定义标量

GraphQL 默认支持 Int、Float、String、Boolean、ID。可以自定义标量类型:

scalar Time

type Event {
  id: ID!
  name: String!
  createdAt: Time!
}

在 gqlgen.yml 中配置映射:

models:
  Time:
    model: github.com/99designs/gqlgen/graphql.Time

常见场景

场景一:REST 迁移到 GraphQL

逐步迁移,先包装现有 REST API:

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    // 调用现有的 REST API
    resp, err := http.Get("http://api.internal/users")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var users []*model.User
    json.NewDecoder(resp.Body).Decode(&users)
    return users, nil
}

场景二:多数据源聚合

GraphQL 的优势之一是可以在一个请求中聚合多个数据源:

func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
    // 从文章服务获取
    return postService.GetByAuthor(ctx, obj.ID)
}

func (r *userResolver) Orders(ctx context.Context, obj *model.User) ([]*model.Order, error) {
    // 从订单服务获取
    return orderService.GetByUser(ctx, obj.ID)
}

场景三:N+1 查询优化

使用 DataLoader 批量加载数据,避免 N+1 问题:

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

// 创建 DataLoader
userLoader := dataloader.NewBatchedLoader(func(ctx context.Context, keys []string) []*dataloader.Result[*model.User] {
    // 批量查询所有用户
    users := db.GetUsersByIDs(keys)
    results := make([]*dataloader.Result[*model.User], len(keys))
    for i, key := range keys {
        results[i] = &dataloader.Result[*model.User]{Data: users[key]}
    }
    return results
})

// 在 Resolver 中使用
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
    thunk := userLoader.Load(ctx, obj.AuthorID)
    result, err := thunk()
    return result, err
}

注意事项与常见错误

  1. 不要手动修改 generated.go:这个文件由 gqlgen 自动生成,修改后会在下次生成时被覆盖。所有自定义逻辑写在 resolver.go 中。

  2. Schema 变更后必须重新生成:修改 schema.graphqls 后,必须运行 go run github.com/99designs/gqlgen generate 更新代码。

  3. N+1 查询问题:如果 User 有一个 posts 字段,查询 10 个用户时会触发 10 次文章查询;嵌套再深一层就是 100 次。根因是 GraphQL 按字段逐个解析、每个 Resolver 独立执行。DataLoader 的思路是”同一个请求周期内,把零散的 Load 攒成一个批量查询”——它依赖 gqlgen 的 Resolver 在同一事件循环内并发执行,因此批处理函数通常在一次事件循环 tick 后被触发,用 IN (...) 一把查回全部 key。

  4. Context 传递:GraphQL 的 Context 与 HTTP 的 Context 是同一个,可以在中间件中设置值,在 Resolver 中读取。键必须用未导出的自定义类型(见认证中间件示例),直接用 string 既容易冲突,静态检查工具(go vet)也会报警。

  5. 空值处理:Schema 中带 ! 的字段表示非空,Resolver 必须返回非 nil 值。不带 ! 的字段可以返回 nil。注意”非空传播”:若列表元素类型是 User!,某个元素解析出错会导致整个列表变 error,给可能失败的字段留可空位是更稳的 API 设计。

进阶用法

自定义模型

默认情况下 gqlgen 会根据 Schema 自动生成模型。如果需要使用自定义模型,在 gqlgen.yml 中配置:

models:
  User:
    model: myapp/models.User

指令(Directive)

自定义指令实现横切关注点:

directive @auth(role: String!) on FIELD_DEFINITION

type Query {
  adminData: String! @auth(role: "admin")
}

Federation(联邦)

多个 GraphQL 服务可以组合成一个统一的 API:

go get github.com/99designs/gqlgen/plugin/federation

在 gqlgen.yml 中启用:

federation:
  filename: graph/federation.go
  package: graph

本篇小结

  1. GraphQL 以 Schema 为单一事实源:客户端声明要什么字段,服务端按需解析响应。gqlgen 走 schema-first 路线,generate 把 .graphqls 编译成类型安全的 Go 代码,业务只写 Resolver,generated.go 永远不手改。
  2. Resolver 树逐字段执行:关联字段是天然入口(postResolver.Author),也正因逐字段执行才产生 N+1 问题,DataLoader 用”单请求内攒批 + IN 查询”化解,是生产 GraphQL 服务的标配组件。
  3. 认证走 HTTP 中间件注入 Context,Resolver 用未导出类型键取值;字段级授权用自定义指令声明在 Schema 上。
  4. GraphQL 的错误是结构化的 errors 数组,graphql.ErrorOnPath 可以带上字段路径;非空语义(!)会在出错时向上传播,可能失败的字段应设计为可空。
  5. 适用边界要清楚:面向多端、形状多变的聚合层与 BFF 是 GraphQL 的主场;内部服务间通信、强缓存诉求、简单 CRUD 仍应首选 REST/gRPC。

动手实践

  1. 跑通本篇的用户示例后,给 Schema 增加 posts: [Post!]! 关联,先直接实现再故意查 users { posts { author { posts } } } 触发 N+1,在数据库层打日志数一数查询次数,然后接入 DataLoader 复测对比。
  2. 实现 @auth 指令:未带 admin 角色的请求访问 adminData 时返回 GraphQL 错误,用 Playground 分别验证带与不带 token 的响应 JSON 结构。
  3. 把已有的某个 REST 端点包装成 GraphQL Query(Resolver 内部仍调 REST),对比两种方式下前端获取”用户 + 其前 3 篇文章”所需的请求次数与响应字节数。