Published by

Il y a 4 mois -

Temps de lecture 13 minutes

Bien représenter le temps en Java

Représenter la mesure physique du temps dans du logiciel, quel que soit le langage de programmation, est souvent mal vécu par les développeurs. Les conversions entre UTC et l’heure locale sont sources de douleur, d’incompréhension et de problèmes plus obscurs les uns que les autres. Il en va de même pour les tests unitaires faisant intervenir le temps, c’est une vraie galère.

Prenons le temps de nous poser et remettre un peu d’ordre dans tout ça. Commençons par la base de la mesure du temps : la définition de la seconde. À partir de là, découvrons ensemble quels concepts de java.time utiliser pour nos besoins plus ou moins courants. Abordons enfin l’épineux problème des tests, pour nous apercevoir qu’il n’est pas si terrible que ça.

Comment est définie la seconde

Définition basée sur le jour solaire

En tant qu’humains, nous sommes habitués à la définition qualitative de la seconde. Elle est basée sur la journée et donc le temps que met la Terre à tourner sur elle-même. Avec 60 secondes dans une minute, 60 minutes dans une heure et 24 heures dans une journée, la définition historique de la seconde dans le Système International d’unités (SI) est qu’elle dure 1/86 400 d’une journée. L’échelle de temps associée à cette définition est celle du Temps Universel (UT).

Cette définition est tout à fait adaptée à notre vécu quotidien mais elle pose des soucis de précision de mesure car la durée de la rotation terrestre n’est pas stable, notamment à cause des forces de marées exercées par la Lune et des mouvements de la croûte terrestre.

Définition basée sur l’atome de césium 133

Depuis 1967, la définition admise par le SI pour la seconde est quantitative. Elle est basée sur la durée de transition entre deux états énergétiques très précis du seul électron de la couche externe de l’atome de césium 133. Par définition, cette transition particulière a une fréquence de 9 192 631 770 Hz, donc une seconde dure 9 192 631 770 fois le temps de cette transition. L’échelle de temps associée à cette définition est celle du Temps Atomique International (TAI), créée en 1972.

UTC, les secondes intercalaires et UTC-SLS

L’échelle de temps UT, celle basée sur le jour solaire, est pratique au quotidien car elle est directement liée à un phénomène évident à notre échelle : la durée d’une journée.

L’échelle de temps TAI, celle basée sur l’atome de césium, est pratique pour des applications nécessitant de la précision mais se trouve de plus en plus décalée par rapport à UT pour les raisons évoquées plus haut.

Combiner les avantages de ces deux échelles pour obtenir une échelle à la fois parlante pour les humains et applicable dans des contextes sensibles est le rôle d’une troisième échelle : celle du Temps Universel Coordonné (UTC).

L’heure UTC a été définie comme égale au TAI plus un certain nombre n de secondes, qualifiées d’intercalaires. Leur nombre est ajusté de temps en temps pour conserver une valeur de l’heure UTC très proche de celle d’UT (moins de 0,9 s atomique de décalage).

L’ajustement du nombre de secondes intercalaires est anticipé par le Service international de la rotation terrestre et des systèmes de référence et appliqué à minuit le dernier jour de juin ou de décembre. En cas d’ajustement, la conséquence est que, la seconde avant minuit, il peut avoir été 23:59:58 ou 23:59:60.

Pour lisser cette « anomalie » et retrouver systématiquement des journées d’exactement 86 400 secondes, la JVM exploite une variante d’UTC nommée UTC-SLS qui va ralentir ou accélérer l’horloge sur les 1 000 dernières secondes d’une journée. De cette façon, l’ajout ou la suppression d’une seconde intercalaire se trouve presque imperceptiblement étalée et nous, développeurs, avons un problème de moins à régler.

Les points de la ligne du temps en Java : Instant

Représentons l’écoulement du temps par une droite. Sur cette droite, ordonnons les évènements qui nous intéressent en plaçant les plus anciens à gauche des plus récents.

