le0daniel/graphql-tools

基于 webonyx/graphql-php 的 PHP graphql 应用程序端点使用工具

v6.0.1 2023-11-19 15:49 UTC

README

Latest Stable Version Total Downloads License

这是一个简单的、有偏见的 PHP 首先代码 GraphQL 应用程序工具包。

主要功能

  • 自定义扩展支持(跟踪、解析器遥测)
  • 支持解析器中间件
  • 抽象化模式注册和多模式支持。这类似于可见函数,但更透明。
  • 抽象类用于扩展以定义类型 / 枚举 / 接口 / 联合 / 标量
  • 字段使用易于使用的字段构建器构建
  • 简单的数据加载器实现以解决 N+1 问题
  • 首次代码方法构建模式
  • 模式拼接:通过添加附加字段扩展类型,允许你尊重你的域边界并在你的代码中构建正确的依赖方向。
  • 类型名别名,因此你可以使用类型名或类的名称。
  • 默认情况下懒惰以提高性能
  • 支持 PHP 中的 @defer 生成器。

安装

通过 composer 安装

composer require le0daniel/graphql-tools

基本用法

用法类似于使用 webonyx/graphql 并定义对象类型。你扩展提供的抽象类,而不是扩展默认类。每个解析函数都由 ProxyResolver 类包装,该类提供扩展支持。

在核心中,我们从模式注册表开始。在那里,你注册类型并注册扩展到类型。

<?php
    use GraphQlTools\Helper\Registry\SchemaRegistry;
    use GraphQlTools\Contract\TypeRegistry;
    use GraphQlTools\Definition\Field\Field;
    use GraphQlTools\Helper\QueryExecutor;
    use GraphQlTools\Definition\Extending\Extend;
    require_once __DIR__ . '/vendor/autoload.php';   

    $schemaRegistry = new SchemaRegistry();
    
    // You can use a classname for lazy loading or an instance of this type class.
    // You can register object-types, interfaces, enums, input-types, scalars and unions
    // See: Defining Types
    $schemaRegistry->register(Type::class);
    
    // You can extend types and interfaces with additional fields
    // See: Extending Types
    $schemaRegistry->extend(
        Extend::type('Lion')->withFields(fn(TypeRegistry $registry) => [
            Field::withName('species')->ofType($registry->string())
        ])
    );

    $schema = $schemaRegistry->createSchema(
        RootQueryType::class, // Define
        'MutationRoot', // Your own root mutation type,
    );

    $executor = new QueryExecutor();

    $result = $executor->execute(
        $schema,
        ' query { whoami }',
        new Context(),
        [], // variables array
        null, // Root Value
        null, // operation name
    );

模式注册表

模式注册表是包含 GraphQL 模式中所有类型定义的中央实体。

你注册类型,扩展类型,然后创建一个模式变体。内部,TypeRegistry 被赋予所有类型和字段,允许你无缝地引用模式中的其他类型。类型注册表解决了每个类型类在每个模式中仅实例化一次且为了最佳性能而懒惰的问题。

加载目录中的所有类型

为了简化类型注册,你可以使用 TypeMap 工具加载目录中的所有类型。它使用 Glob 来查找所有 PHP 文件。在生产中,你应该缓存这些

    use GraphQlTools\Helper\Registry\SchemaRegistry;
    use GraphQlTools\Utility\TypeMap;

    $schemaRegistry = new SchemaRegistry();
    
    [$types, $typeExtensions] = TypeMap::createTypeMapFromDirectory('/your/directory/with/GraphQL');
    
    $schemaRegistry->registerTypes($types);
    $schemaRegistry->extendMany($typeExtensions);

别名

在 GraphQL 中,所有类型都有一个名称。在本节中描述的首次代码方法中,你定义一个类,然后创建一个名称。因此,在代码中,你既有类名,也有在 GraphQL 中使用的类型名。我们自动为类名创建别名。这允许你通过类名或 GraphQL 中使用的类型名来引用其他类型。

类名在模块内部工作得更好,因为你现在可以静态分析类使用情况。

    use GraphQlTools\Helper\Registry\SchemaRegistry;
    use GraphQlTools\Utility\TypeMap;

    $schemaRegistry = new SchemaRegistry();
    $schemaRegistry->register(SomeName\Space\MyType::class);

    // You can now use both, the class name or the type name as in GraphQL.
    $registry->type(SomeName\Space\MyType::class) === $registry->type('My');

从注册表创建模式

