Présenté à Meetup Lyon.rb
Ruby, Rails, et les dates — le temps, c’est rageant
Croyances erronées à propos du temps
Les développeurs ont accumulé collectivement un grand nombre de croyances erronées autour du temps :
- Les journées durent toutes 24h.
- Les mois ont soit 30 soit 31 jours.
- Les années ont toutes 365 jours.
- Et bien d’autres encore…
Noah Sussman a dressé une liste de 34 croyances erronées à propos du temps, et il a été obligé de rajouter encore 79 autres croyances suite aux retours qui lui ont été fait !
Une histoire d’heure d’été
Je me suis réveillé la nuit du 31 mars, j’ai regardé l’heure l’heure sur mon téléphone, et j’ai été surpris de la voir passer de 1h59 à 3h, sans intermédiaire. C’était la nuit du passage à l’heure d’été, et je venais d’y assister. J’avoue, c’est quand même moins impressionnant qu’une pluie d’étoiles filantes !
Le lendemain matin, je me réjouissait du retour de l’été et des activités de plein air, quand un client m’a appelé pour me signaler un problème : plusieurs évènements étaient décalés d’une heure, alors qu’il était certain d’avoir bien saisi.
L’origine du problème : dans le formulaire de saisie des évènements, il y a un champ pour la date, un autre pour l’heure de début, et un dernier pour l’heure de fin. Côté Rails, je fusionne la date avec chacune de ces heures pour enregistrer un timestamp complet. Mais ce code ne gérait pas les changements d’heure…
# Ne faites pas
- date.change(hour: time.hour, min: time.min)
# ou
- Time.local(date.year, date.month, date.day, time.hour, time.min, time.sec)
# Mais plutôt ceci :
+ time.change(year: date.year, month: date.month, day: date.day)
# ou ceci
+ Time.utc(date.year, date.month, date.day, time.hour, time.min)
Moralité, quand du code manipule les heures, il faut toujours vérifier qu’il gère l’heure d’été.
Et avec Ruby on Rails, on a de la chance : ActiveSupport::Testing::TimeHelpers
fournit les méthodes freeze_time
, travel
, travel_to
, et travel_back
,
qui permettent de manipuler le temps pendant les tests, et donc de simuler un passage à l’heure d’été ou d’hiver. Merci Rails !
Parlons de fuseaux maintenant
Si je vous dis kangourou et koala, vous pensez à… l’Australie bien sûr. Saviez-vous que l’Australie passe de 3 fuseaux horaires en “été” (leur hiver) à 5 en “hiver” (notre été). Encore plus surprenant : pendant l’heure d’été, certains états se décalent de 9 et 10 heures et demi par rapport au méridien de Greenwich. Pourquoi faire simple ?
Quand on parle de fuseaux horaires, on s’imagine que la planète est découpée en 24 lignes bien droites, décalées d’une heure chacune par rapport à la suivante. Mais ce n’est pas du tout le cas : il y a actuellement 38 fuseaux, et les décalages sont parfois même de l’ordre du quart d’heure.
Pourquoi autant de fuseaux ? Parce que le temps, c’est de l’argent, et de la politique aussi. Certains pays comme la Chine ont choisi de mettre tout le monde à l’heure de Pékin, alors que le pays s’étale sur environ 5 fuseaux. D’autres ont préféré sauter un jour pour se retrouver de l’autre côté de la ligne de changement de date, histoire d’être raccord avec leur voisin et partenaire commercial principal.
Et tout ça, c’est à cause des trains. En effet, jusque-là tout le monde réglait sa montre sur la position du soleil, et ne cherchait pas plus loin. Mais les trains ont permis de se déplacer plus vite, et il fallait pouvoir imprimer des horaires, et les respecter.
Moralité, Quand les utilisateurs vivent à différents endroits, il faut toujours prendre en compte les fuseaux horaires.
Pour cela, Rails nous fournit ActiveSupport::TimeWithZone
, une classe qui remplace la classe Time
native de Ruby, en y intégrant les données de la gemme TZ-info
pour gérer les fuseaux horaires.
C’est grâce à cette gemme que nous n’avons pas besoin de suivre l’actualité des changements d’heure.
Sachez qu’il y a environ 200 ajustements par an : un pays décide de passer à l’heure d’été à une date qui lui convient, ou de ne plus changer, etc.
On peut dire merci aux mainteneurs, qui se coltinent ce travail pour que notre code marche tout seul.
La classe TimeWithZone
gère donc les fuseaux horaires, et l’heure d’été. Pratique !
# Récupérer le temps courant
- Time.now
+ Time.zone.now
# Convertir une chaîne de caractères en temps
- Time.parse
+ Time.zone.parse
Pour en savoir plus, je vous renvoie vers l’article It’s about time (zones) (en anglais) de Thoughtbot.
Pareil, pour afficher une heure, il est tentant de la localiser sans autre forme de procès, mais il faut la convertir dans le fuseau de l’utilisateur connecté avant de l’afficher.
- I18n.l(time)
+ I18n.l(time.in_time_zone(current_user.time_zone))
On peut automatiser cette conversion au niveau du contrôleur principal, avec le code suivant :
# app/controllers/application_controller.rb
around_action :use_time_zone, if: :current_user
def use_time_zone(&block)
Time.use_zone(current_user.time_zone, &block)
end
On peut aussi utiliser local_time
, une gemme qui ajoute des helpers et du javascript pour que la conversion s’effectue côté client, dans le navigateur.
L’avantage, c’est que le HTML généré peut être plus facilement mis en cache, puisque les dates sont les mêmes pour tous : une balise time
avec un timestamp, que JavaScript localise à la volée. Merci Basecamp !
Mais je vois que je parle trop vite…
Il est donc temps de faire une pause pour boire.
Année solaire et année sidérale
Reprenons : l’année solaire, c’est une rotation complète autour du soleil. L’année sidérale, c’est 365 rotations de la planète sur elle-même (relativement aux étoiles). Le souci, c’est qu’il y a environ 5 heures de différences entre les deux ! D’où le besoin de mettre en place les années bissextiles.
Celles-ci existent depuis le calendrier Julien, mis en place par Jules César en 46 avant JC (l’autre). D’où le nom latin : littéralement, on répète (bis) le sixième jour d’un mois. C’était mieux, mais pas encore assez précis : ce calendrier se décale de 11 minutes par an !
Au bout de 1600 ans, le décalage était de presque deux semaines, ce qui ne convenait pas au pape Grégoire XIII. Il fit donc mettre en place le calendrier grégorien, celui que nous utilisons encore actuellement. Plus précis, l’année dure désormais 365,2422 jours. Sachez seulement qu’il a fallu 350 ans pour que tout le monde l’applique, avec des mois de moins de 28 jours par-ci, des aller-retours par là au gré des conquêtes… Un sacré sac de nœuds.
Le calcul des années bissextile est désormais le suivant :
- toute année multiple de 4,
- sauf si elle est multiple de 100,
- …mais pas de 400.
Les années 2000, 2020, 2024 par exemple sont bissextiles, mais 2100 ne le sera pas.
Nous utilisons le même calendrier depuis plus de 2000 ans, 503 années bissextiles se sont écoulées, et l’informatique est toute récente, moins de 70 ans. On pourrait donc imaginer que tout est bien géré…? Mais non.
Sur son blog, Matt Johnson-Pint liste les bugs liés aux années bissextiles, et nous n’avons pas été épargnés cette année :
- en Nouvelle-Zélande, impossible d’acheter de l’essence
- en Suède, impossible de régler en CB dans les supermarchés Ica
- au Japon, impossible de se faire délivrer un permis de conduire
- en Chine, le calcul d’âge légal échouait, bloquant les certificats de mariages
- et à Paris, les lampadaires se sont tous éteints à minuit.
Moralité : Quand du code manipule les dates, toujours penser aux années bissextiles.
Côté code, si on change manuellement l’année d’une date au 29 février, on obtient une erreur.
- Date.new(2024, 2, 29).change(year: 2023)
# => Date::Error: invalid date
Si on fait la même opération sur un temps cette fois, Ruby basculera sur le jour d’après. Ça peut se comprendre, mais il vaut mieux être prévenu parce que tous les composants ont changé : année, mois, jour.
- Time.new(2024, 2, 29).change(year: 2023)
# => 1er mars 2023
En revanche, si on utilise advance
, une addition, ou une soustraction, Rails basculera automatiquement sur le dernier jour de février de cette année-là, ce qui semble logique.
+ Date.new(2024, 2, 29).advance(years: -1)
# ou
+ Date.new(2024, 2, 29) - 1.year
# => 28 février 2023
Tout cela est rendu possible par ActiveSupport::Duration
, un module qui est ajouté aux classes Numeric
, Date
, et Time
.
C’est ce module qui ajoute la méthode advance
, et permet de dire 1.month.ago
ou 3.weeks.from_now
. Merci Rails !
Jours, heures, secondes…
Le calendrier grégorien n’est pas parfait : il se décale de 26 secondes par an. Et le décalage année solaire/année sidérale varie régulièrement. On ajoute donc de temps en temps des secondes intercalaires. Pensez-vous qu’elles sont mieux gérées que les journées et les heures ? Évidemment que non.
Le 31 décembre 2016 à minuit, le service DNS de CloudFlare a plié à cause d’une seconde intercalaire. Sans rentrer dans le détail, le load-balancer compare le temps de réponse de différents résolveurs, afin de privilégier le plus rapide. Sauf que cette seconde intercalaire a faussé le calcul, produisant une durée négative, qui s’est ensuite retrouvée dans une fonction qui ne s’y attendait pas du tout. Au final, certains datacenters étaient presque hors de service !
Si on veut mesurer le temps écoulé entre 2 instants, un simple t2 - t1
peut avoir un résultat négatif.
Pour s’en prémunir, on peut utiliser (t2 - t1).clamp(0, 1)
, qui combine min
et max
.
Attention cependant à ne pas utiliser cette valeur pour une division sinon un autre type d’erreur se produira !
Bien sûr, ce type de contrôle est un peu inutile quand on fait un benchmark en local, mais à prévoir pour du code production-ready.
Moralité : quand du code manipule des durées, toujours s’attendre à l’absurde. Le temps informatique n’est pas linéaire.
Toujours avec Rails, nous disposons de DateAndTime::Calculations
, qui étend Date
et Time
, et permet d’utiliser before?
et after?
, on_weekend?
, ou encore all_week
, all_month
, all_year
…
Pratique pour les requêtes SQL par exemple.
Les formats de date!
Boromir dit : ‘on ne peut pas faire confiance aux dates saisies par les utilisateurs’
On dit souvent qu’il ne faut jamais faire confiance aux saisies utilisateurs. Avec les dates, en tout cas, c’est très vrai ! Une date peut être saisie de différente manière : année/mois/jour, jour/mois/année, année/jour/mois, mois/jour/année, mois/jour (l’année courante est sous-entendue), ou encore année/jour de l’année (1-366), année/semaine de l’année/jour de la semaine. Sans oublier le calendrier bouddhiste, qui est 543 ans en avance du grégorien.
J’ai tendance à voir désormais le temps comme une langue : différents calendriers, différentes conventions, des préférences personnelles, et même de décalages entre machines… Comme dirait Einstein, le temps est relatif.
- Le temps du serveur peut être différent de celui de la VM (un système de virtualisation en particulier produisait des temps aberrants quand la VM était mise en pause puis réveillée).
- Le temps de la base de données peut être différent si le serveur ou datacenter qui l’héberge n’est pas synchronisé.
- Par défaut, le temps d’une application Rails est l’UTC.
- Enfin, il faut penser au temps côté client : le poste et le navigateur peuvent être sur un autre fuseau horaire.
Pour limiter l’ambigüité, ActionView::Helpers::DateHelpers
nous met à disposition date_select
/datetime_select
, select_date
/select_datetime
(selon qu’on génère un tag directement ou à partir d’un modèle), ainsi que les helpers time_tag
et time_ago_in_words
.
Moralité : pour manipuler dates, heures ou durées, toujours utiliser les méthodes disponibles ! —Et ne jamais faire confiance aux utilisateurs ! 😉
Encore une chose…
Quiconque s’est déjà usé les yeux sur des timestamp
s a constaté que ce format n’est pas très lisible.
Le standard ISO 8601 a donc été défini, il est à la fois lisible, triable, et non-ambigu.
Que des avantages ! Et c’est parfait pour les APIs.
Côté ruby, c’est supporté nativement, mais il faut requérir time
pour avoir accès à deux méthodes :
- la méthode de classe
Time.iso_8601
, qui permet de parser une chaîne dans ce format, et - la méthode d’instance
iso_8601
, qui convertit un objet date/time vers le format ISO.
Conclusion
Dans votre code, toujours penser à tester :
- L’heure d’été
- Les fuseaux horaires
- Les années bissextiles
- Les formats de dates
- Les « failles » temporelles