wwwision/types

创建符合JSON schema规则的PHP类型的工具

1.3.0 2024-07-28 16:47 UTC

This package is auto-updated.

Last update: 2024-08-28 16:57:22 UTC


README

使用JSON Schema启发的属性缩小你的PHP类型范围,允许验证和映射未知数据。

使用方法

此包可以通过composer安装。

composer require wwwision/types

之后,需要三个步骤来利用此包的类型安全。

假设你有以下Contact实体

class Contact {
    public function __construct(public string $name, public int $age) {}
}

此类有几个问题

  • 值是可变的,所以系统的任何部分都可以随意更改它们而不受控制 ($contact->name = 'changed';)
  • $name$age的值未绑定——这使得类型非常脆弱。例如,您可以指定一个包含数千个字符的名字或年龄为负数,这可能会在集成层面导致破坏
  • 没有人类可读的类型信息——$name是否应该是全名、仅给定名或姓氏?...

0. 为你的值对象创建类

注意此列表从0开始,因为这部分略微超出范围,它只是一个一般建议

final class ContactName {
    public function __construct(public string $value) {}
}
final class ContactAge {
    public function __construct(public int $value) {}
}

1. 添加属性

通过添加提供的属性之一,可以将模式信息和文档添加到类型类

#[Description('The full name of a contact, e.g. "John Doe"')]
#[StringBased(minLength: 1, maxLength: 200)]
final class ContactName {
    public function __construct(public string $value) {}
}

#[Description('The current age of a contact, e.g. 45')]
#[IntegerBased(minimum: 1, maximum: 130)]
final class ContactAge {
    public function __construct(public int $value) {}
}

注意在大多数情况下,指定类型的上限是有意义的,因为这允许你在“边缘”重用它们(例如,用于前端验证和数据库模式)

2. 将构造函数设为私有并使类不可变

通过将构造函数设为私有,可以强制执行验证,从而提供信心,即对象不会违反它们的允许范围。有关更多信息,请参阅最佳实践

#[Description('The full name of a contact, e.g. "John Doe"')]
#[StringBased(minLength: 1, maxLength: 200)]
final class ContactName {
    private function __construct(public readonly string $value) {}
}

#[Description('The current age of a contact, e.g. 45')]
#[IntegerBased(minimum: 1, maximum: 130)]
final class ContactAge {
    private function __construct(public readonly int $value) {}
}

final class Contact {
    public function __construct(
        public readonly ContactName $name,
        public readonly ContactAge $age,
    ) {}
}

3. 使用instantiate()创建实例

有了私有构造函数,应该使用instantiate()函数来创建受影响类的新的实例

// ...
instantiate(Contact::class, ['name' => 'John Doe', 'age' => 45]);

注意在实际应用中,您会发现您几乎不需要在应用程序逻辑中创建新的实体/值对象实例,而主要是在基础设施层。例如,一个DatabaseContactRepository可能返回一个Contacts对象。

示例:数据库集成
// ...

#[ListBased(itemClassName: Contact::class)]
final class Contacts implements IteratorAggregate {
    private function __construct(private readonly array $contacts) {}
    
    public function getIterator() : Traversable {
        return new ArrayIterator($this->contacts);
    }
}

interface ContactRepository {
    public function findByName(ContactName $name): Contacts;
}

final class DatabaseContactRepository implements ContactRepository {

    public function __construct(private readonly PDO $pdo) {}

    public function findByName(ContactName $name): Contacts
    {
        $statement = $this->pdo->prepare('SELECT name, age FROM contacts WHERE name = :name');
        $statement->execute(['name' => $name->value]);
        return instantiate(Contacts::class, $statement->fetchAll(PDO::FETCH_ASSOC));
    }
}

最佳实践

为了充分利用此包,应考虑以下规则

构造函数中的所有状态字段

此包使用反射来解析相关类的构造函数。因此,构造函数应包含构成内部状态的所有变量(在我看来,这始终是一种好的实践)。通常,您应仅通过构造函数允许状态更改,并且将DTO类标记为readonly是一个好主意

私有构造函数

为了允许在所有地方验证数据,必须没有使用提供的instantiate()方法以外的方式实例化基于整数基于字符串基于列表的类的方式。

因此,值对象的构造函数应该是私有的

#[StringBased]
final class SomeValueObject {
    private function __construct(public readonly string $value) {}
}

注意 对于形状对象(即复合对象),该规则不适用,因为如果遵循上述规则,它们的所有属性都是有效的