模式注册表的任务是从所有注册的类型动态地创建一个模式。你可以使用模式规则隐藏和显示模式变体。规则主要基于标签。与使用可见函数相比,这更透明。你可以打印不同的 Schema 变体并使用工具验证模式或破坏性更改。隐藏的字段不能被任何用户查询。这防止了数据泄露。

提供的规则:

  • AllVisibleSchemaRule(默认):所有字段都是可见的
  • TagBasedSchemaRules:基于字段标签的黑白名单。

你可以通过实现 SchemaRules 接口来定义自己的规则。

    use GraphQlTools\Helper\Registry\SchemaRegistry;
    use GraphQlTools\Helper\Registry\TagBasedSchemaRules;
    use GraphQlTools\Contract\SchemaRules;
    
    $schemaRegistry = new SchemaRegistry();
    // Register all kind of types
    $publicSchema = $schemaRegistry->createSchema(
        queryTypeName: 'Query',
        schemaRules: new TagBasedSchemaRules(ignoreWithTags: 'unstable', onlyWithTags: 'public')
    );
    
    $publicSchemaWithUnstableFields = $schemaRegistry->createSchema(
        queryTypeName: 'Query',
        schemaRules: new TagBasedSchemaRules(onlyWithTags: 'public')
    );

    class MyCustomRule implements SchemaRules {
        
        public function isVisible(Field|InputField|EnumValue $item): bool {
            // Determine if a field is visible or not.
            return Arr::contains('my-tag', $item->getTags());
        }
    }

定义类型

在 GraphQL 的首次代码方法中,每个类型在代码中都由一个类表示。

命名规范和可扩展类

您可以通过覆盖 getName 函数来重写此行为。

use GraphQlTools\Definition\GraphQlType;
use GraphQlTools\Contract\TypeRegistry;
use GraphQlTools\Definition\Field\Field;
use GraphQL\Type\Definition\NonNull;

class QueryType extends GraphQlType {

    // Define Fields of the type. Use the type registry to reference all other types in the schema.
    // You MUST use the type registry for referencing all types. The type registry guarantees that the type is only 
    // created once and reused everywhere.
    protected function fields(TypeRegistry $registry) : array {
        return [
            Field::withName('currentUser')
                ->ofType(new NonNull($registry->string()))
                ->resolvedBy(fn(array $user) => $user['name']),
            // More fields
        ];
    }
    
    protected function description() : string{
        return 'Entry point into the schema query.';
    }
    
    // Optional
    protected function interfaces(): array {
        return [
            UserInterface::class,
            'Animal', 
        ];
    }
    
    // Optional, define middlewares for the resolver. See Middlewares.
    protected function middleware() : array|null{
        return [];
    }
}

定义字段和 InputFields

在代码优先的方法中,字段定义和解析函数位于同一位置。字段构建器允许您直接在代码中轻松构建具有所有必需和可能属性的字段,并结合它们的解析器。

字段构建器是不可变的,因此使用灵活,可重复使用。

它自动为您的解析函数附加一个 ProxyResolver 类,以便扩展和中间件可以正确工作。

为了声明类型和引用其他类型,为每个定义字段的实例提供了一个类型注册表。这允许您引用您的模式中存在的其他类型。类型注册表本身负责延迟加载并确保在模式中仅创建一个类型的实例。

此外,您可以定义标签,用于定义不同模式变体中字段的可见性。当您从模式注册表创建模式时,它会自动为您创建。

默认情况下,解析器使用 Webonyx 的默认解析函数(Executor::getDefaultFieldResolver())。

在它的最简单形式中

use GraphQlTools\Definition\Field\Field;
use GraphQL\Type\Definition\NonNull;
use GraphQL\Type\Definition\ListOfType;
/** @var \GraphQlTools\Contract\TypeRegistry $registry */

// In type Animal
Field::withName('myField')->ofType(new NonNull($registry->id()));
Field::withName('myOtherField')->ofType(new ListOfType($registry->type('Other')));

// Results in GraphQL
// type Animal {
//   myField: String!
//   myOtherField: [Other]
// }

