воскресенье, 16 февраля 2014 г.

Time Zones

Аннотация

В данном посте будет описана немного истории для понимания, что такое тайм зона и в чем ее сложности. Также будет объяснено, что такое Date, как она представляется в коде и БД. Будут разысканы проблемы, которые возникают с датами в GWT и их решения.

С чего все началось

Форматирование даты в GWT по маске происходит через DateTimeFormat. Однако оказалось, что при выводе дата зависит от клиентской тайм зоны, т.е. часового пояса, который установили на компьютере в Windows. Это явно не очень хорошо, потому что в enterprise приложениях тайм зона (TZ) может не равняться тайм зоне на клиенте.
Логично сделать прокидывание TZ в DateTimeFormat при форматировании и парсинге, но в GWT на клиенте это можно сделать только для форматирования, да и прокинуть тайм зону тоже не очень получиться - нет метода создания TZ по имени. Для справки в Java это есть:
TimeZone timeZone = TimeZone.getTimeZone(“Europe/Moscow”);
SimpleDateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm");
dateFormat.setTimeZone(TimeZone.getTimeZone(TZ_NAME));
Date date = dateFormat.parse("17.01.2014 11:11");
String text = dateFormat.format(date);

Что такое java.util.Date?

Почему-то, когда мы создаем Date в Java, а потом смотрим эту сохраненную Date в БД, то там может оказаться другое время (отличное от того что мы видим в debug, который выводит нам время в виде строки).
Путем экспериментов можно догадаться, что тайм зона например на московских серверах видимо московская, т.е. GMT+4. Видимо, в Date указана TZ? Зачем серверу хранить Date с учетом тайм зоны? Почему бы ее не хранить в UTC хотя бы, или лучше в миллисекундах? Ответ следующий - Date это только лишь миллисекунды. В ней нет никаких тайм зон:
  1. В Java с датами работают через объекты java.util.Date. Внутри эти объекты дату хранят как число миллисекунд с 1970 года в GMT (UTC). Date – по сути обертка над одним единственным long полем.
  2. Любые сравнения дат сводятся к сравнению миллисекунд.
  3. Есть API, которое позволяет печатать даты в разных тайм зонах (есть некоторый метод, принимающий дату и тайм зону).
  4. Значения Date хранятся в БД в полях, имеющим тип DATE. Согласно документации, Oracle сохраняет дату как 7 полей – век, год, месяц, день, часы, минуты, секунды.
  5. Прикладной код работает именно с java.util.Date. Сохранение в DB даты осуществляется через JDBC API. В это API передается java.util.Date, дальше можно считать, что JDBC драйвер/Oracle форматирует дату в тайм зоне DB или App Server (разбивает ее на год, месяц, часы и т.д.) и сохраняет их в таком виде.
  6. Пример для п.5: дата 09/25/2013 1:00:00 AM MSK, в EST тайм зоне эта же дата - [09/24/2013 16:00:00.000 EST], эта же дата как число миллисекунд с 1970 UTC – 1380056400000. В Java с этой датой будут работать просто как с числом миллисекунд с 1970 UTC. Если тайм зона Application Server = EST, то [09/25/2013 1:00:00 AM MSK] сохранится в DB как {2013,09,24,16,00,00}.
  7. Прикладной код читает дату из DB через то же JDBC API. При чтении данных из полей БД (а это просто набор из 7 чисел – год, месяц и т.д.) JDBC драйвер воспринимает их как дату в тайм зоне App Server и в соответствии с этим создает java.util.Date.
  8. Пример для п.7: из примера 6 в поле БД имеем {2013,09,24,16,00,00}. Прикладной код через JDBC читает это значение и получает java.util.Date c millis=1380056400000 (или [09/25/2013 1:00:00 AM MSK] или [09/24/2013 16:00:00.000 EST]), так как JDBC драйвер воспринимает набор {2013,09,24,16,00,00} в таймзоне App Server, т.е. EST.

Попытка использовать TZ на клиенте в GWT

Т.к. мы не можем воспользоваться парсингом даты из строки в GWT, можно пойти по пути передачи в GWT смещения часового пояса. Никогда не пытайтесь передавать смещение вместо тайм зоны. Вычисления смещения это очень сложная функция, хотя она кажется простой на первый взгляд. Сначала вы думаете, что это просто смещение, потом оказывается, что есть летнее\зимнее время (Daylight Saving Time или DST), потом оказывается что в GWT для дат раньше, чем 1970 нет данных о DST. Подробнее о муках хорошо написано в этом посте.
Преобразование даты с учетом тайм зоны вещь сложная, т.к. функции преобразования дат с учетом тайм зон
  • dateAsString = F(date, TZoffset(date))
  • date = F-1(dateAsString , TZoffset(date)))
зависят не только от TZ, но и от самой даты. Особенной сложна конвертация из строки в дату. Сама TZoffset - по сути, табличная функция, значение которой меняется периодически с ходом Истории (законов, политики – например, из-за олимпиады в Сочи переход на летнее время отменили):
  • исчезновения\появления часовых поясов
  • наличием Daylight saving time
  • изменение часового пояса. Например, для Московской тайм зоны 27 марта 2011 изменилось смещение на +4 вместо +3, причем это не переход на летнее время, а именно изменения часового пояса
  • високосные секунды
  • другое
Таким образом, лучше всего парсить и форматировать дату на сервере, где есть уже готовый механизм в Java. Но если все таки нужно на клиенте, то читайте главу ниже.

Решение проблемы TZ на клиенте

Для работы с TZ на клиенте можно воспользоваться JS фреймворком TimeZone-JS (по ссылке подробно написано как с ним работать).
Интересная делать - данные о смещения он берет из IANA Time Zone Database (смотри файл tzdata2013i.tar.gz). База знаний реферативная, поэтому в ней очень много комментариев. Основана она Давидом Олсоном, поэтому ее также легко найти по словам olson timezone.
Такая связка фрейворка и базы знаний умеет учитывать DST, исторические смещения и даже есть данные о високосных секундах. Соответственно можно написать клиентский класс с нативными методами для обращения к TimeZone-JS.

Грабли

Кажется вот и все, однако при отладке GWT хелпера можно наткнулся на несколько граблей. Хочу их описать, чтобы другие их обошли.
Первая грабля. В TimeZone-JS дата создается через new timezoneJS.Date(year, month, day, hour, minute, second, millisecond, timeZoneName). Причем год начинается с 0, а не с 1900 как в Java или с 1970 – момент ноль в UTC, первый месяц с 0, первый день 1.
Вторая грабля. В Java TimeZone.getTimeZone(“GMT+1”).getID() вернет GMT+01:00, а в Olson TZ нет такой TZ. Поэтому нужно ее преобразовать GMT+01:00 -> GMT+1 -> Etc/GMT+1.
Третья грабля. Есть различия в понимании записи GMT+1, ранее это понималось как +1, но в Olson TZ описывают, что это устаревший формат и GMT+1 надо понимать как -1. В результате в Java время 19:00 UTC отображается как 20:00 GTM+1, а в TimeZone-JS - 18:00 GTM+1. Поскольку лицензия Olson TZ позволяет менять данные в ее файлах, а также в комментариях в Olson TZ есть указание, что можно поменять знак, то так и делаем. При выкладывании новых версий Olson TZ нужно также проделывать процедуру смены знака для GMT часовых поясов.
Четвертая грабля. Это наверно довольно известная проблема, но я ее все же опишу. В GWT не стоит передавать Date на сервер или в js. Лучше преобразовать в long, но с лонгом на js тоже могу быть проблемы, поэтому передавайте Date как миллисекунды в виде String в GWT между сервером и клиентом и между клиентом и нативным js. Пятая грабля. Различайте маленькие и большие часы в маске. HH:mm – здесь HH может принимать 0-23. hh:mm – hh может принимать 0-12, обычно используется в виде hh:mm a, где a – вывод AM\PM (подробнее о масках).
Шестая грабля. Можно легко запутаться в 12-часовом формате. Например, при выборе даты GWT через DatePicker всегда ставит 12:00 p.m. Помните, что 12:00 p.m. это 12:00, а не 00:00 в 24-часовом формате. А также что 00:00 в 12-часовом формате нет, оно равняется 12:00 a.m. Подробнее можно посмотреть на видипедии.
Седьмая грабля. Форматировать дату с учетом тайм зоны при получении даты от DatePicker не нужно, т.к. мы хотим получить от него только текст. Точнее нужно форматировать в клиентскую тайм зону.
Восьмая грабля. В дебаге GWT тайм зона для Europe/Moscow может отличается на час относительно смещения в нормальном режиме.

О тайм зонах в браузере и на сервере

