PHP serialize 序列化完全指南

介绍

如果你和我一样,第一次在 PHP 中看到序列化字符串时会觉得很困惑。我当时在做一个 Laravel 项目,想搞清楚将任务推送到队列时到底发生了什么。我发现一些数据被序列化了,但不知道为什么以及怎么工作的。不过在我花时间研究序列化后,发现它其实没那么复杂。

本文会介绍什么是序列化以及工作原理。然后会说明如何使用 PHP 的内置序列化函数,让你能在应用中序列化和反序列化数据。最后会讲如何编写测试来确保序列化代码正常工作。

读完这篇文章,你应该能理解什么是序列化,并且能放心地在项目中使用。

什么是序列化?

序列化就是把变量、对象或数据结构转换成字符串格式的过程。这种字符串格式能表示原始数据,方便存储或传输。反过来,反序列化(在 PHP 中通常叫"unserialization")就是把序列化的数据转换回原来的形式。

序列化很重要,常用于把数据存储到缓存、数据库或文件中。

数据可以序列化成很多格式,比如 JSON、XML,甚至二进制格式(比如 gRPC API 用的 Protocol Buffers)。不过这篇文章主要讲 PHP 的内置序列化函数。

举个例子,如果你用过 Laravel,应该注意到这个框架在把任务推送到队列时会序列化数据。比如下面这个 Laravel 中被推到队列的待处理任务(为了好看,分了行并去掉了一些属性):

json
{
    "uuid": "3d05be68-8cd0-4c3a-8d05-71e86871713a",
    "data": {
        "commandName": "App\\Jobs\\SendOneTimePassword",
        "command": "O:28:\"App\\Jobs\\SendOneTimePassword\"
              :1:{s:15:\"oneTimePassword\";s:6:\"123456\";}"
    }
}

在这个待处理任务的 JSON 例子中,data.command 属性是个序列化字符串,代表一个 App\Jobs\SendOneTimePassword 任务。队列工作器拿到这个任务时,会把序列化字符串反序列化,创建 App\Jobs\SendOneTimePassword 类的实例来处理。如果现在看不懂也没关系,后面会有更多例子来解释。

PHP 中的序列化如何工作?

PHP 中可以用 serializeunserialize 函数来序列化和反序列化数据。

serialize 函数接受要序列化的数据,返回字符串格式。unserialize 函数接受序列化的数据,返回原来的数据结构。

看看怎么在 PHP 中序列化和反序列化不同类型的数据:

序列化字符串

序列化字符串很简单,直接传给 serialize 函数:

php
$serialized = serialize('Hello');

这将返回一个序列化字符串:

s:5:"Hello";

这乍看起来有点奇怪,但一旦你注意到模式,你会发现它并不像看起来那么可怕。我们的序列化数据遵循格式:data_type:string_length:string;

所以在上面序列化字符串的情况下,s 代表字符串并表示反序列化数据时的数据类型,5 是字符串的长度。

然后我们可以将该序列化字符串传递给 unserialize 函数以获取原始字符串:

php
$string = unserialize('s:5:"Hello";');

序列化整数和浮点数

我们也可以在 PHP 中序列化整数和浮点数。以下是序列化整数的方法:

php
serialize(123);

这将返回一个序列化字符串:

i:123;

你可能已经注意到结构与我们之前看到的序列化字符串略有不同。整数使用格式 data_type:data; 进行序列化。注意这里我们没有像字符串那样的大小。在这种情况下,序列化数据的数据类型是 i 表示整数。

同样,我们可以序列化浮点数:

php
serialize(123.45);

这将返回一个序列化字符串:

d:123.45;

这个结构类似于整数序列化,但数据类型是 d 表示双精度浮点数。

序列化布尔值

我们也可以在 PHP 中序列化布尔值。例如,我们可以序列化 true

php
serialize(true);

这将返回一个序列化字符串,其中 b 作为数据类型,1(表示 true)作为值:

b:1;

同样,我们可以序列化 false

php
serialize(false);

这将返回一个序列化字符串,其中 b 作为数据类型,0(表示 false)作为值:

"b:0;"

序列化数组

我们可以这样在 PHP 中序列化数组:

php
serialize([1,2,3]);

这将返回一个序列化字符串:

a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}

现在,你可能已经注意到这比我们已经看过的其他序列化数据要复杂一些。让我们分解一下。

字符串具有 data_type:size:{key_data_type:key_data;value_data_type:value_data;...} 的结构。在这种情况下,data_typea 表示数组,size3,因为数组有 3 个元素。