更广泛的使用

    use GraphQlTools\Definition\GraphQlType;
    use GraphQlTools\Contract\TypeRegistry;
    use GraphQlTools\Definition\Field\Field;
    use GraphQL\Type\Definition\ResolveInfo;
    use GraphQL\Type\Definition\NonNull;
    
    
    final class AnimalType extends GraphQlType {
        // ...        
        protected function fields(TypeRegistry $registry) : array {
            
            return [
                // Uses the default resolver
                Field::withName('id')->ofType($registry->id()),
                
                // Define custom types using the repository
                Field::withName('customType')
                    ->withDescription('This is a custom type')
                    ->ofType($registry->type(CustomType::class))
                    ->tags('public', 'stable'),
                    
                // Using type name instead of class name
                Field::withName('customType2')
                    ->ofType($registry->type('Custom'))
                    ->tags('public', 'stable'),
                   
                // With Resolver
                Field::withName('userName')
                    ->ofType($registry->string())
                    ->resolvedBy(fn(User $user, array $arguments, $context, ResolveInfo $info): string => $user->name),
                
                // Define arguments 
                Field::withName('fieldWithArgs')
                    ->ofType($registry->string())
                    ->withArguments(
                        // Define named arguments, works for all fields
                        InputField::withName('name')
                            ->ofType(new NonNull($registry->string()))
                            ->withDefaultValue('Anonymous')
                            ->withDescription('My Description')
                    )
                    ->resolvedBy(function ($data, array $arguments, $context, ResolveInfo $resolveInfo): string {
                        return "Hello {$arguments['name']}";
                    })
                             
            ];
        }
    }

重用字段

创建可重用字段很简单。创建一个返回字段的函数或方法,然后它可以在任何地方使用。

创建共享字段时,使用 ofTypeResolver() 很有用。您可以定义一个闭包,将类型注册表作为第一个参数。这样,您不需要向下传递类型注册表。

use GraphQlTools\Definition\Field\Field;
use GraphQlTools\Contract\TypeRegistry;
use GraphQlTools\Definition\GraphQlType;
use GraphQL\Type\Definition\NonNull;

class ReusableFields {
    public static function id(): Field {
        return Field::withName('id')
            ->ofTypeResolver(fn(TypeRegistry $registry) => new NonNull($registry->id()))
            ->withDescription('Globally unique identifier.')
            ->resolvedBy(fn(Identifiable $data): string => $data->globallyUniqueId());
    }
}

// Usage in type:
class MyType extends GraphQlType {
    protected function fields(TypeRegistry $registry) : array {
        return [
            ReusableFields::id()
                ->name('id')
                ->withDescription('Overwritten description...'),
            
            // Other Fields
            Field::withName('other')->ofType($registry->string()),
        ];
    }
}

成本

在 GraphQL 中,由于可以导航到深层关系,查询可能很快就会变得非常昂贵。为了防止用户超过某些限制,您可以限制查询可以具有的复杂性。

Webonyx 提供了一种在执行查询之前计算复杂性的方法。这是通过 MaxComplexityRule 实现的。为了使其工作,每个字段都需要定义一个复杂性函数。

我们使用成本的概念,其中每个字段都静态地定义其自己的成本,并提供了一个助手来根据参数计算可变复杂性。

query {
    # Cost: 2
    animals(first: 5) {
        id # Cost: 0
        name # Cost: 1
        # Cost 2
        relatedAnimals(first: 5) {
            id # Cost: 0
            name # Cost: 1
        }
    }
}

在这个例子中,我们可以看到这两个组件

  • 每个字段的静态成本(动物:2,id:0,名称:1,相关动物:2,id:0,名称:1)
  • 可变成本:第一个:5

因为在最坏的情况下,所有 5 个实体都会从数据库中加载,因此在确定查询的最大成本时需要考虑这一点。示例

  • 相关动物的最大成本:5 *(动物成本:1)+(相关动物价格:2)= 7
  • 动物成本:5 *(动物成本:1 + 最大相关动物:7)+(动物价格:2)= 42

为了表示这种动态成本,您可以传递一个闭包作为第二个参数。它的返回值将用于乘以所有子节点的成本。

$field->cost(2, fn(array $args): int => $args['first'] ?? 15);

注意:成本用于确定查询的最坏情况成本。如果您想收集实际成本,请使用 ActualCostExtension。它钩入解析器并汇总查询中实际存在的字段的成本。

中间件

中间件是一个在调用实际解析函数之前和之后执行的功能。它遵循洋葱原则。外部的中间件首先被调用,并调用更深层次的中间件。您可以为整个类型或特定字段定义多个中间件函数,以防止数据泄露。

可以为类型(适用于所有字段)或字段定义中间件。您需要调用 $next(...) 来调用下一层。

中间件允许您操纵传递给实际字段解析器的数据以及解析器的实际结果。例如,您可以使用中间件在调用实际的解析函数之前验证参数。

签名

use GraphQL\Type\Definition\ResolveInfo;
use Closure;

$middleware = function(mixed $data, array $arguments, $context, ResolveInfo $resolveInfo, Closure $next): mixed {
    // Do something before actually resolving the field.
    if (!$context->isAdmin()) {
        return null;
    }
    
    $result = $next($data, $arguments, $context, $resolveInfo);
    
    // Do something after the field was resolved. You can manipulate the result here.
    if ($result === 'super secret value') {
        return null;
    }
    
    return $result;
} 