Chacun des points de cette ligne du temps est modélisé en Java avec un Instant. Pour des raisons pratiques (il faut donner une valeur numérique à chacun des Instants pour pouvoir les comparer), un point zéro est défini arbitrairement sur la ligne de temps. Ce point est nommé Epoch et il est placé, en Java, au 1er Janvier 1970 à minuit. Les Instants antérieurs à Epoch ont une valeur numérique négative ; ceux postérieurs à Epoch ont une valeur numérique positive.

assertThat(Instant.EPOCH).hasToString("1970-01-01T00:00:00Z");
assertThat(Instant.EPOCH.getEpochSecond()).isEqualTo(0);

final var oneSecondBeforeEpoch = Instant.parse("1969-12-31T23:59:59Z");
assertThat(oneSecondBeforeEpoch).isBefore(Instant.EPOCH);
assertThat(oneSecondBeforeEpoch.getEpochSecond()).isEqualTo(-1);

final var oneSecondAfterEpoch = Instant.parse("1970-01-01T00:00:01Z");
assertThat(oneSecondAfterEpoch).isAfter(Instant.EPOCH);
assertThat(oneSecondAfterEpoch.getEpochSecond()).isEqualTo(1);

Aucune notion de fuseau horaire n’entre en jeu dans la définition de la ligne du temps pour assurer qu’elle reste numériquement ordonnée. Dans le cas contraire, nous pourrions nous retrouver avec les mêmes problèmes qu’occasionnent, par exemple, le passage à l’heure d’hiver en France : le dernier dimanche du mois d’Octobre, il est possible que 2 h 30 soit avant 2 h 15.

Des dates et heures parlantes pour nos utilisateurs en Java : ZonedDateTime

Pouvoir placer sans ambiguïté des évènements sur la ligne de temps est fiable mais ne permet pas, seul, de fournir à nos utilisateurs une représentation du temps qui leur soit parlante.

Nos utilisateurs ont l’habitude qu’on leur affiche des dates et heures dans un fuseau horaire qui correspond à leur emplacement géographique. Pour l’affichage et la saisie, convertissons les Instants qui sont utilisés dans notre code en ZonedDateTime en les combinant avec le ZoneId (fuseau horaire, voir plus bas) qui leur convient le mieux.

  assertThat(Instant.parse("2020-07-14T11:23:45Z").atZone(ZoneId.of("Europe/Paris")))
        .isInstanceOf(ZonedDateTime.class)
        .hasToString("2020-07-14T13:23:45+02:00[Europe/Paris]");

Parfois, nos utilisateurs auront l’utilité de disposer de la représentation d’un même Instant sous la forme de deux ZonedDateTime différents car créés grâce à deux ZoneId différents. Par exemple, les billets d’avion portent souvent l’heure d’atterrissage dans le fuseau horaire du lieu de départ et dans celui du lieu d’arrivée.

Ambiguïtés de conversion ZonedDateTimeInstant

La conversion d’Instant vers ZonedDateTime se fait sans ambiguïté. Malheureusement, ça n’est pas toujours le cas dans l’autre sens. Par exemple, le dernier dimanche du mois d’Octobre, en France métropolitaine, il y a une période d’une heure pendant laquelle l’heure légale est ambiguë : sans information supplémentaire, il n’est pas possible de savoir si, à 2 h 30 Europe/Paris, il est 0 h 30 UTC ou 1 h 30 UTC.

Les méthodes de ZonedDateTime withEarlierOffsetAtOverlap et withLaterOffsetAtOverlap nous permettent d’orienter la conversion qui se fait par défaut en utilisant le décalage en vigueur avant le changement.

final var ambiguous = ZonedDateTime.of(LocalDateTime.parse("2020-10-25T02:30:00"), ZoneId.of("Europe/Paris"));

final var withEarlierOffsetAtOverlap = ambiguous.withEarlierOffsetAtOverlap();
assertThat(withEarlierOffsetAtOverlap)
        .hasToString("2020-10-25T02:30+02:00[Europe/Paris]");
