О java.time
2017-07-22
Ну наконец-то,
аж в восьмой яве,
появилось отличное и правильное API
для работы со временем.
Теперь можно смело выкинуть java.util.Date
и java.text.DateFormat
.
Теперь у нас есть 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
— это наш знакомый
григорианский календарь.
P.S. Примеры в этой статье выполнялись в Kotlin REPL.
Именно поэтому метод plus()
можно было заменить на оператор +
.