Serde(读作 “seer-dee”)是一个快速、灵活、强大且易于使用的 PHP 序列化/反序列化库,支持多种标准格式。它从 Rust 的 Serde crate 和 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();
// 以某种方式填充 $object;
$jsonString = $serde->serialize($object, format: 'json');
$deserializedObject = $serde->deserialize($jsonString, from: 'json', to: SomeClass::class);
(命名参数是可选的,但推荐使用。) Serde 高度可配置,但常见场景只需直接使用提供的 SerdeCommon 类即可满足大多数基本需求。
Serde 可以序列化为:
array)json)json-stream)yaml)toml)csv)csv-stream) Serde 可以从以下格式反序列化:array)json)yaml)toml)csv) YAML 支持需要 Symfony/Yaml 库。 TOML 支持需要 Vanodevium/Toml 库。 XML 支持正在开发中。Serde 自动支持在其他对象属性中嵌套的对象,只要没有循环引用,这些嵌套对象会被递归处理。 Serde 可以处理 public、private、protected 和 readonly 属性,支持读取和写入,并支持可选的默认值。 如果你尝试序列化或反序列化一个实现了 PHP __serialize() 或 __unserialize() 钩子的对象,这些钩子会被尊重。(如果你想以 PHP 内部序列化格式读写,直接调用 serialize() / unserialize() 即可。) Serde 还支持加载后回调,允许你在必要时重新初始化派生信息,而无需将其保存在序列化格式中。 PHP 对象可以在序列化格式与对象之间互相转换。嵌套对象可以被“展平”或“收集”,具有公共接口的类可以映射到合适的对象,数组值可以在序列化时拼合成字符串,读取时再拆分为数组。
Serde 的行为几乎完全通过属性(Attributes)来驱动。任何类都可以按原样序列化或反序列化,无需额外配置,但你可以选择性地进行大量配置。
属性处理由 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。(参见下文 renameWith。)类级别的设置适用于没有单独指定行为的字段。omitNullFields,默认为 false。如果设为 true,则序列化时该类中任何为 null 的属性都会被省略。这对反序列化没有影响。该设置也可以按字段单独配置(参见下文 omitIfNull)。scopes,设置某个类定义属性的作用域。参见下文“作用域(Scopes)”部分。exclude(bool,默认 false)如果设为 true,Serde 在序列化和反序列化时都会完全忽略该属性。
serializedName(string,默认 null)如果提供,该字符串会在序列化到某种格式时作为属性名,并在从该格式读取时使用该名称。例如:
use Crell\Serde\Attributes\Field;
class Person
{
#[Field(serializedName: 'callme')]
protected string $name = 'Larry';
}
会往返于:
{
"callme": "Larry"
}
renameWith(RenamingStrategy,默认 null)renameWith 指定一种策略,用于“变形”属性名以生成序列化名称。最常见的例子是大小写转换,例如序列化到一种与 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::UPPERCASECases::lowercaseCases::snake_caseCases::kebab_case(使用连字符而非下划线)Cases::CamelCaseCases::lowerCamelCasePrefix 类在序列化时为值附加前缀,但保留属性名本身不变。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"
}
如果同时指定了 serializedName 和 renameWith,则 serializedName 优先,renameWith 会被忽略。
alias(array,默认 [])仅在反序列化时生效。如果期望的序列化名称在传入数据中不存在,则会检查这些额外的属性名称,看看能否从中找到值。如果找到,就从传入数据的那个键读取值;如果都没找到,行为就和一开始没找到值一样。
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(bool,默认 false)仅在序列化时生效。如果设为 true,当该属性的值为 null 时,该属性会从输出中完全省略。如果为 false,则会在输出中写入一个 null,具体形式取决于格式。
useDefault(bool,默认 true)仅在反序列化时生效。如果类的某个属性在传入数据中不存在,且该属性为 true,则会赋一个默认值。如果为 false,则完全跳过该值。反序列化后的对象是否处于无效状态,取决于对象本身。
使用的默认值会从多个不同位置推导,优先级顺序为:
Field 属性的 default 参数提供的值。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 设为 'Hidden',name 设为 'Anonymous',而 age 仍然未初始化。
default(mixed,默认 null)仅在反序列化时生效。如果指定,那么当传入数据中缺少某个值时,将使用这个值,而不管代码本身声明的默认值是什么。
strict(bool,默认 true)仅在反序列化时生效。如果设为 true,传入数据中的类型不匹配会被拒绝并抛出异常。如果为 false,解格式器会尝试按照 PHP 的常规转换规则对传入值进行类型转换。这意味着,例如当 strict 为 false 时,字符串 "1" 对于整数属性是合法的;但如果为 true,则会抛出异常。 对于序列字段,strict 为 true 时会拒绝非序列值(必须通过 array_is_list() 检查)。如果 strict 为 false,任何“类似数组”的值都会被接受,但会通过 array_values() 丢弃键并重建索引。
此外,在非 strict 模式下,传入数组中的数字字符串会在序列字段和字典字段中按需转换为 int 或 float。在 strict 模式下,数字字符串仍会被拒绝。 该设置的具体行为可能因传入格式略有不同,因为某些格式对自身类型的处理方式不同。(例如在 XML 中,所有内容都是字符串。)
requireValue(bool,默认 false)仅在反序列化时生效。如果设为 true,当传入数据中不包含该字段的值且没有指定默认值时,将抛出 MissingRequiredValueWhenDeserializing 异常。如果未设置,且没有默认值,则该属性会保持未初始化状态。
如果字段有默认值,则缺失数据总是使用默认值,此设置无效。
flatten(bool,默认 false)flatten 关键字只能应用于数组或对象属性。一个被“展平”的属性,其所有属性在序列化时会被直接注入到父对象中;在反序列化时,父对象中的值会被“收集”回该属性。
多个对象和数组可以被展平(序列化),但在反序列化时,只有词法上最后一个标记为 flatten 的数组属性会收集剩余键。任意数量的对象都可以“收集”它们的属性。
以分页为例。在 PHP 中,把分页信息表示为结果集的一个对象属性可能很有帮助,但在序列化后的 JSON 或 XML 中,你可能希望去掉那个额外的对象层。 给定如下一组类:
use Crell\Serde\AttributesasSerde;
class Results
{
publicfunction __construct(
#[Serde\Field(flatten: true)]
public Pagination $pagination,
#[Serde\SequenceField(arrayType: Product::class)]
public array $products,
) {}
}
class Pagination
{
publicfunction __construct(
public int $total,
public int $offset,
public int $limit,
) {}
}
class Product
{
publicfunction __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\AttributesasSerde;
class DetailedResults
{
publicfunction __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
{
publicfunction __construct(
public int $total,
public int $limit,
#[Serde\Field(flatten: true)]
public PaginationState $state,
) {}
}
class PaginationState
{
publicfunction __construct(
public int $offset,
) {}
}
class ProductType
{
publicfunction __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 会反序列化回与前述相同的对象。
当一个对象或数组属性被展平时,默认会使用其现有名称(或 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 对象。arrayType 在 DictionaryField 上的工作方式完全相同。
keyType仅对 DictionaryField 有效,可以将数组限制为只允许整数键或字符串键。有两个合法值:KeyType::Int 和 KeyType::String(一个枚举)。如果设为 KeyType::Int,则反序列化时会拒绝任何包含字符串键的数组,但接受数字字符串。如果设为 KeyType::String,则拒绝任何包含整数键(包括数字字符串)的数组。
(PHP 会自动将整数字符串数组键转为真正的整数,因此在基于字符串的字典中无法允许它们。) 如果不设置,则两种键类型都会被接受。
implodeOn如果 SequenceField 上设置了 implodeOn,表示该值在序列化时应使用提供的“胶水”拼接成一个字符串。例如:
use Crell\Serde\Attributes\SequenceField;
class Order
{
#[SequenceField(implodeOn: ',')]
protected array $productIds = [5, 6, 7];
}
在 JSON 中会序列化为:
{
"productIds": "5,6,7"
}
在反序列化时,该字符串会自动拆分成数组并写入对象。 默认情况下,反序列化时会对各个值执行 trim() 以去除多余空白。可以通过将属性参数 trim 设为 false 来禁用该行为。
joinOnDictionaryField 也支持在序列化时拼合/拆分,但需要两个键。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(),除非在属性参数中设置 trim: false。
DateTime 和 DateTimeImmutable 字段也可以被序列化,你可以通过 DateField 或 UnixTimeField 属性来控制它们的序列化方式。DateField 有两个参数,可以单独使用或一起使用。两个都不指定,效果和不指定 DateField 属性一样。
use Crell\Serde\Attributes\DateField;
class Settings
{
#[DateField(format: 'Y-m-d')]
protected DateTimeImmutable $date = new DateTimeImmutable('2022-07-04 14:22');
}
会序列化为如下 JSON:
{
"date": "2022-07-04"
}
注意,类型为 DateTimeInterface 的属性在反序列化时会变成一个 DateTimeImmutable 对象。
timezonetimezone 参数可以是 PHP 中合法的时区字符串,例如 America/Chicago 或 UTC。如果指定,值会在序列化之前先转换到该时区。如果不指定,则序列化时保持值原来的时区。这是否会影响输出,取决于 format。 在反序列化时,timezone 没有影响。如果传入值指定了时区,生成的 DateTime[Immutable] 对象会使用该时区;如果没有,则使用系统默认时区。
format该参数允许你指定序列化时使用的格式。可以是 PHP date_format 语法接受的任何字符串,包括 DateTimeInterface 上定义的各种常量。如果不指定,默认格式为 RFC3339_EXTENDED,即 Y-m-d\TH:i:s.vP。虽然不太易读,但它是 JavaScript/JSON 的默认格式,因此兼容性较好。 在反序列化时,format 不起作用。Serde 会将字符串值传给 DateTime 或 DateTimeImmutable 构造函数,因此任何 PHP 能识别的格式都会按照 PHP 的标准日期解析规则进行解析。
如果需要将日期序列化为/从 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 年代)自纪元以来的微秒数将无法工作。
mixed 类型的值带来一个有趣的挑战:根据定义,它们的数据类型是不确定的。 在序列化时,Serde 会尽力从值本身推导出要序列化的类型。因此,如果 mixed 属性的值是 "beep",它会尝试作为字符串序列化;如果值是 [1, 2, 3],会尝试作为数组序列化。 在反序列化时,Serde 会通过“有根据的猜测”来推导类型。如果使用的解格式器实现了 SupportsTypeIntrospection(json、yaml、toml、array 已经实现),则会询问解格式器该值应该是什么类型。如果未提供额外信息,序列和字典会被读作数组,但不支持对象(它们会被当作字典处理)。 或者,你可以将字段标记为 #[MixedField(Point::class)],它有一个必需参数 suggestedType。如果指定,任何“类似数组”的传入值都会被反序列化为指定的类。如果值与该类不兼容,会抛出异常。这意味着无法同时支持数组反序列化和对象反序列化。
class Message
{
public string $message;
#[MixedField(SomeClass::class)]
public mixed $result;
}
如果你只是从一个带有 mixed 属性的对象进行序列化,这些顾虑就不存在,通常也不需要额外处理。
大多数 Serde 行为都假定某个字段是单一类型。如果字段是复合类型(union、intersection 或两者的组合),Serde 内部会把它当作 mixed 来处理,行为与上面的 mixed 字段相同。 这意味着,在实践中,只有在使用支持 SupportsTypeIntrospection 的解格式器时,联合类型和复合类型才会被支持。不过这已经包含了最常见的格式,因此大多数情况下都能正常工作。MixedField 属性也可以应用在复合类型上,行为如上所述。 或者,如果字段明确是一个联合类型,你也可以使用 UnionField 类型字段属性。它的行为与 MixedField 相同,但多了一个数组参数,允许你为 union 中的每种类型指定单独的 TypeField。这在例如某个子类型是数组时特别有用,你可以把它标记为序列或字典,以便正确反序列化对象列表。 例如,下面的示例声明 $values 可以是一个字符串,也可以是一个以字符串为键的 Point 对象数组。如果系统无法推断类型,则回退为普通数组。
class Record
{
public function __construct(
#[UnionField('array', [
'array' => new DictionaryField(Point::class, KeyType::String)]
)]
public string|array $values,
) {}
}
PHP 有多种“惰性列表”选项,通常都是实现了 \Traversable 接口的对象。不过有几种语法选项,各自有细微差别。Serde 以不同方式支持它们。 如果一个属性被定义为 iterable,那么无论它是 Traversable 对象还是生成器,在序列化过程中都会被“跑完”并转换为数组。注意,如果该迭代器是一个无限迭代器,进程会永远执行下去,程序会卡死。不要这么做。
此外,使用 iterable 属性时,必须根据需要标记为 #[SequenceField] 或 #[DictionaryField]。Serde 无法像对待普通数组那样(通常能)自动推断它到底是序列还是字典。 在反序列化时,传入值总是会被赋为数组。因为数组本身也是 iterable,这仍然是类型安全的。虽然理论上可以构建一个动态生成器来惰性生成值,但这并不会真正节省内存。
注意,这也意味着序列化和反序列化一个对象并不完全对称:初始对象的某些属性可能是生成器,但反序列化后的对象中会是数组。
如果属性被定义为其他 Traversable 对象(通常是因为实现了 \Iterator 或 \IteratorAggregate),则会被当作普通对象进行序列化和反序列化。它的“可迭代性”会被忽略。此时,#[SequenceField] 和 #[DictionaryField] 属性是被禁止的。
Serde 内置支持 CSV 文件的序列化/反序列化。但由于 CSV 是一种受限的格式,仅支持某些对象结构。
具体而言,目标对象必须有一个标记为 #[SequenceField] 的属性,并且必须显式指定一个 arrayType,且该类型必须是一个类。这个类本身只能包含 int、float 或 string 类型的属性。其他情况会抛出错误。 例如:
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 参数。
返回值会是同一个流句柄,在待序列化对象被写入后返回。 例如:
// JsonStreamFormatter 和 CsvStreamFormatter 默认未包含。
$s = new SerdeCommon(formatters: [new JsonStreamFormatter()]);
// 你可以使用 PHP 支持的任何流,包括文件、网络套接字、stdout、内存临时流等。
$init = FormatterStream::new(fopen('/tmp/output.json', 'wb'));
$result = $serde->serialize($data, format: 'json-stream', init: $init);
// $result 是一个 FormatterStream 对象,封装了与前述相同的句柄。
// 接下来你可以对这个流做什么,取决于它是什么类型的流。
在这个例子中,$data 对象(无论是什么)会被逐步序列化为 JSON,并流式写入指定的文件句柄。CsvStreamFormatter 的使用方式完全相同,只是输出 CSV 数据,并且对所接受对象有与 CsvFormatter 相同的限制。
在很多情况下,这并不会带来太大收益,因为整个对象本来就必须在内存中。但它可以与惰性迭代器结合使用:例如某个属性从数据库查询或其他数据源惰性地生成对象。 考虑这个示例:
use Crell\Serde\Attributes\SequenceField;
class ProductList
{
publicfunction __construct(
#[SequenceField(arrayType: Product::class)]
private iterable $products,
) {}
}
class Product
{
publicfunction __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");
// 假设 $record 是一个关联数组。
foreach ($result as $record) {
yieldnew Product(...$record);
}
};
// 这是一个惰性的产品列表,会从数据库中拉取。
$products = new ProductList($callback());
// 这次使用 CSV 格式化器,但 JsonStream 也可以。
$s = new SerdeCommon(formatters: [new CsvStreamFormatter()]);
// 写入 stdout,也就是回传给浏览器。
$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)]
protectedarray $products;
}
Sale 和 Order 都引用了 Book,但这个值既可能是 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 实现细节(类名)放进输出中,这可能不是你想要的。