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.
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
LocalDateTimevagyZonedDateTimetí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
Timestamptípussá. Majd meghívja a JDBC DriverPreparedStatement.setTimestamp(int, Timestamp)metódusát. Ezt úgy ellenőriztem, hogy a megnyitottam a JDBC DriverPgPreparedStatementosztá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átSimpleDateFormat-tal. Ez amúgy egy hasznos eszköz, és Spring Boot alkalmazás esetén elég felvenni acom.github.gavlyukovskiy:p6spy-spring-boot-starterfüggőséget. - Az érdekesség a JDBC Driveren belül történik. Amennyiben a
setTimestamp()metódus nem kapCalendarobjektumot, úgy a JVM időzónája alapján konvertáljaStringtípussá a paraméterként kapottTimestampértéket. Amennyiben kapCalendarobjektumot (a Hibernate beállítással), akkor annak az időzónájára konvertál. A JDBC Driver ezt aStringé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 viszonttimestamp 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:
LocalDateTimevagyZonedDateTime. - Adatbázisban milyen típust használunk:
timestampvagytimestamp with time zone - Mi a JVM időzónája
- A Hibernate kap-e
spring.jpa.properties.hibernate.jdbc.time_zonebeá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.
Összefoglalás
- Ha
ZonedDateTimetípust éstimestamp with time zonebeá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
LocalDateTimetí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
timestamptí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.

