Python GraphQL API:Strawberry 与 Schema 设计

Python GraphQL API 实战:Strawberry 类型与 resolver、Schema 设计(接口/联合/分页)、DataLoader 消除 N+1、上下文鉴权与错误脱敏、订阅推送、查询复杂度与深度限制、与 FastAPI 集成及生产部署要点。

REST 的痛点是「一个页面要打七八个接口,每个接口返回一堆用不上的字段」;GraphQL 的解法是「一个端点、客户端自述所需字段」。但把控制权交给客户端,也意味着把 N+1 查询、超深嵌套、查询风暴的风险一并交给了服务端。

Python 生态里写 GraphQL 的主流选择是 Strawberry(基于类型标注、代码优先)与 Graphene(较老、schema 优先)。本文以 Strawberry 为主线,重点不在 API 罗列,而在两个真正决定生产可用性的问题:Schema 怎么设计才不给自己挖坑、N+1 与查询成本怎么控制。

1. GraphQL 与 REST 的取舍

1.1 核心差异

维度RESTGraphQL
端点数量每个资源一个单一端点
返回字段服务端决定客户端声明
版本管理/v2/ 路径加字段即可,无版本
缓存HTTP 缓存天然可用需持久化查询或 CDN 配合
错误语义HTTP 状态码200 + errors 数组
过度获取常见基本消除
请求次数多次往返一次取回
学习成本低中高

GraphQL 解决的是客户端形状多样的问题:同一个后端要服务 Web、iOS、Android、小程序,各端需要的字段差异大。如果只有一个客户端且资源边界清晰,REST 往往更简单。

1.2 什么时候不该用 GraphQL

  • 接口纯粹是内部服务间调用(gRPC 更合适)
  • 需要 HTTP 缓存与 CDN 边缘缓存(GraphQL 的 POST + 动态查询天然不友好)
  • 团队没有 schema 治理能力,容易演变成「一个巨型类型」
  • 文件上传、流式响应为主(GraphQL 处理这些很别扭)

2. Strawberry 入门

2.1 安装与最小 schema

uv add strawberry-graphql[fastapi]
import strawberry
from typing import Optional

@strawberry.type
class Author:
    id: strawberry.ID
    name: str

@strawberry.type
class Book:
    id: strawberry.ID
    title: str
    year: int
    author: Author

@strawberry.type
class Query:
    @strawberry.field
    def book(self, id: strawberry.ID) -> Optional[Book]:
        return load_book(id)

    @strawberry.field
    def books(self, limit: int = 20) -> list[Book]:
        return list_books(limit)

schema = strawberry.Schema(query=Query)

类型来自普通 Python 类,@strawberry.type 把字段与标注映射成 GraphQL 类型。strawberry.ID 对应 GraphQL 的 ID 标量(序列化为字符串)。Optional[X] 映射为可空字段,这是 GraphQL 里默认不可空、必须显式标注才可空的语义——与 Python 恰好相反,务必留意。

2.2 Mutation 与 Input 类型

@strawberry.input
class CreateBookInput:
    title: str
    year: int
    author_id: strawberry.ID

@strawberry.type
class Mutation:
    @strawberry.mutation
    def create_book(self, input: CreateBookInput) -> Book:
        return repo.create(input.title, input.year, input.author_id)

schema = strawberry.Schema(query=Query, mutation=Mutation)

用 @strawberry.input 定义入参对象而非直接堆参数,有两个好处:字段可复用、将来加可选字段不破坏已有调用方。命名约定上,单个参数用 input,多个语义独立的参数直接展开。

2.3 与 FastAPI 集成

from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

async def get_context() -> dict:
    return {"request": None}

graphql_app = GraphQLRouter(schema, context_getter=get_context)
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")

启动后访问 /graphql 即得到内置的 GraphiQL 交互界面。GraphQLRouter 也支持 graphiql=False 在生产环境关闭 IDE。若你尚未搭好 Web 层,可先参考 Python Web 框架 选型;FastAPI 与 Strawberry 的组合是当前最省心的搭配。

2.4 异步 resolver

@strawberry.type
class Query:
    @strawberry.field
    async def books(self, limit: int = 20) -> list[Book]:
        return await repo.list_async(limit)

Strawberry 完全支持 async def,且与 FastAPI 的事件循环共用。原则是:IO 密集一律 async,CPU 密集交给进程池(否则会阻塞整个事件循环,拖垮并发)。

3. Schema 设计

3.1 可空性是契约

@strawberry.type
class User:
    id: strawberry.ID
    email: str                  # 不可空:永远存在
    nickname: Optional[str]     # 可空:可能未设置
    avatar_url: Optional[str] = None

判断标准是「业务上是否可能缺失」,而不是「数据库列是否 NOT NULL」。把「理论上永远有值」的字段标为不可空,客户端就不必写防御代码;反过来把可能缺失的字段标成不可空,一次空值就会让整个查询失败(GraphQL 的可空性错误会冒泡到最近的可空父级)。

3.2 接口与联合

@strawberry.interface
class Node:
    id: strawberry.ID

@strawberry.type
class Article(Node):
    title: str

@strawberry.type
class Video(Node):
    duration: int

@strawberry.type
class Query:
    @strawberry.field
    def search(self, q: str) -> list[Node]:
        ...

# 联合类型:成员无共同字段
SearchResult = strawberry.union("SearchResult", (Article, Video))

接口(Interface) 表示「有一组共同字段」;联合(Union) 表示「是其中某一个」。客户端用内联片段(inline fragment)区分具体类型:

query {
  search(q: "python") {
    __typename
    ... on Article { title }
    ... on Video { duration }
  }
}

3.3 分页:Relay 连接规范

GraphQL 官方推荐 Relay 的 Connection 模式:

@strawberry.type
class PageInfo:
    has_next_page: bool
    end_cursor: Optional[str]

@strawberry.type
class BookEdge:
    node: Book
    cursor: str

@strawberry.type
class BookConnection:
    edges: list[BookEdge]
    page_info: PageInfo

@strawberry.type
class Query:
    @strawberry.field
    def books(self, first: int = 20, after: Optional[str] = None) -> BookConnection:
        ...
分页方案优点缺点
偏移量 offset/limit实现简单,可跳页深分页慢,数据变动会错位
游标 first/after稳定、性能好不能跳页
全量 + 客户端分页简单数据量大时不可行

游标(cursor)通常用「排序键 + 主键」编码,既能保证唯一排序,也能用 WHERE (k, id) > (?, ?) 走索引。这与 Python 数据库与 ORM 中「避免深偏移、改用键集分页」的建议是同一个工程结论。

3.4 枚举与标量

from enum import Enum
import strawberry
import datetime

@strawberry.enum
class Status(Enum):
    DRAFT = "draft"
    PUBLISHED = "published"

@strawberry.scalar(
    serialize=lambda v: v.isoformat(),
    parse_value=lambda v: datetime.datetime.fromisoformat(v),
)
class DateTime:
    ...

自定义标量(scalar)用于精确控制序列化,典型如 DateTime、JSON、Decimal。把 Decimal 直接暴露成 Float 会在金额场景引入浮点误差,必须自定义标量按字符串传输。

3.5 字段命名与描述

@strawberry.type
class User:
    created_at: datetime.datetime = strawberry.field(
        description="账户创建时间(UTC)",
        name="createdAt",
    )

约定:GraphQL 字段用 camelCase,Python 用 snake_case,Strawberry 可自动转换;每个公开字段都写 description,它会进入 introspection 结果,是客户端文档的唯一来源。

4. DataLoader 与 N+1

4.1 N+1 是怎么产生的

@strawberry.field
def author(self) -> Author:
    return db.get_author(self.author_id)   # 每本书查一次作者

查询 100 本书时,GraphQL 会为每本书各调用一次 resolver,于是产生 1(列表)+ 100(作者)= 101 次查询。这是 GraphQL 最常见的性能灾难,且在开发环境数据量小时完全看不出来。

4.2 DataLoader 批处理

from strawberry.dataloader import DataLoader

async def load_authors(keys: list[str]) -> list[Author]:
    rows = await db.fetch_authors(keys)          # 一次 IN 查询
    by_id = {r.id: r for r in rows}
    return [by_id.get(k) for k in keys]          # 顺序必须与 keys 对应

@strawberry.type
class Book:
    author_id: strawberry.Private[str]

    @strawberry.field
    async def author(self, info: strawberry.Info) -> Author:
        return await info.context["author_loader"].load(self.author_id)
async def get_context() -> dict:
    return {"author_loader": DataLoader(load_batch=load_authors)}

DataLoader 的核心是在同一事件循环 tick 内收集所有 key,合并成一次批量请求。两个必须遵守的约定:返回列表的顺序必须与输入 keys 严格一致;找不到的 key 要返回 None 占位而不是跳过,否则对应关系会整体错位。

4.3 注意事项

坑后果处理
顺序与 keys 不一致数据错位,且不报错用 by_id.get(k) 逐 key 映射
DataLoader 跨请求复用缓存串数据每个请求新建实例
在同步 resolver 里用无法批处理resolver 必须 async
批量 key 过多SQL 参数超限分片(如每 500 个一批)

DataLoader 实例必须请求级创建(放在 context 里),绝不能做成模块级单例,否则会把 A 用户的缓存泄漏给 B 用户。

5. 鉴权、错误与安全

5.1 上下文与字段级权限

from strawberry.permission import BasePermission

class IsAuthenticated(BasePermission):
    message = "需要登录"

    async def has_permission(self, source, info: strawberry.Info, **kwargs) -> bool:
        return info.context["user"] is not None

@strawberry.type
class Query:
    @strawberry.field(permission_classes=[IsAuthenticated])
    def me(self, info: strawberry.Info) -> User:
        return info.context["user"]

权限应当声明在字段上而非塞进 resolver 逻辑,这样既能在 schema 层面审计「哪些字段需要什么权限」,也便于统一测试。对于「只能看自己的数据」这类对象级权限,需要在 resolver 内结合 context 判断。

5.2 错误处理与脱敏

GraphQL 的约定是:HTTP 状态码恒为 200,错误放在 errors 数组里。但绝不能把内部异常原样抛出——数据库错误信息会泄漏表结构。

import strawberry
from strawberry.extensions import SchemaExtension

class ErrorMasking(SchemaExtension):
    def on_operation(self):
        yield
        result = self.execution_context.result
        if result and result.errors:
            for err in result.errors:
                if err.original_error and not isinstance(err.original_error, UserError):
                    err.message = "内部错误,请稍后重试"
                    err.extensions.clear()

业务错误(如「库存不足」)应作为数据的一部分返回,而不是塞进 errors:

@strawberry.type
class CreateOrderPayload:
    ok: bool
    message: Optional[str]
    order: Optional[Order]

用 Payload 类型承载业务结果,客户端就能像处理普通字段一样处理失败,不必解析 errors。

5.3 查询成本控制

恶意或粗心的客户端可以用一个查询打垮服务:

query {
  users(first: 1000) {
    friends(first: 1000) {
      friends(first: 1000) { id }
    }
  }
}

三层嵌套就是十亿级数据。三道防线:

手段作用实现
深度限制拒绝过深嵌套解析 AST 计算深度,超过阈值报错
复杂度/成本分析按字段权重累计成本给列表字段按 first 加权
分页上限强制 first 有上界服务端裁剪到最大值
超时兜底请求级超时
from strawberry.extensions import QueryDepthLimiter

schema = strawberry.Schema(
    query=Query,
    extensions=[QueryDepthLimiter(max_depth=8)],
)
# 强制分页上限:resolver 内裁剪
@strawberry.field
def books(self, first: int = 20) -> list[Book]:
    return list_books(min(first, 100))    # 客户端传 10000 也只给 100

5.4 生产环境关闭 introspection

graphql_app = GraphQLRouter(
    schema,
    graphiql=False,
    introspection=False,   # 生产环境隐藏 schema
)

