Статьи

Путеводитель по времени и дате на Java

Правильное обращение с датами, временем, часовыми поясами, переходом на летнее время, високосные годы и тому подобное было моей любимой мозолью долгое время. Эта статья не является исчерпывающим руководством по временной области, см. « Дата и время на Java» — гораздо более подробные, но немного устаревшие. Это все еще актуально, но не охватывает java.time из Java 8. Я хочу охватить абсолютный минимум, о котором должен знать каждый java.time Java-разработчик.

Когда произошло событие?

За исключением философии и квантовой физики, мы можем рассматривать время как одномерную метрику, вещественное число. Это значение продолжает расти, когда время проходит. Если одно событие появляется за другим, мы назначаем большее время этому событию. Два события, происходящие одновременно, имеют одинаковое значение времени. По практическим соображениям в компьютерных системах мы храним время в дискретных целых числах, главным образом потому, что часы компьютера дискретно тикают. Поэтому мы можем хранить время как целочисленное значение. По соглашению мы присваиваем время = 0 1 января 1970 года, но в Java мы увеличиваем это значение каждую миллисекунду, а не секунду, как во время UNIX . Исторически использование 32-разрядного целого числа со знаком во времени UNIX вызывало проблему 2038 года . Таким образом, Java хранит время в 64-битном целом числе, что достаточно, даже если вы увеличиваете его в тысячу раз чаще. При этом самый простой, но верный способ хранения времени в Java — это … long примитив:

1
long timestamp = System.currentTimeMillis();

Проблема с long заключается в том, что он настолько распространен, что использование его для хранения времени подрывает систему типов. Это может быть идентификатор, может быть хеш-значением, может быть чем угодно. Также у long нет каких-либо значимых методов, связанных с временной областью. Самым первым подходом для long переноса в более значимый объект был java.util.Date известный с Java 1.0:

1
Date now = new Date();

Однако у класса Date есть множество недостатков:

  1. Это не представляет … дату. Серьезно, официально дата — это «[…] день месяца или года, как указано числом […]» [1], тогда как в Java это представляет момент времени без какого-либо определенного календаря (день / месяц / год).
  2. Его toString() вводит в заблуждение, отображая дату и время календаря в системном часовом поясе. Мало того, что тысячи разработчиков ввели в заблуждение, что Date имеет часовой пояс. Более того, он показывает время, а дата должна представлять только день, а не час.
  3. Он имеет более 20 устаревших методов, включая getYear() , parse(String) и многие конструкторы. Эти методы устарели по какой-то причине, потому что они наводят вас на мысль, что Date представляет, вы знаете, дату .
  4. java.sql.Date расширяет java.util.Date и на самом деле намного точнее, потому что действительно представляет календарную дату ( DATE в SQL). Однако это сужает функциональность базового класса Date , тем самым нарушая принцип подстановки Лискова . Не веришь мне? java.util.Date.toInstant() работает, как и ожидалось, но java.sql.Date.toInstant() завершается ошибкой с UnsupportedOperationException
  5. Хуже всего то, что Date изменчива .

Вы когда-нибудь задумывались, почему старые и сварливые разработчики в вашей команде так взволнованы неизменностью? Представьте кусок кода, который добавляет одну минуту к любой Date . Просто, да?

1
2
3
4
Date addOneMinute(Date in) {
    in.setTime(in.getTime() + 1_000 * 60);
    return in;
}

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

1
2
3
4
Date now = new Date();
System.out.println(now);
System.out.println(addOneMinute(now));
System.out.println(now);

Вывод может выглядеть следующим образом:

1
2
3
Tue Jul 26 22:59:22 CEST 2016
Tue Jul 26 23:00:22 CEST 2016
Tue Jul 26 23:00:22 CEST 2016

Вы заметили, что now значение фактически изменилось после добавления одной минуты? Если у вас есть функция, которая принимает Date и возвращает Date вы никогда не ожидаете, что она изменит свои параметры! Это похоже на функцию, принимающую числа x и y и перенастраивающую их сумму. Если вы обнаружите, что x был каким-то образом изменен в ходе сложения, все ваши предположения рухнут. Кстати, именно поэтому java.lang.Integer является неизменным. Или String Или BigDecimal .

Это не надуманный пример. Представьте себе класс ScheduledTask с одним методом:

1
2
3
class ScheduledTask {
    Date getNextRunTime();
}

Что произойдет, если я скажу:

1
2
ScheduledTask task = //...
task.getNextRunTime().setTime(new Date());

Влияет ли изменение возвращенной Date на время следующего запуска? Или, может быть, ScheduledTask возвращает копию своего внутреннего состояния, которую вы можете изменить? Может быть, мы оставим ScheduledTask в каком-то непоследовательном состоянии? Если бы Date была неизменной, такой проблемы не возникало бы.

Интересно, что каждый Java-разработчик разозлится, если вы перепутаете Java с JavaScript. Но угадайте, что Date в JavaScript имеет те же недостатки, что и java.util.Date и кажется плохим примером копирования-вставки. Date в JavaScript является изменяемой, вводит в заблуждение toString() и не поддерживает часовые пояса.

