Skip to content

Bien utiliser la Date et l’Heure en Java

(Cet article est la traduction en français de [ Date and time in Java ])

Bien souvent, les développeurs débutants (parfois même certains expérimentés) ne savent pas utiliser correctement la date et l’heure en java, si bien qu’on en arrive à des bidouilles du genre :

La date qu’on obtient est décalée d’une heure ? Ajoutons 1 au champ heure de l’objet Date.

Bien évidemment, lors du passage à l’heure d’été/hiver suivant, tout est à refaire. Et encore, nous ne faisons qu’effleurer le problème.

Voici un petit topo sur le sujet.

L’UTC

Pour pouvoir répondre à la question « Quand » autrement que par « dans 27 secondes », il faut disposer d’une référence dans le temps.

L’idée est de disposer d’un point 0 à partir duquel on puisse calculer chaque instant en donnant le nombre de secondes écoulées depuis cette référence.

C’est ce que permet le Temps Coordonné Universel (UTC), il donne à chaque seconde un nom :

2007-12-31 23:59:59.000 UTC

par exemple.

L’UTC présente quelques subtilités :

certaines minutes ne dureront pas exactement 60 secondes.

On trouve notamment des minutes de 59 secondes (jamais arrivé pour l’instant) ou de 61 secondes (par exemple le 31 décembre 2008 à 23:59:60).

Ces secondes sont nommées secondes intercalaires et sont utilisées pour aligner le temps universel (astronomique) et le temps universel coordonné.

L’heure locale

Quand vous consultez votre montre, vous n’obtenez pas l’UTC, mais l’heure locale.

Cette heure est la représentation de l’UTC dans un calendrier et un fuseau horaire donnés.

L’ambiguïté avec les jours

Ca peut sembler évident, mais en fait, pas tant que ça : Un jour est défini comme 86400 secondes (24 heures).

Il s’agit du temps que la terre met pour effectuer une rotation.

Cependant, dans de nombreux pays (dont la France), nous appliquons l’heure d’été/d’hiver (aussi appelée Daylight Saving Time – DST), si bien qu’une journée ne fait pas toujours 24 heures :

Il existe des journées de 23 et 25 heures !

Une application ne devrait donc jamais présupposer qu’une journée fait 24 heures : la notion de jour n’est correctement définie qu’avec un calendrier.

Cette ambiguïté apparaît par exemple dans les Timers Java. La méthode :

java.util.Timer.schedule(TimerTask task, Date firstTime, long period)

n’est pas utilisable pour exécuter des tâches quotidiennes. Si vous spécifiez une période de 86400000 millisecondes (24 heures), le timer sera déclenché toutes les 24 heures, mais suite à un changement d’heure d’été, l’événement sera déclenché avec une heure de décalage.

Représenter des instants avec le temps Unix

En informatique, il faut souvent référencer un point précis dans le temps :

  • Quand un fichier a-t-il été modifié ?
  • A quelle heure commence un rendez-vous donné ?
  • Quand ce mail a-t-il été envoyé ?

Ces instants ont les caractéristiques d’un événement : si 2 événements ont le même « timestamp », ils se produisent au même moment, le fait qu’un d’entre eux se passe à Shangai et l’autre à Londres n’y change rien : ils sont simultanés quelque soit le fuseau horaire. Dans ce cas, on utilise généralement le temps Unix.

Un timestamp Unix représente le nombre de millisecondes écoulées depuis le 1.1.1970 UTC à minuit.

La classe java.util.Date n’est qu’une encapsulation d’un timestamp Unix qui représente un point temporel à l’aide d’un compteur de millisecondes sur 64 bits.

Il est important de ne plus utiliser les méthodes et constructeurs dépréciés de Date. Ils datent d’une époque où même les gens de Sun n’étaient pas à l’aise avec le temps.

javax.management.timer.Timer fournit des constantes très utiles pour toutes les conversions en millisecondes.

// Sont équivalents
long now = System.currentTimeMillis();
Date now = new Date();

// long et Date sont interchangeables
// Date est juste une classe d'encapsulation.
long ts = (new Date()).getTime();
Date d = new Date(1095379201L);

// Arithmétique : Attention aux dépassements de capacité sur les int !
Date now = new Date();
Date in8Hours = new Date(now.getTime() + 8L * Timer.ONE_HOUR);

// A ne pas faire : un jour ne fait pas forcément 24h !
// Date in7Days = new Date(now.getTime() + 7L * Timer.ONE_DAY);

