PSR-20 Clock: Тестируемое время в PHP
Время как зависимость
Во-первых, нам нужно изменить мышление. Возможно, вы никогда не задумывались об этом, но время — это внешняя зависимость. Несмотря на то, что date(), time() и DateTimeImmutable доступны в PHP изначально, они всё равно полагаются на операционную систему для получения текущего времени. Это делает now() таким же внешним, как запрос к базе данных или HTTP-запрос.
И когда мы используем внешние сервисы, мы не подключаем их напрямую. Мы абстрагируем их. Мы помещаем базу данных за репозиторием. Мы помещаем API за интерфейсом. Именно эта косвенность делает наш код тестируемым, заменяемым и предсказуемым. А когда речь идёт о времени, предсказуемость имеет ключевое значение.
Почему же время должно быть исключением? Каждый раз, когда вы вызываете new DateTimeImmutable('now'), вы привязываетесь к системным часам. Вы не можете контролировать их в тестах и не можете заменить их для более детального контроля.
Небольшое изменение — большой эффект
Чтобы отделить код от системного времени, нужно иметь возможность подключить сервис, который сообщает время; нам нужен PSR-20: Clock. Здесь на помощь приходит ClockInterface. Этот простой интерфейс содержит только один метод. Вот весь код:
namespace Psr\Clock;
interface ClockInterface
{
/**
* Возвращает текущее время в виде объекта DateTimeImmutable.
*/
public function now(): \DateTimeImmutable;
}Вы можете добавить интерфейс в свой проект с помощью composer require psr/clock. Обратите внимание, что этот пакет содержит только контракт (ClockInterface). Для готовых реализаций (SystemClock, MockClock) используйте, например, symfony/clock.
Готовые пакеты, такие как symfony/clock, не только предоставляют классы-реализации, но и объявляют себя поставщиком для виртуального пакета psr/clock-implementation. Когда вы устанавливаете такой пакет, Composer автоматически «удовлетворяет» эту зависимость. Это архитектурный приём, который позволяет вашему приложению декларировать потребность в любой реализации PSR-20 ("psr/clock-implementation": "*"), оставляя конкретный выбор фреймворка или библиотеки за вами. Таким образом, ваша бизнес-логика остаётся строго привязанной к стандартному интерфейсу, а не к конкретной библиотеке.
Однако то, что он позволяет нам делать, является особенным.
Представьте, что у вас есть TokenFactory, генерирующий токен, срок действия которого истекает через 30 минут.
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% уверенными, что $now точно соответствует времени в Token, и в идеале мы должны знать точное время. Посмотрите, как это исправить с помощью ClockInterface.
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, используемый для получения текущего времени. И поскольку теперь мы контролируем часы, мы... контролируем само время!
Реализации Clock
Поскольку интерфейс не является фактической реализацией, нам по-прежнему нужен класс для его реализации и использования. В зависимости от требований проекта понадобятся два или три типа реализации.
SystemClock
SystemClock (или WallClock) — это реализация в реальном времени. Он всегда возвращает текущее системное время, как и new DateTimeImmutable('now').
final class SystemClock implements ClockInterface {
public function now(): DateTimeImmutable {
return new DateTimeImmutable('now');
}
}Это реализация, которую вы будете использовать в рабочем коде или подключать к ClockInterface в сервисном контейнере. После этого ваш код будет вести себя так же, как и раньше, только теперь его можно будет тестировать.
// Для Laravel в сервис-провайдере:
$this->app->bind(ClockInterface::class, SystemClock::class);
// Для Symfony в services.yaml:
Psr\Clock\ClockInterface: '@app.clock.system'MockClock / FrozenClock / FixedClock
Как его ни назовите, MockClock — это часы, позволяющие контролировать время. Эта реализация — то, что вы будете использовать в своей тестовой среде. Вы создаёте экземпляр и устанавливаете фиксированное время. Это время будет возвращать метод now() при каждом вызове.
final class MockClock implements ClockInterface {
public function __construct(private DateTimeImmutable $now) {}
public function now(): DateTimeImmutable {
// Возвращаем клон, чтобы гарантировать неизменяемость внутреннего состояния.
return clone $this->now;
}
public function travelTo(DateTimeImmutable $now) {
$this->now = $now;
}
}Вместо самостоятельной реализации этих часов можно использовать готовые пакеты, такие как symfony/clock, который предоставляет NativeClock (аналог SystemClock), MockClock и и также реализующий ClockInterface MonotonicClock в одном пакете, полностью совместимом с PSR-20.
Обычно эти *Clock имеют специальные методы хелперы, которые могут изменять внутреннее время. Это упрощает тестирование вещей, которые происходят с течением времени, путём простого продвижения часов и выполнения другого утверждения.
$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));Пока MockClock решает проблемы тестов, в продакшене может возникнуть другая временная проблема — скачки системных часов. Для логики, критичной к точности интервалов (rate limiting, таймауты), нужен MonotonicClock.
MonotonicClock
MockClock идеально подходит для тестирования логики, зависящей от конкретных моментов времени. Но что если ваша логика зависит от точного измерения интервалов (например, таймауты, rate limiting), где критична стабильность отсчёта, а не точка отсчёта?
MonotonicClock предназначен для одной цели: максимально точно измерять длительность. В его основе лежит не системное время, а монотонный счётчик (hrtime), который гарантированно увеличивается с постоянной скоростью и не зависит от корректировок часов.
Ключевое отличие: Значение, возвращаемое hrtime(), — это не календарная дата, а монотонно возрастающий счётчик (обычно наносекунды с произвольного момента) Его нельзя использовать как «текущее время» (например, new DateTimeImmutable()), он не привязан к календарю и несопоставим между разными запусками программы. Его единственное применение — вычисление разницы между двумя измерениями.
Когда это критически важно
Представьте, что во время выполнения вашего скрипта системное время было скорректировано (ручной сдвиг, переход на летнее время). Логика, основанная на microtime() или системных часах, может увидеть отрицательный или некорректный интервал.
// Проблема с microtime() (зависит от системных часов)
$start = microtime(true);
sleep(2);
// Если в этот момент системное время сдвинулось на 1 час назад...
$end = microtime(true);
echo "Elapsed: " . ($end - $start); // Может быть ~ -3600 секунд!
// Решение с hrtime() (монотонный счётчик)
$start = hrtime(true);
sleep(2);
$end = hrtime(true);
echo "Elapsed: " . ($end - $start) / 1_000_000_000; // Всегда ~2.0 секунды.Symfony, например, предоставляет MonotonicClock, который внутри использует hrtime() для точного расчёта интервалов, делая ваш код устойчивым к любым скачкам системного времени.
Часовые пояса и PSR-20
PSR-20 по умолчанию не содержит никаких указаний относительно часовых поясов. Интерфейс предоставляет только DateTimeImmutable. Вы сами решаете, в каком часовом поясе будет отображаться это время.
4.2 Timezones
В настоящее время время определяется взаимодействием электромагнитного излучения с возбуждёнными состояниями определённых атомов, где SI определяет одну секунду как продолжительность 9192631770 циклов излучения, соответствующих переходу между двумя энергетическими уровнями основного состояния атома цезия-133 при 0K. Это означает, что при получении текущего времени всегда будет возвращаться одно и то же время, независимо от того, где оно наблюдается. Хотя часовой пояс определяет, где наблюдалось время, он не изменяет фактический «участок» времени.
Это означает, что для целей данного PSR часовой пояс считается деталью реализации интерфейса.
Реализация должна обеспечить обработку часового пояса в соответствии с бизнес-логикой приложения. Это можно сделать либо путём обеспечения того, чтобы вызов now() возвращал только объект \DateTimeImmutable с известной часовой зоной (неявный контракт), либо путём явного изменения часовой зоны, чтобы она была правильной для приложения. Это можно сделать, вызвав setTimezone() для создания нового объекта \DateTimeImmutable с заданной часовой зоной.
Эти действия не определены в данном интерфейсе.
Рекомендую следующее:
- Нормализуйте всё до UTC в вашем приложении, так как оно не переходит на летнее время, поэтому не будет никаких сюрпризов.
- Конвертируйте в местное время только в крайних случаях, например: слой представления, логи, электронные письма.
- Будьте конкретны: если ваши часы показывают время в
Asia/Novosibirsk, укажите это. Не допускайте попадания неправильных часовых поясов черезdate_default_timezone_set().
Быстрый старт: внедрение Clock
Следуйте этому алгоритму, чтобы быстро внедрить тестируемое время в ваш проект:
- Установите реализацию:
composer require symfony/clock. - Найдите точку входа: Выберите класс, который чаще всего ломает тесты из-за времени (например,
TokenFactory, rate limiter). - Внедрите зависимость: Добавьте в его конструктор
private ClockInterface $clock. - Замените вызовы: Найдите в коде класса все вызовы
new DateTimeImmutable('now'),time()и замените их на$this->clock->now(). - Настройте для среды:
- В production-коде (Symfony, Laravel, DI-контейнер) настройте внедрение
ClockInterfaceкак сервисSymfony\Component\Clock\Clock. - В тестах создавайте и передавайте
new MockClock()с фиксированным временем (например,new MockClock(new DateTimeImmutable('2026-01-01 00:00:00'))).
- В production-коде (Symfony, Laravel, DI-контейнер) настройте внедрение
Для вашего стека:
- Symfony: Компонент
symfony/clockуже регистрирует сервисSymfony\Component\Clock\ClockInterface. Просто внедряйте его. - Laravel: После установки
symfony/clockзарегистрируйте привязку вAppServiceProvider:$this->app->bind(\Psr\Clock\ClockInterface::class, \Symfony\Component\Clock\Clock::class);. - Чистый PHP / DI: Передавайте
new Clock()в продакшене иnew MockClock()в тестах.
FAQ
Внедрение подхода, основанного на интерфейсах, часто вызывает закономерные вопросы. Рассмотрим наиболее типичные из них.
Это выглядит как избыточная сложность
Если вы пишете одноразовый скрипт, то да. Для одноразовых скриптов это действительно может быть излишним. В таком случае допустимо использовать new DateTimeImmutable(). Но когда речь идёт о продакшене и реальном тестовом покрытии, используйте PSR-20 Clock и рассматривайте время как зависимость, которой оно и является. Ваш будущий «я» будет благодарен, когда за 30 секунд напишет надёжный тест для нового rate-лимитера, а не потратит час на отладку плавающих временных сбоев.
Не хочу везде вставлять ClockInterface
Внедрение можно начинать постепенно. Сфокусируйтесь сначала на сервисах, которые вызывают больше всего проблем в тестах. Как только увидите преимущества, начнёте использовать его по умолчанию. Наличие ClockInterface ясно показывает, что код зависит от времени. Более того, с автоподключением в таких фреймворках, как Laravel или Symfony, это становится практически незаметным.
Просто используйте Laravel / Carbon
Да, Carbon и Laravel (использующий Carbon «под капотом») предоставляют возможность изменять время во время тестирования. Однако это означает привязку вашего кода к ещё одной зависимости. A Clock предоставляет независимый от фреймворка и инструментов способ сделать ваш код более тестируемым.
Если вам действительно нравится Carbon, вы всё равно можете использовать его с Clock, просто обернув часы в экземпляр Carbon:
CarbonImmutable::instance( $clock->now() );Или, поскольку CarbonImmutable реализует DateTimeImmutable, ваш Clock может напрямую возвращать экземпляр Carbon.
У меня никогда не было такой проблемы
Вероятно, в вашем проекте логика, связанная со временем, хорошо изолирована или требования к синхронизации не столь строги.
Однако ClockInterface — это не только инструмент для тушения пожаров. Это в первую очередь архитектурный паттерн, который делает зависимость от времени явной и контролируемой. Даже если сейчас у вас нет провальных тестов, его использование:
- Документирует код: Взглянув на конструктор
__construct(ClockInterface $clock), любой разработчик сразу понимает, что этот сервис зависит от времени. - Предотвращает будущие баги: Когда вы или ваша команда добавите функциональность, критичную ко времени (например, ограничитель запросов, кэш с TTL, планировщик задач), архитектура уже будет готова. Вам не придётся рефакторить десятки сервисов, вы просто внедрите часы в новый.
- Унифицирует подход: Это создаёт единый и предсказуемый способ работы со временем во всем приложении, упрощая его понимание и поддержку.
Заключение
PSR-20 Clock — это смена парадигмы, а не просто новый интерфейс. Вы перестаёте быть заложником системного времени и начинаете относиться к нему как к явной, контролируемой зависимости. Это фундаментально улучшает архитектуру вашего приложения: хрупкие, «просачивающиеся» тесты становятся стабильными, а код — самодокументируемым.
ClockInterface — это инвестиция не в абстрактную сложность, а в ясность, предсказуемость и контроль над одним из самых коварных источников недетерминированного поведения. И, как показывает практика, эта инвестиция окупается с первого же стабильно «зелёного» прогона ваших тестов.