Időzónák használata

Az időzóna az a terület, ahol az óráknak azonos időt kéne mutatniuk. Funkcionális okok miatt ezeket nem a földrajzi hosszúságok határozzák meg, hanem tipikusan az országhatárokhoz igazodnak. Mindegyik időzónát a UTC világidőhöz (egyezményes világidő) viszonyítják. A UTC a GMT-t váltotta, de ez utóbbi már elavult, ne használjuk. A magyarországi időzóna a téli időszámításkor a CET (ami egy órával van előrébb a UTC-nél, jelölése ezért UTC+1), nyáron pedig CEST (, ami kettővel).

A dátumok formázásra van a ISO-8601 szabvány, ennek felel meg például a 2011-12-03T10:15:30+01:00[Europe/Budapest] formátum is, mely tartalmazza az UTC-hez képest az eltolást, és az időzóna nevét.

Ebben a posztban megvizsgálom, hogy hogy lehet kezelni az időzónát operációs rendszer szinten, adatbázisban, Java SE-ben, valamint egy Springes alkalmazásban, JPA/Hibernate perzisztens réteggel, és Jackson JSON library-vel.

Talán nem is fontos a teljes poszt megértése, inkább azt érdemes megjegyezni, hogy hol történnek konverziók, és ezeket hogy érdemes debuggolni.

Operációs rendszer

Az időzóna beállítások már az operációs rendszernél kezdődnek. Windows esetén lekérdezni parancssorból a tzutil /g paranccsal lehet. Ez nálam a Central Europe Standard Time értéket adja vissza. Az elérhető időzónákat a tzutil /l paranccsal lehet kilistázni. Linuxon a date +"%Z %z" paranccsal kérdezhető le, ez nálam CEST +0200. Az elérhető időzónák lekérdezhetőek a timedatectl list-timezones paranccsal.

Adatbázis

A következő elem az adatbázis. Ebben a posztban a PostgreSQL-t fogom megvizsgálni. Időzóna lekérdezése a show timezone; utasítással történik. Az elérhető időzónákat a következő lekérdezés adja vissza:

select * from pg_timezone_names;

Az időzóna beállítás Postgresql alatt lehet globális (pl. a postgresql.conf állományban), de felülírható a sessionben is.

Ez úgy demonstrálható, hogy lekérdezzük az időt, majd átállítjuk az időzónát, majd újra lekérdezzük. Beállításra a set time zone parancs használható. (Működik a SET TIMEZONE TO is, de az nem felel meg annyira az SQL szabványnak.)

set time zone 'UTC';
select now(); -- 2020-07-17 13:59:57

set time zone 'Europe/Budapest';
select now(); -- 2020-07-17 15:59:57

De vigyázz! Vannak olyan kliensek, melyek a visszakapott dátum típusú értéket azonnal átkonvertálják a kliens időzónájára. Ezt úgy lehet ellenőrizni, hogy már a szerver oldalon karakterlánccá alakítjuk.

select now();
select to_char(now(), 'yyyy-mm-dd hh24:mi:ss[TZ]');

Ha nekem eltért a session időzónája, és a kliens időzónája, akkor különböző értékeket adott vissza. Én a DBeavert használtam, és ez konvertálta az időket. Ezt meg lehet akadályozni a Properties / Editors / Data Editor / Data Formats ablakon a Use native date/time format kipipálásával.

Intermezzo

A helyzet ennél még bonyolultabb. A PostgreSQL-t egy Docker konténerben indítottam az alábbi parancs megadásával:

docker run --name timezone-postgres -e POSTGRES_PASSWORD=timezone -d -p 5432:5432 postgres

Kíváncsi voltam, mi van megadva ekkor a konfigurációs fájlban (/var/lib/postgresql/data/postgresql.conf).

$ docker exec -it timezone-postgres cat /var/lib/postgresql/data/postgresql.conf | grep timezone

timezone = 'Etc/UTC'

Valamint megnéztem, hogy mit ad vissza a parancssori kliens.

$ docker exec -it timezone-postgres psql -U postgres -c "show timezone;"
 TimeZone 
