Ruby, Rails, et les dates

Publié le 29 octobre 2024

Tags : Informatique Ruby Rails Internationalisation

Ayant été victime d’un bug lié à l’heure d’été, j’ai commencé à creuser le sujet. Cette présentation de 20 minutes couvre les bases de la gestion du temps en informatique, avec des exemples de code en Ruby et Rails. Et des memes, on ne se refait pas !

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 :

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.

Un formulaire avec champ date, heure de début, et heure de fin

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

Un kangourou nonchalant chante 'do you come from a land down under'

un koala mouillé ça fait peur, très peur

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 ?

Les 5 fuseaux horaires d'Australie

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.

Carte du monde avec fuseaux horaires superposés

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.

Vue du disque-monde, sur le dos de quatre éléphants, eux-mêmes sur la carapace d'une tortue

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…

Speed, le paresseux de Zootopia, réagit au ralenti

Il est donc temps de faire une pause pour boire.

un bébé lapin boit du lait avec excitation

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.

Photo d'Alain Chabat dans le rôle de Jules César

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 !

Portrait du pape Grégoire XII

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 :

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 :

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.

Graphique du taux d'erreur dans les centres DNS de CloudFlare

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.

Einstein tirant la langue

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…

timestamp contre ISO8601, version meme

Quiconque s’est déjà usé les yeux sur des timestamps 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 :

Conclusion

Dans votre code, toujours penser à tester :

Keep calm and use Ruby

Page précédente Page précédente Page suivante Page suivante