Как использовать java.time.ZonedDateTime/LocalDateTime в p: calendar

Я использовал Joda Time для обработки времени на дату в приложении Java EE, в котором строковое представление даты-времени, представленное связанным клиентом, было преобразовано с использованием следующей процедуры преобразования перед отправкой ее в базу данных, то есть в getAsObject() в конвертере JSF.

org.joda.time.format.DateTimeFormatter formatter = org.joda.time.format.DateTimeFormat.forPattern("dd-MMM-yyyy hh:mm:ss a Z").withZone(DateTimeZone.UTC);
DateTime dateTime = formatter.parseDateTime("05-Jan-2016 03:04:44 PM +0530");

System.out.println(formatter.print(dateTime));

Указанный локальный часовой пояс составляет 5 часов и 30 минут раньше UTC/GMT. Следовательно, преобразование в UTC должно вычитать 5 часов и 30 минут с даты, указанной правильно, что происходит правильно, используя Joda Time. Он отображает следующий результат, как ожидалось.

05-Jan-2016 09:34:44 AM +0000

► Отменено временное изменение +0530 вместо +05:30, потому что оно зависит от <p:calendar>, который отправляет смещение зоны в этом формате. Невозможно изменить это поведение <p:calendar> (этот вопрос не понадобился бы в противном случае).


То же самое, однако, сломано, если попытаться использовать Java Time API в Java 8.

java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a Z").withZone(ZoneOffset.UTC);
ZonedDateTime dateTime = ZonedDateTime.parse("05-Jan-2016 03:04:44 PM +0530", formatter);

System.out.println(formatter.format(dateTime));

Он неожиданно отображает следующий неверный вывод.

05-Jan-2016 03:04:44 PM +0000

Очевидно, что преобразование даты и времени не соответствует UTC, в котором предполагается преобразовать.

Для его правильной работы требуются следующие изменения.

java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a z").withZone(ZoneOffset.UTC);
ZonedDateTime dateTime = ZonedDateTime.parse("05-Jan-2016 03:04:44 PM +05:30", formatter);

System.out.println(formatter.format(dateTime));

Что, в свою очередь, отображает следующее.

05-Jan-2016 09:34:44 AM Z

Z был заменен на Z, а +0530 был заменен на +05:30.

Почему эти два API имеют другое поведение в этом отношении, полностью игнорировались в этом вопросе.

Какой средний подход может быть рассмотрен для <p:calendar> и Java Time в Java 8, чтобы работать последовательно и согласованно, хотя <p:calendar> внутренне использует SimpleDateFormat вместе с java.util.Date?


Неудачный тестовый сценарий в JSF.

Конвертер:

@FacesConverter("dateTimeConverter")
public class DateTimeConverter implements Converter {

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(value, DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a Z").withZone(ZoneOffset.UTC));
        } catch (IllegalArgumentException | DateTimeException e) {
            throw new ConverterException(new FacesMessage(FacesMessage.SEVERITY_ERROR, null, "Message"), e);
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object value) {
        if (value == null) {
            return "";
        }

        if (!(value instanceof ZonedDateTime)) {
            throw new ConverterException("Message");
        }

        return DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a z").withZone(ZoneId.of("Asia/Kolkata")).format(((ZonedDateTime) value));
        // According to a time zone of a specific user.
    }
}

XHTML с <p:calendar>.

<p:calendar  id="dateTime"
             timeZone="Asia/Kolkata"
             pattern="dd-MMM-yyyy hh:mm:ss a Z"
             value="#{bean.dateTime}"
             showOn="button"
             required="true"
             showButtonPanel="true"
             navigator="true">
    <f:converter converterId="dateTimeConverter"/>
</p:calendar>

<p:message for="dateTime"/>

<p:commandButton value="Submit" update="display" actionListener="#{bean.action}"/><br/><br/>

<h:outputText id="display" value="#{bean.dateTime}">
    <f:converter converterId="dateTimeConverter"/>
</h:outputText>

Часовой пояс полностью прозрачно зависит от текущего часового пояса пользователя.