----------
 Etc/UTC
(1 row)

Azaz belépés után a show timezone; parancs Europe/Budapest értéket adott vissza. Ez azért van, mert a DBeaver felülvágja a platform alapértelmezett időzónájával. Ők ezt persze a PostgreSQL JDBC Driver ismert tulajdonságára vezetik vissza. Ennek megoldása, hogy vagy átállítjuk a DBeaver alatti JVM-ben az időzónát vagy a dbeaver.ini fájlban, vagy parancssori paraméterben (-vmargs -Duser.timezone=UTC). Vagy belépés után azonnal set time zone parancsot adunk ki.

Tanulság: sose higgyünk a grafikus klienseknek időzóna ügyben!

Az IntelliJ IDEA-ba épített nem trükközik így.

Timestamp with time zone

A legnagyobb meglepetés a típusok körül érhet. Időzónának értelme a dátum és idő együttesénél van. Erre a timestamp típus használható. Azonban van egy timestamp és egy timestamp with time zone típus is. Azonban ez utóbbi az időt mindig UTC-ben tárolja! (Lásd dokumentáció!)

All timezone-aware dates and times are stored internally in UTC. They are converted to local time in the 
zone specified by the TimeZone configuration parameter before being displayed to the client.

A különbség beszúráskor és lekérdezéskor is jelentkezik. Amikor timestamp mezőbe szúrunk be, és megadunk offset-et, akkor azt teljesen figyelmen kívül, hagyja, azaz levágja.

drop table if exists employees;

create table employees (id int8 generated by default as identity, 
  name varchar(255), valid_from timestamp);

insert into employees (name, valid_from) values ('John Doe', '2020-04-01 10:00:00.00+0200');

select name, to_char(valid_from, 'yyyy-mm-dd hh24:mi:ss[TZ]') from employees; 
-- 2020-04-01 10:00:00[]

Azonban ha timestamp with time zone típust használunk, akkor figyelembe veszi, sőt átkonvertálja UTC értékre és úgy tárolja.

drop table if exists employees;

create table employees (id int8 generated by default as identity, 
  name varchar(255), valid_from timestamp with time zone);

insert into employees (name, valid_from) values ('John Doe', '2020-04-01 10:00:00.00+0200');

select name, to_char(valid_from, 'yyyy-mm-dd hh24:mi:ss[TZ]') from employees; 
-- 2020-04-01 08:00:00[UTC]

A különbség csak a create table utasításban van, a típus itt timestamp with time zone.

Lekérdezéskor a különbség annyi, hogy with time zone esetén amikor lekérdezünk, akkor figyelembe veszi a session időzóna beállítását. Nézzünk is rá egy összehasonlítást. Az első példa with time zone nélkül.

set time zone 'Europe/Budapest';

drop table if exists employees;

create table employees (id int8 generated by default as identity, 
  name varchar(255), valid_from timestamp);

insert into employees (name, valid_from) values ('John Doe', '2020-04-01 10:00:00.00[UTC]');

select name, valid_from from employees; 
-- 2020-04-01 10:00:00

UTC-ben szúrunk be 10:00 órát, és bár a session időzóna UTC+2, mégis 10:00 órát kapunk vissza.

És most nézzük meg with time zone típussal:

set time zone 'Europe/Budapest';

drop table if exists employees;

create table employees (id int8 generated by default as identity, 
  name varchar(255), valid_from timestamp with time zone);

insert into employees (name, valid_from) values ('John Doe', '2020-04-01 10:00:00.00[UTC]');

select name, valid_from from employees; 
-- 2020-04-01 12:00:00+02

A különbség csak a create table utasításban van, a típus itt timestamp with time zone.

Ekkor a visszaadott érték 2020-04-01 12:00:00+02, azaz 12:00 óra, ráadásul időzóna megjelöléssel (+02). Azaz itt figyelembe veszi az időzónát. De az adatbázisban nem tárol időzónát!

Java SE

Nézzük az időzónák kezelését Javaban.

