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 RowMapper
s.
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.
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:
Par exemple avec Spring Java Config:
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…
@JB: bien vu
@Sébastien: oui je reconnais que l’appel à ZoneId.systemDefault() n’est pas terrible.