返回

Symfony Serializer:解决反序列化时构造函数参数为NULL

php

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.xsymfony/serializer: 5.1.x

跑完之后,$parsed 里面的 $low, $medium, $high 属性居然是 NULL,或者说这几个对象是空的,这可咋整?别急,咱来分析分析。

一、问题在哪儿?剖析根源

Symfony SerializerObjectNormalizer 在反序列化时,确实会尝试使用类的构造函数。它聪明地通过构造函数的参数名和类型提示来匹配 JSON 数据中的键。

对于 FeeClass 来说,它的构造函数是 __construct(Low $low, Medium $medium, High $high)ObjectNormalizer 看到这个,就会去 JSON 里找 lowmediumhigh 这三个键。

找到是找到了,接下来它需要:

  1. 把 JSON 中 low 键对应的值 ({"networkFee":"0.00003"}) 反序列化成一个 Low 类的实例。
  2. 同理,处理 mediumhigh
  3. 最后,把这三个实例化好的对象传给 FeeClass 的构造函数。

问题就出在第 1 步和第 2 步。LowMediumHigh 这三个类,它们各自只有一个私有属性 $networkFee,并且没有提供任何方式让 ObjectNormalizer 在创建实例后给这个 $networkFee 属性赋值 。它们既没有接受 $networkFee 的构造函数,也没有公开的 setNetworkFee() 方法,属性本身还是 private 的。

所以,ObjectNormalizer 尝试创建 Low 对象时,虽然能实例化一个空的 Low 对象,但没办法把 {"networkFee":"0.00003"} 里面的 "networkFee" 值塞进这个对象的 $networkFee 属性里。结果就是,传给 FeeClass 构造函数的 $low$medium$high 参数,可能是空壳对象(其 $networkFee 属性是 null 或者未初始化)。如果 ObjectNormalizer 发现无法有效填充嵌套对象,甚至可能直接传递 nullFeeClass 的构造函数,具体行为取决于配置和版本。

简言之,根儿上的问题是:嵌套的 LowMediumHigh 类没有合适的途径(构造函数或 setters)来接收 JSON 数据中的 networkFee 值。

二、亮剑!解决方案来了

别慌,有几招可以轻松搞定。

方案一:给嵌套类(Low, Medium, High)添加构造函数(推荐)

这是最符合面向对象设计和依赖注入思想的做法。既然这些类需要 networkFee 这个数据才能完整,那就让它们的构造函数直接接收这个值。

1. 原理和作用

ObjectNormalizer 在尝试实例化一个对象时,会优先检查构造函数。如果构造函数的参数名(比如 $networkFee)和 JSON 数据中的键名(比如 "networkFee")能对上,并且类型也兼容,它就会用 JSON 里的值作为参数来调用构造函数。

2. 操作步骤与代码示例

修改 LowMediumHigh 类,给它们各自加上构造函数:

<?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. 操作步骤与代码示例

修改 LowMediumHigh 类,移除或保留空构造函数,并添加 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 属性设为 publicObjectNormalizer 也能直接给公有属性赋值。但通常不推荐这样做,因为它破坏了封装性。
    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 的脾气。只要咱们把类结构和数据流向捋清楚,它还是挺听话的。