编程

Symfony VarDumper 组件打印变量

1223 2023-05-03 19:58:00

说到调试,Symfony 的 VarDumper 组件带来了革命性变化。它让我们可以用一种简洁高效的方式打印变量。如果你的项目中还没有用到,应该去试一试。

本文我们将一起看看怎么样用它来打印对象,使之更具可读性。

问题

在一个使用 Stripe 的项目用,我们用 dump 打印 StripeObject, 我们得到如下的情况:

如你所想,可读性不是很好。其中有很多内部属性。如果展开,我们可以看到原始 HTTP 响应,这个响应被解析成数组,也解析成对象。

里面的信息太多太杂,而且还有许多重复。而且,这个 VarDumper 组件只能打印有限数量的对象。虽然本例假定它打印了所有对象。因此我们会遗漏一些东西。

方案

该组件有一个强大的方案用来缓解这一问题:Casters。它让我们可以定义如何打印一个对象。这个工具很强大,不过也有点难用。我们来看看怎样用它以更具可读性的方式来打印 StripeObject 吧。

VarDumper 组件的工作原理

要理解 caster 的工作原理,就要先理解 VarDumper 的工作原理。有点复杂,不过值得。

本文,我们不会去解释所有事情,否则覆盖的东西就太多了。我们只会专注于最重要的部分。

该组件由两个主要部分组成:

  • VarCloner,负责克隆变量用来 dump 打印。它是个递归过程,可以克隆任何变量。它的主要目标是对变量进行快照,这样我们就可以在稍后打印他们;
  • VarDumper 负责打印快照。它也是递归的。它的主要目标是将变量以可读的方式显示出来。Symfony 中有三个 dumper:  HtmlDumper,  CliDumper 以及 ServerDumper。第一个用在浏览器,第二个用在 CLI 命令行中,第三个则用在将 dump 信息发送给连接的客户端 (vendor/bin/var-dump-server)。

你猜对了,我们主要关注 VarCloner!

如果组合起来,大概是这样的:

function dump_something($variable, $maxDepth = 3): string
{
    $cloner = new VarCloner();

    // $data is the snapshot of the $variable
    $data = $cloner->cloneVar($variable);

    $dumper = new HtmlDumper();
    $dumper->setDisplayOptions([
        'maxDepth' => $maxDepth,
    ]);

    $output = fopen('php://memory', 'r+b');

    $dumper->dump($data, $output);

    // It returns the dump as a string. It contains some HTML, CSS, and JavaScript
    return stream_get_contents($output, -1, 0);
}

VarCloner

VarCloner 有更加复杂的方法来处理特定的案例,不过主要的理念是将变量转换成数组: 

class Foobar
{
    public $public = 'foo';
    protected $protected = 'bar';
    private $private = 'baz';
}

var_dump((array) new Foobar());

结果是:

^ array:3 [
  "public" => "foo"
  "\x00*\x00protected" => "bar"
  "\x00Foobar\x00private" => "baz"
]

Note:  要显示上述结果,我们实际使用了 dump() 而不是 var_dump(),因为它更有可读性,而且 var_dump() 不会隐藏 \x00 这样的字符。

VarCloner 有一系列 casters,用来从数组中添加或删除一些信息。比如 DateCaster 负责转换 DateTime 并可以用时区以人类可读的格式用添加日期。它也能转换 DateInterval 类型,以人类可读的格式添加时间间隔:

$php > dump(new DateTime());
^ DateTime @1679497380 {#30
  date: 2023-03-22 16:03:00.646944 Europe/Paris (+01:00)
}

php > dump(new DateInterval('P1Y2M3DT4H5M6S'));
^ DateInterval {#30
  interval: + 1y 2m 3d 04:05:06.0
  +"y": 1
  +"m": 2
  +"d": 3
  +"h": 4
  +"i": 5
  +"s": 6
  +"f": 0.0
  +"invert": 0
  +"days": false
  +"from_string": false
}

另一个好的示例是 DoctrineCaster:它从 Doctrine 代理中,删除  __cloner__ 和  __initializer__ 属性。因此,这个 dump 更小。

因此,要想扩展 VarCloner,我们将使用 Caster。

Caster

空的 caster 长这样:

namespace App\Bridge\VarDumper\Caster;

use Stripe\StripeObject;
use Symfony\Component\VarDumper\Cloner\Stub;

class StripeObjectCaster
{
    public static function castStripeData(
        StripeObject $object,
        array $a,
        Stub $stub,
        bool $isNested,
        int $filter = 0,
    ) : array {
        return $a;
    }
}

