Наконец-то в Java появился интуитивный, надежный метод работы с датами и временем.

Принципы даты и времени являются фундаментальными во многих приложениях. Такие разные вещи как даты рождения, сроки аренды, время событий и часы открытия магазина, все основаны на датах и времени, но Java SE не предоставляла удобного способа работы с ними. Начиная с Java SE 8, появился набор пакетов java.time - который предоставляет хорошо структурированный API для работы с датами и временем.

Предыстория

Когда Java впервые появилась, в версии 1.0, единственным классом для работы с датами и временем был java.util.Date. Первым на что обратили внимание разработчики было то, что он не представляет собой «дату». На самом деле он представляет собой момент времени с точностью до миллисекунд, отмеренный с даты 1-го января 1970-го года. Однако, на основании того что метод toString() у Date выводит дату и время в том часовом поясе который указан в настройках java машины, некоторые разработчики ошибочно сделали вывод о том что Date умеет работать с часовыми поясами.

Исправить этот класс оказалось настолько сложно (или настолько лениво) что в версии 1.1 пришлось добавить новый класс - java.util.Calendar. К сожалению, класс Calendar оказался не сильно лучше чем Date.

Вот небольшой список имеющихся проблем в его реализации:

  • Изменяемый. Такие классы как дата и время должны быть неизменяемыми.
  • Смещения. Года в Date начинаются с 1900, месяца в обоих классах начинаются с нуля.
  • Наименования. Date это на самом деле не «дата», и Calendar не является календарем.
  • Форматирование. Форматирование работает только с Date, а не с Calendar, и не является потокобезопасным.

В 2001м году был создан проект Joda-Time. Цель его была проста - создать качественную библиотеку для работы с датами и временем в Java. Это заняло определенное время, но в конечном итоге была выпущена версия 1.0 и она быстро стала очень популярной и широко используемой. Со временем разработчики все больше требовали предоставить аналогичную по удобству библиотеку в составе JDK. При участии Michael Nascimento Santos из Бразилии, был запущен проект JSR-310, который является официальным процессом создания и интеграции нового API для работы с датами и временем в JDK.

Обзор

Новый API java.time содержит 5 пакетов:

  • java.time - базовый пакет, содержащий объекты для хранения значений
  • java.time.chrono - предоставляет доступ к разным календарям
  • java.time.format - форматирование и распознание даты и времени
  • java.time.temporal - низкоуровневые библиотеки и расширенный функционал
  • java.time.zone - классы для работы с часовыми поясами

Большинство разработчиков будут в основном использовать базовый пакет и форматирование, и возможно java.time.temporal. Таким образом, несмотря на то что было добавлено 68 новых типов, разработчики будут использовать не более трети из них.

Даты

Класс LocalDate - один из самых главных в новом API. Он содержит неизменяемое значение, представляющее собой дату. Задать время или часовой пояс нельзя.

Название «local» вам может быть знакомо из Joda-Time, и изначально происходит из стандарта ISO-8601. Оно обозначает именно отсутствие часового пояса. В сущности, LocalDate это описание даты, такое как «5-е апреля 2014». Фактическое же время этой даты будет отличаться, в зависимости от вашего часового пояса. К примеру, в Австралии эта дата будет на 10 часов раньше чем в Лондоне, и на 18 часов раньше чем в Сан Франциско.

В классе LocalDate есть все обычно нужные методы:

Java
LocalDate date = LocalDate.of(2014, Month.JUNE, 10); 
int year = date.getYear(); // 2014 
Month month = date.getMonth(); // Июнь 
int dom = date.getDayOfMonth(); // 10 
DayOfWeek dow = date.getDayOfWeek(); // Вторник
int len = date.lengthOfMonth(); // 30 (дней в Июне) 
boolean leap = date.isLeapYear(); // false (не високосный год)