用法 您可以为一种类型定义多个中间件(这些中间件随后被视为该类型所有字段的中间件)或仅特定字段。

use GraphQlTools\Definition\Field\Field;

Field::withName('fieldWithMiddleware')
    ->middleware(
        $middleware,
        function(mixed $data, array $arguments, $context, ResolveInfo $resolveInfo, Closure $next) {
            return $next($data, $arguments, $context, $resolveInfo);
        }
    )

扩展类型

扩展类型和接口允许您在基础声明之外向类型添加一个或多个字段。这通常是在您不想跨越域边界但需要向另一个类型添加额外字段时的情况。这允许您拼接一个模式。

最简单的方法是使用模式注册表,并扩展一个对象类型或接口,传递一个声明额外字段的闭包。

use GraphQlTools\Helper\Registry\SchemaRegistry;
use GraphQlTools\Contract\TypeRegistry;
use GraphQlTools\Definition\Field\Field;
use GraphQlTools\Definition\Extending\Extend;
$schemaRegistry = new SchemaRegistry();

$schemaRegistry->extend(
    Extend::type('Animal')->withFields(fn(TypeRegistry $registry): array => [
            Field::withName('family')
                ->ofType($registry->string())
                ->resolvedBy(fn() => 'Animal Family')
        ]),
);

使用类

我们的方法允许使用类似类型的类,这些类定义类型扩展。

ClassName 命名模式,以正确实现懒注册: Extends[TypeOrInterfaceName](Type|Interface) 例子

  • ExtendsQueryType => 扩展名为 Query 的类型
  • ExtendsUserInterface => 扩展名为 User 的接口
use GraphQlTools\Definition\Extending\ExtendGraphQlType;
use GraphQlTools\Contract\TypeRegistry;
use GraphQlTools\Helper\Registry\SchemaRegistry;

class ExtendsAnimalType extends ExtendGraphQlType {
    public function fields(TypeRegistry $registry) : array {
        return [
            Field::withName('family')
                ->ofType($registry->string())
                ->resolvedBy(fn() => 'Animal Family')
        ];
    }
    
    // Optional
    protected function middleware() : array{
        return [];
    }
    
    // Optional, can be inferred by class name
    // Follow naming pattern: Extends[TypeOrInterfaceName][Type|Interface]
    public function typeName(): string {
        return 'Animal';
    }
}

$schemaRegistry = new SchemaRegistry();

$schemaRegistry->extend(ExtendsAnimalType::class);
// If the class does not follow the naming patterns, you need to use the extended type name
$schemaRegistry->extend(ExtendsAnimalType::class, 'Animal');
// OR
$schemaRegistry->extend(new ExtendsAnimalType());

联邦中间件

为了解耦并从解析器中删除对完整数据对象的引用,提供了一个联邦中间件。

提供了 2 个中间件

  1. Federation::key('id'),提取数据对象/数组中的 id 属性
  2. Federation::field('id'),运行 id 字段的解析器
use GraphQlTools\Definition\Field\Field;
use GraphQlTools\Utility\Middleware\Federation;
use GraphQlTools\Contract\TypeRegistry;

/** @var TypeRegistry $registry */
Field::withName('extendedField')
    ->ofType($registry->string())
    ->middleware(Federation::key('id'))
    ->resolvedBy(fn(string $animalId) => "Only the ID is received, not the complete Animal Data.");

查询执行(QueryExecutor)

查询执行器用于执行查询。它附加扩展和验证规则,处理错误映射和记录。

扩展:可以插入到查询执行中并监听事件的类。它们不会改变查询的结果,但可以收集有价值的遥测数据。它们是上下文相关的,并且为每个执行的查询创建一个新的实例。验证规则:在执行之前验证查询。它们不一定是上下文相关的。如果提供了工厂或类名,将为每个执行的查询创建一个新的实例。错误映射器:接收 Throwable 实例和相应的 GraphQl 错误。它负责将其映射到可能实现 ClientAware 的 Throwable。这允许您将您的内部异常与您在 GraphQL 中使用的异常断开连接。错误记录器:在映射之前接收 Throwable 实例。这允许您记录发生的错误。

扩展和验证规则

如果您定义了一个工厂,它将获取上下文作为参数。这允许您根据执行查询的用户动态创建或附加它们。如果扩展或验证规则实现了 GraphQlTools\Contract\ProvidesResultExtension,则可以根据 graphql 规范将数据添加到结果的扩展数组中。

