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),解码时就悄悄丢了精度。
两条应对:
- 后端出 JSON 时就把大 ID 当字符串输出(
(string)$id)。 - 解码时加
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 防止浏览器瞎猜类型。
坑清单
- 中文不设
JSON_UNESCAPED_UNICODE,体积翻倍且日志难读。 json_encode失败返回false,不检查就继续跑。- 非法 UTF-8 导致编码失败,用
JSON_INVALID_UTF8_IGNORE掩盖而不是修数据源。 JSON_NUMERIC_CHECK把"0755"变成755。- 19 位 ID 被
json_decode转成浮点,精度丢失。 {}和[]用关联模式解码后分不清。- 空数组编码成
[],但接口要求是对象。 json_encode对象只输出 public 属性,私有字段静默消失,或反过来泄露了密码。- 实体互相引用,
json_encode报循环引用。 unserialize($_COOKIE[...]),远程代码执行。unserialize不设allowed_classes。