A JDK az alapértelmezett időzónát az operációs rendszertől kéri le. Ezt le lehet kérni pl. a ZoneId.systemDefault() metódussal. Ezt parancssorból a -Duser.timezone=UTC megadásával felül is tudjuk bírálni. Az elérhető időzónák lekérdezhetőek a ZoneId.getAvailableZoneIds() metódussal.

A régi Date osztály nem tárol időzónát. Egy időpillanatot reprezentál UTC-ben, valójában az “epoch”-tól eltelt ezredmásodpercek számát tárolja. Lehetőleg ezt a típust már modern alkalmazásokban ne használjuk! Ezért ezt a típust nem is fogom vizsgálni. Akit érdekel, a posztban leírt teszteseteket kipróbálhatja. Azonban a hibakereséshez érdemes ismerni, mert az időt a JDBC Driver kaphatja java.sql.Date vagy Timestamp típusként is, amely mindkettő java.util.Date leszármazott. Az a speciális tulajdonsága van, hogy a toString() metódusát úgy implementálták, hogy figyelembe veszi a JVM időzónáját, és abban írja ki. A LocalDateTime tárol dátumot és időt, időzóna nélkül. A ZonedDateTime reprezentál egy időpillanatot, és tárolja hozzá az időzónát is.

Spring Boot alkalmazás

És most nézzük meg egy komplett Spring Boot alkalmazást REST API-val. Alapesetben a JSON (de)szerializációt a Jackson library végzi. Az adatbázis réteget Spring Data JPA-val implementáltam, mely alatt Hibernate dolgozik, ez hozza létre az adatbázis sémát is. PostgreSQL adatbázist használtam. A példa projekt elérhető a GitHubon.

Az érdekesség kedvéért az alkalmazás Windowson fut CEST időzónában, míg a PostreSQL UTC-ben, egy Docker konténerben.

Az Employee entitás tartalmaz egy validFrom attribútumot.

Alkalmazás

Controller réteg

Az attribútum először legyen LocalDateTime típusú.

Alkalmazott létrehozásához a http://localhost:8080/api/employees címre kell a következő JSON-t post metódussal elküldeni.

{
  "name": "John Doe",
  "validFrom": "2020-04-01T10:00:00"
}

Amennyiben időzónát is megadok, a Jackson kivétellel elszáll. Helyes érték esetén, ha a szerver oldalon kiíratom a mező értékét, szintén 2020-04-01T10:00:00 értéket kapok, azaz egy az egyben letárolja a változóban.

Majd kipróbáltam, hogy a következő Jackson paramétert állítottam be az application.properties állományban.

spring.jackson.time-zone=UTC

Itt bármit is állítottam be, nem volt hatással az alkalmazás működésére, tehát LocalDateTime esetén azt tárolja el, ami jön.

Majd a típust átállítottam ZonedDateTime típusra.

Ekkor már az előző JSON-t el sem fogadja, kivételt dob. Mindenképp meg kell adni az időzónát is.

{
  "name": "John Doe",
  "validFrom": "2020-04-01T10:00:00+02:00"
}

Ekkor átváltja UTC-re (attól függetlenül, hogy a JVM CEST-ben volt), és már Java oldalon így jelenik meg: 2020-04-01T08:00Z[UTC]. (Érdekesség, hogy a z karakter arra utal, hogy a NATO által használt fonetikus ábécében Zulunak mondják. Ugyanis a UTC+1 az Alpha time, a UTC-2 a Bravo time, és így tovább a Zulu time-ig, ami a UTC.)

Majd a következő beállítást használtam az application.properties fájlban:

spring.jackson.time-zone=Europe/Budapest

Ezután szerver oldalon mindig CEST-re konvertálta az időt. Sőt, a beállítás előtt minden időt UTC-ben adott vissza, a beállítás után minden időt CEST-ben adott vissza.

Repository réteg

A repository rétegben történő átváltások már kicsit bonyolultabbak. Nézzük, hogy milyen lépésekből áll, és hogy lehet ezeket debuggolni:

  • Entitásban megjelenik a LocalDateTime vagy ZonedDateTime típus. Ennek értékét kiírattam. Szerencsére ez ISO-8601 szabvány szerinti és nem függ a JVM időzónájától. Valamint a biztonság kedvéért kiírattam a JVM időzónáját is.
  • Az entitás átadásra kerül a Hibernate-nek
  • A Hibernate képes a paraméterek naplózására is, a következő beállítások bekapcsolásával az application.properties állományban.
logging.level.org.hibernate.SQL=debug 
logging.level.org.hibernate.type=trace 

Sajnos ez túl sokat nem ér, ugyanis egy az egyben az entitásban szereplő értéket írja ki.

  • A Hibernate átkonvertálja az entitásban szereplő értéket Timestamp típussá. Majd meghívja a JDBC Driver PreparedStatement.setTimestamp(int, Timestamp) metódusát. Ezt úgy ellenőriztem, hogy a megnyitottam a JDBC Driver PgPreparedStatement osztályát, és breakpointokat helyeztem el.
  • Az érdekesség akkor történt, mikor az application.properties állományban a következő beállítást írtam:
spring.jpa.properties.hibernate.jdbc.time_zone=UTC 

Ezzel az adatbázis időzónáját mondom meg a Hibernate-nek. Ekkor azonban a setTimestamp(int, Timestamp, Calendar) metódus került meghívásra. Látható, hogy ez egy Calendar-t, melyben be van állítva az időzóna (az application.properties fájlban lévő), és a paraméterként kapott, UTC-ben tárolt Timestamp értéket erre az időzónára alakítja. Ezt a Timestamp és Calendar értéket folyamatosan figyeltem.

  • Biztos ami biztos elhelyeztem egy P6Spy wrappert/JDBC Drivert is, ami naplózza az összes keresztülmenő forgalmat. Sajnos ettől nem sokkal lettem okosabb, mert csak a paraméterül átadott Timestamp-et írja ki, ráadásul saját SimpleDateFormat-tal. Ez amúgy egy hasznos eszköz, és Spring Boot alkalmazás esetén elég felvenni a com.github.gavlyukovskiy:p6spy-spring-boot-starter függőséget.
  • Az érdekesség a JDBC Driveren belül történik. Amennyiben a setTimestamp() metódus nem kap Calendar objektumot, úgy a JVM időzónája alapján konvertálja String típussá a paraméterként kapott Timestamp értéket. Amennyiben kap Calendar objektumot (a Hibernate beállítással), akkor annak az időzónájára konvertál. A JDBC Driver ezt a String értéket küldi tovább az adatbázisnak.
  • Ha az adatbázisban lévő mező típusa timestamp, akkor levágja az offsetet, és csak az idő értéket veszi figyelembe. Ha viszont timestamp with time zone, akkor figyelembe veszi a session alapján, és UTC-re konvertálja. Ráadásul a session időzónája a JVM időzónájával egyezik meg a PostgreSQL JDBC drivernél.

Nézzük meg, hogy mitől függhet az időzónák kezelése a repository rétegben:

  • Entitásban lévő attribútum típusa: LocalDateTime vagy ZonedDateTime.
  • Adatbázisban milyen típust használunk: timestamp vagy timestamp with time zone
  • Mi a JVM időzónája
  • A Hibernate kap-e spring.jpa.properties.hibernate.jdbc.time_zone beállítást

Tesztesetek

Nézzük meg sorban a különböző kombinációkat!

Ha az attribútum típusa LocalDateTime, akkor a Hibernate adatbázisban automatikusan timestamp típussal hozza létre a hozzá tartozó oszlopot. A JVM időzónája az alapértelmezett Europe/Budapest, és nincs Hibernate beállítás (1. eset).

Ekkor az adatbázisba a beküldött 10:00 érték került. Az első példánál nézzük végig a teljes folyamatot. A JSON-ban beküldött 10:00 érték 10:00 LocalDateTime lett. Mivel a JVM időzónája Europe/Budapest, ezt egy 8:00[UTC] Timestamp értékké konvertálja a Hibernate. (Ha konzolra kiíratjuk, 10:00 jelenik meg, mert a toString() figyelembe veszi a JVM időzónáját.) Mivel nincs megadva Hibernate paraméter, ezért a JDBC Drivernek nem kerül Calendar átadásra, ezért ezt az értéket JVM default Europe/Budapest időzónájára konvertálja, ami 10:00+02. Mivel az adatbázisban a típus timestamp, a végét levágja, azaz 10:00 értékként tárolja el.