关闭 introspection 能减少 schema 泄露(攻击者据此构造高成本查询),但会牺牲部分客户端开发体验。折中方案是保留 introspection 但加严格的复杂度限制,或对未认证请求关闭。

5.5 持久化查询

{"id": "a3f1c9", "query": "query Books($n:Int!){books(first:$n){id title}}"}

客户端只发送查询 ID 而非完整查询文本,服务端查表还原。好处是:可以走 GET + CDN 缓存、防止任意查询注入、减小请求体。对公网 API 是强烈推荐的加固手段。

6. 订阅(Subscription)

6.1 定义与推送

import asyncio
from typing import AsyncGenerator

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def book_added(self) -> AsyncGenerator[Book, None]:
        async for book in event_bus.subscribe("book_added"):
            yield book
schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription)

订阅走 WebSocket(graphql-transport-ws 协议)。Strawberry 的 FastAPI 集成内置支持,客户端用 graphql-ws 或 Apollo 的订阅链路连接。

6.2 工程注意事项

问题处理
连接数爆炸限制单用户订阅数、心跳超时踢除
消息广播风暴按 topic 订阅,只推给关注者
多实例部署用 Redis Pub/Sub 或 Kafka 做跨实例广播
鉴权在 WebSocket 握手阶段校验 token
背压有界队列 + 丢弃策略,避免慢客户端拖垮服务

单实例内存里的事件总线在多副本部署下会失效——A 实例产生的事件推不到连在 B 实例上的客户端。生产环境必须引入外部消息中间件做广播。

7. 测试与部署

7.1 测试 schema

from strawberry.test import GraphQLTestClient

client = GraphQLTestClient(schema)

def test_books_query():
    res = client.query(
        """
        query {
          books(first: 2) { id title }
        }
        """
    )
    assert not res.errors
    assert len(res.data["books"]) == 2

def test_depth_limit():
    deep = "query { " + "books { " * 12 + "id" + " }" * 12 + " }"
    res = client.query(deep)
    assert res.errors

测试要覆盖三类:功能(字段返回正确)、权限(越权访问被拒)、防护(深度/复杂度限制生效)。第三类最容易被漏,却恰恰是生产事故的高发区。

7.2 可观测性

GraphQL 单端点让传统按 URL 统计的监控失效——所有请求都打在 /graphql。因此必须按 operation name 打点:

class MetricsExtension(SchemaExtension):
    def on_operation(self):
        start = time.perf_counter()
        yield
        name = self.execution_context.operation_name or "anonymous"
        duration = time.perf_counter() - start
        metrics.histogram("graphql.duration", duration, tags={"op": name})
        if self.execution_context.result.errors:
            metrics.increment("graphql.errors", tags={"op": name})

客户端应当为每个查询命名(query Books(...) 而不是匿名查询),否则监控里只有一团匿名流量。这是 GraphQL 生产化的隐形前提。

7.3 部署要点

  • 单端点意味着无法按路径做差异化限流,需按 operation 或客户端标识限流
  • 关闭 introspection + 开启持久化查询
  • 深度限制 + 复杂度限制 + 超时三重兜底
  • 查询日志采样存储(完整查询可用于复现与审计)
  • 与 REST 共存的过渡期,可用网关按路径分流

GraphQL 与 REST 并非二选一:常见做法是对外保留 REST(利于缓存与生态),对内或对多端提供 GraphQL。选型时值得先读 REST、gRPC 与 GraphQL 的 API 设计对比 ,再结合 GraphQL API 工程化 中的治理经验做决策。

小结

GraphQL 的收益来自「客户端自述字段」,代价是「服务端失去对查询形状的掌控」。因此工程重点必须放在三处:可空性当契约设计(少写不可空、多写描述)、DataLoader 消除 N+1(请求级实例、顺序严格对应)、成本控制三重门(深度限制、复杂度分析、分页上限)。把业务错误放进 Payload 而非 errors、按 operation name 打点,是从「能跑」到「能运维」的分水岭。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. Python 桌面 GUI 应用开发
  2. Python 正则与文本处理进阶
  3. Python 图像处理:Pillow 与 OpenCV 实战