assertThat(withEarlierOffsetAtOverlap.toInstant())
        .isEqualTo(ambiguous.toInstant())
        .hasToString("2020-10-25T00:30:00Z");

final var withLaterOffsetAtOverlap = ambiguous.withLaterOffsetAtOverlap();
assertThat(withLaterOffsetAtOverlap)
        .hasToString("2020-10-25T02:30+01:00[Europe/Paris]");
assertThat(withLaterOffsetAtOverlap.toInstant())
        .hasToString("2020-10-25T01:30:00Z");

Les fuseaux horaires en Java : ZoneId

Nous l’avons vu juste avant, ZoneId modélise la notion de fuseau horaire. Il y a deux façons usuelles de faire référence à un fuseau horaire :

  1. avec un décalage positif ou négatif en heures et minutes relatif à UTC, comme dans ZoneId.of("+03:45") ;
  2. avec un nom issu du registre IANA Time Zone Database (TZDB), sous la forme zone/ville, comme dans ZoneId.of("Europe/Paris").

D’une façon générale, il est préférable d’utiliser la forme zone/ville car elle permet à la JVM d’identifier quel décalage horaire appliquer pour représenter un Instant dans un fuseau donné. Au delà de la gestion des passages aux heures d’été et d’hiver, l’heure légale a probablement changé au moins une fois pour chacun des pays au cours de leur histoire. Par exemple, entre 1911 et 1940 ainsi qu’entre 1944 et 1945, l’heure légale française était dans le fuseau +00:00.

Enfin, prendre en compte de nouvelles modifications de fuseaux horaires avec la forme zone/ville se résume à mettre à jour la JVM sur laquelle tourne le code : c’est elle qui embarque la TZDB.

Le cas particulier des horaires en Java : LocalTime et ZoneId

Aussi universel qu’il puisse paraître, Instant n’est pas pour autant la solution à tous les besoins de modélisation des données en lien avec l’heure. Dès qu’il est question de représenter une heure générale, décorrélée d’une date précise, cette classe ne convient plus. Par exemple, les horaires d’ouverture d’un magasin ne peuvent pas être modélisés avec Instant : un magasin qui ouvre ses portes à 9:00 dans une ville de France métropolitaine ouvre à 7:00 UTC ou 8:00 UTC selon que l’heure légale considérée est celle d’été ou celle d’hiver.

Une solution consiste à associer le ZoneId concerné à une heure locale à ce fuseau horaire : LocalTime. Il n’existe pas de classe qui implémente cette association dans java.time, mais rien n’empêche d’en créer une :

package fr.esiha.what.the.utc;

import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Objects;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

public final class ZonedTime {
    private final ZoneId zoneId;
    private final LocalTime localTime;

    public ZonedTime(final ZoneId zoneId, final LocalTime localTime) {
        this.zoneId = requireNonNull(zoneId, "zoneId");
        this.localTime = requireNonNull(localTime, "localTime");
    }

    public ZonedDateTime at(final LocalDate localDate) {
        return ZonedDateTime.of(localDate, localTime, zoneId);
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        final ZonedTime zonedTime = (ZonedTime) o;
        return Objects.equals(zoneId, zonedTime.zoneId) &&
                Objects.equals(localTime, zonedTime.localTime);
    }

    @Override
    public int hashCode() {
        return Objects.hash(zoneId, localTime);
    }

    @Override
    public String toString() {
        return format("%s[%s]", localTime, zoneId);
    }
}

Tester efficacement : l’horloge est une dépendance comme une autre

Écrire des tests unitaires fiables concernant le temps relève souvent de la gageüre : il semble impossible de créer des conditions répétables à l’identique pour permettre de vérifier le comportement attendu de notre code de production. Nous usons alors d’artifices pour tenter de contrer l’implacable avancée de l’horloge, qui entrent généralement dans l’une de ces deux catégories :

  1. vérifier que l’heure produite par notre code est bien comprise entre l’heure avant son exécution et l’heure après son exécution ;
  2. vérifier que l’heure produite par notre code est bien antérieure à l’heure après son exécution, dans une marge de X millisecondes.

