GraphQL 在 Scala 生态里几乎没有悬念地收敛到了 Caliban:它把 Schema 定义变成普通 case class,把解析器变成 ZIO 效果,把 N+1 交给 ZQuery,把订阅交给 ZStream。本文从取舍讲起,一路走到生产环境的复杂度限制、联邦与 schema 演进,每一节都给出可运行的代码骨架与踩坑记录。
前置:/scala-web-http-apps/(Web 服务基础)、/scala-zio-program/(ZIO 与 ZLayer)。
目录
- 1. GraphQL 与 REST 的取舍
- 2. Caliban 入门与 Schema 派生
- 3. Schema 设计与分页建模
- 4. 查询解析与 N+1 问题
- 5. 变更与事务边界
- 6. 订阅与实时推送
- 7. 中间件鉴权与错误处理
- 8. 框架集成与联邦架构
- 9. 生产实践与 Schema 演进
- 10. 速查表与一句话记忆
- 延伸阅读
1. GraphQL 与 REST 的取舍
GraphQL 不是 REST 的升级版,而是另一组权衡:它把「返回什么字段」的决定权从服务端交给客户端,代价是把「一次请求有多贵」的责任也一并交了出去。Scala 服务端要做的,是在享受类型安全与按需取数的同时,重新把复杂度上界、缓存策略和错误语义这三件事补回来。
- 单一端点:所有读写走同一路径,靠 operation 区分语义
- 按需取数:一次请求拿到页面所需的全部字段,避免串行往返
- 强类型契约:Schema 即文档,客户端代码可由 SDL 生成
- 代价一:HTTP 层缓存天然失效,需要持久化查询与 GET 化
- 代价二:查询深度与成本由客户端决定,必须自建上界
- 代价三:N+1 从数据库层上升到图遍历层,批处理成为刚需
- 适合多端字段诉求差异大的 BFF,不适合简单 CRUD 与文件下载
| 维度 | REST | GraphQL |
|---|---|---|
| 端点形态 | 资源导向多端点 | 单一端点 |
| 取数粒度 | 服务端决定 | 客户端决定 |
| 缓存 | HTTP 缓存天然可用 | 需持久化查询与 CDN 键设计 |
| 版本演进 | URL 或 Header 版本号 | 字段废弃加渐进迁移 |
| 错误语义 | HTTP 状态码承载 | 200 加 errors 数组承载 |
| 工具链 | OpenAPI 与代码生成 | SDL 与 introspection |
query HomePage {
viewer {
id
nickname
unreadCount
}
recommended(first: 10) {
edges { node { id title coverUrl } }
pageInfo { hasNextPage endCursor }
}
}
工程要点:把 GraphQL 当成「对外 BFF 层」而非内部服务间协议。内部仍然用 REST 或 gRPC 保持简单可缓存,只有面向多端的聚合层才引入 GraphQL,这样复杂度上界与缓存退化都被限制在一层之内。
2. Caliban 入门与 Schema 派生
Caliban 的核心哲学是「Schema 就是类型」。你用普通 case class 描述领域模型,用 RootResolver 组装查询、变更、订阅三个根对象,Caliban 通过宏在编译期派生 Schema 实例。字段解析器直接返回 ZIO 或 ZStream,效果系统与 Schema 系统天然融合。
- 依赖:caliban 核心,加 caliban-zio-http 或 caliban-http4s 适配层
- 派生:case class 与 sealed trait 自动派生,无需手写 SDL
- 注解:GQLDescription 补充文档,GQLName 重命名字段
- 组装:RootResolver 接收 Queries 与 Mutations 与 Subscriptions
- 执行:graphQL 返回 GraphQLInterpreter,可直接 execute 做测试
- 可选:caliban-codegen 从既有 SDL 反向生成 Scala 类型
// build.sbt
libraryDependencies ++= Seq(
"com.github.ghostdogpr" %% "caliban" % "2.6.0",
"com.github.ghostdogpr" %% "caliban-zio-http" % "2.6.0"
)
import caliban._
import caliban.schema.Annotations.GQLDescription
import zio._
final case class User(id: Long, name: String, email: Option[String])
final case class Queries(
@GQLDescription("按 id 查询单个用户")
user: Long => ZIO[UserRepo, Throwable, Option[User]],
users: ZIO[UserRepo, Throwable, List[User]]
)
final case class Mutations(
createUser: CreateUserInput => ZIO[UserRepo, AppError, User]
)
val api: GraphQL[UserRepo] =
graphQL(RootResolver(Queries(UserRepo.find, UserRepo.all), Mutations(UserRepo.create)))
val sdl: String = api.render // 导出 SDL 供客户端代码生成与契约比对
工程要点:把 api.render 的 SDL 固化进测试快照。任何一次字段改名都会让快照失败,这是最廉价也最有效的兼容性护栏;配合 caliban-client 生成强类型客户端,前后端契约在同一份 SDL 上收敛。
3. Schema 设计与分页建模
Schema 设计是 GraphQL 服务里唯一无法靠重构补救的部分。类型一旦发布就要考虑向后兼容,因此接口与联合类型的选择、null 语义的粒度、分页形态的取舍,都应该在第一版就定下来。
- 类型:type 描述对象,字段默认可空,加感叹号表示非空
- 接口:interface 提供共享字段,实现方必须声明 implements
- 联合:union 表示互斥的多态结果,配合 inline fragment 取值
- 枚举:enum 限制取值集合,比 String 更利于客户端穷举
- 输入:input 单独描述写入载荷,不可与输出类型复用
- null 语义:非空字段一旦为 null 会向上冒泡,整棵子树变 null
- 分页:Relay Connection 以 edges 加 pageInfo 表达游标分页
interface Node { id: ID! }
type User implements Node { id: ID! name: String! email: String role: Role! }
type Post implements Node { id: ID! title: String! author: User! }
union SearchResult = User | Post
enum Role { ADMIN MEMBER GUEST }
input PostFilter { keyword: String authorId: ID }
type PageInfo { hasNextPage: Boolean! endCursor: String }
type PostEdge { node: Post! cursor: String! }
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! total: Int! }
sealed trait SearchResult
object SearchResult {
final case class UserHit(user: User) extends SearchResult
final case class PostHit(post: Post) extends SearchResult
implicit val schema: Schema[Any, SearchResult] = Schema.unionType[SearchResult]
}
final case class PageInfo(hasNextPage: Boolean, endCursor: Option[String])
final case class PostEdge(node: Post, cursor: String)
final case class PostConnection(edges: List[PostEdge], pageInfo: PageInfo, total: Int)
工程要点:Option[String] 与「字段缺失」在 GraphQL 里是两个概念。Caliban 里 Option[A] 渲染为可空字段,双层 Option 才能表达「显式 null 与未提供」的区别。绝大多数业务不需要区分,一律用单层 Option,把非空断言留给真正不可能为空的字段。
4. 查询解析与 N+1 问题
GraphQL 的 N+1 比 ORM 场景更隐蔽:列表字段本身是一次查询,但它下面的关联字段会被逐条解析。Caliban 的解法是让字段直接返回 ZQuery,把解析过程中产生的所有同类型请求收集起来,在同一个批次里合并成一次数据源调用。
- 症状:查 100 条 Post 触发 100 次 author 查询
- 根因:每个字段独立解析,效果系统无法自动识别可合并请求
- 方案一:字段返回 ZQuery,配置 DataSource 按 key 批量取
- 方案二:在 service 层手动批量,按 id 集合一次捞回再映射
- 方案三:请求级缓存,同一 key 在一次执行内只取一次
- 注意:批处理边界是「一次执行」,跨请求不共享缓存
- 验证:打开 SQL 日志,观察一次列表查询的实际语句条数
import zio.query._
final case class User(id: Long, name: String)
def fetchUsers(ids: Set[Long]): ZIO[UserRepo, Nothing, Map[Long, User]] =
UserRepo.findMany(ids).orDie
val userById: DataSource[UserRepo, Long] =
DataSource.fromFunctionBatchedZIO("UserById")(fetchUsers)
def user(id: Long): ZQuery[UserRepo, Nothing, Option[User]] =
ZQuery.fromDataSource(id)(userById)
final case class Post(id: Long, title: String, authorId: Long) {
def author: ZQuery[UserRepo, Nothing, Option[User]] = user(authorId)
}
final case class Queries(posts: ZQuery[UserRepo, Nothing, List[Post]])
| 方案 | 批处理 | 缓存 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| 手动批量 | 需自写 | 无 | 低 | 关联层级浅 |
| ZQuery 与 DataSource | 自动 | 请求级 | 中 | 关联层级深 |
| 请求级缓存 | 无 | 有 | 低 | 重复 key 多 |
工程要点:DataSource.fromFunctionBatchedZIO 的入参是 Set[Long] 而非单个 id,这是批处理生效的关键。如果数据源只支持单条查询,就先用 ZQuery.collectAllBatched 把一批 id 收集起来再落到 SQL 的 IN 查询上,否则批处理形同虚设。
5. 变更与事务边界
GraphQL 的 mutation 与 query 在协议层是平级的,但在工程上完全不同:变更要处理输入校验、幂等、事务与副作用。Caliban 的做法是把 mutation 字段写成返回 ZIO 的函数,事务边界下沉到 service 层,GraphQL 层只负责参数适配与错误映射。
- 输入类型:每个变更定义独立的 input,便于独立演进
- 事务位置:放在 service 方法内,不放在解析器里
- 幂等:客户端传入 requestId,服务端做去重表
- 副作用:事件发布在事务提交之后,避免回滚后消息已发出
- 返回类型:变更返回受影响对象本身,便于客户端更新缓存
- 错误:业务失败走类型化错误,基础设施失败走缺陷
final case class CreatePostInput(title: String, body: String, authorId: Long)
final class PostService(repo: PostRepo, events: EventBus) {
def create(input: CreatePostInput): ZIO[Any, AppError, Post] =
repo.transact {
for {
author <- repo.findUser(input.authorId).someOrFail(AppError.NotFound("author"))
post <- repo.insert(Post(0L, input.title, author.id))
} yield post
}.tap(post => events.publish(PostCreated(post.id)))
}
final case class Mutations(createPost: CreatePostInput => ZIO[PostService, AppError, Post])
工程要点:transact 必须把整个「读取校验加写入」包成一个原子块。常见错误是先查用户再开事务写文章,两者之间存在竞态;另一个错误是在事务内发布事件,一旦后续回滚,消费者已经收到了不存在的数据。
6. 订阅与实时推送
订阅是 GraphQL 里最像「另一个系统」的部分:它建立在 WebSocket 之上,长连接由 graphql-ws 协议承载,服务端把一个 ZStream 的每个元素推给客户端。Caliban 把订阅字段类型定义为 ZStream,天然复用 ZIO 的背压与资源管理。
- 协议:graphql-ws 子协议,握手后通过 message 帧交换
- 类型:订阅字段返回 ZStream,而非 ZIO
- 广播:用 Hub 做一对多分发,每个连接订阅同一个流
- 背压:有界 Hub 会阻塞慢消费者,无界 Hub 有 OOM 风险
- 清理:连接断开时 ZStream 被中断,资源随作用域释放
- 过滤:客户端传入参数,在流上做 filter 而非建多个主题
- 心跳:配置 keepAlive 避免中间代理掐断空闲连接
import caliban.schema.Subscription
import zio.stream.ZStream
final case class Subscriptions(postAdded: ZStream[Any, Nothing, Post])
object Broadcast {
// 生产建议用有界 Hub 加丢弃策略,避免慢消费者拖垮发布者
val hub: UIO[Hub[Post]] = Hub.bounded[Post](1024, Strategy.DropOldest)
def publish(post: Post): UIO[Boolean] = hub.flatMap(_.publish(post))
def stream: ZStream[Any, Nothing, Post] = ZStream.fromHub(hub)
}
val apiWithSubs =
graphQL(RootResolver(Queries(...), Mutations(...), Subscriptions(Broadcast.stream)))
工程要点:订阅数量天然随连接数增长,任何在订阅流里做全量查询的写法都会在连接数上升时雪崩。约定「订阅只推送变更的 id 与最小字段,客户端收到后再走一次 query 补齐」,把推送流量压到最低。
7. 中间件鉴权与错误处理
GraphQL 的错误语义与 REST 完全不同:HTTP 状态码几乎恒为 200,失败信息藏在 errors 数组里。Caliban 把错误分成两类——可预期的类型化错误进 errors,不可预期的缺陷走 ZIO 的 defect 通道并被记录——这个边界必须在设计时就划清。
- 类型化错误:错误类型有 Schema 时自动渲染进 errors 数组
- 缺陷:ZIO 的 Throwable 缺陷不暴露给客户端,只记日志
- 鉴权位置:wrapExecution 包住整个执行,或自定义指令挂到字段
- 指令:用 GQLDirective 声明注解,再在 Schema 包装中拦截
- 错误扩展:在错误类型里带上 code 字段,客户端按 code 分支
- 脱敏:不要把底层异常消息直接透出,防止泄露表结构
- 日志:为每次执行打上 operationName 与 requestId 标注
sealed trait AppError
object AppError {
final case class NotFound(what: String) extends AppError
final case class Invalid(reason: String) extends AppError
final case class Forbidden(reason: String) extends AppError
implicit val schema: Schema[Any, AppError] = Schema.gen[AppError]
}
// 整个执行包一层鉴权,未通过直接短路
val secured: GraphQLInterpreter[AuthService with UserRepo] =
api.wrapExecution { (effect, _) => ZIO.serviceWithZIO[AuthService](_.ensure) *> effect }
工程要点:把「校验失败」建模成类型化错误而不是抛异常,客户端才能可靠地区分「我传错了」与「服务挂了」。同时给错误类型补一个 code: String 字段并写入 extensions,前端按 code 做分支比匹配文案稳健得多。
8. 框架集成与联邦架构
Caliban 本身只负责 Schema 与执行,HTTP 与 WebSocket 由适配层提供。选择哪一层取决于现有技术栈:ZIO 项目用 zio-http,Cats Effect 项目用 http4s,存量 Play 项目则通过 Tapir 端点接入。跨团队时再往上叠加 Apollo Federation,把单体 schema 拆成可组合的子图。
- zio-http:原生 ZIO,订阅与路由一体,最省心
- http4s:Cats Effect 生态,用 caliban-http4s 适配
- Play:通过 Tapir 端点接入,保留既有中间件
- Federation:caliban-federation 加 GQLKey 注解声明实体
- 网关:Apollo Router 或 GraphQL Mesh 负责子图编排
- 性能:复杂度限制、深度限制、持久化查询三件套
- 观测:ApolloTracing 或 OpenTelemetry 记录每个解析器耗时
import caliban.federation._
import caliban.schema.Annotations.GQLKey
@GQLKey("id")
final case class User(id: Long, name: String)
// 声明本子图能通过 id 解析出的实体,供网关跨子图拼接
val entityResolver: EntityResolver[UserRepo] =
EntityResolver.from[User](args => UserRepo.find(args("id").toLong))
val federatedApi = federated(graphQL(RootResolver(Queries(...))), entityResolver)
工程要点:持久化查询(APQ)是生产环境性价比最高的一项优化:客户端首次发送完整查询,服务端以哈希缓存,之后只发哈希。它同时解决三件事——请求体变小、缓存键可预测、任意查询被拦截在哈希白名单之外。
9. 生产实践与 Schema 演进
上线之后,GraphQL 服务的运维重点从「能不能跑」转向「能不能控」。可观测性要能回答「哪个字段最慢」,缓存要能回答「哪些查询可以复用」,测试要能回答「这次改动是否破坏契约」,演进要能回答「旧客户端会不会挂」。
- 观测:按 operationName 与字段路径打点,记录解析器耗时与错误率
- 缓存:响应缓存以查询哈希加变量哈希为键,注意用户隔离
- 测试:SDL 快照、解析器单测、端到端契约测试三层
- 演进:只增不删,字段先标 deprecated,观察调用量归零再移除
- 兼容:给参数加默认值可安全新增,改类型与改可空性均破坏兼容
- 压测:用真实客户端查询集而非手写简单查询,才暴露 N+1
- 灰度:新字段先对内部用户开放,再逐步放量
| 变更类型 | 是否破坏兼容 | 处理方式 |
|---|---|---|
| 新增字段 | 否 | 直接发布 |
| 字段标记废弃 | 否 | 加 deprecated 观察用量 |
| 字段改为非空 | 是 | 新建字段并迁移 |
| 修改字段类型 | 是 | 新建字段并迁移 |
| 删除字段 | 是 | 用量归零后再删 |
// 契约测试:把 SDL 快照作为断言对象
object SchemaSpec extends ZIOSpecDefault {
def spec = suite("schema")(
test("SDL 关键类型保持稳定") { assertTrue(api.render.contains("type Query")) }
)
}
工程要点:GraphQL 的兼容性规则比 REST 更细,但核心只有一条——任何让既有查询返回类型发生变化的改动都是破坏性的。把字段废弃当作流程而非语法:先标记、再观测调用量、最后删除,中间至少跨一个发布周期。
10. 速查表与一句话记忆
| 场景 | 做法 |
|---|---|
| 定义 Schema | case class 加 RootResolver,或由 SDL 反向生成 |
| 多态返回 | sealed trait 派生联合类型,接口用于共享字段 |
| 游标分页 | Relay Connection,edges 加 pageInfo |
| 消除 N+1 | 字段返回 ZQuery,配置批量 DataSource |
| 事务边界 | 下沉到 service 层,读取校验与写入同块 |
| 实时订阅 | 订阅字段返回 ZStream,Hub 做广播中心 |
| 鉴权 | wrapExecution 包执行,或指令挂字段 |
| 错误建模 | 类型化错误进 errors,缺陷只记日志 |
| 跨团队拆分 | caliban-federation 加 GQLKey 声明实体 |
| 成本控制 | 深度与复杂度限制,叠加持久化查询白名单 |
一句话记忆:Caliban 把 GraphQL 变成一张纯函数式的类型化图,N+1 交给 ZQuery 批处理,实时交给 ZStream,剩下的全是 Schema 治理与成本控制。
延伸阅读
- /scala-web-http-apps/ — Web 服务与路由基础
- /scala-zio-program/ — ZIO 与 ZLayer 依赖注入
- /scala-functional-error-handling/ — 类型化错误建模
- /scala-database-access/ — 数据访问与事务
- /scala-observability-logging-tracing/ — 可观测性与链路追踪
- /scala-testing-practice/ — 测试分层与契约测试
- GraphQL 专题 — GraphQL 全栈主题文章
- Scala 专题 — 本专题全部文章
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。