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

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

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

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

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

Пока разработка шла в Лондоне, никто и не думал ни про какие зоны, и код писали без их учета. Разработчик с зоной +3 столкнулся с тем, что не проходит ни один тест, потому что время прибито гвоздями без учета пояса. В итоге коллега проработал у них год сидя с переведенными часами. Было проще поменять зону локально, чем исправить код.

Забавно, что бизнес той фирмы был связан с расписанием общественного транспорта, где время имеет решающую роль. Это снова поднимает вопросы о том, кто и как пишет повседневное ПО.

В нашем проекте фронтендер решил, что он умнее системы и что нужно помочь клиенту перевести локальное время в UTC. И тем самым заложил в систему трудноуловимый баг. При отправке объекта Date через Ajax первый автоматически приводится к общемировому времени. Затем срабатывал обработчик коллеги, который тоже корректировал пояс. Получается, что мы дважды вычитали локальное время на клиенте и тем самым портили любую дату с сервера.

Теперь представьте, что будет, если показать не то время на сайте продажи авиабилетов. Или прислать смс-напоминание с ошибкой в приведении часовых зон. Это будет стоить колоссальных денег и нервов.

Я заметил, что откровенно плохо обстоит со временем у фронтендеров. Это связано с тем, что время – скорее серверная часть приложения, и к тому же требует некоторой подготовки и мышления. Чаще всего ошибки случались на стороне клиентской части.

Главное правило: никогда не пишите свой код для операций над временными зонами. Все современные библиотеки уже имеют этот функционал из коробки. Обертки над временными операциями должны быть минимальными. Если модуль разрастается без контроля, вы явно пишете говнокод. Остановитесь, пока весь проект не стал зависеть от кривых классов или функций.

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

Для работы со временем лучше сразу брать хорошую стороннюю библиотеку. Время настолько сложная вещь, что редкий язык может похвастаться качественной коробочной реализацией. В Джаваскрипте объект Date убог. В Джаве класс java.util.Date был объявлен deprecated уже с версии 1.1, то есть почти сразу. Родной datetime в Питоне приемлем только для базовых задач.

Не нужно думать, что “мне только оберточку написать”, зато без зависимостей. Время – важно. Пусть будут зависимости, но меньше багов.

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

(Конечно, часовые пояса идут не идеально ровно как полоски на арбузе. Еще недавно в России меняли временные зоны – объединяли и делили соседние. Так, стараниями Медведева в моей родной Чите зимой в 16 часов было уже темно как ночью. Но для простоты будем считать, что пояса распределены равномерно.)

Поэтому фраза “созвонимся в три часа” в общемировом масштаба не значит ничего. По чьему времени в три часа? По Москве? Нью-Йорку? Очевидно, нужна особая метка чтобы обозначить, откуда это время получено. Тогда путем простых вычислений можно перевести время из одной метки в другую. Эта метка и называется временной зоной.

Обозначение у нее может быть разное, например смещение в часах (+03:00), имя части света и города через косую черту (Europe/Berlin) или одна из аббревиатур (CET, Центральное Европейское время), но это уже тонкости стандартов кадирования. Важно то, что теперь время привязано к конкретной местности и может быть преобразовано в универсальное время UTC.

UTC – универсальное координированное время – это современный стандарт отсчета, пришедший на смену GMT. Не будем сейчас разбирать его особенности, достаточно сказать, что на шкале поясов он играет роль нуля: именно от него отсчитываются отрицательные (западные) и положительные (восточные) зоны.

Например, я живу в Воронеже, мой пояс третий по счету на восток, поэтому я могу написать в резюме “when calling, please consider my +3 UTC timezone” или просто “my TZ is +3”. Тогда заказчик из Лондона, у которого нулевой пояс, добавит к своему времени 3 часа и получит мое локальное время.

Я подвожу вас к мысли, что в приложениях недостаточно знать только время, нужно знать еще и зону, иначе одно и то же время может быть истолковано по-разному. Так мы переходим к технической части статьи.

Ваш сервер и база данных отвечают за хранение дат. Современные БД (здесь и далее – PostgreSQL) предоставляют два типа полей: это timestamp with time zone и timestamp without time zone, то есть с зоной или без. Если в первое поле записать дату с зоной, то БД сконвертирует ее в UTC, потому что так удобней. А при выводе этой даты в утилитах она будет представлена в локальном времени того пользователя, кто сейчас ее просматривает.

Рассмотрим пример:

create table foo (
  ts_z timestamp with time zone,
  ts timestamp without time zone
);

CREATE TABLE

insert into foo (ts_z) values
  ('2017-12-13T10:00:00-05:00'::timestamp with time zone);

INSERT 0 1

select * from foo;

Результат:

          ts_z          | ts
------------------------+----
 2017-12-13 18:00:00+03 |
(1 row)

Я создал таблицу с двумя полями: время с зоной и без. Потом записал в поле с временной зоной время с часовым поясом Нью-Йорка. В базе оно будет храниться как 2017-12-13 15:00:00 UTC. Но при выводе я увижу время в консоли в моем локальном времени. Все верно, мой пояс +3, дата была приведена верно.

А в поле ts нужно писать только время по UTC, иначе будет ошибка:

insert into foo (ts) values
  ('2017-12-13T10:00:00-05:00'::timestamp with time zone);

INSERT 0 1

select * from foo;
          ts_z          |         ts
------------------------+---------------------
 2017-12-13 18:00:00+03 |
                        | 2017-12-13 18:00:00
(2 rows)

Видим, что во второе поле записалось не то, что нужно: правильное время по UTC должно быть 15 часов, а не 18. Вот как нужно было сделать:

insert into foo (ts) values
  ('2017-12-13T10:00:00-05:00'::timestamp with time zone AT TIME ZONE 'UTC');

INSERT 0 1

select * from foo;
          ts_z          |         ts
------------------------+---------------------
 2017-12-13 18:00:00+03 |
                        | 2017-12-13 18:00:00
                        | 2017-12-13 15:00:00
(3 rows)

Эта и другие ошибки будут рассмотрены ниже.

В различных библиотеках и ORM при выборке подобных дат они восстанавливаются в объекты языка с заполненной часовой зоной (поля tz, zone, tzinfo и тд).

Вариант дат без зон тоже имеет право на жизнь, и на мой взгляд он проще. В команде вы просто соглашаетесь, что все даты хранятся как локальное время по UTC. Все библиотеки имеют функции и методы для работы с таким временем, например, datetime.utcnow() в Питоне, moment.utc() в JS и тд.

Клиент и сервер должны обмениваться друг с другом только датами в UTC. Универсальное время переводится в локальное на стороне клиента исходя из локального смещения относительно нулевого меридиана. Эту величину можно определить методом .getTimezoneOffset() объекта Date на клиенте.

При передаче дат клиенту вы кодируете их в ISO-формат с зоной. Если зона UTC, она обозначается буквой Z. Таким образом, в текстовом представлении время, отправляемое клиенту будет выглядеть так:

"2017-12-12T14:12:23.502Z"

Слева направо: год, месяц, день, разделитель, часы, минуты, секунды, микросекунды, индикатор нулевой зоны UTC.

При кодировании даты без зоны некоторые библиотеки могут не добавить Z на конце, что может запутать клиента. Обязательно учтите это: добавьте в модуль функцию, которая будет добавлять Z к результирующей строке и пользуйтесь только этой функцией (см. выше о единой кодовой базе для работы со временем).

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

В обратную сторону с клиента на сервер даты передаются в таком же формате: ISO-строка в UTC с Z на конце в качестве зоны.

Рассмотрим, что делать клиенту с полученной строкой. Легче всего восстановить ее в объект библиотекой moment.js:

moment("2017-12-12T14:20:05.849Z").format()
"2017-12-12T17:20:05+03:00"

Видим, что все правильно: мой пояс +3, часы были увеличены на эту разницу, на конце индикатор +03:00.

В сторону: сейчас кто-то встанет и начнет заливать, что moment.js уже не популярен, и что горячие парни пишут какую-то новую либу на замену ей. Мой совет – шлите лесом таких советчиков. Нам нужно проверенное решение, а не модная поделка.

В каком формате показывать дату – зависит от вас. В каждой стране свои форматы, определяющие порядок следования составляющих (день, месяц или месяц, день), разделителей (точка, косая черта, дефис), написание месяцев (Dec, December). Важно понять, что успешный вывод даты зависит от того, была ли правильно она восстановлена из строки на этапе ранее.

Стоит запомнить: если показ даты требует от вас каких-то махинаций вроде прибавления или вычитания часов, минут или что-то в этом роде – срочно остановитесь, вы гарантированно что-то делаете неправильно. Или должно быть четкое понимание, почему нельзя сделать по-другому.

При передаче JSON-тела на сервер объект Date не нужно приводить в ISO-строку вручную. Для этого существует метод .toJSON(), который автоматически будет вызван при кодировании внешнего словаря. Например:

var d = new Date();
Tue Dec 12 2017 18:40:48 GMT+0300 (MSK)

JSON.stringify({foo: 42, bar: d})
"{"foo":42,"bar":"2017-12-12T15:40:48.573Z"}"

Рассмотрим основные ошибки, связанные с обработкой времени и как с ними бороться. Начнем с сервера.

  • Тип поля указан timestamp with time zone, но вы передаете объект времени без зоны.

В этом случае сервер подставит текущую зону, то есть ту, в которой расположен сервер. Если пользователь расположен далеко, например, на другом континенте, это может вызвать значительные неудобства. Скажем, пользователь из Нью-Йорка (-5 UTC) передал на сервер время "2017-12-12T14:20:00Z". Сервер расположен в Швейцарии (+1 UTC). По какой-то причине зона была отброшена, и в базу вместо "2017-12-12T23:20:00 UTC" (исходное плюс 5 часов) записалось "2017-12-12T13:20:00 UTC" (исходное минус 1 час). Ошибка в 10 часов! Представьте, что это напоминания о поездке или рассылка смс.

  • Тип поля timestamp without time zone, но в базу пишется локальное время сервера.

