编程

PSR-20 Clocks: PHP 中的可测试时间

17 2025-10-29 12:07:00

想象一下。你推送了一些代码,然后砰的一声,一个不相关的单元测试在 CI 中失败了:差了一秒。你重新运行测试:砰,又失败了。再一次重新运行:终于,测试通过了。你目测了一下逻辑,发现它完全没问题,那么到底是怎么回事呢?

最有可能的答案是:时间。设置 Fixture、触发模拟(不要这么做)以及运行几个断言所花费的时间,很容易将“现在 + 30 秒”的检查变成“现在 + 31 秒”,突然之间,你原本“可靠”的测试就变成了抛硬币。在这篇文章中,我们将立即修复这些问题。

时间是依赖

首先,我们需要转变思维。你可能从未意识到,时间是一种外部依赖。尽管 date()time()DateTimeImmutable 是 PHP 原生支持的,但它们仍然依赖操作系统来获取当前时间。这使得 “now” 与数据库查询或 HTTP 请求一样,具有外部性。

每当我们处理外部服务时,我们都不会硬编码它们,而是将它们抽象化。我们将数据库置于存储库之后,将 API 置于接口之后。这种间接性使我们的代码可测试、可替换且可预测。尤其是在时间方面,可预测性至关重要。

那么,时间为什么应该有所不同呢?每次调用 new DateTimeImmutable('now') 时,都会与系统时钟耦合。你无法在测试中控制它,也无法将其替换为更有颗粒度的控制。

时间一变,立即影响

为了将代码与系统时间解耦,我们需要注入一个显示时间的服务;我们需要一个 Clock。这就是 PSR-20: Clock 的作用所在。这个简单的接口只包含一个方法。完整代码如下:

namespace Psr\Clock;
 
interface ClockInterface
{
    /**
     * Returns the current time as a DateTimeImmutable object.
     */
    public function now(): \DateTimeImmutable;
}

如你所见,这个接口其实没什么特别的。你可以使用 composer require psr/clock 命令将它安装到你的 Composer 项目中。(我个人不喜欢这个接口名中的 Interface 部分,所以我经常在项目中只创建一个 Clock。除非我构建的是公共包,否则我会用它来实现互操作性。)

但它允许我们做的事情很特别。

想象一下,你有一个 TokenFactory,它会生成一个 30 分钟后过期的令牌。

如你所见,这个接口没什么特别的。你可以在你的composer 项目中安装它:composer require psr/clock。 (就我个人而言,我不喜欢名称中的 Interface 部分,所以我经常在项目中只创建一个 Clock。除非我正在构建一个公共包,在这种情况下,我会用它来实现互操作性。)

尽管如此,它允许我们做的事情却很特别。

假设你有一个 TokenFactory 类,用以生成 30 分钟后到期的 token。 

final class TokenFactory
{
    public function generateToken(): Token
    {
        $now = new DateTimeImmutable('now');
 
        return new Token(
            bin2hex(random_bytes(16)),
            $now,
            $now->modify('+30 minutes'),
        );
    }
}

你可能会这样测试:

$token = $factory->generateToken();
$now = new DateTimeImmutable('now');
assert($token->expires_at->format('c') === $now->modify('+30 minutes')->format('c'));

虽然这看起来很简单,但由于测试执行过程中的时间漂移​​,这个断言偶尔可能会失败。$now 可能与 Token 中的原始时间有一点点偏差。其次,我们断言的是一个非固定值,因为我们怎么可能知道确切的时间呢?

为了进行可靠的测试,我们需要 100% 确保 $nowToken 的时间完全匹配,理想情况下,我们应该知道确切的时间。看看我们如何使用 Clock 来解决这个问题。

final class TokenFactory
{
    public function __construct(private ClockInterface $clock) {}
 
    public function generateToken(): Token
    {
        $now = $this->clock->now();
 
        return new Token(
            bin2hex(random_bytes(16)),
            $now,
            $now->modify('+30 minutes'),
        );
    }
}

本例中,我们注入了 ClockInterface,我们用它来检索当前时间。既然我们已经控制了始终,我们也就控制了时间。】

不同时间,不同实现

由于接口并非实际的实现,我们仍然需要一个类来实现并使用它。根据项目需求,你需要两到三种类型的时钟。

SystemClock

SystemClock (或者你也可以称之为 WallClock) 是实时实现。它总是返回当前系统时间,就像 new DateTimeImmutable('now') 一样。

final class SystemClock implements ClockInterface {
    public function now(): DateTimeImmutable {
        return new DateTimeImmutable('now');
    }
}

这是你将在生产代码中使用的实现,或者将其连接到服务容器上的 ClockInterface。完成后,你的代码将像以前一样运行;只不过现在它是可测试的。

MockClock / FrozenClock / FixedClock

无论怎么称呼,MockClock 都是一个可以控制时间的时钟。这个实现就是你在测试环境中会用到的。你创建一个实例,然后设置一个固定的时间。这个时间就是 now() 方法每次调用时返回的值。

