crell/serde

一个通用的序列化和反序列化库

1.3.0 2024-09-20 04:48 UTC

README

Latest Version on Packagist Software License Total Downloads

Serde(发音为“seer-dee”)是一个快速、灵活、强大且易于使用的PHP序列化和反序列化库,支持多种标准格式。它从Rust的Serde包和Symfony Serializer中汲取灵感,尽管它不是基于任何一个。

目前,Serde支持将PHP对象序列化和反序列化成PHP数组、JSON、YAML、TOML和CSV文件。它还支持通过流将数据序列化为JSON或CSV。计划进一步支持,但设计上也可以由任何人扩展。

安装

通过Composer

$ composer require crell/serde

用法

Serde旨在快速上手使用,同时在更高级的情况下也非常稳健。在其最基本的形式中,您可以执行以下操作

use Crell\Serde\SerdeCommon;

$serde = new SerdeCommon();

$object = new SomeClass();
// Populate $object somehow;

$jsonString = $serde->serialize($object, format: 'json');

$deserializedObject = $serde->deserialize($jsonString, from: 'json', to: SomeClass::class);

(命名参数是可选的,但建议使用。)

Serde配置非常灵活,但大多数情况只需要使用提供的SerdeCommon类即可。对于大多数基本用例,这已经足够。

关键特性

支持的格式

Serde可以序列化为

  • PHP数组(array
  • JSON(json
  • 流式JSON(json-stream
  • YAML(yaml
  • TOML(toml
  • CSV(csv
  • 流式CSV(csv-stream

Serde可以从以下内容反序列化

  • PHP数组(array
  • JSON(json
  • YAML(yaml
  • TOML(toml
  • CSV(csv

YAML支持需要Symfony/Yaml库。

TOML支持需要Vanodevium/Toml库。

XML支持正在进行中。

稳健的对象支持

Serde自动支持嵌套对象,只要没有循环引用,它们将递归处理。

Serde处理publicprivateprotectedreadonly属性,包括读写,并支持可选默认值。

如果您尝试序列化或反序列化一个实现PHP的__serialize()__unserialize()钩子的对象,这些钩子将被尊重。(如果您想从PHP的内部序列化格式中读取/写入,只需直接调用serialize()/unserialize()即可。)

Serde还支持后加载回调,允许您在必要时重新初始化派生信息,而无需将其存储在序列化格式中。

PHP对象可以转换为序列化格式,也可以从序列化格式转换回来。嵌套对象可以被扁平化或收集,具有公共接口的类可以映射到适当的对象,数组值可以在序列化时压缩为一个字符串,在读取时再解压缩回数组。

配置

Serde的行为几乎完全由属性驱动。任何类都可以在不进行额外配置的情况下直接序列化或反序列化,但有许多配置选项可以选择。

属性处理由Crell/AttributeUtils提供。也值得一看。

主要属性是Crell\Serde\Attributes\Field属性,它可以放置在任意对象属性上。(静态属性将被忽略。)它的所有参数都是可选的,Field本身也是如此。(也就是说,添加没有参数的#[Field]与完全不指定是一样的。)下面列出了可用参数的含义。

尽管不是必需的,但强烈建议您始终使用命名参数与属性一起使用。参数的确切顺序没有保证

在下面的示例中,Field通常直接引用。然而,您也可以导入命名空间,然后使用属性的命名空间版本,如下所示

use Crell\Serde\Attributes as Serde;

#[Serde\ClassSettings(includeFieldsByDefault: false)]
class Person
{
    #[Serde\Field(serializedName: 'callme')]
    protected string $name = 'Larry';
}

您如何做主要取决于个人喜好,但如果您将Serde属性与其他库的属性混合使用,则建议使用命名空间方法。

还有一个ClassSettings属性,可以放置在要序列化的类上。目前它有四个参数

  • includeFieldsByDefault,默认为true。如果设置为false,则没有#[Field]属性的属性将被忽略。它相当于在所有属性上隐式设置exclude: true
  • requireValues,默认为false。如果设置为true,则在反序列化时未提供的数据中的任何字段将导致异常。这也可以在字段级别打开或关闭。(见下文requireValue。)类级别的设置适用于任何未指定其行为的字段。
  • renameWith。如果设置,则将使用指定的重命名策略对类的所有属性进行重命名,除非属性指定了自己的。 (见下文renameWith。)类级别的设置适用于任何未指定其行为的字段。
  • omitNullFields,默认为false。如果设置为true,则序列化时将忽略类上任何为null的属性。它对反序列化没有影响。这也可以在字段级别打开或关闭。(见下文omitIfNull。)
  • scopes,用于设置给定类定义属性的范围。请参阅下面的作用域部分。

exclude(布尔值,默认false)

如果设置为true,Serde将在序列化和反序列化时完全忽略属性。

serializedName(字符串,默认null)

如果提供,此字符串将被用作序列化输出到格式时以及读取它时使用的属性名称。例如

use Crell\Serde\Attributes\Field;

class Person
{
    #[Field(serializedName: 'callme')]
    protected string $name = 'Larry';
}

往返于

{
    "callme": "Larry"
}

renameWith(重命名策略,默认null)

renameWith键指定了将属性名称杂乱化以产生serializedName的方法。这里最常见的例子是大小写折叠,例如,如果序列化到与PHP使用不同约定的格式。

renameWith的值可以是实现RenamingStrategy接口的任何对象。最常见版本已经通过Cases枚举和Prefix类提供,但您可以自由提供自己的。

Cases枚举实现了RenamingStrategy并提供了一系列实例(情况)用于常见的重命名。例如

use Crell\Serde\Attributes\Field;
use Crell\Serde\Renaming\Cases;

class Person
{
    #[Field(renameWith: Cases::snake_case)]
    public string $firstName = 'Larry';

    #[Field(renameWith: Cases::CamelCase)]
    public string $lastName = 'Garfield';
}

序列化到/从

{
    "first_name": "Larry",
    "LastName": "Garfield"
}

可用情况如下

  • Cases::UPPERCASE
  • Cases::lowercase
  • Cases::snake_case
  • Cases::kebab_case

    (以短划线显示,而不是下划线)
  • Cases::CamelCase
  • Cases::lowerCamelCase

Prefix类在序列化时附加前缀到值,但其他方面保持属性名称不变。

use Crell\Serde\Attributes\Field;
use Crell\Serde\Renaming\Prefix;

class MailConfig
{
    #[Field(renameWith: new Prefix('mail_')]
    protected string $host = 'smtp.example.com';

    #[Field(renameWith: new Prefix('mail_')]
    protected int $port = 25;

    #[Field(renameWith: new Prefix('mail_')]
    protected string $user = 'me';

    #[Field(renameWith: new Prefix('mail_')]
    protected string $password = 'sssh';
}

序列化到/从

{
    "mail_host": "smtp.example.com",
    "mail_port": 25,
    "mail_user": "me",
    "mail_password": "sssh"
}

如果同时指定了serializedNamerenameWith,则将使用serializedName,而忽略renameWith

alias(数组,默认[]

在反序列化时(仅限反序列化),如果预期的序列化名称在传入数据中未找到,将检查这些额外的属性名称,以查看是否可以找到值。如果找到了,将从传入数据中的该键读取值。如果没有找到,则与值最初就没有找到时的行为相同。

use Crell\Serde\Attributes\Field;

class Person
{
    #[Field(alias: ['layout', 'design'])]
    protected string $format = '';
}

以下三个JSON字符串都会被读取到相同的目标对象中

{
    "format": "3-column-layout"
}
{
    "layout": "3-column-layout"
}
{
    "design": "3-column-layout"
}

这主要在API密钥更改时很有用,而旧版传入数据可能仍然包含旧键名。

omitIfNull(布尔值,默认为false)

此键仅在序列化时适用。如果设置为true,并且当对象被序列化时此属性的值为null,则将从输出中完全省略。如果为false,则将写入一个null值,无论其格式如何。

useDefault(布尔值,默认为true)

此键仅在反序列化时适用。如果一个类的属性在传入数据中未找到,并且此属性为true,则将分配默认值。如果为false,则完全跳过该值。反序列化后的对象是否处于无效状态取决于对象本身。

默认值来源于多个不同的位置。默认值的优先级顺序为

  1. Field属性的default参数提供的值。
  2. 由反射报告的代码提供的默认值。
  3. 如果有的话,与构造函数参数同名的默认值。

例如,以下类

use Crell\Serde\Attributes\Field;

class Person
{
    #[Field(default: 'Hidden')]
    public string $location;

    #[Field(useDefault: false)]
    public int $age;

    public function __construct(
        public string $name = 'Anonymous',
    ) {}
}

如果从一个空源(如JSON中的{})反序列化,将得到一个将location设置为Hiddenname设置为Anonymous,而age仍未初始化的对象。

default(混合类型,默认为null)

此键仅在反序列化时适用。如果指定,则如果反序列化的传入数据中缺少值,将使用此值,无论源代码中的默认值是什么。

strict(布尔值,默认为true)

此键仅在反序列化时适用。如果设置为true,则将拒绝传入数据中的类型不匹配,并抛出异常。如果为false,则格式化器将尝试根据PHP的正常转换规则转换传入的值。这意味着,例如,如果strictfalse,则"1"是整数属性的合法值,但如果设置为true,将抛出异常。

对于序列字段,如果strict设置为true,将拒绝非序列值。(它必须通过array_is_list()检查。)如果strictfalse,则将接受任何类似数组的值,但通过array_values()传递以删除任何键并重新索引。

此外,在非strict模式下,传入数组中的数字字符串将根据需要转换为整数或浮点数,无论是在序列字段还是字典字段中。在strict模式下,数字字符串仍然会被拒绝。

此设置的精确处理可能因传入格式而略有不同,因为某些格式以不同的方式处理它们自己的类型。(例如,在XML中,一切都是字符串。)

requireValue(布尔值,默认为false)

此键仅在反序列化时适用。如果设置为true,如果传入数据不包含此字段的值,并且没有指定默认值,将抛出MissingRequiredValueWhenDeserializing异常。如果没有设置,并且没有默认值,则将保留属性未初始化。

如果字段有默认值,则将始终使用默认值用于缺失的数据,并且此设置没有作用。

flatten(布尔值,默认为false)

“flatten”(扁平化)关键字只能应用于数组或对象属性。被“扁平化”的属性在序列化时,其所有属性将直接注入到父对象中,而在反序列化时,父对象中的值将被收集到其中。

多个对象和数组可以进行扁平化(序列化),但在反序列化时,只有标记为“flatten”的最后一个数组属性会收集剩余的键。不过,任何数量的对象都可以“收集”其属性。

以分页为例。在PHP中表示分页信息可能非常有用,将其作为结果集的对象属性,但在序列化的JSON或XML中,可能希望移除额外的对象。

给定以下类集

use Crell\Serde\Attributes as Serde;

class Results
{
    public function __construct(
        #[Serde\Field(flatten: true)]
        public Pagination $pagination,
        #[Serde\SequenceField(arrayType: Product::class)]
        public array $products,
    ) {}
}

class Pagination
{
    public function __construct(
        public int $total,
        public int $offset,
        public int $limit,
    ) {}
}

class Product
{
    public function __construct(
        public string $name,
        public float $price,
    ) {}
}

在序列化时,$pagination 对象将被“扁平化”,这意味着其三个属性将直接包含在Results的属性中。因此,此对象的JSON序列化副本可能如下所示

{
    "total": 100,
    "offset": 20,
    "limit": 10,
    "products": [
        {
            "name": "Widget",
            "price": 9.99
        },
        {
            "name": "Gadget",
            "price": 4.99
        }
    ]
}

“Pagination”对象的额外“层”已被移除。在反序列化时,那些额外的属性将被“收集”回一个Pagination对象。

现在考虑一个更复杂的示例

use Crell\Serde\Attributes as Serde;

class DetailedResults
{
    public function __construct(
        #[Serde\Field(flatten: true)]
        public NestedPagination $pagination,
        #[Serde\Field(flatten: true)]
        public ProductType $type,
        #[Serde\SequenceField(arrayType: Product::class)]
        public array $products,
        #[Serde\Field(flatten: true)]
        public array $other = [],
    ) {}
}

class NestedPagination
{
    public function __construct(
        public int $total,
        public int $limit,
        #[Serde\Field(flatten: true)]
        public PaginationState $state,
    ) {}
}

class PaginationState
{
    public function __construct(
        public int $offset,
    ) {
    }
}

class ProductType
{
    public function __construct(
        public string $name = '',
        public string $category = '',
    ) {}
}

在这个例子中,在序列化时,NestedPagination 和 PaginationState 都将被扁平化。NestedPagination 本身还有一个应该扁平化的字段。只要它们没有共享属性名,两者都会干净地扁平化和收集。

此外,还有一个额外的数组属性$other。$other可以包含所需的任何关联数组,其值也将被扁平化到输出中。

在收集时,只有最后扁平化的数组会获取任何数据,并将获取所有其他属性未计入的属性。例如,DetailedResults的实例可能序列化为JSON如下

{
    "total": 100,
    "offset": 20,
    "limit": 10,
    "products": [
        {
            "name": "Widget",
            "price": 9.99
        },
        {
            "name": "Gadget",
            "price": 4.99
        }
    ],
    "foo": "beep",
    "bar": "boop"
}

在这种情况下,$other属性有两个键,foo 和 bar,分别具有值 beep 和 boop。相同的JSON将反序列化回之前相同的对象。

值对象

扁平化还可以与重命名结合使用,以静默地转换值对象。考虑以下示例

class Person
{
    public function __construct(
        public string $name,
        #[Field(flatten: true)]
        public Age $age,
        #[Field(flatten: true)]
        public Email $email,
    ) {}
}

readonly class Email
{
    public function __construct(
        #[Field(serializedName: 'email')] public string $value,
    ) {}
}

readonly class Age
{
    public function __construct(
        #[Field(serializedName: 'age')] public int $value
    ) {
        $this->validate();
    }

    #[PostLoad]
    private function validate(): void
    {
        if ($this->value < 0) {
            throw new \InvalidArgumentException('Age cannot be negative.');
        }
    }
}

在这个例子中,Email 和 Age 都是值对象,在后者的情况下有额外的验证。然而,它们都被标记为 flatten: true,因此它们的属性在序列化时将被提升到 Person 的一个级别。但是,它们都使用了相同的属性名,因此都指定了自定义序列化名称。上述对象将序列化为(并反序列化为)类似以下的内容

{
    "name": "Larry",
    "age": 21,
    "email": "me@example.com"
}

请注意,因为反序列化绕过了构造函数,所以Age中的额外验证必须放置在从构造函数中调用的单独方法中,并标记为在反序列化后自动运行。

还可以为扁平化的值指定一个前缀,这也会递归地应用。例如,假设上面的Age类相同

readonly class JobDescription
{
    public function __construct(
        #[Field(flatten: true, flattenPrefix: 'min_')]
        public Age $minAge,
        #[Field(flatten: true, flattenPrefix: 'max_')]
        public Age $maxAge,
    ) {}
}

class JobEntry
{
    public function __construct(
        #[Field(flatten: true, flattenPrefix: 'desc_')]
        public JobDescription $description,
    ) {}
}

在这种情况下,序列化JobEntry将首先使用desc_作为前缀扁平化$description属性。然后,JobDescription将扁平化其年龄字段,每个字段都使用一个单独的前缀。这将导致类似于以下内容的序列化输出

{
    "desc_min_age": 18,
    "desc_max_age": 65,
}

并且它将反序列化回原始的3层对象结构。

flattenPrefix(字符串,默认'')

当对象或数组属性被扁平化时,默认情况下,其属性将使用它们现有的名称(如果指定了serializedName,则使用serializedName)进行扁平化。如果同一个类被包含在父类中两次,或者存在其他名称冲突,这可能会导致问题。因此,扁平化字段可以给出一个flattenPrefix值。该字符串将在序列化时添加到属性名称的前面。

如果设置在非扁平化字段上,此值没有意义且没有效果。

序列和字典

在大多数语言以及许多序列化格式中,值序列(有时称为数组、序列或列表)与任意大小任意值的映射(称为字典或映射)之间存在区别。PHP 不区分这两种数据类型,而是将它们都放入单个关联数组变量类型中。

有时这可以工作,但有时两种类型之间的区别很大。为了支持这些情况,Serde 允许您将数组属性标记为 #[SequenceField]#[DictionaryField](建议您始终这样做)。这样做确保了使用正确的序列化路径来处理该属性,并且还打开了许多其他功能。

arrayType

#[SequenceField]#[DictionaryField] 上,arrayType 参数允许您指定该结构中所有值的类型。例如,整数序列可以轻松地序列化和反序列化到大多数格式,而无需任何额外帮助。然而,有序的 Product 对象列表可以序列化,但没有方法可以告诉如何将其反序列化为 Product 对象而不是嵌套的关联数组(这也是合法的)。arrayType 参数解决了这个问题。

如果指定了 arrayType,则假定该数组的所有值都是该类型。它可以是指定所有值都是类的 class-string,或者是一个表示四个支持的标量的 ValueType 枚举值。

因此,在反序列化时,Serde 将验证所有传入值是否为正确的标量类型,或者根据特定格式查找嵌套的对象结构(取决于具体格式),并将这些结构转换为指定的对象类型。

例如

use Crell\Serde\Attributes\SequenceField;

class Order
{
    public string $orderId;

    public int $userId;

    #[SequenceField(arrayType: Product::class)]
    public array $products;
}

在这种情况下,该属性告诉 Serde,$products 是一个索引的、顺序的 Product 对象列表。在序列化时,这可能表示为 JSON 或 YAML 中的字典数组,或者在其他格式中使用一些额外的元数据。

在反序列化时,其他对象忽略的数据将被提升回 Product 对象。

arrayTypeDictionaryField 上以完全相同的方式工作。

keyType

仅在对 DictionaryField 时,可以限制数组只允许整数或字符串键。它有两个合法值,KeyType::IntKeyType::String(枚举)。如果设置为 KeyType::Int,则反序列化将拒绝任何具有字符串键的数组,但将接受数字字符串。如果设置为 KeyType::String,则反序列化将拒绝任何具有整数键的数组,包括数字字符串。

(PHP 会自动将整数字符串数组键自动转换为实际整数,因此无法在基于字符串的字典中允许它们。)

如果没有设置值,则接受任一键类型。

implodeOn

SequenceFieldimplodeOn 参数,如果存在,表示应将值连接成字符串序列化,使用提供的值作为粘合剂。例如

use Crell\Serde\Attributes\SequenceField;

class Order
{
    #[SequenceField(implodeOn: ',')]
    protected array $productIds = [5, 6, 7];
}

将序列化为 JSON

{
    "productIds": "5,6,7"
}

在反序列化时,该字符串将自动在放置到对象中时被拆分成数组。

默认情况下,在反序列化时,单个值将被 trim() 以删除多余的空白。可以通过将 trim 属性参数设置为 false 来禁用此操作。

joinOn

DictionaryField 也支持序列化时的合并/拆分,但需要两个键。implodeOn 指定在区分值之间使用的字符串。joinOn 指定在键和值之间使用的字符串。

例如

use Crell\Serde\Attributes\DictionaryField;

class Settings
{
    #[DictionaryField(implodeOn: ',', joinOn: '=')]
    protected array $dimensions = [
        'height' => 40,
        'width' => 20,
    ];
}

将序列化/反序列化为以下 JSON

{
    "dimensions": "height=40,width=20"
}

SequenceField 一样,除非在属性参数列表中指定了 trim: false,否则值将自动被 trim()

日期和时间字段

《DateTime》和《DateTimeImmutable》字段也可以进行序列化,您可以使用《DateField》或《UnixTimeField》属性来控制它们的序列化方式。《DateField》有两个参数,可以单独使用也可以一起使用。如果不指定任何参数,等同于没有指定《DateField》属性。

use Crell\Serde\Attributes\DateField;

class Settings
{
    #[DateField(format: 'Y-m-d')]
    protected DateTimeImmutable $date = new DateTimeImmutable('4 July 2022-07-04 14:22);
}

将序列化为以下JSON格式

{
    "date": "2022-07-04"
}

时区

《timezone》参数可以是PHP中合法的任何时区字符串,例如《America/Chicago》或《UTC》。如果指定了时区,则序列化前首先将值转换为该时区。如果没有指定,则序列化前将保留其原始时区。这是否会影响到输出取决于《format》。

在反序列化时,《timezone》没有影响。如果传入的值指定了时区,则生成的《DateTime[Immutable]》对象将使用该时区。如果没有指定,则使用系统默认时区。

格式

此参数允许您指定序列化时使用的格式。它可以接受PHP的日期格式语法中的任何字符串,包括在《DateTimeInterface》上定义的各个常量之一。如果没有指定,默认格式为《RFC3339_EXTENDED》,或《Y-m-d\TH:i:s.vP》。虽然这不是最符合人类习惯的格式,但它与JavaScript/JSON的默认格式兼容性良好。

在反序列化时,《format》没有影响。Serde会将字符串值传递给《DateTime》或《DateTimeImmutable》构造函数,因此PHP可以识别的任何格式都将根据PHP的标准日期解析规则进行解析。

Unix时间

在需要将日期序列化为Unix时间或从Unix时间反序列化的情况下,可以使用《UnixTimeField》,它支持可以处理高达微秒分辨率的分辨率参数。

use Crell\Serde\Attributes\UnixTimeField;
use Crell\Serde\Attributes\Enums\UnixTimeResolution;

class Jwt
{
    #[UnixTimeField]
    protected DateTimeImmutable $exp;
    
    #[UnixTimeField(resolution: UnixTimeResolution::Milliseconds)]
    protected DateTimeImmutable $iss;
}

将序列化为以下JSON格式

{
    "exp": 1707764358,
    "iss": 1707764358000
}

序列化的整数应读取为“自纪元以来的这些秒”或“自纪元以来的这些毫秒”等。(“纪元”是指1970年1月1日,人类首次登上月球后的第一年。)

请注意,毫秒和微秒的可接受范围远小于秒,因为我们能表示的整数的尺寸有限。对于21世纪初的时戳应该没有问题,但尝试记录沙丘(约在10000年代)的纪元以来的微秒数将不会工作。

生成器、可迭代对象和可遍历对象

PHP有多种“惰性列表”选项。通常,它们都是实现了《\Traversable》接口的对象。然而,有几种语法选项可用,它们都有自己的细微差别。Serde以不同的方式支持它们。

如果属性被定义为《iterable》,那么不管它是一个《Traversable》对象还是一个生成器,序列化过程都会“执行”可迭代对象并将其转换为数组。请注意,如果可迭代对象是无限迭代器,则该过程将永远继续,并且您的程序会冻结。不要这样做。

此外,当使用《iterable》属性时,属性必须根据需要标记为《#[SequenceField]》或《#[DictionaryField]》。Serde无法像处理数组那样独立推断出它是哪种类型。

在反序列化时,传入的值始终会被分配到一个数组。由于数组是《iterable》,因此这仍然是类型安全的。虽然在理论上可以动态构建一个生成器以惰性生成值,但这实际上不会节省任何内存。

请注意,这并不意味着序列化和反序列化对象将完全对称。初始对象可能具有生成器属性,但反序列化对象将具有数组。

如果某个属性的类型被指定为其他 Traversable 对象(通常因为它实现了 \Iterator\IteratorAggregate),则它将被序列化和反序列化为普通对象。它的 iterable 特性将被忽略。在这种情况下,禁止使用 #[SequenceField]#[DictionaryField] 属性。

CSV 格式化器

Serde 支持序列化和反序列化 CSV 文件。但是,由于 CSV 是一种更有限的格式,仅支持某些对象结构。

具体来说,相关的对象必须有一个标记为 #[SequenceField] 的单个属性,并且它必须有一个显式的 arrayType,该类型是一个类。该类反过来只能包含 intfloatstring 属性。其他任何内容都会引发错误。

例如

namespace Crell\Serde\Records;

use Crell\Serde\Attributes\SequenceField;

class CsvTable
{
    public function __construct(
        #[SequenceField(arrayType: CsvRow::class)]
        public array $people,
    ) {}
}

class CsvRow
{
    public function __construct(
        public string $name,
        public int $age,
        public float $balance,
    ) {}
}

这种组合将生成一个三列的 CSV 文件,并可以从三列的 CSV 文件中反序列化。

CSV 格式化器使用 PHP 的原生 CSV 解析和写入工具。如果您想控制使用的分隔符,请将这些作为构造函数参数传递给 CsvFormatter 实例,并将其注入到 Serde 类中,而不是默认设置。

请注意,唯一的属性可能是一个生成器。这允许 CSV 在需要时动态生成任意数据。当反序列化时,它仍然会反序列化为数组。

Serde 包括两个基于流的格式化器(但目前不包括反格式化器),一个是用于 JSON 的,另一个用于 CSV。它们几乎与任何其他格式化器一样工作,但在调用 $serde->serialize() 时,您可以(并且应该)传递一个额外的 init 参数。 $init 应该是 Serde\Formatter\FormatterStream 的一个实例,它包装了一个可写的 PHP 流句柄。

返回的值将是相同的流句柄,在将待序列化的对象写入它之后。

例如

// The JsonStreamFormatter and CsvStreamFormatter are not included by default.
$s = new SerdeCommon(formatters: [new JsonStreamFormatter()]);

// You may use any PHP supported stream here, including files, network sockets,
// stdout, an in-memory temp stream, etc.
$init = FormatterStream::new(fopen('/tmp/output.json', 'wb'));

$result = $serde->serialize($data, format: 'json-stream', init: $init);

// $result is a FormatterStream object that wraps the same handle as before.
// What you can now do with the stream depends on what kind of stream it is.

在这个例子中,$data 对象(无论是什么)将被分块序列化为 JSON,并输出到指定的文件句柄。

CsvStreamFormatter 以完全相同的方式工作,但输出 CSV 数据,并且对它接受的对象的限制与 CsvFormatter 相同。

在许多情况下,这实际上并不会带来太多好处,因为整个对象必须驻留在内存中。然而,它可以与对懒加载迭代器的支持结合使用,以具有生成对象的属性,例如从数据库查询中或从其他来源读取。

考虑这个例子

use Crell\Serde\Attributes\SequenceField;

class ProductList
{
    public function __construct(
        #[SequenceField(arrayType: Product::class)]
        private iterable $products,
    ) {}
}

class Product
{
    public function __construct(
        public readonly string $name,
        public readonly string $color,
        public readonly float $price,
    ) {}
}

$databaseConn = ...;

$callback = function() use ($databaseConn) {
    $result = $databaseConn->query("SELECT name, color, price FROM products ORDER BY name");

    // Assuming $record is an associative array.
    foreach ($result as $record) {
        yield new Product(...$record);
    }
};

// This is a lazy list of products, which will be pulled from the database.
$products = new ProductList($callback());

// Use the CSV formatter this time, but JsonStream works just as well.
$s = new SerdeCommon(formatters: [new CsvStreamFormatter()]);

// Write to stdout, aka, back to the browser.
$init = FormatterStream::new(fopen('php://output', 'wb'));

$result = $serde->serialize($products, format: 'csv-stream', init: $init);

这种设置将懒加载数据库中的记录,并从它们中实例化一个对象,然后懒加载地将这些数据输出到 stdout。无论数据库中有多少产品记录,内存使用量都大致保持不变。(注意数据库驱动程序可能会对整个结果集进行自己的缓冲,这可能导致内存问题。但这是一个单独的问题。)

虽然对于 CSV 来说可能有些过度,但它对于将更复杂的对象序列化为 JSON 来说可以工作得很好。

类型映射

类型映射是 Serde 的一个强大功能,允许精确控制具有继承的对象的序列化和反序列化方式。类型映射在对象的类和序列化数据中包含的唯一标识符之间进行转换。

在抽象层面,类型映射是任何实现了 TypeMap 接口的对象。类型映射可以作为属性的属性、类或接口的属性提供,或者在 Serde 设置时提供,以允许任意映射。

考虑以下示例,该示例将用于后续关于类型映射的解释。

use Crell\Serde\Attributes\SequenceField;

interface Product {}

interface Book extends Product {}

class PaperBook implements Book
{
    protected string $title;
    protected int $pages;
}

class DigitalBook implements Book
{
    protected string $title;
    protected int $bytes;
}

class Sale
{
    protected Book $book;

    protected float $discountRate;
}

class Order
{
    protected string $orderId;

    #[SequenceField(arrayType: Book::class)]
    protected array $products;
}

“销售”和“订单”都引用了“图书”,但该值可以是“PaperBook”、“DigitalBook”或其他实现了“Book”接口的任何类。类型映射为Serde提供了识别具体类型的方法。

类名映射

类名映射的最简单情况是在对象属性上包含一个#[ClassNameTypeMap]属性。例如,

use Crell\Serde\ClassNameTypeMap;

class Sale
{
    #[ClassNameTypeMap(key: 'type')]
    protected Book $book;

    protected float $discountRate;
}

现在,当Sale进行序列化时,将包含一个名为type的额外属性,其中包含类名。因此,一本数字图书的销售将序列化如下

{
    "book": {
        "type": "Your\\App\\DigitalBook",
        "title": "Thinking Functionally in PHP",
        "bytes": 45000
    },
    "discountRate": 0.2
}

在反序列化时,“type”属性将被读取并用于确定剩余的值应该用于构建一个DigitalBook实例,具体而言。

类名映射的优点是它们非常简单,并且可以与实现该接口的任何类一起工作,即使是你尚未想到的类。缺点是它们将PHP实现细节(类名)放入输出中,这可能不是所希望的。

静态映射

静态映射允许您从类到有意义的键提供固定映射。

use Crell\Serde\Attributes\StaticTypeMap;

class Sale
{
    #[StaticTypeMap(key: 'type', map: [
        'paper' => Book::class,
        'ebook' => DigitalBook::class,
    ])]
    protected Book $book;

    protected float $discountRate;
}

现在,如果Sale对象进行序列化,它将看起来像这样

{
    "book": {
        "type": "ebook",
        "title": "Thinking Functionally in PHP",
        "bytes": 45000
    },
    "discountRate": 0.2
}

静态映射的优点在于其简单性,并且不会将PHP特定的实现细节污染输出。缺点是它们是静态的:它们只能处理在代码时已知的类,如果遇到其他类将抛出异常。

集合上的类型映射

类型映射也可以应用于数组属性,无论是序列还是字典。在这种情况下,它们将应用于该集合中的所有值。例如

use Crell\Serde\Attributes as Serde;

class Order
{
    protected string $orderId;

    #[Serde\SequenceField(arrayType: Book::class)]
    #[Serde\StaticTypeMap(key: 'type', map: [
        'paper' => Book::class,
        'ebook' => DigitalBook::class,
    ])]
    protected array $books;
}

$products是一个实现了Book接口的对象数组,可以是PaperBookDigitalBook。此对象的序列化副本可能如下所示

{
    "orderId": "abc123",
    "products": [
        {
            "type": "ebook",
            "title": "Thinking Functionally in PHP",
            "bytes": 45000
        },
        {
            "type": "paper",
            "title": "Category Theory for Programmers",
            "pages": 335
        }
    ]
}

在反序列化时,将再次使用“type”属性来确定其余属性应填充到哪个类中。

类型映射类

除了在属性上放置类型映射外,您还可以将其放置在属性引用的类或接口上。

use Crell\Serde\Attributes\StaticTypeMap;

#[StaticTypeMap(key: 'type', map: [
    'paper' => Book::class,
    'ebook' => DigitalBook::class,
])]
interface Book {}

现在,该类型映射将适用于Sale::$bookOrder::$books,无需我们进行更多操作。

类型映射也可以继承。这意味着如果我们想,可以在Product上放置类型映射

use Crell\Serde\Attributes\StaticTypeMap;

#[StaticTypeMap(key: 'type', map: [
    'paper' => Book::class,
    'ebook' => DigitalBook::class,
    'toy' => Gadget::class,
])]
interface Product {}

并且SaleOrder仍然使用适当的键进行序列化。

动态类型映射

类型映射还可以在创建Serde对象时直接提供。任何实现了TypeMap接口的对象都可以使用。这在基于用户配置、数据库值、应用程序中安装的插件等动态基于可能类的列表时最有用。

use Crell\Serde\TypeMap;

class ProductTypeMap implements TypeMap
{
    public function __construct(protected readonly Connection $db) {}

    public function keyField(): string
    {
        return 'type';
    }

    public function findClass(string $id): ?string
    {
        return $this->db->someLookup($id);
    }

    public function findIdentifier(string $class): ?string
    {
        return $this->db->someMappingLogic($class);
    }
}

$typeMap = new ProductTypeMap($dbConnection);

$serde = new SerdeCommon(typeMaps: [
    Your\App\Product::class => $typeMap,
]);

$json = $serde->serialize($aBook, to: 'json');

在实际操作中,您可能通过依赖注入系统来设置它。

请注意,ClassNameTypeMapStaticTypeMap也可以注入,以及任何实现了TypeMap接口的其他类。

自定义类型映射

您还可以编写自己的类型映射作为属性。唯一的要求是

  1. 该类实现了TypeMap接口。
  2. 该类被标记为<#[\Attribute]>。
  3. 该类在两者类和属性上都是合法的。即#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY)]

作用域

Serde支持“作用域”,以便在不同的上下文中识别不同的属性版本。

任何属性(FieldTypeMapSequenceFieldDictionaryFieldPostLoad等)都可以接受一个scopes参数,该参数接受一个字符串数组。如果指定了该参数,则该属性仅在指定的作用域中进行序列化或反序列化时有效。如果没有指定具有作用域的属性,则行为将回退到无作用域属性或省略的属性。

例如,给定这个类

class User
{
    private string $username;

    #[Field(exclude: true)]
    private string $password;

    #[Field(exclude: true)]
    #[Field(scope: 'admin')]
    private string $role;
}

如果您这样序列化它

$json = $serde->serialize($user, 'json');

结果将是这样的JSON响应

{
    "username": "Larry"
}

这是因为在不具有作用域的请求中,$role上的第一个Field被使用,它将其排除在输出之外。然而,如果您指定了一个作用域

$json = $serde->serialize($user, 'json', scopes: ['admin']);

则将使用$roleFieldadmin版本,它不会被排除,并得到以下结果

{
    "username": "Larry",
    "role": "Developer"
}

当使用作用域时,禁用自动属性包含并明确指定每个属性可能会有所帮助。例如

#[ClassSettings(includeFieldsByDefault: false)]
class Product
{
    #[Field]
    private int $id = 5;

    #[Field]
    #[Field(scopes: ['legacy'], serializedName: 'label')]
    private string $name = 'Fancy widget';

    #[Field(scopes: ['newsystem'])]
    private float $price = '9.99';

    #[Field(scopes: ['legacy'], serializedName: 'cost')]
    private float $legacyPrice = 9.99;

    #[Field(serializedName: 'desc')]
    private string $description = 'A fancy widget';

    private int $stock = 50;
}

如果没有指定作用域进行序列化,结果将是这样的

{
    "id": 5,
    "name": "Fancy widget",
    "desc": "A fancy widget"
}

因为这些是未指定作用域时“在作用域内”的唯一字段。

如果使用legacy作用域进行序列化

{
    "id": 5,
    "label": "Fancy widget",
    "cost": 9.99,
    "desc": "A fancy widget"
}

则使用作用域特定的$name上的Field,这改变了序列化的名称。$legacyPrice属性现在也被包括在内,但重命名为"cost"。

如果使用newsystem作用域进行序列化

{
    "id": 5,
    "name": "Fancy widget",
    "price": "9.99",
    "desc": "A fancy widget"
}

在这种情况下,$name属性使用无作用域版本的Field,因此没有重命名。基于字符串的$price现在在作用域内,但基于浮点的$legacyPrice不在作用域内。请注意,在这些情况下,当前的$stock都没有包含,因为它没有任何属性。

最后,还可以同时序列化多个作用域。这是一个或操作,因此任何标记为任何指定作用域的字段都将被包含。

$json = $serde->serialize($product, 'json', scopes: ['legacy', 'newsystem']);
{
    "id": 5,
    "name": "Fancy widget",
    "price": "9.99",
    "cost": 9.99,
    "desc": "A fancy widget"
}

请注意,由于存在$name上无作用域和作用域版本的Field,作用域版本将获胜,并且属性将被重命名。

如果有多个属性变体适用于指定的作用域,则作用域中字典序第一个将优先于后面的,并且作用域属性将优先于无作用域属性。

请注意,在反序列化时,指定作用域将排除作用域外的属性及其默认值。也就是说,它们将不会设置,即使设置为默认值,因此可能是“未初始化”的。这很少是所希望的,因此可能最好在没有作用域的情况下进行反序列化,即使值是用作用域序列化的。这取决于您的用例。

有关作用域的更多信息,请参阅AttributeUtils文档。

使用#[PostLoad]进行验证

重要的是要注意,在反序列化时,根本不会调用__construct()。这意味着构造函数中存在的任何验证在反序列化时都不会运行。

相反,Serde将查找任何具有#[\Crell\Serde\Attributes\PostLoad]属性的方法或方法。此属性不接受除了作用域之外的任何参数。在对象被填充后,任何PostLoad方法将按字典顺序以无参数方式调用。此功能的主要用例是验证,在这种情况下,如果填充的数据在某些方面无效,则方法应抛出异常。(例如,某些整数必须是正数。)

方法的可见性无关紧要。Serde将以相同的方式调用publicprivateprotected方法。请注意,但是,父类中的private方法在反序列化到的类中不会被调用,因为它从该作用域无法访问PHP。

扩展Serde

在内部,Serde 有六种类型的扩展,它们协同工作以生成序列化或反序列化产品。

  • 类型映射(如上所述)是可选的,可以将类名转换为查找标识符,然后再转换回来。
  • 一个Exporter 负责从对象中提取值,如果需要则进行处理,然后将它们传递给格式化器。这是序列化管道的一部分。
  • 一个 Importer 负责使用格式化器从传入的数据中提取数据,并将其转换为必要的格式,以便写入对象。这是反序列化管道的一部分。
  • 一个 Formatter 负责写入特定的输出格式,例如 JSON 或 YAML。这是序列化管道的一部分。
  • 一个 Deformatter 负责从传入的格式中读取数据,并将其传递回一个 Importer。这是反序列化管道的一部分。
  • 一个 TypeField 是可以添加到属性中以提供更多特定类型逻辑的自定义类型字段,可以添加到属性中以提供更多特定类型逻辑的。在某种程度上,它们是 Exporter 或 Importer 的“额外参数”,如果你实现了自定义的,你几乎肯定也会实现自己的 Exporter 或 Importer 来配合使用。《DateTimeField》、《DictionaryField》和《SequenceField》是类型字段的例子。类型字段也是可传递的,因此可以将它们放在自定义类上,以应用于该类在属性中使用的任何地方(除非局部覆盖)。

总的来说,ImporterExporter 实例被称为“处理器”。

一般来说,ImporterExporterPHP类型特定的,而 FormatterDeformatter序列化格式特定的。自定义导入器和导出器也可以声明自己为格式特定的,如果它们包含与格式相关的优化。

ImporterExporter 可以在同一个对象上实现,也可以不在。同样,FormatterDeformatter 可以一起实现,也可以不实现。这取决于特定实现中最简单的方法,并且提供的扩展根据用例做了一些这样的工作。

上面链接的接口提供了更精确的解释说明如何使用它们。在大多数情况下,你只需要实现一个格式化器或反格式化器来支持新的格式。只有在你处理需要额外特殊处理的特定类时,你才需要实现导入器或导出器,例如,其序列化表示与对象表示几乎没有关系。

作为一个例子,包含了一些自定义处理器来处理常见情况。

  • DateTimeExporter:此对象将 DateTimeDateTimeImmutable 对象转换为和从序列化形式转换为字符串。具体来说,它将在序列化时使用 \DateTimeInterface::RFC3339_EXTENDED 格式。时间戳将作为普通字符串出现在序列化输出中。在反序列化时,它将接受由 DateTime 构造函数支持的任何日期时间格式。
  • DateTimeZoneExporter:此对象将 DateTimeZone 对象转换为和从序列化形式转换为时区字符串。也就是说,DateTimeZone('America/Chicago)将以字符串形式表示为America/Chicago`。
  • NativeSerializeExporter:此对象适用于任何具有 __serialize() 方法(在序列化时)或 __unserialize() 方法(在反序列化时)的类。这些 PHP 魔术方法提供了对象的不同表示形式,用于与 PHP 的原生 serialize()unserialize() 方法一起使用,但也可以用于任何其他格式。如果定义了 __serialize(),则将调用它,并将返回的关联数组作为字典写入所选格式。如果定义了 __unserialize(),则此对象将从传入数据中读取字典,并将其传递给新创建的对象上的该方法,然后该对象将负责以适当的方式填充对象。在任一方向上都不会进行进一步处理。
  • EnumOnArrayImporter:Serde 原生支持 PHP 枚举,可以根据需要将其序列化为整数或字符串。然而,在从 PHP 数组格式读取的特殊情况下,此对象将接管并支持读取传入数据中的枚举字面量。这允许,例如,配置数组包含手动插入的枚举值,并且仍然可以干净地导入到类型化定义的对象中。

架构图

序列化工作大致如下

反序列化看起来非常相似

在这两种情况下,请注意,几乎所有行为都由一个单独的序列化器/反序列化器对象控制,而不是由 Serde 本身控制。Serde 本身只是一个配置运行器对象上下文的包装器。

依赖注入配置

Serde 设计为“开箱即用”,无需任何额外设置。但是,当将其包含在更大的系统中时,最好通过依赖注入进行正确配置。

您可以通过三种方式设置 Serde。

  1. SerdeCommon 类包含大多数可用的处理器和格式化程序,开箱即用,尽管您可以通过构造函数添加额外的处理器。
  2. SerdeBasic 类没有任何预构建的配置;您需要自己提供所有处理器、格式化程序或类型映射,按所需应用的顺序。
  3. 您还可以扩展 Serde 基类本身,并创建自己的自定义预制作配置,仅包括所需的处理器或格式化程序(提供的或自定义)。

SerdeCommonSerdeBasic 都接受四个参数:要使用的 ClassAnalyzer、处理器数组、格式化程序数组和类型映射数组。如果没有提供分析器,Serde 默认创建一个内存缓存的分析器,以确保始终可用。然而,在 DI 配置中,强烈建议您自己配置分析器,并进行适当的缓存,然后将它作为依赖项注入到 Serde 中,以避免重复的分析器(和重复的缓存)。如果您在不同的服务中具有多个不同的 Serde 配置,也可能有益于将所有处理器和格式化程序也作为服务,并显式地将它们注入到 SerdeBasic 中,而不是依赖于 SerdeCommon

变更日志

有关最近更改的更多信息,请参阅变更日志

测试

$ composer test

贡献

有关详细信息,请参阅 贡献指南行为准则

安全性

如果您发现任何与安全相关的问题,请使用 GitHub 安全报告表 而不是问题队列。

鸣谢

此库的初始开发由 TYPO3 GmbH 赞助。

许可

Lesser GPL 版本 3 或更高版本。请参阅许可文件获取更多信息。