Когда вы разрабатываете какое-то программное обеспечение, вы поначалу можете не задумываться о часовых поясах. Если только вы не живете в стране, которая имеет несколько часовых поясов, например, в США или России.
Не так давно я столкнулся с проблемой, которая была связана с часовыми поясами. Было несколько юнит-тестов, имеющих дело с датами. Они работали в моем офисе во Франции, но не работали в Марокко у новых членов нашей команды.
Вот тот самый юнит-тест, который работает во Франции, но не работает в Марокко.
Для меня это была возможность научиться правильно обрабатывать дату и время в международном программном обеспечении. В этой статье я расскажу о часовых поясах и поделюсь некоторыми правилами, которым нужно следовать.
Краткое введение в часовые пояса
Поскольку земля имеет форму практически сферы, то солнце восходит в Японии, а садится в Америке. Если бы все использовали глобальное время, то, например, 9:00
было бы восходом солнца в Японии, но закатом в Америки. Это было бы не очень удобно.
Для того, чтобы время согласовывалось с фазами солнца, необходимо перейти от глобального времени ко времени в соответствии с вашим местоположением. В результате земной шар разбивается на часовые пояса (time zones), и каждый из них имеет некоторое смещение (offset). Это смещение представляет собой количество минут, которое необходимо добавить к глобальному времени, чтобы получить время вашего часового пояса. Это смещение может быть положительным или отрицательным.
Глобальное время называется UTC, оно расшифровывается как Universal Time Coordinate (всемирное координированное время). Вы также могли слышать о GMT – часовом поясе без смещения.
Например, когда по UTC время 10:50
, то в Сан-Франциско – 03:50
со смещением -0700
, а в Пекине – 18:50
со смещением +0800
. Однако смещение может быть не только в целых часах. Например, смещение Непала составляет +0545
.
В придачу к этому смещению, связанному с часовым поясом, в некоторых странах еще дополнительно смещают часы два раза в год. DST, или летнее время, добавляет еще один час к смещению часового пояса перед наступлением летнего времени. Затем часы переводятся обратно зимой. Цель такого смещения заключается в том, чтобы сделать дневное время длиннее.
Самый простой способ определить часовой пояс – использовать базу данных часовых поясов IANA. В результате вы получите строку, такую как Europe/Paris
(Европа/Париж), в соответствии с шаблоном Регион/Город. Кроме того, Microsoft поддерживает собственную базу данных часовых поясов Microsoft, которая используется в их операционных системах. Но использование этой базы может вызвать проблемы при запуске кроссплатформенных приложений .NET Core.
IANA по-прежнему остается лучшим вариантом. База данных Microsoft обновляется не так часто, имеет меньшую историю и использует довольно любопытные названия часовых поясов (например, Romantic Standard Time – романтическое стандартное время) и подвержена ошибкам. Например, постарайтесь не перепутать следующее: Arab Standard Time
, Arabic Standard Time
и Arabian Standard Time
.
И последнее: есть множество способов записать дату. К счастью, спецификация ISO 8601 устанавливает общее правило для представления даты.
Ноябрь 11, 2018 в 12:51:43 AM (в часовом поясе UTC+00:00) 2018-11-05T12:51:43Z <- Z означает UTC Ноябрь 11, 2018 в 12:51:43 AM (в часовом поясе UTC +07:30) 2018-11-05T12:51:43+0730
Как компьютеры обрабатывают даты
Компьютеры умеют выполнять операции только с числами. А это значит, что 2020-08-01 + 1
не равно 2020-08-02
и не может быть обработано.
Для того, чтобы упростить работу с датами, мы можем представить их в виде чисел. Вот что такое метки времени - timestamps. Это количество миллисекунд, прошедших с заранее определенной даты (или начала отсчета времени - epoch) до указанной даты.
Отлично, теперь тогда давайте выберем начало отсчета времени! На самом деле общее начало отсчета уже установлено – 1 января 1970 года (полночь UTC).
Для того, чтобы убедиться в том, что вы все поняли, запустите фрагмент, представленный выше, в своем браузере. Ну и как? Вы не получили тот же результат?
Хорошо, я немного сжульничал, чтобы получить такой результат... Я должен получить четверг, 1 января 1970 года, 01:00 GMT +0100
, потому что часовой пояс моего компьютера установлен на Europe/Paris
.
На самом деле, точка с нулевой меткой времени – это полночь в Гринвиче, а также 05:45
в Мумбаи и даже 1969-12-31Т16:30
в Сан-Франциско, если учитывать смещение их часовых поясов.
Правило №1: Метки времени предназначены только для сохранения, а не для отображения. Они учитываются в формате UTC, поскольку не включают смещение или часовой пояс.
Раньше вы не получали «правильную» дату, потому что JavaScript использует ваш местный часовой пояс, чтобы показать вам наиболее точную дату/время.
А теперь попробуйте запустить следующий фрагмент. Я уверен, что сейчас вы получите тот же результат, что и я:
Да, нулевая метка времени – это 1970-01-01 Т00:00:00 UTC
для всех по всему миру. Тем не менее, это не будет верным, если вы выберите другой часовой пояс.
Таким образом, toString
отображает дату с использованием вашего местного часового пояса, а toUTCString
с помощью UTC. Также будьте осторожны с toISOString
, который аналогичен toUTCString
, но выводит дату и время в формате ISO 8601 (он должен называться toUTCISOString
).
Я рекомендую команду date
для преобразования второй метки времени (из миллисекунд) в читабельную строку. Использование этой команды с параметром UTC гарантирует, что часовой пояс вашего компьютера/браузера не будет учитываться.
# Linux $ date -d @1586159897 -u Mon Apr 6 07:58:17 UTC 2020 # Для пользователей Osx $ date -r 1586159897 -u
Давайте исправим наш юнит-тест
Проблема, связанная с часовыми поясами, с которой я столкнулся, была в моих юнит-тестах. Потратьте немного времени, чтобы прочитать и понять, то он должен был сделать:
Цель этого теста в том, чтобы проверить, что setHours
устанавливает часы и минуты даты на ноль (т.е. полночь). Сначала я выбираю случайную метку времени, которая не соответствует полуночи. Затем сравниваю результат с меткой времени того же дня в полночь.
Это, на самом деле, работает, но только в том случае, если смещение вашего часового пояса +0200
(с учетом летнего времени). Так, например, он не будет работать для Africa/Casablanca
(+0100
с учетом летнего времени). Давайте посмотрим, как печатаются эти метки времени:
Вот и все, как мы и предполагали, дата UTC для двух результатов не совпадает. Это также означает, что полученные метки времени также не совпадают.
Как вы можете видеть, смещение для Парижа – это +0200
, а для Касабланки - +0100
. Но они оба отображают полночь с toString
. Это значит, что функция setHours
использует часовой пояс вашего компьютера для выполнения операции. И toString
, соответственно, отображает дату, используя ваш часовой пояс.
Это не единственная проблема, возникающая в этом тесте. Что будет, если вы запустите этот тест в Сан-Франциско? Верно, будет указан день 2020-07-31
для обеих дат со смещениями -0700
.
Самый безопасный способ сделать этот тест надежным и позволить ему работать по всему миру – использовать дату вашего местного часового пояса. И вам больше не потребуется использовать метки времени для установки начальных дат.
Теперь модернизируем предыдущее правило о метках времени:
Правило №2: Строковые даты подходят для отображения с использованием часового пояса пользователя и вычислений. Они не относятся к UTC, но, как правило, включают смещение.
Продолжайте в том же духе и на стороне сервера
Правило о метках времени по-прежнему применяется на стороне сервера. Однако втрое правило о строковых датах не может быть использовано.
Действительно, в некоторых случаях при использовании таких технологий, как PHP, Java и Rails, страницы отображаются на стороне сервера (SSR). Это значит, что весь HTML-код генерируется сервером, и он не имеет представления о том, какой часовой пояс у клиента. Просто подумайте, сервер – это не более чем просто компьютер. У него есть собственный часовой пояс, но он не обязательно совпадает с часовым поясом клиента.
Правило №3: Серверы могут знать часовой пояс клиента или отправлять дату в формате UTC. Часовой пояс сервера значения не имеет.
Новый Java 8 Date/Time
считается одним из самых понятных API, помогающих работать с датами. Я не буду объяснять, как он работает, но давайте рассмотрим некоторые интересные моменты.
LocalDateTime
, OffsetDateTime
и ZonedDateTime
– это 3 класса, предназначенные для вычисления и отображения даты и времени. Нет больше Date
и DateTime
, которые путают отображения локальной даты и даты в формате UTC.
Следующие примеры были взяты из статьи, в которой описывается API Java 8 Date/Time с множеством примеров.
Давайте посмотрим различия между этими тремя классами:
Между OffsetDateTime
и ZonedDateTime
есть небольшое, но важное отличие, вы его заметили?
Как следует из названия, OffsetDateTime
осведомлен только о смещении между локальной датой и UTC. Это значит, что он обрабатывает летнее время не как дату, привязанную к часовому поясу.
Пример с часовым поясом кажется правильным. На самом деле оба варианты верны, потому что добавление 1 дня может означать:
- Добавление 1 дня и сохранение того же часа (обрабатывает летнее время с
ZonedDateTime
) - Добавление 24 часов к текущей дате (с
OffsetDateTime
).
Помните правило №1 о метках времени? Для сохранения следует использовать только временную метку UTC. Java API представляет класс Instant
, который является меткой времени, которую вы можете получить их любого из трех классов, используемых для отображения даты.
Заключение
Из этой статьи вы узнали, что метки времени предназначены для сохранения (правило №1), а строковые даты – для отображения (правило №2). Вы заметили, наверное, что количество секунд от начала отсчета времени — это дольно большое число?
Вот почему после проблемы Unix Millennium Bug (Y2K) (некорректная интерпретация дат после 2000 года, или «ошибка тысячелетия») появляется проблема Y2K38, которая служит обозначением 2038 года. 19 января 2038 года в 03:14:07 (2038-01-19T03:14:07Z) отметка времени (в секундах) достигнет своего максимума для 32-битных целых чисел – 2 147 483 647. Затем, после добавления еще одной секунды, оно превратиться в отрицательное число.
Это табло отображает январь 1900 года, а не январь 2000 года
На форумах люди утверждают, что для них это не имеет значения, поскольку их программное обеспечение не будет использоваться в течение 20 лет без перезаписи. Это может быть и так, но давайте подумаем над решением этой проблемы (с помощью MySQL):
- Можно изменить тип
TIMESTAMP
на 64-битные целые числа со знаком. - Можно сохранять даты в формате UTC в столбцах
DATETIME
вместоTIMESTAMP
.
Оба решения имеют свои плюсы и минусы. Первый вариант похож на танцы с бубном, проблема все равно появится. Тем не менее, такой подход решает проблему почти на бесконечное количество времени (миллиарды лет). Когда проблема возникнет снова, то ваше программное обеспечение устареет и больше не будет использоваться.
Второй вариант тоже решает проблемы с долгосрочным результатом (до 31 декабря 9999 года 23:59:59 (9999-12-31T23:59:59Z)).
Для логов рекомендуется использовать TIMESTAMP
, а для других целей лучше использовать DATETIME
. Всегда помните, что метка времени не может хранить дату до 1 января 1970 года 00:00:00 (1970-01-01T00:00:00Z) и после 19 января 2038 03:14:07 (2038-01-19T03:14:07Z). Это значит, что для сохранения дат далекого прошлого и будущего вы должны использовать DATETIME
.
Кроме того, в MySQL TIMESTAMP
хранятся в формате UTC, но отображаются в соответствии с указанным часовым поясом (и конвертируются в UTC перед сохранением). Такой механизм удобен в том случае, если вам нужно получить локальную дату или вы не хотите иметь дело с DATETIME
.
И последнее слово о moment.js
– популярной библиотеке для работы с датами. Я в первый раз экспериментировал с этой проблемой и хотел бы вас предупредить:
Оба файла console.log
будут выводить 2020-08-02 00:00
. Если вы привыкли к функциональному программированию, то будете ждать, что hours
и minutes
вернут новый объект времени, так как это чистые функции. Но это не так – они меняют дату ввода и возвращают ее для упрощения образования цепочек.