PHP GraphQL API 开发实战:Schema、DataLoader 与查询防护

系统讲解 PHP 中的 GraphQL 服务端开发:Schema 与类型系统、解析器与上下文、N+1 问题与 DataLoader 批处理、查询复杂度与深度限制、变更与订阅、错误处理、安全加固,以及 GraphQL 与 REST 的取舍与共存架构。

引言

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 的取舍

维度RESTGraphQL
数据形状服务端固定客户端按需声明
请求次数聚合页面常需多次一次请求取整棵树
版本管理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 类型系统的四个要点

  1. 非空与列表语义:String! 表示不可为空,[OrderLine!]! 表示列表本身与其元素都不可为空。
  2. 接口与联合:interface Node { id: ID! } 让不同实体共享字段;union SearchResult = Order | Customer 表达「多选一」。
  3. 输入类型:input CreateOrderInput { sku: String!, qty: Int! } 专门用于变更参数,与输出类型分离。
  4. 枚举优先于字符串:状态类字段用 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 次 SQL2 次 SQL
20 订单 × 每单 5 行商品121 次 SQL3 次 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 迁移路径建议

  1. 先只读后写入:GraphQL 先承接查询,写入仍走 REST,降低风险。
  2. 用真实页面驱动 Schema:从最复杂的聚合页面(订单详情、工作台)反推字段,不要凭空设计。
  3. 建立字段废弃流程:用 @deprecated 标注,统计调用量后再删除。
  4. 监控接入:按「查询哈希」统计耗时、复杂度、错误率,把慢查询当作线上事故对待。

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 文档

继续阅读

探索更多技术文章

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

全部文章 返回首页

「php」更多文章

  1. Symfony 框架实战:组件化架构、依赖注入容器与 Doctrine 集成
  2. PHP 领域驱动设计实战:限界上下文、聚合与六边形架构
  3. PHP 遗留代码现代化实战:坏味道识别、绞杀者模式与灰度切换