О дате
2019-05-18
За последние две недели мне пришлось дважды спорить по одному и тому же поводу. Заказчика я (почти) убедил после трёх дней переписки. Коллег пока не всех убедил. Поэтому пишу этот пост.
Начнём с теории.
С астрономической точки зрения у нас есть лишь два видимых периода. Во-первых, день или сутки. То есть период обращения Земли вокруг своей оси. Во-вторых, год. То есть период обращения Земли вокруг Солнца. Первый относится со вторым примерно как 1 к 365.242199. Но это не точно, потому что орбита этих чёртовых планет и звёзд постоянно немного меняется.
Ну ладно. В-третьих, у нас ещё есть период обращения Луны вокруг Земли и смена фаз Луны. Отсюда получается месяц.
Неделя — это выдумка, чтобы иметь хоть какую-то гарантированную периодичность появления выходных.
Человечество изобрело множество способов подсчёта дней. В 99.9% процентах случаев в программировании и по жизни вам придётся иметь дело с григорианским календарём. По этому календарю сегодня у нас 18 мая 2019 года. По этому календарю у нас в году выделяют двенадцать месяцев: с января по декабрь. По этому календарю в феврале случается то 28, то 29 дней, по правилам, которые никто не может ни запомнить, ни запрограммировать.
Но также на этой планете в ходу и другие календари. Например, исламский календарь. По этому календарю сегодня у нас 13 число месяца Рамадан, 1440 года с даты переселения пророка Мухаммеда из Мекки в Медину. Этот календарь — лунный, поэтому новый год начинается когда попало.
А до 1918 года в России использовался юлианский календарь. У нас его называют «старым стилем».
А как считать марсианские солы, я понятия не имею.
Календарей много. Поэтому ограничимся григорианским.
Часы, минуты и секунды придумали сильно позднее календарей. Вместе с часами (которое устройство изменения времени). Потому что жизнь ускорялась. И понадобилось договариваться о чём-то более конкретном, чем встреча «сразу после заката».
Далее воспользуемся терминологией
лучшей библиотеки по работе со временем,
что я знаю:
java.time
.
LocalDate
— это просто дата.
Это год, месяц года и день месяца
(по григорианскому календарю).
Не больше,
но и не меньше.
Записывая в ISO 8601,
сегодня "2019-05-18".
Year + Month + Day → LocalDate
Про дату нельзя сказать, когда именно она началась. Это просто какой-то день. День рождения. Дата подписания договора. Дата поступления денег на счёт.
Иногда можно выяснить, когда именно в этот день произошло данное событие. Иногда нельзя. Иногда нас просто не интересует конкретное время.
LocalTime
— это просто время суток.
Часы и минуты.
Плюс секунды.
Плюс ещё миллисекунды и наносекунды,
если нам нужна такая точность.
По ISO 8601 в данный момент может быть,
например, "22:53".
Или "22:53:49".
Или "22:53:49.678".
Hour + Minute → LocalTime
Hour + Minute + Second → LocalTime
Hour + Minute + Second + Millisecond → LocalTime
Hour + Minute + Second + Nanosecond → LocalTime
Про время нельзя сказать, какой именно это момент времени на временной оси. Это просто какой-то момент в сутках. Время начала рабочей смены в рабочий день. Время закрытия магазина, каждый день, когда он работает.
Если соединить LocalDate
и LocalTime
,
то получится LocalDateTime
.
То есть год, месяц, день, час, минута и секунда.
Где-то.
По ISO 8601 что-то вроде "2019-05-18T22:53:49".
Year + Month + Day + Hour + Minute + Second → LocalDateTime
LocalDate + LocalTime → LocalDateTime
LocalDateTime
по-прежнему не указывает
на конкретный момент времени.
10:00 утра 18 мая 2019 года во Владивостоке
наступает всё же раньше,
чем 10:00 утра 18 мая 2019 года в Москве.
Разница — в часовом поясе.
Его ещё называют timezone.
Таймзону можно указать двумя способами.
ZoneOffset
— это фиксированное смещение
в часах и минутах
(если надо, и в секундах)
от времени нулевого меридина,
что в Гринвиче.
Например,
в Омске,
по сравнению с Гринвичем,
если выражать снова в ISO 8601:
"+06:00".
А в центральной Европе
зимой будет "+01:00",
а летом "+02:00".
Время по Гринвичу называют ещё GMT (Greenwich Mean Time) или UTC (английское CUT = Coordinated Universal Time или французское TUC = Temps Universel Coordonné → UTC). GMT — астрономическое понятие. UTC считают атомными часами. В типичных расчётах на компьютере — разницы нет. Но нынче правильнее говорить UTC, ибо атомные часы рулят.
Соответственно,
ZoneOffset
,
бывает,
обозначают как "GMT+6", или "UTC+6", или "UT+6".
Отрицательные смещения,
соответственно,
в западном полушарии.
Такие обозначения вполне понятны человекам.
Но это не есть ISO 8601.
Само время по Гринвичу, то есть "GMT", или "UTC", или "+00:00", в ISO 8601 обозначают как "Z".
ZoneId
— это часовой пояс в конкретной местности.
Это может быть и фиксированное смещение,
совпадающее с ZoneOffset
.
Но чаще это именно обозначение в стиле "{area}/{city}".
Например, "Asia/Omsk" или "America/New_York".
Полный список таких обозначений берётся
из tz database.
Прелесть tz database в том,
что здесь хранится,
как именно менялись таймзоны (то есть смещения от UTC)
и правила перехода на летнее и зимнее время
в исторической перспективе.
"Asia/Omsk" помнит,
что между 2011 и 2014 тут было UTC+7,
а ранее были переходы на летнее время.
Только используя ZoneId
из tz database
вы всегда получите правильное время в данной местности,
учитывая все эти daylight saving time.
В ISO 8601 указание имени таймзоны из tz database
не предусмотрено.
Но java.time
умеет писать в квадратных скобках:
"[Asia/Omsk]".
ZonedDateTime
— год, месяц, день, час, минута, секунда
в конкретной местности
или по конкретному смещению от UTC.
Дата, время и таймзона.
LocalDate + LocalTime + ZoneOffset → ZonedDateTime
LocalDate + LocalTime + ZoneId → ZonedDateTime
LocalDateTime + ZoneOffset → ZonedDateTime
LocalDateTime + ZoneId → ZonedDateTime
ZonedDateTime
указывает уже на конкретный момент времени,
да ещё и в конкретной местности.
Уже можно сравнить,
на сколько часов раньше
завтра открываются магазины во Владивостоке,
чем в Москве.
Есть лишь небольшая неоднозначность при переходе на зимнее время. Стрелки часов переводятся на час назад. Одни и те же показания часов повторяются дважды. А таймзона вроде одна. Отсюда и неоднозначность. Потому и нет таймзон из tz database в ISO 8601. Если указывать смещение от UTC, то неоднозначности не будет. Хоть показания часов повторяются, но меняется смещение от UTC.
Вот как выглядит переход на зимнее время в Европе: "02:00+02" → "02:59+02" → "02:00+01" → "02:59+01" → "03:00+01".
В ISO 8601 ZonedDateTime
будет выглядеть
как "2019-05-18T22:53:49+06:00"
или даже (с дополнениями от java.time
)
как "2019-05-18T22:53:49+06:00[Asia/Omsk]".
А теперь упрощаем всё назад. Умные программисты давно уже придумали, что для представления точки на оси времени вовсе не нужно хранить год, месяц, день, час, минуту, секунду, да ещё и таймзону. Достаточно одного числа. Числа секунд, прошедших от «начала эпохи». За начало эпохи в Unix приняли полночь 1 января 1970 года, UTC. И секунды от 1 января 1970 года называют unix timestamp.
Позднее, для большей точности, стали считать не секунды, а миллисекунды. Различаются они, соответственно, в тысячу раз. Это тоже timestamp. Но, строго говоря, уже не unix timestamp.
Таймстамп в java.time
представлен классом Instant
.
Если его привести к строке,
то получится ISO 8601 в UTC: "2019-05-18T16:53:49Z".
Epoch Seconds → Instant
Epoch Milliseconds → Instant
Instant
— это конкретная точка на временной оси.
Это конкретный момент времени.
Но неизвестно,
где именно этот момент приключился,
и какое время показывали часы и календари
непосредственных наблюдателей этого события.
«Когда в столице пятнадцать часов,
в Петропавловске-Камчатском полночь».
Собственно, само понятие временной оси и глобального времени возникло как эффект глобализации. Раньше время было только локальным. Солнце взошло — день начался. Солнце над головой — полдень. Солнце закатилось — день закончился.
А с развитием мореплавания оказалось, что время везде разное. И более того, географическую долготу можно определять по разнице показаний точного хронометра, показывающего время в порту отправления, и локального наблюдаемого астрономического времени.
Но только с появлением самолётов разницу в часовых поясах каждый может непосредственно ощутить на собственной шкуре. Привет, jet lag.
Для полноты картины стоит ещё упомянуть Period
и Duration
.
Это не какие-то моменты времени или показания часов.
Это длительности и продолжительности.
Они нужны для перемещения по оси времени.
Переход на сутки вперёд — это не всегда переход на 24 часа или 86400 секунд. Когда происходит переход на летнее время, и часы переводят на час вперёд, в сутках оказывается 23 часа. Когда происходит переход на зимнее время, и часы переводят на час назад, в сутках оказывается 25 часов.
Переход на год вперёд — это не всегда переход на 365 дней. Иногда и на 366.
Переход на месяц вперёд — ну вы поняли, да. Это вообще очень тонкая материя. И куда вы перейдёте из 31 числа предыдущего месяца сильно зависит от конкретной реализации.
Собственно,
для учёта всех этих нюансов,
и нужны Duration
и Period
.
А теперь переходим к практике. К тому самому вопросу, вызвавшему споры.
Самый универсальный способ представления времени
в программных системах
— это Instant
.
Можно представить это в виде строки ISO 8601:
"2019-05-18T16:53:49Z".
Можно в виде unix timestamp,
то есть числа секунд от начала эпохи:
1558198429.
Или миллисекунд: 1558198429000.
Во всех базах данных есть соответствующие типы
для хранения метки времени.
Метка времени прекрасно подходит для обозначения конкретных моментов времени. Момент возникновения события. Момент передачи сообщения. Начало и конец конкретной рабочей смены. Начало и конец выбранного временного интервала, для которого нужно осуществить выборку.
Но метка — это лишь метка. Пользователи плохо воспринимают число секунд. Момент времени им нужно показывать в годах, месяцах, днях, часах, минутах и секундах.
При этом сами пользователи могут находиться в разных углах нашей необъятной планеты. В разных часовых поясах. У них разное время. И, чаще всего, год, месяц, день, час, минуту, секунду конкретного события нужно показывать в локальном времени пользователя.
Таймстампа недостаточно, чтобы отобразить время пользователю. Нужна ещё таймзона пользователя.
Instant + ZoneId → ZonedDateTime
Однако, в этом мире встречаются сущности и с датой. Просто с датой. Год-месяц-день.
Дата рождения. Дата смерти. Дата подписания договора. Дата заключения соглашения. Дата выхода в прокат фильма «Детектив Пикачу». Дата, на которую запланированы работы. Дата, в которую были произведены работы. Дата, на которую Центробанк определил курс доллара.
Дата — это не конкретный момент времени. Дата — это не таймстамп.
Да, можно поднять записи и выяснить более-менее точную минуту вашего рождения. Но родственники из Владивостока всё равно будут звонить с поздравлениями раньше, чем родственники из Москвы. Просто потому, что проснутся раньше, и день для них начнётся раньше. День вашего рождения.
Да, можно записать на договоре и время подписания. Но это, кроме экзотических случаев, никому не интересно. Тогда и город, где договор был подписан, будет важен. Тайзона ведь.
С датой начала проката фильма интереснее. Это таки дата. Самые ушлые кинотеатры начитают прокат ночью в "00:05", по сути в конце предыдущего рабочего дня. И да, кинотеатры из Владивостока начинают показ первыми.
Нельзя просто так взять и представить дату в виде таймстампа. Таймстамп — это конкретная точка на временной оси. Дата — это сутки, чаще всего 24 часа. Одновременно сразу во всех возможных часовых поясах. Весьма абстрактное понятие.
Можно выработать соглашение. Скажем, дату будем выражать таймстампом в "00:00:00" UTC в начале этого дня. Или в полдень этого дня. Или в "08:00" этого дня в UTC+2, потому что именно в это время начинается смена.
Но любое соглашение работает только пока все ему следуют. Все компоненты системы должны помнить, что вот этот вот таймстамп — это на самом деле не точка на временной оси, а проекция абстрактной даты на эту конкретную точку согласно принятому соглашению. И это соглашение не должно включать переменных величин, вроде таймзоны конкретного пользователя.
Не проще ли хранить и передавать дату как дату? Пусть хотя бы в виде ISO 8601 строки: "2019-05-18".
Возникают проблемы совмещения дат,
то есть LocalDate
,
с таймстампами,
то есть Instant
.
Допустим,
пользователь у нас фильтрует данные по некоторому временному интервалу.
От одного Instant
до другого Instant
.
Выбрать события,
чьё время задано тоже в виде Instant
,
— просто.
Все Instant
упорядочены на временной оси,
достаточно отобрать те,
что лежат на выбранном интервале.
Выбрать продолжающиеся события,
чьё начало и конец заданы в виде Instant
,
— тоже просто.
У нас есть отрезки на временной оси.
Нужно выбрать те отрезки,
которые пересекаются с выбранным интервалом.
А вот как выбрать события, для которых указаны лишь даты?
Очевидно, первым шагом нужно понять, на какие даты приходится начало и конец выбранного интервала в таймзоне (и календаре!) пользователя. Какие даты, с его точки зрения, он выбрал?
Instant + ZoneId → ZonedDateTime → LocalDate
Далее нужно отобрать те события, которые попадают на выбранные даты. Тут тоже могут быть нюансы. Например, если выбран интервал с "2019-05-18T00:00:00+06" по "2019-05-19T00:00:00+06", то, пожалуй, нужно выбрать только дату "2019-05-18", но не выбирать дату "2019-05-19", поскольку этот день фактически не попадает в выбранный интервал.
На самом деле, если вы выбираете интервалы размером в несколько суток, то и выражать такой выбор нужно в виде дат. С "2019-05-13" по "2019-05-19" включительно, вот вам вся неделя, с понедельника. Если вы выбираете интервалы размером в месяцы, записывайте их как месяцы, в ISO 8601 возможно и такое: c "2019-05" по "2019-05" включительно. Записывайте то, что видит пользователь, безо всяких воображаемых полуночей по Гринвичу.
Если фильтр задан в датах, а выбрать нужно таймстампы, вы сталкиваетесь с обратной проблемой. Нужно даты превратить в таймстампы.
Вспоминаем, что сутки обычно начинаются в "00:00:00", а заканчиваются в "23:59:59". Впрочем, операционные дни в тех же банках запросто могут начинаться в любое другое время. Здесь у вас должны сработать не абстрактные соглашения, а конкретные строгие бизнес-правила.
Вспоминаем, в какой таймзоне работает пользователь. Это может быть его локальная таймзона. Но тот же бизнес день может всегда начинаться по московскому времени. Почему бы и нет? Тут вам тоже нужны бизнес-правила. Если пользователь выбирает дату, то какой операционный день, в каком часовом поясе, он хочет увидеть?
Зная дату по выбору пользователя, время и таймзону из бизнес-правил, вы сможете сконструировать нужный таймстамп.
LocalDate + LocalTime + ZoneId → ZonedDateTime → Instant
Нельзя придумать универсальные на весь проект правила представления и таймстампов, и дат. Это разные сущности. И с ними нужно работать по-разному.
Преобразования таймстампа в дату и даты в таймстамп, приведённые выше, могут показаться чересчур сложными. Казалось бы, пусть всё будет таймстампом, и мы легко сможем сравнивать всё.
Но это не так. Преобразования всё равно понадобятся. Просто в одном случае у вас будут правильные типы данных, позволяющие только правильные преобразования. А в другом случае вам нужно помнить, где настоящий таймстамп, а где таймстамп, представляющий дату, и не перепутать одно с другим, и помнить соглашения о представлении даты как таймстампа.
Счастливым исключением могут быть разве что проекты, где и все сущности, и все пользователи находятся строго в одной таймзоне. Но я таких не видел.
Не путайте дату и время, дату и таймстампы. Это всё разные сущности, которые отражают разные реалии этого мира. Не выдумывайте универсального простого представления для них. Полагайтесь на правильные типы данных и правильные библиотеки работы с ними.