Статья не претендует на глубину и/или академичность. Это попытка систематизировать опыт и обратить внимание разработчиков на не очень очевидные аспекты.
В JDK8 появилось новое Date Time API, в котором появилось множество новых полезных классов, но я не буду его упоминать, также как и об отличных сторонних библиотеках. Это отдельная, большая тема, которая заслуживает отдельной статьи; плюс, как я уже упомянул, я стараюсь рассказывать не о какой-то конкретной технологии, а о принципах работы в целом, а в этом плане новое API принципиально ничем не отличается от старого.
Временная ось
Начнем сильно издалека. Прямо очень издалека. Нарисуем ось времени.
Прямо тут начинаются разные вопросы. Идет ли время всегда в одном направлении? Идет ли оно равномерно? Непрерывно ли оно? Что принять за единичный вектор? Едина ли эта ось для всех? Зависит ли от положения в пространстве? От скорости движения? Каков минимально измеряемый отрезок?
Собственно тут я готов только задавать вопросы, но никак не отвечать на них. Есть мнение, что никакого времени нет, но я также пока не готов к обсуждению этого вопроса.
Но есть и уже решенные моменты.
Про единицу измерения все ясно — она четко и однозначно специфицирована.
Расстояние от Москвы до Вашингтона составляет примерно 7840000 метров и свет проходит это расстояние по поверхности земли минимум за 0.026 секунды, что совсем немало. Запрос на создание учетной записи пользователем во Владивостоке, будет отработан на московском сервере только через некоторое время. Таким образом информация о происходящих событиях доступна совсем не сразу и зависит от расстояния между точками в пространстве.
Кроме того, сама скорость течения времени зависит от скорости перемещения объекта, причем даже для вполне рядовых около-земных технологий вроде GPS.
Текущая стандартная библиотека обработки времени на Java считает, что никаких релятивистских эффектов не существует и никто не движется на около-световых скоростях, а ось времени одна и едина для всех (по крайней мере в масштабах одной планеты) — и это вполне всех нас устраивает. Возможно впоследствии в JDK #6543 будет реализован новый Java Date Time API, который позволит написать сервис для внутренней офисной системы «Сокола Тысячелетия» с учетом скорости его движения и наличия/отсутствия кротовых нор рядом.
Теперь отметим на временной оси некий момент. Вот, например, прямо сейчас я нажму на кнопку «точка». (Нажал)
Теперь нужно придумать способ, с помощью которого я смог бы сообщить вам о том, в какой именно момент я нажал эту кнопку. Самый простой способ сделать это — обозначить какой-то момент времени, общий для нас всех, с которого мы все постоянно отсчитываем временные отсчеты. Если этот момент времени обозначен (тот-самый-момент), то я смогу передавать вам число своих отсчетов с этого общего момента, а вы сможете понять отношение между временем нажатия кнопки и своим текущим временем при получении моего значения.
В примитивном физическом мире мы могли бы встретиться и одновременно запустить одинаковые песочные часы. После чего спокойно разойтись по своим делам, а времена событий сообщать в виде высоты песочного столба на нашем экземпляре часов (вероятно часы должны быть очень большими и громоздкими).
Используемый нами тот-самый-момент, в свою очередь, может быть также измерением — но уже относительно какого-то более общего и важного события. В нашем случае так и происходит, тот-самый-момент в системе Unix-time (числовое значение 0), которая используется в Java — это временная точка с меткой 00:00:00 1 января 1970 от Р.Х. по UTC уже по другой шкале — Григорианскому календарю.
При чем тут Java
Для задания временной точки на временной оси в Java существует класс java.util.Date. Вообще, java.util.Date — это позор Java, начиная с самых ранних версий. Во-первых, у него название не отражает суть; а во-вторых, он mutable. Однако жизнь будет проще, если вы будете воспринимать его как простую обертку над внутренним полем типа long в котором хранится количество миллисекунд с того-самого-момента — и ничего более. Все остальные методы помечены как устаревшие и использовать их не нужно ни в коем случае. Просто запомните, что java.utl.Date тождественен (по своей сути) простому числовому long-значению Unix-time в миллисекундах.
Date moment = new Date(1451665447567L); // Задаем количество миллисекунд Unix-time с того-самого-момента
moment.getTime(); // Узнаем количество миллисекунд Unix-time с того-самого-момента.
Если немного подумать, то становится понятно, что в любом языке есть ограничения точности представления времени. Поэтому java.util.Date (как и любые другие подобные типы) представляет собой вовсе не точку на временной оси, а отрезок. В нашем случае — отрезок миллисекундной длительности. Но с практической точки зрения такая точность нас устраивает, поэтому и дальше будем называть это точкой.
Поскольку представление в Java с самого начала 64-битное, то на наш век хватит точно:
Date theEnd = new Date(Long.MAX_VALUE);
DateFormat dateFormat = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.LONG, SimpleDateFormat.LONG);
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
String text = dateFormat.format(theEnd);
System.out.println(text);
# August 17, 292278994 7:12:55 AM UTC
Для различных операций с временем таких как чтение/установка/модификация отдельных календарных полей (год, месяц, день, часы, минуты, секунды и прочее) существует класс java.util.Calendar. Он также не без греха — при операциях помните, что месяцы идут с 0 (лучше использовать константы), а дни идут с 1.
@Test
public void testSunday() throws Exception {
Calendar calendar = Calendar.getInstance();
calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
calendar.set(2016, Calendar.JANUARY, 5, 12, 30, 0);
calendar.add(Calendar.DAY_OF_YEAR, -2);
Assert.assertEquals(Calendar.SUNDAY, calendar.get(Calendar.DAY_OF_WEEK));
}
Другой мутный момент в java.util.Calendar состоит в том, что при установке в нем полной даты (yyyy,MM,dd,HH,mm,ss) количество миллисекунд не сбрасывается в 0, а остается равным количеству миллисекунд от предыдущего установленного момента (текущего времени, если календарь не менялся). Поэтому, если по условиям задачи в миллисекундах должно быть 0, то это поле нужно сбросить еще одним дополнительным вызовом:
@Test
public void testCalendarMs() throws Exception {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
Calendar calendar = Calendar.getInstance(tz);
calendar.setLenient(false);
calendar.set(2016, Calendar.APRIL, 20, 12, 0, 0);
System.out.println(calendar.getTimeInMillis());
calendar.set(Calendar.MILLISECOND, 0);
System.out.println(calendar.getTimeInMillis());
}
1461142800808 1461142800000
Первое число гуляет в первых трех разрядах в зависимости от времени вызова. Это поведение может быть достаточно критичным в тестах.
Для перевода временных меток в точки на оси и обратно существует класс java.text.DateFormat и его наследники.
@Test
public void testFormat() throws Exception {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
dateFormat.setLenient(false);
dateFormat.setTimeZone(tz);
Date moment = dateFormat.parse("2005-03-27 01:30:00");
Assert.assertEquals("2005-03-27 01:30:00", dateFormat.format(moment));
}
Про java.text.DateFormat и java.util.Calendar обязательно нужно сказать следующее:
- У обоих классов есть метод setTimezone() для явной установки временной зоны. Крайне желательно всегда его использовать для того, чтобы обозначить, что вы полностью контролируете процесс, а не полагаетесь на временную зону по-умолчанию.
- У обоих классов есть метод setLenient() для установки «мягкого» режима. В таком режиме оба класса будут снисходительно относиться к ошибкам в календарных метках, пытаясь угадать что же вы имели в виду на самом деле. Тут зависит от ситуации, но я бы рекомендовал угадывание отключать (по умолчанию «мягкий» режим включен).
- Оба класса потоко-небезопасны. И, если для java.util.Calendar это совершенно ожидаемо (поскольку мы понимаем что у него есть внутреннее состояние), то, в случае java.text.DateFormat, это для многих оказывается сюрпризом.
Также есть (в старом АПИ) еще несколько классов:
java.sql.Timestamp — расширение (subclass) java.util.Date с наносекундной точностью для работы с типом TIMESTAMP в БД
java.sql.Date — расширение (subclass) java.util.Date для работы с типом DATE в БД.
java.sql.Time — расширение (subclass) java.util.Date для работы с типом TIME в БД.
Кроме того, любую временную точку можно хранить в виде обычного long-значения Unix-time в миллисекундах.
Временные зоны
Любой, кто работал в глобальной компании со множеством офисов по всему миру (или даже просто по России) знает, что информация которая содержится во фразе «совещание будет в 01 января 2016 в 14:00:00» практически бесполезна. Метка «14:00» не соответствует какой-то конкретной точке на временной оси, а вернее сказать — соответствует сразу нескольким. Для того, чтобы все собрались в видео-переговорках в одно и то же время, организатору совещания нужно указать кое-что еще, а именно временную зону, в которой мы будем интерпретировать метку «14:00». Часто временная зона подразумевается по главенству головного офиса («работаем по московскому времени»), в противном же случае, если временная зона по-умолчанию вообще никак не подразумевается, то нужно задать ее в явном виде, например «01 января 2016 в 14:00:00 MSK» — в этом случае точка на временной оси задается совершенно однозначно и все соберутся на совещание в одно и тоже время.
Для устранения неоднозначности при операциях с временными метками в формате ЧЧ:MM:CC временная зона должна быть указана как при выводе временной метки, так и при вводе.
Можно не указывать временную зону явно, в случаях когда ее можно каким-либо образом подразумевать неявно:
- временная зона подразумевается одной и той же по умолчанию для всего сервиса;
- временная зона явно указана в профиле самим пользователем;
- временную зону пользователя можно вычислить по его положению через гео-координаты;
- временную зону пользователя можно вычислить по его положению через IP адрес;
- временную зону пользователя можно вычислить по его положению через косвенные признаки (анализ поведения);
- текущее смещение временной зоны пользователя можно вычислить через JavaScript;
Наверное стоит отметить, что временная зона сервиса (по умолчанию) и временная зона сервера (по умолчанию) — это в общем совсем не одно и то же. BIOS, cистема, приложение и утилиты могут, например, работать во временной зоне UTC, но при всем этом временной зоной сервиса будет временная зона Москвы. Хотя конечно сильно проще когда они совпадают — в таком случае админы настраивают временные зоны на серверах, а программисты о них не думают вообще. Если это как раз ваш случай — дальше можете не читать.
Одну и ту же временную метку можно выводить по-разному для пользователей — с использованием той временной зоны, которая наиболее привычна для каждого. Например, следующие временные метки указывают на одну и ту же временную точку на временной оси:
Fri Jan 1 16:29:00 MSK 2016
Fri Jan 1 13:29:00 UTC 2016
Fri Jan 1 14:29:00 CET 2016
Fri Jan 1 21:29:00 SGT 2016
Fri Jan 1 22:29:00 JST 2016
Википедия про временные зоны.
В Java информация о временной зоне представлена классом java.util.TimeZone.
Нужно сказать, что временная зона — это не смещение. Нельзя сказать что GMT+3 — это Europe/Moscow. Но можно сказать, что в течение всего 2016-го года временная зона Europe/Moscow будет соответствовать смещению GMT+3. Временная зона — это вся история смещений полностью за весь исторический период, а также другие данные, которые позволяют нам правильно вычислять смещения в разные исторические моменты, а также производить правильные временные расчеты.
Поисследуем временные зоны — для начала посмотрим на Europe/Moscow:
@Test
public void testTzMoscow() throws Exception {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
System.out.println(tz.getRawOffset());
System.out.println(tz.getOffset(System.currentTimeMillis()));
System.out.println(tz.useDaylightTime());
System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.ENGLISH));
System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.ENGLISH));
System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.ENGLISH));
System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.ENGLISH));
System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.FRENCH));
System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.FRENCH));
System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.FRENCH));
System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.FRENCH));
}
10800000 10800000 false Moscow Standard Time MSK Moscow Daylight Time MSD Heure standard de Moscou MSK Heure avancee de Moscou MSD
Видим, что у зоны Europe/Moscow текущее базовое смещение составляет +3 часа относительно UTC, а общее смещение в данный момент составляет также +3 часа. Перевод стрелок на летнее время в перспективе отсутствует. У зоны есть отдельные имена для летнего и зимнего времени.
Теперь посмотрим на парижское время:
@Test
public void testTzParis() throws Exception {
TimeZone tz = TimeZone.getTimeZone("Europe/Paris");
System.out.println(tz.getRawOffset());
System.out.println(tz.getOffset(System.currentTimeMillis()));
System.out.println(tz.useDaylightTime());
System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.ENGLISH));
System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.ENGLISH));
System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.ENGLISH));
System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.ENGLISH));
System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.FRENCH));
System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.FRENCH));
System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.FRENCH));
System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.FRENCH));
}
3600000 3600000 true Central European Time CET Central European Summer Time CEST Heure d'Europe centrale CET Heure d'ete d'Europe centrale CEST
Базовое смещение составляет +1 час относительно UTC, общее смещение в данный момент составляет также +1 час. Переход на летнее время в перспективе у зоны есть. Также присвоено два имени — для зимнего и для летнего времени отдельно.
Теперь посмотрим на зону «GMT+5». Это фактически не совсем временная зона — у нее нет истории, нет летнего времени, а смещение постоянно.
@Test
public void testGmt5() throws Exception {
TimeZone tz = TimeZone.getTimeZone("GMT+5");
System.out.println(tz.getRawOffset());
System.out.println(tz.getOffset(System.currentTimeMillis()));
System.out.println(tz.useDaylightTime());
System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.ENGLISH));
System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.ENGLISH));
System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.ENGLISH));
System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.ENGLISH));
System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.FRENCH));
System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.FRENCH));
System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.FRENCH));
System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.FRENCH));
}
18000000 18000000 false GMT+05:00 GMT+05:00 GMT+05:00 GMT+05:00 GMT+05:00 GMT+05:00 GMT+05:00 GMT+05:00
Так и есть, смещение постоянно, составляет +5 часов относительно GMT и никогда не меняется.
Примеры
Продолжим рассказывать примерами то, что слишком долго объяснять на словах. Для начала посмотрим, что происходило в 2005 году во временной зоне «Europe/Moscow»:
$ zdump -v /usr/share/zoneinfo/Europe/Moscow | grep 2005
/usr/share/zoneinfo/Europe/Moscow Sat Mar 26 22:59:59 2005 UT = Sun Mar 27 01:59:59 2005 MSK isdst=0 gmtoff=10800
/usr/share/zoneinfo/Europe/Moscow Sat Mar 26 23:00:00 2005 UT = Sun Mar 27 03:00:00 2005 MSD isdst=1 gmtoff=14400
/usr/share/zoneinfo/Europe/Moscow Sat Oct 29 22:59:59 2005 UT = Sun Oct 30 02:59:59 2005 MSD isdst=1 gmtoff=14400
/usr/share/zoneinfo/Europe/Moscow Sat Oct 29 23:00:00 2005 UT = Sun Oct 30 02:00:00 2005 MSK isdst=0 gmtoff=10800
Отлично, мы видим перевод стрелок на летнее и обратно на зимнее время. Посмотрим на то, что происходит в эти моменты с временными метками. Для начала — переход на зимнее время:
@Test
public void testWinterTime() throws Exception {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
dateFormat.setLenient(false);
dateFormat.setTimeZone(tz);
Calendar calendar = Calendar.getInstance();
calendar.setLenient(false);
calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
calendar.set(2005, Calendar.OCTOBER, 29, 22, 0, 0);
for (int i = 0; i < 62; i++) {
String mark = dateFormat.format(calendar.getTime());
System.out.printf("%s - %d, %s\n", mark,
tz.getOffset(calendar.getTimeInMillis()),
tz.inDaylightTime(calendar.getTime()));
calendar.add(Calendar.MINUTE, +1);
}
}
2005-10-30 02:00:00 MSD - 14400000, true 2005-10-30 02:01:00 MSD - 14400000, true ... 2005-10-30 02:58:00 MSD - 14400000, true 2005-10-30 02:59:00 MSD - 14400000, true 2005-10-30 02:00:00 MSK - 10800000, false 2005-10-30 02:01:00 MSK - 10800000, false
Видим, что после 02:59:00 MSD стрелки сдвигаются на час назад и следующей меткой идет уже 02:00:00 MSK — зимнее время. Также временная зона говорит о том, что летнее время закончилось, а смещение изменилось с GMT+4 на GMT+3.
В примере есть интересный нюанс: c помощью зоны «Europe/Moscow» совершенно невозможно установить в календаре точку соответствующую метке 02:00:00 MSD — устанавливается точка 02:00:00 MSK, что на час позже чем нужно нам. Чтобы задать эту точку как начало отсчета, приходится прибегать к услугам временной зоны UTC, в которой можно установить все. Другим вариантом может быть установка точки 01:00:00 MSD в зоне «Europe/Moscow» и прибавление часа.
Теперь — переход на летнее время:
@Test
public void testSummerTime() {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
dateFormat.setLenient(false);
dateFormat.setTimeZone(tz);
Calendar calendar = Calendar.getInstance();
calendar.setLenient(false);
calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
calendar.set(2005, Calendar.MARCH, 26, 22, 0, 0);
for (int i = 0; i <= 60; i++) {
String mark = dateFormat.format(calendar.getTime());
System.out.printf("%s - %d, %s\n", mark,
tz.getOffset(calendar.getTimeInMillis()),
tz.inDaylightTime(calendar.getTime()));
calendar.add(Calendar.MINUTE, +1);
}
}
2005-03-27 01:00:00 MSK - 10800000, false 2005-03-27 01:01:00 MSK - 10800000, false ... 2005-03-27 01:58:00 MSK - 10800000, false 2005-03-27 01:59:00 MSK - 10800000, false 2005-03-27 03:00:00 MSD - 14400000, true 2005-03-27 03:00:01 MSD - 14400000, true
Видно, что после 01:59:00 MSK сразу следует 03:00:00 MSD — то есть перевод стрелок на час вперед. Временная зона сигнализирует, что в этот момент смещение меняется с GMT+3 на GMT+4, а также появляется флаг летнего времени.
Но что будет если мы попробуем обработать метку «2005-03-27 02:30:00» в зоне «Europe/Moscow» — в теории такой метки существовать не должно?
@Test
public void testMissing() throws Exception {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
dateFormat.setLenient(false);
dateFormat.setTimeZone(tz);
Date moment = dateFormat.parse("2005-03-27 02:30:00");
System.out.println(moment);
}
java.text.ParseException: Unparseable date: "2005-03-27 02:30:00"
Все верно — в строгом режиме мы получаем исключение.
Посчитаем длительность дня в день перевода стрелок на зимнее время:
@Test
public void testWinterDay() {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
Calendar calendar = Calendar.getInstance();
calendar.setLenient(false);
calendar.setTimeZone(tz);
calendar.set(2005, Calendar.OCTOBER, 30, 0, 0, 0);
Date time1 = calendar.getTime();
calendar.add(Calendar.DAY_OF_YEAR, +1);
Date time2 = calendar.getTime();
System.out.println(TimeUnit.MILLISECONDS.toHours(time2.getTime() - time1.getTime()));
}
25
С 2005-10-30 00:00:00 MSD до 2005-10-31 00:00:00 MSK прошло 25 часов, а не 24.
Теперь проверим день перехода на летнее время:
@Test
public void testSummerDay() {
TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");
Calendar calendar = Calendar.getInstance();
calendar.setLenient(false);
calendar.setTimeZone(tz);
calendar.set(2005, Calendar.MARCH, 27, 0, 0, 0);
Date time1 = calendar.getTime();
calendar.add(Calendar.DAY_OF_YEAR, +1);
Date time2 = calendar.getTime();
System.out.println(TimeUnit.MILLISECONDS.toHours(time2.getTime() - time1.getTime()));
}
23
C 2005-03-27 00:00:00 MSK до 2005-03-28 00:00:00 MSD прошли 23 часа, а не 24.
Эти два последних примера посвящены тем, кто прибавляет 24*60*60*1000 миллисекунд не как 24 часа, а как календарный день. Вы можете сказать, теперь такой проблемы нет, так как больше нет и переводов на летнее/зимнее время. На это я могу ответить следующее:
- ваша программа должна работать корректно в любой временной зоне, а не только зоне «Europe/Moscow»;
- расчеты «назад» (в прошлое) все равно требуют корректного подхода;
- в 2016 году у нас будут выборы госдумы, а в 2018 будут выборы президента — так что я думаю, что история еще не закончена.
java.sql.Time, java.sql.Date
Типы предназначаются для работы с SQL типами TIME и DATE соответственно. Подразумевается, что оба значения от временной зоны не зависят, но к сожалению это не совсем так. Поскольку оба типа являются наследниками java.util.Date — интерпретация дней-часов зависит от временной зоны:
@Test
public void testSqlTime() throws Exception {
// Предположим что сейчас 2015-01-01 01:00:00 MSK
Calendar calendar = Calendar.getInstance();
calendar.setTimeZone(TimeZone.getTimeZone("Europe/Moscow"));
calendar.setTimeInMillis(0);
calendar.set(2015, Calendar.JANUARY, 10, 1, 0, 0);
long now = calendar.getTimeInMillis();
// Создаем инстанс java.sql.Time
java.sql.Time sqlTime = new java.sql.Time(now);
java.sql.Date sqlDate = new java.sql.Date(now);
// Теперь выводим значение времени в временной зоне Europe/London
DateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
timeFormat.setTimeZone(TimeZone.getTimeZone("Europe/London"));
Assert.assertEquals("22:00:00", timeFormat.format(sqlTime));
// Теперь выводим значение даты в временной зоне Europe/London
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
dateFormat.setTimeZone(TimeZone.getTimeZone("Europe/London"));
Assert.assertEquals("2015-01-09", dateFormat.format(sqlDate));
}
В принципе оба типа справляются с задачей переноса информации от бизнес-логики в JDBC-драйвер поскольку обычно код и там и там работает в одной временной зоне, но в более продвинутых случаях, включая сериализацию, надо быть очень аккуратным при использовании этих классов.
В новом API для соответствующих типов подобные проблемы решены.
UTC, GMT
Большинство знают, что GMT и UTC — это особые обозначения, относительно которых оформляются смещения в других временных зонах. Но не все знают, что UTC и GMT — это не совсем одно и тоже (формально). Я имею в виду то, что метки «2015-12-01 00:00:00 GMT» и «2015-12-01 00:00:00 UTC» обозначают различные (хоть и близкие) точки на временной оси.
GMT вычисляется астрономически по положению земли относительно других объектов. GMT также напрямую используется в качестве временной зоны в некоторых странах.
Поскольку вращение земного шара хаотично замедляется, земля оказывается в одном и том же положении через все увеличивающиеся промежутки времени. Таким образом расстояние между временными точками по соседним меткам по GMT (например «10:00:01» и «10:00:02») может точно не равняться одной секунде.
UTC введен на замену GMT и рассчитывается по атомным часам. Непосредственно в качестве временной зоны не используется (только как опора для смещения).
В UTC расстояние между временными метками (например «10:00:01» и «10:00:02») совершенно одинаковое и строго равно одной секунде. Замедление земного вращения и накапливающееся отличие от GMT решается вводом лишней секунды в году (или даже двух) — а именно секунды координации (leap second).
Таким образом разница между точками с одинаковыми метками в GMT и UTC никогда не превышает одной секунды.
Пишут, что время UTC практически повсюду вытеснило GMT, и что использовать обозначения смещений в виде GMT+3 уже давно моветон — правильно использовать обозначение UTC+3.
Ни GMT ни UTC летнего времени не имеют.
Надо сказать что Unix-time, который используется в Java, ни UTC ни GMT напрямую не соответствует. С одной стороны в Unix-time разница между соседними метками составляет всегда равно 1 секунду, с другой стороны наличие leap second в Unix-time не предполагается.
Временная зона по умолчанию
Отображаете ли вы временную зону при выводе явно или не отображаете, запрашиваете ли вы временную зону при вводе или не запрашиваете, указываете ли вы временную зону при операциях над временем или не указываете — какая-то временная зона все равно присутствует в этих операциях неявно. Если вы не указали свою — будет использована временная зона по-умолчанию.
Термин временная зона по-умолчанию уже был упомянут несколько раз по тексту выше. Все потому что без этого понятия ничего и объяснить толком нельзя. Все операции с временем, вывод и ввод временных меток требуют временную зону. То что вы ее не указываете, не означает что ее нет — просто она берется по-умолчанию.
Но все снова не так-то просто — по умолчанию для кого и чего?
Начнем с ядра. В мануале к hwclock сказано, что в ядре есть внутренняя концепция временной зоны, но ее почти никто не использует, кроме некоторых редких модулей — вроде драйвера файловой системы FAT. Проинформировать ядро о смене временной зоны можно этой же командой hwclock.
Прикладные приложения определяют временную зону по-умолчанию несколькими способами.
Во-первых, общесистемная временная зона (полная информация о ней) в Ubuntu хранится в файле (может быть симлинком) /etc/localtime, а имя этой временной зоны — в файле /etc/timezone:
$ cat /etc/timezone
Europe/Moscow
$ file /etc/localtime
/etc/localtime: timezone data, version 2, 15 gmt time flags, 15 std time flags, no leap seconds, 77 transition times, 15 abbreviation chars
$ zdump -v /etc/localtime | head -n 10
/etc/localtime -9223372036854775808 = NULL
/etc/localtime -9223372036854689408 = NULL
/etc/localtime Wed Dec 31 21:29:42 1879 UT = Wed Dec 31 23:59:59 1879 LMT isdst=0 gmtoff=9017
/etc/localtime Wed Dec 31 21:29:43 1879 UT = Thu Jan 1 00:00:00 1880 MMT isdst=0 gmtoff=9017
/etc/localtime Sun Jul 2 21:29:42 1916 UT = Sun Jul 2 23:59:59 1916 MMT isdst=0 gmtoff=9017
/etc/localtime Sun Jul 2 21:29:43 1916 UT = Mon Jul 3 00:01:02 1916 MMT isdst=0 gmtoff=9079
/etc/localtime Sun Jul 1 20:28:40 1917 UT = Sun Jul 1 22:59:59 1917 MMT isdst=0 gmtoff=9079
/etc/localtime Sun Jul 1 20:28:41 1917 UT = Mon Jul 2 00:00:00 1917 MST isdst=1 gmtoff=12679
/etc/localtime Thu Dec 27 20:28:40 1917 UT = Thu Dec 27 23:59:59 1917 MST isdst=1 gmtoff=12679
/etc/localtime Thu Dec 27 20:28:41 1917 UT = Thu Dec 27 23:00:00 1917 MMT isdst=0 gmtoff=9079
Установить временную зону для системы можно специальной командой для вашего дистрибутива, для Ubuntu это:
$ dpkg-reconfigure tzdata
А также есть вежливая утилита tzselect:
$ tzselect
Please identify a location so that time zone rules can be set correctly.
Please select a continent, ocean, "coord", or "TZ".
1) Africa
2) Americas
3) Antarctica
4) Arctic Ocean
5) Asia
6) Atlantic Ocean
7) Australia
8) Europe
9) Indian Ocean
10) Pacific Ocean
11) coord - I want to use geographical coordinates.
12) TZ - I want to specify the time zone using the Posix TZ format.
#?
Вторым способом указания временной зоны является переменная окружения TZ, в которой можно указать идентификатор временной зоны индивидуально для каждой программы и/или пользователя.
$ echo $TZ
$ date
Wed Dec 30 20:18:18 MSK 2015
$ TZ=UTC date
Wed Dec 30 17:18:25 UTC 2015
$ TZ=Europe/London date
Wed Dec 30 17:18:35 GMT 2015
$ TZ=Europe/Paris date
Wed Dec 30 18:18:40 CET 2015
Некоторые программы можно попросить о специфической временной зоне в настройках и/или аргументах командной строки:
$ date --utc
Fri Jan 1 08:34:36 UTC 2016
Кстати, можно попросить date вывести только текущую временную зону без времени:
$ date +%Z
MSK
Но это для обычных программ под libc, а у нас целая платформа Java. Поэтому кроме этих двух перечисленных возможностей у нас есть еще две.
Можно указать аргумент для запуска JVM.
$ cat << EOF | scala -Duser.timezone=Europe/Paris
print("%s\n%s\n".format(java.util.TimeZone.getDefault().getID(), new java.util.Date()))
EOF
...
Europe/Paris
Wed Dec 30 19:24:00 CET 2015
А можно прямо в коде установить временную зону по умолчанию через метод TimeZone.setDefault(TimeZone timeZone):
$ cat << EOF | scala
> java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/Los_Angeles"))
> print("%s\n%s\n".format(java.util.TimeZone.getDefault().getID(), new java.util.Date()))
> EOF
...
America/Los_Angeles
Wed Dec 30 10:25:45 PST 2015
Или даже все сразу:
$ TZ=Europe/London cat << EOF | scala -Duser.timezone=Europe/Paris
java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/Los_Angeles"))
print("%s\n%s\n".format(java.util.TimeZone.getDefault().getID(), new java.util.Date()))
EOF
...
America/Los_Angeles
Wed Dec 30 10:37:28 PST 2015
База временных зон
Законодатели и правительства различных стран и даже регионов не сидят сложа руки, регулярно включая/отключая летнее время или даже перемещая регионы между часовыми поясами. Критически важно иметь на системах всю последнюю информацию о подобных изменениях — в противном случае время будет вводиться и выводиться неправильно, люди будут получать СМС-ки во время своего сна, а расчеты вроде "+2 календарных дня" будут неправильными.
В Linux обычные программы на libc используют базу временных зон состоящую из файлов в директории /usr/share/zoneinfo. Эти файлы принадлежат пакету tzdata, за которым активно присматривают разработчики каждого из дистрибутивов. Этот пакет своевременно обновляется и проблем с ним я не помню. В крайнем случае всю информацию можно обновить вручную, если ваша развернутая версия Linux уже больше никем не поддерживается.
К счастью, мне не придется расписывать тут формат содержимого этих файлов, ни историю их возникновения — поскольку на Хабре уже есть отличная статья на эту тему. Не менее отличная статья есть в википедии.
Но не все так просто.
Java использует свою собственную базу с временными зонами. И, если, для OpenJDK как правило можно просто и легко обновить пакет tzdata-java штатным пакетным менеджером, то для Oracle JDK придется либо апгрейдить всю JDK целиком на новую версию, либо пользоваться отдельной специальной утилитой для обновления базы временных зон в уже установленной JDK.
Кстати, упомянутая выше библиотеке Joda-time не использует ни системную базу tzdata, ни базу из JVM — да, у нее есть еще одна своя внутренняя база временных зон, которую нужно также обновлять отдельным и неповторимым способом.
Для python нужно ставить (и затем также не забыть обновлять) отдельную библиотеку.
Для javascript есть куча каких-то сторонних библиотек, как минимум я точно помню что поддержка есть в Google Closure.
Вообще тема того, что тот или иной софт использует свои личные базы с временными зонами, всплывает регулярно. Например, модуль календаря Lightning для почтового клиента Thunderbird хранит свою личную sqlite-базу с часовыми зонами, и поэтому при последних изменениях в нашем государстве мне приходилось делать прямые интервенции в эту базу для корректировки. Иначе все митинги просто плыли по времени.
Вообще, есть ощущение, что в основной массе разработчики не страдают паранойей (как я), про временные зоны никто не думает и в базовые поставки своих платформ tzdata никто не включает — кроме разработчиков JVM.
Отдельное слово я хотел бы сказать про Android. Буду краток — временные зоны в Android это боль. При разработке платформы никто не подумал про отдельный механизм обновления tzdata, как и про то, что у законодателей по всему миру есть страшный зуд к переменам (кто бы мог подумать). Базы с временными зонами меняются только в случае, если вендор прошивки этого захочет. Учитывая то, что некоторые вендоры перестают узнавать свои собственные телефоны уже через полгода, то можно сказать, что на многих аппаратах tzdata просто не обновляется никогда. Продвинутые пользователи меняют текущую временную зону в своих аппаратах на другую, более-менее подходящую текущим условиям (например Europe/Minsk вместо Europe/Moscow). Непродвинутые пользователи все также живут в Europe/Moscow (GMT+4) и просто переводят стрелки — в результате чего временные метки событий во всех программах сдвигаются на час назад. Есть конечно вариант с рутированием и использованием сторонних решений для обновления, но всех пользователей рутировать телефоны не заставишь.
Календари
Про необходимость указания временной зоны вместе с меткой уже было сказано. Однако настоящие параноики должны бы указывать еще и систему летоисчисления. Мы не делаем этого, потому как наиболее развитая часть населения земного шара уже договорилась использовать григорианский календарь, хотя мы до сих пор в удовольствием празднуем новый год по юлианскому календарю, а некоторые из нас высказывают другие, отличающиеся точки зрения на то, как именно правильно считать даты.
Есть совершенно другие, порой достаточно странные системы счисления, в которых одно и ту же временную точку можно отобразить совершенно по-иному. Например — календарь Чучхе. Вообще таких систем оказывается достаточно много, а все мы просто не задумываемся, что наш календарь лишь один из многих, возможно самый используемый, но не единственный. Поиграться с некоторыми можно тут.
Leap year
Високосный год — год в котором 366 дней, а не 365 дней как в обычном году. В високосном году добавляется один день к февралю — 29 февраля.
Формула определения того, что год високосный проста и описана в википедии
Leap second
А вот с лишней секундой (секунда координации) все сильно сложнее. Суть процесса в том, что земля постоянно немножко замедляется и ее положение относительно звезд по одним и тем же меткам времени постоянно меняется. Если не производить коррекцию то время дня и ночи будет постоянно сдвигаться. Чтобы этого не происходило ученые отслеживают положение земли, вычисляют необходимую коррекцию и вносят ее в план корректировки. Поскольку процесс замедления хаотичен, долгосрочный план по коррекции составить невозможно — определение необходимости ввода секунды коррекции происходит по текущей ситуации. Также теоретически возможен ввод отрицательной секунды координации — в случае если земной шар вдруг наоборот ускорится.
Подразумевается, что при наличии секунды координации, время по UTC течет с появлением 60-й секунды:
23:59:58
23:59:59
23:59:60 # leap second
00:00:00
00:00:01
В концепции Unix-time не существует понятия секунды с номером 60: «Because it does not handle leap seconds, it is neither a linear representation of time nor a true representation of UTC.»
Для того, чтобы хоть как-то соответствовать времени по UTC используется трюк с переводом времени на секунду назад в полночь:
23:59:58
23:59:59
23:59:59 # leap second
00:00:00
00:00:01
Трюк проводится либо через сервисы точного времени, либо самим ядром автономно на основании данных в файле таймзоны.
Это именно хак, который имеет свои негативные последствия:
- Количество секунд между 23:59:00 и 00:01:00 следующего дня равно 120, а не 121 как должно быть
- Поскольку одну секунду мы съедаем, все прошлое смещается на секунду вперед
В Java, поскольку все время завязано на концепцию Unix-time, также нет никакого учета leap-second. Нет ни в старом API, ни в новом API, ни в библиотеке Joda-time. При этом сама информация о leap-second в базах tzdata есть, а в JavaDoc к методу java.util.Date#getSeconds говорится о том, что в неких, пока несуществующих, гипотетических Java-машинах значение поля секунд может быть равно 60 или даже 61.
Сначала проверим то, что в момент leap second классы Java эту секунду не учитывают.
@Test
public void testLeapSecond1() throws Exception {
TimeZone tz = TimeZone.getTimeZone("UTC");
Calendar calendar = Calendar.getInstance();
calendar.setLenient(false);
calendar.setTimeZone(tz);
calendar.set(2015, Calendar.JUNE, 30, 23, 59, 0);
Date d1 = calendar.getTime();
calendar.set(2015, Calendar.JULY, 1, 0, 1, 0);
Date d2 = calendar.getTime();
long elapsed = d2.getTime() - d1.getTime();
System.out.println(TimeUnit.MILLISECONDS.toSeconds(elapsed));
}
120
Результат — 120 секунд, а не 121 как должно быть.
Теперь проверим новое API:
@Test
public void testLeapSecond2() throws Exception {
ZonedDateTime beforeLeap = ZonedDateTime.of(2015, 6, 30, 23, 30, 0, 0, ZoneOffset.UTC);
ZonedDateTime afterLeap = ZonedDateTime.of(2015, 7, 1, 0, 30, 0, 0, ZoneOffset.UTC);
long elapsed = afterLeap.toInstant().toEpochMilli() - beforeLeap.toInstant().toEpochMilli();
System.out.println(TimeUnit.MILLISECONDS.toSeconds(elapsed));
}
3600
Ровно 3600 секунд, а должно быть 3601.
Выясним, сколько именно секунд координации было за все время. Самое простое — проверить это на странице в вики.
Проверим и другими способами. Информация о секундах координации есть в дубликатах временных зон в директории /usr/share/zoneinfo/right.
$ file /usr/share/zoneinfo/right/UTC
/usr/share/zoneinfo/right/UTC: timezone data, version 2, 1 gmt time flag, 1 std time flag, 26 leap seconds, no transition times, 1 abbreviation char
$ zdump -v /usr/share/zoneinfo/right/UTC | grep '59:60' | wc -l
26
В основной директории /usr/share/zoneinfo файлы таймзон информацию о секундах координации не содержат.
$ file /usr/share/zoneinfo/UTC
/usr/share/zoneinfo/UTC: timezone data, version 2, 1 gmt time flag, 1 std time flag, no leap seconds, no transition times, 1 abbreviation char
$ zdump -v /usr/share/zoneinfo/UTC | grep '59:60' | wc -l
0
Как бы мы не смотрели — всего получается 26 таких секунд.
Теперь посчитаем сколько секунд прошло между 1970-01-01 00:00:00 UTC и 2016-01-01 00:00:00 UTC. Посчитаем двумя способами: в Java (по Unix-time) и каким-нибудь другим, более высокоточным способом.
Сначала Java:
@Test
public void testEpochDiff() throws Exception {
ZonedDateTime s = ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
ZonedDateTime f = ZonedDateTime.of(2016, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
long elapsed = f.toInstant().toEpochMilli() - s.toInstant().toEpochMilli();
System.out.println(TimeUnit.MILLISECONDS.toSeconds(elapsed));
}
1451606400
Получилось 1451606400, перепроверим:
$ dateutils.ddiff --from-zone "right/UTC" -f '%S' "1970-01-01 00:00:00" "2016-01-01 00:00:00"
1451606400
Все сходится — также 1451606400 секунд, теперь натравим высокоточное оружие:
$ dateutils.ddiff --from-zone "right/UTC" -f '%rS' "1970-01-01 00:00:00" "2016-01-01 00:00:00"
1451606425
Вот оно, теперь 1451606425 секунд. Мне не очень понятно почему разница составляет 25, а не 26 секунд, но других точных калькуляторов я пока что не нашел.
Становится понятно, что с путешествиями во времени есть серьезные проблемы — по крайней мере если мы делаем движок управления с использованием стандартной Java. Установив точное время, мы не сможем точно рассчитать количество секунд на которые мы должны откатиться — ошибка составит до полуминуты. Точные путешествия в будущее невозможны вообще в принципе — поскольку количество секунд координации заранее предопределить невозможно.
currentTimeMillis(), nanoTime()
Как практически на любой другой платформе в Java существует два источника времени: первый отображает текущую метку на общей временной оси, второй — считает время с момента подачи питания на процессор.
- java.lang.System#currentTimeMillis — возвращает количество миллисекунд прошедших с полночи 1 января 1970 (по временной зоне UTC). Именно этот метод используется при создании новых экземпляров java.util.Date и java.util.Calendar. Несмотря на то, что возвращаемое значение измеряется в миллисекундах, реальная гранулярность может быть сильно выше — до десятков миллисекунд. Никаких гарантий на монотонность нет, значение может скакать вперед и назад — в результате перевода системных часов оператором или сервисами точного времени.
- java.lang.System#nanoTime — возвращает некоторое абстрактное количество наносекундных «тиков». Значение тиков не обязательно берется с процессорных счетчиков — во-первых, на современном железе есть множество других источников точных сигналов; во-вторых — на многопроцессорных системах есть проблема с тем, что у каждого из процессора счетчик свой и поэтому последовательные вызовы метода могут возвращать скачущие значения в разных потоках. Конкретная реализация зависит от железа, типа и версии операционной системы. Также, несмотря на наносекундную точность результата, никаких гарантий на реальную гранулярность не дается — точность также может снижаться до десятков миллисекунд, но никак не хуже гранулярности java.lang.System#currentTimeMillis
Эти и другие вопросы подробно разобраны тут:
- Inside the Hotspot VM: Clocks, Timers and Scheduling Events
- Is System.nanoTime() completely useless?
- Linux FAQ for clock_gettime(CLOCK_REALTIME)
Измерение длительности операций
Исходя из из предыдущей главы, казалось бы можно сделать только один-единственный вывод о правильном измерении времени — нужно использовать только метод java.lang.System#nanoTime. Метод java.lang.System#currentTimeMillis не подходит, поскольку его значение может скакать при изменении системного времени. Этот вывод подтверждается также чтением JavaDoc к обоим методам.
Тем не менее, мы смотрим в методы класса java.lang.Thread и видим нечто очень странное:
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
По всей видимости, это что-то очень древнее, поскольку, например в java.util.concurrent.ExecutorService, используется уже System.nanoTime.
Этот же вопрос также очень актуален для различных сторонних библиотек, а уже сколько измерений длительности на базе System.currentTimeMillis() реализовано в самописном коде — просто не сосчитать.
Время при тестировании
Не буду растекаться по древу в этом вопросе — просто расскажу про свой успешный опыт в уже упомянутом вначале проекте восьмилетней давности.
Проект был достаточно ответственным — деньги, баллы, сложная бизнес-логика, откаты операция по таймаутам, смена статусов по истечении времени и все подобное.
Разработчиков было два: я и фронтендер, тестировщиков не было вообще. На сервере у нас был фреймворк для IoC, инжекции, принципы low-coupling и high-cohesion. Не то, что бы это был необходимый запас для разработки. Но если начал читать соответствующие книги, становится трудно остановиться. Единственное что вызывало у меня опасение — это тесты. Нет ничего более беспомощного, безответственного и испорченного, чем разработчик начавший писать тесты. Но я знал, что рано или поздно мы перейдем и на эту дрянь.
В общем, я сразу же решительно завел интерфейс:
public interface Chronometer {
Date getCurrentMoment();
long getCurrentMs();
long getCurrentTicks();
}
Этот интерфейс инжектится практически везде, где нужно текущее время. Класс для отслеживания длительности (org.myproject.Timer) получает этот интерфейс в конструктор.
Самое тяжелое при таком подходе — помнить, что можно, а что нельзя:
# Так нельзя
user.setCreated(new Date());
user.setModified(new Date());
# Так можно
Date now = chronometer.getCurrentMoment();
user.setCreated(now);
user.setModified(now);
# Так нельзя
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR, -3);
Date expiration = calendar.getTime();
# Так можно
Calendar calendar = Calendar.getInstance();
calendar.setTimeZone(this.operationTimeZone)
calendar.setTime(chronometer.getCurrentMoment());
calendar.add(Calendar.DAY_OF_YEAR, -3);
Date expiration = calendar.getTime();
Полностью отследить такие куски конечно можно только в своем личном коде, но нам важно чтобы этот правильный подход реализовывался в коде отвечающем за персистентность и бизнес-логику.
При обсуждении вопросов тестирования никак невозможно не вспомнить про базы данных.
# Плохой вариант - нетестируем вообще
DELETE FROM records
WHERE created <= DATE_SUB(NOW(), INTERVAL 30 DAY)
# Вариант уже тестируем - но все равно не очень хорошо
DELETE FROM records
WHERE created <= DATE_SUB(:now, INTERVAL 30 DAY)
# А так надо - параметр полностью вычисляется в бизнес логике и/или тестах
DELETE FROM records
WHERE created <= :expiration
При таком подходе, если он корректно реализован везде, все автотесты можно отправить в любое время — весь код можно легко выполнить в 1953 году или в 2312:
// Устанавливаем "текущее" время
mockChronometer.setCurrentTime("2120.06.10 15:33:11");
// Сохраняем запись с параметрами относительно "текущего" времени
Period period = new Period();
period.setStart(TimeUtils.parse("2120.06.01 00:00:00"));
period.setEnd(TimeUtils.parse("2120.07.01 00:00:00"));
period.setIndex("wdwwddwwdw");
period.setType(PeriodType.MONTH);
period.setDescription("efefef");
periodsDao.save(period);
// Что-то проверяем - вся логика будет думать что сейчас на часах "2120.06.10 15:33:11".
// Время не двигается, пока мы его не сдвинем вручную.
// Поэтому в самом коде теста мы можем делать что-угодно и сколь угодно долго (готовить данные например).
checkSomething1();
// ... прошло 2 дня и еще немного
mockChronometer.setCurrentTime("2120.06.12 15:38:14");
// Проверка того как код реагирует на повторный вызов по истечению времени
checkSomething2();
Если вы уже ткнули пальцем в монитор и сказали мне кг/ам за то, что я не поставил временную зону в вызовах MockChronometer и TimeUtils — то тогда вот мой вам респект. Оставлять их в тестах на откуп текущей временной зоне — значит сделать тест хрупким. На самом же деле оба класса по-умолчанию оперируют в временной зоне UTC всегда, когда зона не указана специально в аргументах методов.
В Java 8 в новом Date Time API появился интерфейс java.time.Clock, который введен ровно для тех же целей — но я не уверен, что общественность это уже оценила.
Альтернативный подход — запускать тесты в отдельной JVM с указанием агента, который будет производить инструментацию кода с целью перехвата вызовов к System.nanoTime() и System.currentTimeMillis(). Такой подход я не пробовал, а беглый поиск готовых решений не предлагает. Более здравым вариантом кажется простой препроцессинг исходного кода в процессе сборки — замена вызовов System.nanoTime(), System.currentTimeMillis(), new Date(), Calendar.getInstance() на вызовы к своему классу.
Spring Framework
8 лет назад в Spring был LocaleResolver, но не было TimezoneResolver (что, как мне кажется, вполне характеризует общее отношение к проблеме). Пришлось написать свой комплект, а заодно сделать сабкласс DispatcherServlet.
После не очень многочисленных, но достаточно настойчивых просьб сообщества (моих в том числе), штатный резолвер временной зоны запроса был введен в 4-й версии фреймворка.
MVC
Отдельный вопрос — как, зная временную зону в контроллере, правильно установить ее для шаблонизатора.
В FreeMarker предусмотрена специальная настройка для текущего рендеринга:
<#setting time_zone="Europe/Moscow">
Для JSP также можно указать временную зоны индивидуально для одиночного вывода или сразу для всего блока:
<fmt:formatDate type="both" value="${now}" timeZone="Europe/Moscow"/>
<fmt:timeZone value="Europe/Moscow">
<fmt:formatDate type="both" value="${now}"/>
</fmt:timeZone>
В Velocity тоже что-то есть, но я лично не пробовал.
Хранение времени в БД
Самый бронебойный способ хранения временной точки в БД — эта передача значения java.util.Date#getTime() в базу в виде простого числового long-значения Unix-time. Соответственно при чтении мы преобразуем long в java.util.Date с помощью конструктора. Это можно сделать в конверторах Hibernate или RowMapper'ах. База данных в этом случае ничего не знает про время, поэтому никаких внезапных эффектов мы получить не сможем. Если очень надо вывести временную точку в виде метки, то, например в MySQL, всегда можно вызвать метод FROM_UNIXTIME.
Такой способ подходит, если в запросах в БД и/или в хранимых процедурах нет операций с временем. Если же такие операции есть (чего, для простоты разработки, лучше конечно бы избегать), то вы уже в курсе, что без временной зоны они не проходят. В этом случае, надо понять какая именно временная зона действует в ходе операции преобразования или ввода/вывода:
- временная зона указанная по-умолчанию для сервера;
- временная зона указанная по-умолчанию для СУБД;
- временная зона указанная по-умолчанию для базы данных;
- временная зона указанная по-умолчанию для таблицы;
- временная зона указанная по-умолчанию для соединения;
- временная зона хранящаяся в ячейке вместе с временной меткой.
Я ни в коем случае не хочу сказать, что не надо хранить время в специально предназначенных для этого типах. Просто вариант с прямым хранением long очень сложно как-либо сломать (я не смог придумать как) и такой способ никак не зависит от типа хранилища.
При хранении времени в специально предназначенных для этого типах нужно обязательно проверить, что при указанных настройках БД и ее драйвера указанное значение времени пишется, читается и выводится в родной консоли управления этой БД консистентно даже при смене часовых поясов на ходу.
Неконсистентность может быть вызвана тем, что БД на самом деле может хранить временную метку, а не точку. Например в случае MySQL для хранения времени существуют два стандартных типа: TIMESTAMP and DATETIME.
Судя по официальной документации, TIMESTAMP хранит именно временную точку и, получив значение «2015-01-01 12:00:00 MSK» от клиента в московской временной зоне, вернет «2015-01-01 09:00:00 UTC» для другого клиента во временной зоне UTC, что соответствует одной временной точке и совершенно правильно по своей сути. А с типом DATETIME, получив от MSK-клиента «2015-01-01 12:00:00 MSK», сервер MySQL вернет UTC-клиенту «2015-01-01 12:00:00 UTC», что соответствует уже другой временной точке и все дальнейшие расчеты будут неверными.
Проверим MySQL. Сначала все подготовим:
$ sudo docker run --name mysql-time -e MYSQL_ROOT_PASSWORD=root -d mysql/mysql-server:5.7
$ sudo docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
82bb3eebc8bc mysql/mysql-server:5.7 /entrypoint.sh mysq 5 minutes ago Up 5 minutes 3306/tcp mysql-time
$ sudo docker exec -it 82bb3eebc8bc bash
[root@82bb3eebc8bc /]# TZ=Europe/Moscow mysql -u root -p
Enter password:
mysql> CREATE DATABASE test;
Query OK, 1 row affected (0.00 sec)
mysql> use test;
Database changed
mysql> CREATE TABLE dates (id INTEGER, t1 TIMESTAMP, t2 DATETIME);
Query OK, 0 rows affected (0.02 sec)
Устанавливаем в сессии зону «Europe/Moscow» и создаем запись:
mysql> SET time_zone = 'Europe/Moscow';
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT @@session.time_zone;
+---------------------+
| @@session.time_zone |
+---------------------+
| Europe/Moscow |
+---------------------+
1 row in set (0.00 sec)
mysql> INSERT INTO dates VALUES (1, '2015-01-01 12:00:00', '2015-01-01 12:00:00');
Query OK, 1 row affected (0.00 sec)
mysql> SELECT * FROM dates WHERE id = 1;
+------+---------------------+---------------------+
| id | t1 | t2 |
+------+---------------------+---------------------+
| 1 | 2015-01-01 12:00:00 | 2015-01-01 12:00:00 |
+------+---------------------+---------------------+
1 row in set (0.00 sec)
Меняем временную зону сессии на «UTC» и читаем запись снова:
mysql> SET time_zone = 'UTC';
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT @@session.time_zone;
+---------------------+
| @@session.time_zone |
+---------------------+
| UTC |
+---------------------+
1 row in set (0.00 sec)
mysql> SELECT * FROM dates WHERE id = 1;
+------+---------------------+---------------------+
| id | t1 | t2 |
+------+---------------------+---------------------+
| 1 | 2015-01-01 09:00:00 | 2015-01-01 12:00:00 |
+------+---------------------+---------------------+
1 row in set (0.00 sec)
Видим, что временная метка поля t1 изменилась — как и должно быть, а временная метка для поля t2 при смене временной зоны не изменилась и теперь соответствует другой точке на числовой оси.
Для большинства БД существуют специфичные типы, которые хранят только дату и только время. Такие типы невозможно привести к временной точке, не указав временную зону (о чем мы уже знаем), но также необходимо дополнить эти значения недостающей частью (временем или датой соответственно). На самом деле, можно и не приводить такие типы к временным точкам, если в этом нет никакой необходимости — например в случае простого вывода дня исторического события. Просто нужно помнить, что это метка (или даже ее часть), а не временная точка на оси.
Многие БД имеют разновидности типов, которые кроме самой временной точки хранят дополнительную и информацию о временной зоне в которой она была введена. Например, это может быть полезно, если нужно знать какая именно временная зона была первична для этого значения.
NTP
Вне зависимости от причин, по которым локальное время в системе отклоняется от реального, на помощь приходят сервисы поставки точного времени с различными протоколами, самый популярный из которых — это протокол NTP.
Атомные часы — достаточно дорогое удовольствие, поэтому чтобы не перегрузить связанные с ними сервера, в NTP выстраивается целая иерархия для обслуживания запросов рядовых пользователей.
Клиент регулярно опрашивает сразу несколько сервисов точного времени в разных подсетях одновременно, производит компенсацию времени потраченного на отправку и прием UDP датаграмм, откидывает значения являющиеся выбросами и затем усредняет их специальным алгоритмом.
Заявляется, что при использовании в публичных сетях возможная точность синхронизации может достигать 10мс.
Уведомления
В большинстве сервисов мобильные уведомления (пуши и СМС) можно поделить на два класса: срочные (сообщения, новые дружбы, платежи) и несрочные (промо, реклама, предложения, изменения регламента). Первые пользователь скорее всего ожидает получить сразу же при наступлении события, для вторых же события как такового может и не быть, и пользователя лучше беспокоить в комфортное для него время. Комфортным временем по-умолчанию можно считать например период с 10 утра до 20 вечера. Если это критично, комфортное время можно позволить указывать вручную индивидуально.
Так или иначе, этот период мы обязаны трактовать в какой-то временной зоне и вполне очевидно, что это будет временная зона пользователя. В принципе, если сервис работает только для какой-то ограниченной территории (например для одного города) все это можно не учитывать и считать, что временная зона всех пользователей совпадает с временной зоной сервера, но для распределенных сервисов этого недостаточно.
Поэтому временную зону необходимо сохранять для фоновых задач — как минимум индивидуально для пользователя, а еще лучше отдельно для каждого устройства пользователя. В современных реалиях одна и та же учетная запись приложения может использоваться сразу на нескольких устройствах: например на стационарном Android TV, который всегда находится дома в Москве; и на Android-планшете, которые уезжает вместе с пользователем в отпуск в Таиланд. Поэтому возможно, что уведомления на оба устройства придется отсылать в разное время. Имея временную зону пользователя и комфортное для него время, всегда можно рассчитать точку на временной оси, когда мы можем начать его спамить.
Date now = chronometer.getCurrentMoment();
TimeZone timeZone = userDeviceRecord.getTimeZone();
Calendar calendarFrom = Calendar.getInstance(timeZone);
calendarFrom.setTime(now);
calendarFrom.set(Calendar.HOUR_OF_DAY, comfortHourFrom);
Calendar calendarTill = Calendar.getInstance(timeZone);
calendarTill.setTime(now);
calendarTill.set(Calendar.HOUR_OF_DAY, comfortourHourTill);
if (now.after(calendarTill.getTime())) {
calendarFrom.add(Calendar.DAY_OF_YEAR, +1);
}
long delayMs = Math.max(calendarFrom.getTime().getTime() - now.getTime(), 0);
notificationService.sendNotificationWithDelay(msg, delayMs);
A.M. / P.M.
Я предполагаю, что тут нет людей, которые бы уже не поняли, что речь идет о 12-часовом представлении временных меток.
Но не только лишь все знают, как именно переводятся эти аббревиатуры: a.m. (лат. ante meridiem дословно — «до полудня») и p.m. (лат. post meridiem дословно — «после полудня»).
А сюрпризом для многих будет то, что полночь — это не 12pm и даже не 0am, а очень даже 12am. Аналогично, полдень — это не 12am и не 0pm, а 12pm. В таких обозначениях путаются даже жители привычных к такому формату стран, поэтому придумывают различные трюки.
Часы реального времени
На любой железке с любой архитектурой есть специальный чип с подключенной батарейкой — это часы реального времени или в терминологии Linux «The Hardware Clock».
Они бесшумно тикают своими электронами вне зависимости от внешних условий: подано ли основное питание на системный блок, установлена ли операционная система, а также от наличия или отсутствия каких-либо ваших действий. Их ход прерывается только после того как энергия в батарейке иссякнет, что на самом деле произойдет с вашим оборудованием достаточно нескоро.
Для управления часами Hardware Clock (фактически для отправки команд в этот чип) в Linux существует специальная команда hwclock. Поскольку она напрямую общается с оборудованием (как правило это /dev/rtc), выполнять ее нужно с правами root.
$ sudo hwclock
Wed 30 Dec 2015 17:59:12 MSK -0.328637 seconds
Команда автоматически приводит время полученное из Hardware Clock к временной зоне системы. Но что это за странное смещение в конце вывода?
Дело в том, что на самом деле в ядре Linux есть еще другие, свои собственные часы (The System Time). Ядро Linux считывает показания с чипа один раз, в начале загрузки ядра, после чего тикает уже само по себе в ходе прерываний. Скорее всего это сделано по одной простой причине — читать данные с железного чипа Hardware Clock по последовательной шине на каждый пользовательский запрос относительно долго, поэтому проще вести свой локальный, исключительно программный счетчик. После того как ядро Linux прочитало значение с Hardware Clock, с последними теоретически можно производить любые операции — на время в приложениях это никак не повлияет.
Таким образом в системе параллельно тикают двое часов — настоящие электронные часы и программные часы в ядре. Между ними неизбежно возникает разница, которая и отображена в выводе команды hwclock
Есть два варианта решения этой проблемы (а для кого-то это и вовсе не проблема).
Рассмотрим первый вариант, предположив также что система не подключена к сети. Поскольку железные часы Hardware Clock работают круглыми сутками без каких либо перерывов — то и ошибаются они в среднем на одну и ту же величину каждый день. Утилита hwclock может нивелировать это через специальный механизм коррекции при чтении значения из чипа. Пользователю достаточно два раза установить Hardware Clock с каким-то существенным промежутком времени между установками. Утилита hwclock сама посчитает на сколько именно ошибаются Hardware Clock в течении суток, после чего сохранит эту величину в файле /etc/adjtime. После этого мы можем периодически читать значение из Hardware Clock и устанавливать значение System Time по нему, при этом утилита сама произведет коррекцию накопленной ошибки на сохраненную ранее величину дневной ошибки.
Второй вариант предполагает, что у нас есть какой-то способ периодической правильной установки System Time в ядре. Скорее всего, это какой-то внешний источник точного времени, с помощью которого мы можем приводить System Time в актуальное состояние. Все что остается после этого — попросить ядро периодически (раз в 11 секунд) скидывать верное значение ядра System Time в чип Hardware Time.
Подробнее обо всем этом можно прочитать в мануале команды hwclock.
Также наверное стоит рассказать о том, что система Linux может интерпретировать значения из Hardware Clock двумя способами. Связано это с тем, что Hardware Clock хранит время в виде счетчиков yyyy, MM, dd, HH, mm, ss. А как было уже сказано выше, без временной зоны эти счетчики нельзя привязать к точке на временной оси.
На самом деле тут всего два варианта: это будет или UTC, или локальная временная зона (та, что установлена в операционной системе по-умолчанию).
Для начала зайдите в BIOS и посмотрите на часы в главном меню. Сравните показания часов в BIOS и своих наручных часов, если они совпадают, то у вас часы BIOS идут по локальному времени (поздравляю — скорее всего у вас Windows компьютера); а если не совпадают, то часы BIOS установлены во временной зоне UTC (или другой вариант — они идут неправильно).
Windows по умолчанию требует, чтобы время в чипе Hardware Clock соответствовало локальному времени. А Linux легко может работать как с локальным временем в Hardware Clock, так и временем по UTC (последнее предпочтительнее). Поэтому, как правило, при двойной загрузке системы часы в BIOS идут по локальному времени специально для Windows, а Linux к этому приспосабливается.
Посмотреть текущий режим в Debian/Ubuntu можно так:
$ cat /etc/default/rcS | grep UTC
# assume that the BIOS clock is set to UTC time (recommended)
UTC=yes
Хаки времени
Если нужно заставить какую-то программу думать, что в данный момент для нее время отличается от времени всей системы, то в репозиториях Ubuntu/Debian уже есть утилита faketime, которая перехватывает и модифицирует системные вызовы.
$ date
Wed Dec 30 22:53:11 MSK 2015
$ faketime -f '+2y' date
Fri Dec 29 22:53:39 MSK 2017
День рождения
Если кто-то скажет, что родился «15 апреля», мы получим возможность поздравлять этого человека с днем рождения каждый год. Если он скажет, что родился «15 апреля 2001 года», мы получим возможность узнать еще и его возраст. Но и в том, и другом случае это никак не соответствует никакой временной точке на оси. Во-первых, не указано время рождения. Во-вторых, не указана временная зона рождения. Хотя, теоретически, временную зону даты рождения можно узнать, если указано точное место рождения.
Как именно нам рассчитывать временную точку в которую мы можем отправить поздравления самому пользователю, а в какую временную точку отправлять уведомления о его дне рождения его друзьям? Как вариант, можно предложить следующее:
- Отправка нотификации пользователю — t(u). Поскольку временная зона рождения нам неизвестна, используем текущую временную зону пользователя. Поскольку время рождения неизвестно, используем комфортное для пользователя время.
- Отправка нотификации другу — t(f). Также комбинируем дату рождения пользователя, временную зону друга и комфортное время друга.
@Test
public void testBirthday() throws Exception {
TimeZone japanTz = TimeZone.getTimeZone("Japan");
Calendar japanCalendar = Calendar.getInstance(japanTz);
japanCalendar.setLenient(false);
japanCalendar.setTimeInMillis(0);
japanCalendar.set(2016, Calendar.APRIL, 15, 9, 0, 0);
System.out.println("Japan 2016-04-15 09:00:00: " + japanCalendar.getTimeInMillis());
japanCalendar.set(2016, Calendar.APRIL, 15, 21, 0, 0);
System.out.println("Japan 2016-04-16 21:00:00: " + japanCalendar.getTimeInMillis());
TimeZone franceTz = TimeZone.getTimeZone("Europe/France");
Calendar franceCalendar = Calendar.getInstance(franceTz);
franceCalendar.setLenient(false);
franceCalendar.setTimeInMillis(0);
franceCalendar.set(2016, Calendar.APRIL, 14, 9, 0, 0);
System.out.println("France 2016-04-14 09:00:00: " + franceCalendar.getTimeInMillis());
franceCalendar.set(2016, Calendar.APRIL, 14, 21, 0, 0);
System.out.println("France 2016-04-14 21:00:00: " + franceCalendar.getTimeInMillis());
franceCalendar.set(2016, Calendar.APRIL, 15, 9, 0, 0);
System.out.println("France 2016-04-15 09:00:00: " + franceCalendar.getTimeInMillis());
franceCalendar.set(2016, Calendar.APRIL, 15, 21, 0, 0);
System.out.println("France 2016-04-15 21:00:00: " + franceCalendar.getTimeInMillis());
}
Japan 2016-04-15 09:00:00: 1460678400000 Japan 2016-04-16 21:00:00: 1460721600000 France 2016-04-14 09:00:00: 1460624400000 France 2016-04-14 21:00:00: 1460667600000 France 2016-04-15 09:00:00: 1460710800000 France 2016-04-15 21:00:00: 1460754000000
Время t(f) может быть сильно больше чем время t(u) — например, если пользователь находится в Японии, а его друг — в Европе. В этом случае получается, что к моменту, когда другу в Европе рано утром придет уведомление, сам пользователь в Японии уже возможно закончит праздновать свое ДР. В таком случае можно сдвинуть время уведомления друга на день назад, а во фразу уведомления добавить слово «завтра».
Пограничный возраст
Есть еще один, крайне интересный для меня момент, уже юридического характера. Есть целый ряд граничных возрастов: возраст совершеннолетия, возраст сексуального согласия, возраст уголовной ответственности.
Предположим, что некто родился в 2000.01.10 00:00:01 во Владивостоке. Совершил нетяжкое преступление 2016.01.09 23:59:59 в Москве. По записи дня рождения в паспорте (2000.01.10) и дня преступления в протоколе (2016.01.09) получается что 16 лет человеку еще не исполнилось. Однако 2016.01.09 23:59:59 в Москве — это уже будет 2016.01.10 во Владивостоке (где он родился) и тогда 16 лет ему уже есть. В обратной ситуации, человеку родившемуся в Москве и совершившему преступление во Владивостоке уже исполнится 16, а вот если посчитать по московскому времени — получается что еще нет.
Для устранения этого казуса в судебной практике используется норма, при которой активация прав/ответственности наступает с 00:00:00 в день, следующий за ожидаемым. То есть уголовная ответственность наступит в 2016.01.11 00:00:00 по месту совершения события — в этом случае человеку точно будет 16 лет, где бы он не находился.
Выводы
- Не игнорируйте таймзону
- Если у вас почти готова машина времени, то я не рекомендую использовать стандартную библиотеку Java для точного расчета смещения в прошлое. Точные путешествия в будущее по временным меткам невозможны в принципе.
- Также текущая версия JDK не подходит для написания внутренних систем планирования на космических кораблях Галактической империи.
Вторая часть статьи — про новое Date Time API в Java 8.
Комментарии (10)
chabapok
11.01.2016 13:13ИМХО, проблема leap second кроется не в java (хотя может и в ней тоже), а в ОС, и в самом принципе високосных секунд: их вводят административно когда захотят, и эти секунды приходят по ntp. Это значит, что в будущем мы не можем точно прогнозировать временные интервалы.
Под убунтой все печательней. (наверное, под виндой тоже)
Дело в том, что System.currentTimemillis выдает время в миллисекундах, и оно не монотонно: в момент високосной секунды это время прыгает на 1000мс назад, но при этом запланированные с интервалом такси — выполняются с этим интервалом более-менее нормально — как будто время линейно. И получается этот прыжок назад надо как-то обрабатывать, если это важно.
Якобы в кернеле могут быть альтернативные time discipline при который часы ведут себя по другому, но есть ли они там и как их можно включить — я не разбирался.MzMz
11.01.2016 13:35+1Это стандартное поведение Unix-time — не учитывать leap-second, а просто скакать назад (или вперед) на одну секунду.
При желании локальную реализацию скачка в ядре можно отключить, а NTP (или другой внешний инициатор) может самостоятельно сглаживать момент перехода:
developerblog.redhat.com/2015/06/01/five-different-ways-handle-leap-seconds-ntp
googleblog.blogspot.ru/2011/09/time-technology-and-leaping-seconds.html
ivanra
11.01.2016 15:31+1Хорошая статья.
Тоже в свое время пришлось заниматься интернационализацией web-приложений, и работа с календарями оказалась немаловажной её частью. Вот от меня 2 копейки.
1) site.icu-project.org — проект, включающий в себя, кроме прочего, классы для работы со временем за пределами григорианского календаря. Какие — можно посмотреть тут
2) В статье встречается Calendar.getInstance(). Данный метод теоретически может вернуть не григорианский календарь (хотя практически, на данный момент — нет). Поэтому в проектах, распределенных по разным странам, я бы избегал использования данного метода, заменяя на new GregorianCalendar(). Просто для того, чтобы быть уверенным, что и в будущем летоисчисление и манипуляции с датами во всех подсистемах будут одинаковыми.
silentnuke
11.01.2016 20:15Хорошая статья.
Есть еще класное видео на этот счет www.youtube.com/watch?v=-5wpm-gesOY.
andrewsch
11.01.2016 22:52Статья замечательная и очень подробная. Мне только кажеться, что вы зря обошли вниманием Java 8 — лучше было-бы все примеры пояснять на новых классах, чем объяснять кривизну старых.
MzMz
12.01.2016 09:26+2Про Java 8 будет в следующей части, да и эта статья больше про необходимость уделять внимание временным зонам, чем про недостатки текущей реализации
Throwable
Спасибо за исчерпывающую статью!
Насчет leap second: последний раз она добавлялась в полночь 30 июня 2015 года, в связи с чем утром лежало большое количество сервисов, в том числе и java. С чем могут быть связаны подобные сбои?
MzMz
Насколько я понял по баг-репортам, проблема была косвенная — при выводе диагностического сообщения о появлении leap second ядром в лог. Потому как сам по себе сдвиг времени на секунду назад (вручную или через NTP) корректными системами должен обрабатываться без проблем.
Также я видел баг-репорт про ORACLE (подробностей не помню) где в функции разбора времени каким-то образом прокидывалась честная 60-я секунда, которую стандартная функция не могла обработать (валидный диапазон секунд был 0..59)
Вот еще статейка: www.slashroot.in/leap-second-bug-linux-kernel