// A ne pas faire : allocation inutile
//Date now = new Date(System.currentTimeMillis());

Les Calendriers

Représenter un instant avec son timestamp Unix est assez précis, mais c’est loin d’être utilisable.

Si je vous dis :

Rencontrons nous à 1 095 379 201

ça vous demandera sûrement un peut de temps avant de réaliser que cet instant est passé de quelques années. C’est pourquoi nous utilisons des calendriers.

Dans le monde occidental, nous utilisons le calendrier grégorien. Celui-ci définit 12 mois de différentes durées et prend en compte une année bissextile tous les 4 ans (la règle exacte est un peu plus complexe).

Calendriers et fuseaux horaires

Un calendrier définit des dates.

Sans calendrier, le 31 décembre 2008 n’a aucune signification.

Un calendrier est toujours lié à une notion de « jour ». Puisque notre notion de jour est liée au levé et au couché de soleil, un calendrier n’est correctement défini qu’avec une position géographique. C’est particulièrement évident au jour de l’an où chaque pays lance des feux d’artifice : ces lancements s’étendent sur 24 heures.

On en arrive à la conclusion que le 31 décembre 2008 n’est pas défini clairement. Il y a en effet constamment 2 dates autour du monde à chaque instant de la journée et cette date peut donc dire « n’importe quoi » pendant 48h.  Même le « 31 décembre 2008 à 24:00:00 » ne veut rien dire sans fuseau horaire : il peut désigner n’importe quelle heure pendant une durée de 24h.

Toutes les expressions suivantes ne veulent rien dire sans fuseau horaire :

  • 1.1.2007
  • 14:30:00
  • 1.1.2007 14:30:00
  • 29.10.2006 02:30 horaire local suisse (cf. heure d’été)
  • lundi
  • 2 jours (cf . jours ambigus)

Les fuseaux horaires

Vous avez déjà entendu parler de fuseau horaire. Le fuseau principal permettant de définir tous les autres est nommé  Greenwich Mean Time (GMT) et survole l’Angleterre. C’est le fuseau utilisé pour l’UTC.

Tous les autres fuseaux sont définis par des « décalages » par rapport au GMT. Les fuseaux de l’est ont des décalages positifs, les zones de l’ouest des décalages négatifs.

Convertir une date entre fuseaux est simple :

18:00 GMT = 18:00+00:00 = 19:00+01:00 = 17:00-01:00

La classe java.util.TimeZone peut représenter 2 choses différentes suivant le cas d’utilisation (ce qui peut être déstabilisant):

  • Un fuseau horaire
  • Une base de données des fuseaux horaires d’un lieu

Les fuseaux horaires ne sont pas attribués par les astronomes mais par les hommes politiques. Ces attributions peuvent donc changer.

C’est pourquoi la classe TimeZone gère une base de données des attributions historiques de plusieurs villes.

De plus, de nombreux pays changent de fuseau 2 fois par an : l’heure d’été n’est rien d’autre qu’un fuseau horaire.

Important : Même l’Angleterre passe à l’heure d’été, ils ne sont donc pas tout le temps en GMT !

La majorité des programmes utilisent une entrée de la base de données de la manière suivante :

TimeZone.getTimeZone(« Europe/Paris »)

Cette méthode évite les problèmes de passage à l’heure d’été, mais une application peut aussi spécifier directement le fuseau à utiliser :

TimeZone.getTimeZone(« GMT+04:30 »)

Une bonne utilisation des timezones pour java.util.Calendar et java.text.DateFormat est cruciale pour le bon comportement de l’application.

Si vous ne spécifiez pas de TimeZone, Java prendra par défaut celle du système … qui est rarement la bonne sur un serveur …

« Localiser » une application ne signifie pas juste supporter différentes Locales, mais aussi différentes TimeZones. Les classes de vos applications dépendant d’une Locale dépendent donc aussi sûrement d’une TimeZone.

Représenter un instant donné à l’aide d’un calendrier

Comme nous l’avons vu, un point donné dans le temps n’a pas besoin d’être représenté par un calendrier. Les timestamps Unix suffisent.

Utiliser un calendrier et un fuseau n’a aucune incidence sur le point temporel représenté.

La seule occasion où l’utilisation d’un calendrier est souhaitable est pour l’affichage en IHM et pour les calculs. Quand une personne lit une date, elle s’attend à ce qu’elle soit lisible dans son fuseau local.

