О дате

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.

java.time classes

А теперь переходим к практике. К тому самому вопросу, вызвавшему споры.

Самый универсальный способ представления времени в программных системах — это 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

Нельзя придумать универсальные на весь проект правила представления и таймстампов, и дат. Это разные сущности. И с ними нужно работать по-разному.

Преобразования таймстампа в дату и даты в таймстамп, приведённые выше, могут показаться чересчур сложными. Казалось бы, пусть всё будет таймстампом, и мы легко сможем сравнивать всё.

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

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

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

ручная иллюстрация