Отличная альтернатива Datejava.time.Instant . Он делает именно то, что утверждает: хранит момент во времени. Instant не имеет методов, связанных с датой или календарем, его toString() использует знакомый формат ISO в часовом поясе UTC (подробнее об этом позже) и, самое главное, он неизменен. Если вы хотите вспомнить, когда произошло определенное событие, Instant — лучшее, что вы можете получить в простой Java:

1
2
Instant now = Instant.now();
Instant later = now.plusSeconds(60);

Обратите внимание, что Instant не имеет plusMinutes() , plusHours() и так далее. Минуты, часы и дни — это понятия, относящиеся к календарным системам, тогда как Instant географически и культурно не зависимы.

ZonedDateTime календари с помощью ZonedDateTime

Иногда вам нужно мгновенное представление человека. Это включает месяц, день недели, текущий час и так далее. Но здесь есть одно серьезное осложнение: дата и время варьируются в зависимости от страны и региона. Instant простое и универсальное, но не очень полезное для людей, это просто число. Если у вас есть бизнес-логика, связанная с календарем, например:

  • … должно произойти в рабочее время …
  • … до одного дня …
  • … два рабочих дня …
  • … действует до одного года …

тогда вы должны использовать какую-то календарную систему. java.time.ZonedDateTime — лучшая альтернатива абсолютно ужасному java.util.Calendar . На самом деле java.util.Date и Calendar настолько разбиты по дизайну, что считаются устаревшими полностью в JDK 9. Вы можете создать ZonedDateTime из Instant только с предоставлением часового пояса. В противном случае используется системный часовой пояс по умолчанию, который вы не можете контролировать. Преобразование Instant в ZonedDateTime любым способом без предоставления явного ZoneId , вероятно, является ошибкой:

1
2
3
4
5
6
7
8
9
Instant now = Instant.now();
System.out.println(now);
  
ZonedDateTime dateTime = ZonedDateTime.ofInstant(
        now,
        ZoneId.of("Europe/Warsaw")
    );
  
System.out.println(dateTime);

Вывод следующий:

1
2
2016-08-05T07:00:44.057Z
2016-08-05T09:00:44.057+02:00[Europe/Warsaw]

Обратите внимание, что Instant (для удобства) отображает дату, отформатированную в формате UTC, тогда как ZonedDateTime использует предоставленный ZoneId (+2 часа летом, подробнее об этом позже).

Заблуждения календаря

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

1
2
3
4
5
6
7
8
9
LocalDate localDate = LocalDate.of(2016, Month.AUGUST, 5);
LocalTime localTime = LocalTime.of(10, 21);
LocalDateTime local = LocalDateTime.of(localDate, localTime);
ZonedDateTime warsaw = ZonedDateTime.of(local, ZoneId.of("Europe/Warsaw"));
  
ZonedDateTime sydney = warsaw.withZoneSameInstant(ZoneId.of("Australia/Sydney"));
  
System.out.println(warsaw);
System.out.println(sydney);

Выходные данные показывают, что разница между Варшавой и Сиднеем составляет ровно 8 часов:

1
2
2016-08-05T10:21+02:00[Europe/Warsaw]
2016-08-05T18:21+10:00[Australia/Sydney]

Или это? Измените август на февраль, и разница станет 10 часов:

1
2
2016-02-05T10:21+01:00[Europe/Warsaw]
2016-02-05T20:21+11:00[Australia/Sydney]

Это потому, что Варшава не наблюдает летнее время в феврале (зима), а в Сиднее лето, поэтому они используют летнее время (+1 час). В августе наоборот. Чтобы сделать вещи еще более сложными, время переключения на летнее время варьируется, и это всегда происходит ночью по местному времени, поэтому должен быть момент, когда одна страна уже переключилась, но не другая, например, в октябре:

1
2
2016-10-05T10:21+02:00[Europe/Warsaw]
2016-10-05T19:21+11:00[Australia/Sydney]

9 часов разницы. Другая причина, почему смещение времени отличается, является политической:

1
2
3
4
5
6
7
8
9
LocalDate localDate = LocalDate.of(2014, Month.FEBRUARY, 5);
LocalTime localTime = LocalTime.of(10, 21);
LocalDateTime local = LocalDateTime.of(localDate, localTime);
ZonedDateTime warsaw = ZonedDateTime.of(local, ZoneId.of("Europe/Warsaw"));
  
ZonedDateTime moscow = warsaw.withZoneSameInstant(ZoneId.of("Europe/Moscow"));
  
System.out.println(warsaw);
System.out.println(moscow);

Разница во времени между Варшавой и Москвой 5 февраля 2014 года составила 3 ​​часа:

1
2
2014-02-05T10:21+01:00[Europe/Warsaw]
2014-02-05T13:21+04:00[Europe/Moscow]

Но разница в тот же день года составляет 2 часа:

1
2
2015-02-05T10:21+01:00[Europe/Warsaw]
2015-02-05T12:21+03:00[Europe/Moscow]