bean, имеющий только одно свойство.

@ManagedBean
@ViewScoped
public class Bean implements Serializable {

    private ZonedDateTime dateTime; // Getter and setter.
    private static final long serialVersionUID = 1L;

    public Bean() {}

    public void action() {
        // Do something.
    }
}

Это будет работать неожиданным образом, как показано во втором последнем примере/середине в первых трех фрагментах кода.

В частности, если вы введете 05-Jan-2016 12:00:00 AM +0530, он будет повторно отображаться 05-Jan-2016 05:30:00 AM IST, потому что исходное преобразование 05-Jan-2016 12:00:00 AM +0530 в UTC в преобразователе не работает.

Преобразование из локального часового пояса, смещение которого составляет от +05:30 до UTC, а затем преобразование из UTC обратно в эту часовую зону должно, очевидно, повторно отображать ту же дату-дату, которая вводится через компонент календаря, который является рудиментарной функциональностью указанного преобразователя.


Update:

Конвертер JPA преобразуется в и из java.sql.Timestamp и java.time.ZonedDateTime.

import java.sql.Timestamp;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

@Converter(autoApply = true)
public final class JodaDateTimeConverter implements AttributeConverter<ZonedDateTime, Timestamp> {

    @Override
    public Timestamp convertToDatabaseColumn(ZonedDateTime dateTime) {
        return dateTime == null ? null : Timestamp.from(dateTime.toInstant());
    }

    @Override
    public ZonedDateTime convertToEntityAttribute(Timestamp timestamp) {
        return timestamp == null ? null : ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneOffset.UTC);
    }
}

Ответы

Ответ 1

Ваша конкретная проблема заключается в том, что вы перенесли из экземпляра даты без даты года Joda DateTime в экземпляр времени с датой времени Java8 ZonedDateTime вместо Java8 без знака даты времени LocalDateTime.

Использование ZonedDateTime (или OffsetDateTime) вместо LocalDateTime требует как минимум 2 дополнительных изменений:

  • Не форсируйте часовой пояс (смещение) во время преобразования времени. Вместо этого во время разбора будет использоваться часовой пояс входной строки, если таковой имеется, и во время форматирования должен использоваться часовой пояс, хранящийся в экземпляре ZonedDateTime.

    DateTimeFormatter#withZone() приведет к запутывающим результатам с помощью ZonedDateTime, поскольку он будет действовать как откат во время разбора (он используется только тогда, когда время зона отсутствует в исходной строке или шаблоне формата), и она будет действовать как переопределение во время форматирования (часовой пояс, сохраненный в ZonedDateTime, полностью игнорируется). Это основная причина вашей наблюдаемой проблемы. Просто опустив withZone(), в то время как создание форматирования должно исправить его.

    Обратите внимание, что когда вы указали конвертер и не имеете timeOnly="true", вам не нужно указывать <p:calendar timeZone>. Даже если вы это сделаете, вы скорее захотите использовать TimeZone.getTimeZone(zonedDateTime.getZone()) вместо hardcoding.

  • Вам необходимо переносить часовой пояс (смещение) по всем слоям, включая базу данных. Однако, если ваша база данных имеет тип столбца "дата времени без часового пояса", информация о часовом поясе теряется во время сохранения, и при работе с базой данных возникают проблемы.

    Неясно, какая БД вы используете, но имейте в виду, что некоторые DB не поддерживают тип столбца TIMESTAMP WITH TIME ZONE, как известно из Oracle и PostgreSQL DB. Например, MySQL не поддерживает его. Вам понадобится второй столбец.

Если эти изменения неприемлемы, вам необходимо вернуться к LocalDateTime и полагаться на фиксированный/предопределенный часовой пояс на всех уровнях, включая базу данных. Обычно для этого используется UTC.


Работа с ZonedDateTime в JSF и JPA

При использовании ZonedDateTime с соответствующим типом столбца столбца TIMESTAMP WITH TIME ZONE используйте приведенный ниже JSF-конвертер для преобразования между String в пользовательском интерфейсе и ZonedDateTime в модели. Этот конвертер будет искать атрибуты pattern и locale из родительского компонента. Если родительский компонент не поддерживает атрибут pattern или locale, просто добавьте их как <f:attribute name="..." value="...">. Если атрибут locale отсутствует, вместо него будет использоваться (по умолчанию) <f:view locale>. Существует атрибут нет timeZone по причине, описанной выше в # 1.

@FacesConverter(forClass=ZonedDateTime.class)
public class ZonedDateTimeConverter implements Converter {

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object modelValue) {
        if (modelValue == null) {
            return "";
        }

        if (modelValue instanceof ZonedDateTime) {
            return getFormatter(context, component).format((ZonedDateTime) modelValue);
        } else {
            throw new ConverterException(new FacesMessage(modelValue + " is not a valid ZonedDateTime"));
        }
    }

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null || submittedValue.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(submittedValue, getFormatter(context, component));
        } catch (DateTimeParseException e) {
            throw new ConverterException(new FacesMessage(submittedValue + " is not a valid zoned date time"), e);
        }
    }

    private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) {
        return DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component));
    }

    private String getPattern(UIComponent component) {
        String pattern = (String) component.getAttributes().get("pattern");

        if (pattern == null) {
            throw new IllegalArgumentException("pattern attribute is required");
        }

        return pattern;
    }

    private Locale getLocale(FacesContext context, UIComponent component) {
        Object locale = component.getAttributes().get("locale");
        return (locale instanceof Locale) ? (Locale) locale
            : (locale instanceof String) ? new Locale((String) locale)
            : context.getViewRoot().getLocale();
    }

}

И используйте приведенный ниже JPA-конвертер для преобразования между ZonedDateTime в модели и java.util.Calendar в JDBC (для достойного JDBC-драйвера потребуется/использовать его для столбца TIMESTAMP WITH TIME ZONE):

@Converter(autoApply=true)
public class ZonedDateTimeAttributeConverter implements AttributeConverter<ZonedDateTime, Calendar> {

    @Override
    public Calendar convertToDatabaseColumn(ZonedDateTime entityAttribute) {
        if (entityAttribute == null) {
            return null;
        }

        Calendar calendar = Calendar.getInstance();
        calendar.setTimeInMillis(entityAttribute.toInstant().toEpochMilli());
        calendar.setTimeZone(TimeZone.getTimeZone(entityAttribute.getZone()));
        return calendar;
    }

    @Override
    public ZonedDateTime convertToEntityAttribute(Calendar databaseColumn) {
        if (databaseColumn == null) {
            return null;
        }

        return ZonedDateTime.ofInstant(databaseColumn.toInstant(), databaseColumn.getTimeZone().toZoneId());
    }

}

Работа с LocalDateTime в JSF и JPA

При использовании UTC на основе LocalDateTime с соответствующим типом столбца базы данных TIMESTAMP (без часового пояса!) DBTC с использованием UTC используйте приведенный ниже JSF-конвертер для преобразования между String в пользовательском интерфейсе и LocalDateTime в модели. Этот конвертер будет искать атрибуты pattern, timeZone и locale из родительского компонента. Если родительский компонент не поддерживает атрибут pattern, timeZone и/или locale, просто добавьте их как <f:attribute name="..." value="...">. Атрибут timeZone должен представлять резервный часовой пояс входной строки (когда pattern не содержит часовой пояс) и часовой пояс выходной строки.