В процессе разработки возникает справедливый вопрос как же браузер и сервер узнают о смещениях в часовых поясах.
Рассмотрим браузер. Попробуем вывести оффсет тайм зоны до и после 27.03.2011 02:00 (в этот момент менялось смещение в Москве):
var dt = new timezoneJS.Date(2011, 2, 27, 1, 59, 'Europe/Moscow'); //изменение пояса
var dt = new timezoneJS.Date(2010, 9, 31, 2, 59, 'Europe/Moscow'); //переход на летнее\зимнее время
dt.getTimezoneOffset(); // оффсет при 59 и 61 минуте
В результате получим один и тот же оффсет. Судя по данным в интернете и экспериментах getTimezoneOffset – всего лишь возвращает в миллисекундах смещение, которое указано в часовом поясе в винде. Есть даже специальные хаки для вычисления DST. Поэтому можно получить забавную ситуацию, когда пользователь на старом ПК без обновлений в винде вносит данные его часовой пояс может быть устаревшим, т.е. время будет отличиться на несколько часов.
Теперь рассмотрим Java. Можно предположить, что она тоже берет смещения из OS, только более умно, но это не так. Согласно описанию Timezones на сайте Oracle – данные о смещения беруться не из OS, а хранятся в Java. Причем важно обновлять Java, чтобы иметь актуальные данные о TZ. Соответственно java не гарантирует точность данных TZ, т.к. может быть что обновление только что вышло, а часовые пояса изменились чуть позже обновления.
Возникает вопрос, как часто меняются TZ данные? Если посмотреть на версии TZ данных в java, то видим, что java использует TZ от IANA! Т.е. тоже самое, что и мы в главе выше.

Что такое UTC и GMT

Напоследок немного справки, в чем различия между основными тайм зонами. Информация взята из википедии, описания java.util.Date и еще одного сайта.
GMT – время относительно нулевого меридиана. Устаревшее время, т.к. оно крайне неточное для сегодняшнего мира, поэтому в 1970 создали UTC. Сейчас GMT используют как синоним UTC.
UTC - отличается от TAI (международные атомные часы) на целое число секунд, так чтобы совпадать с UT1 (точное локальное астрономическое время на меридиане 0, скорректированное в зависимости от скорости вращения в различное время года) с точностью не хуже 0.9 секунд. Подгонка осуществляется добавлением к UTC дополнительной - leap - секунды время от времени на границе полугодия (в среднем секунда "набегает" каждые 18 месяцев); так что значение времени 23:59:60 возможно, а число секунд в году непостоянно; теоретически также возможна необходимость уменьшения UTC.
GPS – синхронизирована с UTC, но в ней нет добавления leap секунды.
Europe/Moscow - сегодня она равна UTC+4, но может менять сдвиг в зависимости от законов\политики и времени года.

Ссылки

  1. Документация ORACLE о хранении даты в БД - http://docs.oracle.com/cd/B28359_01/server.111/b28318/datatype.htm#i1847
  2. Проблемы в GWT с тайм зонами - http://www.summa-tech.com/blog/2012/08/06/the-problem-with-gwts-datetimeformat/
  3. Описание смещение часовых поясов для Москвы - http://www.timeanddate.com/worldclock/timezone.html?n=166
  4. База знаний о часовых поясах - http://www.iana.org/time-zones
  5. JS-фреймворк для работы с тайм зонами - https://github.com/mde/timezone-js
  6. Трудности в 12-часовом формате - http://ru.wikipedia.org/wiki/12-%D1%87%D0%B0%D1%81%D0%BE%D0%B2%D0%BE%D0%B9_%D1%84%D0%BE%D1%80%D0%BC%D0%B0%D1%82_%D0%B2%D1%80%D0%B5%D0%BC%D0%B5%D0%BD%D0%B8
  7. Что такое UTC - http://ru.wikipedia.org/wiki/%D0%92%D1%81%D0%B5%D0%BC%D0%B8%D1%80%D0%BD%D0%BE%D0%B5_%D0%BA%D0%BE%D0%BE%D1%80%D0%B4%D0%B8%D0%BD%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5_%D0%B2%D1%80%D0%B5%D0%BC%D1%8F
  8. Что такое UTC - http://docs.oracle.com/javase/6/docs/api/java/util/Date.html
  9. Что такое UTC - http://cisco.bog.pp.ru/work/time.html
  10. getTimezoneOffset - http://javascript.ru/Date/getTimezoneOffset
  11. Хак для вычисления DST в JS - http://javascript.about.com/library/bldst.htm
  12. Тайм зоны в java - http://www.oracle.com/technetwork/java/javase/timezones-137583.html
  13. Версии тайм зон в java - http://www.oracle.com/technetwork/java/javase/tzdata-versions-138805.html
  14. Маски даты - http://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html