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
LocalDateTime
vagyZonedDateTime
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 DriverPreparedStatement.setTimestamp(int, Timestamp)
metódusát. Ezt úgy ellenőriztem, hogy a megnyitottam a JDBC DriverPgPreparedStatement
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á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-starter
függőséget. - Az érdekesség a JDBC Driveren belül történik. Amennyiben a
setTimestamp()
metódus nem kapCalendar
objektumot, úgy a JVM időzónája alapján konvertáljaString
típussá a paraméterként kapottTimestamp
értéket. Amennyiben kapCalendar
objektumot (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:
LocalDateTime
vagyZonedDateTime
. - Adatbázisban milyen típust használunk:
timestamp
vagytimestamp 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.
Összefoglalás
- Ha
ZonedDateTime
típust éstimestamp 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.