О java.time

2017-07-22

Ну наконец-то, аж в восьмой яве, появилось отличное и правильное API для работы со временем. Теперь можно смело выкинуть java.util.Date и java.text.DateFormat. Теперь у нас есть java.time.

Java Time!

Чаще всего вам понадобится просто отметка времени, точка на временной оси. Это — java.time.Instant.

Можно получить момент времени сейчас. Можно получить момент времени из юниксовых секунд от начала эпохи. Можно получить момент времени из миллисекунд от начала эпохи, как это представляется в java.util.Date. А можно взять момент времени из строки в формате ISO 8601 с буковкой «Z» в конце, что означает, что это время в UTC, а значит, не подвержено особенностям разных таймзон.

>>> import java.time.Instant
>>>
>>> Instant.now()
2017-07-22T08:39:59.665Z
>>> Instant.ofEpochSecond(1_500_000_000)
2017-07-14T02:40:00Z
>>> Instant.ofEpochMilli(1_500_000_000_000)
2017-07-14T02:40:00Z
>>> Instant.parse("2017-07-14T02:40:00Z")
2017-07-14T02:40:00Z

Соответственно, можно и наоборот, извлечь из Instant число секунд или миллисекунд, или преобразовать в строку.

>>> val instant = Instant.now()
>>>
>>> instant.getEpochSecond()
1500713246
>>> instant.toEpochMilli()
1500713246133
>>> instant.toString()
2017-07-22T08:47:26.133Z

Эта возможность гонять из чисел и строк туда и обратно очень полезна. Сохраняйте таким образом Instant куда угодно и загружайте обратно, это будет совершенно правильно и безопасно.

Допустим, у нас есть какая-то дата вида «12/07/2017» (тут главное, сразу выяснить, где месяц, а где день месяца). Её можно распарсить с помощью java.time.format.DateTimeFormatter и получить java.time.LocalDate.

>>> import java.time.format.DateTimeFormatter
>>> import java.time.LocalDate
>>>
>>> val dateString = "12/07/2017"
>>> val dateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
>>> val localDate = LocalDate.parse(dateString, dateFormat)
>>> localDate
2017-07-12

LocalDate содержит сведения о годе, месяце и дне (конкретного календаря). Не больше, но и не меньше. Он ничего не знает ни о таймзоне, ни о времени вообще. Этого недостаточно, чтобы определить точку на временной оси. Но вполне достаточно, чтобы обозначить день в календаре.

>>> Instant.from(localDate)
java.time.DateTimeException: Unable to obtain Instant from TemporalAccessor: 2017-07-12 of type java.time.LocalDate
Caused by: java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: InstantSeconds

Допустим, у нас есть какое-то время вида «08:52:17» (тут надо сразу выяснить, что это действительно 24-часовой формат, иначе появляется неоднозначность). Его можно распарсить, снова с помощью DateTimeFormatter, и получить java.time.LocalTime.

>>> import java.time.LocalTime
>>>
>>> val timeString = "08:52:17"
>>> val timeFormat = DateTimeFormatter.ofPattern("HH:mm:ss")
>>> val localTime = LocalTime.parse(timeString, timeFormat)
>>> localTime
08:52:17

LocalTime содержит сведения о часах, минутах, секундах (и наносекундах). Какого-то неопределённого дня. В каком-то неопределённом часовом поясе. Очевидно, этого тоже недостаточно, чтобы определить конкретную точку на временной оси. Но достаточно, например, чтобы поставить будильник, на определённое локальное время, каждый день.

>>> Instant.from(localTime)
java.time.DateTimeException: Unable to obtain Instant from TemporalAccessor: 08:52:17 of type java.time.LocalTime
Caused by: java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: InstantSeconds

DateTimeFormatter похож на java.text.SimpleDateFormat. Да, он поддерживает такой же синтаксис шаблонов. Но его ещё можно собрать с помощью DateTimeFormatterBuilder, накидав нужные поля в нужном порядке. Ещё в нём есть куча предопределённых форматтеров вроде DateTimeFormatter.ISO_INSTANT. А ещё DateTimeFormatter, в отличие от SimpleDateFormat, потокобезопасен (ибо иммутабелен).

Итак, у нас есть LocalDate и LocalTime. Что с ними можно сделать? Можно соединить их вместе, чтобы получить определённое время определённого дня. Это будет java.time.LocalDateTime.

>>> import java.time.LocalDateTime
>>>
>>> val localDateTime = LocalDateTime.of(localDate, localTime)
>>> localDateTime
2017-07-12T08:52:17

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

>>> Instant.from(localDateTime)
java.time.DateTimeException: Unable to obtain Instant from TemporalAccessor: 2017-07-12T08:52:17 of type java.time.LocalDateTime
Caused by: java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: InstantSeconds

Нам нужна таймзона.

Допустим, известно, что дело происходит в Мексике, где таймзона, в базе данных IANA, носит название «Mexico/General» (хотя правильнее, вообще-то, «America/Mexico_City»). По названию таймзоны получаем java.time.ZoneId, и, добавив его к LocalDateTime, получаем java.time.ZonedDateTime.