// ...

final class SomeComposite {
    public function __construct(
        public readonly SomeValueObject $alreadyValidated,
        public readonly bool $neverInvalid,
    ) {}
}

// this is fine:
instantiate(SomeComposite::class, ['alreadyValidated' => 'some value', 'neverInvalid' => true]);

// and so is this:
new SomeComposite(instantiate(SomeValueObject::class, 'some value'), true);

最终类

在我看来,PHP中的类应该默认是最终的。对于核心域类型来说,这一点尤为重要,因为继承可能会导致无效的架构和验证失败。相反,在适用的情况下,应该使用组合

不可变性

为了保证类型的正确性,不应该有改变值而不重新应用验证的方法。实现这一点的最简单方法是使这些类型不可变——这也会带来一些其他好处。

可以使用readonly关键字在属性上(在PHP 8.2+中甚至可以在类本身上)来确保PHP类型级别的不可变性。

如果类型应该从外部进行更新...

  • 应返回一个新的实例
  • 并且它不应该调用私有的构造函数,而应该使用instantiate()来应用验证
#[StringBased(format: StringTypeFormat::date, pattern: '^1980')]
final class Date {
    private function __construct(public readonly string $value) {}
    
    public function add(\DateInterval $interval): self
    {
        return instantiate(self::class, \DateTimeImmutable::createFromFormat('Y-m-d', $this->value)->add($interval)->format('Y-m-d'));
    }
}

$date = instantiate(Date::class, '1980-12-30');
$date = $date->add(new \DateInterval('P1D'));

// this is fine
assert($date->value === '1980-12-31');

// this is not because of the "pattern"
$date = $date->add(new \DateInterval('P1D'));

// Exception: Failed to cast string of "1981-01-01" to Date: invalid_string (Value does not match regular expression)

属性

描述

描述属性允许您向类和参数添加一些特定领域的文档。

示例:具有描述的类
#[Description('This is a description for this class')]
final class SomeClass {

    public function __construct(
        #[Description('This is some overridden description for this parameter')]
        public readonly bool $someProperty,
    ) {}
}

assert(Parser::getSchema(SomeClass::class)->overriddenPropertyDescription('someProperty') === 'This is some overridden description for this parameter');

IntegerBased

使用IntegerBased属性,您可以创建表示整数的值对象。它具有可选参数

  • minimum – 指定允许的最小
  • maximum – 指定允许的最大
示例
#[IntegerBased(minimum: 0, maximum: 123)]
final class SomeIntBased {
    private function __construct(public readonly int $value) {}
}

instantiate(SomeIntBased::class, '-5');

// Exception: Failed to cast string of "-5" to SomeIntBased: too_small (Number must be greater than or equal to 0)

FloatBased

从版本 1.2 开始

使用FloatBased属性,您可以创建表示浮点数(即双精度浮点数)的值对象。它具有可选参数

  • minimum – 指定允许的最小值(整数或浮点数)
  • maximum – 指定允许的最大值(整数或浮点数)
示例
#[FloatBased(minimum: 12.34, maximum: 30)]
final class SomeFloatBased {
    private function __construct(public readonly float $value) {}
}

instantiate(SomeFloatBased::class, 12);

// Exception: Failed to cast integer value of 12 to SomeFloatBased: too_small (Number must be greater than or equal to 12.340)

StringBased

使用StringBased属性,您可以创建表示字符串的值对象。它具有可选参数

  • minLength – 指定字符串允许的最小长度
  • maxLength – 指定字符串允许的最大长度
  • pattern – 指定字符串必须匹配的正则表达式
  • format – 字符串必须满足的预定义格式之一(这是JSON Schema字符串格式的子集)
示例:具有最小和最大长度约束的字符串值对象
#[StringBased(minLength: 1, maxLength: 200)]
final class GivenName {
    private function __construct(public readonly string $value) {}
}

instantiate(GivenName::class, '');

// Exception: Failed to cast string of "" to GivenName: too_small (String must contain at least 1 character(s))
示例:具有格式和模式约束的字符串值对象

与JSON Schema类似,formatpattern可以结合使用以进一步缩小类型

#[StringBased(format: StringTypeFormat::email, pattern: '@your.org$')]
final class EmployeeEmailAddress {
    private function __construct(public readonly string $value) {}
}

instantiate(EmployeeEmailAddress::class, 'not@your.org.localhost');

