ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP8.4怎么实现API版本管理兼容旧接口

PHP8.4怎么实现API版本管理兼容旧接口 前言先纠正一个容易产生的误解API 版本管理是一套接口演进规范PHP 8.4 并没有提供内置的版本路由或版本隔离能力。你在 8.0 上怎么设计版本8.4 上还是怎么设计。标题把「8.4」和「版本管理」放在一起容易让人以为升级语言版本就能解决兼容问题。PHP 8.4 对这件事的真实价值在于属性钩子Property Hooks和非对称可见性让版本适配层写得更干净。旧版本接口和新版本接口共用同一个内部模型输出格式的差异集中在几个计算属性上而不是散落在十几个if ($version 1)分支里。本文先讲清楚哪些改动算破坏性变更再给出三种版本策略的取舍最后用一个完整的版本路由器加适配层示例PHP 8.4把「一套代码同时服务 v1 和 v2」落地。一、先定义什么叫做「兼容」版本管理的全部难度来自一件事已上线的接口有人在用你不能改坏它。所以第一步是分清哪些改动是安全的改动是否破坏性原因新增一个响应字段安全老客户端忽略未知字段即可新增一个可选请求参数安全不给值就走默认行为新增一个接口安全不影响既有调用方删除响应字段破坏性新客户端可能已经在依赖它重命名字段破坏性等价于删一个加一个改字段类型int 变 string破坏性强类型客户端会直接反序列化失败改时间格式时间戳变 ISO8601破坏性同上把可选参数改成必填破坏性老请求会直接 400改错误码或错误结构破坏性客户端的错误分支全部失效改分页默认值或上限破坏性会悄悄改变返回的数据量收紧枚举取值范围破坏性原本合法的输入被拒改认证方式破坏性全量客户端需要同步升级一个实用的判断标准只要一个老请求在改动后可能拿到不同的结果或者失败就算破坏性变更就必须开新版本。二、三种版本策略的取舍策略形式优点缺点URL 路径版本GET /v2/orders直观、易调试、CDN 和网关好做路由URL 会变资源标识不够「纯净」请求头版本Accept: application/vnd.acme.v2jsonURL 稳定浏览器里不便调试需要工具支持自定义头版本X-API-Version: 2实现最简单同一 URL 返回不同结构缓存策略复杂查询参数版本GET /orders?version2临时切版本很方便容易出现在日志和分享链接里默认值一改就出事对绝大多数团队来说URL 路径版本是默认选择它让日志、监控、网关限流规则都能按版本切分排查问题时一眼能看出调用方在用哪一版。三、兼容旧接口的三层结构真正决定项目会不会演变成「每个版本复制一份代码」的是有没有把这三层分开入口层版本路由 │ 根据 URL 前缀决定用哪套适配器 ▼ 适配层DTO / 资源转换 │ 把内部统一模型转换成某个版本的线上格式 ▼ 领域层业务逻辑只有一份 订单怎么创建、库存怎么扣与版本无关关键原则领域层永远只有一份版本差异只允许出现在入口层和适配层。如果某次改动让两个版本的业务逻辑真的不同了比如 v2 引入了新的风控流程那应该做成两个不同的领域服务而不是在同一个方法里塞版本判断。代码实战一套代码同时服务两个版本需求v1的用户接口返回合并后的name字段和 Unix 时间戳v2返回拆分后的first_name/last_name和 ISO 8601 时间。内部模型只有一个。先写适配层PHP 8.4用属性钩子把格式转换集中在属性定义处?php // resources.php —— 需要 PHP 8.4 declare(strict_types1); /** 内部统一模型领域层只认它 */ final class UserModel { public function __construct( public readonly int $id, public readonly string $firstName, public readonly string $lastName, public readonly DateTimeImmutable $createdAt, ) {} } /** v1 的线上格式 */ final class UserResourceV1 { public function __construct(private UserModel $user) {} // 虚拟属性没有后备存储只由 get 钩子算出来 public string $name { get trim({$this-user-firstName} {$this-user-lastName}); } public int $created_at { get $this-user-createdAt-getTimestamp(); } public function toArray(): array { return [ id $this-user-id, name $this-name, created_at $this-created_at, ]; } } /** v2 的线上格式字段拆开时间用 ISO 8601 */ final class UserResourceV2 { public function __construct(private UserModel $user) {} public string $first_name { get $this-user-firstName; } public string $last_name { get $this-user-lastName; } public string $created_at { get $this-user-createdAt-format(DateTimeInterface::ATOM); } // 非对称可见性外部可读只有本类能改省掉一个 getter public private(set) string $schema user.v2; public function toArray(): array { return [ id $this-user-id, first_name $this-first_name, last_name $this-last_name, created_at $this-created_at, schema $this-schema, ]; } }再写版本路由。这里用一个「按版本逐级回退」的注册表——某个接口在 v2 里没有特殊处理时自动复用 v1 的实现这样新增一个版本只需要登记真正变化的接口?php // router.php —— 需要 PHP 8.4 declare(strict_types1); require __DIR__ . /resources.php; final class VersionRouter { /** var arraystring, arraystring, callable 版本 [路由键 处理器] */ private array $handlers []; public function register(string $version, string $route, callable $handler): void { $this-handlers[$version][$route] $handler; } /** 从请求版本开始向低版本逐级回退查找处理器 */ public function resolve(string $version, string $route): callable { $candidates [v1, v2, v3]; // 有序的版本列表 $start array_search($version, $candidates, true); if ($start false) { throw new RuntimeException(未知的 API 版本: {$version}); } for ($i $start; $i 0; $i--) { $v $candidates[$i]; if (isset($this-handlers[$v][$route])) { return $this-handlers[$v][$route]; } } throw new RuntimeException(未找到处理器: {$version} {$route}); } } // ---- 组装 ---- $router new VersionRouter(); // 两个版本共用的接口只在 v1 注册一次即可 $router-register(v1, GET /users/{id}, function (int $id): array { $model new UserModel($id, Ada, Lovelace, new DateTimeImmutable(2026-01-02T03:04:0500:00)); return (new UserResourceV1($model))-toArray(); }); // v2 覆盖这一个接口返回新格式 $router-register(v2, GET /users/{id}, function (int $id): array { $model new UserModel($id, Ada, Lovelace, new DateTimeImmutable(2026-01-02T03:04:0500:00)); return (new UserResourceV2($model))-toArray(); }); // ---- 模拟请求 ---- function dispatch(VersionRouter $router, string $method, string $path): array { // 解析 /v1/users/42 这类路径 if (preg_match(#^/(v\d)(/.*)$#, $path, $m) ! 1) { throw new RuntimeException(路径必须带版本前缀例如 /v1/users/42); } [$all, $version, $rest] $m; $id 0; if (preg_match(#^/users/(\d)$#, $rest, $mm) 1) { $id (int) $mm[1]; } $handler $router-resolve($version, {$method} {$rest}); return $handler($id); } header(Content-Type: application/json; charsetutf-8); // 对已废弃的 v1 明确发出弃用信号 $requestPath /v1/users/42; if (str_starts_with($requestPath, /v1/)) { header(Deprecation: true); header(Sunset: Wed, 30 Jun 2027 23:59:59 GMT); // 必须用 HTTP-date 格式 } echo json_encode(dispatch($router, GET, $requestPath), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);同一份代码分别请求两个版本输出分别是GET /v1/users/42 { id: 42, name: Ada Lovelace, created_at: 1767323045 } GET /v2/users/42 { id: 42, first_name: Ada, last_name: Lovelace, created_at: 2026-01-02T03:04:0500:00, schema: user.v2 }Deprecation和Sunset响应头是被广泛支持的弃用信号前者告诉调用方「这个版本已经进入废弃期」后者给出一个明确的关停时间格式必须是 HTTP-dateWed, 30 Jun 2027 23:59:59 GMT写成2027-06-30这类格式客户端解析不了。常见坑点每个版本复制一份控制器❌ 建app/v1/OrderController.php、app/v2/OrderController.php两边各改一份。 ✅ 领域逻辑只保留一份差异放到资源适配层新版本默认复用旧实现。版本号只写在前端后端不校验❌ 前端把/v1硬编码进 URL后端对未知版本默默按最新版处理。 ✅ 后端维护一份版本白名单遇到未知版本返回 400 并列出支持的值避免「静默升级」引发的事故。把版本号当默认参数放在查询串❌GET /orders?version2某天有人把默认值从 1 改成 2所有没传参数的调用方瞬间换结构。 ✅ 版本放在路径前缀里缺失就是未定义行为而不是「默认最新版」。用响应头里的字段告诉客户端版本但结构本身没变❌ 同一个 URL 有时返回name有时返回first_name靠头区分缓存中间件一脸懵。 ✅ 结构变了就换 URL 前缀让「同一 URL 结构恒定」这条不变式成立。属性钩子拿不到序列化结果❌ 直接json_encode($resourceV1)期望带出虚拟属性name。 ✅ 序列化只读取后备存储虚拟属性不会自动出现必须显式实现toArray()或JsonSerializable。把Sunset写成非 HTTP-date❌Sunset: 2027-06-30—— 解析失败的客户端会直接忽略这个头等于没发。 ✅ 用gmdate(D, d M Y H:i:s \G\M\T, $ts)生成。旧版本没有退出时间表❌ 三个版本同时在线跑了两年每个改动都要维护三套适配。 ✅ 从发布新版本那天起就定下旧版本的Sunset日期并提前至少一个季度发出弃用通知。不做契约测试❌ 改完内部模型v1 的输出结构悄悄变了直到客户报障才发现。 ✅ 为每个版本固化一组响应 fixture在 CI 里对两个版本都跑一遍断言。总结层次职责是否随版本变化版本路由从 URL 前缀解析版本、校验白名单变化资源适配层把内部模型转成某版本的线上格式变化领域层业务规则、事务、校验不变兼容约定只增不减、字段类型稳定、错误结构稳定不变要让新旧接口长期共存而代码不膨胀靠的是三件事把版本差异全部收敛到适配层、让高版本默认回退复用低版本的实现、以及给每个旧版本一个明确的Sunset时间。PHP 8.4 的属性钩子在这里只承担了一个很具体的角色——让「同一个内部字段、两种线上表示」写在一处而不是散落在各处的条件分支里。
返回列表