控制序列化

很多时候,本地 PHP 数据需要存储在文件或数据库表中。目前技术的问题是,直接存储对象或数组等复杂的 PHP 数据是不可能的,但也有例外。

克服这一限制的方法之一是将对象或数组转换为字符串。因此,通常会选择 JSON(JavaScript Object Notation,JavaScript 对象符号)。数据一旦转换成字符串,就可以很容易地存储到任何文件或数据库中。不过,使用 JSON 格式化对象有一个问题。虽然 JSON 可以很好地表示对象的属性,但它无法直接还原原始对象的类和方法。

为了解决这个问题,PHP 语言包含了两个本地函数:serialize()unserialize(),它们可以轻松地将对象或数组转换为字符串,并将其还原为原始状态。虽然这听起来很美妙,但与 PHP 本地序列化相关的问题也不少。

在正确讨论现有 PHP 序列化架构的问题之前,我们需要仔细了解一下本地 PHP 序列化是如何工作的。

了解 PHP 序列化

当需要将 PHP 对象或数组保存到非 OOP 环境(如平面文件或关系数据库表)时,可以使用 serialize() 将对象或数组扁平化为适合保存的字符串。相反,unserialize() 则可以恢复原来的对象或数组。

下面是一个演示这一概念的简单例子:

  1. 首先,我们定义一个具有三个属性的类:

    // /repo/ch05/php8_serialization.php
    class Test {
        public $name = 'Doug';
        private $key = 12345;
        protected $status = ['A','B','C'];
    }
  2. 然后,我们创建一个实例,将实例序列化,并显示生成的字符串:

    $test = new Test();
    $str = serialize($test);
    echo $str . "\n";
  3. 下面是序列化对象的外观:

    O:4:"Test":3:{s:4:"name";s:4:"Doug";s:9:"Testkey";
    i:12345;
    s:9:"*status";a:3:{i:0;s:1:"A";i:1;s:1:"B";i:2;s:1:"C";}}

    从序列化的字符串中可以看出,字母 O 代表对象,a 代表数组,s 代表字符串,i 代表整数。

  4. 然后,我们将对象解序列化为一个新变量,并使用 var_dump() 来检查这两个变量:

    $obj = unserialize($str);
    var_dump($test, $obj);
  5. var_dump() 输出并排放置,可以清楚地看到恢复后的对象与原始对象完全相同:

    image 2023 11 21 15 22 56 942

    现在让我们来看看提供传统 PHP 序列化支持的神奇方法: __sleep()__wakeup()

理解 __sleep() 魔术方法

__sleep() 魔术方法的目的是提供一个过滤器,用于防止序列化字符串中出现某些属性。以用户对象为例,你可能希望从序列化中排除敏感属性,如国民身份证号码、信用卡号或密码。

下面是一个使用 __sleep() 魔术方法排除密码的示例:

  1. 首先,我们定义一个具有三个属性的 Test 类:

    // /repo/ch05/php8_serialization_sleep.php
    class Test {
        public $name = 'Doug';
        protected $key = 12345;
        protected $password = '$2y$10$ux07vQNSA0ctbzZcZNA'
        . 'lxOa8hi6kchJrJZzqWcxpw/XQUjSNqacx.';
  2. 然后,我们定义了一个排除 $password 属性的 __sleep() 方法:

        public function __sleep() {
            return ['name','key'];
        }
    }
  3. 然后,我们创建该类的一个实例并将其序列化。最后一行是序列化字符串的状态:

    $test = new Test();
    $str = serialize($test)
    echo $str . "\n";
  4. 在输出结果中,可以清楚地看到 $password 属性不存在。下面是输出结果:

    O:4:"Test":2:{s:4:"name";s:4:"Doug";s:6:"*key";i:12345;}

这一点很重要,因为在大多数情况下,需要序列化对象的原因是希望将其存储在某处,无论是会话文件还是数据库中。如果文件系统或数据库随后遭到破坏,你就少了一个需要担心的安全漏洞!

了解 __sleep() 方法中潜在的代码中断

__sleep() 魔法方法可能存在代码错误。在 PHP 8 之前的版本中,如果 __sleep() 返回的数组中包含不存在的属性,它们仍会被序列化并赋值为 NULL。这种方法的问题是,当对象随后被取消序列化时,就会出现一个额外的属性,而这个属性并不是设计时就有的!

