Во многих языках программирования работа с датой и временем  —  непростая задача. Но, начиная с 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
  • Статические методы

Само значение перечисления содержит два метода для большей понятности кода вычислений.

Предыдущие примеры также можно выразить через эти методы:

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 может быть преобразован в другой тип, если предоставлены соответствующие данные относительно часового пояса:

Возможности преобразования между типами времени Java

Соответствующий код вполне понятен:

// Шаг 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 Стивеном Коулборном.

Ссылки

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Ben Weidig: Essentials of Java’s Time API

Предыдущая статьяКак работает архитектурный паттерн «модель-вид-контроллер»
Следующая статьяЛучшие практики модульного тестирования