@FacesConverter(forClass=LocalDateTime.class)
public class LocalDateTimeConverter implements Converter {

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object modelValue) {
        if (modelValue == null) {
            return "";
        }

        if (modelValue instanceof LocalDateTime) {
            return getFormatter(context, component).format(ZonedDateTime.of((LocalDateTime) modelValue, ZoneOffset.UTC));
        } else {
            throw new ConverterException(new FacesMessage(modelValue + " is not a valid LocalDateTime"));
        }
    }

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null || submittedValue.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(submittedValue, getFormatter(context, component)).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
        } catch (DateTimeParseException e) {
            throw new ConverterException(new FacesMessage(submittedValue + " is not a valid local date time"), e);
        }
    }

    private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component));
        ZoneId zone = getZoneId(component);
        return (zone != null) ? formatter.withZone(zone) : formatter;
    }

    private String getPattern(UIComponent component) {
        String pattern = (String) component.getAttributes().get("pattern");

        if (pattern == null) {
            throw new IllegalArgumentException("pattern attribute is required");
        }

        return pattern;
    }

    private Locale getLocale(FacesContext context, UIComponent component) {
        Object locale = component.getAttributes().get("locale");
        return (locale instanceof Locale) ? (Locale) locale
            : (locale instanceof String) ? new Locale((String) locale)
            : context.getViewRoot().getLocale();
    }

    private ZoneId getZoneId(UIComponent component) {
        Object timeZone = component.getAttributes().get("timeZone");
        return (timeZone instanceof TimeZone) ? ((TimeZone) timeZone).toZoneId()
            : (timeZone instanceof String) ? ZoneId.of((String) timeZone)
            : null;
    }

}

И используйте нижний конвертер JPA для преобразования между LocalDateTime в модели и java.sql.Timestamp в JDBC (для достойного JDBC-драйвера потребуется/использовать его для столбца TIMESTAMP):

@Converter(autoApply=true)
public class LocalDateTimeAttributeConverter implements AttributeConverter<LocalDateTime, Timestamp> {

    @Override
    public Timestamp convertToDatabaseColumn(LocalDateTime entityAttribute) {
        if (entityAttribute == null) {
            return null;
        }

        return Timestamp.valueOf(entityAttribute);
    }

    @Override
    public LocalDateTime convertToEntityAttribute(Timestamp databaseColumn) {
        if (databaseColumn == null) {
            return null;
        }

        return databaseColumn.toLocalDateTime();
    }

}

Применяя LocalDateTimeConverter к вашему конкретному случаю с помощью <p:calendar>

Вам нужно изменить ниже:

  • Поскольку <p:calendar> не ищет конвертеры по forClass, вам нужно либо перерегистрировать его с помощью <converter><converter-id>localDateTimeConverter в faces-config.xml, либо изменить аннотацию ниже

    @FacesConverter("localDateTimeConverter")
    
  • Поскольку <p:calendar> без timeOnly="true" игнорирует timeZone и предлагает во всплывающем окне возможность редактировать его, вам нужно удалить атрибут timeZone, чтобы избежать путаницы конвертера (это атрибут требуется только тогда, когда часовой пояс отсутствует в pattern).

  • Вам нужно указать желаемый атрибут display timeZone во время вывода (этот атрибут не требуется при использовании ZonedDateTimeConverter, поскольку он уже сохранен в ZonedDateTime).

Здесь полный рабочий фрагмент:

<p:calendar id="dateTime"
            pattern="dd-MMM-yyyy hh:mm:ss a Z"
            value="#{bean.dateTime}"
            showOn="button"
            required="true"
            showButtonPanel="true"
            navigator="true">
    <f:converter converterId="localDateTimeConverter" />
</p:calendar>

<p:message for="dateTime" autoUpdate="true" />

<p:commandButton value="Submit" update="display" action="#{bean.action}" /><br/><br/>

<h:outputText id="display" value="#{bean.dateTime}">
    <f:converter converterId="localDateTimeConverter" />
    <f:attribute name="pattern" value="dd-MMM-yyyy hh:mm:ss a Z" />
    <f:attribute name="timeZone" value="Asia/Kolkata" />
</h:outputText>

Если вы намереваетесь создать свой собственный <my:convertLocalDateTime> с атрибутами, вам нужно добавить их как bean -подобные свойства с геттерами/сеттерами в класс преобразователя и зарегистрировать его в *.taglib.xml, как показано в этом answer: Создание настраиваемого тега для конвертера с атрибутами

<h:outputText id="display" value="#{bean.dateTime}">
    <my:convertLocalDateTime pattern="dd-MMM-yyyy hh:mm:ss a Z" 
                             timeZone="Asia/Kolkata" />
</h:outputText>