В нашем примере, мы видим дату созданную с помощью метода-фабрики (все конструкторы приватные). Дальше мы запрашиваем у объекта некоторые данные. Обратите внимание, что перечисления Month и DayOfWeek созданы для того чтобы делать код более читаемым и надежным.

В следующем примере мы увидим как модифицировать дату. Так как класс неизменяемый, результатом будут новые объекты, а исходный останется как был.

Java
LocalDate date = LocalDate.of(2014, Month.JUNE, 10); 
date = date.withYear(2015); // 2015-06-10 
date = date.plusMonths(2); // 2015-08-10 
date = date.minusDays(1); // 2015-08-09

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

Java
import static java.time.DayOfWeek.* 

import static java.time.temporal.TemporalAdjusters.* 

LocalDate date = LocalDate.of(2014, Month.JUNE, 10); 
date = date.with(lastDayOfMonth()); 
date = date.with(nextOrSame(WEDNESDAY));

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

Время и дата как значения

Имеет смысл потратить немного времени на то чтобы разобраться, что превращает LocalDate в значения. Значения - простые типы данных, которые являются полностью взаимозаменяемыми когда они равны, идентичность объектов теряет всякий смысл. Классический пример класса-значения - String. Мы сравниваем строки через equals(), и нас не волнует идентичны ли объекты при сравнении оператором ==.

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

Альтернативные календари

Класс LocalDate, как и все главные классы в java.time, привязан к единственному календарю - описанному в стандарте ISO-8601.

В стандарте 8601 описан общемировой стандартный календарь, также известный как грегорианский. Стандартный год включает в себя 365 дней, високосный - 366. Високосным является каждый четвертый год, если он не делится на 100 либо делится на 400. Год перед первым годом новой эры считается нулевым для удобства вычислений.

Первым последствием от того что эта система принята по умолчанию, является то что результаты не всегда совпадают с рассчитанными с помощью GregorianCalendar. В классе GregorianCalendar встроено переключение на юлианскую систему для всех дат раньше 15го октября 1582 года. В юлианской системе високосным годом был каждый четвертый год без исключений.

Спрашивается, раз переход от одной системы к другой является историческим фактом, почему java.time его не моделирует? Да потому что разные страны переходили на грегорианскую систему в разное время, и учитывая только дату перехода Ватикана мы получим неверные данные для большинства других стран. К примеру, Британская Империя, включая колонии в Северной Америке, перешла на грегорианский календарь 14го сентября 1752го года, почти 200 лет спустя. Россия не меняла свой календарь до 14го февраля 1918го года, а переход Швеции вообще дело темное. В результате, реальное значение дат до 1918го года сильно зависит от обстоятельств. Авторами кода LocalDate было принято вполне рациональное решение не моделировать переход от юлианского календаря к грегорианскому вообще, чтобы избежать разночтений.

Вторым последствием использования ISO-8601 в качестве календаря по умолчанию во всех основных классах, является необходимость дополнительного набора классов для работы с остальными календарями. Интерфейс Chronology является основой для работы с альтернативными календарями, позволяющей найти нужный календарь по имени локали. С Java 8 поставляется 4 дополнительных календаря - Тайский буддистский, Мингуо (тайваньский), Японский и Исламский. Другие календари могут поставляться с программами.

Для каждого календаря есть специальный класс даты, такой как ThaiBuddhistDate, MinguoDate, JapaneseDate и HijrahDate. Использовать их имеет смысл в очень сильно локализованных приложениях, таких как приложения для японского правительства. Дополнительный интерфейс, ChronoLocalDate, применяется как основная абстракция четырех вышеперечисленных классов вместе с LocalDate, что позволяет писать код независимый от используемого типа календаря. Несмотря на существование этой абстракции, ее использование не рекомендуется.

