Date and Time API contre le reste du monde

Java 8 apporte une nouvelle API pour manipuler les dates et heures en Java, la Date and Time API, aussi connue comme JSR 310. L’objectif de cet article n’est pas de présenter cette API, mais de montrer ce que l’on doit faire aujourd’hui, pour l’intégrer avec les librairies habituelles comme Spring ou Hibernate, et se débarrasser des java.util.Date et autres java.util.GregorianCalendar

Si vous utilisez encore Java 7, cet article est probablement transposable sur Joda Time (qui a inspiré la JSR 310) ou sur le « backport » ThreeTen.
Pour commencer, on va persister en base une entité Utilisateur avec un attribut dateNaissance de type java.time.LocalDate. Au niveau, JDBC, cela signifie qu’il faudra être capable de convertir ce type depuis et vers une java.sql.Date

Persistance avec Hibernate

Avec Hibernate, pour faire en sorte que le type java.time.LocalTime soit reconnu, il faut implémenter et utiliser un UserType. Par chance, une telle implémentation existe déjà dans une librairie nommée Jadira UserType Extended

<dependency>
        <groupId>org.jadira.usertype</groupId>
        <artifactId>usertype.extended</artifactId>
        <version>3.2.0.GA</version>
    </dependency>

 
Avec cette librairie, on annotera juste la date de naissance au niveau de l’entité pour indique les UserType:

@Entity
    public class Utilisateur implements Serializable {
        ...
        @Type(type="org.jadira.usertype.dateandtime.threeten.PersistentLocalDate")
        private LocalDate dateNaissance;

Persistance avec JDBC

Si l’on dispose d’un driver compatible JDBC 4.2 (la version de JDBC incluse à Java 8), on doit pouvoir en théorie écrire:

// Dans les ResultSet
    LocalDate localDate = resultSet.getObject("date_naissance", LocalDate.class);
    // Pour les PreparedStatement
    preparedStatement, setObject(3, localDate, Types.DATE);

En pratique peu de bases de données fournissent un driver compatible JDBC 4.2.

  • Oracle 10.1 (i.e. 12c): JDBC 4.1
  • PostgreSQL 9.3: JDBC 4.1
  • MySQL Connector/J 5.1: JDBC 4.0
  • H2 1.4: JDBC 4.0
  • HSQL 2.3: JDBC 4.0
  • Apache Derby 10.11: JDBC 4.2

Même la base JavaDB, incluse à Java 8 et issue de Apache Derby, qui implémente pourtant JDBC 4.2 ne semble pas s’intégrer avec la JSR 310. Au final, il faudra donc écrire quelques méthodes utilitaires pour convertir, extraire et injecter les dates en passant par le type java.sql.Date:

// Pour les ResultSet
    public static LocalDate toLocalDate(java.sql.Date sqlDate) {
        if (sqlDate == null) {
            return null;
        } else {
            return Instant.ofEpochMilli(utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate();
        }
    }
    public static LocalDate getLocalDate(ResultSet resultSet, String columnLabel) throws SQLException {
        return toLocalDate(resultSet.getDate(columnLabel));
    }
    // Pour les PreparedStatement
    public static java.sql.Date toSqlDate(LocalDate localDate) {
        return new java.sql.Date(localDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
    }
    public static void setLocalDate(PreparedStatement preparedStatement, int parameterIndex, LocalDate localDate) throws SQLException {
        if (localDate == null) {
            preparedStatement.setNull(parameterIndex, Types.DATE);
        } else {
            preparedStatement.setDate(parameterIndex, toSqlDate(localDate));
        }
    }

Persistance avec Spring JDBC

Grâce aux méthodes utilitaires précédentes, le code Spring JDBC coule de source. Les lambdas permettent même de raccourcir l’écriture des RowMappers.

utilisateurs = jdbcTemplate.query(
                // SQL
                "select * from utilisateur "
                        + "where date_naissance between ? and ?",
                // Paramètres
                new Object[]{toSqlDate(dateMin), toSqlDate(dateMax)},
                // RowMapper
                (resultSet, rowNum) -> {
                    Utilisateur utilisateur = new Utilisateur();
                    // ...
                    utilisateur.setDateNaissance(getLocalDate(resultSet, "date_naissance"));
                    return utilisateur;
                });

En utilisant le BeanPropertyRowMapper pour peupler automatiquement les attributs, on peut automatiser la conversion. On commence par enregistrer un Converter dans le ConversionService de Spring, on ne peut pas utiliser une lambda ici, sinon Spring n’arrive pas à déterminer les types sources/cibles par réflexion. On lie ensuite le ConversionService au BeanPropertyRowMapper via le BeanWrapper:

@Bean
    public ConversionService conversionService() {
        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new Converter<Date, LocalDate>() {
            public LocalDate convert(Date date) {
                return toLocalDate(date);
            }
        });
        return conversionService;
    }
        RowMapper<Utilisateur> rowMapper = new BeanPropertyRowMapper(Utilisateur.class){
            protected void initBeanWrapper(BeanWrapper bw) {
                bw.setConversionService(conversionService);
            }
        };

Nous savons à présent lire/écrire des LocalDate avec Hibernate et Spring JDBC. Voyons à présent comment les utiliser lors des échanges REST/JSON et SOAP/XML.

Sérialisation JSON

Pour traiter le cas du JSON, avec Jackson c’est immédiat, il y a un module dédié:

<dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <version>${jackson.version}</version>
        </dependency>

Il suffit de l’enregistrer dans l’ObjectMapper. On peut personnaliser le format de la sérialisation en activant/désactivant l’option WRITE_DATES_AS_TIMESTAMPS:

ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JSR310Module());
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

Pour peu qu’il détecte ce qu’il faut sur le classpath, Spring Boot s’occupe de tout (voir JacksonAutoConfiguration).
En ce qui concerne le passage de dates dans les URL HTTP, Spring MVC 4.1 supporte nativement les types JSR 310, il faut juste mettre une annotation @DateTimeFormat pour stipuler le format:

@RequestMapping(value="/utilisateur", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
    public List<Utilisateur> findAllWithDateNaissance(
            @RequestParam("dateMin") @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate dateMin,
            @RequestParam("dateMax") @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate dateMax) {

Sérialisation XML

Passons à présent aux échanges XML avec JAXB, on écrit un XmlAdapter capable de convertir la LocalDate en XMLGregorianCalendar:

public class LocalDateXmlAdapter extends XmlAdapter<XMLGregorianCalendar, LocalDate> {
    private final DatatypeFactory datatypeFactory;
    public LocalDateXmlAdapter() throws DatatypeConfigurationException{
        this.datatypeFactory = DatatypeFactory.newInstance();
    }
    public LocalDate unmarshal(XMLGregorianCalendar xmlDate) throws Exception {
        return LocalDate.of(xmlDate.getYear(), xmlDate.getMonth(), xmlDate.getDay());
    }
    public XMLGregorianCalendar marshal(LocalDate localDate) throws Exception {
        return datatypeFactory.newXMLGregorianCalendarDate(localDate.getYear(), localDate.getMonth().getValue(), localDate.getDayOfMonth(),  DatatypeConstants.FIELD_UNDEFINED);
    }
}

Puis on applique cet adaptateur sur les attributs de type LocalDate avec l’annotation @XmlJavaTypeAdapter:

@XmlJavaTypeAdapter(LocalDateXmlAdapter.class)
    @XmlSchemaType(name = "date")
    protected LocalDate dateNaissance;

Afin d’automatiser la génération des classes Java depuis les XSD avec XJC, on mettra dans le fichier de binding:

<jaxb:globalBindings>
          <xjc:javaType name="java.time.LocalDate" xmlType="xs:date"
            adapter="com.zenika.test.jsr310.xml.LocalDateXmlAdapter" />
      </jaxb:globalBindings>

Validation

On souhaite à présent valider le champ dateNaissance avec Hibernate Validator:

@Past
    private LocalDate dateNaissance;

Pour que celà fonctionne, il y a deux solutions. Soit on a le gout du risque et on prend la version 5.2.0.Alpha1 d’Hibernate Validator (voir HV-874 et le blog in.relation.to). Soit, on se retrouve contraint d’écrire un validateur personnalisé:

@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PastValidator.class)
@Documented
public @interface Past {
    String message() default "com.zenika.test.jsr310.entity.Past.message";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class PastValidator implements ConstraintValidator<Past, LocalDate> {
    public void initialize(Past past) {}
    public boolean isValid(LocalDate localDate, ConstraintValidatorContext context) {
        return localDate == null || localDate.isBefore(LocalDate.now());
    }
}

Conclusion

L’effort à fournir pour éradiquer les java.util.Date n’est pas négligeable, vivement que les librairies classiques intègrent ces types nativement. On soulignera toutefois l’effort fourni par les équipes Spring et Jackson.

3 réflexions sur “Date and Time API contre le reste du monde

  • 6 novembre 2014 à 17 h 27 min
    Permalien

    Pour JPA/Hibernate, il suffit d’ajouter la propriété suivante aux propriétés JPA pour éviter d’avoir à annoter chaque propriété avec @Type:

       jadira.usertype.autoRegisterUserTypes = true.

    Par exemple avec Spring Java Config:

       localContainerEntityManagerFactoryBean.getJpaPropertyMap().put("jadira.usertype.autoRegisterUserTypes", "true");
    Répondre
  • 9 novembre 2014 à 19 h 48 min
    Permalien

    Salut,

    Perso ca me semble très dangereux de vouloir a tout prix convertir une Java Date en LocalDate sans prendre en considérations ce que cela implique. De la manière dont c’est fait, cela couple beaucoup trop fortement l’appli à la timezone par defaut de la JVM…

    Répondre
  • 13 novembre 2014 à 16 h 18 min
    Permalien

    @JB: bien vu

    @Sébastien: oui je reconnais que l’appel à ZoneId.systemDefault() n’est pas terrible.

    Répondre

Répondre à Gérald Annuler la réponse.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :