尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP 8 实战:用 DTO 构建清晰的数据边界,告别数组传参

PHP 8 实战:用 DTO 构建清晰的数据边界,告别数组传参 1. 为什么 PHP 项目里要用 DTO先看清楚痛点说到 DTO很多 PHP 开发者的第一反应是“这不就是个类嘛我平时不也在用”。但真要在团队里落地一套成熟的 DTO 方案大部分人又会犹豫到底有没有必要会不会过度设计先说结论如果你的项目还在用数组在接口层、业务层、数据层之间来回传数据那大概率已经踩过下面这些坑。1.1 数组传参的三个隐患第一个隐患是键名拼错毫无提示。我们口头约定的“user_name”“userName”“username”在代码里全靠自觉IDE 不会帮你检查参数到了业务层才发现键不存在排查成本不低。第二个隐患是类型完全失控。数组里放的是字符串还是整数、是 null 还是空字符串从代码上根本看不出来。写 PHP 时间久的人都有过这种体验接口返回的数据明明前端要的是数字结果上层取出个字符串JS 那边比较半天对不上。第三个隐患是边界模糊。一个接口从 Controller 到 Service 到 Model全程数组贯穿。模型字段一变几层代码全部失效而且失败是静默的没有任何编译期或运行期的明确报错。1.2 用 DTO 之后发生了什么DTOData Transfer Object数据传输对象的核心思路很简单在层与层之间传递数据时用一个显式的类来定义数据的形状。举个例子过去你可能是这么写的$data [ uid 123, name 张三, age 28, ];用了 DTO 之后是这么写的$user new UserDTO( uid: 123, name: 张三, age: 28, );看起来只是换了个写法但实际差别很大。第一IDE 可以识别 UserDTO 的属性拼错字段名会直接报错第二构造函数参数有类型声明传错类型马上能发现第三DTO 是一个独立类你可以单独给它写单元测试业务代码不再依赖数组的隐式约定。这篇文章不是讲高大上的架构理论就是实战。我会从零开始写 DTO讲清楚设计思路、PHP 8 特性怎么用、和 Entity/VO 的区别再分享几个我实际踩过的坑。适合从 PHP 7 过渡到 PHP 8 的团队以及那些想规范接口数据、想让业务代码不再被数组绑架的同学。2. 手写一个能用的 PHP DTO从基础到进阶这一章直接上代码。我默认你用的是 PHP 8.0 以上版本最好是 PHP 8.1 或 8.2因为构造器属性提升、readonly、enum 这些特性会让 DTO 写起来非常爽。2.1 基础版构造函数复制型 DTO最朴素的 DTO 长这样class UserDTO { public int $uid; public string $name; public int $age; public function __construct(int $uid, string $name, int $age) { $this-uid $uid; $this-name $name; $this-age $age; } }然后这样实例化$user new UserDTO(123, 张三, 28);问题来了如果参数特别多调用方根本分不清第三个参数是 age 还是 gender。PHP 8 的命名参数可以解决这个问题$user new UserDTO( uid: 123, name: 张三, age: 28 );命名参数的可读性提升非常明显尤其 DTO 字段多的时候别人看代码时不需要再去翻类定义。如果你用的 PHP 8.0 及以上构造器属性提升可以让 DTO 精简到极致class UserDTO { public function __construct( public int $uid, public string $name, public int $age, ) {} }到这里一个最基础的 DTO 就完成了。注意属性提升里的public是必须的如果你写成private或者protected外部无法直接访问属性DTO 作为传输对象的意义就丢了。2.2 进阶给 DTO 加上 fromArray 和 toArray基础版的问题在于数据库查询返回的是数组请求参数也是数组你总不能每处都手写new UserDTO($row[uid], $row[name], ...)字段一多能写到怀疑人生。静态工厂方法是常见解法。从一个关联数组构造 DTOclass UserDTO { public function __construct( public int $uid, public string $name, public int $age, ) {} public static function fromArray(array $data): self { return new self( uid: (int)($data[uid] ?? 0), name: (string)($data[name] ?? ), age: (int)($data[age] ?? 0), ); } public function toArray(): array { return get_object_vars($this); } }fromArray负责把散乱的数组洗成结构化的对象toArray负责把对象还原成数组方便写入缓存、传给模板或者兼容老代码。这里有两个细节值得展开。第一??空合并运算符配合默认值可以让 DTO 对缺失字段宽容但不放纵。缺字段时给个安全默认值比直接报错更适合接口输入这种不可控场景。第二get_object_vars在 PHP 8 里返回的是当前作用域下的可见属性。因为构造器属性提升定义的属性是 public所以能拿到全部字段。如果你混入了private辅助属性它不会被带出来这其实是个隐性的好处。实际项目中我更建议把toArray写得更显式一些比如public function toArray(): array { return [ uid $this-uid, name $this-name, age $this-age, ]; }显式的好处是字段重命名时你能看到全貌。比如前端要求的字段名从 name 变成 nickname你只需要改toArray的键。2.3 嵌套 DTO 与复杂数据结构的处理真实业务里几乎没有只有三个字段这么理想的情况。常见的是用户在订单里订单里有商品列表商品里还有分类信息。DTO 最好的处理方式就是嵌套而不是摊平。class OrderItemDTO { public function __construct( public int $goodsId, public string $goodsName, public int $price, public int $qty, ) {} } class OrderDTO { /** * param OrderItemDTO[] $items */ public function __construct( public string $orderNo, public int $uid, public array $items, ) {} }注意我在构造参数上加了 PHPDoc 注解说明$items是OrderItemDTO[]。PHP 本身支持数组类型但没法声明这个数组里装的全是某个类所以 PHPDoc 是实际情况下的折中方案。嵌套 DTO 在组装时比较麻烦建议在fromArray里处理子集public static function fromArray(array $data): self { return new self( orderNo: (string)($data[order_no] ?? ), uid: (int)($data[uid] ?? 0), items: array_map( fn($item) OrderItemDTO::fromArray($item), (array)($data[items] ?? []) ), ); }array_map配合箭头函数每个子数组自动转成子 DTO。这里我用的是order_no而不是orderNo因为数据库字段通常都是 snake_caseDTO 属性坚持 camelCase中间由fromArray做一次映射这是 PHP 生态里比较舒服的约定。2.4 readonly 与不可变性给 DTO 上把锁PHP 8.1 引入了 readonly 属性对 DTO 来说几乎是量身定做的特性。class UserDTO { public function __construct( public readonly int $uid, public readonly string $name, public readonly int $age, ) {} }readonly的意思是属性只能在构造函数里赋值一次之后不能再改。这解决了 DTO 一个很实际的问题防止别人在业务代码里把 DTO 当临时变量到处改。我见过不少团队DTO 本来是传输用的结果业务层为了省事直接$dto-name 新名字类里还写着privategetter/setter最后 DTO 变成了一堆行为不明确的小实体。用了 readonly 之后想改可以重新 new 一个 DTO$newUser new UserDTO( uid: $user-uid, name: 新名字, age: $user-age, );这样做的最直接好处是DTO 的整个生命周期里它的状态是确定的。后面在复杂业务流程中数据传了多少层、被谁看了一眼都不可怕因为没人能改动它。有个坑需要提醒readonly属性不能有默认值不能在构造函数外面赋默认值。所以如果你需要可选字段默认值请写在fromArray的空合并那一步。2.5 用 enum 取代魔法字符串PHP 8.1 的 enum枚举同样是 DTO 的好搭档特别是那些状态类型字段。以前你可能是这么写的public string $status; // 值可能是 pending paid canceled在任何一处都可能拼错拼错之后还没有任何报错直到前端发现状态不对。用了 enum 之后enum OrderStatus: string { case Pending pending; case Paid paid; case Canceled canceled; } class OrderDTO { public function __construct( public string $orderNo, public OrderStatus $status, ) {} }这里$status的类型不再是 string而是 OrderStatus。外部传入非法的状态值会直接抛TypeError不可能再拼错字符串。fromArray里对应转换$status OrderStatus::tryFrom((string)($data[status] ?? )) ?? OrderStatus::Pending;tryFrom尝试把字符串转成枚举转不了就返回 null再通过??给一个默认值。处理脏数据时这个组合很常用。3. DTO、VO、Entity 到底差在哪一次讲清楚很多 PHP 开发者包括一些架构师经常把 DTO、VO、Entity 混为一谈。面试时每次聊到这块十个人里有六七个表达得比较模糊。这很正常因为这几个概念在 Java 世界里分层清晰到了 PHP 这种灵活的语言里边界确实容易模糊。3.1 从数据角色看四种对象名称英文全称核心用途典型场景是否携带行为DTOData Transfer Object跨层传递数据Controller 到 Service、Service 到外部接口否VOValue Object / View Object承载展示层数据渲染页面、响应 JSON否EntityEntity领域模型的核心业务逻辑操作、持久化关联是POPersistent Object数据库表映射ORM 模型类否或少量最重要的一句话Entity 是有行为的领域对象DTO/VO 是纯粹的数据壳。3.2 从真实代码看 Entity 和 DTO 的区别假设你在写一个订单模块。Entity 可以长这样class Order { public function __construct( private int $id, private string $orderNo, private int $status, private float $amount, ) {} public function canRefund(): bool { return $this-status 2 $this-amount 0; } public function markRefunded(): void { $this-status 3; } }Entity 里的canRefund、markRefunded承载了业务规则。Controller 和 Service 层操作的是 Order 的行为而不是直接改它的字段。DTO 则完全不同class OrderDTO { public function __construct( public readonly int $id, public readonly string $orderNo, public readonly int $status, public readonly float $amount, ) {} }DTO 里没有任何行为逻辑它就是拿来装数据、传数据的。那么 VO 呢class OrderVO { public function __construct( public readonly string $orderNo, public readonly string $statusText, ) {} }VO 往往会在 DTO 基础上再做一次面向展示的加工。比如 DTO 里的status是 int 2VO 里的statusText是 已支付amount_formatted是 299.00 元这些就是典型的视图模型做的事。有些团队直接拿 DTO 渲染也能跑但遇到复杂展示逻辑时VO 会整洁很多。我这几年做项目的体会是小项目不用强行分 VO 和 DTO一个 DTO 管到底没毛病但 Entity 必须和 DTO 分开尤其是用了 ORM 的团队直接在 Controller 里把 Model 丢给前端隐患太大——暴露了不该暴露的字段还让前端依赖上数据库结构后面一旦重构就是灾难。4. 实战DTO 在接口层和数据层的落地方案说了这么多概念这一章我们看几个可以直接抄作业的实战场景。4.1 API 请求参数转 DTO后端接收前端传参最常用的场景。比如有一个创建用户的接口前端 POST 过来的 JSON 是这样的{ name: 李四, email: lisiexample.com, age: 30 }用 ThinkPHP 或者 Laravel通常在 Controller 里拿到的是$request-all()也就是一个数组。推荐的做法是先做数据校验再转 DTO最后交给 Service。// Controller public function store(Request $request) { $validated $request-validate([ name required|string|max:50, email required|email, age required|integer|min:1|max:150, ]); $dto CreateUserDTO::fromArray($validated); $this-userService-createUser($dto); }这里有几层意思。校验通过后$validated数组里的字段已经被框架过滤过、格式检查过了再交给fromArray等于给 DTO 上了一道双保险。如果连 DTO 都防御不了的类型问题说明校验规则有遗漏。CreateUserDTO 的定义class CreateUserDTO { public function __construct( public readonly string $name, public readonly string $email, public readonly int $age, ) {} }你看Service 层接收的一定是一个完整、合法、类型正确的对象。Service 里面不需要再到处写如果 $name 为空之类的判断代码会干净很多。4.2 从 ORM 模型转 DTO从数据库查出来的模型对象往 DTO 转时最忌讳的是先-toArray()再fromArray()多了一次中间态、多了一次拷贝。正确的用法是直接读取模型属性构造 DTOclass UserService { public function getUserProfile(int $uid): UserProfileDTO { $user User::query()-find($uid); if (!$user) { throw new UserNotFoundException(); } return new UserProfileDTO( uid: $user-id, name: $user-name, email: $user-email, createdAt: $user-created_at-toDateTimeString(), ); } }这里直接用$user-id、$user-name这些模型属性来构造 DTO不需要经过数组中转。这么做有两个好处一是不用担心模型里带出来的多余字段比如密码哈希被意外泄漏二是 DTO 的字段名和数据库字段名解耦你完全可以在 DTO 里用createdAt底层数据库叫created_at。如果有嵌套关联比如用户查出来还要带上他最近的三条订单推荐的方式是先用 ORM 关联查询把数据取齐再统一组装$user User::query() -with([recentOrders fn($q) $q-limit(3)]) -find($uid); $dto new UserProfileDTO( uid: $user-id, name: $user-name, recentOrders: array_map( fn(Order $order) new OrderDTO( orderNo: $order-order_no, amount: (float)$order-amount, ), $user-recentOrders-all() ), );重点务必把数组转换成 DTO 放在预加载之后不要在循环里执行数据库查询。你可能觉得这是常识但我真见过不少朋友在循环里写查询一次接口几十条 SQL慢得要命。4.3 列表接口的批量 DTO 组装列表接口通常是这样的public function listUsers(): array { $users User::query()-paginate(20); $list array_map( fn(User $user) new UserDTO( uid: $user-id, name: $user-name, email: $user-email, ), $users-items() ); return [ total $users-total(), list $list, ]; }array_map批量把一个模型的 collection 映射成 DTO 数组。这里我用了UserDTO而不直接用模型原因在于列表接口往往只暴露部分字段如果直接把模型塞回去等于把所有字段无差别丢给前端安全问题只是一方面更重要的是中后台接口会越来越肥胖前端拿到的永远比用到的多。分页这种大数据量场景还应注意内存。paginate(20)本身是分页查询不受影响。但如果你一次性查 10 万条再array_map内存会瞬间暴涨所以列表 DTO 只应用于分页或限流后的结果集。5. 踩坑记录DTO 用不好反而更糟最后分享几个我在实际项目中踩过的坑以及团队引入 DTO 时最容易走的弯路。5.1 DTO 不是越多越好有一种滥用方式是把 Service 内部每一步都拆一个 DTO。比如第一步临时数据DTO、第二步中间结果DTO、第三步组装完成DTO。说实话这样做只会让代码更啰嗦review 的人还要来回跳转类定义。我的原则是DTO 只出现在边界上。什么是边界Controller 和 Service 之间是边界Service 和外部接口/网关之间是边界Service 和 Repository/Model 返回数据之间是边界。边界之内的数据处理老老实实用基础变量和数组别为了 DTO 而 DTO。5.2 字段映射的蛇形与驼峰之争很多团队折在字段命名上。数据库字段是user_name前端接口要求userNameDTO 属性到底用哪个我的建议是DTO 内部属性用 camelCasefromArray入口处接收 snake_casetoArray出口处再按业务要求转换为 camelCase。这样一个 DTO 把两层命名都消化干净。public static function fromArray(array $data): self { return new self( userName: (string)($data[user_name] ?? ), userEmail: (string)($data[user_email] ?? ), ); } public function toArray(): array { return [ userName $this-userName, userEmail $this-userEmail, ]; }千万不要一会儿$this-user_name一会儿$this-userName混着写就是给自己埋雷。5.3 校验逻辑别塞进 DTO一个常见误区是把这个字段必填那个值不能小于多少之类的验证规则也写进fromArray。你可以做基本的类型强转和默认值处理但业务规则校验应该交给独立的校验器比如 ThinkPHP 的 Validate 类或 Laravel 的 FormRequest。为什么因为 DTO 有两个职责之外的禁忌一是承载行为二是承载校验规则。一旦 DTO 开始管业务规则它就不再是简单的传输对象你会发现在不同接口里同一个 DTO 的校验规则互相打架改一处牵一发动全身。正确的取舍是校验在入口做传输由 DTO 管。拿到 DTO 的人默认它已经是合法数据没必要再防一遍真防不住说明入口校验有漏洞修入口而不是在 DTO 里打补丁。5.4 别让 DTO 泄漏到数据库层我见过一个团队把 DTO 直接传给 Repository 的save方法代码大概是这样$repository-save($userDTO);Repository 层接收 DTO 本身没问题但直接在内部把 DTO 属性赋给 Model$model-name $dto-name;短期能跑但问题是 Repository 的接口被 DTO钉死了。一旦 DTO 重构比如字段改名、结构调整Repository 层的改动成本会非常大。更稳妥的做法是Repository 层只依赖基础数据类型或者模型自身由 Service 负责把 DTO 转换成 Repository 需要的形态。分层清晰了DTO 才不会变成一条贯穿全局的铁索把各层全绑在一起。我个人做了这么多年 PHP 项目从纯数组传参到全面引入 DTO最大的体会是DTO 的价值不在于类本身多高级而在于它帮你把数据边界固定下来让每个人都知道到这里数据长什么样、该有什么字段。它就像接口对外承诺的数据契约层与层之间的谈话终于有了一个大家都认的准话。最后再分享一个小技巧写 DTO 时把fromArray、toArray、构造器提升、readonly 全部配合起来几十行代码就能实现一个完整可用的 DTO而且不需要引入任何第三方库。真到了要序列化成 JSON 的场景直接json_encode($dto)只要属性是 public 就能正常工作如果还需要更定制化的输出格式实现一下JsonSerializable接口就能精确控制 JSON 里哪些字段出现、用什么字段名。这套组合拳打下来团队里每个人写出来的 DTO 风格都会高度统一review 代码时也省心得多。
返回列表