>>> import java.time.ZoneId
>>> import java.time.ZonedDateTime
>>>
>>> val timeZone = ZoneId.of("Mexico/General")
>>> val zonedDateTime = ZonedDateTime.of(localDateTime, timeZone)
>>> zonedDateTime
2017-07-12T08:52:17-05:00[Mexico/General]

Кроме ZoneId, который представляет собой таймзону в конкретной географической области, включая переходы на летнее и зимнее время, а также исторические события типа передвижения границ или смены часовых поясов, можно ещё просто задать смещение в виде java.time.ZoneOffset. Отсюда можно получить либо тот же (но немножко другой) ZonedDateTime, либо java.time.OffsetDateTime.

>>> import java.time.ZoneOffset
>>> import java.time.OffsetDateTime
>>>
>>> val zoneOffset = ZoneOffset.ofHours(-5)
>>> ZonedDateTime.of(localDateTime, zoneOffset)
2017-07-12T08:52:17-05:00
>>> val offsetDateTime = OffsetDateTime.of(localDateTime, zoneOffset)
>>> offsetDateTime
2017-07-12T08:52:17-05:00

И ZonedDateTime, и OffsetDateTime, и Instant представляют конкретную точку на оси времени.

>>> Instant.from(zonedDateTime)
2017-07-12T13:52:17Z
>>> Instant.from(offsetDateTime)
2017-07-12T13:52:17Z
>>>
>>> ZonedDateTime.from(Instant.now())
java.time.DateTimeException: Unable to obtain ZonedDateTime from TemporalAccessor: 2017-07-22T10:49:38.075Z of type java.time.Instant
Caused by: java.time.DateTimeException: Unable to obtain ZoneId from TemporalAccessor: 2017-07-22T10:49:38.075Z of type java.time.Instant
>>> ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault())
2017-07-22T16:51:07.317+06:00[Asia/Omsk]
>>> OffsetDateTime.ofInstant(Instant.now(), ZoneId.systemDefault())
2017-07-22T16:51:49.311+06:00

Разница будет в том, что с ними можно сделать.

Instant — это лишь точка, и больше ничего. Её можно сдвинуть в прошлое или будущее, на секунды или миллисекунды, даже на часы. Но будет ли через два часа уже завтра в конкретном Мехико-сити или Омске, Instant вам не ответит.

>>> instant
2017-07-22T08:47:26.133Z
>>> instant.plusSeconds(4)
2017-07-22T08:47:30.133Z
>>> instant.plusMillis(866)
2017-07-22T08:47:26.999Z
>>>
>>> import java.time.Duration
>>> instant.plus(Duration.ofHours(4))
2017-07-22T12:47:26.133Z
>>> instant + Duration.ofHours(4)
2017-07-22T12:47:26.133Z

OffsetDateTime — это точка, которая знает своё смещение от UTC. Но она ничего не знает о летнем и зимнем времени. Зимой в Мексике зимнее время, UTC-6, но OffsetDateTime будет считать по-прежнему в UTC-5.

>>> offsetDateTime
2017-07-12T08:52:17-05:00
>>>
>>> import java.time.Period
>>> offsetDateTime + Period.ofMonths(6)
2018-01-12T08:52:17-05:00

А вот ZonedDateTime — это точка, которая знает о своём местоположении всё (если, конечно, была создана с ZoneId, если создать с ZoneOffset, то поведение не будет отличаться от OffsetDateTime). Включая то, каким было смещение от UTC в данной местности при царе Горохе. (Кто бы знал, что такое UTC-06:36?)

>>> zonedDateTime
2017-07-12T08:52:17-05:00[Mexico/General]
>>> zonedDateTime + Period.ofMonths(6)
2018-01-12T08:52:17-06:00[Mexico/General]
>>> zonedDateTime - Period.ofYears(100)
1917-07-12T08:52:17-06:36:36[Mexico/General]

Если вам нужно просто обозначить точку на оси времени, используйте Instant. Если вам нужно манипулировать временем по всем правилам календаря, используйте ZonedDateTime с правильным ZoneId.

Когда у нас последний день текущего месяца? Тут, кстати, хватит LocalDate.

>>> LocalDate.now()
2017-07-22
>>> LocalDate.now() + Period.ofMonths(1)
2017-08-22
>>> LocalDate.now().plus(Period.ofMonths(1))
2017-08-22
>>> LocalDate.now().plus(Period.ofMonths(1)).withDayOfMonth(1)
2017-08-01
>>> LocalDate.now().plus(Period.ofMonths(1)).withDayOfMonth(1).minus(Period.ofDays(1))
2017-07-31

На самом деле все эти Instant, LocalDate, LocalTime, LocalDateTime и ZonedDateTime реализуют интерфейс TemporalAccessor. Именно объекты этого интерфейса возвращает DateTimeFormatter.parse() и принимают методы типа Instant.from(). TemporalAccessor позволяет узнать, какие TemporalField (год, месяц, день, часы, минуты и т.п.) имеются в данном объекте и запросить их значения.