如果我们然后查看 { } 内的数据,我们可以看到键由 i 表示整数,值也由 i 表示整数。通过将它们分成新行来可视化结构可能会有所帮助:

i:0;i:1;
i:1;i:2;
i:2;i:3;

作为另一个例子,让我们看看序列化的字符串数组可能是什么样子。我们可以序列化以下数组:

php
serialize(['a','b','c']);

这将返回一个序列化字符串:

a:3:{i:0;s:1:"a";i:1;s:1:"b";i:2;s:1:"c";}

正如我们在上面的序列化字符串中看到的,键仍然由 i 表示,而值由 s 表示字符串。为了帮助可视化结构,我们可以将数据分成新行:

i:0;s:1:"a";
i:1;s:1:"b";
i:2;s:1:"c";

同样,我们也可以序列化关联数组:

php
serialize(['a' => 'A', 'b' => 'B', 'c' => 'C']);

这将返回一个序列化字符串:

a:3:{s:1:"a";s:1:"A";s:1:"b";s:1:"B";s:1:"c";s:1:"C";}

正如我们所看到的,结构与我们已经看过的序列化数组非常相似。但是,在这种情况下,键由 s 表示字符串。为了帮助可视化结构,我们可以将数据分成新行:

s:1:"a";s:1:"A";
s:1:"b";s:1:"B";
s:1:"c";s:1:"C";

序列化枚举

我们也可以在 PHP 中序列化枚举。作为一个基本示例,假设我们有以下表示博客文章状态的枚举:

php
namespace App\Enums;

enum PostStatus: string
{
    case Published = 'published';
    case Draft = 'draft';
    case Pending = 'in_review';
}

让我们想象然后创建此枚举的新实例并像这样序列化它:

php
serialize(PostStatus::Published);

这将返回一个序列化字符串:

E:30:"App\Enums\PostStatus:Published";

序列化枚举的结构是 data_type:size:"enum_type:enum_value";。在这种情况下,数据类型由 E 表示,大小是 30,因为类名是 App\Enums\PostStatus,枚举值是 Published

序列化对象

到目前为止,我们已经介绍了序列化如何适用于基本数据类型,如字符串、整数、浮点数、布尔值、数组和枚举。但是对象呢?

默认情况下,除了少数内置 PHP 类之外,所有对象都是可序列化的。

为了解释对象序列化的工作原理,让我们以一个基本的 App\User 类为例,它包含三个公共属性:

php
namespace App;

class User
{
    public function __construct(
        public string $name,
        public string $email,
        public string $apiToken,
    ) { }
}

我们将创建此类的新实例并序列化它:

php
$user = new User(
    name: 'Ash Allen',
    email: 'mail@ashallendesign.co.uk',
    apiToken: 'secret',
);

serialize($user);

这将返回一个序列化字符串:

O:8:"App\User":3:{s:4:"name";s:9:"Ash Allen";s:5:"email";s:
25:"mail@ashallendesign.co.uk";s:8:"apiToken";s:6:"secret";}

让我们分解序列化对象的结构。我们有以下结构:

data_type:class_name_size:class_name:property_count:{
    property_name_type:property_name_size:property_name;
    property_value_type:property_value_size:property_value;
    ...
}

因此,从这个结构中,我们可以看到数据类型是 O 表示对象,类名大小是 8,类名是 App\User,属性计数是 3,因为对象有 3 个属性。然后我们可以看到 { } 内的每个序列化属性。

然后我们可以将此序列化字符串传递给 unserialize 函数以获取原始对象:

php
$serialized = 'O:8:"App\User":3:{s:4:"name";s:9:"Ash Allen'
    .'";s:5:"email";s:25:"mail@ashallendesign.co.uk";s:8:'
    .'"apiToken";s:6:"secret";}';

$user = unserialize($serialized);

这将返回 App\User 类的实例,每个属性都像原始对象一样设置。

属性可见性

序列化对象时,属性的可见性很重要,因为它会影响返回的字符串。

让我们更新我们的 App\User 类以具有公共、受保护和私有属性:

php
namespace App;

class User
{
    public function __construct(
        public string $name,
        protected string $email,
        private string $apiToken,
    ) { }
}

然后我们将创建此类的新实例并序列化它:

php
$user = new User(
    name: 'Ash Allen',
    email: 'mail@ashallendesign.co.uk',
    apiToken: 'secret',
);

serialize($user);

这将返回一个序列化字符串:

O:8:"App\User":3:{s:4:"name";s:9:"Ash Allen";s:8:
"\0*\0email";s:25:"mail@ashallendesign.co.uk";s:18:
"\0App\User\0apiToken";s:6:"secret";}