Убедитесь, что для получения текущего времени вы используете функцию с utc в ее имени, например, datetime.utcnow() вместо datetime.now() и аналогично в других языках. В PostgreSQL аналогично: почти для всех вызовов временных функций нужно указывать зону UTC. Сравните:

> select current_timestamp;
       now
-------------------------------
 2017-12-12 19:03:07.683706+03


> select current_timestamp at time zone 'UTC';
     timezone
--------------------------
 2017-12-12 16:04:21.7177

Перевести зону из одной в другую на уровне БД можно следующим оператором:

select
  '2017-12-13T10:00:00-05:00'::timestamp with time zone AT TIME ZONE 'UTC'
  as right_time;
     right_time
---------------------
 2017-12-13 15:00:00

Теперь клиент.

  • С сервера приходят UTC-даты в ISO, но без метки Z, например "2017-12-12T14:20:05".

Попытка отобразить такую дату с помощью moment.js, как мы делали выше, приведет к ошибке:

moment("2017-12-12T14:20:05").format()
"2017-12-12T14:20:05+03:00"

Вариантов тут два: либо приклеить к строке со временем нужную зону, либо явно указать библиотеке, что это время в UTC:

moment.utc("2017-12-12T14:20:05").local().format()
"2017-12-12T17:20:05+03:00"

Метод .utc() парсит строку с учетом того, что результат будет время по UTC. Метод .local() преобразует его к локальному времени на основании смещения, которое берется из браузера. Теперь все правильно.

  • С сервера приходит его локальное время.

Это беда, но лечится явным указанием зоны. Например, вы в Москве, а сервер в Нью-Йорке. Он отдает локальное время "2017-12-12T14:20:05". Нью-Йорк в минус пятом часовом поясе. Приклеим зону к строке и распарсим:

moment("2017-12-12T14:20:05" + "-05:00").format()
"2017-12-12T22:20:05+03:00"

Значит, это 22:20 вечера по Москве. Проверим устно: если от НЙ до нулевого меридиана 5 часов, а потом еще 3 часа до Москвы, то в сумме 8 часов. 14 + 8 = 22 часа местного времени.

  • Сервер отдает время в виде “unix epoch”.

Это значит в количестве секунд со дня рождения ОС Юникс – 1 января 1970 года. Такое время всегда представлено в UTC. Отобразить его пользователю можно так:

moment.unix(1513107997).format()
"2017-12-12T22:46:37+03:00"

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

Обобщу вышесказанное.

Научиться работать со временем не сложно. Главное – не оправдывать лень и не дожидаться момента, когда съедут все даты вечером в пятницу. Время – важно.

Воздержитесь от собственных поделок для работы с часовыми поясами. Оставляйте операции со временем на откуп проверенным библиотекам. Выносите вспомогательные функции в особый модуль.

Храните даты на сервере либо с временной зоной UTC, либо без нее, но подразумевая это.

Клиент и сервер должны пересылать даты только в UTC с нулевой зоной Z.

Локальное время выводится на клиенте из UTC относительно локального смещения пользователя.

При кодировании дат в JSON последний сам позаботится часовом поясе. Вы не должны что-то прибавлять или вычитать.

Маленький бонус: рассмотрим как быть, если формировать локальное время нужно не на клиенте, а на сервере. До сих пор я вел разговор в том ключе, что у нас Single page application и все рендериться на клиенте. Однако, чисто серверные приложения никуда не делись, и такая задача тоже может возникнуть.

Представим, что даты хранятся в UTC, но вот пришел HTTP-запрос от какого-то пользователя, и нужно показать даты в его локальном формате. Как быть?

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

Но страна вовсе не означает время. Так, пользователь Воронежа может сидеть через прокси или Тор с выходной нодой где-то в Индии, но при это его локальное время не имеет отношения к этой стране.

Узнать локальное смещение можно через специальный метод объекта Date:

(new Date()).getTimezoneOffset()
-180

Оно выражено в минутах и имеет знак противоположный часовому поясу. Если у меня зона +3, то смещение будет -180 минут.

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

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

Рассмотрим код на Питоне:

from datetime import datetime, timedelta

utc = datetime.utcnow()
loc = utc - timedelta(minutes=-180)

utc # datetime.datetime(2017, 12, 13, 13, 57, 18, 108606)
loc # datetime.datetime(2017, 12, 13, 16, 57, 18, 108606)

Локальное время на 3 часа больше, теперь эту дату можно показывать пользователю.

Это был пример явной манипуляции с часовыми поясами. Выше я писал, что этого следует избегать. Однако, здесь явно присутствует понимание того, что мы делаем. Простой вычет будет эффективней и проще использования навороченной библиотеки с часовыми поясами.

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

Награда для тех, кто прочел до конца: