Статьи

Руководство для начинающих по обработке часовых поясов Java

Основные понятия времени

Большинство веб-приложений должны поддерживать разные часовые пояса, и правильная обработка часовых поясов не так проста. Что еще хуже, вы должны убедиться, что временные метки согласованы для разных языков программирования (например, JavaScript на внешнем интерфейсе, Java в промежуточном программном обеспечении и MongoDB в качестве хранилища данных). Этот пост призван объяснить основные понятия абсолютного и относительного времени.

эпоха

Эпоха — это абсолютная временная привязка. Большинство языков программирования (например, Java, JavaScript, Python) используют эпоху Unix (полночь 1 января 1970 г.) при выражении данной метки времени в виде количества миллисекунд, прошедших с фиксированной привязки к моменту времени.

Относительная числовая метка времени

Относительная числовая временная метка выражается как количество миллисекунд, прошедших с начала эпохи.

Часовой пояс

Всемирное координированное время (UTC) является наиболее распространенным стандартом времени. Часовой пояс UTC (эквивалент GMT ) представляет собой эталон времени, к которому относятся все остальные часовые пояса (с положительным / отрицательным смещением).

Часовой пояс UTC обычно упоминается как время Зулу (Z) или UTC + 0. Часовой пояс Японии UTC + 9, а часовой пояс Гонолулу — UTC-10. Во времена эпохи Unix (1 января 1970 г. 00:00 UTC) это было 1 января 1970 г. 09:00 в Токио и 31 декабря 1969 г. 14:00 в Гонолулу.

ISO 8601

ISO 8601 является наиболее распространенным стандартом представления даты / времени и использует следующие форматы даты / времени:

Часовой пояс нотация
универсальное глобальное время 1970-01-01T00: 00: 00,000 + 00: 00
UTC время Зулу 1970-01-01T00: 00: 00,000 + Z
Tokio 1970-01-01T00: 00: 00,000 + 09: 00
Гонолулу 1969-12-31T14: 00: 00.000-10: 00

Основы времени Java

java.util.Date

java.util.Date — определенно самый распространенный класс, относящийся ко времени. Он представляет собой фиксированный момент времени, выраженный как относительное количество миллисекунд, прошедших с начала эпохи. java.util.Date не зависит от часового пояса , за исключением метода toString, который использует локальный часовой пояс для генерации представления String.

java.util.Calendar

Java.util.Calendar является одновременно фабрикой даты / времени, а также экземпляром синхронизации с учетом часового пояса. Это один из наименее удобных для работы классов Java API, и мы можем продемонстрировать это в следующем примере:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testTimeZonesWithCalendar() throws ParseException {
    assertEquals(0L, newCalendarInstanceMillis("GMT").getTimeInMillis());
    assertEquals(TimeUnit.HOURS.toMillis(-9), newCalendarInstanceMillis("Japan").getTimeInMillis());
    assertEquals(TimeUnit.HOURS.toMillis(10), newCalendarInstanceMillis("Pacific/Honolulu").getTimeInMillis());
    Calendar epoch = newCalendarInstanceMillis("GMT");
    epoch.setTimeZone(TimeZone.getTimeZone("Japan"));
    assertEquals(TimeUnit.HOURS.toMillis(-9), epoch.getTimeInMillis());
}
 
private Calendar newCalendarInstance(String timeZoneId) {
    Calendar calendar = new GregorianCalendar();
    calendar.set(Calendar.YEAR, 1970);
    calendar.set(Calendar.MONTH, 0);
    calendar.set(Calendar.DAY_OF_MONTH, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    calendar.setTimeZone(TimeZone.getTimeZone(timeZoneId));
    return calendar;
}

Во времена эпохи Unix (часовой пояс UTC) токийское время было на девять часов впереди, в то время как Гонолулу отставал на десять часов.

Изменение часового пояса Календаря сохраняет фактическое время при сдвиге смещения зоны. Относительная временная метка изменяется вместе со смещением часового пояса Календаря.

Joda-Time и Java 8 Date Time API просто делают java.util.Calandar устаревшим, так что вам больше не придется использовать этот причудливый API.

org.joda.time.DateTime

Joda-Time стремится исправить устаревший API Date / Time, предлагая:

С Joda-Time так выглядит наш предыдущий тестовый пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testTimeZonesWithDateTime() throws ParseException {
    assertEquals(0L, newDateTimeMillis("GMT").toDate().getTime());
    assertEquals(TimeUnit.HOURS.toMillis(-9), newDateTimeMillis("Japan").toDate().getTime());
    assertEquals(TimeUnit.HOURS.toMillis(10), newDateTimeMillis("Pacific/Honolulu").toDate().getTime());
    DateTime epoch = newDateTimeMillis("GMT");
    assertEquals("1970-01-01T00:00:00.000Z", epoch.toString());
    epoch = epoch.toDateTime(DateTimeZone.forID("Japan"));
    assertEquals(0, epoch.toDate().getTime());
    assertEquals("1970-01-01T09:00:00.000+09:00", epoch.toString());
    MutableDateTime mutableDateTime = epoch.toMutableDateTime();
    mutableDateTime.setChronology(ISOChronology.getInstance().withZone(DateTimeZone.forID("Japan")));
    assertEquals("1970-01-01T09:00:00.000+09:00", epoch.toString());
}
 
 
private DateTime newDateTimeMillis(String timeZoneId) {
    return new DateTime(DateTimeZone.forID(timeZoneId))
            .withYear(1970)
            .withMonthOfYear(1)
            .withDayOfMonth(1)
            .withTimeAtStartOfDay();
}

Свободный API DateTime намного проще в использовании, чем набор java.util.Calendar # . DateTime является неизменным, но мы можем легко переключиться на MutableDateTime, если это подходит для нашего текущего варианта использования.

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

Меняется только восприятие человеческого времени ( 1970-01-01T00: 00: 00.000Z и 1970-01-01T09: 00: 00.000 + 09: 00, указывающее на одно и то же абсолютное время).

Относительное и абсолютное время

При поддержке часовых поясов у вас есть две основные альтернативы: относительная временная метка и абсолютная информация о времени.

Относительная временная метка

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

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

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

Абсолютная отметка времени

Абсолютная временная метка содержит как относительное время, так и информацию о часовом поясе. Довольно часто выражать метки времени в их строковом представлении ISO 8601.

По сравнению с числовой формой (длина 64 бита) строковое представление менее компактно и может занимать до 25 символов (200 бит в кодировке UTF-8).

Стандарт ISO 8601 довольно распространен в файлах XML, поскольку в схеме XML используется лексический формат, основанный на стандарте ISO 8601 .

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

Загадки

Следующее упражнение призвано продемонстрировать, насколько сложно правильно обрабатывать структуру даты / времени, соответствующую стандарту ISO 8601, с использованием древних утилит java.text.DateFormat .

java.text.SimpleDateFormat

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
/**
 * DateFormat parsing utility
 * @param pattern date/time pattern
 * @param dateTimeString date/time string value
 * @param expectedNumericTimestamp expected millis since epoch
 */
private void dateFormatParse(String pattern, String dateTimeString, long expectedNumericTimestamp) {
    try {
        Date utcDate = new SimpleDateFormat(pattern).parse(dateTimeString);
        if(expectedNumericTimestamp != utcDate.getTime()) {
            LOGGER.warn("Pattern: {}, date: {} actual epoch {} while expected epoch: {}", new Object[]{pattern, dateTimeString, utcDate.getTime(), expectedNumericTimestamp});
        }
    } catch (ParseException e) {
        LOGGER.warn("Pattern: {}, date: {} threw {}", new Object[]{pattern, dateTimeString, e.getClass().getSimpleName()});
    }
}

Вариант использования 1

Давайте посмотрим, как различные шаблоны ISO 8601 ведут себя с этим первым анализатором:

1
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "1970-01-01T00:00:00.200Z", 200L);

Дает следующий результат:

1
Pattern: yyyy-MM-dd'T'HH:mm:ss.SSS'Z', date: 1970-01-01T00:00:00.200Z actual epoch -7199800 while expected epoch: 200

Этот шаблон не соответствует стандарту ISO 8601. Символ одинарной кавычки является escape-последовательностью, поэтому последний символ «Z» не обрабатывается как директива времени (например, время Зулу). После анализа мы просто получим ссылку на местный часовой пояс.

Этот тест был выполнен с использованием моего текущего системного часового пояса Европа / Афины , который на момент написания этой статьи на два часа опережал UTC

Вариант использования 2

Согласно документации java.util.SimpleDateFormat следующий шаблон: гггг-ММ-дд’Т’ХЧ: мм: сс.СССЗ должен соответствовать строковому значению даты / времени ISO 8601:

1
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "1970-01-01T00:00:00.200Z", 200L);

Но вместо этого мы получили следующее исключение:

1
Pattern: yyyy-MM-dd'T'HH:mm:ss.SSSZ, date: 1970-01-01T00:00:00.200Z threw ParseException

Таким образом, этот шаблон, кажется, не анализирует строковые значения UTC времени Зулу.

Вариант использования 3

Следующие шаблоны прекрасно работают для явных смещений:

1
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "1970-01-01T00:00:00.200+0000", 200L);

Вариант использования 4

Этот шаблон также совместим с другими смещениями часового пояса:

1
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "1970-01-01T00:00:00.200+0100", 200L - 1000 * 60 * 60);

Вариант использования 5

Чтобы соответствовать нотации времени зулу, нам нужно использовать следующую схему:

1
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "1970-01-01T00:00:00.200Z", 200L);

Вариант использования 6

К сожалению, этот последний шаблон не совместим с явными смещениями часовых поясов:

1
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "1970-01-01T00:00:00.200+0000", 200L);

Окончание со следующим исключением:

1
Pattern: yyyy-MM-dd'T'HH:mm:ss.SSSXXX, date: 1970-01-01T00:00:00.200+0000 threw ParseException

org.joda.time.DateTime

В отличие от java.text.SimpleDateFormat , Joda-Time совместим с любым шаблоном ISO 8601. Следующий тестовый пример будет использоваться для следующих тестовых случаев:

01
02
03
04
05
06
07
08
09
10
11
/**
 * Joda-Time parsing utility
 * @param dateTimeString date/time string value
 * @param expectedNumericTimestamp expected millis since epoch
 */
private void jodaTimeParse(String dateTimeString, long expectedNumericTimestamp) {
    Date utcDate = DateTime.parse(dateTimeString).toDate();
    if(expectedNumericTimestamp != utcDate.getTime()) {
        LOGGER.warn("date: {} actual epoch {} while expected epoch: {}", new Object[]{dateTimeString, utcDate.getTime(), expectedNumericTimestamp});
    }
}

Joda-Time совместим со всеми стандартными форматами даты / времени ISO 8601:

1
2
3
jodaTimeParse("1970-01-01T00:00:00.200Z", 200L);
jodaTimeParse("1970-01-01T00:00:00.200+0000", 200L);
jodaTimeParse("1970-01-01T00:00:00.200+0100", 200L - 1000 * 60 * 60);

Вывод

Как видите, с древними утилитами Java Date / Time работать нелегко. Joda-Time — намного лучшая альтернатива, предлагающая лучшие функции обработки времени.

Если вам доведется работать с Java 8, стоит перейти на API даты / времени Java 8 , разработанный с нуля, но очень вдохновленный Joda-Time .

  • Код доступен на GitHub .