>>> import java.time.temporal.ChronoField
>>>
>>> LocalTime.now().isSupported(ChronoField.HOUR_OF_DAY)
true
>>> LocalTime.now().isSupported(ChronoField.DAY_OF_MONTH)
false
>>> LocalTime.now().get(ChronoField.HOUR_OF_DAY)
17
>>> LocalTime.now().get(ChronoField.DAY_OF_MONTH)
java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: DayOfMonth

А Duration и Period реализуют интерфейс TemporalAmount. Тут всё просто. Duration умеет дни (24 часа ровно), часы, минуты, секунды, миллисекунды, наносекунды. Period умеет года, месяцы, недели, дни. Они задают количество TemporalUnit, которые можно прибавить или отнять к/от TemporalAccessor. Можно задавать длительность в виде другой половины формата ISO 8601.

>>> LocalTime.now()
17:38:14.342
>>> LocalTime.now() + Duration.parse("PT1H30M")
19:08:17.343
>>> LocalDate.now()
2017-07-22
>>> LocalDate.now() + Period.parse("P1M8D")
2017-08-30

Получается, что TemporalAccessor хранят значения времени, TemporalAmount позволяют сдвигать значения времени. А ещё есть TemporalAdjuster, которые позволяют подкручивать время более хитрыми способами. В простейшем случае можно просто выставить какое-то TemporalField в нужное значение. В более интересных случаях можно поискать тот же конец месяца или следующий понедельник. Для этого используются методы with*.

>>> import java.time.Year
>>>
>>> LocalDate.now()
2017-07-22
>>> LocalDate.now().with(Year.of(2019))
2019-07-22
>>>
>>> import java.time.temporal.TemporalAdjusters
>>>
>>> LocalDate.now().with(TemporalAdjusters.firstDayOfMonth())
2017-07-01
>>> LocalDate.now().with(TemporalAdjusters.lastDayOfMonth())
2017-07-31
>>>
>>> import java.time.DayOfWeek
>>>
>>> LocalDate.now().with(TemporalAdjusters.next(DayOfWeek.MONDAY))
2017-07-24

Всё сложно, но мощно.

Все методы могут кидать исключения. Особенно parse(). Базовый класс исключений тут java.time.DateTimeException. Причём он — наследник java.lang.RuntimeException. А это значит, что компилятор не попросит вас обернуть манипуляции с датой и временем в try-catch. В новейших API в Java явно прослеживается отказ от использования checked exceptions. Будьте внимательны.

А ещё есть java.time.Clock. Он является источником текущего времени (в виде Instant), а также текущей таймзоны и всего такого. Но его можно переопределить, например, для тестов. Например, чтобы время застряло на одной отметке. Если вы спроектируете свои классы так, чтобы текущие настройки и время всегда получались из Clock, то сможете легко тестировать все аспекты поведения, завязанные на время.

>>> import java.time.Clock
>>>
>>> val defaultClock = Clock.systemDefaultZone()
>>> defaultClock.instant()
2017-07-22T13:24:38.378Z
>>> defaultClock.instant()
2017-07-22T13:24:40.727Z
>>> defaultClock.instant()
2017-07-22T13:24:42.055Z
>>>
>>> val fixedClock = Clock.fixed(Instant.now(), ZoneId.systemDefault())
>>> fixedClock.instant()
2017-07-22T13:26:09.282Z
>>> fixedClock.instant()
2017-07-22T13:26:09.282Z
>>> fixedClock.instant()
2017-07-22T13:26:09.282Z

А для особо экзотических случаев у нас есть java.time.chrono.Chronology. Это другие календари. Наслаждайтесь.

>>> import java.time.chrono.IsoChronology
>>> IsoChronology.INSTANCE.dateNow()
2017-07-22
>>> import java.time.chrono.HijrahChronology
>>> HijrahChronology.INSTANCE.dateNow()
Hijrah-umalqura AH 1438-10-28
>>> import java.time.chrono.JapaneseChronology
>>> JapaneseChronology.INSTANCE.dateNow()
Japanese Heisei 29-07-22
>>> import java.time.chrono.MinguoChronology
>>> MinguoChronology.INSTANCE.dateNow()
Minguo ROC 106-07-22
>>> import java.time.chrono.ThaiBuddhistChronology
>>> ThaiBuddhistChronology.INSTANCE.dateNow()
ThaiBuddhist BE 2560-07-22

Обратите внимание, что все хронологии работают только с датой. Видимо, день — это слишком ничтожный промежуток времени, чтобы какой-то другой способ дробления его на части, кроме сложившихся в Европе двадцати четырёх часов, получил бы распространение. IsoChronology — это наш знакомый григорианский календарь.

Kotlin logo

P.S. Примеры в этой статье выполнялись в Kotlin REPL. Именно поэтому метод plus() можно было заменить на оператор +.