Scala GraphQL 服务实战:Caliban、Schema 设计与订阅

以 Caliban 为主线,系统讲解 Scala 侧 GraphQL 服务的落地路径。覆盖 Schema 定义与 case class 派生、接口与联合类型、Relay 分页、ZQuery 批处理消除 N+1、变更与事务边界、基于 ZStream 的订阅推送,以及鉴权指令、查询复杂度限制、Apollo Federation 与 schema 演进等生产议题,并给出可直接复用的代码片段与踩坑清单。

GraphQL 在 Scala 生态里几乎没有悬念地收敛到了 Caliban:它把 Schema 定义变成普通 case class,把解析器变成 ZIO 效果,把 N+1 交给 ZQuery,把订阅交给 ZStream。本文从取舍讲起,一路走到生产环境的复杂度限制、联邦与 schema 演进,每一节都给出可运行的代码骨架与踩坑记录。

前置:Web 服务基础、ZIO 与 ZLayer。

目录

1. GraphQL 与 REST 的取舍

GraphQL 不是 REST 的升级版,而是另一组权衡:它把「返回什么字段」的决定权从服务端交给客户端,代价是把「一次请求有多贵」的责任也一并交了出去。Scala 服务端要做的,是在享受类型安全与按需取数的同时,重新把复杂度上界、缓存策略和错误语义这三件事补回来。

- 单一端点:所有读写走同一路径,靠 operation 区分语义
- 按需取数:一次请求拿到页面所需的全部字段,避免串行往返
- 强类型契约:Schema 即文档,客户端代码可由 SDL 生成
- 代价一:HTTP 层缓存天然失效,需要持久化查询与 GET 化
- 代价二:查询深度与成本由客户端决定,必须自建上界
- 代价三:N+1 从数据库层上升到图遍历层,批处理成为刚需
- 适合多端字段诉求差异大的 BFF,不适合简单 CRUD 与文件下载
维度RESTGraphQL
端点形态资源导向多端点单一端点
取数粒度服务端决定客户端决定
缓存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. 速查表与一句话记忆

场景做法
定义 Schemacase 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」更多文章

  1. Scala 云原生部署实战:容器化、健康检查与 Kubernetes 运维
  2. Scala Web 安全与鉴权实战:JWT、OAuth2 与安全加固
  3. Scala 缓存与 Redis 集成:Caffeine、Redis4cats 与缓存模式