Это потому, что Россия меняет свою политику DST и часовой пояс, как сумасшедший.

Другое распространенное заблуждение о датах заключается в том, что день — это 24 часа. Это опять же связано с переходом на летнее время:

1
2
3
4
5
6
7
8
LocalDate localDate = LocalDate.of(2017, Month.MARCH, 26);
LocalTime localTime = LocalTime.of(1, 0);
ZonedDateTime warsaw = ZonedDateTime.of(localDate, localTime, ZoneId.of("Europe/Warsaw"));
  
ZonedDateTime oneDayLater = warsaw.plusDays(1);
  
Duration duration = Duration.between(warsaw, oneDayLater);
System.out.println(duration);

Как вы знаете, разница между PT23H 26 и 27 марта 2017 года составляет… 23 часа ( PT23H ). Но если вы измените часовой пояс на Australia/Sydney вы познакомитесь 24 часа, потому что в этот день в Сиднее ничего особенного не происходит. Этот особенный день в Сиднее 2 апреля 2017 года:

1
2
3
LocalDate localDate = LocalDate.of(2017, Month.APRIL, 2);
LocalTime localTime = LocalTime.of(1, 0);
ZonedDateTime warsaw = ZonedDateTime.of(localDate, localTime, ZoneId.of("Australia/Sydney"));

В результате один день равен 25 часам. Но не в Брисбене ( "Australia/Brisbane" ), в тысячах километров к северу от Сиднея, где не наблюдается летнее время. Почему все это важно? Когда вы договариваетесь со своим клиентом о том, что что-то может занять один день вместо 24 часов, это может действительно иметь огромное значение в определенный день. Вы должны быть точными, иначе ваша система станет несовместимой дважды в год. И не заводи меня на високосную секунду .

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

Хранение и передача времени

По умолчанию вы должны хранить и отправлять время либо как метку времени ( long значение), либо как ISO 8601, что в основном и делает Instant.toString() в соответствии с документацией. Предпочитайте long значение, так как оно более компактное, если вам не нужен более читаемый формат в некоторой кодировке текста, такой как JSON. Также long зависит от часового пояса, поэтому вы не притворяетесь, что часовой пояс, который вы отправляете / храните, имеет какое-то значение. Это относится как к передаче времени, так и к сохранению его в базе данных.

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

Местное время и дата

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

1
2
//1985-12-25
LocalDate.of(1985, Month.DECEMBER, 25)

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

1
2
//20:00
LocalTime.of(20, 0, 0)

Независимо от часового пояса. Я даже могу сказать, что мой день рождения в этом году будет именно на:

1
2
3
4
5
//2016-12-25T20:00
LocalDateTime party = LocalDateTime.of(
        LocalDate.of(2016, Month.DECEMBER, 25),
        LocalTime.of(20, 0, 0)
);

Но до тех пор, пока я не предоставлю вам местоположение, вы не знаете, в каком часовом поясе я живу, и каково фактическое время начала. Невозможно (или очень глупо) конвертировать из LocalDateTime в Instant или ZonedDateTime (которые оба указывают на точный момент времени) без указания часового пояса. Так что местное время полезно, но на самом деле оно не представляет ни одного момента времени.

тестирование

Я только поцарапал поверхность ловушек и проблем, которые могут возникнуть со временем свидания. Например, мы не охватывали високосные годы, которые могут стать серьезным источником ошибок. Я считаю, что тестирование на основе свойств чрезвычайно полезно при проверке дат:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
import spock.lang.Specification
import spock.lang.Unroll
  
import java.time.*
  
class PlusMinusMonthSpec extends Specification {
  
    static final LocalDate START_DATE =
            LocalDate.of(2016, Month.JANUARY, 1)
  
    @Unroll
    def '#date +/- 1 month gives back the same date'() {
        expect:
            date == date.plusMonths(1).minusMonths(1)
        where:
            date << (0..365).collect {
                day -> START_DATE.plusDays(day)
            }
    }
  
}

Этот тест гарантирует, что добавление и вычитание одного месяца к любой дате в 2016 году возвращает ту же дату. Довольно просто, правда? Этот тест не проходит в течение нескольких дней:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
date == date.plusMonths(1).minusMonths(1)
|    |  |    |             |
|    |  |    2016-02-29    2016-01-29
|    |  2016-01-30
|    false
2016-01-30
  
  
date == date.plusMonths(1).minusMonths(1)
|    |  |    |             |
|    |  |    2016-02-29    2016-01-29
|    |  2016-01-31
|    false
2016-01-31
  
  
date == date.plusMonths(1).minusMonths(1)
|    |  |    |             |
|    |  |    2016-04-30    2016-03-30
|    |  2016-03-31
|    false
2016-03-31
  
...

Високосные годы вызывают всевозможные проблемы и нарушают законы математики. Другой подобный пример — добавление двух месяцев к дате, которая не всегда равна добавлению одного месяца два раза.

Резюме

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

Ссылка: Путеводитель по времени и дате на Java от нашего партнера по JCG Томаша Нуркевича в блоге, посвященном Java и соседству .