четверг, 2 августа 2012 г.

Joda time

Joda-time - наиболее известная и активно поддерживаемая/используемая библиотека проекта joda. Как гордо говорится на её странице, это "де-факто стандартная библиотека для работы с датами и временем в java". Реализация как Date, так и Calendar в java core имеют серьёзные недостатки, из-за чего joda-time и стала такой популярной. В качестве подтверждения популярности и качества библиотеки можно рассматривать предложения о включении её в состав jdk 7 (правда, эти ожидания так и не оправдались, зато появились новые - о включении в jdk 8).

Вот несколько причин, по которым joda-time появилась и стала востребованной: простота в использовании, лёгкая расширяемость, большой перечень возможностей, 100% совместимость со стандартными классами jdk, улучшенные характеристики производительности, поддержка нескольких (в настоящее время - 8) календарей, хорошее тестовое покрытие, полная документация, зрелость, открытый исходный код. Подробнее обо всём этом можно почитать прямо на главной странице проекта. Далее в этом посте - инфо, примеры использования joda-time и полезные ссылки.

ПОКАЖИТЕ МНЕ РАЗНИЦУ!

Практически ни одно крупное приложение не обходится без работы с датами и временем; часто им требуется знать текущее время или какое-то время в прошлом/будущем, а иногда ещё и высчитывать разницу между ними. Кодировать эти операции с jdk можно, но сложно и нудно. joda-time позволяет делать это проще и понятнее. Проверим.

Пусть нам необходимо зафиксировать момент времени, скажем, ровно в полночь 01.08.2012. Как это сделать?
  • java.util.Date? Доступны только два конструктора - без параметров (для фиксации текущего момента времени) и с одним параметром типа long - количества миллисекунд, прошедших "от начала времён" - оба нам явно не подходят.
  • java.util.Calendar? Уже лучше, можно решить задачу так:
        Calendar calendar = Calendar.getInstance();
        calendar.set(2012, Calendar.AUGUST, 1, 0, 0, 0);
  • org.joda.time? Решение выглядит так:
        DateTime dateTime = new DateTime(2012, 8, 1, 0, 0, 0);
Пока особенно большой разницы не заметно - подумаешь, одна строка вместо двух! Но давайте усложним задачу - попробуем добавить к зафиксированной дате 90 дней (например, период действия какой-то учётной записи) и распечатать результат.
Listing 1. Добавление 90 дней и форматирование вывода в Calendar
        Calendar calendar = Calendar.getInstance();
        calendar.set(2012, Calendar.AUGUST, 1, 0, 0, 0);

        // add 90 days and format
        calendar.add(Calendar.DAY_OF_MONTH, 90);
        SimpleDateFormat sdf = new SimpleDateFormat("E yyyy-MM-dd HH:mm:ss");
        System.out.println(sdf.format(calendar.getTime()));
Listing 2. Добавление 90 дней и форматирование вывода в joda-time
        DateTime dateTime = new DateTime(2012, 8, 1, 0, 0, 0);

        // add 90 days and format
        System.out.println(dateTime.plusDays(90).toString("E yyyy-MM-dd HH:mm:ss"));
Здесь одна и та же операция записывается всего двумя строками вместо пяти. А если приложению потребуется получить дату последнего дня той недели, которая будет спустя 2 месяца и 5 дней с сегодняшней даты? Пытаться реализовать это на Calendar даже не хочется. Но на joda-time результат выглядит вполне понятным и элегантным:
Listing 3. joda-time и путь к спасению
        DateTime dateTime = new DateTime(2012, 8, 1, 0, 0, 0);

        //tricky task
        System.out.println(
            dateTime.plusMonths(2).plusDays(5).dayOfWeek().withMaximumValue().toString("E yyyy-MM-dd HH:mm:ss"));
Результатом выполнения будет "Вс 2012-10-07 00:00:00" - можете проверить!

КЛЮЧЕВЫЕ КОНЦЕПЦИИ
  • Неизменность (immutability)