castStripeData() 方法由 VarCloner 调用。我们可以按照我们所需的方式命名。它有 5 个参数:

  1. $object: 原始对象 (一个 StripeObject 实例)
  2. $a: 该对象的数组表示( (array) $object 的转换结果)
  3. $stub: VarDumper 用以显示变量的一个对象。我们可以添加一些元数据到其中,它会在 dump 中显示
  4. $isNested: 一个布尔值说明一个对象是否嵌套在另一个对象中
  5. $filter: 位掩码说明那些信息该展示

该方法必须返回一个用来展示变量的数组。

现在,我们需要填充 castStripeData() 方法,移除内部属性。

删除一些属性

要移除这些属性,我们需要先知道他们的属性名。还记得吗?这些名字很奇怪。我们建议先用 dump() 找到所有属性:

dump(array_keys($a));

// StripeObjectCaster.php on line 15:
// array:57 [▼
//   0 => "\x00*\x00_opts"
//   1 => "\x00*\x00_originalValues"
//   2 => "\x00*\x00_values"
//   3 => "\x00*\x00_unsavedValues"
//   4 => "\x00*\x00_transientValues"
//   5 => "\x00*\x00_retrieveOptions"
//   6 => "\x00*\x00_lastResponse"
//   7 => "saveWithParent"
//   8 => "\x00~\x00id"
// ...

现在我们可以从数组中删除这些属性:

unset($a["\x00*\x00_opts"], $a["\x00*\x00_originalValues"], ...);

这么做也有个小缺点:我们不清楚有些值已经被删除了。要解决这个问题,我们可以让 VarDumper 显示删除属性的数量:

$stub->cut += 7; // 7 is the number of removed properties

添加一些属性

我们也可以添加一些属性到数组中。比如我们可以添加虚拟的(virtual) billing_cycle_anchor_as_date_time 属性:

if (isset($r->billing_cycle_anchor)) {
    $a += [
        Caster::PREFIX_VIRTUAL.'billing_cycle_anchor_as_date_time' => new \DateTime('@'.$r->billing_cycle_anchor),
    ];
}

组合

下面是最终的 caster 代码。它被稍微优化成不一定要维护属性列表:

class StripeObjectCaster
{
    private static array $propertiesToRemove;

    public static function castStripeData(StripeObject $r, array $a, Stub $stub, bool $isNested, int $filter = 0)
    {
        if (!isset(self::$propertiesToRemove)) {
            self::$propertiesToRemove = array_keys((array) new StripeObject());
        }

        foreach (self::$propertiesToRemove as $property) {
            if (array_key_exists($property, $a)) {
                unset($a[$property]);
                $stub->cut++;
            }
        }

        if (isset($r->billing_cycle_anchor)) {
            $a += [
                Caster::PREFIX_VIRTUAL.'billing_cycle_anchor_as_date_time' => new \DateTime('@'.$r->billing_cycle_anchor),
            ];
        }

        return $a;
    }
}

注册 caster

如果你失踪的不是全栈框架,你可以这样注册 caster :

$cloner = new VarCloner();
$cloner->addCasters([
    StripeObject::class => StripeObjectCaster::castStripeData(...),
]);

这个数组中,key 是需要转换的类,value 是用于转换的可调用类型(callable)。

如果你用的是全栈框架,你也需要手动注册 caster。的确,VarCloner 没有在容器中注册。有一个这么做的好地方是,在  Kernel::boot() 方法中:

class AppKernel extends Kernel
{
    public function boot()
    {
        parent::boot();

        AbstractCloner::$defaultCasters += [
            \Stripe\StripeObject::class => StripeObjectCaster::castStripeData(...),
        ];
    }
}

结语

🎉 恭喜!现在我们学会了如何扩展 VarDumper 用更佳的展示对象。

顺便提一下,你知道你可以使用 PHP REPL(php -a) 中的 dump() 函数吗?要做到这一点,你应该克隆 VarDumper 组件到某个位置,安装依赖项,并在 php.ini 文件中添加下面这行: 

auto_prepend_file = /path/to/var-dumper/vendor/autoload.php

现在,你可以在 PHP REPL 中使用 dump() 了:

$ php -a
Interactive shell

php > dump(new \DateTimeZone('Europe/Paris'));
^ DateTimeZone {#23
  timezone: Europe/Paris (+01:00)
  +"timezone_type": 3
  +"timezone": "Europe/Paris"
}

另一个我们常用的小技巧是:Twig 扩展在生产环境中 dump 变量!只有管理员有充足的权限,所以别担心。有人说这是坏的实践,不过,我们发现,在生产环境中调试一些数据问题时,这对我们很有帮助。