Понимание почему использование данной абстракции не рекомендуется, является важным для понимания работы всего java.time API. Суть в том, что большая часть кода который пишут без привязки к конкретному календарю, оказывается нерабочей. К примеру, вы не можете быть увереными что в году 12 месяцев, но некоторые разработчики прибавляют 12 месяцев и считают что добавили целый год. Вы не можете быть уверены что все месяцы содержат примерно одинаковое количество дней - в Коптском календаре 12 месяцев по 30 дней, и 1 месяц из пяти или шести дней. Также вы не можете быть увереными, что номер следующего года будет на 1 больше чем текущего, потому что в Японском календаре годы считаются от провозглашения очередного императора (в данном случае, даже 2 дня одного и того же месяца могут принадлежать разным годам).

Единственный способ написать качественный и рабочий код работающий с несколькими календарями сразу - ядро вашего кода, производящее все операции над датами и временем, должно быть написано с использованием стандартного календаря, и только при вводе/выводе дат производить преобразование в другие календарные системы. То есть, рекомендуется использовать LocalDate для хранения и всех манипуляций с датами в вашем приложении. И только при локализации вводимых и выводимых дат использовать ChronoLocalDate, которую обычно получают из хранимого в профиле пользователя класса календаря. Правда, большинство приложений не нуждаются в столь серьезной локализации.

Если вам нужно более подробное обоснование всего описанного в этой главе - добро пожаловать в документацию класса ChronoLocalDate.

Время суток

Итак, идем дальше. Следующей сущностью, после даты, является время суток, представляемое классом LocalTime. Классический пример - представление времени работы магазина, скажем с 7:00 до 23:00. Магазины открываются в это время по всей стране, но фактическое время будет различное, в зависимости от часового пояса.

LocalTime это класс-значение, в котором хранится только время, без ассоциированной даты и часового пояса. При добавлении или вычитании временного промежутка, он обрежется по полночи. То есть, 20:00 плюс 6 часов это 2:00.

Использование LocalTime похоже на LocalDate:

Java
LocalTime time = LocalTime.of(20, 30); 
int hour = date.getHour(); // 20 
int minute = date.getMinute(); // 30 
time = time.withSecond(6); // 20:30:06 
time = time.plusMinutes(3); // 20:33:06

Модификаторы могут работать и с LocalTime, однако операции с временем обычно не настолько сложны чтобы требовалось использование модификаторов.

Комбинирование даты и времени

Следующий класс который мы рассмотрим - LocalDateTime. Этот класс-значение является комбинацией LocalDate и LocalTime. Он представляет и дату и время, без часового пояса.

LocalDateTime может быть создан или напрямую, или комбинируя дату и время:

Java
LocalDateTime dt1 = LocalDateTime.of(2014, Month.JUNE, 10, 20, 30); 
LocalDateTime dt2 = LocalDateTime.of(date, time); 
LocalDateTime dt3 = date.atTime(20, 30); 
LocalDateTime dt4 = date.atTime(time);

Третий и четвертый варианты используют метод atTime(), который предоставляет гибкий способ комбинировать дату и время. Большинство системных классов даты и времени имеют «at» методы, которые могут быть использованы при комбинировании вашего объекта с другим чтобы создать более сложный.

Другие методы класса LocalDateTime похожи на аналогичные из LocalDate и LocalTime. Сходные шаблоны наименования методов помогают легче изучить API. В этой таблице перечислены все задействованные префиксы методов:

Интуитивно понятная, надежная библиотека для работы с временем и датами, наконец-то появилась в Java (Часть 2). - 1

Instant

Когда мы имеем дело с датами и временем, обычно мы работаем с годами, месяцами, днями, часами, минутами, секундами. Однако, это лишь одна модель времени, которую можно назвать «человеческая». Вторая часто используемая модель - «машинного» или «непрерывного» времени. В этой модели точка на оси времени представлена одним большим числом. Данный подход упрощает алгоритмы рассчета, и используется для хранения времени в операционной системе Unix, там время представлено числом секунд прошедших с 1го января 1970го года. Аналогично, в Java время хранится как число миллисекунд прошедших с 1го января 1970го года.

Машинный подход к рассчетам времени в java.time API обеспечивается классом-значением Instant. Он предоставляет возможность представить точку на оси времени без всей сопутствующей информации, такой как часовой пояс. Фактически, данный класс содержит в себе число наносекунд прошедшее с полуночи 1го января 1970го года.

Java
Instant start = Instant.now(); 
// произведем вычисления
Instant end = Instant.now(); 
assert end.isAfter(start); 
//машина времени не сработала

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

В большинстве случаев, интереснее то чего мы не можем сделать с классом Instant чем то что мы сможем с ним сделать. К примеру, следующие строки кода вызовут исключения:

Java
instant.get(ChronoField.MONTH_OF_YEAR); 
instant.plus(6, ChronoUnit.YEARS);

Исключения происходят потому, что объект instant хранит только количество наносекунд и не предоставляет возможность работать с единицами времени более полезными человеку. Для того чтобы воспользоваться другими единицами измерения, вам как минимум нужно указать часовой пояс.

Часовые пояса

Принцип часовых поясов был разработан в Англии, когда изобретение железных дорог и улучшение других способов связи позволило людям перемещаться на расстояния, достаточные для того чтобы разница в солнечном времени была заметной. До этого времени, каждая деревня и город жили по своему времени, которое чаще всего измеряли по солнечным часам.

На этой картинке виден пример того, к каким сложностям это приводило - красные стрелки на часах показывают время по Гринвичу, а черная - местное время, отличающееся на 10 минут:

Интуитивно понятная, надежная библиотека для работы с временем и датами, наконец-то появилась в Java (Часть 2). - 2

Система часовых поясов развивалась, заменяя собой локальное солнечное время. Но ключевой факт - часовые пояса созданы политиками, и часто используются чтобы демонстрировать политический контроль над территорией. Как и любая политика, правила связанные с часовыми поясами часто противоречат логике. А также, эти правила могут меняться, и часто меняются, без каких-то предупреждений.

Правила часовых поясов собраны международной группой, опубликовавшей базу часовых поясов IANA. Там содержится идентификатор каждого региона Земли, и история изменений часовых поясов для него. Идентификаторы выглядят как "Europe/London" или "America/New_York".

До выхода java.time API, для представления часового пояса использовался класс TimeZone. Теперь вместо него используется ZoneId. Между ними есть два ключевых различия. Первое - ZoneId является неизменяемым, что дает возможность хранить объекты этого класса в статических переменных среди всего прочего. Второе - сами правила хранятся в классе ZoneRules, а не в самом ZoneId, и чтобы получить их нужно вызвать метод getRules() у объекта класса ZoneId.

Общей чертой всех часовых поясов является фиксированное смещение от UTC/Greenwich. Чаще всего вы используете это, когда говорите о разнице во времени между разными городами, такими как «Нью Йорк на 5 часов отстает от Лондона». Класс ZoneOffset, являющийся наследником ZoneId, представляет разницу во времени с нулевым мередианом, проходящим через Гринвич в Лондоне.

С точки зрения разработчика, было бы отлично не возиться с часовыми поясами и их сложностями. java.time API позволяет вам делать это до тех пор пока это в принципе возможно. Везде где есть возможность, используйте классы LocalDate, LocalTime, LocalDateTime и Instant. Там же где без часовых поясов нельзя, используйте класс ZonedDateTime.

Класс ZonedDateTime позволяет преобразовывать даты и время с человеческих единиц измерения, которые мы видим на календарях и часах, в машинные единицы. Как следствие, создать ZonedTimeDate вы можете как из Local класса, так и из Instant:

Java
ZoneId zone = ZoneId.of("Europe/Paris"); 

LocalDate date = LocalDate.of(2014, Month.JUNE, 10); 
ZonedDateTime zdt1 = date.atStartOfDay(zone); 

Instant instant = Instant.now(); 
ZonedDateTime zdt2 = instant.atZone(zone);