Majd az application.properties állományban a spring.jpa.properties.hibernate.jdbc.time_zone értékét UTC-re állítottam (2. eset). Ekkor az adatbázisba a 8:00 érték került. Ez azért történt, mert a 8:00[UTC] Timestamp String-gé konvertáláskor egy UTC Calendar át lett adva, ezért a String 8:00+00 lett.

Ha a -Duser.timezone parancssori paramétert UTC-re állítottam, akkor 10:00 került az adatbázisba (3. eset). A 10:00-át ekkor UTC-ben értelmezte, ezért a Timestamp 10:00[UTC] lett, amit 10:00+00 String-gé alakított át, hiszen nem kellett konverzió, mert a JVM időzóna UTC.

A következő kísérlet az volt, hogy átállítottam az adatbázisban a mező típusát timestamp with time zone értékre. Ez úgy a legegyszerűbb, hogy a JPA sémagenerálását annotációval konfiguráltam. A validFrom mezőre a @Column(name = "valid_from", columnDefinition = "timestamp with time zone") JPA annotációt tettem (4. eset). Az adatbázisba került érték ekkor a 8:00[UTC] lett. Így a JDBC String 10:00+02, hiszen CEST-re kellett átváltani, de ezt megfelelően UTC-re konvertálta az adatbázis.

Ekkor hiába állítgattam a JPA beállítást (spring.jpa.properties.hibernate.jdbc.time_zone), nem volt hatással a működésre. Hiszen itt mindig küldött offsettet, bármilyen cél időzónába is kellett átváltani, és azt megfelelően kezelte le az adatbázis.

Ha viszont beállítottam a -Duser.timezone=UTC JVM paramétert, akkor 10:00[UTC] érték került be. Ez logikus, hiszen már a entitásnál 10:00 volt UTC-ben értelmezve, így a Timestamp is ez lett (5. eset).

A következő kísérlet, mikor nem használok semmilyen konfigurációt, de a típust átállítottam ZonedDateTime típusra. Ekkor a sémageneráláskor még mindig egyszerű timestamp típussal hozza létre a mezőt (6. eset).

Innentől kezdve mindig jó érték szerepelt az entitásban, és a Timestamp is mindig 8:00[UTC]. Az adatbázisba a 10:00 került. Ekkor a JVM időzónájába váltotta, azonban a +02:00-t levágta az adatbázis.

A 8:00 érték kerül be az adatbázisba, ha a spring.jpa.properties.hibernate.jdbc.time_zone konfigurációs paraméter értékét állítjuk UTC-re (7. eset). Hiszen ekkor UTC-re konvertálva 08:00+00 lesz a String.

Majd a JVM időzónáját átállítottam UTC-re a -Duser.timezone=UTC paraméterrel. Ekkor a String 8:00 és az adatbázisba a jó 8:00 érték kerül bele (8. eset).

Végül pedig Java oldalon maradt a ZonedDateTime, és adatbázis oldalon a timestamp with time zone. Ekkor is helyesen került az adatbázisba (9. eset). A működésen ekkor sem a Hibernate konfigurációja, sem a JVM időzónája nem változtatott. Ez azért van, mert a String-be mindig bekerült az offset (mikor melyik), és ezt a timestamp with time zone miatt a PostgreSQL mindig figyelembe is vette.

Tesztesetek

Összefoglalás

  • Ha ZonedDateTime típust és timestamp with time zone beállítást használunk, akkor minden helyesen fog működni, ugyanis végig tárolva és feldolgozva lesz az időzóna.
  • Ha LocalDateTime típust használunk, ott nincs időzóna tárolva, azaz bejátszhat a Jackson konfiguráció és a JVM időzónája is.
  • Ha timestamp típust használunk, akkor vigyázni kell, hogy legyen beállítva a Hibernate konfiguráció, vagy legyen a JVM megfelelő időzónában.