En Java, on utilise la classe java.text.DateFormat et son implémentation principale java.text.SimpleDateFormat pour convertir les instances de Date en information lisible ou pour lire des informations date utilisateur. L’important ici est de ne pas oublier de spécifier la TimeZone, sinon le fuseau par défaut du système sera utilisé, ce qui n’est valable que pour les applications de bureau.

Il est aussi important de savoir que les instances de  SimpleDateFormat ne sont pas thread-safe. Il faut donc éviter de les stocker dans des variables statiques.

// A ne pas faire : non thread-safe (supprimer le "static")
public static SimpleDateFormat dfm = new SimpleDateFormat("yyyy-MM-dd");

// Voici comment initialiser une date connue 
// (souvent utilisé dans les tests unitaires)
DateFormat dfm = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
dfm.setTimeZone(TimeZone.getTimeZone("Europe/Paris"));
Date a = dfm.parse("2007-02-26 20:15:00");
Date b = dfm.parse("2007-02-27 08:00:00");

// Seuls les idiots (!) font ça
// Date badhabit = new Date(106, 1, 26, 20, 15, 00);

// L'affichage est très simple
System.out.println("Result: "+ dfm.format(a));

// Si vous n'initialisez pas le DateFormat avec une TimeZone,
// précisez la dans le format !
DateFormat dfm = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");
Date a = dfm.parse("2007-02-26 20:15:00 +0200");

Attention aux symboles de SimpleDateFormat.

« hh » correspond à Calendar.HOUR qui représente l’heure au format 12 heures (type horloge analogique). L’heure retournée est donc ambiguë si vous ne précisez pas AM/PM.

D’autre part, la classe Calendar ne lève pas d’exception quand vous faites :

calendar.set(Calendar.HOUR, 20);

Utilisez « HH » et Calendar.HOUR_OF_DAY pour travailler sur 24h.

Java propose aussi java.util.Calendar et java.util.GregorianCalendar. Ces classes permettent de convertir vers et depuis les Date avec les méthodes setTime() et getTime().

Elles permettent aussi de modifier des champs individuellement. C’est GregorianCalendar que vous devez utiliser pour déterminer le début et la fin d’une journée.

Bien évidemment, précisez la bonne TimeZone ou vos actions seront indéfinies.

// Pour obtenir un point temporel précis
GregorianCalendar cal = new GregorianCalendar(tz);
cal.set(2009, Calendar.DECEMBER, 31, 20, 15, 00);
cal.set(Calendar.MILLISECONDS, 0);
Date d = cal.getTime();

/**
* Calcule minuit de la date en cours en respectant la TimeZone.
**/
public Date midnight(Date date, TimeZone tz) {
  Calendar cal = new GregorianCalendar(tz);
  cal.setTime(date);
  cal.set(Calendar.HOUR_OF_DAY, 0);
  cal.set(Calendar.MINUTE, 0);
  cal.set(Calendar.SECOND, 0);
  cal.set(Calendar.MILLISECOND, 0);
  return cal.getTime();
}

/**
* Ajoute un nombre de jour donné à une date.
* L'heure d'été est gérée correctement grâce à la TimeZone.
* Nécessaire car les jours en question ne font pas 24 h
*/
public Date addDays(Date date, int days, TimeZone tz) {
  Calendar cal = new GregorianCalendar(tz);
  cal.setTime(date);
  cal.add(Calendar.DATE, days);
  return cal.getTime();
}

AM/PM

Dans la majorité des pays, travailler autrement qu’en 24h est assez perturbant. Soyez donc très prudent si vous utilisez l’horloge sur 12h.

Imaginez que vous ayez des billets d’avion avec l’heure d’embarquement suivante :

« 10. January 12:30 AM »

Vous pouvez être sûrs que la moitié des passagers arriveront à minuit et demi (ce qui est correct) et que les autres arriveront à midi et demi (et auront manqué leur avion).

Il n’y a pas d’heure 0 en mode 12h et une journée commence en AM. Donc :

12:00 AM équivaut à 0:00 en mode 24h et 12:00 PM équivaut à 12:00

En conclusion : n’utilisez que le mode 24h si vous pouvez, ou alors laissez le choix à l’utilisateur.

En Java ça signifie : évitez Calendar.HOUR, Calendar.AM_PM et le symbole « hh » de SimpleDateFormat. Utilisez à la place Calendar.HOUR_OF_DAY et le symbole « HH ».

Conversions de TimeZones

Premier point :

Vous ne pouvez pas convertir une java.util.Date vers une TimeZone différente

C’est impossible car une instance de Date est toujours en UTC. Seul un Calendar ou un texte peut être converti dans une TimeZone différente.

En pratique, les changements de fuseau horaire ne sont nécessaires que quand les dates sont lues depuis un fichier texte ou une IHM.

Dans ces cas, une instance de java.text.DateFormat doit être utilisée avec la bonne TimeZone.

DateFormat indfm = new SimpleDateFormat("MM/dd/yyyy HH'h'mm");
indfm.setTimeZone(TimeZone.getTimeZone("Australia/Sydney"));
Date purchaseDate = dfm.parse("12/31/2007 20h15");

DateFormat outdfm = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
outdfm.setTimeZone(TimeZone.getTimeZone("GMT"));
csvfile.println(outdfm.format(purchaseDate) +" GMT");

Evitez un code comme le suivant : il n’a aucun effet et prouve que vous n’avez pas compris ce que vous faites :

// Code foireux : il ne fait rien.
public static Date convertTz(Date date, TimeZone tz) {
  Calendar cal = Calendar.getInstance();
  cal.setTimeZone(TimeZone.getTimeZone("UTC"));
  cal.setTime(date);
  cal.setTimeZone(tz);
  return cal.getTime();
}

Les jours de la semaine

Il est nécessaire de faire très attention avant de stocker un nom de jour dans la base de données.

Considérons le cas suivant :

Une application d’agenda stocke un événement récurrent dans la BDD. Par exemple « Monday March 12 2007 01h00 GMT+02:00 », se répétant tous les lundis. L’occurrence suivante est donc « Monday March 19 2007 01h00 GMT+02:00 ».

L’application choisit de stocker la date au format GMT sans préciser la TimeZone. L’utilisateur voyage autour du globe et veut voir les dates de son agenda dans le fuseau local. Le fait de stocker la date dans la TimeZone où il était quand il a créé l’événement n’a pas de sens.

La date de départ devient alors « 2007-03-11 23h00 GMT » qui tombe un dimanche en GMT. Si l’application stocke maintenant « se répétant tous les lundis », l’occurrence suivante sera calculée comme « 2007-03-19 23h00 » en GMT (le 19 est un lundi) qui dans le fuseau de l’utilisateur devient « Tuesday March 20 2007 01h00 GMT+02:00 » — un jour trop tard!

Ce problème survient car la notion de « lundi » est propre à chaque TimeZone.

Il apparaît que l’application doit aussi convertir le jour de la semaine en GMT, ce qui est fait en recherchant  minuit dans un lundi du fuseau de l’utilisateur puis en le convertissant en GMT et en évaluant le jour de la semaine à nouveau … ce qui donne dimanche (GMT) !

Transmettre des dates et heures

Il faut prendre de grandes précautions lors de transferts de dates et heures entre systèmes d’information. Comme vu précédemment, l’information de fuseau horaire est une partie importante de la date ne devant pas être modifiée ou perdue. Dans d’autres situations, cette information est sans importance.

Dans des formats textes tels que CSV , XML, ou les entêtes SMTP les dates sont généralement mises en forme pour être lues par des êtres humains. D’une manière générale, de telles informations doivent intégrer une TimeZone pour éviter toute ambiguïté. XML schema utilise par exemple la norme ISO 8601 pour laquelle l’information de zone est optionnelle alors que SMTP suit la RFC 2822 rendant la TimeZone obligatoire.

La TimeZone doit toujours être donnée à l’aide d’un décalage absolu (pas une position géographique), autrement, l’interprétation de la donnée peut être ambiguë (à cause de l’heure d’été).

ISO 8601 date and time: « 2007-02-26T21:23:14.250+01:00 »
RFC 2822 date and time: « Mon, 26 Feb 2007 21:23:14 +0100 »

En règle générale :

  • Les timestamps UNIX peuvent être traduits dans n’importe quelle TimeZone. Une valeur sûre est GMT.
  • Les informations de TimeZone ne doivent jamais être modifiées.

Pour la persistance, les mêmes règles s’appliquent.

Un cycle de stockage/chargement d’un timestamp unix ne doit pas modifier la donnée si un des événements suivant a lieu :

  • passage en heure d’été
  • changement de fuseau horaire client
  • changement de fuseau horaire de la BDD

Le passage à l’heure d’été / d’hiver

Lors du passage à l’heure d’été, une heure est ajoutée à l’heure actuelle, si bien qu’on passe directement de 2h du matin à 3h du matin. En automne, l’horloge passe directement de 3h à 2h, répétant ainsi une heure.

Comme vu précédemment, l’heure d’été n’est rien d’autre qu’une TimeZone.

