Точность через неточность: Улучшаем Time-объекты

http://rosstuck.com/precision-through-imprecision-improving-time-objects
  • Перевод
При создании 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-объектов. Даааааа.


Бонусом будет, если библиотека дает возможность легко определять пользовательские единицы времени.


Кто-нибудь сделал такую? Неужели ни у кого нет времени?

Поделиться публикацией
Похожие публикации
Ммм, длинные выходные!
Самое время просмотреть заказы на Фрилансим.
Мне повезёт!
Реклама
Комментарии 25
  • +2
    Если я правильно понимаю, то изначальная проблема лишь в том, что перед сравнением объекты небыли приведены к одному формату. И для решения этой проблемы надо дополнительно писать библиотеку? По мне — это немного бредовая идея
    • +1
      О какой библиотеке идёт речь?
      Автор предлагает написать простой ValueObject, что в целом очень удобно и даёт дополнительную проверку типов.
      • 0

        То есть в пхп нет простого способа отбросить часы, минуты и секунды?
        И приходится писать свою обертку каждый раз, когда понадобится работать с датами?

    • 0

      Что не так со вторым решением? Какие еще операции сравнения нужны и почему они не будут работать?

      • 0

        Похожую проблему я поднимал в своей статье, где привёл в пример библиотеку $jin.time, которая хранит время не "с некоторой точностью", а "покомпонентно". Например, "Полдень в любом часовом поясе любого дня 2017 года" — это в соответствии с ISO8601 "2017T12:00:00". Если сегодня "1 апреля 2017 года" (2017-04-01), то приведение "2017T12:00:00" к числу даст штамп времени для "полудня 1 апреля 2017 года".


        В вашем примере, я бы сделал так:


        if( now.toString( 'YYYY-MM-DD' ) > estimatedDeliveryDate.toString( 'YYYY-MM-DD' ) ) {
        ...
        }

        Что в точности соответствовало бы поставленной задаче. Особенность iso8601 в том, что временные отметки с одинаковым набором временных компонент (и в одном часовом поясе) можно сравнивать как строки.

        • 0
          Не особо силен в PHP — а как учитывается разный часовой пояс тогда?
          • 0

            Это JS :-) В общем случае, часовой пояс учитывается приведением к одному часовому поясу. Но в данном он будет одинаковый, так как будет просто взят текущий.

          • +1
            Что-то меня гложут сомнения, что запись 2017T12:00:00 будет корректной — насколько я помню, нельзя просто так брать и отбрасывать значения из средины выражения.
            • 0
              «От даты и времени можно отбросить любое число полей, но менее значимые поля обязательно должны быть отброшены раньше более значимых.»
              • 0

                Вы правы: "the date component shall not be represented with reduced accuracy".
                Но это логичное и удобное расширение ISO8601.

            • +4

              Использую карбон — он и с тестированием отлично помогает и со сравнениями, и с таймзонами


               public function testSameDates()
                  {
                      Carbon::setTestNow('2017-08-14 23:59:59.59');
                      $d0 = Carbon::now();
                      $d1 = Carbon::today();
                      $d2 = Carbon::createFromFormat('Y-m-d', '2017-08-14');
                      expect_that($d1->isSameDay($d2));
                      expect_that($d0->isSameDay($d2));
                      expect_that($d0->isSameDay($d1));
                      expect_that($d0->isSameMonth($d1));
                      expect_that($d0->isSameYear($d1));
                      expect_that($d0->toDateString() === $d1->toDateString());
                      expect_that($d0->toDateString() === $d2->toDateString());
                      expect_that($d1->toDateString() === $d2->toDateString());
                  }
              • +1
                не понимаю, почему нельзя сравнить даты с любой необходимой точностью

                $estimatedDeliveryDate = new DateTimeImmutable('2017-08-16');
                // представим, что сегодня ТАКЖЕ 2017-08-16
                $now = new DateTimeImmutable('now');
                
                /** @link http://php.net/manual/en/dateinterval.format.php */
                $diffInFormat = $estimatedDeliveryDate->diff($now)->format('necessary_format');
                
                • +2
                  Моделируя работу с числами считается хорошим тоном указывать точность. Неважно о чем идет речь — о деньгах, размере или весе; округляйте до заданного десятичного знака.

                  На будущее: Деньги никогда и не при каких случаях не хранятся в float/double, только int/long.
                  • 0

                    Чаще удобнее как decimal или просто строки — точностью можно управлять динамически, а если нет деления, только сложение, вычитание и умножение, то точность может быть неограниченной (в разумных пределах, конечно).

                    • –1
                      Я написал как правильно, а не как удобнее. Удобнее, например, фигурные скобки не писать в if/else, а правильно — тыкать палочкой (можно и чем потяжелее) в тех, которые их опускают.
                      • 0

                        Нет единого "правильно" в разработке. У int/long свои плюсы и минусы, у decimal свои, у string свои, и даже у float/double есть плюсы.

                      • 0

                        Удобнее работать с деньгами как с valueObject. А хранить уже не особо принципиально как. Единственно что хранение в виде дробей может быть сопряжено с потенциальными проблемами. Особенно если динамически управлять точностью.

                        • 0

                          Удобнее, да. Но хранение — важная часть реализации. И хранение в виде целых тоже может быть сопряжено с проблемами.

                      • 0

                        Тут соглашусь. Округлять деньги — гиблое дело.

                        • +1

                          Их очень часто приходится округлять в реальном мире. И проблемы в целом даже не в точности, а в том, что иногда деньги надо делить, а у них есть минимальная неделимая единица — копейка, цент и т. п… Как не крути, но если надо обсчитать реальные денежные движения, то разделить 1 копейку/цент попалам никак не получится.

                      • 0

                        Это ещё проблема часовых поясов не затронута. Даже с точностью до дня, месяца или года могут быть ситуации, что в одном поясе сгод наступил, а в другом нет. Первый вопрос к эскпертам предметной области как только упомянули даты — "часовой пояс какой"?

                        • 0
                          del
                          • 0
                            Оффтоп
                            Оказывается, если нажать «редактировать» и в форме, после редактирования, нажать большую кнопку «Отправить» — комментарий не редактируется, а создаётся новый.

                            Простите пожалуйста за спам выше в этой ветке. А удалить, как известно, комментарий невозможно.
                            • 0

                              Всё зависит от бизнес-логики, а точнее какими временными величинами она готова оперировать. Помещать, скажем DateTime, в VO с описанием точности в названии, а затем сравнивать эти объекты не самый лучший вариант. Если вы уже и работаете со временем, то лучшей практикой является вычисление временных интервалов для DateTime объектов, например, через date_diff(). Делается на уровне бизнес-логики и сравнивается разница в неделях, днях, часах и т. д. Здесь вопрос с точностью сам собой отпадает.

                              • +1

                                Разница может очень криво себя показывать. Например, разница между 2017-08-17T20:59:59 и 2017-07-17T21:00:00 покажет секунду, а для бизнеса это сутки, а то и неделя/месяц/год/… Суть поста в целом — сравнение дат, вычисление разниц и т. п. инкапсулируем в VO, там реализуется бизнес-логика работы с датовременем. Можно вообще абстрагироваться от DateTimeInterface, а можно его реализовывать с учётом бизнес-правил.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.