Solution Unifiée pour la Gestion du Temps UTC dans les Projets Java
Introduction
Récemment, après la mise à niveau de certains frameworks dans nos projets d'équipe, nous avons constaté une décalage de 8 heures dans certaines valeurs temporelles. L'erreur provenait de l'interprétation incorrecte des données temporelles de la base de données comme étant en UTC (alors que les anciennes versions les considéraient comme étant en heure de Pékin).
Afin d'assurer la cohérence dans la gestion du temps pour nos futurs projets, j'ai décidé d'uniformiser l'utilisation du temps UTC. Après une recherche approfondie, j'ai rédigé cet article.
Configuration des fuseaux horaires et types temporels dans MySQL
Fuseau horaire de la base de données
La base de données MySQL possède un paramètre de fuseau horaire, qui utilise par défaut le fuseau horaire du système.
Vous pouvez interroger le fuseau horaire actuel avec la commande suivante :
show variables like '%time_zone%';
Voici les paramètres de fuseau horaire MySQL sur ma machine personnelle :
La configuration du fuseau horaire de la base de données en production est la suivante :
On peut voir que la base de données utilise le fuseau horaire CST (China Standard Time) UTC+8:00, qui correspond à l'heure côtière chinoise (heure de Pékin).
Explication des types temporels
datetime
Stocke exactement ce qui lui est fourni (Just stores what you have stored and retrieves the same thing which you have stored.)
Indépendant du fuseau horaire (It has nothing to deal with the TIMEZONE and Conversion.)
timestamp
La valeur est stockée en millisecondes UTC (it stores the number of milliseconds)
Le stockage et la récupération effectuent une conversion en fonction du fuseau horaire actuel
Étant donné que timestamp est lié au fuseau horaire et que le fuseau horaire de la base de données en production est l'heure de Pékin (c'est-à-dire UTC+8:00), l'utilisation incorrecte de colonnes timestamp dans la base de données peut introduire des erreurs lors de la conversion vers le format UTC ! L'explication détaillée suit plus loin.
Aperçu du plan de conversion vers le temps UTC
Définition d'un fuseau horaire unique
Dans le nouveau framework du projet, la classe UTCTimeZoneConfiguration définit le fuseau horaire par défaut du processus lors de l'initialisation du projet.
@Configuration
public class UTCTimeZoneConfiguration implements ServletContextListener{
public void contextInitialized(ServletContextEvent event) {
System.setProperty("user.timezone", "UTC");
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
}
public void contextDestroyed(ServletContextEvent event) {}
}
Utilisation de Joda DateTime pour la gestion du temps
Pour la gestion des dates et heures, on peut utiliser java.util.Date, mais il est préférable d'utiliser Joda DateTime qui est plus pratique. Cette section explique comment sérialiser/désérialiser Joda DateTime.
Le type Joda DateTime est utilisé pour définir les paramètres d'entrée/sortie d'interface et nécessite des opérations de sérialisation/désérialisation. Contrairement au type Date natif, DateTime nécessite un traitement supplémentaire.
1. Remplacer le type Date par DateTime dans les champs de modèle
Voici un exemple de code :
public class Entity {
@JsonSerialize(using = UTCDateTimeSerializer.class)
@JsonDeserialize(using = UTCDateTimeDeserializer.class)
private DateTime dateTime;
public DateTime getDateTime() {
return dateTime;
}
public void setDateTime(DateTime dateTime) {
this.dateTime = dateTime;
}
}
Les implémentations des classes UTCDateTimeSerializer et UTCDateTimeDeserializer sont disponibles en annexe.
2. Gestion des paramètres temporels dans les requêtes GET
Une méthode efficace consiste à accepter les paramètres de date sous forme de chaînes de caractères :
@RequestMapping(value = "/xxx", method = RequestMethod.GET)
public CommonResponse getXxx(@RequestParam(value = "beginTime") String beginTimeText,
@RequestParam(value = "endTime") String endTimeText) {
DateTime beginTime = DateTime.parse(beginTimeText).withZone(DateTimeZone.UTC);
DateTime endTime = DateTime.parse(endTimeText).withZone(DateTimeZone.UTC);
...
}
Opérations DAO pour les colonnes datetime de la base de données
Prenons l'exemple de l'utilisation du type Joda DateTime. Voici deux méthodes dans une classe DAO :
public void update(int id, DateTime dateTime) {
String sql = "UPDATE " + TABLE_NAME + " SET datetime = ? WHERE id = ?";
jdbcTemplate.update(sql, new Timestamp(dateTime.getMillis()), id);
}
public DateTime getDateTime(int id) {
String sql = "SELECT datetime FROM " + TABLE_NAME + " WHERE id = ?";
List<DateTime> dateTimeList = jdbcTemplate.query(sql, new Object[] {id}, new RowMapper<DateTime>() {
@Override
public DateTime mapRow(ResultSet rs, int rowNum) throws SQLException {
return new DateTime(rs.getTimestamp("datetime").getTime());
}
});
return dateTimeList.size() > 0 ? dateTimeList.get(0) : null;
}
Pour l'insertion ou la mise à jour de données, veuillez utiliser new Timestamp(dateTime.getMillis()) comme paramètre temporel.
Pour la lecture des paramètres temporels, utilisez new DateTime(rs.getTimestamp("datetime").getTime())
Opérations DAO pour les colonnes timestamp de la base de données
Le type timestamp de la base de données est adapté pour enregistrer l'heure de dernière modification d'une donnée.
Pour les autres cas d'utilisation, il est recommandé d'utiliser datetime ou int.
Solution 1 : Changer le fuseau horaire de la session en UTC
Les opérations sur les colonnes timestamp ne sont pas différenciées de celles sur les colonnes datetime. Il faut définir le fuseau horaire de la session de connexion à la base de données. Par défaut, il est réglé sur l'heure de Pékin, il faut donc le définir sur UTC avec la commande suivante :
set time_zone = '+0:00';
Dans les projets réels utilisant un pool de connexions à la base de données, après avoir créé le datasource, utilisez la méthode suivante pour définir le fuseau horaire, ce qui s'appliquera à toutes les connexions :
dataSource.setInitSQL("set time_zone = '+0:00'");
Après cette opération, le fuseau horaire est uniformisé en UTC, et les opérations temporelles dans le DAO n'ont pas besoin de traitement spécial pour les timestamp.
Solution 2 : Ne pas changer le fuseau horaire de la session
Étant donné que le fuseau horaire n'est pas modifié, l'utilisation des données de type timestamp présente certaines limitations.
1. Comment mettre à jour les données timestamp
Pour les colonnes timestamp dans les tables de base de données, leur mise à jour doit être gérée par la base de données elle-même. Elle doit être définie lors de la création de la table, comme suit :
CREATE TABLE t1 (
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
Peut être abrégé ainsi :
CREATE TABLE t1 (
ts TIMESTAMP
);
Il n'est pas autorisé de mettre à jour manuellement les colonnes timestamp par le code.
La base de données en production utilise l'heure de Pékin, donc les données de date qu'elle reçoit sont considérées comme étant en heure de Pékin. Or, la logique métier du programme supérieur utilise uniformément le temps UTC, créant une incohérence de fuseau horaire. Par conséquent, afin d'éviter des interprétations divergentes des données de date enregistrées dans la base de données, il n'est pas autorisé de mettre à jour les colonnes timestamp par des opérations d'écriture dans le code.
Les données suivantes sont issues de mes tests personnels, où la colonne timestamp est mise à jour par le programme, tandis que la colonne update_time est mise à jour automatiquement par la base de données.
La première montre l'heure UTC, qui semble correcte mais est en réalité erronée, car le temps stocké en interne dans la base de données est UTC-8:00.
La colonne update_time correspond au fuseau horaire de la base de données, renvoyant l'heure de Pékin, mais stockant en réalité l'heure UTC.
2. Comment lire les données timestamp
Pour éviter d'obtenir des données temporelles liées au fuseau horaire (heure de Pékin), on force l'utilisation du temps UTC en utilisant la fonction UNIX_TIMESTAMP pour obtenir le nombre de secondes depuis 1970, que l'on multiplie par 1000 pour convertir en millisecondes lors de la transformation en DateTime.
public DateTime getTimestamp(int id) {
String sql = "SELECT UNIX_TIMESTAMP(update_time) as unix_timestamp FROM " + TABLE_NAME + " WHERE id = ?";
List<DateTime> dateTimeList = jdbcTemplate.query(sql, new Object[] {id}, new RowMapper<DateTime>() {
@Override
public DateTime mapRow(ResultSet rs, int rowNum) throws SQLException {
return new DateTime(rs.getLong("unix_timestamp") * 1000);
}
});
return dateTimeList.size() > 0 ? dateTimeList.get(0) : null;
}
Annexes
Configuration du fuseau horaire MySQL
Pour définir le fuseau horaire global, des droits d'administrateur sont nécessaires.
Utiliser le fuseau horaire du système local :
SET GLOBAL time_zone = SYSTEM;
Utiliser le temps UTC :
SET GLOBAL time_zone = '+0:00';
Utiliser l'heure de Pékin :
SET GLOBAL time_zone = '+8:00';
Définir le fuseau horaire de la session en cours :
set time_zone = '+0:00';
UTCDateTimeSerializer et UTCDateTimeDeserializer
UTCDateTimeSerializer convertit un objet DateTime en chaîne de caractères UTC, au format : yyyy-MM-ddTHH:mm:ssZ
UTCDateTimeDeserializer convertit une chaîne de caractères temporelle en objet DateTime dans le fuseau horaire UTC.
Les implémentations spécifiques sont les suivantes :
public class UTCDateTimeSerializer extends JsonSerializer<DateTime> {
@Override
public void serialize(DateTime dateTime,
JsonGenerator jsonGenerator,
SerializerProvider provider) throws IOException {
String dateTimeAsString = dateTime.withZone(DateTimeZone.UTC).toString(BecConstant.DATETIME_FORMAT);
jsonGenerator.writeString(dateTimeAsString);
}
}
public class UTCDateTimeDeserializer extends JsonDeserializer<DateTime> {
@Override
public DateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
throws IOException {
JsonToken currentToken = jsonParser.getCurrentToken();
if (currentToken == JsonToken.VALUE_STRING) {
String dateTimeAsString = jsonParser.getText().trim();
return DateTime.parse(dateTimeAsString).withZone(DateTimeZone.UTC);
}
return null;
}
}