PHP 8.6 ext/uri 新增两个 Builder:先建后校验,不必再先有合法 URI
RFC 已接受并在 PHP 8.6 实现:Followup Improvements for ext/uri(v0.1,2025-10-17,作者 Máté Kocsis,Status Accepted)。RFC 原文:。
该 RFC 包含四块内容:URI 构建、URI 类型检测、Host 类型检测、百分号编码支持。查询参数操作与路径段操作被拆到单独的 RFC 中。
wither 链的两个问题
目前只能通过 wither 方法修改已经存在且校验通过的 URI。每次调用都会返回新实例,以保持不可变:
$uri1 = Uri\Rfc3986\Uri::parse("http://example.com");
$uri2 = $uri1->withScheme("https")->withHost("example.net")->withPath("/foo/bar"); // 3 个对象
同时修改多个组件时,中间对象会带来额外开销。更麻烦的是无法从零构造:必须先有一个合法 URI。空字符串对 RFC 3986 合法,对 WHATWG URL 不合法。某些转换能否成功还取决于当前状态:
$uri1 = Uri\Rfc3986\Uri::parse("");
$uri2 = $uri1->withScheme("https")->withUserInfo("user:pass") // 抛 Uri\InvalidUriException: Cannot set a userinfo without having a host
->withHost("example.com");
// 换个顺序就不抛
$uri1->withScheme("https")->withHost("example.com")->withUserInfo("user:pass");
Builder 的用法
RFC 引入了 fluent Builder。组件先在 Builder 上设好,最后 build() 才真正创建 URI 实例:
$uriBuilder = new Uri\Rfc3986\UriBuilder()
->setScheme("https")->setUserInfo("user:pass")->setHost("example.com")
->setPort(8080)->setPath("/foo/bar")->setQuery("a=1&b=2")->setFragment("section1");
$uri = $uriBuilder->build();
echo $uri->toRawString();
// https://user:pass@example.com:8080/foo/bar?a=1&b=2#section1
WHATWG 侧对应 Uri\WhatWg\UrlBuilder,组件名是 username / password:
$urlBuilder = new Uri\WhatWg\UrlBuilder()
->setScheme("https")->setUsername("user")->setPassword("pass")->setHost("example.com")
->setPort(8080)->setPath("/foo/bar")->setQuery("a=1&b=2")->setFragment("section1");
$url = $urlBuilder->build();
echo $url->toAsciiString();
Builder 默认是可变的。如果实例不是自己或可信方创建的,就无法确定它是否已经带有组件;需要干净状态时应先调用 reset()。复用同一个 Builder 构造多个 URI 时,reset() 同样有用。
类签名方面,UriBuilder 是 final class,包含 __construct、reset(): static、setScheme(?string)、setUserInfo(#[SensitiveParameter] ?string)、setHost(?string)、setPath(string)、setQuery(?string)、setFragment(?string)、build(?Uri $baseUrl = null): Uri。UrlBuilder 结构类似,带 setUsername / setPassword,build(?Url $baseUrl = null, &$errors = null): Url。
校验分两层
- 单组件校验:每个 setter 尽早校验语法正确性。比如 scheme 不允许出现百分号编码的字节,
setScheme("%")会直接抛InvalidUriException。这层只是尽力而为,因为文法本身依赖上下文,path 的合法形式取决于 scheme 是否存在。 - 整体状态校验:依赖整体结构的约束推迟到
build()。例如 RFC 3986 要求设置 userinfo 时必须同时有 host。延后校验正是为了避免上面那种时序耦合。
为什么不用一个类
Builder 模式之所以选中,而不是 fromComponents 这类工厂方法,是为了灵活性。RFC 3986 与 WHATWG 拆成两个类,是因为两套规范并不认同同一组组件:RFC 3986 有 userinfo,WHATWG 有 username / password;RFC 3986 只要求 path(空字符串就合法),WHATWG 强制要求 scheme;setter 按各自规范校验——RFC 3986 的 URI 不能含 Unicode,WHATWG 则可以处理 Unicode。
setter 统一用 set 前缀,因为它们都是完整覆盖对应组件;这也为将来加 appendQueryParams()、appendPathSegments() 之类增量方法留了空间。
UriType:区分相对引用的几种形态
RFC 3986 区分多种引用类型:绝对路径引用 /foo、相对路径引用 foo、网络路径引用 //host/foo、以 scheme 开头的 URI、不带 fragment 的 absolute URI。新增枚举 Uri\Rfc3986\UriType { AbsolutePathReference, RelativePathReference, NetworkPathReference, Uri } 与 Uri::getUriType()。单独的 AbsoluteUri case 被省略。
WHATWG 侧对应的是 Url::isSpecialScheme(),对 http / https / ftp / file / ws / wss 返回 true。
HostType:RFC 3986 与 WHATWG 的分类不同
enum Uri\Rfc3986\UriHostType { IpV4, IpV6, IpVFuture, RegisteredName }
enum Uri\WhatWg\UrlHostType { IpV4, IpV6, Domain, Opaque, Empty }
两者都通过 getHostType() 获取。RFC 3986 下,空 host(如 https://)返回 RegisteredName,因为 reg-name 的语法本身就允许空字符串;host 缺失时返回 null,例如 file:///C:/a.txt、foo:bar/baz、/foo/bar、mailto:john.doe@example.com。WHATWG 的特殊 URL 中,host 只会是 IPv4、IPv6、domain 或 Empty。
WHATWG 接受的 IPv4 比 parse_url 宽松
new Uri\WhatWg\Url("https://127.1/")->getAsciiHost(); // 127.0.0.1
new Uri\WhatWg\Url("https://0x7f.0x0.0x0.0x1")->getAsciiHost(); // 127.0.0.1
new Uri\WhatWg\Url("https://2130706433/")->getAsciiHost(); // 127.0.0.1
三种写法都是 WHATWG 的合法 IPv4 形式,最终规范化成 127.0.0.1。这也是 ext/uri 的 WHATWG 实现与 parse_url() 语义不同的地方:后者不按这套规则解析和规范化主机。
小结式示例
回到 Builder,构造一个带 query 和 fragment 的 URI:
$builder = new Uri\Rfc3986\UriBuilder();
$uri = $builder
->setScheme("https")
->setHost("example.com")
->setPath("/search")
->setQuery("q=php+8.6&lang=zh")
->setFragment("results")
->build();
echo $uri->toRawString();
// https://example.com/search?q=php+8.6&lang=zh#results
// 复用前清空状态
$builder->reset();
投票结果:URI building 21 赞成、0 反对、2 弃权;URI type 18 赞成、0 反对、2 弃权。