Обсуждаемые классы являются неизменными, т. ч. их инстансы не могут быть изменены (кстати, именно отсюда следует thread-safe характеристика joda-time). Методы API, используемые для вычислений, возвращают новый инстанс соответствующего joda класса, оставляя инстанс-оригинал неизменным. Поэтому при работе с joda нужно всегда принимать возвращаемые методами значения, поскольку объект, на котором эти методы вызываются, не изменяется (эта концепция близка к тому, как работает java.lang.String, например). 
Строго говоря, если вам так уже необходимы изменяемые (mutable) объекты, joda-time предоставит такую возможность.
Дело в том, что в библиотеке имеются два интерфейса - ReadableХХХ и ReadWritableХХХ (где ХХХ - DateTime, Instant, Period и др.). 
Единственное отличие интерфейсов - в способности к изменению - первые immutable, вторые mutable. Соответственно, вместо того же DateTime применяйте MutableDateTime - и будет счастье (вернее, изменяемые объекты).
  • Момент времени (instant)
Фиксируемый момент времени представляет собой время в миллисекундах, прошедших "от начала времён". Это определение соответствует аналогичному в jdk, что способствует совместимости joda-time с jdk Date и Calendar.
  • Частичное время (partial)
Концепция частичного временного отрезка (a partial time snippet) позволяет не привязываться к какому-то конкретному уникальному моменту (как instant), а создавать "скользящие", относительные времена. Например, время "1-е августа" является относительным - оно бывает каждый год. А время полудня "12:00:00" повторяется каждый день. В результате частичное время удобно для конструирования объектов, связанных с какими-то периодическими временами, когда точная фиксация момента не требуется.
  • Хронология (chronology)
Это концепция, лежащая в самом сердце joda-time (и описываемая одноимённым абстрактным классом). Фактически хронология - это календарь, система исчисления времени, и одновременно каркас для выполнения операций со временем. joda-time поддерживает 8 хронологий, в том числе ISO (по умолчанию), григорианский, коптский, арабский, юлианский, буддийский и др. календарные системы.
  • Временная зона (time zone)
Временная зона - это географическое размещение относительно Гринвича, используемое для вычисления времени. Чтобы с точностью знать время, когда произошло то или иное событие, важно знать, где это событие случилось. Любые важные операции со временем должны выполняться с учётом временных зон (в виде исключения временные зоны можно не учитывать, если расчёты делаются для одной зоны и результаты не будут использоваться в других зонах). Данная концепция реализуется в joda-time в классе DateTimeZone. По умолчанию при расчётах библиотека использует зону, получаемую из настроек системы (на котором выполняется прикладной код); дополнительно многие конструкторы и методы имеют реализации для задания нужной зоны.

КАК СОЗДАТЬ ОБЪЕКТЫ joda-time?

Давайте поближе познакомимся с классами joda-time, которые обычно используются для работы с библиотекой. Как правило, их конструкторы имеют несколько реализаций с разными параметрами:
    * текущее системное время;
    * заданный момент времени (или частичный временной отрезок) с возможностью уточненить значения конкретных полей для достижения необходимой точности;
    * заданный момент времени (или частичный временной отрезок) в миллисекундах;
    * другой объект (например, java.util.Date или другой joda объект).
Попробуем поэкспериментировать с конструкторами.
  • Момент времени. joda-time реализует концепцию момента времени через классы, имплементирующие интерфейс ReadableInstant.
    • DateTime: класс, который используется наиболее часто. Он фиксирует момент времени с точностью до миллисекунд. Он всегда связан с временной зоной DateTimeZone, которая — если вы не уточнили отдельно — по умолчанию является системной таймзоной вашего компьютера. Как создать такой объект?
      •  На основе системного времени:
        DateTime dateTime1 = new DateTime();
        DateTime dateTime2 = DateTime.now();
      • Явно задавая значения полей:
        DateTime dateTime = new DateTime(
                       2012, //year
                       8,      // month
                       1,      // day
                       5,      // hour (midnight is zero)
                       30,    // minute
                       0,      // second
                       10     // milliseconds
        );
      • На основе количества миллисекунд с начала эпохи:
      DateTime dateTime = new DateTime(timeInMillis);
      • На основе другого объекта:
Listing 4. Передача различных объектов в конструктор DateTime
        DateTime dateTime;

        // Use a Date class
        java.util.Date jdkDate = obtainDateSomehow();
        dateTime = new DateTime(jdkDate);
       
        // Use a Calendar
        java.util.Calendar calendar = obtainCalendarSomehow();
        dateTime = new DateTime(calendar);

        // Use another joda DateTime
        DateTime anotherDateTime = obtainDateTimeSomehow();
        dateTime = new DateTime(anotherDateTime);

        // Use a String (must be formatted properly)
        String timeString = "2006-01-26T13:30:00-06:00";
        dateTime = new DateTime(timeString);
        timeString = "2006-01-26";
        dateTime = new DateTime(timeString);
    • DateMidnight: то же, что и простой DateTime, однако та его часть, которая хранит время суток, всегда соответствует полночи. Этот и другие классы, рассматриваемые ниже, создаются подобным образом, что и DateTime. Поэтому в целях экономии места и времени их конструкторы будут описываться не так подробно.

  • Далеко не всегда приложению требуется знание одновременно даты и времени (например, важны только год/месяц/день, или час дня, или день недели), поэтому часто оправдано применение концепции частичных временных отрезков, описываемой интерфейсом ReadablePartial.
    •  LocalDate: этот класс реализует комбинацию год/месяц/день (класс заменяет класс org.joda.time.YearMonthDay, применявшийся ранее и объявленный как deprecated с версии 1.3). Это удобно для хранения дат, например, дня рождения, даты заказа и пр.
        LocalDate lDate1 = LocalDate.now();
        LocalDate lDate2 = new LocalDate(2012, 8, 1); 
  • LocalTime: этот класс реализует комбинацию типа час/минута/секунда.
        LocalTime lTime1 = LocalTime.now();
        LocalTime lTime2 = LocalTime.MIDNIGHT;
        LocalTime lTime3 = new LocalTime(14, 43, 0, 0);
  • Промежутки времени. Иногда полезно работать с объектами, выражающими промежутки времени. Для решения такой задачи joda-time предлагает три класса, т. ч. вы можете выбирать наиболее подходящую реализацию:
    • продолжительность (Duration): абсолютный математический промежуток времени, выраженный в миллисекундах. Методы этого класса умеют конвертировать стандартные time-units (секунды, минуты, часы) на основе стандартных математических соотношений (таких, как 60 секунд в минуте и 24 часа в сутках). Класс полезен, если требуется выразить разницу во времени в миллисекундах;
    • период (Period): реализует ту же концепцию, что и Duration, но делает это в более человекочитаемых терминах месяцев, недель, дней и т.д.;
    • интервал (Interval): представляет специфический промежуток времени с определенным моментом, отмечая его границы. Интервал полуоткрыт, т. е. промежуток времени, хранящего в объекте типа Interval, включает начало промежутка, но не включает конец.

РАБОТА СО ВРЕМЕНЕМ В СТИЛЕ joda-time

Теперь, когда мы изучили создание полезных классов, давайте посмотрим, как использовать их для манипулирования и вычисления. Если всё, что вам требуется при работе со временем, это хранить информацию о дате и времени, тогда вам хватит и возможностей jdk. Но если вы замахиваетесь на вычисление времени - то joda-time вам в помощь. Вот несколько простых примеров.
Предположим, какая-то информация в приложении собирается на последний день предыдущего месяца. В этом случае нет нужды хранить время суток - достаточно будет формата год/месяц/день, т. ч. можно примерить LocalDate:
Listing 5. Расчёт даты
        LocalDate lastDayOfPreviousMonth = LocalDate.now()
                .minusMonths(1)
                .dayOfMonth()
                .withMaximumValue();
Возможно, следует подробнее остановиться на вызове dayOfMonth() в приведённом листинге (или на вызове dayOfWeek() в листинге 3). Это вызовы т. н. свойств (которые являются атрибутами некоторых объектов joda-time). Именно через свойства реализуется большая часть возможностей библиотеке по вычислению времени. Кроме рассмотренных dayOfMonth и dayOfWeek ещё есть yearOfCentury, monthOfYear, dayOfYear и др.
Теперь давайте пошагово изучим представленный в листинге 6 пример. Сначала мы получаем текущую дату, затем отнимаем один месяц, чтобы узнать "предыдущий месяц". Далее запрашиваем свойство dayOfMonth (день месяца), а у него получаем его максимальное значение - т. е. искомый последний день предыдущего месяца.
Обратите внимание, что вызовы организованы вместе в одну цепочку, что достигается за счёт неизменности применяемых joda-time объектов. Т. ч. достаточно сохранить результат последнего вызова, чтобы получить желаемое значение; и нет никакой необходимости в хранении промежуточных результатов.
А вот ещё несколько примеров:
Listing 6. Несколько небольших примеров
        DateTime now = DateTime.now();

        DateTime twoWeeksBefore = now.minusWeeks(2);

        DateTime tomorrow = now.plusDays(1);
        DateTime twentyEightDaysAfterTomorrow = tomorrow.plusDays(28);

        DateTime someSecondsLater = now.plusSeconds(156);

        LocalDate then = LocalDate.now() // current time
                .minusYears(5)                       // five years ago
                .monthOfYear()                      // get 'monthOfYear' property
                .setCopy(2)                            // set it to February
                .dayOfMonth()                       // get 'dayOfMonth' property
                .withMaximumValue();           // and find the last day of month
А вот как можно рассчитать разницу между датами:
Listing 7. Разница между датами
        LocalDate now = LocalDate.now();
        LocalDate then = new LocalDate(2005, 4, 20);
        System.out.println(then.toString("E yyyy-MM-dd") + ", " + now.toString("E yyyy-MM-dd"));

        // difference in days, or months, or other time units
        System.out.println("Diff in days: " + Days.daysBetween(then, now).getValue(0));
        System.out.println("Diff in months: " + Months.monthsBetween(then, now).getValue(0));

        // period between two dates
        Period period = new Period(then, now);
        System.out.println(period.toString());
Примеров можно написать ещё много, но, я думаю, вы поняли идею. Вспомните, какие задачи по работе со временем приходилось решать вам в ваших приложений, и попробуйте решить их с помощью joda-time.

СОВМЕСТИМОСТЬ jdk И joda-time

Разработчики joda-time положили в основу библиотеки принцип, который можно назвать одним из важнейших - совместимость с jdk; в результате можно оставить уже имеющийся основанный на jdk код и написать/переделать наиболее трудоёмкие операции на joda-time. Это значительно облегчает переход на новую библиотеку.
Listing 8. joda and jdk interoperability
        //from java.util.Calendar
        Calendar calendar = Calendar.getInstance();
        calendar.set(2012, Calendar.AUGUST, 1, 0, 0, 0);

        //to org.joda.time.DateTime to make the most difficult calculations
        DateTime dateTime = new DateTime(calendar.getTimeInMillis());
        dateTime = dateTime.plusMonths(2).plusDays(5);
        System.out.println(
                dateTime.dayOfWeek().withMaximumValue().toString("E MM/dd/yyyy HH:mm:ss.SSS"));

        // back to calendar in this way
        calendar.setTime(dateTime.toDate());
        // or in this one
        calendar = dateTime.toCalendar(Locale.getDefault());

        // from DateMidnight
        DateMidnight dateMidnight = DateMidnight.now();
        Date dateFromDM = dateMidnight.toDate();

        // from LocalDate through one extra hoop
        LocalDate localDate = LocalDate.now();
        Date dateFromLD = localDate.toDateMidnight().toDate();
В приведённом примере мы фиксируем время в Calendar, далее переходим к DateTime для вычисления и форматирования, а затем снова возвращаемся к Calendar! При необходимости к стандартному Date можно привести и классы ReadablePartial (правда, через одно дополнительное преобразование). Действительно, очень удобная фича.

ФОРМАТИРОВАНИЕ

Форматирование времени в jdk вполне адекватное, но можно было бы и попроще - зачем создавать отдельный объект SimpleDateFormat?
В joda-time достаточно вызвать toString() на joda объекте, при необходимости передав ему стандартную ISO-8601 или jdk-совместимую строку с правилом форматирования - и получить желаемый результат (строго говоря, в библиотеке есть-таки класс DateTimeFormatter, если кто-то без него просто не может).
Listing 9. ISO-8601
        DateTime dateTime = DateTime.now();
        dateTime.toString(ISODateTimeFormat.basicDateTime());
        dateTime.toString(ISODateTimeFormat.basicDateTimeNoMillis());
        dateTime.toString(ISODateTimeFormat.basicOrdinalDateTime());
        dateTime.toString(ISODateTimeFormat.basicWeekDateTime());

        20120802T114141.219+0300
        20120802T114141+0300
        2012215T114141.219+0300
        2012W314T114141.219+0300
Listing 10. jdk-совместимый формат
        DateTime dateTime = DateTime.now();
        dateTime.toString("MM/dd/yyyy hh:mm:ss.SSSa");
        dateTime.toString("dd-MM-yyyy HH:mm:ss");
        dateTime.toString("EEEE dd MMMM, yyyy HH:mm:ssa");
        dateTime.toString("MM/dd/yyyy HH:mm ZZZZ");
        dateTime.toString("MM/dd/yyyy HH:mm Z");

        08/02/2012 11:41:41.219AM
        02-08-2012 11:41:41
        четверг 02 Август, 2012 11:41:41AM
        08/02/2012 11:41 Asia/Baghdad
        08/02/2012 11:41 +0300
НЕДОСТАТКИ ЕСТЬ?

Если подойти к анализу библиотеки совсем строго, то некоторые вопросы/недочёты можно-таки найти.
  • Ограничение точности до миллисекунд. С одной стороны, в некоторых приложениях высокая точность обработки времени может быть весьма важна, а joda-time по каким-то своим причинам ограничивает разработчика. С другой стороны, высокая точность приводит к большим числам, для хранения которых требуется уже не примитив long, а класс BigInteger, что приводит к серьёзному проигрышу в перформансе. А ограничение именно до миллисекунд обусловлено концепцией совместимости с jdk и той же производительностью; к тому же, для подавляющего большинства приложений этой точности вполне достаточно. В результате разработчики остановились на миллисекундах.
  • joda-time разрешает использовать изменяемые (mutable) версии классов. В общем, кто-то рад, что такая возможность есть, а кто-то недоволен, что она есть.
  • Достаточно большой размер библиотеки - в версии 2.1 более 150 классов, которые надо изучить для эффективного применения. Впрочем, начать работать просто - можно ограничиться тем же org.joda.time.DateTime, а затем по мере необходимости узнавать другие возможности. К тому же, эти классы предоставляют действительно богатый функционал, вовсе не сложный для понимания.
  • В случае изменения какие-то правил, связанных со временем (например, изменение часовых поясов, правил перехода на летнее/зимнее время и т. п.) joda-time необходимо обновлять для актуализации.
  • Если в joda-time к дате "31 мая" добавить 1 месяц (date.plusMonths(1)), то получим "30 июня", и никак иначе. А "30 июня" + 1 месяц = "30 июля"!
  • Если не знаешь с уверенностью, что делаешь, отлаживаться дебаггером не очень удобно, во время выполнения об объекте можно получить немного информации. Лучше работать с javadoc и user guide'ом.

РАЗНОЕ И ССЫЛКИ

Q: Как вообще произносить название библиотеки?
A: The 'J' in 'Joda' is pronounced the same as the 'J' in 'Java'.

Joda-Time v2.0, пост автора проекта Stephen Colebourne о выходе версии 2.0 (если быть точным, то на момент публикации данного поста номер последней версии - 2.1). Около десяти лет развития говорят о зрелости продукта.

Переводной пост "Способы сравнения объектов дат в Java" с примерами, можно перейти и к оригиналу статьи. Становится понятнее, почему joda-time проще в использовании.

Потокобезопасный DateFormat, сравнение производительности операций parse/format в различных вариантах реализации. Наглядные характеристики производительности; цифры, конечно, у всех разные, но общая тенденция понятна. Ссылка на исходный код тестов также есть в посте, т. ч. можно скачать и поэкспериментировать самостоятельно.

Работа с Date и TimeZone, простая, но крайне понятная статья про основные проблемы.

Допустимые шаблоны даты и времени (y, M, d, h и пр.) в java.text.SimpleDateFormat. А также стандарт ISO-8601 описания даты и времени в международном контексте.

date4j, маленький проект, реализующий донельзя упрощённую модель работы с датами/временем и предназначенную в основном для взаимодействия с БД.

Краткий обзор работы с Date/Calendar для новичков.

Joda-Time. You can't escape time. Why not make it easy? Отличная англоязычная статья про библиотеку, с поясняющими примерами, которая послужила основой для данного поста.

ЗАКЛЮЧЕНИЕ

Когда дело доходит до обработки дат и времени, joda-time проявляет себя как весьма удобный и эффективный инструмент. Требуется ли рассчитать время, напечатать его или распарсить - всё выполняется удобно и понятно, на уровне интуиции. Положенные в основу библиотеки концепции позволили разработчикам создать инструмент, которым стОит пользоваться!

1 комментарий: