引言
REST 的接口是「服务端定义好形状,客户端照单全收」:移动端只要用户名和头像,接口却返回整个用户对象;页面要展示订单加商品,客户端就得串行发三四个请求。GraphQL 把「要哪些字段」的决定权交给客户端,一次请求拿回一棵刚好够用的数据树。
PHP 侧的 GraphQL 生态并不弱:底层有 webonyx/graphql-php(服务端参考实现),Laravel 上则有 nuwave/lighthouse 用 SDL(Schema Definition Language)声明式地生成整个 API 层。配合 DataLoader 批处理,N+1 可以被压成常数级查询。
但 GraphQL 也带来了 REST 时代不存在的新问题:恶意深度嵌套查询可以让一个请求打爆数据库;N+1 从「偶发」变成「默认」;权限必须在字段级而非路由级校验。本文按「建模 → 解析 → 优化 → 防护」的顺序,给出可直接落地的 PHP 实践。
关联阅读:REST 侧的设计与版本控制见 https://plumephp.com/php-api-design-rest/;字段级权限与注入防护可参考 https://plumephp.com/php-security-hardening/。
目录
- 1. 为什么用 GraphQL:与 REST 的取舍
- 2. Schema 与类型系统
- 3. 解析器(Resolver)与上下文
- 4. N+1 问题与 DataLoader
- 5. 查询复杂度与深度限制
- 6. 变更、订阅与错误处理
- 7. 安全与性能加固
- 8. Laravel 中的工程化落地
- 9. 与 REST 共存的架构决策
- 延伸阅读
1. 为什么用 GraphQL:与 REST 的取舍
| 维度 | REST | GraphQL |
|---|---|---|
| 数据形状 | 服务端固定 | 客户端按需声明 |
| 请求次数 | 聚合页面常需多次 | 一次请求取整棵树 |
| 版本管理 | URL 版本(v1/v2) | 类型演进 + 字段废弃 |
| 缓存 | HTTP 缓存天然支持 | 需持久化查询或客户端缓存 |
| 错误语义 | 状态码 | HTTP 200 + errors 数组 |
| 学习成本 | 低 | 高(Schema/解析器/防护) |
| 适用场景 | 简单资源、强缓存需求 | 多端复用、聚合视图、快速迭代 |
结论:面向多种客户端(Web/iOS/Android/小程序)且数据关系复杂的后台,GraphQL 收益最大;纯资源型、强 CDN 缓存需求的开放 API,REST 更省心。
2. Schema 与类型系统
2.1 SDL 定义
type Query {
order(id: ID!): Order
orders(status: OrderStatus, first: Int = 20, after: String): OrderConnection!
}
type Order {
id: ID!
orderNo: String!
amount: Money!
status: OrderStatus!
customer: Customer! # 关联字段,最易触发 N+1
lines: [OrderLine!]!
}
type Money { amountCents: Int!, currency: String! }
enum OrderStatus { PENDING PAID SHIPPED CANCELLED }
type OrderConnection { edges: [OrderEdge!]!, pageInfo: PageInfo! }
2.2 类型系统的四个要点
- 非空与列表语义:
String!表示不可为空,[OrderLine!]!表示列表本身与其元素都不可为空。 - 接口与联合:
interface Node { id: ID! }让不同实体共享字段;union SearchResult = Order | Customer表达「多选一」。 - 输入类型:
input CreateOrderInput { sku: String!, qty: Int! }专门用于变更参数,与输出类型分离。 - 枚举优先于字符串:状态类字段用
enum,客户端能获得自动补全与校验。
2.3 分页:Relay Cursor 规范
GraphQL 官方推荐 Cursor 分页而非 offset 分页(offset 在数据变动时会漏读/重读):
type PageInfo { hasNextPage: Boolean!, endCursor: String }
服务端把游标编码为 base64(created_at + id),查询条件写成 WHERE (created_at, id) < (?, ?),配合复合索引即可稳定翻页。
2.4 用 PHP 构建 Schema
use GraphQL\Type\Definition\{ObjectType, Type};
$orderType = new ObjectType([
'name' => 'Order',
'fields' => fn () => [
'id' => Type::nonNull(Type::id()),
'orderNo' => Type::nonNull(Type::string()),
'status' => Type::nonNull($orderStatusEnum),
],
]);
字段用闭包返回,解决类型之间的循环引用(Order 引用 Customer,Customer 又引用订单列表)。
3. 解析器(Resolver)与上下文
3.1 解析器签名
'customer' => [
'type' => Type::nonNull($customerType),
'resolve' => fn (Order $order, array $args, $context, ResolveInfo $info) =>
$context['customerLoader']->load($order->customerId),
],
四个参数分别是父对象、参数、上下文、解析信息。上下文($context)是每个请求共享的容器,用来放当前用户、DataLoader、数据库连接。
3.2 上下文构建
$context = [
'user' => $currentUser,
'orderRepo' => $container->get(OrderRepository::class),
'customerLoader' => new CustomerLoader($container->get(CustomerRepository::class)),
];
$result = GraphQL::executeQuery($schema, $query, null, $context, $variables);
关键纪律:解析器里不要直接 new 服务或读全局状态,一切通过上下文注入——这与 DDD 的依赖倒置一致。
3.3 默认解析器
若字段名与数组键/对象属性同名,GraphQL 会走默认解析器(读取 $order['orderNo'] 或 $order->orderNo)。这带来便利,也埋下隐患:默认解析器不会做权限校验,敏感字段必须显式写解析器。
4. N+1 问题与 DataLoader
4.1 N+1 是怎么发生的
query {
orders(first: 20) {
edges { node { orderNo customer { name } } }
}
}
默认解析下:1 次查询取 20 个订单,再对每个订单各查 1 次客户 → 21 次 SQL。列表越长,放大的倍数越大。
4.2 DataLoader:批处理 + 缓存
DataLoader 的核心是「收集同一帧内的所有 key,合并成一次查询」:
use Overblog\DataLoader\DataLoader;
use Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter;
$adapter = new WebonyxGraphQLSyncPromiseAdapter();
$customerLoader = new DataLoader($adapter, function (array $ids) use ($repo) {
$rows = $repo->findByIds($ids); // 一次 IN 查询
$map = array_column($rows, null, 'id');
return array_map(fn ($id) => $map[$id] ?? null, $ids); // 顺序与 key 一一对应
});
返回值的顺序必须与传入的 key 顺序严格一致,否则数据会张冠李戴——这是 DataLoader 最常见的 bug。
4.3 效果对比
| 场景 | 无 DataLoader | 有 DataLoader |
|---|---|---|
| 20 个订单取客户 | 21 次 SQL | 2 次 SQL |
| 20 订单 × 每单 5 行商品 | 121 次 SQL | 3 次 SQL |
| 深层嵌套 3 层 | 指数放大 | 每层 1 次 |
4.4 使用要点
- DataLoader 实例必须每请求新建,否则缓存会跨请求泄漏数据(尤其涉及权限时)。
- 同一 key 在一次请求内只查一次(内置缓存),天然去重。
- 关联字段的仓储方法要提供批量接口(
findByIds),只支持单个查询的仓储无法被批处理。
5. 查询复杂度与深度限制
5.1 攻击面:一个请求打爆数据库
query Evil {
orders { edges { node { lines { order { lines { order { lines { id } } } } } } } }
}
无限嵌套会在服务端展开成指数级解析,REST 时代没有这种攻击面。
5.2 三层防护
use GraphQL\Validator\Rules\{DisableIntrospection, QueryComplexity, QueryDepth};
$validationRules = [
new QueryDepth(10),
new QueryComplexity(1000),
new DisableIntrospection(DisableIntrospection::ENABLED),
];
$result = GraphQL::executeQuery($schema, $query, null, $context, $variables)
->setValidationRules($validationRules);
5.3 为昂贵字段单独加权
$queryComplexity->setRawVariableValues($variables);
// 在自定义复杂度函数里给聚合类字段加权
$complexityFn = fn ($childrenComplexity, $args) =>
1 + $childrenComplexity + ($args['first'] ?? 0) * 2;
5.4 其他限流手段
| 手段 | 作用 |
|---|---|
| 持久化查询(Persisted Query) | 只允许白名单查询,杜绝任意查询 |
| 速率限制(按复杂度计费) | 用复杂度而非请求数作为配额单位 |
| 超时与熔断 | 单请求 SQL 超时、慢查询熔断 |
6. 变更、订阅与错误处理
6.1 Mutation
input CreateOrderInput { sku: String!, qty: Int! }
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
type CreateOrderPayload { order: Order, errors: [UserError!]! }
Payload 模式(返回 order + errors 而非直接抛错)让业务错误成为类型的一部分,客户端可强制处理。
'resolve' => function ($root, array $args, $context) {
$input = CreateOrderInput::fromArray($args['input']);
if ($input->qty <= 0) {
return ['order' => null, 'errors' => [['field' => 'qty', 'message' => '数量必须为正']]];
}
return ['order' => $context['orderService']->create($input), 'errors' => []];
}
6.2 订阅 Subscription
PHP 的常驻内存方案(Swoole、ReactPHP)支持 WebSocket 订阅;传统 FPM 模式下通常退化为轮询 + 缓存或交给前端直接用 WebSocket 通道。订阅实现依赖发布订阅后端(Redis Pub/Sub),与 https://plumephp.com/php-laravel-broadcasting-events/ 中的广播架构同源。
6.3 错误分类
| 错误类型 | 处理方式 | 是否暴露细节 |
|---|---|---|
| 语法/校验错误 | GraphQL 层自动返回 | 是(客户端错误) |
| 业务规则错误 | Payload.errors | 是 |
| 未预期异常 | 统一错误格式化器 | 否(记日志,返回通用消息) |
$result->setErrorFormatter(function (\Throwable $e) {
if ($e instanceof \GraphQL\Error\UserError) {
return ['message' => $e->getMessage()];
}
$this->logger->error('GraphQL 内部错误', ['exception' => $e]);
return ['message' => '服务器内部错误'];
});
7. 安全与性能加固
7.1 认证与授权
- 认证在 HTTP 层完成(JWT / Session),把用户放进上下文。
- 授权在字段级完成:
customer.email这种字段必须显式解析并校验$context['user']->can('viewEmail', $order)。 - 用指令(Directive)声明式表达权限,例如
@can(ability: "viewEmail"),避免遗漏。
7.2 常见风险清单
| 风险 | 防护 |
|---|---|
| 深度/复杂度攻击 | 深度与复杂度上限(第 5 节) |
| 内省泄漏 | 生产禁用 introspection |
| 字段越权 | 字段级授权 + 默认拒绝 |
| 批量查询绕过限流 | 按复杂度配额 + 单请求查询数限制 |
| 注入 | 参数化查询,禁止拼接 DQL/SQL |
| 信息泄漏 | 错误格式化器统一脱敏 |
7.3 性能优化
- DataLoader 消灭 N+1(第 4 节);
- 持久化查询把查询文本换成短哈希,减少解析开销;
- 响应缓存:对公共查询按「查询哈希 + 变量」缓存结果;
- SQL 层:为关联字段的批量查询建好复合索引,参考 https://plumephp.com/php-laravel-eloquent-advanced/ 的 Eager Loading 与索引实践。
8. Laravel 中的工程化落地
8.1 Lighthouse:SDL 驱动
# graphql/order.graphql
type Query {
order(id: ID! @eq): Order @find(model: "App\\Models\\Order")
}
type Order {
id: ID!
orderNo: String!
customer: Customer! @belongsTo
lines: [OrderLine!]! @hasMany
}
Lighthouse 用 @find、@belongsTo、@hasMany 等指令把 SDL 直接映射到 Eloquent,自动启用批处理(内部集成 DataLoader),大幅降低手写解析器的工作量。
8.2 自定义解析器与策略
// app/GraphQL/Queries/OrdersQuery.php
final class OrdersQuery
{
public function __invoke($_, array $args, GraphQLContext $context): Collection
{
return Order::query()
->where('tenant_id', $context->user()->tenantId) // 多租户隔离
->when($args['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
->limit($args['first'] ?? 20)
->get();
}
}
多租户隔离必须在查询构造阶段完成,不能依赖后续字段级过滤——否则一次遗漏就是跨租户数据泄漏。
8.3 测试
public function test_orders_uses_single_query(): void
{
DB::enableQueryLog();
$this->graphQL('{ orders { edges { node { customer { name } } } } }');
$this->assertLessThanOrEqual(3, count(DB::getQueryLog())); // 断言无 N+1
}
用查询计数做断言是 GraphQL 性能回归最有效的护栏。
9. 与 REST 共存的架构决策
9.1 三种共存策略
| 策略 | 说明 | 适用 |
|---|---|---|
| 双栈并行 | REST 与 GraphQL 各服务不同客户端 | 迁移期最常见 |
| GraphQL 网关 | REST 作为下游数据源,网关聚合 | 已有大量内部 REST 服务 |
| REST 优先 | 仅对复杂聚合页面开 GraphQL | 增量引入 |
9.2 迁移路径建议
- 先只读后写入:GraphQL 先承接查询,写入仍走 REST,降低风险。
- 用真实页面驱动 Schema:从最复杂的聚合页面(订单详情、工作台)反推字段,不要凭空设计。
- 建立字段废弃流程:用
@deprecated标注,统计调用量后再删除。 - 监控接入:按「查询哈希」统计耗时、复杂度、错误率,把慢查询当作线上事故对待。
9.3 何时不该用 GraphQL
- 只有一两个简单接口,客户端固定;
- 需要强 HTTP/CDN 缓存(GraphQL 默认 POST,缓存需额外设计);
- 团队没有精力做复杂度限制与字段级授权(裸奔的 GraphQL 比 REST 更危险)。
9.4 一句话总结
GraphQL 的收益来自「客户端按需取数」,代价是「服务端必须自己做限流、批处理与字段级授权」。把 DataLoader、复杂度上限、字段级权限这三件事做扎实,它才是生产力;否则它只是一个更好用的、也更容易被打垮的接口层。
延伸阅读
- https://plumephp.com/php-api-design-rest/ — REST 的资源建模、版本控制与 OpenAPI 契约
- https://plumephp.com/php-laravel-eloquent-advanced/ — Eager Loading 与索引优化,GraphQL 性能的底层支撑
- https://plumephp.com/php-security-hardening/ — 字段级授权与注入防护的安全底座
- https://plumephp.com/php-caching-redis/ — 响应缓存与 Redis 在查询加速中的应用
- webonyx/graphql-php 文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。