Webrium
Подписаться

Время в приложении: где оно ломается

Под одним словом прячутся момент, местное время и намерение на будущее. Путаница между ними двигает встречи и ломает суточные отчёты.

Момент, местное время и намерение на будущее хранятся по-разномумомент внутри

Время выглядит простым типом данных, пока не приходит отчёт, где события за сутки не сходятся, или пользователь не жалуется, что напоминание пришло на час раньше. Тогда выясняется, что «время» — это как минимум три разные вещи, и путать их дорого.

Три вещи под одним словом

Момент. Точка на мировой оси: когда именно произошло событие. Не зависит от того, где находится наблюдатель. Момент создания заказа один и тот же для всех.

Местное время. Показания часов в конкретном месте: «девять утра» без уточнения, где именно. Само по себе не определяет момент — девять утра в разных местах наступает в разное время.

Намерение на будущее. «Встреча в понедельник в десять» — это не момент и не просто местное время, а обещание, которое должно выполниться по часам участника, что бы ни случилось с правилами перевода часов.

Отсюда главное правило: хранить надо то, что означает событие, а не то, что удобно показать. Момент хранится как момент. Намерение хранится как местное время плюс зона. Смешение этих двух и есть источник почти всех ошибок.

Тупик: перевести всё в одну зону и забыть

Первое, что пробуют, — хранить всё в единой зоне и переводить при показе. Для моментов это правильно и достаточно.

Для будущих событий не помогает. Встреча, назначенная на десять утра и сохранённая как момент, при изменении правил перевода часов в стране участника сдвинется на час. Формально момент сохранён верно; фактически обещание нарушено — человек придёт не тогда, когда договаривались.

Правила перевода часов меняются чаще, чем принято думать, и решение об этом принимается за месяцы, а не за годы. Программа, сохранившая будущее намерение как момент, окажется неправа задним числом.

Второй частый ход — хранить местное время без зоны, потому что «у нас все в одном городе». Работает, пока это так. Ломается не постепенно, а сразу: в день, когда появляется первый пользователь в другом месте, и чинить приходится уже накопленные данные.

Где ломается чаще всего

Границы суток. «События за сегодня» — это интервал, зависящий от зоны. Отчёт, посчитанный по одной зоне, и отчёт по другой дадут разные числа на одних данных, и оба будут верны. Спор о том, какой правильный, решается не кодом, а договорённостью, которую надо записать.

Сутки не всегда длятся сутки. В дни перевода часов они короче или длиннее. Код, прибавляющий фиксированный интервал вместо календарного дня, в эти дни ошибается, а тесты, написанные на обычной неделе, этого не показывают.

Несуществующее и повторяющееся время. При переводе вперёд час пропадает: назначенного на него события не существует. При переводе назад час повторяется дважды, и «половина третьего» означает два разных момента. Обе ситуации возникают дважды в год и обе роняют код, который их не ждёт.

Разные единицы. Секунды и миллисекунды выглядят одинаково, различаясь тремя разрядами. Смешение даёт даты в далёком прошлом или будущем, и это самая быстрая в обнаружении ошибка из перечисленных — потому что результат абсурден.

Хранение зоны как смещения. Смещение — это не зона. Смещение действует сейчас и меняется при переводе часов; зона — это правило, по которому смещение вычисляется. Сохранив смещение, вы сохранили следствие вместо причины.

Название зоны, которое устарело. Правила и сами названия обновляются, а база правил лежит в системе и обновляется вместе с ней. Сервер, который давно не обновляли, считает по старым правилам и расходится с телефоном пользователя. Это редкая, но крайне неприятная ошибка: код верен, данные верны, расходится справочник.

Время, пришедшее от клиента. Часы на устройстве пользователя могут быть сбиты, и метка, присланная браузером, — не факт, а мнение. Для порядка событий это опасно: сортировка по клиентскому времени даёт ленту, где ответ появляется раньше вопроса.

Что делать

Момент хранить в единой шкале и переводить при показе. Это тот случай, где привычный совет верен полностью.

Будущее намерение хранить местным временем и зоной. Отдельными полями, а не одним. Момент вычислять при необходимости, а не при сохранении.

Зону выбирать явно. Не подставлять зону сервера и не угадывать по браузеру молча. Зона — это данные пользователя, и она должна быть видна ему в настройках.

Сравнивать моменты, а не строки. Текстовое представление сортируется правильно только в одном формате и только при одинаковой зоне; в остальных случаях сортировка по строке даёт неверный порядок.

Границы отчётов определять явно. В каком часовом поясе считаются сутки — договорённость уровня продукта, и её место в документации, а не в чьей-то голове.

Длительность хранить отдельно от момента. «Через сутки» и «плюс двадцать четыре часа» — разные вещи в дни перевода часов. Если срок задан в календарных единицах, считать его надо календарно, а не прибавлением интервала.

Не изобретать разбор форматов. Дата, пришедшая строкой в неизвестном виде, разбирается библиотекой, а не выражением. Форматов записи существует много, различаются они мелочами, и самодельный разбор угадывает неверно ровно в тех случаях, которых не было в примерах.

Как это проверять

Тесты на времени, которое сейчас, проверяют только сегодняшний день. Нужны фиксированные даты: день перевода часов вперёд, день перевода назад, конец месяца, конец года, високосный день.

Отдельно стоит проверить поведение при зоне, отличной от серверной. Большая часть ошибок этого класса не воспроизводится у разработчика именно потому, что у него совпадают зона машины и зона тестовых данных.

И полезно один раз прогнать данные через смену зоны сервера. Если результаты меняются там, где не должны, — где-то в коде зона берётся неявно.

Позиция редакции

Мы считаем, что различать момент и намерение надо на уровне хранения, а не при показе, и что будущие события нельзя сохранять как момент. Практический вывод: посмотрите, как в вашей базе лежат напоминания и встречи. Если одним полем с меткой момента — вы уже согласились сдвинуть их при следующем изменении правил.

Мы неправы, если ваша система живёт в одном месте, не назначает будущих событий и не строит суточных отчётов: там достаточно одной шкалы, и вся эта осторожность — лишняя работа.

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Изменение схемы пробивает границу контракта и зажигает сигнал, останавливающий деплой до того, как сломается отчётграница контрактаизменение схемы
Усиление

Контракты данных: как перестать чинить отчёты

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

Четыре показателя-поля трейса: ID запроса, шаг вызова и код ошибки в норме, а тело запроса уходит за шкалу — его место в архиве, а не в трейсетело запроса
Практика

Что писать в трассировку, а что нет

Руками по трассировке: какие поля писать в спанах, что выносить наружу и как проектировать её под дежурного, а не под отчёт.

Контракт существует, только если записан отдельно от кода обеих сторонполе вне схемы
Углубление

JSON как контракт: где он подводит

Глубже про сам формат: почему JSON без отдельного описания не является контрактом и что записать рядом с кодом, чтобы обмен пережил расхождения.

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть