Traduire les dates

29 mai 2017. 29 May 2017. May 29, 2017. 2017年5月29日.

Quatre représentations de la même date, le vingt-neuvième jour du cinquième mois de la deux mille dix-septième année du calendrier grégorien, dans quatre langues (le français, l’anglais britannique, l’anglais américain, le japonais). Autant de syntaxes fondamentalement diverses, avec non seulement un vocabulaire différent, mais également un ordre des éléments variable, une ponctuation et une typographie spécifiques à chaque pays.

Bienvenue dans le monde merveilleux de la traduction de dates.

Retranscrire une date dans un format qui soit naturel pour un locuteur natif est un vrai défi. Il n’y aucune logique à chercher, aucun algorithme universel à employer. Il faut, pour chaque langue, et même pour chaque variante de langue, connaître la règle particulière en vigueur et l’appliquer.

Heureusement, de courageux héros, réunis au sein du Unicode Common Locale Data Repository, ont déjà accompli cette tâche titanesque pour nous, associant à chaque code IETF, la syntaxe et le vocabulaire qui lui correspondent.

Et d’autres, au sein du projet ICU, ont transformé cette donnée brute en du code C/C++ directement prêt à l’emploi, lui-même intégré à votre navigateur. Qui peut donc être appelé avec un peu de Javascript, via Intl.DateTimeFormat.

Toutefois, je ne vais pas m’étendre sur cette implémentation, parce que ce ne serait que de la redite de la documentation de MDN, qui est, comme toujours, très bien faite.

À la place, je vais plutôt m’intéresser à une autre intégration, trop souvent ignorée mais pourtant très utile à connaître, car correspondant au langage encore aujourd’hui le plus utilisé dans la conception de sites web : IntlDateFormatter

Ouvrant directement le bal avec un exemple :

<?php

function day(\DateTime $date, $locale) {
  return (new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::NONE, $date->getTimezone()))->format($date);
}

function hour(\DateTime $date, $locale) {
  return (new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::MEDIUM, $date->getTimezone()))->format($date);                
}

function mixed(\DateTime $date, $locale) {
  return (new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT, $date->getTimezone()))->format($date); 
}

$date = new \DateTime();
$locales = ['fr_FR', 'en_GB', 'en_US', 'ja_JA'];

foreach ($locales as $locale) {
  print($locale.PHP_EOL);
  print(day($date, $locale).PHP_EOL);
  print(hour($date, $locale).PHP_EOL);
  print(mixed($date, $locale).PHP_EOL);
}
fr_FR
29 mai 2017
14:39:13
29/05/17 14:39
en_GB
29 May 2017
14:39:13
29/05/2017 14:39
en_US
May 29, 2017
2:39:13 PM
5/29/17 2:39 PM
ja_JA
2017年5月29日
14:39:13
17/05/29 14:39

Ou, pour ne garder que l’essentiel :

return (new \IntlDateFormatter($locale, $datetype, $timetype, $date->getTimezone()))->format($date);

Où $datetype et $timetype sont deux constantes à choisir dans la liste suivante : http://php.net/manual/fr/class.intldateformatter.php#intl.intldateformatter-constants

Cette syntaxe (directement reprise de l’implémentation en C) n’est pas forcément très intuitive, et je conseille d’expérimenter sur quelques exemples, typiquement en copiant-collant le code précédent dans un coin et en jouant avec les constantes employées, les dates et les locales.

En pratique, une fois le coup pris, ça fonctionne plutôt pas mal, et ça permet de se libérer d’un poids. Ainsi, une fois le bon format déterminé pour une langue donnée, on a l’assurance d’en avoir le meilleur équivalent possible dans chaque autre, sans prise de tête.

Terminons par le sujet qui fâche. Les exceptions à la norme.

En effet, parfois, quand une date ou une horaire est intégrée à une phrase complète, il peut être nécessaire, pour une langue donnée, de prendre quelques libertés avec la norme pour que le texte sonne plus naturel.

Exemple : Le Royaume-Uni emploie officiellement le système horaire sur 24 heures. La machine nous renverra donc l’heure dans ce format, et c’est d’ailleurs ce qui se passe dans l’exemple ci-dessus.

Sauf que, comme pour le système métrique, il y a un fossé entre adoption officielle et adoption populaire. Ainsi dans la langue parlée, et même la langue écrite informelle, AM et PM dominent encore.

Une exception, codée comme telle, peut donc s’avérer nécessaire, via un formattage explicite (syntaxe) :

function hour(\DateTime $date, $locale) {
  if ('en_GB' === $locale) {
    return (new \IntlDateFormatter($locale, null, null, $date->getTimezone(), null, 'h:mm a'))->format($date);
  }
  return (new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT, $date->getTimezone()))->format($date);
}

$date = new \DateTime('16:00');
$locales = ['fr_FR', 'en_GB', 'en_US', 'ja_JA'];

foreach ($locales as $locale) {
  print($locale.PHP_EOL);
  print(hour($date, $locale).PHP_EOL);
} 
fr_FR
16:00
en_GB
4:00 PM
en_US
4:00 PM
ja_JA
16:00

J’insiste toutefois, un peu lourdement je sais, sur le fait que ces exceptions doivent rester exceptionnelles, et n’être appliquées qu’en toute connaissance de cause, en ayant explicitement et strictement défini la langue et le contexte dans lesquelles elles s’appliquent.

Surtout, ne jamais utiliser la surchage du format, qui prend le pas sur tous les autres paramètres, dans le cas général. C’est un coup à se retrouver avec 4:00 PM en français, ce qui n’aurait aucun sens.

Ou, plus subtilement, des dates en XX/YY/ZZ où le mois et le jour sont inversés par rapport à la norme en vigueur du pays auquel on s’adresse, avec les problèmes évidents que cela implique.

 

Modifier une table critique sans panique

À la suite de notre conférence sur le déploiement continu, nous avons eu pas mal de questions sur les astuces que nous employions pour mettre à jour la base de données sans maintenance.

Prenons un exemple pratique inspiré de faits réels.

Avant 2016, la Ruche qui dit Oui ne travaillait qu’avec un unique prestataire de paiement. Quelle que soit la méthode de paiement que vous employiez alors pour régler vos courses hebdomadaires (tous types de cartes bleues, de portemonnaies électroniques etc.), le traitement de l’opération bancaire proprement dite était assuré par le même PSP.

En 2016, nous avons entrepris de changer progressivement et sans interruption de service de prestataire de paiement. Un projet ambitieux, au cours duquel nous avons énormément appris et sur lequel il y a beaucoup à dire, et qui servira sans doute de base à de nombreux autres articles.

Mais pour l’heure, intéressons-nous à l’une des toutes premières étapes de ce travail de titan, un minuscule détail qui oblige déjà à se poser beaucoup de questions.

Changer de prestataire de paiement signifie stocker (en base) l’information qu’un paiement donné a été effectué avec tel ou tel prestataire.

Donc de rajouter une colonne à la table Payment, et de la remplir avec la bonne valeur.

Une table pas du tout critique sur une fonctionnalité pas du tout critique comme vous pouvez vous en douter.

Note : Dans la suite, les exemples données emploieront la syntaxe et la documentation de PostgreSQL, mais les principes généraux qu’ils démontrent sont pour la plupart directement transposables à tout autre système de base de données relationnelle.

Approche naïve

CREATE TYPE psp AS ENUM ('old_psp', 'new_psp');
-- https://www.postgresql.org/docs/9.5/static/datatype-enum.html

ALTER TABLE payment ADD COLUMN psp psp NOT NULL DEFAULT 'old_psp';

Et là, c’est l’explosion.

postgres=# CREATE TYPE psp AS ENUM ('old_psp', 'new_psp');
CREATE TYPE
postgres=# \timing
Chronométrage activé.
postgres=# ALTER TABLE payment ADD COLUMN psp psp NOT NULL DEFAULT 'old_psp';
ALTER TABLE
Temps : 122385,927 ms

En effet, la table Payment comprenant tous les paiements du site, réussis, échoués ou abandonnés en cours de toute, depuis sa création, contient un certain nombre de lignes.

postgres=# SELECT COUNT(*) FROM payment;
2504147

Or, la présence d’une valeur par défaut dans cette nouvelle colonne fait que PostgreSQL va devoir parcourir l’intégralité de la table déjà existante pour l’ajouter partout.

Séquentiellement, sans optimisation aucune, en prenant donc un temps directement proportionnel au nombre total de lignes.

Bon, d’accord, c’est lent, mais est-ce vraiment un problème ?

Oh que oui, parce qu’un ALTER TABLE verrouille complètement la table.

Ce qui signifie que durant la durée totale de l’opération, personne ne pourra y accéder. Toute requête, en lecture ou en écriture, sera mise en attente, avant de très probablement échouer parce qu’elle a dépassé le temps limite qui lui est alloué.

Dans le meilleur des cas, parce que si elles commencent à s’embouteiller, c’est carrément la base qui peut partir en vrille. Ce qui aura bien sûr des conséquences en cascade, parce quand la base ne va plus bien, il est rare que le site derrière affiche une forme éblouissante.

Même sans aller jusque là, ce sont des utilisateurs qui vont essayer de payer et se manger une page d’erreur, ce qui est toujours fâcheux.

Approche moins naïve

Étape 1 : Préparer le terrain

Créons donc la colonne vide, acceptant NULL et sans valeur par défaut. Le LOCK sera toujours présent, mais nettement plus court, et sa durée ne s’allongera plus proportionnellement au nombre d’entrées déjà présentes dans la table.

postgres=# ALTER TABLE payment ADD COLUMN psp psp;
ALTER TABLE
Temps : 197,397 ms

Bon à savoir : Dans PostgreSQL, la présence d’un DEFAULT, même d’un DEFAULT NULL, force toujours le parcours linéaire. Pour un résultat final identique, il est donc largement préférable de ne pas préciser de valeur par défaut du tout lors de l’ajout d’une colonne nullable, auquel cas PostgreSQL se contentera d’une rapide opération sur les métadonnées (source : second paragraphe des notes de la documentation officielle).

C’est bien gentil, mais le code derrière, il attend une chaîne de caractère, pas « null ». Ça va encore tout casser.

Et c’est là que le déploiement continu intervient.

Dans un premier déploiement, livrons juste cette migration et exécutons-la.

Voilà, nous avons une colonne magnifiquement vide, inusitée et inutilisable telle quelle en production. Alors certes, cela ne casse rien, mais nous voilà bien avancés.

Patience.

Étape 2 : Mise à jour progressive

Pour remplir cette table, nous pouvons maintenant avoir recours à un UPDATE. L’avantage du UPDATE, c’est qu’il ne verrouille que les lignes qu’il est actuellement en train de tripoter, et n’empêche pas les autres de vivre leur vie.

Évidemment, avec une approche brutale comme « UPDATE payment SET psp=’old_psp’; », cela ne change pas grand chose par rapport au cas précédent, puisque vous vous attaquez à toutes les lignes à la fois quand même.

En revanche, prenons une requête rédigée de la sorte :

UPDATE payment SET psp='old_psp' WHERE id IN (SELECT id FROM payment WHERE psp IS NULL LIMIT 1000);

Avec une requête pareille, vous ne bloquez l’accès qu’à mille lignes à la fois, et durant un temps éphémère (de l’ordre du dizième de seconde). Bien sûr la table comporte bien plus que mille lignes, mais si vous emballez cette requête dans une structure conditionnelle qui l’exécute encore et encore et vous allez progressivement tout mettre à jour en douceur.

Un tel script a en plus l’avantage que vous pouvez l’arrêter en plein milieu et le reprendre plus tard comme s’il ne s’était rien passé. Beaucoup plus confortable qu’une gigantesque transaction qu’il faudra reprendre à zéro la fois suivante si cela se passe mal.

C’est l’approche que nous emploierons… Mais plus tard. Car pour l’heure, une telle requête va tourner éternellement, les utilisateurs rajoutant constamment de nouveaux paiements, avec des valeurs NULL. Il faut au préalable régler ce problème.

Étape 3 : Prévoir le futur

Intéressons-nous enfin au code applicatif. Si pour l’instant, le contenu de cette table n’est pas utilisable en lecture, car riche en valeurs NULL dont ne nous voulons pas, rien n’empêche d’y écrire dès maintenant.

L’étape suivante est donc de s’assurer que tout nouveau paiement inséré dans la base de données le soit avec un psp. Pour l’instant, la valeur sera toujours la même, mais les fondations seront déjà là pour la suite.

Note : Soyez particulièrement explicite sur le fait que la valeur peut actuellement être null, et ne devrait pas être lue pour le moment.

Exemple basique en PHP/Symfony/Doctrine :

class Payment
{
  /**
   * @experimental
   * @write-only
   * @var string|null
   *
   * @ORM\Column(name="psp", type="psp", nullable=true)
   */
  private $psp;

  __construct() {
    $this->psp = 'old_psp';
  }
}

À partir du moment où ce code va être déployé en production, des valeurs utiles et des valeurs null vont cohabiter en base. Cela ne cassera rien, mais il n’y a pas de raison que cela ne dure éternellement.

Astuce : Les étapes 2 et 3 peuvent être développées en parallèle.

Étape 4 : Se défaire des NULL

Exécutez le script conçu au 2. Une fois celui-ci terminé, il ne devrait normalement plus y avoir la moindre valeur NULL dans cette colonne, et le code ne devrait plus en injecter de nouvelles. Vérifiez que tel est bien le cas, et si oui, il ne reste plus qu’à conclure.

Aussi bien en base :

ALTER TABLE payment ALTER COLUMN psp SET NOT NULL;

Que dans le code :

class Payment
{
  /**
   * @var string
   *
   * @ORM\Column(name="psp", type="psp")
   */
  private $psp;

  __construct() {
    $this->psp = 'old_psp';
  }
}

Et voilà, la colonne a été ajoutée, correctement remplie et est proprement typée, sans que vos utilisateurs ne se soient rendus compte de rien.

Vous êtes maintenant libre de lui donner un usage pratique.

En résumé, il nous aura fallu :

  1. Base de données : Ajouter une colonne facultative
  2. Code applicatif : Remplir correctement cette colonne pour les nouvelles entrées
  3. Base : Mettre à jour progressivement les anciennes entrées
  4. Base + Code : Rendre la colonne obligatoire et nettoyer les scories de l’époque où cette valeur pouvait encore être non affectée

Avec un minimum de deux déploiements (quatre si vous déployez vos modifications de base de données comme le code).

Cela peut sembler long et fastidieux, surtout si votre processus de déploiement est encore très complexe et sporadique.

Mais l’absence d’interruption de service, et avec elle des bugs en cascade issus des incohérences en base ou entre votre base et celles de vos prestataires qui suivent en général de tels interruptions, en vaut très largement la peine.

json_encode et array

Si vous avez jamais eu à travailler avec une API PHP, qu’il s’agisse de la produire ou de la consommer, vous avez sans doute déjà rencontré des cas où le logiciel client se met à agonir d’injures son fournisseur sous prétexte qu’il a demandé un array et qu’il se retrouve avec un object.

Pourtant, côté serveur, le code se contente d’appliquer un json_encode à un tableau tout bête supposément non-associatif.

Quelque chose comme :

$someArray = ["a", "b", "c", "d"];

var_dump(json_encode($someArray));
string(17) "["a","b","c","d"]"

Pas de problème.

Tiens, et si j’enlevais le premier élément du tableau :

unset($someArray[0]);

var_dump(json_encode($someArray));
string(25) "{"1":"b","2":"c","3":"d"}"

Mais, que, quoi ?

On la refait, mais en changeant un peu la syntaxe.

var_dump(json_encode([0 => "a", 1 => "b", 2 => "c", 3 => "d"]));
var_dump(json_encode([1 => "b", 2 => "c", 3 => "d"]));
string(17) "["a","b","c","d"]"
string(25) "{"1":"b","2":"c","3":"d"}"

Vous commencez à voir le truc ?

En PHP, tout « tableau » est fondamentalement un dictionnaire. Un dictionnaire sur lequel a été greffé une relation d’ordre certes (le langage conserve les éléments dans l’ordre dans lequel ils ont été insérés dans la structure), mais qui se base avant tout sur cette idée d’associer chaque valeur qu’il transporte à une clé.

Et si ces clés ne sont pas explicitement précisées, le langage va par défaut utiliser des entiers, en commençant par celui suivant la plus grande clé actuellement dans le tableau.

Un exemple, parce que c’est franchement tordu :

$myArray = [12 => "b", "toto" => "c", 1 => "a"];
$myArray []= "a";
print_r($myArray);
Array
(
    [12] => b
    [toto] => c
    [1] => a
    [13] => a
)

Bon, soit, à la rigueur.

Sauf qu’en JSON, les choses ne fonctionnent pas ainsi. Il y a d’un côté les array, une structure ordonnée, et de l’autre les object, qui sont des associations clé-valeur.

Et lorsque json_encode doit convertir un array (PHP) vers l’une de ces deux structures, il tente de faire au mieux selon un algorithme simple :

  • Si l’array PHP ne comporte que des clés entières (assertion avec un ==) consécutives et commençant à 0, alors celui-ci deviendra un array JSON
  • Sinon, il sera représenté sous la forme d’un type object

Autrement dit :

[] -> array
["a"] -> array
[0.0 => "a"] -> array
["key" => "a"] -> object
[1 => "a"] -> object

Ouch.

Cela explique notamment des blagues comme :

var_dump(json_encode(array_filter(["a", "b", null])));
var_dump(json_encode(array_filter(["a", null, "b"])));
string(9) "["a","b"]"
string(17) "{"0":"a","2":"b"}"

En effet, array_filter, comme la plupart des fonctions PHP, conserve les clés d’origine du tableau qu’il manipule. Autrement dit :

print_r(array_filter(["a", "b", null]));
print_r(array_filter(["a", null, "b"]));
Array
(
    [0] => a
    [1] => b
)
Array
(
    [0] => a
    [2] => b
)

Dans le premier cas, par hasard, les clés continuent de se suivre, le typage est donc bon, dans le second, ce n’est plus le cas, et les problèmes surviennent.

Dans la pratique, au moindre doute sur les clés d’un tableau PHP, ce qui sera le cas la plupart des temps, une astuce pour sécuriser le résultat consiste à forcer le typage JSON via l’utilisation de (object) et de array_values.

var_dump(json_encode(array_values([0 => "a", 2 => "c"])));
var_dump(json_encode((object) [0 => "a"]));
string(9) "["a","c"]"
string(9) "{"0":"a"}"

Un peu lourd, mais efficace. Au pire, rien n’empêche de cacher ces roueries un peu bas niveau derrière des classes, via l’implémentation de JsonSerializable.

class JsonArray implements \JsonSerializable
{
  private $array;

  public function __construct(array $array)
  {
    $this->array = $array;
  }

  public function jsonSerialize()
  {
    return array_values($this->array);
  }
}

class JsonObject implements \JsonSerializable
{
  private $array;

  public function __construct(array $array)
  {
    $this->array = $array;
  }

  public function jsonSerialize()
  {
    return (object) $this->array;
  }
}

var_dump(json_encode(new JsonArray([0 => "a", 2 => "c"])));
var_dump(json_encode(new JsonObject([0 => "a"])));
string(9) "["a","c"]"
string(9) "{"0":"a"}"

Mais on rentre dans des détails d’implémentation hors-sujet.

L’important restant en effet, quelle que soit la solution choisie, que le client reçoive bien ce que le serveur s’est engagé à lui envoyer, et pas autre chose.