字符串格式与我们之前的序列化对象非常相似。但是,emailapiToken 属性的名称略有不同。

当 PHP 序列化对象时,它将为属性名添加前缀以指示属性的可见性。受保护的属性由 * 前缀指示,私有属性由类名前缀指示。所以我们可以看到,我们有 \0*\0email\0App\User\0apiToken\0 表示空字节),而不是 emailapiToken

让我们将序列化字符串分成新行以帮助可视化结构:

s:4:"name";s:9:"Ash Allen";
s:8:"\0*\0email";s:25:mail@ashallendesign.co.uk"
s:18:"\0App\User\0apiToken";

这意味着通过查看序列化对象,我们可以确定属性的可见性。

序列化包含其他对象的对象

有时你可能需要序列化包含另一个对象的对象。我们将快速看一下包含另一个对象的序列化对象可能是什么样子。

假设我们有一个简单的 App\ValueObjects\Address 类:

php
namespace App\ValueObjects;

class Address
{
    public function __construct(
        public int $number,
        public string $postalCode,
    ) { }
}

然后我们假设我们的 App\User 类有一个 App\ValueObjects\Address 对象作为属性。我们可能想要创建一个新对象并序列化它:

php
$user = new User(
    name: 'Ash Allen',
    email: 'mail@ashallendesign.co.uk',
    apiToken: 'secret',
    address: new Address('18', 'SW1A 2AA'),
);

serialize($user);

这将导致如下序列化字符串:

O:8:"App\User":4:{s:4:"name";s:9:"Ash Allen";s:5:"email";
s:25:"mail@ashallendesign.co.uk";s:8:"apiToken";s:6:
"secret";s:7:"address";O:24:"App\ValueObjects\Address":2:
{s:6:"number";i:18;s:10:"postalCode";s:8:"SW1A 2AA";}}

让我们将此对象的内容分解到单独的行上:

s:4:"name";s:9:"Ash Allen";
s:5:"email";s:25:"mail@ashallendesign.co.uk";
s:8:"apiToken";s:6:"secret";
s:7:"address";O:24:"App\ValueObjects\Address":2:{
    s:6:"number";i:18;
    s:10:"postalCode";s:8:"SW1A 2AA";
}

正如我们在这里看到的,App\ValueObjects\Address 对象只是作为 App\User 对象的属性序列化。

反序列化时的错误处理

处理尝试反序列化无效数据时可能发生的任何错误很重要。根据你尝试反序列化的无效数据,PHP 8.3 将发出 E_WARNING 或抛出 \Exception\Error

例如,让我们看看这个无效的序列化字符串,它对字符串 hello 的长度为 10 而不是预期的 5:

php
unserialize('s:10:"hello";');

如果我们在 PHP 8.3 中运行此代码,将发出 E_WARNING,错误消息如下:

Warning: unserialize(): Error at offset 2 of 13 bytes in
/www/serialization.php on line 3

为了处理警告以便我们可以在代码中捕获和处理它们,我们可以使用 set_error_handler 函数设置自定义错误处理程序。这将允许我们捕获警告并将它们作为异常抛出。

为此,我们首先创建一个新的 App\Services\Serializer 类,如下所示:

php
declare(strict_types=1);

namespace App\Services;

final readonly class Serializer
{
    public function unserialize(string $serialized): mixed
    {
        try {
            set_error_handler(static function (
                $severity, $message, $file, $line
            ) {
                throw new \ErrorException(
                    $message, 0, $severity, $file, $line
                );
            });

            $result = unserialize($serialized);
        } finally {
            restore_error_handler();
        }

        return $result;
    }
}

在此类中,我们添加了一个接受序列化字符串的 unserialize 方法。然后我们覆盖错误处理程序,以便我们可以捕获任何警告并将它们作为异常抛出。然后我们尝试反序列化数据。如果发出警告,它将作为异常抛出。然后我们在 finally 块内将错误处理程序恢复到其原始状态,无论反序列化是否成功都会运行。假设成功,我们然后返回反序列化的数据。

然后我们可以使用此类来反序列化数据:

php
use App\Services\Serializer;

$result = (new Serializer())->unserialize(
    serialized: 's:10:"hello";'
);

运行上述代码将导致抛出 \ErrorException,消息如下:

unserialize(): Error at offset 2 of 13 bytes

或者我们可以运行以下代码:

php
use App\Services\Serializer;

$result = (new Serializer())->unserialize(
    serialized: 's:5:"hello";'
);

这将导致返回字符串 hello

