Symfony Serializer:解决反序列化时构造函数参数为NULL
2025-05-04 13:37:46
Symfony Serializer:搞定 JSON 响应反序列化与类构造函数参数的那些事儿
哥们儿,你在用 PHP Symfony 的 Serializer 组件把 JSON 响应体反序列化成 PHP 对象时,是不是也遇到过这种蛋疼的情况:目标类的构造函数明明定义好了参数,可反序列化出来的对象,这些参数对应的属性值却是 NULL
?就像下面这段代码的场景:
JSON 数据长这样:
$response body = {"low":{"networkFee":"0.00003"},"medium":{"networkFee":"0.0000428"},"high":{"networkFee":"0.00024"}}
PHP 类结构呢,是这么定义的:
class Low {
private $networkFee;
// 为了能看到反序列化后的值,咱们加个 getter
public function getNetworkFee(): ?string { return $this->networkFee; }
}
class Medium {
private $networkFee;
public function getNetworkFee(): ?string { return $this->networkFee; }
}
class High {
private $networkFee;
public function getNetworkFee(): ?string { return $this->networkFee; }
}
class FeeClass{
private Low $low; // 明确类型是个好习惯
private Medium $medium;
private High $high;
public function __construct(Low $low, Medium $medium, High $high) {
$this->low = $low;
$this->medium = $medium;
$this->high = $high;
}
// 加几个 getter 方便验证
public function getLow(): Low { return $this->low; }
public function getMedium(): Medium { return $this->medium; }
public function getHigh(): High { return $this->high; }
}
// 假设 $response->getBody() 返回的是上面的 JSON 字符串
$jsonString = '{"low":{"networkFee":"0.00003"},"medium":{"networkFee":"0.0000428"},"high":{"networkFee":"0.00024"}}';
$encoders = [new \Symfony\Component\Serializer\Encoder\JsonEncoder()];
// 注意:为了让构造函数参数注入更好用,通常需要 PropertyInfoExtractor
// 如果安装了 symfony/property-info,ObjectNormalizer 会自动使用它
$normalizers = [new \Symfony\Component\Serializer\Normalizer\ObjectNormalizer(null, null, null, new \Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor())];
$serializer = new \Symfony\Component\Serializer\Serializer( $normalizers, $encoders );
// $parsed = $serializer->deserialize($jsonString, FeeClass::class, 'json');
// var_dump("parsed:", $parsed); // 结果是三个 NULL 或者说 $low, $medium, $high 属性是 NULL
依赖环境是 laravel/framework: 6.20.x
和 symfony/serializer: 5.1.x
。
跑完之后,$parsed
里面的 $low
, $medium
, $high
属性居然是 NULL
,或者说这几个对象是空的,这可咋整?别急,咱来分析分析。
一、问题在哪儿?剖析根源
Symfony Serializer
的 ObjectNormalizer
在反序列化时,确实会尝试使用类的构造函数。它聪明地通过构造函数的参数名和类型提示来匹配 JSON 数据中的键。
对于 FeeClass
来说,它的构造函数是 __construct(Low $low, Medium $medium, High $high)
。ObjectNormalizer
看到这个,就会去 JSON 里找 low
、medium
、high
这三个键。
找到是找到了,接下来它需要:
- 把 JSON 中
low
键对应的值 ({"networkFee":"0.00003"}
) 反序列化成一个Low
类的实例。 - 同理,处理
medium
和high
。 - 最后,把这三个实例化好的对象传给
FeeClass
的构造函数。
问题就出在第 1 步和第 2 步。Low
、Medium
、High
这三个类,它们各自只有一个私有属性 $networkFee
,并且没有提供任何方式让 ObjectNormalizer
在创建实例后给这个 $networkFee
属性赋值 。它们既没有接受 $networkFee
的构造函数,也没有公开的 setNetworkFee()
方法,属性本身还是 private
的。
所以,ObjectNormalizer
尝试创建 Low
对象时,虽然能实例化一个空的 Low
对象,但没办法把 {"networkFee":"0.00003"}
里面的 "networkFee"
值塞进这个对象的 $networkFee
属性里。结果就是,传给 FeeClass
构造函数的 $low
、$medium
、$high
参数,可能是空壳对象(其 $networkFee
属性是 null
或者未初始化)。如果 ObjectNormalizer
发现无法有效填充嵌套对象,甚至可能直接传递 null
给 FeeClass
的构造函数,具体行为取决于配置和版本。
简言之,根儿上的问题是:嵌套的 Low
、Medium
、High
类没有合适的途径(构造函数或 setters)来接收 JSON 数据中的 networkFee
值。
二、亮剑!解决方案来了
别慌,有几招可以轻松搞定。
方案一:给嵌套类(Low, Medium, High)添加构造函数(推荐)
这是最符合面向对象设计和依赖注入思想的做法。既然这些类需要 networkFee
这个数据才能完整,那就让它们的构造函数直接接收这个值。
1. 原理和作用
ObjectNormalizer
在尝试实例化一个对象时,会优先检查构造函数。如果构造函数的参数名(比如 $networkFee
)和 JSON 数据中的键名(比如 "networkFee"
)能对上,并且类型也兼容,它就会用 JSON 里的值作为参数来调用构造函数。
2. 操作步骤与代码示例
修改 Low
、Medium
、High
类,给它们各自加上构造函数:
<?php
// 先把所有类定义和序列化器配置放在一起,方便演示
// composer require symfony/serializer symfony/property-info
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; // 用于改进类型提示的读取
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; // 用于读取反射信息
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
// 辅助类定义
class Low {
private string $networkFee; // 推荐加上类型提示
public function __construct(string $networkFee) { // 参数名 networkFee 对应 JSON 键
$this->networkFee = $networkFee;
}
public function getNetworkFee(): string {
return $this->networkFee;
}
}
class Medium {
private string $networkFee;
public function __construct(string $networkFee) {
$this->networkFee = $networkFee;
}
public function getNetworkFee(): string {
return $this->networkFee;
}
}
class High {
private string $networkFee;
public function __construct(string $networkFee) {
$this->networkFee = $networkFee;
}
public function getNetworkFee(): string {
return $this->networkFee;
}
}
// 主类定义
class FeeClass{
private Low $low;
private Medium $medium;
private High $high;
public function __construct(Low $low, Medium $medium, High $high) {
$this->low = $low;
$this->medium = $medium;
$this->high = $high;
}
public function getLow(): Low { return $this->low; }
public function getMedium(): Medium { return $this->medium; }
public function getHigh(): High { return $this->high; }
}
// JSON 数据
$jsonString = '{"low":{"networkFee":"0.00003"},"medium":{"networkFee":"0.0000428"},"high":{"networkFee":"0.00024"}}';
// Serializer 配置
$encoders = [new JsonEncoder()];
// 为了让 ObjectNormalizer 更好地识别构造函数参数和类型,推荐配置 PropertyInfoExtractor
// ReflectionExtractor 用于获取类型信息,PhpDocExtractor 用于从 PHPDoc 获取更精确的类型信息(比如泛型、集合的元素类型)
$reflectionExtractor = new ReflectionExtractor();
$phpDocExtractor = new PhpDocExtractor();
$propertyInfoExtractor = new PropertyInfoExtractor(
[$reflectionExtractor], // list extractors for type info
[$phpDocExtractor, $reflectionExtractor], // list extractors for description
[$phpDocExtractor], // list extractors for access
[$reflectionExtractor] // list extractors for property initializability (PHP 8.0+ constructor promotion)
);
$normalizers = [new ObjectNormalizer(null, null, null, $propertyInfoExtractor)];
// 若未安装 symfony/property-info, ObjectNormalizer 会退化,某些高级特性(如构造函数参数的精确匹配)可能受影响。
// 可以通过 composer require symfony/property-info 来安装。
$serializer = new Serializer( $normalizers, $encoders );
// 执行反序列化
$parsed = $serializer->deserialize($jsonString, FeeClass::class, 'json');
// 验证结果
var_dump($parsed->getLow()->getNetworkFee()); // 输出: string(0.00003) "0.00003"
var_dump($parsed->getMedium()->getNetworkFee()); // 输出: string(0.0000428) "0.0000428"
var_dump($parsed->getHigh()->getNetworkFee()); // 输出: string(0.00024) "0.00024"
echo "FeeClass 实例: \n";
print_r($parsed);
/*
输出应类似:
FeeClass 实例:
FeeClass Object
(
[low:FeeClass:private] => Low Object
(
[networkFee:Low:private] => 0.00003
)
[medium:FeeClass:private] => Medium Object
(
[networkFee:Medium:private] => 0.0000428
)
[high:FeeClass:private] => High Object
(
[networkFee:High:private] => 0.00024
)
)
*/
3. 额外安全建议
- 在构造函数里对传入的参数进行校验。比如,
$networkFee
应该是数字或者可以转为数字的字符串。 - 如果 JSON 数据来源不可信,要警惕潜在的注入风险,虽然在这个场景下风险较低。
4. 进阶使用技巧
- 安装
symfony/property-info
:ObjectNormalizer
在处理构造函数注入时,会依赖PropertyInfoExtractor
(如果可用) 来获取参数的类型信息。如果你还没装,composer require symfony/property-info
一下,能让它工作得更顺畅。在上面的示例代码中,我已经演示了如何显式配置PropertyInfoExtractor
。通常,ObjectNormalizer
会尝试自动侦测并使用它。 - PHP 类型提示 : 给类的属性和构造函数参数加上明确的 PHP 类型提示 (如
string
,int
,float
, 或者自定义类名) 能大大帮助 Serializer 正确推断类型并进行转换。从 PHP 7.4 开始,你也可以给类属性加上类型提示。
方案二:给嵌套类的属性添加公有 Setters
如果你不想修改嵌套类的构造函数,或者这些类可能在其他地方以不同方式实例化,那么添加公有的 setter 方法是另一种选择。
1. 原理和作用
ObjectNormalizer
在实例化一个对象后(通常是通过无参构造函数,如果存在的话,或者直接创建),会查找与 JSON 键名匹配的公有 setter 方法 (例如,对于 networkFee
键,它会找 setNetworkFee()
方法) 并调用它们来给属性赋值。
2. 操作步骤与代码示例
修改 Low
、Medium
、High
类,移除或保留空构造函数,并添加 setters:
<?php
// 接续上面的命名空间和 use 语句
// 辅助类定义 - 使用 Setters
class LowSetter {
private ?string $networkFee = null; // 允许初始为 null
// 可以有一个无参构造函数,或者不定义,PHP会自动提供
public function __construct() {}
public function setNetworkFee(string $networkFee): void { // Setter 方法
$this->networkFee = $networkFee;
}
public function getNetworkFee(): ?string {
return $this->networkFee;
}
}
class MediumSetter {
private ?string $networkFee = null;
public function setNetworkFee(string $networkFee): void {
$this->networkFee = $networkFee;
}
public function getNetworkFee(): ?string {
return $this->networkFee;
}
}
class HighSetter {
private ?string $networkFee = null;
public function setNetworkFee(string $networkFee): void {
$this->networkFee = $networkFee;
}
public function getNetworkFee(): ?string {
return $this->networkFee;
}
}
// 主类定义 - 构造函数参数类型要对应修改
class FeeClassSetter {
private LowSetter $low;
private MediumSetter $medium;
private HighSetter $high;
// 构造函数参数类型也改成 LowSetter, MediumSetter, HighSetter
public function __construct(LowSetter $low, MediumSetter $medium, HighSetter $high) {
$this->low = $low;
$this->medium = $medium;
$this->high = $high;
}
public function getLow(): LowSetter { return $this->low; }
public function getMedium(): MediumSetter { return $this->medium; }
public function getHigh(): HighSetter { return $this->high; }
}
// JSON 数据保持不变
$jsonString = '{"low":{"networkFee":"0.00003"},"medium":{"networkFee":"0.0000428"},"high":{"networkFee":"0.00024"}}';
// Serializer 配置和 PropertyInfoExtractor 保持和方案一中类似
// ... (假设 $serializer 已经按照方案一的方式配置好了)
$encoders = [new JsonEncoder()];
$reflectionExtractor = new ReflectionExtractor();
$phpDocExtractor = new PhpDocExtractor();
$propertyInfoExtractor = new PropertyInfoExtractor(
[$reflectionExtractor], [$phpDocExtractor, $reflectionExtractor], [$phpDocExtractor], [$reflectionExtractor]
);
$normalizers = [new ObjectNormalizer(null, null, null, $propertyInfoExtractor)];
$serializerSetter = new Serializer( $normalizers, $encoders );
// 执行反序列化,注意目标类名变化
$parsedSetter = $serializerSetter->deserialize($jsonString, FeeClassSetter::class, 'json');
// 验证结果
var_dump($parsedSetter->getLow()->getNetworkFee());
var_dump($parsedSetter->getMedium()->getNetworkFee());
var_dump($parsedSetter->getHigh()->getNetworkFee());
echo "FeeClassSetter 实例: \n";
print_r($parsedSetter);
3. 额外安全建议
- Setter 方法同样是外部数据进入对象的入口,所以在这里做数据校验和过滤也很重要。
4. 进阶使用技巧
- 属性直接可见(Public Properties) : 如果图省事,并且封装不是首要考虑,可以直接把
$networkFee
属性设为public
。ObjectNormalizer
也能直接给公有属性赋值。但通常不推荐这样做,因为它破坏了封装性。class LowPublic { public ?string $networkFee = null; // 公有属性 // ... getters }
方案三:使用 Name Converter (名称转换器)
假如 JSON 里的键名和你的类属性名或构造函数参数名格式不一样,比如 JSON 用 network_fee
(蛇形命名),而 PHP 用 $networkFee
(驼峰命名),那你就需要一个名称转换器。
1. 原理和作用
名称转换器(实现 NameConverterInterface
接口)可以在序列化和反序列化过程中,动态转换属性名。Symfony 自带一个 CamelCaseToSnakeCaseNameConverter
。
2. 操作步骤与代码示例
如果在你的场景中,JSON 键名是 network_fee
,而 PHP 类里的构造函数参数或属性仍是 $networkFee
:
// JSON 变为
$jsonStringSnakeCase = '{"low":{"network_fee":"0.00003"},"medium":{"network_fee":"0.0000428"},"high":{"network_fee":"0.00024"}}';
// 在创建 ObjectNormalizer 时传入名称转换器
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
// ... (PropertyInfoExtractor 配置如前)
$nameConverter = new CamelCaseToSnakeCaseNameConverter();
$normalizersWithConverter = [new ObjectNormalizer(null, $nameConverter, null, $propertyInfoExtractor)]; // 第三个参数是 PropertyAccessor, 这里用默认
$serializerWithConverter = new Serializer( $normalizersWithConverter, $encoders );
// 假设 Low, Medium, High 类使用了构造函数方案 (方案一)
// class Low { public function __construct(string $networkFee) {...} }
// ...
$parsedConverted = $serializerWithConverter->deserialize($jsonStringSnakeCase, FeeClass::class, 'json');
var_dump($parsedConverted->getLow()->getNetworkFee()); // 应该能正确获取到 "0.00003"
在当前这个问题中,你的 JSON 键名 networkFee
和类属性/参数名 networkFee
(如果采用驼峰式) 是一致的,所以暂时用不上这个。但了解一下没坏处,真实项目中命名不一致是常有的事。
三、选哪个方案?
- 首选方案一 (给嵌套类添加构造函数) 。这种方式使得对象在创建时就是完整的、有效状态,更符合不可变性和依赖注入的原则。配合
symfony/property-info
和 PHP 类型提示,ObjectNormalizer
能很好地完成工作。 - 方案二 (使用 Setters) 也很常见,尤其当类库设计需要更灵活的实例化方式,或者要兼容旧代码时。
- 方案三 (Name Converter) 是处理命名差异的利器,根据实际情况按需使用。
搞定 JSON 和 PHP 对象之间的眉来眼去,其实也就是多了解一下 Serializer 的脾气。只要咱们把类结构和数据流向捋清楚,它还是挺听话的。