Dans les deux cas, la mise en place et la vérification du résultat est rapidement jugée trop contraignante et abandonnée. À bien y réfléchir, ces symptômes de tests trop contraignants voire impossibles à réaliser sont les mêmes que ceux observables en l’absence d’injection de dépendances.

Ici, la dépendance qui n’est pas injectée est celle de l’horloge fournie par la JVM, à laquelle il est accédé par Instant.now() :

package fr.esiha.what.the.utc;

import org.joda.money.Money;

import java.time.Instant;

import static java.util.Objects.requireNonNull;

public final class AccountDepositService {
    private final AccountRepository accountRepository;

    public AccountDepositService(final AccountRepository accountRepository) {
        this.accountRepository = requireNonNull(accountRepository, "accountRepository");
    }

    public void deposit(final Account.Id accountId, final Money depositAmount) {
        getAccount(accountId).deposit(depositAmount, Instant.now());
    }

    private Account getAccount(final Account.Id accountId) {
        return accountRepository.get(accountId)
                .orElseThrow(() -> new UnknownAccountException(accountId));
    }
}

La notion d’horloge est représentée par Clock. Elle est conçue pour permettre de fournir l’heure courante, soit directement avec Clock.instant() soit indirectement avec Instant.now(Clock) :

package fr.esiha.what.the.utc;

import org.joda.money.Money;

import java.time.Clock;

import static java.util.Objects.requireNonNull;

public final class AccountDepositService {
    private final AccountRepository accountRepository;
    private final Clock clock;

    public AccountDepositService(final AccountRepository accountRepository, final Clock clock) {
        this.accountRepository = requireNonNull(accountRepository, "accountRepository");
        this.clock = requireNonNull(clock, "clock");
    }

    public void deposit(final Account.Id accountId, final Money depositAmount) {
        getAccount(accountId).deposit(depositAmount, clock.instant());
    }

    private Account getAccount(final Account.Id accountId) {
        return accountRepository.get(accountId)
                .orElseThrow(() -> new UnknownAccountException(accountId));
    }
}

Dans les tests unitaires, il devient alors possible de simuler une horloge pour vérifier facilement que le comportement attendu est bien celui observé. Il n’est même pas besoin d’utiliser un outil comme Mockito pour ce faire car Clock propose la méthode statique fixed qui permet de créer une horloge qui renvoie toujours un même Instant :

final var now = Instant.now();
assertThat(Clock.fixed(now, ZoneId.of("Europe/Paris")).instant())
        .isEqualTo(now);

Il reste une question à régler : quelle horloge utiliser pour le fonctionnement nominal de notre code ? La réponse est simple : Clock.systemUTC(). Cette horloge ne dépend d’aucun réglage de localisation de la machine sur laquelle tourne la JVM, produisant alors un comportement homogène quelque soit l’environnement d’exécution.

Pour conclure

Nous avons pris le temps de comprendre la définition de la seconde, des échelles de temps UT, TAI et UTC pour mieux aborder la façon dont Java propose de modéliser le temps.

Après avoir parcouru les cas d’utilisation de la notion de temps parmi les plus courants et trouvé comment bien les modéliser en Java, nous avons réglé le problème récurrent des tests unitaires faisant intervenir l’horloge en reprenant la main sur cette dépendance système.

Le paquet java.time est bien plus riche en fonctionnalités que ce qui a été abordé dans cet article, je vous invite à vous plonger dans la documentation officielle qui est à la hauteur des possibilités offertes.

Un dernier mot, pour finir. GMT est à UTC ce que SSL est à TLS : un terme obsolète auquel beaucoup s’accrochent encore. Il est temps d’arrêter.

Published by

Commentaire

2 réponses pour " Bien représenter le temps en Java "

  1. Published by , Il y a 4 mois

    Très bon article merci.
    Petite remarque sur la partie test: il devrait être question d’injection de dépendances et non d’inversion.

  2. Published by , Il y a 3 mois

    Merci !

    J’ai corrigé la coquille, merci aussi :)

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.