2015-12-27

О времени

Скоро пройдет последняя, 31536001-я, секунда 2015 года. Такая некруглая цифра, на одну секунду больше обычного, из-за того, что 30 июня 2015 года была добавлена високосная секунда. Дело в том, что вращение Земли замедляется, и, чтобы как-то компенсировать это расхождение со временем, которое мы уже давно меряем атомными часами, и вводят дополнительную секунду.
А в 2016 году будет 31622400 секунд, на один день больше, потому что год будет високосный. Это уже из-за попытки согласовать период обращения Земли вокруг своей оси и вокруг Солнца, которые соотносятся как 365.256366004 к одному. Вспоминайте это число всякий раз, когда вас будут убеждать в креационизме.
Но это всё касается григорианского календаря. А если помните, до Революции в России был «старый стиль», благодаря которому мы теперь удачно отмечаем Рождество 7 января. Это — юлианский календарь. Ну а в исламском календаре нынче 1437 год со дня переселения пророка Мухаммеда из Мекки в Медину.
Leap Second
Не так-то просто всё это учесть в наших компьютерных системах. Так что сосредоточимся пока только на григорианском календаре, как наиболее распространённом международном стандарте.
Как хранить дату и время? Казалось бы, очевидно, — хранить числа: отдельно год, месяц, день, и еще часы, минуты, секунды, ну и миллисекунды, если надо. Так и делают. Правда, если хотят сэкономить, и, например, хранят только последние две цифры года, то получается проблема 2000 года. Зато можно, если не нужно время, а только конкретный день, хранить только год-месяц-день. Или наоборот, если интересно время безотносительно конкретного дня — хранить только часы-минуты-секунды.
Таким образом хранят дату-время чаще всего базы данных. В MySQL таковыми являются типы DATE, TIME, DATETIME, а также YEAR. Еще они могут хранить доли секунды с нужной точностью. В PostgreSQL есть DATE и TIME, но нет DATETIME. В Oracle тоже есть DATE, который по сути является DATETIME. В языках программирования тоже используется подобное представление. В Си есть стандартная структура tm, определённая в time.h. В Python имеются date, time и datetime. В C# тоже есть «структура» DateTime. А вот в Java такой структуры нет. Но об этом ниже.
Есть ещё обожаемый мною стандарт ISO 8601 о строковом представлении даты и времени. Вместо того, чтобы мучительно вспоминать, что американцы пишут сначала месяц, а потом день («12/31/2015»), мы говорим, что сначала надо писать год, потом месяц, потом день: «2015-12-31T23:59:59». Замечательной особенностью таких строк является то, что лексикографический порядок их сортировки совпадает с реальным порядком даты-времени. ISO 8601 поддерживается всеми современными СУБД и библиотеками работы с датой-временем для преобразование строки в дату и наоборот.
Gregorian Calendar
Всё хорошо, пока речь идёт о времени в одном месте. Но и тут могут быть тонкости. При переходе с летнего времени на зимнее, когда часы переводятся на час назад, показания часов с часу до двух ночи повторяются. Если мы храним лишь часы и минуты как два числа, возникает неоднозначность. Ну а летнее время еще далеко не везде отменили.
А ещё Земля у нас круглая. Поэтому, когда где-то день, то где-то в это же время — ночь. Где-то уже наступило завтра, а где-то ещё сегодня. Поэтому одних значений даты-времени недостаточно. Необходимо уточнить, в каком именно месте Земли это время. Это и есть часовые пояса или таймзоны.
Взяли Землю, и поделили на 24 дольки. За нуль взяли меридиан, проходящий через город Гринвич. И получилось 24 часовых пояса: от -12 до +12 часов от Гринвича. И стали записывать часовой пояс как смещение от времени по Гринвичу: в Москве (сейчас) — GMT+3, в Омске — GMT+6. Правда, сейчас чаще вместо времени по Гринвичу (GMT) используется всемирное координированное время — UTC. Разница в том, что GMT — это астрономическое понятие, а UTC — это то самое время по атомным часам. Сейчас в компьютерах уместнее говорить именно об UTC и смещении от него.
Итак, мы задаём год-месяц-день-часы-минуты-секунды и смещение от UTC. И однозначно получаем момент времени. И даже при переводе часов мы знаем, что сначала было 1:30 UTC+7, а потом случилось 1:30 UTC+6.
Time Zones
Но есть одна неприятная особенность. Смещение от UTC для данной местности есть величина непостоянная. Тут и летнее-зимнее время, и смены государственных границ, и попытки государств оптимизировать потребление электроэнергии, что выливается то в введение летнего времени, то в отмену летнего времени, то в смену часовых поясов. Чтобы как-то это учитывать, в каждой системе есть некая база данных, которая задаёт часовые пояса в некоторых географических терминах и хранит сведения об изменениях смещения от UTC в данной местности.
В подавляющем большинстве случаев используется база данных часовых поясов, ныне поддерживаемая IANA, также известная как Olson database, или timezone database, или tz database, или zoneinfo database. Это набор свободно доступных файликов, которые уже включены в состав операционной системы, среды выполнения (например, JRE) или библиотеки для работы с датой-временем. В файликах содержатся, например, сведения о том, что «Asia/Omsk» — это нынче UTC+6, до 26 октября 2014 это было UTC+7, а ещё раньше это было летом UTC+7, а зимой UTC+6. Хранятся все подобные исторические изменения в таймзонах.
Кроме длинных имён таймзон вроде «America/New_York» часто используются сокращения вроде «EST» (Eastern Standard Time — стандартное время на восточном побережье США, где Нью-Йорк как раз находится) или «EDT» (Eastern Daylight Time — летнее время на восточном побережье США). Как правило, инструменты работы с датой-временем прекрасно понимают подобные сокращения. Однако, я предпочитаю их не использовать, ибо понятны они в основном только местным жителям, неоднозначны, и, кроме того, сами местные жители часто употребляют их неправильно, например, летом говорят EST, хотя технически более верно говорить EDT.
End of Daylight Saving Time
Несмотря на то, что и смещение от UTC, и название географической местности — это таймзоны, надо эти два понятия различать. Смещение от UTC позволяет однозначно идентифицировать момент времени, заданный в виде года-месяца-дня-часа-минуты-секунды. Это очень хорошо для хранения момента времени, нет неоднозначностей. А имя часового пояса задаёт правила преобразования и отображения даты-времени в данном окружении. Часовой пояс может различаться для каждого пользователя системы, и надо корректно отобразить дату-время в часовом поясе именно данного пользователя, независимо от того, как мы эти дату-время сохранили ранее. И при этом будут корректно обработаны исторические перепетии местного законодательства.
Основная боль в работе с базой таймзон — её актуальное состояние. IANA-то отслеживает изменения в мире, и свежие версии появляются в худшем случае за пару месяцев до вступления изменений в силу. Но вот эти обновления очень долго проникают в реальные системы. Хорошо, если у вас какой-нибудь Linux с актуальной поддержкой. Тогда системные пакеты с базой таймзон сами прилетят с обновлениями. Плохо, что, например, Java поддерживает свою версию этой БД. И если вы не озаботились о ручном обновлении, вы можете получить изменения слишком поздно. Отдельную разновидность глюков порождают разные версии базы (читай, JRE) на разных окружениях.
Еще раз, не нужно использовать «географические» названия часовых поясов для хранения даты-времени в БД. Здесь возможны неоднозначности при переходе на летнее-зимнее время, полностью аналогичные описанным выше. «2015-11-01T01:30» в «America/New_York» — это UTC-4 или UTC-5? Поэтому в ISO 8601 можно задать только смещение, чтобы однозначно определить момент времени. «2015-11-01T01:30-05» и «2015-11-01T01:30-04». А для самого UTC (нулевое смещение) используется специальная буква «Z»: «2015-12-31T23:59:59Z».
Базы данных и библиотеки для работы с датой-временем умеют работать с часовыми поясами. В C# DateTime можно задать либо в UTC, либо в «локальной» (то бишь, установленной в данной ОС) таймзоне. В SQL есть типы данных с пометкой WITH TIME ZONE. Однако, это не означает, что в БД будет хранится смещение от UTC. Тут всё хитрее. И сильно зависит от БД.
Unix Timestamp
Есть ещё один способ задания даты-времени. Мы просто берём некий момент времени: эпоху или начало эпохи — и начинаем считать секунды или миллисекунды от этого момента. И получаем просто число, которое однозначно указывает на некий момент времени, с секундной или миллисекундной точностью. В Unix за начало эпохи взяли 1 января 1970 года UTC (полночь, начало этого дня). И секунды с этого момента принято называть Unix timestamp.
Замечательной особенностью такого указания даты-времени является то, что оно совсем никак не зависит ни от таймзон (начало эпохи определено в UTC), ни от календаря. Число секунд легко (компьютеру) перевести в часы-минуты-секунды любого часового пояса и в год-месяц-день любого календаря.
К недостаткам можно отнести проблему 2038 года. Если счётчик секунд у нас — 32-битное знаковое целое, то 19 января 2038 года этот счётчик переполнится. Но это не сильно большая беда, ибо всё чаще используется 64-битный счётчик миллисекунд, который переполнится в 292278994 году, на наш век хватит.
Именно таковым является класс Date в Java. Единственное, что он хранит, — это счётчик миллисекунд от начала Unix эпохи типа long. То, как Date отображается в IDE, зависит от окружения, в котором запущена IDE, включая часовой пояс и локаль операционной системы. То, как Date будет выглядеть где-нибудь на сервере (что вернёт его метод toString()), зависит от окружения сервера: его часового пояса и локали, а также актуальности его базы часовых поясов. Но это всегда будет конкретная миллисекунда от начала эпохи.
Аналогичные типы есть в базах данных. TIMESTAMP в MySQL. TIMESTAMP и TIMESTAMP WITH TIME ZONE в PostgreSQL, тут началом эпохи принято 1 января 2000 года, а суффикс WITH TIME ZONE влияет на преобразование дат при записи и чтении (учитывая часовой пояс клиента БД). TIMESTAMP в Oracle.
Real Timestamp
Так как метка времени — лишь число, возникает соблазн произвести арифметические действия над этим числом. Не надо так.
Вам может захотеться прибавить или отнять 3600 секунд, чтобы «подкорректировать» часовой пояс или летнее/зимнее время, которые у вас отображаются «неправильно». Это категорически неверно. Метка времени не содержит никакой информации о часовом поясе. Прибавление секунд переставляет эту метку на другой момент времени, совсем другой. А отображаться она может не так, как вы ожидаете, по двум причинам. Либо у вас уже есть неправильные данные, и тогда их надо поправить, а не подкручивать на лету. Либо у вас стоит неверный часовой пояс, либо база данных часовых поясов устарела. Для проверки правильности метки времени можно воспользоваться стандартными командами.
Отобразить метку времени в человекочитаемом формате:
 % date -d "@1451584799"              
Thu Dec 31 23:59:59 OMST 2015
 % date -d "@1451584799" -u
Thu Dec 31 17:59:59 UTC 2015
 % TZ="America/New_York" date -d "@1451584799"
Thu Dec 31 12:59:59 EST 2015
Преобразовать дату-время в метку:
 % date -d "2015-12-31T23:59:59" "+%s"
1451584799
 % date -d "2015-12-31T23:59:59Z" "+%s"
1451606399
 % date -d "2015-12-31T23:59:59-05" "+%s"
1451624399
А ещё вы можете захотеть перейти к следующему часу или дню. Запомните, добавлять 86400 секунд к метке времени, чтобы получить завтра, — это так же глупо, как добавлять 30 дней, чтобы получить то же число следующего месяца. Про то, что в месяце может быть совсем разное число дней, помнят все (а вы точно помните, какой год считается високосным в григорианском календаре?). А про переход на зимнее-летнее время все почему-то забывают. Ну и я вам еще рассказал про високосные секунды.
Чтобы правильно переходить от одной даты к другой применяются специальные методы, тесно связанные с используемым календарём. Часто это специальные типы данных, предназначенные для кодирования интервалов времени. Тогда к типу, представляющему дату-время, можно добавить или отнять тип, представляющий интервал, и получить новую дату-время. Таков класс TimeSpan в C#, timedelta в Python, INTERVAL в PostgreSQL и Oracle. Используйте их. Даже в ISO 8601 есть синтаксис для интервалов: например, «P3Y6M4DT12H30M17S».
А в стандартной библиотеке Java таких классов нет. Потому что нет переопределения операторов, наверное. Зато есть класс Calendar, который делает то, что нужно. Есть методы для установки года-месяца-дня-часов-минут-секунд. Есть методы для чтения их же. Есть методы для добавления годов-месяцев-дней-часов-минут-секунд. Ну а на входе или выходе можно получить стандартную метку времени — Date. В общем-то, всё что нужно, если достаточно григорианского календаря. Ведь что такое «последний день текущего месяца»? Это взять сегодня, добавить месяц, взять его первое число и отнять день.
Все эти метки времени, часовые пояса, базы данных таймзон и прочее, конечно, имеют смысл только если вы работаете с новейшим временем. Т.е. с тем временем, который используется в 99% софта. Но если вам нужно датировать исторические события до нашей эры, или наоборот, события, связанные с продолжительностью жизни звёзд, вероятно, вам понадобится и другой, вовсе не григорианский, календарь. Да и миллисекундная точность не нужна будет. Вероятно, тут нужны более специальные решения.
Creation of Light
Подведём итог.
  • Если вы храните дату в виде года-месяца-дня-часов-минут-секунд, вам просто обязательно явно указывать смещение этих значений от UTC.
  • Или же соблюдать соглашение о том, что все значения даты-времени будут в UTC.
  • Или же хранить стандартные метки времени, не привязанные к часовому поясу.
  • Внимательно читайте документацию к типам данных вашей БД, хранение даты-времени везде сделано по-разному.
  • При преобразовании входных значений в дату-время, при преобразовании даты-времени в человекопонятные значения обязательно явно указывайте часовой пояс из базы часовых поясов, не полагайтесь на умолчательные значения, они могут различаться в разных окружениях.
  • Используйте длинные имена из базы данных часовых поясов, не используйте сокращения.
  • Не используйте типы даты-времени, привязанные к окружению или не содержащие часовой пояс (типа LocalDateTime). Всегда считайте, что ваш код может быть запущен на сервере с совершенно левой таймзоной и должен обслуживать пользователей по всему миру.
  • Следите за актуальностью базы данных часовых поясов.
  • При преобразовании даты-времени в строку, кроме часового пояса вам понадобится еще и явно указать локаль.
  • Используйте ISO 8601 на входе и на выходе, если нет явных указаний на локализацию (например, в логах или для указания даты-времени в API).
  • Никогда не производите арифметических действий с метками времени, годами, месяцами, днями, часами, минутами и секундами. Никогда не пытайтесь сами высчитать високосный год или переход на летнее/зимнее время.
  • Используйте стандартные библиотеки для работы с датой-временем, и только их. Не пытайтесь изобрести велосипед. Следите за актуальным состоянием базы данных часовых поясов.
  • Могут быть библиотеки, более удобные, чем стандартные (например, JodaTime). Решайте сами, нужна ли вам дополнительная библиотека только ради удобства.
  • В редких экзотических случаях (работа с не григорианским календарём) вам могут понадобиться дополнительные библиотеки вроде ICU.
  • Всегда синхронизируйте время на серверах, NTP в помощь.
  • При отладке и сопровождении, а также по жизни, не запутаться в часовых поясах поможет timeanddate.com.