前言
接口层的数据转换(data transformation)通常表现成两种症状。第一种是"入参永远对不上":接口文档写page_size,前端传pageSize;数据库里status是tinyint,请求里传的是字符串"1",if ($status === 1)判断永远不成立;布尔字段传"false"被(bool)转成true,用户"取消订阅"的操作反而把订阅打开了。
第二种是"出参全靠手写数组":每个控制器里都有一段$data['created_at'] = date('Y-m-d H:i:s', $row['created_at']),几十个接口复制几十遍,某天要给手机号加脱敏,改漏了三处,于是三个接口泄露了全量手机号。
这两个问题的根因是同一个:转换规则散落在业务代码里,没有成为可复用的契约。本文把数据转换拆成"入参水合(hydrate)"和"出参呈现(present)"两条线,用 PHP 8.3 的属性和类型系统把规则集中到声明上,并给出一份可以直接保存运行的完整代码。
一、先把四个方向的转换分清楚
| 方向 | 名称 | 典型动作 | 出错后果 |
|---|
| 外部 → 内部(单条) | 入参水合 | 字段名映射、类型转换、必填校验、默认值 | 类型错误、逻辑判断失效 |
| 外部 → 内部(集合) | 批量水合 | 逐条水合 + 数量上限 | 大数组打满内存 |
| 内部 → 外部(单条) | 出参呈现 | 白名单裁剪、脱敏、单位换算、时间格式化 | 数据泄露、前端解析报错 |
| 内部 → 外部(集合) | 批量呈现 | 逐条呈现 + 分页结构包装 | 序列化 N+1 |
分清方向很重要,因为两侧的安全要求完全不同:入参转换要"宽松接收、严格校验",出参转换要"白名单输出、绝不透传"。把两边写成同一个双向映射函数,是很多数据泄露事故的起点。
二、入参:用属性把字段契约写在构造函数上
思路是把"外部字段名 → 内部属性"的映射关系,用属性(Attribute,PHP 8.0 引入)声明在 DTO(Data Transfer Object)的构造函数参数上,再写一个通用水合器按声明执行。
<?php
// api_transform.php —— 需 PHP 8.3+
declare(strict_types=1);
#[\Attribute(\Attribute::TARGET_PARAMETER)]
final class Map
{
public function __construct(
public readonly string $source,
public readonly string $cast = 'raw',
public readonly bool $required = false,
public readonly mixed $default = null,
) {}
}
final class ValidationException extends \RuntimeException {}
final class Hydrator
{
/** @var array<class-string, list<array{name: string, map: Map}>> */
private array $plan = [];
/**
* @param class-string $class
* @param array<string, mixed> $input
*/
public function hydrate(string $class, array $input): object
{
$args = [];
foreach ($this->plan($class) as ['map' => $map]) {
// 用 array_key_exists 区分"字段缺失"和"字段显式为 null"
if (!array_key_exists($map->source, $input)) {
if ($map->required) {
throw new ValidationException("缺少必填字段: {$map->source}");
}
$args[] = $map->default;
continue;
}
$args[] = $this->cast($input[$map->source], $map);
}
/** @var object $obj */
$obj = (new \ReflectionClass($class))->newInstanceArgs($args);
return $obj;
}
private function cast(mixed $value, Map $map): mixed
{
if ($value === null || $value === '') {
return $map->default;
}
return match ($map->cast) {
'int' => (int) $value,
'float' => (float) $value,
// 注意:不能用 (bool),(bool) "false" 是 true
'bool' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'string' => (string) $value,
'raw' => $value,
default => throw new ValidationException("未知转换类型: {$map->cast}"),
};
}
/** @return list<array{name: string, map: Map}> */
private function plan(string $class): array
{
if (isset($this->plan[$class])) {
return $this->plan[$class];
}
$ctor = (new \ReflectionClass($class))->getConstructor()
?? throw new ValidationException("$class 必须有构造函数");
$plan = [];
foreach ($ctor->getParameters() as $param) {
$attrs = $param->getAttributes(Map::class);
if ($attrs === []) {
// 强制全量声明,否则参数顺序会错位
throw new ValidationException(
"$class::\${$param->getName()}() 缺少 #[Map] 声明"
);
}
$plan[] = ['name' => $param->getName(), 'map' => $attrs[0]->newInstance()];
}
return $this->plan[$class] = $plan;
}
}对应的 DTO 只要声明一次,规则就跟类走。注意这里用了构造函数属性提升(PHP 8.0)和readonly属性(PHP 8.1):
<?php
// 需 PHP 8.1+,属性参数写法需 PHP 8.3 的代码风格
final class OrderQuery
{
public function __construct(
#[\Map('order_id', 'int', required: true)]
public readonly int $orderId,
#[\Map('status', 'string', default: 'all')]
public readonly string $status,
#[\Map('page', 'int', default: 1)]
public readonly int $page,
#[\Map('page_size', 'int', default: 20)]
public readonly int $pageSize,
#[\Map('tags', 'raw', default: [])]
public readonly array $tags = [],
) {}
}readonly属性是 PHP 8.1 引入的,readonly类(class 级别整体只读)是 PHP 8.2 引入的,别把两者混为一谈:前者修饰属性,后者修饰类。
三、出参:白名单式呈现,而不是json_encode($row)
出参转换的第一原则是白名单:只输出 schema 里显式声明过的字段。数据库加了一个internal_memo字段,白名单方案不会让它自动泄露。
<?php
// 需 PHP 8.1+
interface MaskRule
{
public function apply(string $value): string;
}
final class Masker implements MaskRule
{
// 类型化类常量(typed class constants)是 PHP 8.3 的新特性
public const string PHONE = 'phone';
public const string EMAIL = 'email';
#[\Override] // PHP 8.3 引入,拼错方法名时直接报错
public function apply(string $value): string
{
if ($value === '') {
return '';
}
return match ($this->detect($value)) {
self::PHONE => substr($value, 0, 3) . '****' . substr($value, -4),
self::EMAIL => (static function (string $v): string {
[$name, $domain] = explode('@', $v, 2) + ['', ''];
return substr($name, 0, 1) . '***@' . $domain;
})($value),
default => str_repeat('*', min(8, strlen($value))),
};
}
private function detect(string $value): string
{
return str_contains($value, '@') ? self::EMAIL : self::PHONE;
}
}
final class Presenter
{
/**
* @param array<string, array{as: string, cast?: string, mask?: MaskRule}> $schema
*/
public function __construct(private readonly array $schema) {}
/** @param array<string, mixed> $row */
public function present(array $row): array
{
$out = [];
foreach ($this->schema as $field => $rule) {
$value = $row[$field] ?? null;
$value = match ($rule['cast'] ?? null) {
'int' => $value === null ? null : (int) $value,
'float' => $value === null ? null : (float) $value,
// 数据库存分,接口输出元,避免浮点金额误差
'cents' => $value === null
? null
: number_format(((int) $value) / 100, 2, '.', ''),
default => $value,
};
if ($value !== null && isset($rule['mask'])) {
$value = $rule['mask']->apply((string) $value);
}
$out[$rule['as']] = $value;
}
return $out;
}
}四、PHP 8.3 在转换场景里真正新增了什么
很多文章把"接口数据转换"说成 8.3 的新能力,这里要澄清:数据转换本身不是某个版本的功能,它靠的是类型系统。PHP 8.3 在这个场景里具体新增了三样东西:
| PHP 8.3 新特性 | 对转换代码的作用 |
|---|
类型化类常量const string X = '...' | 掩码类型、字段类型这类"常量字典"不用担心写错类型 |
#[\Override]属性 | 转换器实现接口方法时,改名/拼错会立刻报错,而不是静默失效 |
json_validate() | 解析前先验合法性,不用再靠json_decode() === null猜 |
动态类常量获取Foo::{$name} | 可按字段名动态取转换配置常量 |
json_validate()是 PHP 8.3 引入的,接收 JSON 字符串、可选的深度与标志位,返回布尔值,且不产生解码后的数据,所以比"解码再判断"更省内存:
<?php
// 需 PHP 8.3+
$body = '{"order_id":"10086","page":"2"}';
if (!json_validate($body)) {
throw new ValidationException('请求体不是合法 JSON');
}
// 确认合法后再解码,并让解码失败直接抛异常
$input = json_decode($body, true, 512, JSON_THROW_ON_ERROR);代码实战:跑起来看结果
把上面的类放进一个文件,末尾接上这段驱动代码,就能直接php api_transform.php看到输出。
<?php
// 需 PHP 8.3+
$hydrator = new Hydrator();
$masker = new Masker();
$presenter = new Presenter([
'id' => ['as' => 'orderId', 'cast' => 'int'],
'status' => ['as' => 'status'],
'total_cents' => ['as' => 'totalAmount', 'cast' => 'cents'],
'buyer_phone' => ['as' => 'buyerPhone', 'mask' => $masker],
'buyer_email' => ['as' => 'buyerEmail', 'mask' => $masker],
'created_at' => ['as' => 'createdAt'],
]);
$body = '{"order_id":"10086","status":"paid","page":"2","tags":["vip"]}';
if (!json_validate($body)) {
throw new ValidationException('请求体不是合法 JSON');
}
$query = $hydrator->hydrate(OrderQuery::class, json_decode($body, true, 512, JSON_THROW_ON_ERROR));
printf("orderId=%d status=%s page=%d pageSize=%d\n",
$query->orderId, $query->status, $query->page, $query->pageSize);
$rows = [
[
'id' => '10086', 'status' => 'paid', 'total_cents' => 2599,
'buyer_phone' => '13800001111', 'buyer_email' => 'alice@example.com',
'created_at' => '2026-03-01T10:00:00+08:00',
],
];
$payload = array_map([$presenter, 'present'], $rows);
echo json_encode(
['code' => 0, 'data' => ['list' => $payload, 'page' => $query->page]],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
), PHP_EOL;预期输出形如:
orderId=10086 status=paid page=2 pageSize=20
{
"code": 0,
"data": {
"list": [
{
"orderId": 10086,
"status": "paid",
"totalAmount": "25.99",
"buyerPhone": "138****1111",
"buyerEmail": "a***@example.com",
"createdAt": "2026-03-01T10:00:00+08:00"
}
],
"page": 2
}
}常见坑点
- ❌ 用
(int) $value直接转换,null被转成0,无法区分"没传"和"传了 0"
✅ 先array_key_exists($field, $input)判断存在性,缺失走$map->default,显式null单独处理
- ❌ 布尔字段用
(bool) $value
✅(bool) "false"的结果是true,必须用filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE),失败返回null时再按业务决定报错还是取默认
- ❌
$data = json_decode($raw, true);之后直接遍历
✅ 解码失败时它返回null,foreach (null)会报 Warning 而业务继续跑。加JSON_THROW_ON_ERROR,或先用json_validate()校验
- ❌ 出参直接用
json_encode($row)把数据库整行吐出去
✅ 用白名单 schema 逐字段呈现;SELECT *加上新字段就等于自动对外暴露,密码哈希、内部备注都走过这条路
- ❌ 金额用浮点数转换和计算:
(float) $row['price']
✅ 数据库存最小单位整数(分),转换时只在输出层除一次,并用number_format固定两位小数,避免0.1 + 0.2这类精度问题
- ❌ 订单号走
(int)转换后再交给前端
✅ 雪花算法(Snowflake)类 ID 超过 2 的 53 次方时,JavaScript 的 Number 会丢精度。用字符串承载 ID,PHP 侧不要先转 int
- ❌ 用反射加
newInstanceWithoutConstructor()给readonly属性赋值
✅readonly属性(PHP 8.1)只能在声明它的类作用域内初始化,绕过反射赋值会抛Error。转换器应当通过构造函数传值
- ❌ 时间字段原样输出数据库的
2024-05-03 10:00:00
✅ 没有时区信息的时间字符串是歧义的,统一输出 ISO 8601 带偏移量的格式(DateTimeInterface::ATOM)
总结
| 环节 | 推荐做法 | 依赖的最低 PHP 版本 |
|---|
| 声明字段契约 | 构造函数参数上的自定义属性 | 8.0 |
| 不可变 DTO | 构造函数属性提升 +readonly属性 | 8.1 |
| 整体只读对象 | readonly类 | 8.2 |
| 通用水合 | 反射读取属性 +newInstanceArgs() | 8.0 |
| 类型常量字典 | 类型化类常量 | 8.3 |
| 接口实现保护 | #[\Override] | 8.3 |
| JSON 预校验 | json_validate() | 8.3 |
| 出参白名单 | schema 驱动 + 强制类型转换 + 掩码 | 8.0 |
数据转换的复杂度不在单个字段的转换写法,而在于"规则放在哪里"。把映射规则从散落的if里搬到 DTO 的属性声明和 Presenter 的 schema 上之后,新增字段只是加一行声明,改脱敏规则只改一处,接口契约从此有了唯一事实来源——这比用哪个语言新特性重要得多。