При создании value-объекта для хранения времени, я рекомендую выбирать вместе с экспертами в предметной области и вокруг нее с какой точностью он будет храниться.
Моделируя работу с числами считается хорошим тоном указывать точность. Неважно о чем идет речь — о деньгах, размере или весе; округляйте до заданного десятичного знака. Наличие округления делает данные предсказуемее для обработки и хранения, даже если это число только для отображения пользователю.
К сожалению, так делают не часто, и, когда приходит момент, проблема дает о себе знать. Рассмотрим следующий код:
$estimatedDeliveryDate = new DateTimeImmutable('2017-06-21');
// представим, что сегодня ТАКЖЕ 2017-06-21
$now = new DateTimeImmutable('now');
if ($now > $estimatedDeliveryDate) {
echo 'Package is late!';
} else {
echo 'Package is on the way.';
}
Ожидаемо что, что 21 июня этот код выведет Package is on the way.
, ведь день еще не закончился и пакет, например, доставят ближе к вечеру.
Несмотря на это код так не делает. Так как не указана часть со временем, PHP заботливо подставляет нулевые значения и приводит $estimatedDeliveryDate
к 2017-06-21 00:00:00
.
С другой стороны $now
вычисляется как… сейчас. Now
включает в себя текущий момент времени, который, скорее всего, не полночь, так что получится 2017-06-21 15:33:34
или вроде того, что будет позднее, чем 2017-06-21 00:00:00
.
Решение 1
“О, это легко исправить.” скажут многие, и обновят необходимую часть кода.
$estimatedDeliveryDate = new DateTimeImmutable('2017-06-21');
$estimatedDeliveryDate = $estimatedDeliveryDate->setTime(23, 59);
Круто, мы изменили время до полуночи. Но теперь время дополняется до 23:59:00
, так что если вы запустите код в последние 59 секунд дня, вы получите те же проблемы.
“Брр, ладно.” — последует ответ.
$estimatedDeliveryDate = new DateTimeImmutable('2017-06-21');
$estimatedDeliveryDate = $estimatedDeliveryDate->setTime(23, 59, 59);
Отлично, теперь это исправлено.
… До тех пор, пока вы не обновитесь до PHP 7.1, который добавляет микросекунды в DateTime
-объекты. Так что теперь проблема возникнет на последней секунды дня. Возможно я стал слишком предвзят, работая с высоконагруженными трафик-системами, но пользователь или процесс обязательно наткнутся на это. Удачи в поисках ЭТОГО бага. :-/
Окей, добавим микросекунд.
$estimatedDeliveryDate = new DateTimeImmutable('2017-06-21')
$estimatedDeliveryDate = $estimatedDeliveryDate->modify('23:59:59.999999');
И теперь это работает.
Пока не получим наносекунды.
В PHP 7.2.
Ладно, ладно, мы МОЖЕМ уменьшать погрешность все дальше и дальше до того момента, когда появления ошибки станет малореалистичным. На этом моменте ясно, что такой подход ошибочен: мы гонимся за бесконечно делимым значением и становимся все ближе и ближе к точке, которую не можем достичь. Давайте попробуем другой подход.
Решение 2
Вместо того, чтобы вычислять последний момент перед нашей границей, давайте проверим вместо этого сравнение границ.
$estimatedDeliveryDate = new DateTimeImmutable('2017-06-21');
// Начнем считать от момента, когда уже поздно, а не от последнего необходимого момента
$startOfWhenPackageIsLate = $estimatedDeliveryDate->modify('+1 day');
$now = new DateTimeImmutable('now');
// Мы изменили оператор > на >=
if ($now >= $startOfWhenPackageIsLate) {
echo 'Package is late!';
} else {
echo 'Package is on the way';
}
Этот вариант работает и будет работать в течении дня. Такой код выглядит сложнее. Если не инкапсулировать эту логику в value-объект или во что-то похожее, вы обязательно пропустите это где-нибудь в вашем приложении.
Даже если сделать это, только один тип операций (>=) будет логичным и последовательным, для остального это не работает. Если мы реализуем поддержку проверки равенства, придется сделать еще один тип данных, а затем жонглировать ими для корректной работы. Хех.
Наконец (возможно только для меня) это решение имеет неприятные моменты в виде потенциально пропущенной концепции домена. "Существует ли LatePeriodRange? A DeliveryDeadline?" могли бы вы спросить. "Пакет опоздал и… что-то будет? Эксперт не говорил о сроках, вроде бы сроков нет. Чем это отличается от EstimatedDeliveryDate? Что потом?". Пакет никуда не идет. Это просто странная особенность построенной логики, которая застряла теперь в голове.
Это лучшее решение в предоставлении правильного ответа… но это не очень хорошее решение. Посмотрим, что еще можно сделать.
Решение 3
Наша цель — сравнить два дня. Представим DateTime
-объект c now
в виде набора цифр (год, месяц, день, час, минута, секунда и т.д.), тогда часть до дня будет работать нормально. Проблемы начинаются из-за дополнительных показателей: час, минута, секунда. Можно спорить о хитрых способах решения проблемы, но факт остается фактом — компонент времени вредит нашим проверкам.
Если нам важна только часть с днем, то зачем мириться с этими дополнительными значениями? дополнительные часы или минуты не должны менять логику бизнес-правил, если важен только переход в следующий день.
Просто выкинем лишний хлам подальше.
// Упрощаем дату до дня, отбрасывая остальное
$estimatedDeliveryDate = day(new DateTimeImmutable('2017-06-21'));
$now = day(new DateTimeImmutable('now'));
// Теперь сравнение стало проще
if ($now > $estimatedDeliveryDate) {
echo 'Package is late!';
} else {
echo 'Package is on the way.';
}
// Неуклюжий, но эффективный способ уменьшения точности
// Как мы видели, PHP подставит ноль для неуказанных значений
function day(DateTimeImmutable $date) {
return DateTimeImmutable::createFromFormat(
'Y-m-d',
$date->format('Y-m-d')
);
}
Это упрощает сравнение или расчет того что есть в решении 1, с точностью из решения 2. Но… это самый уродливый вариант, плюс, при такой реализации очень легко забыть вызвать day()
.
Такой код легко превратить в абстракцию. Теперь, когда прояснилась ситуация с предметной областью, ясно: когда мы говорим о сроках доставки, мы говорим о дне, не о времени. Обе эти вещи делают код хорошим кандидатом для инкапсуляции внутрь типа.
Инкапсуляция
Другими словами, давайте сделаем этот value-объект.
$estimatedDeliveryDate = EstimatedDeliveryDate::fromString('2017-06-21');
$today = EstimatedDeliveryDate::today();
if ($estimatedDeliveryDate->wasBefore($today)) {
echo 'Package is late!';
} else {
echo 'Package is on the way.';
}
Посмотрите как читается код. Теперь реализуем value-объект:
class EstimatedDeliveryDate
{
private $day;
private function __construct(DateTimeInterface $date)
{
$this->day = DateTimeImmutable::createFromFormat(
'Y-m-d',
$date->format('Y-m-d')
);
}
public static function fromString(string $date): self
{
// Тут можно валидировать YYYY-MM-DD формат и т.д.
return new static(new DateTimeImmutable($date));
}
public static function today(): self
{
return new static(new DateTimeImmutable('now'));
}
public function wasBefore(EstimatedDeliveryDate $otherDate): bool
{
return $this->day < $otherDate->day;
}
}
Имея в наличии класс, автоматически получаем полезное ограничение: сравнение EstimatedDeliveryDate
идет только с другим EstimatedDeliveryDate
, теперь точность будет сходиться.
Обработка с необходимой точностью расположена в одном месте. Консьюмерский код никак не касается этой работы.
Это легко тестировать.
И у вас есть отличное место для централизованного хранения обработки часовых поясов (не обсуждается в статье, но важно).
Один совет: Я использовал метод today()
чтобы показать, что есть возможность создавать несколько конструкторов. На практике я бы рекомендовал добавить класс системных часов и получать экземпляры now
оттуда. Так намного легче писать юнит-тесты. "Реальная" версия будет выглядеть так:
$today = EstimatedDeliveryDate::fromDateTime($this->clock->now());
Точность через неточность
Важно понимать, нам удалось снять несколько различных видов ошибок за счет уменьшения точности DateTime
, обработкой которого мы занимались. Если бы мы это не сделали, нам пришлось бы обрабатывать все проблемные стороны и, скорее всего, в каком-нибудь случае все пошло бы не так.
Снижение качества данных для получения правильного результата может показаться нелогичным, но на самом деле это более реалистичный взгляд на систему, которую мы пытаемся моделировать. Наши компьютеры могут работать за пикосекунды, но наш бизнес (скорее всего) нет. Плюс, компьютер, вероятно, лжет.
Возможно, как разработчики, мы чувствуем. что лучше быть более гибким и перспективным, сохраняя всю возможную информацию. В конце концов, кто вы такой, чтобы решать какую информацию выбрасывать? Истина заключается в том, что информация потенциально может стоить денег в будущем, в настоящем же она наверняка стоит расходов на ее содержание до наступления возможного будущего. Это не только стоимость места на жестком диске, это стоимость проблем, людей, времени, и, в случае ошибки, репутации. Иногда работала с данными в наиболее полной их форме будет оправдывать себя, но иногда слепое сохранение всего, что вы можете, только потому что можете, не стоит того.
Чтобы было понятней: я не рекомендую вам просто бездумно удалять доступную информацию о времени.
Я рекомендую: четко выбрать точность для ваших отметок времени вместе с экспертами в предметной области. Если вы можете получить большую точность, чем ожидаете — это может повлечь за собой ошибки и дополнительную сложность. Если вы получаете меньше необходимой точности — это также вызовет проблемы и несрабатывание бизнес-логики. Важно то, что мы определяем ожидаемый и необходимый уровень точности.
Также, выбирайте точность отдельно для каждого случая использования. Округление обычно реализуется внутри value-объекта, а не на уровне системных часов. Кое-где нужна точность до наносекунд, но кому-то может понадобиться только год. Правильное получение точности сделает ваш код более ясным.
Оно везде
Стоит отметить, что мы говорили только об одном конкретном типе ошибок: несовпадение требуемой точности для проверок. Но этот совет применим к гораздо более широкому кругу ошибок. Не буду вдаваться во все из них, но все же хочу отметить мою любимую, "остаточная" погрешность.
// Предположим, что сегодня 21 июня, таким образом перменная будет иметь значение 28 июня
$oneWeekFromNow = new DateTimeImmutable('+7 days');
// Также 28 июня возьмем из БД
$explicitDate = new DateTimeImmutable('2017-06-28');
// Сравним, эти же одинаковые даты?
var_dump($oneWeekFromNow == $explicitDate);
Нет, они неодинаковы, потому что $oneWeekFromNow
также хранит текущее время, в то время как $explicitDate
имеет значение 00:00:00
. Восхитительно.
Приведенные выше примеры говорили о точности, в первую очередь, при сравнении времени против даты, но моделирование точности распространяется на любую единицу времени. Представьте, какому количеству приложений для планирования требуется только время, а какому количеству финансовых приложений требуется поддержка точности по кварталам года.
Как только начинаешь смотреть на проблему, понимаешь, сколько ошибок со временем может быть объяснено неопределенной точностью. Они могут выглядеть как некорректные проверки или плохо спроектированные логические рамки, но при погружении в это, вы начнете видеть как вырисовывается картина.
Мой опыт показывает, что этот класс ошибок часто пропускается при тестировании. Объекты с системными часами не являются привычными вещами (пока), поэтому тестирование кода, использующего текущее время немного сложнее. Данные для тестов часто не предоставляются в таком формате, который получается в системе, что приводит к появлению ошибок.
И это не проблема конкретной DateTime
-библиотеки в PHP. Когда я писал об этом на прошлой недели, Anthony Ferrara упомянул, что точность времени в Ruby варьируется в зависимости от операционной системы, но библиотека для работы БД имеет фиксированный уровень. Весело такое отлаживать
Работать со временем сложно. Сравнивать время — вдвойне.
Выбор уровня точности
Говоря, что выбор уровня точности важен, мы не говорили о том как выбрать правильный. Как правило, нужно иметь достаточную точность временных отметок для технических нужд и одновременно задавать уровень точности для объектов домена.
Для логов, отметках о событиях, метрик выбирайте детализацию по желанию. Такие данные в первую очередь необходимы техническому персоналу, для них дополнительная точность часто необходима при отладке. Также, вероятно, высокая точность понадобится для системных или последовательных данных.
В случае бизнес-задач поговорите с экспертами в предметной области о необходимой точности информации. Они могут помочь сбалансировать между тем, что используются сейчас и тем, что понадобится в будущем. Бизнес-логика часто бывает областью, где приходится оперировать заемными знаниями, поэтому уменьшение сложности будет умным ходом. Помните, вы не строите модель прямо-как-в-реальной-жизни, вы строите полезную модель.
В жизни это приводит к необходимости наличия различной степени точности, даже в пределах одного класса. Рассмотрим этот класс приложения:
class OrderShipped
{
// Объект из бизнес-логики (доставка), требуется точность до дня
private $estimatedDeliveryDate;
// Объект из бизнес-логики (доставка), требуется точность до секунды
private $shippedAt;
// Event sourcing объект, требуется точность до микросекунд
private $eventRecordedAt;
}
Если наличие нескольких уровней точности кажутся странными, напомню, что эти отметки времени используются по разному. Даже $shippedAt
и $eventRecordedAt
указывают на одно и тоже "время", но относятся к совершенно разным частям кода.
Вам возможно попадется бизнес, который работает с блоками времени, которые вы можете не ожидать: кварталы, финансовые календари, смены, делением на утро, день или вечер. Много интересного опыта получится при работе с этими дополнительными единицами.
Изменение требований
Еще одна хорошая часть обсуждений: если бизнес-правила изменятся в будущем, потребуется больше точности, чем изначально договаривались, то это будет совместным решением и станет ясно что делать с уже накопленными данными. В идеале, это избавит бизнес от технических проблем.
Это несложно: "Изначально требовалась только дата регистрации, но теперь нужно время, чтобы увидеть регистрации до времени закрытия офиса". Простым способом решения будет установить время до начала следующего рабочего дня, возможно, небольшое количество учетных записей будет некорректным, но для большинства приемлемо. Или просто нули. Или в компании есть дополнительные бизнес-правила, когда после 18-00 дата окончания подписки выставляется в tomorrow +1 year
вместо today +1 year
. Обсудите с ними это. Люди активнее и лояльнее к изменениям, если их включить в обсуждение с самого начала.
В более сложных случаях обратитесь к восстановлению данных на основе других данных в системе. Возможно, время регистрации хранится в логах или метриках. В некоторых случаях сделать это будет просто невозможно и вам придется создавать новую логику для переноса легаси-случаев. Но ведь невозможно спланировать все, и, скорее всего, не знаете что поменяется. Это жизнь.
Мое заключение о точности времени: используйте что нужно, не больше.
Приложение: Идеальное Решение
Двигаясь вперед, я чувствую, что есть практическая польза от выбора фиксированной точности и использования классов. Моя идеальная PHP библиотека для работы со временем выглядела бы так: набор абстрактных классов обозначающих точность, от которых я наследуюсь в моих value-объектах и использую при сравнении.
class ExpectedDeliveryDate extends PointPreciseToDate
{
}
class OrderShippedAt extends PointPreciseToMinute
{
}
class EventGenerationTime extends PointPreciseToMicrosecond
{
}
Перемещая вопрос точности в класс, мы берем ответственность за решение. Можно ограничить методы, такие как setTime()
до необходимой точности, округлять DateInterval
, делать все, что имеет смысл при работе со временем. Инкапсулируем большинство методов value-объектов и наружу выставим только необходимые для предметной области. Кроме того, таким образом мы поощрим людей создавать сами value-объекты. Очень. Много. Value-объектов. Даааааа.
Бонусом будет, если библиотека дает возможность легко определять пользовательские единицы времени.
Кто-нибудь сделал такую? Неужели ни у кого нет времени?