在 PHP 8 中,__sleep() 魔术方法返回值中不存在的属性会被静默忽略。如果您的旧代码预见到了旧的行为,并采取措施删除了不需要的属性,或者更糟糕的是,如果您的代码假定不需要的属性存在,最终就会出错。这种假设是非常危险的,因为它会导致意想不到的代码行为。

为了说明这个问题,请看下面的代码示例:

  1. 首先,我们定义了一个 Test 类,该类定义了 __sleep() 来返回一个不存在的变量:

    class Test {
        public $name = 'Doug';
        public function __sleep() {
            return ['name', 'missing'];
        }
    }
  2. 接下来,我们创建一个 Test 实例并将其序列化:

    echo "Test instance before serialization:\n";
    $test = new Test();
    var_dump($test);
  3. 然后我们将字符串反序列化到一个新实例 $restored 中:

    echo "Test instance after serialization:\n";
    $stored = serialize($test);
    $restored = unserialize($stored);
    var_dump($restored);
  4. 理论上,两个对象实例 $test$restored 应该是相同的。但是,请看一下在 PHP 7 中运行的输出结果:

    root@php8_tips_php7 [ /repo/ch05 ]#
    php php8_bc_break_sleep.php
    Test instance before serialization:
    /repo/ch05/php8_bc_break_sleep.php:13:
    class Test#1 (1) {
        public $name => string(4) "Doug"
    }
    Test instance after serialization:
    PHP Notice: serialize(): "missing" returned as member
    variable from __sleep() but does not exist in /repo/ch05/
    php8_bc_break_sleep.php on line 16
    class Test#2 (2) {
        public $name => string(4) "Doug"
        public $missing => NULL
    }
  5. 从输出结果可以看出,这两个对象显然不一样!不过,在 PHP 8 中,不存在的属性会被忽略。请看一下在 PHP 8 中运行的相同脚本:

    root@php8_tips_php8 [ /repo/ch05 ]# php php8_bc_break_
    sleep.php
    Test instance before serialization:
    object(Test)#1 (1) {
        ["name"]=> string(4) "Doug"
    }
    Test instance after serialization:
    PHP Warning: serialize(): "missing" returned as member
    variable from __sleep() but does not exist in /repo/ch05/
    php8_bc_break_sleep.php on line 16
    object(Test)#2 (1) {
        ["name"]=> string(4) "Doug"
    }

您可能还会注意到,在 PHP 7 中,会发出一个通知,而在 PHP 8 中,同样的情况会产生一个警告。在这种情况下,很难对潜在的代码错误进行迁移前检查,因为如果定义了神奇的 __sleep() 方法,就需要确定列表中是否包含了一个不存在的属性。

现在让我们看看对应的方法,__wakeup()

了解 __wakeup()

魔法方法 __wakeup() 的作用主要是对未序列化对象执行额外的初始化。例如,恢复数据库连接或恢复文件句柄。下面是一个使用 __wakeup() 魔法重新打开文件句柄的简单示例:

  1. 首先,我们定义了一个在实例化时打开文件句柄的类。我们还定义了一个返回文件内容的方法:

    // /repo/ch05/php8_serialization_wakeup.php
    class Gettysburg {
        public $fn = __DIR__ . '/gettysburg.txt';
        public $obj = NULL;
        public function __construct() {
            $this->obj = new SplFileObject($this->fn, 'r');
        }
        public function getText() {
            $this->obj->rewind();
            return $this->obj->fpassthru();
        }
    }
  2. 要使用该类,请创建一个实例,然后运行 getText()(假设 $this→fn 引用的文件存在!)。

    $old = new Gettysburg();
    echo $old->getText();
  3. 输出(未显示)是 Gettysburg Address。

  4. 如果我们现在尝试序列化这个对象,就会出现问题。下面是一个序列化对象的代码示例:

    $str = serialize($old);
  5. 此时,运行目前的代码,输出结果如下:

    PHP Fatal error: Uncaught Exception: Serialization
    of 'SplFileObject' is not allowed in /repo/ch05/php8_
    serialization_wakeup.php:19
  6. 为了解决这个问题,我们返回类并添加了一个 __sleep() 方法,防止 SplFileObject 实例被序列化:

    public function __sleep() {
        return ['fn'];
    }
  7. 如果我们重新运行代码序列化对象,则一切正常。下面是取消序列化并调用 getText() 的代码:

    $str = serialize($old);
    $new = unserialize($str);
    echo $new->getText();
  8. 但是,如果我们尝试取消序列化对象,就会出现另一个错误:

    PHP Fatal error: Uncaught Error: Call to a member
    function rewind() on null in /repo/ch05/php8_
    serialization_wakeup.php:13

    当然,问题在于文件句柄在序列化过程中丢失了。在对象解序列化时,没有调用 __construct() 方法。

  9. 这正是 __wakeup() 神奇方法存在的原因。为了解决这个错误,我们定义了一个调用 __construct() 方法的 __wakeup() 方法:

    public function __wakeup() {
        self::__construct();
    }
  10. 如果我们重新运行代码,现在会看到两次 Gettysburg Address(未显示)。