Un pays appliquant le changement d’heure change donc de TimeZone 2 fois par an.

Par exemple, en France, l’heure « normale » est GMT+01:00 et l’heure d’été est GMT+02:00. Les début et fin d’heure d’été dépendent entièrement de décisions politiques (l’Australie a repoussé le début de l’heure d’été en 2006 à cause d’événements sportifs :-) )

D’autre part, certains pays comme l’Inde ont utilisé l’heure d’été à une époque mais ne l’utilisent plus maintenant. Tout cet historique est enregistré dans la base de données de TimeZones Java (ainsi que dans la bdd de TimeZones Unix). Il est donc important de mettre à jour cette base de données régulièrement.

Il est important de comprendre que bien que l’heure d’été crée un saut d’une heure, ce saut n’existe pas en GMT :

  • En été : 02:00+01:00 = 03:00+02:00 = 01:00 GMT
  • En automne : 03:00+02:00 = 02:00+01:00 = 01:00 GMT

Il ne s’agit que d’un changement de fuseau horaire. Notre horloge mesure le temps dans une nouvelle TimeZone, mais l’UTC continue sa vie.

Le changement horaire peut aussi poser problème à la saisie d’une date par l’utilisateur. Aucune IHM ne demande à l’utilisateur dans quelle TimeZone il se situe lorsqu’il rentre une date.

Considérons le cas où l’utilisateur saisit « 29.10.2006 02:30 » à Paris. C’est à cette Date que l’Europe sort de l’heure d’été pour revenir à l’heure « standard ».

L’heure entre 2 et 3 heures du matin apparaît donc 2 fois sur les montres. L’heure saisie sur l’IHM est donc ambiguë : on ne sait pas s’il s’agit de 02:30+02:00 ou 02:30+01:00 une heure plus tard.

Si votre application a besoin de cette distinction, il faut vérifier la saisie. Java ne fournit pas d’API pour gérer ce cas, mais vous pouvez faire le test suivant (est ce que le jour fait 23 heures ?) :

public boolean isDSTend(Calendar cal) {
  int year = cal.get(Calendar.YEAR);
  int month = cal.get(Calendar.MONTH);
  int date = cal.get(Calendar.DATE);
  TimeZone tz = cal.getTimeZone();
  return isDSTend(year, month, date, tz);
}

public boolean isDSTend(int year, int month, int date, TimeZone tz) {
  java.util.Calendar cal = new java.util.GregorianCalendar(tz);
  cal.set(java.util.Calendar.MILLISECOND, 0);
  cal.set(year, month, date, 00, 30, 00);
  cal.add(java.util.Calendar.HOUR_OF_DAY, 24);
  return (23 == cal.get(java.util.Calendar.HOUR_OF_DAY));
}

La numérotation des semaines

Les numéros de semaines sont populaires chez les managers : « Peut on organiser une réunion semaine 34 ? »

En général, ils parlent des numéros affichés dans MS Outlook. Cependant, ce qu’ils ne savent pas est que la numérotation des semaines suit une norme : l’ISO-8601, l’implémentation d’Outlook n’est pas forcément compatible avec cette norme. La classe Calendar de Java peut être rendue compatible ISO avec le réglage setMinimalDaysInFirstWeek(4).

Cependant, les numéros de semaines ont une petite ambiguïté.

Le problème est le suivant : le 30 décembre 2002 est dans la semaine 1 de 2003. Mais la classe Calendar de Java n’a qu’un champ YEAR. Ce champ a différentes significations en fonction du contexte :

  • En modifiant les champs WEEK_OF_YEAR et YEAR, YEAR est interprété comme l’année de la semaine
  • En modifiant les champs DATE et YEAR, YEAR est interprété comme l’année de la date
  • En lisant le champ YEAR, l’année de la date est systématiquement retourné
  • Il n’y a pas moyen de déterminer l’année de la semaine

Sun refuse de corriger le bug. Voici une méthode pour retrouver la bonne année de la semaine :

public int getYearForWeek(GregorianCalendar cal) {
   int year = cal.get(Calendar.YEAR); 
   int week = cal.get(Calendar.WEEK_OF_YEAR);
   int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);

   if (week == 1 && dayOfMonth > 20)
      return year + 1;

   if (week >= 52 && dayOfMonth < 10)
      return year - 1;

   return year;
}

2 Comments

  1. Retrouvez cet article sur Blogasty …

    Vous aimez cet article? Votez pour lui sur Blogasty …

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.