Pull to refresh

Как перестать думать о часовых поясах и начать жить

Reading time 7 min
Views 18K
В вашей системе время играет важную роль? Ваши пользователи/компоненты распределены по территории всего земного шара, или хотя бы нашей необъятной родины? Значит, вам нужны часовые пояса. Что ж, это просто. Самое сложное, что вам придется сделать — не запутаться. Об этом мы с вами и поговорим. Для начала вам нужно научиться правильно думать. Думая правильно, все остальное будет для вас либо самоочевидным, либо достаточно простым.

Начнем с часов. Все мы привыкли определять время, глядя на часы на стене. При работе с часовыми поясами такое время называется Wall clock time. В принципе, ничего плохого в нем нет, только в разных местах земного шара в один и тот же момент времени часы показывают разное время. Если задаться целью, можно придумать алгоритм перевода wall clock time одного часового пояса в wall clock time другого. Обычно надо прибавить/отнять разницу в часах между часовыми поясами, кроме (внимание) моментов перехода на летнее/зимнее время. Вот когда начинается переход, вычисления становятся по-настоящему сложными.

Нам же нужно что-то простое и пуленепробиваемое, как… целое число. Так появилось понятие момента во времени (instant in time, Unix time, POSIX time, time since (unix) epoch), который представляет собой число секунд (в Java — миллисекунд), прошедших с 1 января 1970 года, 00:00:00 по GMT. Момент времени одинаков по всему земному шару — если представить, что в кто-то нажал на «паузу» и течение времени остановилось, число, соответствующее моменту времени по всей Земле будет одно и то же, независимо от часового пояса. Если бы кто-то нажал на паузу через час после того, как на Гринвиче наступил новый 1970 год, момент во времени по всей Земле показывал бы 3 600,000. А сейчас, например, это уже число 1 280 720 431,859.

Итак, момент во времени — это универсальная конвертируемая валюта временных вычислений. Он зависит только от, хм, времени, моменты можно сравнивать (соответственно, определять, какое из событий произошло раньше, а какое позже), и в этом не участвует никакая ерунда, связанная с географическим положением, часовыми поясами и переводами часов, что кардинально повышает надежность таких вычислений. Собственно, так реализована работа со временем в Java (с версии 1.1), где java.util.Date представляет собой обертку над long-моментом во времени (датам ранее 1970 соответствуют отрицательные long-и), является Comparable, а все человеческо-календарные преобразования вынесены в отдельные классы Calendar и DateFormat.

Про преобразования. Обычному человеку мало что скажет число 1 280 720 431,859 (хотя пытливый читатель может вычислить по нему время, когда я писал эти строки), поэтому нужно уметь переводить момент во времени в wall clock time, и, соответственно, парсить обратно wall clock time в момент во времени. Вот для этих преобразований уже требуется знать часовой пояс, и эти вычисления совсем не тривиальные. Дело в том, что в разных странах/территориях/местах мало того, что разное смещение относительно GMT (время по Гринвичу), так еще и правила этих смещений исторически несколько раз менялись и продолжают меняться (вводят/отменяют летнее время, объединяют пояса — слышали про такую инициативу у нас, наверное?, или вспомним, например, мой родной город Новосибирск, который в начале девяностых перенесли из GMT+7 в GMT+6, а в начале века в нем вообще два пояса было — граница пояса проходила по реке Оби, и на разных берегах были разные пояса). Короче, чтобы не сойти с ума, вся эта историческая информация аккуратно ведется в виде базы данных Olson tz database, названной в честь основателя Arthur David Olson, хотя редактором является Paul Eggert. В этой базе данных каждому крупному населенному пункту соответствует код (Новосибирск, например, по этой базе называется Asia/Novosibirsk) и список всех его приключений по часовым поясам, начиная с 1970 года. Эта база используется во многих (всех?) Linux/Unix/BSD-системах, насчет Windows не скажу, в Java Runtime Environment (у нее, например, были какие-то апдейты, связанные исключительно с обновлением tz database), и так далее, см., в общем, Википедию. Алгоритм преобразований времени в/из этой базы мы рассматривать не будем, будем считать, что он есть у нас готовый. Он, собственно, практически везде и есть готовый.

Итак, сформулируем правила обращения со временем для программ, работающих в нескольких часовых поясах:
  • внутри программы работать только с моментами во времени;
  • преобразование моментов во времени в wall clock time производить только во время ввода/вывода даты. Помните, что в этом преобразовании всегда (всегда!) участвует часовой пояс, поэтому нужно следить, какой именно (это не всегда видно, потому что по умолчанию берется текущий);
  • еще один случай, когда требуется wall clock time, это календарные преобразования (вычислить начало следующего дня и т.п.). Здесь тоже нужно следить, чтобы эти преобразования происходили в правильном часовом поясе;
  • при сохранении даты/времени в базу данных делать это в часовом поясе UTC.
Последний пункт из всего вышерассмотренного не следует, поэтому разберем его отдельно. При работе с базами данных (у меня есть опыт только с Oracle и sqlite3), а именно при сохранении/чтении из БД зачем-то требуется часовой пояс. А это значит, что после сохранения/прочтения можно попортить данные. Каким образом попортить? Есть одна неприятная особенность, связанная с переходом на с летнего (+1) на зимнее время (02:00 — 03:00 27 октября 2002, например): во время перехода в течение часа каждому значению wall clock time соответствуют два момента времени (часы дважды показывают 02:01, 02:02, 02:03 и т.д, при этом это разные моменты времени). То есть, мы не можем однозначно определить по wall clock time 02:30 27.10.2002, какой это момент во времени, потому что не знаем, летнее это еще время или зимнее. Если мы получим некий момент во времени и преобразуем его в 02:30 27.10.2002, то обратное преобразование однозначно мы уже выполнить не сможем.

Можно придумать разные варианты решения этой проблемы — хранить отдельной колонкой признак л/з, хранить моменты во времени в колонке типа NUMBER, но наименее радикальным и простым мне кажется хранение даты/времени в UTC. В часовом поясе UTC нет перехода на летнее/зимнее время, поэтому преобразования wall clock time instant in time всегда выполняются однозначно. Кроме того, что такой подход позволяет надежно хранить все моменты во времени в БД, включая переводы часов, он еще и:
  1. дисциплинирует (если вы забудете где-то в преобразованиях указать часовой пояс, то сразу увидите, что что-то не то, по крайней мере, если вы живете не в UTC);
  2. позволяет не запутаться в датах/временах, когда информация в БД поступает из разных часовых поясов — в базе время всегда в UTC;
  3. упрощает код, так как при преобразовании времени в/из БД можно не думать о часовом поясе, он всегда один и тот же.
Напоследок пару слов о работе с часовыми поясами в python. В python для работы с датами используется класс datetime.datetime, для работы с часовыми поясами — модуль pytz, основанный на все той же Olson tz database. Напрямую моментов времени у них нет, вместо этого есть два понятия: timezone-aware datetime и naive datetime. Первое, понятно, дата-время с указанным часовым поясом, второе — наивное дата-время, в чистом виде wall clock time без уточнения, где эти wall clock висят. Хранятся datetime в виде структур «год месяц день час минута секунда микросекунда» плюс объект tzinfo для tz-aware datetime. Момент времени можно получить только через time.time(), это будет float и он будет ограничен чем-то вроде [1970 г., 2038 г.], то есть, его легко может не хватить для каких-то вычислений. То есть, (насколько я понимаю, может быть, меня поправят?) у них в datetime что-то вроде того самого алгоритма перевода напрямую из одного часового пояса в другой реализовано, без моментов времени, зато даты теоретически любые может понимать, с 1 по 9999 годы.

Переводится naive в tz-aware datetime с помощью метода:

tzaware_datetime = some_timezone.localize(some_naive_datetime, is_dst=True)
(обратите внимание на второй параметр, он нужен как раз из-за неоднозначного преобразования), либо

another_tzaware_datetime = tzaware_datetime.astimezone(another_tz)
(перевод tz-aware даты-времени в другой часовой пояс).

Поскольку это все реализуется через один и тот же класс datetime.datetime и вся разница в наличии свойства tzinfo, нужно быть чертовски осторожным, чтобы не перепутать, где у нас даты с часовым поясом, а где нет. Здесь Питон хуже Джавы в том смысле, что в Джаве при распечатывании хочешь-не хочешь, а нужно DateFormat создать и часовой пояс указать, в Питоне же многие операции, в т.ч. и печать, могут и для наивных дат выполняться. Понятно, что в сколь-нибудь сложном приложении желательно позаботиться, чтобы все даты были с часовым поясом, потому что если в каком-то месте приложения окажется, что его нет, то уже фиг вычислишь, а какой он там должен быть. А с поясом и сравниваться даты будут корректно, и распечатываться, и вообще. Кроме того, поскольку при сохранении в/чтении из БД сохраняется только наивная часть (год месяц день час минута секунда микросекунда), единственный толковый способ с этим работать— это иметь в базе наивное представление в UTC.

Бонусы


Правила человека, работающего с календарными датами. Помните, что:
  1. не в каждом году 365 дней;
  2. не в каждом дне 24 часа;
  3. к счастью, в каждом часе 60 минут;
  4. не в каждой минуте 60 секунд (может оказаться 59 и 61. 61-ая называется leap second, добавляется либо 30 июня, либо 31 декабря, в это время часы в UTC должны показывать 23:59:60. Добавление 61-ой секунды вызвано замедляющимся вращением Земли. Возможность отнять одну секунду предусмотрена для случаев, если Земля начнет вращаться быстрее, но эта возможность еще ни разу не потребовалась).
Время GMT вычисляется не по моменту, когда солнце пересекает мередиан, а по некоторому усредненному моменту времени этого события. Реальное пересечение меридиана может отличаться от него до 16 минут из-за эллиптичности земной орбиты.

Хотя UTC и GMT очень похожи, но все-таки немного отличаются. Если GMT определяется по среднесолнечному времени в Королевской обсерватории в Гринвиче, то UTC отмеряется атомными часами (средневзвешенное время двухсот атомных часов в семидесяти лабораториях по всему миру, синхронизируемых через спутники). Расхождение GMT и UTC не должно превышать 0,9 секунды и компенсируется как раз добавлением leap seconds.

Ожидается, что хранение даты в 32 signed int в UNIX-системах приведет к проблеме 2038 года, когда 31 бит переполнится и последующим моментам во времени будут соответствовать отрицательные числа, что сломает все методы сравнения. Новые 64-х битные системы и программы уже используют для хранения времени 64 бита, но успеют ли такие системы полностью заменить 32-х разрядные к 2038 году?
Tags:
Hubs:
+52
Comments 70
Comments Comments 70

Articles