现在你已经了解了 PHP 本地序列化的工作原理,也对 __sleep()__wakeup() 魔法方法以及潜在的代码断点有了一些了解。现在让我们来看看为方便对象的自定义序列化而设计的接口。

引入 Serialized 接口

为了方便对象的序列化,PHP 5.1 开始在语言中加入了 Serializable 接口。该接口的设计理念是提供一种方法来识别具有序列化能力的对象。此外,该接口指定的方法旨在为对象序列化提供一定程度的控制。

只要类实现了这个接口,开发人员就可以放心,因为它定义了两个方法:serialize()unserialize()。下面是接口定义:

interface Serializable {
    public serialize () : string|null
    public unserialize (string $serialized) : void
}

任何实现该接口的类都会在本地序列化或非序列化过程中自动调用其自定义 serialize()unserialize() 方法。为说明这一技术,请看下面的示例:

  1. 首先,我们定义一个实现 Serializable 接口的类。该类定义了三个属性,其中两个是字符串类型,另一个代表日期和时间:

    // /repo/ch05/php8_bc_break_serializable.php
    class A implements Serializable {
        private $a = 'A';
        private $b = 'B';
        private $u = NULL;
  2. 然后,我们定义了一个自定义 serialize() 方法,在序列化对象的属性之前初始化日期和时间。unserialize() 方法会恢复所有属性的值:

        public function serialize() {
            $this->u = new DateTime();
            return serialize(get_object_vars($this));
        }
        public function unserialize($payload) {
            $vars = unserialize($payload);
            foreach ($vars as $key => $val)
                $this->$key = $val;
        }
    }
  3. 然后,我们创建一个实例,并使用 var_dump() 检查其内容:

    $a1 = new A();
    var_dump($a1);
  4. var_dump() 的输出显示 u 属性尚未初始化:

    object(A)#1 (3) {
        ["a":"A":private]=> string(1) "A"
        ["b":"A":private]=> string(1) "B"
        ["u":"A":private]=> NULL
    }
  5. 然后,我们将其序列化,并将其还原到变量 $a2

    $str = serialize($a1);
    $a2 = unserialize($str);
    var_dump($a2);
  6. 从下面的 var_dump() 输出可以看出,对象已完全恢复。此外,我们还知道调用了自定义 serialize() 方法,因为 u 属性被初始化为日期和时间值。下面是输出结果:

    object(A)#3 (3) {
        ["a":"A":private]=> string(1) "A"
        ["b":"A":private]=> string(1) "B"
        ["u":"A":private]=> object(DateTime)#4 (3) {
            ["date"]=> string(26) "2021-02-12 05:35:10.835999"
            ["timezone_type"]=> int(3)
            ["timezone"]=> string(3) "UTC"
        }
    }

现在让我们来看看实现 Serializable 接口的对象的序列化过程中存在的问题。

检查 PHP 可序列化接口问题

先前的序列化方法存在一个总体问题。如果要序列化的类定义了一个 __wakeup() 魔法方法,它不会在非序列化时立即被调用。相反,任何已定义的 __wakeup() 魔法方法都要先排队,然后对整个对象链进行非序列化,最后才执行队列中的方法。这可能导致对象的 unserialize() 方法与队列中的 __wakeup() 方法所看到的内容不匹配。

在处理实现了 Serializable 接口的对象时,这一架构缺陷会导致不一致的行为和模糊的结果。由于嵌套对象序列化时需要创建回引用,许多开发人员认为 Serializable 接口已严重损坏。在发生嵌套序列化调用时,就会产生这种需要。

例如,当一个类定义了一个方法,而该方法又调用了 PHP serialize() 函数时,就可能出现这种嵌套调用。在 PHP 8 之前的 PHP 序列化中,创建反向引用的顺序是预设的,这可能会导致层叠故障。

解决方法是使用两个新的神奇方法,让你完全控制序列化和非序列化的顺序,接下来将介绍这两个方法。

控制 PHP 序列化的新魔法方法

在 PHP 7.4 中首次引入了控制序列化的新方法,并沿用到 PHP 8 中。 为了利用这一新技术,只需实现两个神奇的方法即可: __serialize()__unserialize()。如果实现了这两个方法,PHP 就会把序列化的控制权完全交给 __serialize() 方法。同样,非序列化也完全由 __unserialize() 魔术方法控制。如果定义了 __sleep()__wakeup() 方法,它们将被忽略。

另一个好处是,PHP 8 在以下 SPL 类中提供了对这两个新的神奇方法的完全支持:

  • ArrayObject

  • ArrayIterator

  • SplDoublyLinkedList

  • SplObjectStorage

最佳实践

要获得对序列化的完全控制,请实现新的 __serialize()__unserialize() 魔法方法。你不再需要实现 Serializable 接口,也不需要定义 __sleep()__wakeup() 。有关 Serializable 接口最终终止的更多信息,请参阅本 RFC: https://wiki.php.net/rfc/phase_out_serializable

以下代码示例说明了 PHP 序列化的新用法:

  1. 在示例中,Test 类在实例化时使用随机密钥初始化:

    // /repo/ch05/php8_bc_break_serialization.php
    class Test extends ArrayObject {
        protected $id = 12345;
        public $name = 'Doug';
        private $key = '';
        public function __construct() {
            $this->key = bin2hex(random_bytes(8));
        }
  2. 我们添加了一个 getKey() 方法,用于显示当前键值:

    public function getKey() {
        return $this->key;
    }
  3. 在序列化时,键会从生成的字符串中被过滤掉:

    public function __serialize() {
        return ['id' => $this->id,
            'name' => $this->name];
    }
  4. 取消序列化后,会生成一个新密钥:

        public function __unserialize($data) {
            $this->id = $data['id'];
            $this->name = $data['name'];
            $this->__construct();
        }
    }
  5. 现在我们创建一个实例,并显示密钥:

    $test = new Test();
    echo "\nOld Key: " . $test->getKey() . "\n";

    下面是密钥的外观:

    Old Key: mXq78DhplByDWuPtzk820g==
  6. 我们添加代码来序列化对象并显示字符串:

    $str = serialize($test);
    echo $str . "\n";

    以下是序列化字符串的显示方式:

    O:4:"Test":2:{s:2:"id";i:12345;s:4:"name";s:4:"Doug";}

    请注意,输出结果显示序列化字符串中并没有出现秘密。这一点很重要,因为如果序列化字符串的存储位置被泄露,安全漏洞就可能暴露,从而给攻击者提供入侵系统的途径。

  7. 然后,我们添加代码,对字符串进行反序列化并显示密钥:

    $obj = unserialize($str);
    echo "New Key: " . $obj->getKey() . "\n";

    下面是最后一点输出结果。请注意,新密钥已经生成:

    New Key: kDgU7FGfJn5qlOKcHEbyqQ==

如你所见,使用新的 PHP 序列化功能并不复杂。由于新的神奇方法是按照对象序列化和非序列化的顺序执行的,因此任何时间问题现在都完全在你的控制之中。

PHP 7.4 及以上版本能理解旧版本 PHP 中的序列化字符串,但旧版本 PHP 可能无法正确解序列化由 PHP 7.4 或 8.x 序列化的字符串。

有关详细讨论,请参阅有关自定义序列化的 RFC: https://wiki.php.net/rfc/custom_object_serialization

现在,您已经充分了解了 PHP 序列化以及两个新的魔法方法所提供的改进支持。现在是时候换个角度,看看 PHP 8 是如何扩展变量支持的。