final class MockClock implements ClockInterface {
    public function __construct(private DateTimeImmutable $now) {}
 
    public function now(): DateTimeImmutable {
        return $this->now;
    }
 
    public function travelTo(DateTimeImmutable $now) {
        $this->now = $now;
    }
}

你注意到我们不得不返回日期的克隆吗?这是因为 DateTimeImmutable 是不可变的,所以我们可以安全地重用同一个实例!

通常,这些时钟都有特定的辅助方法来改变内部时间。这样,只需推进时钟并进行另一个断言,就能更轻松地测试随时间推移发生的事情。

$now = new DateTimeImmutable();
$clock = new MockClock($now);
 
$factory = new TokenFactory($clock);
$validator = new TokenValidator($clock);
 
$token = $factory->generateToken();
assert(true === $validator->isValidToken($token));
 
$clock->travelTo($now->modify('+31 minutes'));
assert(false === $validator->isValidToken($token));

MonotonicClock

在检查已用时间方面,SystemClock 或许可以正常工作,但它有一个明显的缺点:系统时间可能会发生变化。这种情况可能通过 Clock Sync 事件发生,也可能由用户更改系统时间,或者夏令时 (DST) 影响当前时间;不过,PHP 对 DST 的支持非常出色,因此这种情况不太可能造成问题。

高分辨率时间

因此,即使不太可能发生,时钟变化仍然是一个现实问题,尤其是在你依赖精确计时的情况下。为了解决这个问题,PHP 7.3 引入了 hrtime() 函数,它为你提供单调且高精度的时间,不受系统时钟变化的影响;它只会向前移动。在底层,它遵循操作系统原生的单调时钟。

Symfony 6.2 中实现了一个很好的 MonotonicClock 示例。在内部,它使用 hrtime() 函数计算精确的日期时间,这些日期时间不受时间变化的影响。

为什么不直接使用 Microtime?

历史上,已用 microtime() 来测量运行时间。但由于它参考的是系统时钟,因此容易受到时间变化的影响,甚至可能为负数。

$start = microtime(true);
sleep(2);
$end = microtime(true);
 
echo "Elapsed: " . ($end - $start); // Might be wrong if system time shifts.
 
$start = hrtime(true);
sleep(2);
$end = hrtime(true);
 
echo "Elapsed: " . ($end - $start) / 1_000_000_000; // Always accurate.

注意hrtime() 返回的值不是时间戳!它是一个相对内部计数器,无法跨系统(或启动)比较。它作为时钟时间毫无意义。只能用它来测量持续时间。

有趣的是sleep() 不受系统时间的影响。这意味着它内部引用了一个单调时钟。

关于时区

PSR-20 设计上没有提及时区。接口只提供了一个 DateTimeImmutable 对象。日期时间的时区由你决定。

我个人推荐这样写:

  • 将应用中的所有内容标准化为 UTC,因为它不会因夏令时而变化,因此不会出现意外。
  • 仅在边缘位置转换为本地时间,例如:视图层、日志、电子邮件。
    • 请明确说明:如果你的时钟返回的是欧洲/阿姆斯特丹( Europe/Amsterdam)时间,请明确说明。不要让错误的时区通过date_default_timezone_set() 混入。

注意:如果你要处理未来用户指定的日期时间(例如事件调度),请勿立即将其存储为 UTC 时间。请存储原始本地时间及其时区。在运行时(规则已知时)转换为 UTC 时间,以避免夏令时或政策变更带来的意外影响。

一些疑虑

读了这篇文章后,你可能会产生类似以下的想法:

感觉优点过度工程化了

如果你写的是一次性脚本,是的,我同意。直接使用 new DateTimeImmutable() 就行。但如果涉及到生产代码和实际测试覆盖率,我强烈建议使用 Clock 并将时间视为依赖项。未来的你会感激你的。

我不想到处注入时钟!

那就别这么做。先从痛点开始:失败的测试。一旦你看到了好处,你就会默认使用它。有了 ClockInterface,代码就很清楚地依赖于时间,而且说实话,有了 Laravel 或 Symfony 等框架的自动装配功能,它几乎算不上“注入”。

只使用 Laravel / Carbon!

是的,Carbon 和 Laravel(底层使用 Carbon)提供了一种在测试期间更改时间的方法。然而,这意味着你的代码会与另一个依赖项耦合。Clock 提供了一种与框架和工具无关的方法,让你的代码更易于测试。

如果你真的喜欢 Carbon,你仍然可以将它与 Clock 一起使用,只需将时钟包装在一个 Carbon 实例中即可:

CarbonImmutable::instance( $clock->now() );

或者,由于 CarbonImmutable 实现了 DateTimeImmutable,你的 Clock 可以直接返回一个 Carbon 实例。

我从来没有遇到过这个问题!

这真是太幸运了。也许你没有编写或使用过很多与时间相关的组件,比如令牌或速率限制器。又或许,时间一直站在你这边。

ClockInterface 的目的并非修复没有问题的东西;它是一种防止事情发生故障的方法。

如果你还没有遇到问题,那只是时间问题。

 

PHP