Одной из самых неприятных особенностей часовых поясов является так называемое летнее время. С переходом на летнее время и обратно, разница вашего часового пояса с Гринвичем меняется дважды (или больше) в год, обычно увеличиваясь весной и уменьшаясь осенью. Когда это происходит, мы должны перевести все часы у нас дома. В классах java.time данные смещения представлены как «преобразования смещения». Весной это вызывает «разрыв» во времени, когда некоторые значения времени невозможны, а осенью наоборот - некоторые значения времени возникают дважды.

Все это поддерживается классом ZonedDateTime с помощью его методов-фабрик и методов преобразователей. К примеру, прибавление одного дня добавляет логический день, который может быть представлен больше или меньше чем 24 часами, если мы переходим на летнее время или обратно. Аналогично, метод atStartOfDay() назван так потому, что мы не можем гарантировать что день начнется ровно в полночь - надо учитывать разрыв времени при переходе на летнее.

И последняя подсказка касающаяся летнего времени. Если вы хотите продемонстрировать что вы учли нахлест времени при переходе с летнего на зимнее (когда одно и то же значение времени возникает дважды), вы можете использовать один из двух специальных методов, предназначенных для таких ситуаций:

Java
zdt = zdt.withEarlierOffsetAtOverlap(); 
zdt = zdt.withLaterOffsetAtOverlap();

Данные методы вернут более раннее или более позднее значение, если объект угодил в нахлест при переходе с летнего времени на зимнее. Во всех остальных ситуациях, возвращаемые значения будут одинаковыми.

Промежутки времени

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

Класс Duration представляет отрезок времени, измеряемый в секундах и наносекундах. К примеру, «23.6 секунд».

Класс Period представляет промежуток времени, измеряемый в годах, месяцах и днях. К примеру - «3 года, 2 месяца и 6 дней».

Эти промежутки могут быть добавлены или вычтены из даты или времени:

Java
Period sixMonths = Period.ofMonths(6); 
LocalDate date = LocalDate.now(); 
LocalDate future = date.plus(sixMonths);

Форматирование и разбор

Целый пакет предназначен для форматирования и вывода дат и времени - java.time.format. Пакет вращается вокруг класса DateTimeFormatter и его фабрики DateTimeFormatterBuilder.

Самыми распространенными способами создания форматировщика являются статические методы и константы в DateTimeFormatter, включая:

  • Константы для распространенных форматов описанных в ISO, таких как ISO_LOCAL_DATE.
  • Шаблоны обозначаемые буквами, такие как ofPattern("dd/MM/uuuu").
  • Локализованые стили, такие как ofLocalizedDate(FormatStyle.MEDIUM).

После того как вы создали форматировщик, обычно вы используете его передав в соответствующий метод класса даты:

Java
DateTimeFormatter f = DateTimeFormatter.ofPattern("dd/MM/uuuu"); 
LocalDate date = LocalDate.parse("24/06/2014", f); 
String str = date.format(f);

Таким образом, код отвечающий за форматированый вывод даты изолирован в отдельный класс.

Если вам нужно отдельно указать локаль для форматирования даты, используйте метод форматировщика withLocale(Locale). Аналогичные методы есть у классов отвечающих за календарь, часовой пояс, ввод/вывод дробных чисел.

Если вам нужна более тонкая настройка параметров, смотрите документацию на класс DateTimeFormatterBuilder, позволяющий создавать сложные форматировщики шаг за шагом. Также он позволяет задавать регистронезависимый разбор текста, игнорировать часть ошибок разбора, задавать смещения и необязательные элементы.

Итог

java.time API - новая всеобъемлющая модель для работы с датой и временем в Java SE 8. Она берет идеи и реализации из Joda-Time на следующий уровень и наконец-то позволяет разработчикам не использовать java.util.Date и Calendar. Теперь работа с датами и временем может доставлять удовольствие!