use GraphQlTools\Helper\QueryExecutor;
use GraphQlTools\Helper\Validation\QueryComplexityWithExtension;
use GraphQlTools\Helper\Validation\CollectDeprecatedFieldNotices;

$executor = new QueryExecutor(
    [fn($context) => new YourTelemetryExtension],
    [CollectDeprecatedFieldNotices::class, fn($context) => new QueryComplexityWithExtension($context->maxAllowedComplexity)],
    function(Throwable $throwable, \GraphQL\Error\Error $error): void {
        YourLogger::error($throwable);
    },
    function(Throwable $throwable): Throwable {
        match ($throwable::class) {
            AuthenticationError::class => GraphQlErrorWhichIsClientAware($throwable),
            default => $throwable
        }
    }
);

$result = $executor->execute(/* ... */);

// You can access extensions after the execution
$telemetryExtension = $result->getExtension(YourTelemetryExtension::class);
$application->storeTrace($telemetryExtension->getTrace());

// You can access validation rules after execution
$deprecationNotices = $result->getValidationRule(CollectDeprecatedFieldNotices::class);
$application->logDeprecatedUsages($deprecationNotices->getMessages());

$jsonResult = json_encode($result);

Defer (@defer 指令)

您可以通过将 DeferExtension 添加到您的查询执行器来使用和启用 @defer 扩展。强烈建议同时添加 ValidateDeferUsageOnFields 验证规则,以限制每个查询允许的 defer 数量。

要使用它,在幕后,查询将多次运行,并使用缓存的结果。这使我们能够将某些字段的解析延迟到后续执行。

use GraphQlTools\Helper\QueryExecutor;
use GraphQlTools\Helper\Validation\ValidateDeferUsageOnFields;
use GraphQlTools\Helper\Extension\DeferExtension;
use GraphQlTools\Helper\Results\CompleteResult;
use GraphQlTools\Helper\Results\PartialResult;
use GraphQlTools\Helper\Results\PartialBatch;

$executor = new QueryExecutor(
    [fn() => new DeferExtension()],
    [new ValidateDeferUsageOnFields(10)]
);

$generator = $executor->executeGenerator(/* ... */);

/** @var CompleteResult|PartialResult $initialResult */
$initialResult = $generator->current();
/* SEND INITIAL RESPONSE */

$generator->next();
while ($result = $generator->current()) {
    $generator->next();
    
    /** @var PartialResult|PartialBatch $result */
    /* Send next chunk */
}

/* Close Response */

验证规则

我们使用 webonyx/graphql 的默认验证规则,并添加了一个收集弃用通知的额外规则。

您可以定义自定义验证规则,通过从 webonyx/graphql 的默认 ValidationRule 类扩展。如果您还实现了 ProvidesResultExtension,则规则可以在结果中添加一个条目。验证规则在查询实际运行之前执行。

扩展

扩展可以在执行过程中挂钩并收集数据。它们允许您收集跟踪或遥测数据。扩展不允许操纵字段解析器的结果。如果您想操纵结果,则需要使用中间件。扩展是上下文相关的,并且每次运行查询时都会创建一个新的实例。您可以通过将类名传递给查询执行器或工厂来定义扩展。每个工厂都传递当前的上下文。

为了定义一个扩展,一个类需要实现ExecutionExtension接口。扩展还可以实现ProvidesResultExtension,并添加到结果中的扩展字段。抽象类Extension实现了一些辅助和通用逻辑,以简化扩展的构建。

以下事件被提供

  • StartEvent:当执行开始但尚未运行任何代码时
  • ParsedEvent:一旦查询成功解析
  • EndEvent:一旦执行完成

每个事件都包含特定属性和事件的纳秒时间。

在执行过程中,扩展可以使用visitField钩子钩入每个解析器的resolve函数。您可以传递一个闭包回来,该闭包在解析完成后执行。这样,它将在所有承诺解析完毕后执行,让您能够访问实际解析的字段数据。如果发生失败,将返回一个可抛出对象。

use GraphQlTools\Helper\Extension\Extension;
use GraphQlTools\Utility\Time;
use GraphQlTools\Data\ValueObjects\Events\FieldResolution;

class MyCustomExtension extends Extension {
    //...
    public function visitField(FieldResolution $event) : ?Closure{
        Log::debug('Will resolve field', ['name' => $event->info->fieldName, 'typeData' => $event->typeData, 'args' => $event->arguments]);
        return fn($resolvedValue) => Log::debug('did resolve field value to', [
            'value' => $resolvedValue, 
            'durationNs' => Time::durationNs($event->eventTimeInNanoSeconds),
        ]); 
    }
}