wwwision / types
创建符合JSON schema规则的PHP类型的工具
Requires
- php: >=8.1
- ramsey/uuid: ^4.7
- webmozart/assert: ^1.11
Requires (Dev)
- phpbench/phpbench: ^1.2
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.1
- roave/security-advisories: dev-latest
- squizlabs/php_codesniffer: ^4.0.x-dev
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类似,format
和pattern
可以结合使用以进一步缩小类型
#[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和静态类型分析器提高开发体验- 描述属性
minCount
和maxCount
验证Countable
和JsonSerializable
实现(仅作为示例,这不是验证正常工作所必需的)
// ... /** * @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库问题格式的启发
集成
此库的声明性方法允许一些有趣的集成。到目前为止,存在以下两个——请随意创建另一个,我会很乐意将其添加到这个列表中
- types/graphql – 从PHP类型创建GraphQL模式
- types/glossary – 为所有相关PHP类型创建Markdown术语表
依赖关系
此包目前依赖于以下第三方库
- webmozart/assert – 用于简化类型和值断言
- ramsey/uuid – 用于
StringTypeFormat::uuid
检查
...并且有以下开发要求
- roave/security-advisories – 用于检测依赖包中的漏洞
- phpstan/phpstan – 用于静态代码分析
- squizlabs/php_codesniffer – 用于代码风格分析
- phpunit/phpunit – 用于单元和集成测试
- phpbench/phpbench – 用于性能基准测试
性能
此包使用反射来检查类型。因此,会有一定的性能损耗。幸运的是,PHP中反射的性能并不像它的名声那么糟糕,虽然你可以确实测量到差异,但我怀疑在实际应用中它不会有显著的影响——除非你处理的是像实时交易这样极端时间敏感的应用程序,在这种情况下,你根本不应该使用PHP...你也许还应该重新考虑你的生活选择 :)
尽管如此,此包包含所有反射类的运行时缓存。所以如果你返回一个巨大的同一类型的列表,性能影响应该是最小的。我通过PHPBench来测量API的性能,以避免回归,如果性能成为一个问题,我可能会添加更多的缓存。