Во многих языках программирования работа с датой и временем — непростая задача. Но, начиная с Java 8, JDK предоставляет новый API Time, полностью меняющий подход к концепциям, которые относятся к времени.
Примечания. Хотя JSR 310 был представлен в Java 8, для большей удобочитаемости в приведенных примерах кода задействована такая функция Java 10, как вывод типа локальной переменной. Однако сами примеры кода совместимы с Java 8. В части примеров, содержащих // =>
, будет показан вывод toString()
предыдущей строки/переменной.
До JSR310
До появления нового API в JDK было лишь несколько классов для обработки даты и времени.
Вот наиболее известные из них.
java.util.Date
— конкретный момент времени с точностью до миллисекунды, например 1 января 1970 года 00:00:00 по Гринвичу.java.util.Calendar
— мостик между моментом времени и параметрами календаря, например месяц, год, день и т. д.java.util.TimeZone
отвечает за смещение часовых поясов и обработку летнего времени (DST).java.text.DateFormat
— форматирование и парсинг.
На первый взгляд эти четыре варианта охватывают наиболее распространенные сценарии. Но действительно ли они способны справиться со всеми деликатными особенностями даты и времени?
Миллисекунды
Поскольку java.util.Date
базируется на миллисекундах, мы склонны думать о дате и времени как о сумме миллисекунд.
static long MILLIS_PER_MINUTE = 1_000L * 60L; // 60_000L
static long MILLIS_PER_HOUR = MILLIS_PER_MINUTE * 60L; // 3_600_000L
static long MILLIS_PER_DAY = MILLIS_PER_HOUR * 24L; // 86_400_000L
Использование миллисекунд может показаться интуитивно понятным, но в конечном итоге приводит к ошибкам.
Работать со временем сложно из-за интуитивных предположений о дате и времени, которые не соответствуют действительности. Дейв ДеЛонг ведет большой список таких представлений с краткими объяснениями. Вот несколько таких неверных предположений.
- Дни длятся 86 400 секунд.
- Полночь бывает каждый день.
- Границы часовых поясов всегда проходят по ровным часам.
Проблема с граничными случаями в том, что мы часто не осознаем их существования, пока не столкнемся с ними. Если наш код ломается только в високосные годы, он может нормально работать до четырех лет.
Не все — идеальный момент во времени
Date
представляет один момент времени с точностью до миллисекунды. Но как насчет более широких единиц?
В какой день начинается январь 2021 года? Во сколько наступает 6 января?
Нам нужно представлять различные концепции даты и времени, помимо одной точки.
- Даты без указания времени: 30 декабря 2020 года.
- Время без дат: 12:24.
- Месяц и годы: декабрь 2020 года.
- Годы: 2020 год.
- Сроки: 7 дней.
- Различные календарные системы: японский календарь.
Эти концепции приходилось реализовывать самостоятельно с помощью контрактов кода, например представлять даты без указания времени в виде java.util.Date
со временем полночь. Из-за таких контрактов снижается гибкость данных, и они легко ломаются. Эти крайние случаи и соображения могут легко привести ко многим ошибкам, которые не сразу становятся очевидными.
Joda Time
К счастью, сторонний фреймворк Joda-Time предоставляет лучшую концепцию для работы с датами и временем, благодаря чему он стал стандартной библиотекой до Java 8. Однако, начиная с Java 8, на его основе был разработан новый API Time.
API Time Java (JSR 310)
Для устранения ранее упомянутых недостатков понадобился совершенно новый API. Он был разработан с нуля при участии автора Joda-Time Стивена Коулборна, который возглавлял работу. Результатом стало полное и всестороннее дополнение к JDK. Но чем этот API настолько лучше предшественника?
Цели проектирования
Новый API разрабатывался с учетом нескольких основных принципов.
Неизменяемость. Каждый тип должен быть по возможности неизменяемым. Преимущества неизменяемости хорошо известны: потокобезопасность, снижение числа ошибок из-за отсутствия состояний и лучшая совместимость со сборщиком мусора.
Гибкость API. Гибкий код — это понятный код, с которым легче работать.
Ясность, очевидность, ожидаемый результат. Каждый метод должен быть четко определен и понятен. Управляемый доменом дизайн с согласованными префиксами имен методов повышает ясность и читаемость.
Расширяемость. ISO 8601 — наиболее распространенная и основная для нового API календарная система. Однако нужна возможность пользоваться и другими календарями, которые могли бы предоставлять разработчики приложений, а не только сам JDK.
Локальные типы
Новый пакет java.time.*
содержит много различных типов, каждый из которых предназначен для определенной цели. Сначала изучим типы Local
, которые отделены от концепции часовых поясов.
java.time.LocalDate
В соответствии с названием, этот тип представляет дату без часового пояса: только день, месяц и год.
var date = LocalDate.of(2021, 1, 1);// => 2021-01-01
java.time.LocalTime
Это время без даты и часового пояса. Применяется стандартное определение времени: время внутри суток, в 24-часовом формате, начиная с полуночи.
LocalTime
хранит часы, минуты, секунды и наносекунды. Хотя данный тип поддерживает точность вплоть до наносекунд, имейте в виду, что фактическая точность зависит от реализации JVM/JDK.
var now = LocalTime.now();
// => 12:45:38.896793
var time = LocalTime.of(12, 45);
// => 12:45
java.util.LocalDateTime
Это комбинация LocalTime
и LocalDate
.
var dt = LocalDateTime.of(2021, 1, 1, 12, 45);// => 2021-01-01T12:45
Есть возможность легко декомпозировать на составляющие:
var dt = LocalDateTime.of(2021, 1, 1, 12, 45);
// => 2021-01-01T12:45
var date = dt.toLocalDate();
// => 2021-01-01
var time = dt.toLocalTime();
// => 12:45
Часовые пояса и смещения
Часовые пояса и их смещения — проклятие всех, кто работает со временем. При работе с ними можно допустить много ошибок (что обычно и происходит).
Чтобы это исправить, API Time разделяет ответственность на несколько классов.
ZoneOffset
— смещение от времени в UTC/GMT, от +14:00 до -12:00.ZoneRules
— правила изменения смещения для одного часового пояса (например, летнее время, исторические изменения).ZoneId
— идентификатор часового пояса, например Европа/Берлин.
Доступны два различных типа часовых поясов.
ZonedDateTime
— привязка к определенномуZoneId
.OffsetDateTime
/OffsetTime
— дата/время со смещением, но не привязанные к определенному часовому поясу.
java.time.OffsetDateTime / java.time.OffsetTime
OffsetDateTime
— это упрощенная версия ZonedDateTime
без привязки к конкретному часовому поясу, определенная только смещением. Она больше подходит для форматов обмена, таких как сохранение в базах данных или JSON/XML.
var dt = LocalDateTime.of(2021, 1, 1, 12, 45);
// => 2021-01-01T12:45
var offset = ZoneOffset.of("+02:00");
// => +02:00
var odt = OffsetDateTime.of(dt, offset);
// => 2021-01-01T12:45+02:00
java.time.ZonedDateTime
Смещения часто бывает достаточно, но иногда нужно обрабатывать данные, относящиеся к конкретному часовому поясу. Для таких случаев существует ZonedDateTime
.
var dt = LocalDateTime.of(2021, 1, 1, 12, 45);
// => 2021-01-01T12:45
var zoneId = ZoneId.of("Europe/Berlin");
// => Europe/Berlin
var zdt = ZonedDateTime.of(dt, zoneId);
// => 2021-01-01T12:45+01:00[Europe/Berlin]
Другие типы даты и времени
Помимо типов даты, времени и даты/времени, новый API содержит специальные классы и для других концепций.
java.time.Instant
Instant
— ближайший доступный эквивалент java.util.Date
. Это классическая временная метка по отношению к эпохе. По умолчанию эпоха начинается с метки времени Unix “0” (1970–01–01T00:00:00Z, полночь в начале 1 января 1970 UTC).
var instant = Instant.ofEpochSecond(1609505123);// => 2021-01-01T12:45:23Z
Этот тип можно преобразовать в другие, если предоставить недостающую информацию. Например, чтобы создать LocalDateTime
, нужно указать соответствующий ZoneId
. В таком случае можно будет применить правила, такие как летнее время и смещение.
var instant = Instant.ofEpochSecond(1609505123);
// => 2021-01-01T12:45:23Z
var zoneId = ZoneId.of("Europe/Berlin");
// => Europe/Berlin
var dt = LocalDateTime.ofInstant(instant, zoneId);
// 2021-01-01T13:45:23
java.time.Duration
Duration
представляет собой промежуток, основанный на времени (часы, минуты, секунды, наносекунды). Его можно создать либо напрямую, либо вывести в качестве разности между другими типами:
var sixHours = Duration.ofHours(6);
// => PT6H
var lhs = LocalDateTime.of(2020, 12, 24, 15, 22, 23);
var rhs = LocalDateTime.of(2021, 1, 1, 12, 45, 18);
var diff = Duration.between(lhs, rhs);
// => PT189H22M55S
java.time.Period
Period
— двойник Duration
, только не на основе промежутка времени, а на основе даты (года, месяца, дня).
var threeQuarters = Period.ofMonths(9);
// => P9M
var lhs = LocalDate.of(2020, 7, 12);
var rhs = LocalDate.of(2021, 1, 1);
var diff = Period.between(lhs, rhs);
// => P5M20D
java.time.Year
Год по календарю ISO.
var jan2021 = YearMonth.of(YearMonth.of(2021, Month.JANUARY));// => 2021-01
Имейте в виду: соответствие между григорианским и юлианским календарями есть только для недавних лет. Например, некоторые области России перешли на современный григорианский календарь только в 1920 году. Кроме того, многочисленные календарные реформы могут дополнительно усложнить расчеты с использованием исторических дат.
java.time.YearMonth
Тип даты без указания дня, к примеру январь 2021 года.
var jan2021 = YearMonth.of(YearMonth.of(2021, Month.JANUARY));// => 2021-01
java.time.MonthDay
Указание даты без года, например 6 января.
var threeKingsDay = MonthDay.of(Month.JANUARY, 6);// --01-06
Строка вывода может показаться странной, но подобный вид точно так же определен в стандарте ISO 8601:2000. Однако обновленный стандарт ISO 8601:2004 запрещает пропускать год, если присутствует месяц.
Перечисления Month / DayOfWeek
Еще один источник множества багов — разовые ошибки, связанные с месяцами и днями недели.
- Январь представлен цифрой 1 или цифрой 0?
- А декабрь — 11 или 12?
- Когда начинается неделя? В воскресенье или в понедельник?
- Какими значениями представлены эти дни?
Поскольку API Java Time основан на стандарте ISO 8601, неделя всегда начинается в понедельник. Чтобы сохранять последовательность, для понедельника и января используются значения 1.
Для большего удобства представлены два перечисления, которые в большинстве методов взаимозаменяемы с числовыми значениями.
var january2021 = YearMonth.of(2021, Month.JANUARY);
var wednesday = LocalDateTime.now().with(DayOfWeek.WEDNESDAY);
Документация:
Общий дизайн API
Когда в API много новых классов, приходится запоминать большое количество новых методов и концепций. Чтобы облегчить когнитивную нагрузку, API был разработан с согласованными префиксами имен методов и общими концепциями между типами.
Префиксы имен методов
Можно легко изучить различные возможности типа, просто запустив автозаполнение после запуска префикса.
- get
Классический геттер для извлечения частей типа.
var date = LocalDate.of(2021, Month.JANUARY, 1);
var year = date.getYear();
// => 2021
- with
Возвращает копию с некоторым изменением.
var date = LocalDate.of(2021, Month.JANUARY, 1);
date = date.withDayOfMonth(15)
// => 2021-01-15
- plus/minus
Возвращает копию с результатом вычисления.
var date = LocalDate.of(2021, Month.JANUARY, 1);
date = date.plusDays(15L);
date = date.minusYears(10L)
// => 2011-01-16
- multipliedBy/dividedBy/negated
Дополнительные вычисления для Duration
/Period
.
var quarter = Period.ofMonths(3);
var fourQuarters = quarter.multipliedBy(4);
// => P12M
- to
Преобразование между типами.
var dt = LocalDateTime.of(2021, 1, 1, 12, 45);
var date = dt.toLocalDate();
// => 2021-01-01
var time = dt.toLocalTime();
// => 12:45
- at
Возвращает новый объект с изменениями относительно часового пояса.
var date = LocalDate.of(2021, 1, 1);
LocalDateTime dt = date.atTime(12, 45);
// => 2021-01-01T12:45
var zoneId = ZoneId.of("Europe/Berlin");
ZonedDateTime zdt = dt.atZone(zoneId);
// => 2021-01-01T12:45+01:00[Europe/Berlin]
- of
Статические методы фабрики без преобразований.
var date = LocalDate.of(2021, 1, 1);
var zoneId = ZoneId.of("Europe/Berlin");
- from
Статические методы фабрики c преобразованием.
var dt = LocalDateTime.of(2021, 1, 1, 12, 45);
var date = LocalDate.from(dt);
// => 2021-01-01
Имейте в виду, что преобразование работает только для нисходящих операций. К примеру, нельзя создать LocalDateTime
из LocalDate
:
var date = LocalDate.of(2021, 1, 1);
var dt = LocalDateTime.from(date);
// Выбросит исключение:
// Exception java.time.DateTimeException: Unable to obtain LocalDateTime from TemporalAccessor: 2021-01-01 of type java.time.LocalDate
// | at LocalDateTime.from (LocalDateTime.java:461)
// | at do_it$Aux (#47:1)
// | at (#47:1)
// | Caused by: java.time.DateTimeException: Unable to obtain LocalTime from TemporalAccessor: 2021-01-01 of type java.time.LocalDate
// | at LocalTime.from (LocalTime.java:431)
// | at LocalDateTime.from (LocalDateTime.java:457)
// | ...
- parse
Статические методы фабрики для обработки текстового ввода.
var date = LocalDate.parse("2021-01-01");
var dt = LocalDateTime.parse("2021-01-01T12:45:32");
Парсинг и форматирование
У всех типов определены методы toString()
, основанные на стандарте ISO 8601.
TYPE | FORMAT
----------------|-------------------------------------
LocalDate | uuuu-MM-dd
LocalTime | HH:mm
| HH:mm:ss
| HH:mm:ss.SSS
| HH:mm:ss.SSSSSS
| HH:mm:ss.SSSSSSSSS
LocalDateTime | uuuu-MM-dd'T'HH:mm
| uuuu-MM-dd'T'HH:mm:ss
| uuuu-MM-dd'T'HH:mm:ss.SSS
| uuuu-MM-dd'T'HH:mm:ss.SSSSSS
| uuuu-MM-dd'T'HH:mm:ss.SSSSSSSSS
Year | value without leading zeroes
YearMonth | uuuu-MM
MonthDay | --MM-dd
OffesetDateTime | uuuu-MM-dd'T'HH:mmXXXXX
| uuuu-MM-dd'T'HH:mm:ssXXXXX
| uuuu-MM-dd'T'HH:mm:ss.SSSXXXXX
| uuuu-MM-dd'T'HH:mm:ss.SSSSSSXXXXX
| uuuu-MM-dd'T'HH:mm:ss.SSSSSSSSSXXXXX
OffestTime | HH:mm:ssXXXXX
| HH:mm:ss.SSSXXXXX
| HH:mm:ss.SSSSSSXXXXX
| HH:mm:ss.SSSSSSSSSXXXXX
ZonedDateTime | LocalDateTime + ZoneOffset
ZoneOffset | Z (for UTC)
| +h
| +hh
| +hh:mm
| -hh:mm
| +hhmm
| -hhmm
| +hh:mm:ss
| -hh:mm:ss
| +hhmmss
| -hhmmss
Duration | PT[n]H[n]M[n]S
Period | P[n]Y[n]M[n]D
Формат, созданный через toString()
, также доступен в соответствующих методах parse(CharSequence text)
, что отлично подходит для методов обмена или нелокализованного отображения.
Для более понятных и локализованных представлений можно воспользоваться классом java.time.format.DateTimeFormatter
. Он потокобезопасен, неизменяем и предоставляет гибкий API:
var formatted = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
.withLocale(Locale.GERMAN)
.format(LocalDate.of(2021, 1, 1))
// => Freitag, 1. Januar 2021
Его также можно применить для парсинга, предоставив форматер для метода parse
:
var formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
.withLocale(Locale.GERMAN);
var parse = LocalDate.parse("Freitag, 1. Januar 2021", formatter);
// => 2021-01-01
java.time.TemporalAdjuster
С помощью функционального интерфейса TemporalAdjuster
можно определить стратегии настройки типов, реализующих Temporal
. Таким образом можно получить четко определенные, повторно используемые настройки для новых типов API Time.
API Time предоставляет несколько предопределенных регуляторов через служебный класс TemporalAdjusters
. Названия методов (в основном) говорят сами за себя:
dayOfWeekInMonth(int ordinal, DayOfWeek dayOfWeek)
firstDayOfMonth()
firstDayOfNextMonth()
firstDayOfNextYear()
firstDayOfYear()
firstInMonth(DayOfWeek dayOfWeek)
lastDayOfMonth()
lastDayOfYear()
lastInMonth(DayOfWeek dayOfWeek)
next(DayOfWeek dayOfWeek)
nextOrSame(DayOfWeek dayOfWeek)
ofDateAdjuster(UnaryOperator<LocalDate> dateBasedAdjuster)
previous(DayOfWeek dayOfWeek)
previousOrSame(DayOfWeek dayOfWeek)
Например, в приложении, где есть платная подписка, можно создать настройки для конкретных клиентов, такие как следующая дата выставления счета nextBillingDate(Customer customer)
. Код легко читаем, а вся логика расчета даты выставления счета в зависимости от клиента находится в одном месте.
java.time.temporal.TemporalUnit и java.time.temporal.ChronoUnit
Интерфейс TemporalUnit
предоставляет способ выражения единиц даты и времени, которые затем можно задействовать в расчетах.
Наиболее востребованные единицы измерения уже доступны в перечислении ChronoUnit
, где предоставлены константы для всевозможных видов единиц измерения, таких как MILLENNIA
, DAYS
, HOURS
, MILLIS
и т. д.
- В качестве параметра
Помимо конкретных методов расчета, таких как LocalDate plusDays(long daysToAdd)
, существуют также неспецифические методы, для которых требуется TemporalUnit
, например LocalDate plus(long amountToAdd, TemporalUnit unit)
.
Все вычисления API Time по возможности сохраняют рациональность, а также остаются в пределах своей единицы и связанных с ней более крупных единиц. Новые типы — скорее комбинации различных единиц измерения, а не единичные значения.
Это означает, что если мы добавим месяц к LocalDate
, это повлияет на месяц (и связанные с ним единицы, такие как год).
var date = LocalDate.of(2021, 1, 31);
// 2021-01-31
date = date.plus(1L, ChronoUnit.MONTHS);
// => 2021-02-28
Разницу также легко подсчитать с помощью ChronoUnit
. В случае LocalDate
:
var startDate = LocalDate.of(2021, 1, 1);
// 2021-01-01
var endDate = LocalDate.of(2023, 11, 8);
// 2023-11-08
var amount = startDate.until(endDate, ChronoUnit.WEEKS);
// => 148
- Статические методы
Само значение перечисления содержит два метода для большей понятности кода вычислений.
<R extends Temporal> R addTo(R temporal, long amount)
;long between(Temporal temporal1Inclusive, Temporal temporal2Exclusive)
.
Предыдущие примеры также можно выразить через эти методы:
var date = LocalDate.of(2021, 1, 31);
// 2021-01-31
date = ChronoUnit.MONTHS.addTo(date, 1L);
// => 2021-02-28
var startDate = LocalDate.of(2021, 1, 1);
// => 2021-01-01
var endDate = LocalDate.of(2023, 11, 8);
// => 2023-11-08
var amount = ChronoUnit.WEEKS.between(startDate, endDate);
// => 148
В сочетании со static import
читаемость повышается еще больше:
import static java.time.temporal.ChronoUnit;
var date = LocalDate.of(2021, 1, 31);
// => 2021-01-31
date = MONTHS.addTo(date, 1L);
// 2021-02-28
var startDate = LocalDate.of(2021, 1, 1);
// => 2021-01-01
var endDate = LocalDate.of(2023, 11, 8);
// => 2023-11-08
var amount = WEEKS.between(startDate, endDate);
// => 148
- Продолжительность
Константы перечисления также представимы в виде Duration
в рамках календаря ISO:
ChronoUnit.HALF_DAYS.getDuration();
// => PT12H
ChronoUnit.WEEKS.getDuration();
// => PT168H
ChronoUnit.CENTURIES.getDuration();
// => PT876582H
Преобразование между типами
Нельзя просто взять и заменить все экземпляры java.util.Date
одним из новых типов. Поэтому необходима возможность осуществлять преобразование между ними. Метод java.util.Date#toInstant()
обеспечивает связь между старыми и новыми датами.
Instant
может быть преобразован в другой тип, если предоставлены соответствующие данные относительно часового пояса:
Соответствующий код вполне понятен:
// Шаг 1: Есть объект даты до-JSR310
var date = new Date();
// Шаг 2: Определить подходящий часовой пояс
var zone = ZoneId.systemDefault();
// Шаг 3: Преобразовать дату в Instant
var instant = date.toInstant();
// SШаг 4: Преобразовать в ZonedDateTime
var zdt = instant.atZone(zone);
Теперь можно воспользоваться еще одним методом to
для дальнейших преобразований.
Поддержка Android
В прошлом многие функции Java 8+ не сразу были доступны для Android. Для них требовался либо сторонний фреймворк, либо бэкпорты.
Благодаря плагину Android Gradle 4.0 многие функции Java 8 можно использовать с помощью десахаризацию без необходимости повышать уровень API. Однако потребуются некоторые изменения в build.gradle
и новая зависимость:
android {
defaultConfig {
// Требуется при установке minSdkVersion на 20 или ниже
multiDexEnabled true
}
compileOptions {
// Отметка для получения поддержки нового API языка
coreLibraryDesugaringEnabled true
// Установка совместимости с Java 8
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
}
Список всех API, доступных через десахаризацию, можно найти здесь.
API Time для Java 6 и 7
Хотя Java 8 вышла шесть лет назад, не все могут позволить себе перейти на эту версию. Но не отчаивайтесь. Благодаря бэкпорту у вас остается возможность воспользоваться новым API.
Проект ThreeTen Backport предоставляет совместимый с Java 6 и 7 способ применения новых типов без дополнительных зависимостей. Проект поддерживается главным автором API Time Стивеном Коулборном.
Ссылки
- JSR-310 Date and Time API Guide (JCP.org).
- Package java.time (Oracle).
- ISO 8601 (Wikipedia).
- ThreeTen Backport.
Читайте также:
- 3 применения исключений, которые улучшат навыки программирования на Java
- Осваиваем реактивное программирование на Java
- Асинхронность в Java
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Ben Weidig: Essentials of Java’s Time API