// Exception: Failed to cast string of "not@your.org.localhost" to EmployeeEmailAddress: invalid_string (Value does not match regular expression)

ListBased

使用ListBased属性,您可以创建指定itemClassName的泛型列表(即集合、数组、集合等)。它具有可选参数

  • minCount – 指定列表必须包含的至少项目数量
  • maxCount – 指定列表必须包含的最多项目数量
示例:简单的泛型数组
#[StringBased]
final class Hobby {
    private function __construct(public readonly string $value) {}
}

#[ListBased(itemClassName: Hobby::class)]
final class Hobbies implements IteratorAggregate {
    private function __construct(private readonly array $hobbies) {}
    
    public function getIterator() : Traversable {
        return new ArrayIterator($this->hobbies);
    }
}

instantiate(Hobbies::class, ['Soccer', 'Ping Pong', 'Guitar']);
示例:更详细的泛型数组,具有类型提示和最小/最大计数约束

以下示例显示了列表的更现实实现,包括

  • @implements注解,允许IDE和静态类型分析器提高开发体验
  • 描述属性
  • minCountmaxCount验证
  • CountableJsonSerializable实现(仅作为示例,这不是验证正常工作所必需的)
// ...

/**
 * @implements IteratorAggregate<Hobby> 
 */
#[Description('A list of hobbies')]
#[ListBased(itemClassName: Hobby::class, minCount: 1, maxCount: 3)]
final class HobbiesAdvanced implements IteratorAggregate, Countable, JsonSerializable {
    /** @param array<Hobby> $hobbies */
    private function __construct(private readonly array $hobbies) {}
    
    public function getIterator() : Traversable {
        return new ArrayIterator($this->hobbies);
    }
    
    public function count(): int {
        return count($this->hobbies);
    }
    
    public function jsonSerialize() : array {
        return array_values($this->hobbies);
    }
}

instantiate(HobbiesAdvanced::class, ['Soccer', 'Ping Pong', 'Guitar', 'Gaming']);

// Exception: Failed to cast value of type array to HobbiesAdvanced: too_big (Array must contain at most 3 element(s))

复合类型

上述示例演示了如何创建具有严格验证和内省的非常具体的值对象。

示例:复杂复合对象
#[StringBased]
final class GivenName {
    private function __construct(public readonly string $value) {}
}

#[StringBased]
final class FamilyName {
    private function __construct(public readonly string $value) {}
}

final class FullName {
    public function __construct(
        public readonly GivenName $givenName,
        public readonly FamilyName $familyName,
    ) {}
}

#[Description('honorific title of a person')]
enum HonorificTitle
{
    #[Description('for men, regardless of marital status, who do not have another professional or academic title')]
    case MR;
    #[Description('for married women who do not have another professional or academic title')]
    case MRS;
    #[Description('for girls, unmarried women and married women who continue to use their maiden name')]
    case MISS;
    #[Description('for women, regardless of marital status or when marital status is unknown')]
    case MS;
    #[Description('for any other title that does not match the above')]
    case OTHER;
}

#[Description('A contact in the system')]
final class Contact {
    public function __construct(
        public readonly HonorificTitle $title,
        public readonly FullName $name,
        #[Description('Whether the contact is registered or not')]
        public bool $isRegistered = false,
    ) {}
}

// Create a Contact instance from an array
$person = instantiate(Contact::class, ['title' => 'MRS', 'name' => ['givenName' => 'Jane', 'familyName' => 'Doe']]);
assert($person->name->familyName->value === 'Doe');
assert($person->isRegistered === false);

// Retrieve the schema for the Contact class
$schema = Parser::getSchema(Contact::class);
assert($schema->getDescription() === 'A contact in the system');
assert($schema->propertySchemas['isRegistered']->getDescription() === 'Whether the contact is registered or not');

泛型

泛型不太可能进入PHP(参见这个布伦特的视频,解释了为什么会是这种情况)。

ListBased 属性允许相对容易地创建特定项类型的类型安全集合。

目前你仍然需要为它创建一个自定义类,但我不认为这是一个大问题,因为通常一个通用集合类不会满足所有特定要求。例如:PostResults可能提供与Posts集合不同的函数和实现(前者可能是未绑定的,后者可能有minCount约束等)。

进一步思考

我在考虑添加一个更通用的方式(无意中用了双关语)来允许在不指定属性中的itemClassName的情况下,但在实例化时使用通用类,可能类似于以下内容

#[Generic('TKey', 'TValue')]
final class Collection {
    // ...
}