目前有一个 RFC(https://wiki.php.net/rfc/improve_unserialize_error_handling)部分实现,旨在改进反序列化数据时的错误处理。RFC 包含一个提案,从 PHP 9.0 开始,改变 unserialize 的行为,使其抛出 \UnserializationFailedException 而不是发出 E_WARNING。因此,如果实现了这一点,我们不需要覆盖错误处理程序来捕获警告并将它们作为异常抛出,就像我们上面所做的那样。

在 PHP 中定义序列化逻辑

正如我们在上面已经看到的,PHP 默认提供序列化和反序列化对象的能力。

但是,有时你可能想要为对象定义自定义序列化逻辑。这可能有几个原因,例如在序列化之前加密敏感数据,或者你可能需要在反序列化对象时执行一些额外的逻辑。

值得庆幸的是,PHP 提供了两个魔术方法,你可以使用它们来定义如何序列化和反序列化对象:__serialize__unserialize

为了了解这可能如何工作,让我们看一个例子。坚持我们之前的 App\User 类,假设我们想要在序列化对象之前加密 apiToken 属性,并在反序列化对象时解密它。这可能是因为我们将序列化数据存储在缓存或队列中,所以我们想要确保数据在受到威胁时是安全的。

为了本文的目的,我们将假设我们有两个可以调用来加密和解密数据的函数:encryptdecrypt。我们现在不需要担心这些函数的实现,我们只是假设它们存在。如果你们中的任何人是 Laravel 开发者,你们可能会认识这些函数,因为它们都随 Laravel 一起提供。

让我们更新我们的 App\User 类以包含 __serialize__unserialize 方法,然后讨论正在做什么:

php
declare(strict_types=1);

namespace App;

class User
{
    public function __construct(
        public string $name,
        public string $email,
        public string $apiToken,
    ) { }

    public function __serialize(): array
    {
        return [
            'name' => $this->name,
            'email' => $this->email,
            'apiToken' => encrypt($this->apiToken),
        ];
    }

    public function __unserialize(array $data): void
    {
        $this->name = $data['name'];
        $this->email = $data['email'];
        $this->apiToken = decrypt($data['apiToken']);
    }
}

__serialize 方法中,我们返回要序列化的属性数组。我们在返回之前加密 apiToken 属性。这意味着当我们对对象调用 serialize 时,apiToken 属性将被加密。

让我们创建 App\User 类的新实例并序列化它:

php
$user = new User(
    name: 'Ash Allen',
    email: 'mail@ashallendesign.co.uk',
    apiToken: 'secret',
);

$serialized = serialize($user);

序列化字符串可能如下所示(为简洁起见缩短了加密字符串):

O:8:"App\User":3:{s:4:"name";s:9:"Ash Allen";s:5:"email";s:
25:"mail@ashallendesign.co.uk";s:8:"apiToken";s:200:
"eyJpdiI6Ikx0N3BDQwYzcwMzE1NGQy...sdfsfsfdssInRhZyI6IiJ9";}

正如我们所看到的,apiToken 属性现在已加密,没有加密密钥就无法解密数据。

现在,如果我们想从序列化字符串创建 App\User 类的实例,我们可以对字符串调用 unserialize,将调用 __unserialize 方法。此 __unserialize 方法接受序列化数据的数组,因此我们可以分配每个属性并解密 apiToken 属性。

测试你的序列化代码

就像应用程序的任何其他部分一样,如果你正在自定义对象的序列化和反序列化方式,你可能会想要为序列化逻辑编写测试。这是确保你的序列化代码按预期工作并且你可以捕获任何错误的好方法。

例如,让我们看看我们刚刚看过的前面的例子。如果我们意外地从 apiToken 属性的 __unserialize() 函数中删除了 decrypt() 函数调用,会发生什么?这将导致我们拥有一个具有加密令牌而不是我们期望的原始未加密值的对象。

你的测试可以根据你的喜好进行深入和严格。让我们看看我们可以编写的一个简单测试,以确保 App\User 对象可以被序列化然后反序列化:

php
declare(strict_types=1);

namespace Tests\Feature\User;

use App\User;
use Illuminate\Foundation\Testing\TestCase;
use PHPUnit\Framework\Attributes\Test;

final class SerializeTest extends TestCase
{
    // ...

    #[Test]
    public function serialize_and_unserialize_works(): void
    {
        $user = new User(
            name: 'Ash Allen',
            email: 'mail@ashallendesign.co.uk',
            apiToken: '1234567890',
        );

        $serialized = serialize($user);

        $unserializedUser = unserialize($serialized);

        // 断言我们刚刚构建的用户与我们最初序列化的用户相同。
        $this->assertEquals($user, $unserializedUser);
    }
}

在上面的测试中,我们创建了 App\User 的实例,序列化它,然后反序列化它。然后我们断言我们刚刚构建的用户与我们最初序列化的用户相同。这是一个简单的测试,可以让我们确信我们的序列化代码按预期工作。

但是,如果我们删除 encryptdecrypt 函数调用,测试仍然会通过,即使我们可能没有像预期的那样加密和解密 apiToken 属性。

如果你更喜欢对测试更严格一些,你可以编写两个更多的测试来确保 apiToken 属性按预期被加密和解密。

我们将编写测试,就像它们是 Laravel 应用程序的一部分,并且 encryptdecrypt 函数只是使用 encrypter 键从服务容器解析 Illuminate\Contracts\Encryption\Encrypter 接口的实例。但是如果你不熟悉 Laravel,这并不重要。你只需要知道我们正在模拟 encryptdecrypt 函数调用的底层类,这样我们就可以在测试中硬编码预期的加密值并对其进行断言。

我们要编写的第一个测试是确保在序列化用户对象时 apiToken 属性被加密:

php
declare(strict_types=1);

namespace Tests\Feature\User;

use App\User;
use Illuminate\Foundation\Testing\TestCase;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\Test;

final class SerializeTest extends TestCase
{
    // ...

    #[Test]
    public function user_object_can_be_serialized(): void
    {
        // 模拟加密器,这样我们就可以严格测试用户对象的序列化。
        $this->mock('encrypter', function ($mock): void {
            $mock->shouldReceive('encrypt')
                ->once()
                ->withArgs(['1234567890', true])
                ->andReturn('encrypted');
        });

        $user = new User(
            name: 'Ash Allen',
            email: 'mail@ashallendesign.co.uk',
            apiToken: '1234567890',
        );

        $serialized = serialize($user);

        $expectedString = 'O:8:"App\User":3:{s:4:"name";s:'.
            '9:"Ash Allen";s:5:"email";s:25:"'.
            'mail@ashallendesign.co.uk";s:8:"apiToken";s:'.
            '9:"encrypted";}';

        // 断言序列化字符串正是我们期望的。
        $this->assertSame(
            expected: $expectedString,
            actual: $serialized,
        );
    }
}

在测试中,我们首先模拟加密器,这样我们就可以在测试中硬编码预期的加密值。在这种情况下,我们期望 apiToken 属性的值为 1234567890,当加密它时,我们将返回字符串 encrypted。然后我们创建 App\User 类的新实例并序列化它。然后我们断言序列化字符串正是我们期望的。

然后我们可以编写另一个测试来确保在反序列化用户对象时 apiToken 属性被解密:

php
declare(strict_types=1);

namespace Tests\Feature\User;

use App\User;
use Illuminate\Foundation\Testing\TestCase;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\Test;

final class SerializeTest extends TestCase
{
    // ...

    #[Test]
    public function user_string_can_be_unserialized(): void
    {
        // 模拟加密器,这样我们就可以严格测试用户对象的序列化。
        $this->mock('encrypter', function ($mock): void {
            $mock->shouldReceive('decrypt')
                ->once()
                ->withArgs(['encrypted', true])
                ->andReturn('1234567890');
        });

        $serialized = 'O:8:"App\User":3:{s:4:"name";s:9:"'.
            'Ash Allen";s:5:"email";s:25:"'.
            'mail@ashallendesign.co.uk";s:8:"apiToken";s:'.
            '9:"encrypted";}';

        $user = unserialize($serialized);

        $this->assertInstanceOf(User::class, $user);
        $this->assertSame('Ash Allen', $user->name);
        $this->assertSame(
            'mail@ashallendesign.co.uk',
            $user->email
        );
        $this->assertSame('1234567890', $user->apiToken);
    }
}

在上面的测试中,我们模拟了加密器,这样我们就可以在测试中硬编码预期的解密值。然后我们反序列化序列化字符串并断言用户对象是 App\User 的实例,并且属性符合我们的预期。

结论

在本文中,我们了解了什么是序列化以及它是如何工作的。我们探索了如何使用 PHP 的内置序列化函数在 PHP 应用程序中序列化和反序列化数据。我们还讨论了如何编写测试来确保你的序列化代码按预期工作。

希望你现在对什么是序列化有很好的理解,并且有足够的信心在自己的应用程序中使用它。如果你有任何问题或意见,请随时在下面的评论中留言。

CatchAdmin
后端开发工程师,前端入门选手,略知相关服务器知识,偏爱❤️ Laravel & Vue
本作品采用《CC 协议》,转载必须注明作者和本文链接