PHP 语法进阶 18|JSON 与序列化:类型丢失、大整数和 `unserialize` 的风险

json_encode 的四个必带选项

<?php
$data = ['name' => '张三', 'url' => 'https://a.com/x', 'tags' => []];

echo json_encode($data);   // {"name":"\u5f20\u4e09","url":"https:\/\/a.com\/x","tags":[]}

中文变成 \uXXXX、斜杠被转义,虽然合法但可读性差、体积也大。实际项目里基本都这么写:

<?php
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// {"name":"张三","url":"https://a.com/x","tags":[]}

调试时加 JSON_PRETTY_PRINT:

<?php
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

生产环境的 API 响应建议都用 JSON_UNESCAPED_UNICODE。它省下的字节数很可观(一个汉字从 6 字节的转义变成 3 字节),而且日志里能直接看懂。

失败处理和 JSON_THROW_ON_ERROR

<?php
$json = json_encode($payload);
if ($json === false) {
    throw new RuntimeException("编码失败:" . json_last_error_msg());
}

PHP 7.3 起有更干净的方式:

<?php
try {
    $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    throw new RuntimeException("编码失败:" . $e->getMessage(), 0, $e);
}

最常导致编码失败的是非法 UTF-8 编码(比如从 GBK 数据库里取出来的数据),错误是 Malformed UTF-8 characters。遇到这个先查数据源的编码,别硬塞 JSON_INVALID_UTF8_IGNORE 掩盖问题(那会静默丢数据)。

JSON_NUMERIC_CHECK 慎用

<?php
$order = ['code' => '0755', 'qty' => '10'];
echo json_encode($order, JSON_NUMERIC_CHECK);
// {"code":755,"qty":10}

它会把看起来像数字的字符串转成数字,"0755" 变成 755,前导零和原有类型全丢了。手机号 "13800138000" 也会变成数字,某些前端会因此丢精度。不要用这个选项,需要数字就自己转。

json_decode 的几个参数

<?php
$arr = json_decode($json, true);            // 关联数组
$obj = json_decode($json);                  // stdClass 对象
$arr = json_decode($json, true, 512, JSON_THROW_ON_ERROR);  // 失败抛异常

四个参数依次是:$json、$associative、$depth、$flags。

$depth 默认 512,超限会返回 null 且 json_last_error() 是 JSON_ERROR_DEPTH。深层嵌套的树形数据容易撞到。

大整数会被转成浮点

<?php
$json = '{"id": 9007199254740993}';
var_dump(json_decode($json, true)['id']);        // float(9.0071992547410E+15)

var_dump(json_decode($json, true, 512, JSON_BIGINT_AS_STRING)['id']);
// string(16) "9007199254740993"   —— 保住了

这是最容易被忽视的数据损坏。雪花 ID、订单号、支付流水号动辄 18~19 位,超过 PHP 浮点的精确表示范围(2^53 ≈ 9.007e15),解码时就悄悄丢了精度。

两条应对:

  1. 后端出 JSON 时就把大 ID 当字符串输出((string)$id)。
  2. 解码时加 JSON_BIGINT_AS_STRING。

前端同理:JSON.parse 也会丢精度,接口里大 ID 一律字符串。

空对象和空数组会混淆

<?php
var_dump(json_decode('{}', true));    // array(0) {}       —— 空对象变成空数组!
var_dump(json_decode('[]', true));    // array(0) {}

因为 $associative = true 会把对象也转成数组,{} 和 [] 就分不出来了。如果业务里要区分,用对象模式解码:

<?php
var_dump(json_decode('{}'));          // object(stdClass)   —— 能区分

反过来编码也有类似问题:空数组 [] 编码成 [],但如果你想要 {}:

<?php
echo json_encode([]);                          // []
echo json_encode(new stdClass());              // {}
echo json_encode([], JSON_FORCE_OBJECT);       // {}

接口要求"字段必须是对象"时,这个细节会让前端报错。

编码对象时会发生什么

<?php
class User
{
    public string $name = "张三";
    private int $age = 28;
    protected string $dept = "技术";
}

echo json_encode(new User());
// {"name":"张三"}

json_encode 只输出 public 属性。private/protected 全部丢失。

要控制输出,实现 JsonSerializable:

<?php
class User implements JsonSerializable
{
    public function __construct(
        private int $id,
        private string $name,
        private string $password,
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'id'   => (string)$this->id,     // 大 ID 转字符串
            'name' => $this->name,
            // password 不输出
        ];
    }
}

echo json_encode(new User(9007199254740993, "张三", "secret"), JSON_UNESCAPED_UNICODE);
// {"id":"9007199254740993","name":"张三"}

JsonSerializable 的价值不只是"能输出私有属性",更重要的是显式控制暴露哪些字段——防止"新增一个 password 字段,一上线就被 API 泄露出去"。

循环引用会直接报错

<?php
class Node { public ?Node $next = null; }

$a = new Node();
$b = new Node();
$a->next = $b;
$b->next = $a;

json_encode($a);   // ✗ 无限递归,返回 false,错误 JSON_ERROR_RECURSION

检测办法:

<?php
$json = json_encode($a);
if ($json === false && json_last_error() === JSON_ERROR_RECURSION) {
    throw new RuntimeException("数据结构存在循环引用");
}

实体关联(订单 → 用户 → 订单列表)很容易构成环。用 DTO 拆出"要输出的字段"是最实际的解法。

serialize / unserialize:能存对象,但危险

<?php
$s = serialize(['a' => 1, 'b' => [2, 3]]);
echo $s;                          // a:1:{s:1:"a";i:1;s:1:"b";a:2:{i:0;i:2;i:1;i:3;}}

var_dump(unserialize($s));

它能保留类型(数组、对象、整数、浮点都对),比 JSON 更"忠于 PHP"。但有两个致命问题:

一、不能跨语言。其他语言读不了。

二、unserialize 用户可控数据 = 远程代码执行。

<?php
// ✗ 极度危险:$_COOKIE 完全可控
$prefs = unserialize($_COOKIE['prefs']);

攻击者可以构造一个序列化的对象,触发目标类的 __wakeup / __destruct,进而执行任意代码(历史上 ThinkPHP、Laravel 都出过这类反序列化漏洞)。

如果确实需要反序列化不可信数据,必须限制允许的类:

<?php
$data = unserialize($input, ['allowed_classes' => false]);       // 全部禁止
$data = unserialize($input, ['allowed_classes' => [Prefs::class]]); // 只允许指定类

allowed_classes => false 时,对象会变成 __PHP_Incomplete_Class,不会被实例化成真实类,危险的回调就不会触发。

更稳妥的做法:不要用 unserialize 处理任何外部数据。 存缓存可以(Redis 里的数据是你自己写的),但一定要设 allowed_classes 收紧范围。

var_export:生成可执行的 PHP

<?php
$config = ['host' => '127.0.0.1', 'port' => 3306];
echo var_export($config, true);
// array (
//   'host' => '127.0.0.1',
//   'port' => 3306,
// )

var_export($v, true) 返回 PHP 代码字符串,可以写进 .php 文件被 require 回来。生成"缓存成 PHP 文件的配置"时非常好用,因为它能被 OPcache 缓存,比每次读 JSON 再解析快得多。

<?php
file_put_contents(
    __DIR__ . "/cache/config.php",
    "<?php\nreturn " . var_export($config, true) . ";\n",
    LOCK_EX
);

// 使用
$config = require __DIR__ . "/cache/config.php";

var_export 的局限:不能导出资源、闭包、循环引用(会警告并输出 NULL)。

四种方式对比

跨语言 保留类型 速度 安全风险
json_encode ✓ 部分(int/float 混淆) 快 低
serialize ✗ ✓(含对象) 中 高(反序列化)
var_export ✗ ✓ 慢(生成代码) 低(但生成的是可执行代码)
igbinary(扩展) ✗ ✓ 最快、体积最小 同 serialize

实际选择:

  • API / 前后端交互:JSON,没有商量余地。
  • 缓存 / 队列:JSON 优先;对体积和速度敏感再考虑 igbinary。
  • 配置缓存成文件:var_export。
  • serialize:只在"PHP 内部、数据完全可信、需要保留对象类型"时用,并且设 allowed_classes。

一个完整的 API 输出函数

<?php
function jsonResponse(mixed $data, int $status = 200): never
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    header('X-Content-Type-Options: nosniff');

    echo json_encode(
        $data,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
    );
    exit;
}

Content-Type 里的 charset=utf-8 不能省,X-Content-Type-Options: nosniff 防止浏览器瞎猜类型。

坑清单

  1. 中文不设 JSON_UNESCAPED_UNICODE,体积翻倍且日志难读。
  2. json_encode 失败返回 false,不检查就继续跑。
  3. 非法 UTF-8 导致编码失败,用 JSON_INVALID_UTF8_IGNORE 掩盖而不是修数据源。
  4. JSON_NUMERIC_CHECK 把 "0755" 变成 755。
  5. 19 位 ID 被 json_decode 转成浮点,精度丢失。
  6. {} 和 [] 用关联模式解码后分不清。
  7. 空数组编码成 [],但接口要求是对象。
  8. json_encode 对象只输出 public 属性,私有字段静默消失,或反过来泄露了密码。
  9. 实体互相引用,json_encode 报循环引用。
  10. unserialize($_COOKIE[...]),远程代码执行。
  11. unserialize 不设 allowed_classes。