// won't work as of now:
$posts = generic(Collection::class, $dbRows, TKey: Types::int(), TValue: Types::classOf(Post::class));

但这增加了一些奇怪之处,而且根据上述原因,我目前并不真正需要它。

接口

从版本1.1开始,此包允许引用接口类型。

为了通过其接口实例化对象,必须通过__type键指定实例类名。所有剩余的数组项将按常规使用。对于仅期望单个标量值的简单对象,可以额外指定__value键。

interface SimpleOrComplexObject {
    public function render(): string;
}

#[StringBased]
final class SimpleObject implements SimpleOrComplexObject {
    private function __construct(private readonly string $value) {}
    public function render(): string {
        return $this->value;
    }
}

final class ComplexObject implements SimpleOrComplexObject {
    private function __construct(private readonly string $prefix, private readonly string $suffix) {}
    public function render(): string {
        return $this->prefix . $this->suffix;
    }
}

$simpleObject = instantiate(SimpleOrComplexObject::class, ['__type' => SimpleObject::class, '__value' => 'Some value']);
assert($simpleObject instanceof SimpleObject);

$complexObject = instantiate(SimpleOrComplexObject::class, ['__type' => ComplexObject::class, 'prefix' => 'Prefix', 'suffix' => 'Suffix']);
assert($complexObject instanceof ComplexObject);

特别是当与泛型列表一起工作时,允许多态性可能很有用,即允许列表包含接口的任何实例

示例:接口的泛型列表
// ...

#[ListBased(itemClassName: SimpleOrComplexObject::class)]
final class SimpleOrComplexObjects implements IteratorAggregate {
    public function __construct(private readonly array $objects) {}
    
    public function getIterator() : Traversable{
        return new ArrayIterator($this->objects);
    }
    
    public function map(Closure $closure): array
    {
        return array_map($closure, $this->objects);
    }
}

$objects = instantiate(SimpleOrComplexObjects::class, [
    ['__type' => SimpleObject::class, '__value' => 'Simple'],
    ['__type' => ComplexObject::class, 'prefix' => 'Com', 'suffix' => 'plex'],
]);

assert($objects->map(fn (SimpleOrComplexObject $o) => $o->render()) === ['Simple', 'Complex']);

错误处理

在实例化对象过程中发生的错误会导致抛出InvalidArgumentException异常。该异常包含一个可读的错误消息,有助于调试任何错误,例如

无法实例化FullNames:在键"0"处:属性"givenName"的值"a"没有所需的至少3个字符的长度

从版本1.2开始,抛出更具体的CoerceException,它包含改进的异常消息,收集所有失败

无法将类型为数组的值转换为FullNames:在"0.givenName"处:too_small(String必须包含至少3个字符)。在"1.familyName"处:invalid_type(必需)

此外,异常包含一个issues属性,允许程序性地解析和/或重写错误消息。异常本身是JSON序列化的,上面的示例相当于

[
  {
    "code": "too_small",
    "message": "String must contain at least 3 character(s)",
    "path": [0, "givenName"],
    "type": "string",
    "minimum": 3,
    "inclusive": true,
    "exact": false
  },
  {
    "code": "invalid_type",
    "message": "Required",
    "path": [1, "familyName"],
    "expected": "string",
    "received": "undefined"
  }
]

注意 如果您对语法熟悉,这并不奇怪。它受到(实际上几乎是完全兼容的)出色的Zod库问题格式的启发

集成

此库的声明性方法允许一些有趣的集成。到目前为止,存在以下两个——请随意创建另一个,我会很乐意将其添加到这个列表中

依赖关系

此包目前依赖于以下第三方库

...并且有以下开发要求

性能

此包使用反射来检查类型。因此,会有一定的性能损耗。幸运的是,PHP中反射的性能并不像它的名声那么糟糕,虽然你可以确实测量到差异,但我怀疑在实际应用中它不会有显著的影响——除非你处理的是像实时交易这样极端时间敏感的应用程序,在这种情况下,你根本不应该使用PHP...你也许还应该重新考虑你的生活选择 :)

尽管如此,此包包含所有反射类的运行时缓存。所以如果你返回一个巨大的同一类型的列表,性能影响应该是最小的。我通过PHPBench来测量API的性能,以避免回归,如果性能成为一个问题,我可能会添加更多的缓存。

贡献

问题拉取请求讨论的形式做出的贡献是非常受欢迎的

许可

LICENSE