先说一件旧事。三年前我做 OAuth 接入,要把用户传过来的回调地址跟白名单比对。白名单里写了一串 https://app.example.com/callback,觉得万无一失。上线之后有人提交了 https://app.example.com/callback?x=1,被拒绝;有人提交 https://app.example.com:443/callback,也被拒绝;还有人用 HTTPS://app.example.com/callback,继续被拒。
问题在于,字符串比较永远解决不了 URL 的等价性问题。:443 是 https 的默认端口、大小写在部分组件里不敏感、多余的空格和百分号编码形式各异。我们只能一个个补规则,到后面那段校验代码变成了一堆 str_replace 拼起来的地雷阵。
这些事情本不该手写。PHP 8.5 终于把 URI 变成了语言层面的东西,两个新的类可以覆盖几乎所有 URL 处理需求。这篇文章拿当年那个校验器做例子,把它重写一遍。
parse_url 到底哪里不够
先摆一下旧工具的问题,这样能更清楚地看到新的东西解决了什么。
parse_url 是函数式的,返回一个数组,失败时返回 false 或者产出警告。它不区分”没给这个部分”和”给了个空值”,字段有时有有时没有。写起来永远是 $parts['host'] ?? '' 这种判断。
它对 URL 的合法性检查非常宽松。parse_url('https://') 会返回 ['scheme' => 'https'],没有 host 也没有报错。这在做安全校验的时候是灾难性的——你以为输入合法,其实是个残次品。
它没有规范化能力。同一份地址,写 :443 和省略端口是两码事,它不会帮你归一。它也不能做相对路径解析,把你从浏览器里学到的”./ 是当前目录”这套逻辑用上去。
顺便说一句,PHP 8.5 里 parse_url 并没有被废弃——它依然能用,只是在新代码里我会优先用 URI 扩展。老代码不用急着改。
两个类,到底该用哪个
PHP 8.5 引入了两个独立的 URI 实现,放在 Uri 命名空间下。
UriRfc3986Uri 严格遵循 RFC 3986。这个是互联网的教科书标准,对什么字符可以出现在哪一段规定得非常细。它的校验体系严格,遇到不合规的输入会直接判错。适合做协议交互的场景——比如你正在写 OAuth 服务端,需要严格按规范校验客户端提交的回调地址。
UriWhatWgUrl 遵循 WHATWG URL 标准,也就是浏览器用的那套。它比 RFC 3986 宽容得多,会自动修复一些常见的不规范写法。比如 HTTP://Example.COM:80/ 会被拆成 scheme 是 http、host 是 example.com、port 是 null(因为 80 是默认端口,被自动去掉了)。适合做用户输入处理、爬虫 URL 归一化这类场景。
选哪个?一个简单的判断方法:如果这份 URL 要和浏览器打交道,用 WhatWg;如果它要在两个服务之间按规范传递,用 Rfc3986。
我这个校验器的场景是用户从浏览器把回调地址填进表单,然后服务端要跟白名单比。也就是说来源是浏览器,目标也是浏览器,那用 WhatWg 更贴切。
一起看一遍两个类的解析行为
先上代码:
<?php
declare(strict_types=1);
use UriRfc3986Uri;
use UriWhatWgUrl;
$input = 'HTTPS://App.Example.COM:443/callback?a=1#done';
$rfc = Uri::parse($input);
$wg = Url::parse($input);
echo "RFC 3986:n";
var_dump($rfc->getScheme());
var_dump($rfc->getHost());
var_dump($rfc->getPort());
var_dump($rfc->getPath());
var_dump($rfc->getQuery());
var_dump($rfc->getFragment());
echo "WHATWG:n";
var_dump($wg->getScheme());
var_dump($wg->getHost());
var_dump($wg->getPort());
var_dump($wg->getPath());
var_dump($wg->getQuery());
var_dump($wg->getFragment());
看结果差在哪。
RFC 3986 那边,getScheme() 返回的是原样的大写 HTTPS,getHost() 也是原样保留的大小写混合。这是 RFC 的要求——URI 的各个组件在执行规范化之前,大小写都是”原样保留”的。getPort() 返回 443,因为它不知道 443 是 https 的默认端口——默认端口这件事是 HTTP 层的事,不是 URI 层的事。
WhatWg 那边就完全不一样了。scheme 回来的是小写的 https,host 是小写的 app.example.com,port 是 null——因为 443 被识别出来是 https 的默认端口,直接省略。
这就是为什么说做用户输入处理的时候不能直接用 RFC 3986——它返回的东西虽然”正确”,但跟用户在浏览器里看到的会对不上。
开始写校验器
回到那个场景。
<?php
declare(strict_types=1);
use UriWhatWgUrl;
final class CallbackValidator
{
/** @var string[] */
private array $allowedHosts;
private array $allowedSchemes;
public function __construct(array $allowedHosts, array $allowedSchemes = ['https'])
{
$this->allowedHosts = array_map('strtolower', $allowedHosts);
$this->allowedSchemes = $allowedSchemes;
}
public function isAllowed(string $raw): bool
{
$url = Url::parse($raw);
if ($url === null) {
return false;
}
if (!in_array($url->getScheme(), $this->allowedSchemes, true)) {
return false;
}
$host = $url->getHost();
if ($host === null) {
return false;
}
return in_array(strtolower($host), $this->allowedHosts, true);
}
}
用法:
$validator = new CallbackValidator([
'app.example.com',
'auth.example.com',
]);
var_dump($validator->isAllowed('https://app.example.com/callback')); // true
var_dump($validator->isAllowed('HTTPS://App.Example.COM/callback')); // true
var_dump($validator->isAllowed('https://app.example.com:443/cb')); // true
var_dump($validator->isAllowed('http://app.example.com/callback')); // false
var_dump($validator->isAllowed('https://evil.com/app.example.com/')); // false
最后那一行特意写出来。如果白名单匹配是靠简单的 str_contains 或者正则 .*app.example.com.* 做的,evil.com 会把 app.example.com 塞在路径里骗过检查。用 getHost() 拿真实 host 来比,就不会出这种岔子。这种事自己写正则永远会漏情况,用解析器就没有这个问题。
让它支持子域和白名单路径
实际业务里往往还有两个需求:允许所有 *.example.com 的子域,以及只允许某个路径下的回调。
final class CallbackValidator
{
private array $allowedHosts;
private array $allowedSchemes;
private bool $allowSubdomains;
private ?string $requiredPathPrefix;
public function __construct(
array $allowedHosts,
array $allowedSchemes = ['https'],
bool $allowSubdomains = false,
?string $requiredPathPrefix = null,
) {
$this->allowedHosts = array_map('strtolower', $allowedHosts);
$this->allowedSchemes = $allowedSchemes;
$this->allowSubdomains = $allowSubdomains;
$this->requiredPathPrefix = $requiredPathPrefix;
}
public function isAllowed(string $raw): bool
{
$url = Url::parse($raw);
if ($url === null) {
return false;
}
if (!in_array($url->getScheme(), $this->allowedSchemes, true)) {
return false;
}
$host = $url->getHost();
if ($host === null) {
return false;
}
$host = strtolower($host);
if (!$this->hostMatches($host)) {
return false;
}
if ($this->requiredPathPrefix !== null) {
$path = $url->getPath() ?? '/';
if (!str_starts_with($path, $this->requiredPathPrefix)) {
return false;
}
}
return true;
}
private function hostMatches(string $host): bool
{
if (in_array($host, $this->allowedHosts, true)) {
return true;
}
if (!$this->allowSubdomains) {
return false;
}
foreach ($this->allowedHosts as $allowed) {
if (str_ends_with($host, '.' . $allowed)) {
return true;
}
}
return false;
}
}
子域匹配那段的 '.' . $allowed 前面那个点不能省。省掉的话 evilexample.com 会被判定为 example.com 的子域——它的字符串结尾确实是 example.com,但不带点的匹配在这里就是错的。
路径前缀匹配也一样有个边界问题。requiredPathPrefix = '/callback',写 str_starts_with($path, '/callback'),/callback-evil 也会通过。要更严格一点,应该比较下一个字符是不是 /:
if ($path !== $this->requiredPathPrefix &&
!str_starts_with($path, $this->requiredPathPrefix . '/')) {
return false;
}
看着啰嗦,但这正是”用解析器也好、做字符串比较也好”,都是细节堆出来的东西。没什么优雅的办法,只能一个一个想清楚。
相对路径解析
校验之外,另一个常见需求是”根据回调地址拼一个跳转目标”。比如 OAuth 授权成功后要回跳到 https://app.example.com/callback?code=xxx&state=yyy。这个拼接用字符串加号很容易把已经存在的查询串覆盖掉,或者留下双重的 ?。
URL 里拼接的正规做法是用”相对引用解析”。Rfc3986Uri 提供了这个方法,WhatWgUrl 应该也有对应的实现(规范里叫 resolve):
use UriRfc3986Uri;
$base = Uri::parse('https://app.example.com/auth/callback?v=1');
$r1 = $base->resolve('login');
// https://app.example.com/auth/login
$r2 = $base->resolve('/status');
// https://app.example.com/status
$r3 = $base->resolve('../logout');
// https://app.example.com/logout
$r4 = $base->resolve('?next=home');
// https://app.example.com/auth/callback?next=home
这套规则和你熟悉的浏览器地址栏行为完全一致。login 是相对当前路径,/status 从根目录开始,../ 回退一级,?next=home 只替换查询串。以前要在 PHP 里实现这个,得上 league/uri 之类的库。现在语言自带了。
拿这个实现”保留原有查询参数、追加新的”就非常干净:
function appendQuery(Uri $uri, string $key, string $value): Uri
{
$current = $uri->getQuery();
$extra = rawurlencode($key) . '=' . rawurlencode($value);
$merged = $current === null || $current === ''
? $extra
: $current . '&' . $extra;
return $uri->withQuery($merged);
}
withQuery 是 URI 对象上的”不可变修改”方法。它不改变原对象,返回一个新的实例。这类 withXxx 系列的命名和 PSR-7 里的请求对象一致,用过 Guzzle 或者 Nyholm 的人会很熟悉。
归一化:什么是”同样的 URL”
从 URL 本身的角度看,”同样的 URL”有两种意思。
第一种是语法上完全一致。https://a.com/x 和 https://a.com/x 就是同一个。这没什么好说的。
第二种是语义上等价。https://a.com 和 https://a.com/ 实际上访问的是同一个资源,但字面量不一样。
URI 对象上的 equals() 方法处理的是第二种。它会先对两边做一次语法归一化,再比较。
use UriRfc3986Uri;
$u1 = Uri::parse('https://Example.COM/path');
$u2 = Uri::parse('https://example.com/path');
var_dump($u1->equals($u2)); // true
$u3 = Uri::parse('https://example.com/path?b=2&a=1');
$u4 = Uri::parse('https://example.com/path?a=1&b=2');
var_dump($u3->equals($u4)); // false
最后那个返回 false,可能会让人意外。原因是 URI 规范并没有规定查询参数的顺序可以改变——?b=2&a=1 和 ?a=1&b=2 在字符串上不同,对于服务端来说也可能是两个不同的请求(比如某个博客接口按参数顺序区分)。
如果你做的事确实不关心查询参数的顺序,得自己在比较前做一层归一化:
function sortQuery(Uri $uri): Uri
{
$query = $uri->getQuery();
if ($query === null || $query === '') {
return $uri;
}
$parts = explode('&', $query);
sort($parts);
return $uri->withQuery(implode('&', $parts));
}
粗暴但管用。sort() 是按字符串排的,参数值里如果还有 & 的编码形式不会被打乱——因为大家都是在同一次 explode 之后处理的。
别忘了错误处理
URL 层的最底层是用户输入,一定会有奇奇怪怪的字符串进来。Url::parse 和 Uri::parse 的签名是这个样子:
public static function parse(string $uri, &$errors = null): ?static
解析失败时返回 null,同时把错误信息写进第二个引用参数。
$errors = [];
$url = Url::parse($rawInput, $errors);
if ($url === null) {
// $errors 是一个数组,里面是详细原因
logger()->warning('URL 解析失败', ['input' => $rawInput, 'errors' => $errors]);
return null;
}
不要忽略这些错误。在排查”为什么这个回调地址一直被拒”这类问题时,错误信息往往比什么都管用。有时候是用户真的输错了,有时候是自己的解析逻辑有问题——两者从表现上看起来一样,但错误信息能区分开。
有一点比较容易忘:Rfc3986Uri 和 WhatWgUrl 的宽容程度差得很远。同一个输入,很可能 RFC 那个判失败而 WhatWg 判成功。所以不要拿 Rfc3986 的结果去判断”这个 URL 是不是合法”——它回答的是”这个 URL 是不是完全合规”。
一点收尾的想法
我当年那个用正则加拼接写出来的校验器,前后维护了两年多,补了十几个 corner case。有了 URI 扩展之后,我把它整个换掉了,最后算下来是三十多行。
但真正省的不是行数,是”不用再猜测”。以前每加一条规则都是靠猜——用户还可能输什么变体?现在这些规则由标准化组织替我们维护,getHost() 拿到的就是”这个 URL 的主机”,不需要每次都在日志里翻一遍,看到底是 Example.COM 还是 example.com。
如果你手上还有一堆 parse_url 加手工字符串操作拼起来的 URL 处理代码,可以挑一个坑最多的、出错最多的模块,先试着改成 URI 扩展。跑通之后再推广到其它地方,改动量其实不大。

