Az idő és a dátum ábrázolása az informatikában kissé zűrös témakör és figyelmetlenségből vagy tudáshiányból fakadó hibák jó nagy galibákat tudnak okozni. A Java nyelv eredeti dátum/idő API-ja ráadásul hírhedten nehézkes és rosszul tervezett volt, a java.util package-ben lévő dátum/idő API-t legjobb messzire elkerülni. A problémákat a Java 8 új dátum/idő API-ja szerencsére megoldotta, de persze lehet rosszul használni és rosszul tervezni egy rendszert ezzel is. (Érdemes az alapokhoz elolvasni a Java 8 cikkem dátum/idő kezeléssel foglalkozó részét.)
Ebben a cikkben annak járok utána, hogyan érdemes Spring környezetben REST/RPC API-kat fejlesztve és MySQL adatbázist használva megtervezni és használni az időzóna- és idő formátum-kezelést. Minden példát Java 21 alatt teszteltem, MySQL 8.3-as adatbázissal, a Spring/Hibernate példákat a Spring Boot 3.3.4-es változatával, Hibernate 6.5-tel . Minden MySQL leírás a MySQL 8.3-as verziójára vonatkozik. Mivel az időzóna és dátum/idő típusok kezelése adatbázisonként eltérhet, ez a leírás erősen épít a MySQL adatbázisra és a MySQL típusait fogom tárgyalni részletesebben.
Mielőtt azonban a konkrétumokra rátérnék, meg kell ismerkedni az időzónákkal kapcsolatos alapfogalmakkal, mert ezek ismerete elengedhetetlen a megfelelő tervezési döntések meghozatalához. Az idő kezelése nagyon bonyolult, aki nem hisz nekem az nézze meg ezt a listát.
Időzóna-kezelési alapfogalmak
Az időszámítási módszerek, időzónák kialakulása érdekes témakör, itt most összefoglalva elég annyi, hogy az egységes és pontos időszámítást és időzónákat a hajózás, később pedig a vasúti közlekedés megjelenése tette elengedhetetlenül szükségessé.
Úgy tűnik, az alább tárgyalt fogalmak magyar megfelelőjében van némi kavarodás, én nem vagyok a téma szakértője, megpróbálom a lehető legjobb kifejezéseket használni, de lehetnek a leírásban pontatlanságok.
GMT
A vasúti utazás és hajózás terjedésével összefüggő különféle időszámítások és időzónák kavarodása tette szükségessé az egységes rendszer létrehozását. Ezért létrejött egy Nemzetközi Meridián Konferencia 1884-ben Washingtonban. Ez a konferencia azt a döntést hozta, hogy az angliai Greenwichben lévő királyi obszervatórium helyi ideje legyen az a kiindulási időzóna, amihez a többi időzóna idejét viszonyítják. Ezt az időzónát nevezzük Greenwich Mean Time-nak (greenwichi középidő, GMT). A helyszín kiválasztásában szerepet játszott, hogy addigra a térképek kb. 2/3-a ezt használta kezdő meridiánként (itt halad át a kezdő délkör, amelyet nulla hosszúsági fokkal jelölnek). Nagy-Britanniának pedig több hajózása és hajója volt ami ezt használta meridiánként mint a világ többi részének együttvéve. Emellett a Greenwich-i Obszervatórium állította elő a legjobb minőségű adatokat.
UT
A közös egyezményes világidőt, amihez mindent viszonyítanak (például az időzónákat) szintén ezen a konferencián határozták meg. Ehhez is a GMT-t választották alapként és ezt nevezték universal time-nak (UT). Ezt csillagászati mérések alapján határozták meg és különböző altípusai vannak (UT0, UT1, stb) attól függően hogy milyen mérést vesznek alapul. A Föld forgása nem eléggé egyenletes és lassul is, emiatt növekszik egy nap átlagos hossza. A Föld lassulásának mértéke előre nem megjósolható, egy adott nap pontos hosszát mindig csak utólagos méréssel lehet meghatározni.
Az 1950-es években jelentek meg az atomórák, ezek használata egyszerűbb volt mint csillagászati mérésekkel meghatározni a pontos időt, emellett stabilabb időjelet is szolgáltattak. A nemzetközi atomóra időt TAI (International Atomic Time)-nak hívják. Az atomóra alapján definiálták az ún. SI-másodpercet, vagyis azt, hogy mennyi a világszerte elfogadott egy másodperc hossza. (Ha valakit furdal a kíváncsiság: „1 s az alapállapotú (0 K) 133Cs céziumatom két hiperfinom energiaszintje közti átmenethez tartozó sugárzás 9 192 631 770 rezgésének idõtartama”.)
UTC
Kiderült viszont, hogy az atomórák által adott idő és a Föld forgása alapján meghatározott idő eltér egymástól. Így a Föld forgása alapján (utólag) meghatározott másodperc hossz eltért a TAI által adott másodperc hosszától. Mivel UT-ből a fentebb írtak alapján akkoriban már többféle is volt, a 60-as években megjelent az igény arra, hogy egy világszerte egységes időmérési szabványt hozzanak létre ami valahogyan összeegyezteti az UT-t és a TAI-t és kiküszöböli az UT másodperc-hossz ingadozását. Ez lett 1972-ben az UTC.
Ez a 24 órási időszabvány lényegében a TAI-n alapul viszont időnként a Föld forgásából adódóan szökőmásodperceket iktatnak be. Ez fontos különbség abból a szempontból, hogy míg a TAI folytonos, az UTC a szökőmásodpercek miatt nem az. Egy TAI nap mindig 86400 SI másodperc, az UTC viszont lehetővé teszi, hogy egy nap 86399 vagy 86401 SI másodpercből álljon, de emellett szinkronban maradjon a Naphoz képest. A szökőmásodperces helyesbítések biztosítják, hogy a csillagászati UT1 és az UTC-ben megvalósított polgári időskála ne térjen el egymástól 0,9 másodpercnél jobban. A beiktatás gyakorisága a Föld forgásának rendszertelensége miatt maga is ingadozik: 1972 óta 27 alkalommal került sor szökőmásodperc beiktatására, eddig mindig pozitív irányban.
Tudományos számításoknál ahol több éves időszakok számítására van szükség, UTC helyett TAI-vel szoktak számolni. Érdekesség még, hogy a TAI lassacskán elveszíti a szinkronját a Föld forgási idejéből számított idővel, hiszen a TAI-ban nincsenek szökőmásodpercek. A TAI időskálához képest az UT1 ma már majdnem 40 másodperc késésben van.
Az UTC bevezetésével az egyezményes világidő alapja tehát már nem a GMT időzóna volt többé. Sokan felcserélik vagy összekeverik az UTC-t és a GMT-t. Bár a gyakorlatban a GMT és az UTC ugyanazt az időt mutatják, alapvető eltérés van a kettő között:
- a GMT egy időzóna, amit hivatalosan használnak néhány európai és afrikai országban. Az időt 12 vagy 24 órás formátumban is mutathatja.
- az UTC nem időzóna hanem egy szabvány ami a polgári időszámítás és az időzónák alapja 24 órás formában. Vagyis nincs olyan ország vagy terület ami hivatalosan az UTC-t használná helyi időnek.
Sem az UTC sem a GMT nem változik a nyári időszámításra, ugyanakkor van néhány ország ami GMT-t használ és a nyári időszámításnál átvált másik időzónába. Például az Egyesült Királyság nem GMT-t használ egész évben, egy órával GMT előtt van a nyári hónapokban (British Summer Time – BST).
Időzóna (time zone)
Az időzóna a helyi megfigyelt idő meghatározására szolgáló szabályok összessége, és hogy az hogyan viszonyul az UTC időhöz egy adott földrajzi régióban. Az időzónákat is a közlekedés fejlődése miatt kellett definiálni. Az egyenlítőn elegendő 28 kilométert megtenni keleti vagy nyugati irányba, hogy a megfigyelt helyi dél ideje egy perccel módosuljon (északabbbra és délebbre haladva a távolság még kisebb).
Bár az időzónákat általában hosszúsági körönként (meridiánonként) való beosztásban képzeljük el, valójában egy időzóna általában egy ország vagy egy országrész és így szabálytalan alakú terület. A legtöbb időzóna egy óra széles és a közepén lévő helyi időt használja az egész régió. Így az emberek által észlelhető legnagyobb eltérés a helyi időhöz képest nagyjából fél óra lehet ami elég kis érték ahhoz, hogy a legtöbb ember ne vegy észre a különbséget a tényleges és a megfigyelt idő között. Az időzónák ideje az UTC-től többnyire egész órával különbözik, de vannak fél- és negyedórás eltérésű időzónák is. A másodperc számértéke mindenütt azonos. A legnagyobb pozitív eltérés az UTC-től a Pacific/Kiritimati időzóna (UTC+14:00), a legkisebb negatív pedig a Pacific/Honolulu (UTC-11:00). Létezik UTC-12:00 zóna is de az lakatlan. Több időzóna is rendelkezhet ugyanazokkal a szabályokkal: például az Europe/Berlin és az Europe/Budapest két külön időzóna de mindkettő ugyanazzal az ofszettel (idő eltolással) és nyári/téli időszámítással rendelkezik. Fontos különbség: egy időzónához mindig tartozik egy ofszet (például +03:00) de egy ofszet önmagában még nem adja meg az időzónát.
Van olyan eset amikor egy országból már meghatározható az időzóna. Sok esetben kisebb országok (de akár még nagyobbak is, pl Kína) teljes egészében egy időzónában vannak. Ezt gyakorlati okokból választották. Vannak speciális esetek amikor egy országnak tengerentúli gyarmatai vagy messze lévő tengeri területei vannak, például Chile, Új Zéland, Portugália. Még speciálisabb eset Franciaország. Ezek esetén az ország alapján még nem egyértelmű az időzóna. Nagyobb országok esetén az ország és az ofszet általában már egyértelműen megadja az időzónát. De például Arizóna időzónája nem határozható meg az időből, az országból és az ofszetből sem.
Időzóna azonosítók
Az időzóna szabályok legteljesebb gyűjteménye a TZ adatbázis (Olson vagy IANA időzóna adatbázisnak is hívják). Ezt használja sok kereskedelmi UNIX operációs rendszer, Linux, Java és sok rendszer és könyvtár. A Windows persze nem, az a saját egyedi rendszerét használja. A TZ adatbázisban az időzónáknak általában olyan azonosítójuk van ami egy régiót és egy városnevet tartalmaz. A városnév általában olyan ami az adott időzónát használó városok közül a legismertebb. Az adatbázis egy időzónához általában több aliast is tartalmaz. Például „Asia/Ulan Bator” megegyezik ezzel: „Asia/Ulaanbaatar”.
Nyári időszámítás (angolul DST – daylight saving time)
Egyes időzónákban van átállás nyári időszámításra, másokban nincs. Általában az egyenlítőhöz közelebbi területeken nincs rá szükség. A DST az alábbi szabályok összessége:
- eltolás mértéke: az az idő amennyivel az órát meg kell változtatni amikor a nyári időszámítás kezdődik vagy végetér. Ez a legtöbb helyen egy óra, de például Ausztráliában van olyan hely ahol csak fél óra.
- kezdő és befejező nap: az a nap amikor a DST kezdődik és az amikor végetér. Ez sem mindenhol egységes. A legtöbb helyen tavasszal van a kezdő és ősszel a befejező nap. De az északi és a déli féltekén fordítva van a tavasz és az ősz, ezért a napok is eltérhetnek. Még azonos ofszetet használó időzónákban is eltérő lehet. Sőt, ezek a napok is változhatnak. 2007 előtt az Egyesült Államokban minden időzónában április első vasárnapján hajnali 2-kor kezdődött a nyári időszámítás, 2007-ben viszont ezt március második vasárnapján hajnali 2-re módosították.
- alkalmazási dátumok: még ha adott régiókban ma azonosak is az ofszetek és a DST, lehet hogy a múltban ezekre eltérő szabályok vonatkoztak. A múltbeli időbélyegek helyes kezeléséhez ezeket a régiókat is külön időzónaként kell kezelni. Például Dél-Korea és Japán ma ugyanazt az ofszetet használja és egyik sem alkalmaz nyári időszámítást, de Japánban a nyári időszámításra átállás már 1951-ben megszűnt, Dél-Koreában viszont 1988-ban. Ha tehát olyan alkalmazást fejlesztünk ami ilyen régre visszanyúló időértékekkel is dolgozik, lehet hogy külön kell kezelni ezeket az időzónákat.
A nyári időszámítás megtöri a folytonosságot és az egyértelműséget is:
- a téliről a nyári időszámításra átállás egy órás lyukat okoz éjszaka (nálunk március utolsó vasárnapján). Emiatt például az olyan időpont mint 2024-03-31T02:30:00 a közép-európai időzónában érvénytelen. Amikor például egy rendszer periodikusan adatokat gyűjt, ebben a tartományban nem lesznek bejegyzések. Ha mégis vannak, az nem létező időt mutat.
- a nyáriról téli időszámításra átállás egy órás átfedést jelent éjszaka (október utolsó vasárnapján). Emiatt például az olyan időpont mint a 2024-10-27T02:30:00 nem egyértelmű mivel nyári és téli időszámítás alatt is megjelenhet. Amikor például egy rendszer periodikusan adatokat gyűjt, ebben a tartományban több egyforma időponttal létrejött bejegyzés is lehet és kiegészítő információ tárolása nélkül eldönthetetlen, hogy az azonos időpontú bejegyzések közül melyik következett be korábban.
Tovább ront a helyzeten, hogy az egész még csak nem is determinisztikus. Az óraátállítást politikai döntések befolyásolhatják és folyamatosan figyelni kell az ilyen döntéseket és frissíteni a szabálykönyvet. Ráadásul néhány programozási nyelv és keretrendszer ami támogatja az időzónákat, még nem biztos, hogy támogatja a nyári időszámítást is.
Dátum és idő ábrázolása
Dátum és idő ábrázolásához több nemzetközileg elfogadott módszer is van. Alapvetően kétféle: inkrementális (epoch alapú) és mező alapú ábrázolás létezik.
Unix idő (epoch idő)
Inkrementális időábrázolás, az időt fix egész egységekkel méri amelyek egy adott időponttól monoton módon növekednek. Ez az időpont az epoch, bár magyarul epocha-nak írják, én maradok az informatikában elterjedt epoch-nál. Az epoch a wikipédia szerint egy meghatározott időpont, amihez a naptárhasználó népek az időszámításukat igazítják. A szó jelentése: korszak. Ettől a kezdőponttól számlált időadatok összessége az éra.
Nos a legalább Java 8-at használó népek számára a szabványos Java epoch 1970-01-01T00:00:00Z
Az inkrementális ábrázolásban egy szám (int vagy long) reprezentációja az aktuális dátumnak/időnek, ezt hívják instantnak, például:
1725882354
Ez megmutatja, hány másodperc telt el az UTC szerinti 1970 január elseje óta (a szökőmásodperceket nem számolva). Ezt a dátumot hívjuk epoch-nak, viszont kissé félrevezető a Unix időt epoch időnek hívni mert más hasonló rendszerek is léteznek amiknek viszont más az epoch-ja. Tehát helyesen: unix idő az aminek az epoch-ja 1970 január 1. 0 óra 0 perc 0 másodperc.
A legtöbb programnyelv és operációs rendszer inkrementális időt ad az időértékekkel való műveletekhez. Ezt általában nem látják a felhasználók, megjelenítésnél leképződik valamilyen emberi fogyasztásra is alkalmas formára, például mező alapú ábrázolásra. Bizonyos műveletek nagyon egyszerűek inkrementális időábrázolás esetén, például két instant összehasonlítása (melyik a korábbi és a későbbi) mert ez csak egész értékek összehasonlítását jelenti.
Mező alapú időábrázolás. A mező alapú ábrázolás általában valamilyen naptárrendszerhez kötődik és a dátumot/időt mezőkben (év, hónap, nap, óra, stb) ábrázolja. Tartalmazhat információt a használt időzónáról is.
Az ISO 8601 az internetes kommunikációban használt szabvány, ezt használja a W3C-től az IETF-ig minden szabvány/szervezet és a legtöbb programnyelv/keretrendszer is támogatja. Nekünk külön jó, hogy a dátum megegyezik a magyar sorrenddel. ISO 8601 szerinti ábrázolásra példa:
Ez a példa: 2024-09-09T16:52:15+01:00 azt jelenti, hogy a helyi idő szerint 16:52 van, és ez 1 órával több mint az UTC idő. Az ofszet helyett „Z” karakter is állhat:
2024-09-09T13:17:31Z
Ez jelzi az UTC szerinti időt. Az UTC idő jelölésére használják a Z betűt, ezzel Spring-ben is találkozhatunk. Ez azért van, mert a hozzá tartozó tengerészeti zóna jele Z. Mivel a NATO által használt fonetikus ábécében, és a rádióamatőröknél is a Z-t Zulu-nak mondják, ezért az UTC időt is így nevezik. Az ISO 8601 természetesen azt is lehetővé teszi, hogy elhagyjuk az idő részt és csak a dátumot jelenítsük meg.
A Java 21 dátumformátuma az ISO formátumból többféle egyéb változatot is származtat, ezek listája az előre definiált formázók között itt látható.
Hasonló az ISO 8601-hez, szintén széles körben használt mező alapú szabvány az internetes kommunikációban. Apróságokban tér el az ISO 8601-ről. Például az elválasztó T helyett szóközt is lehet alkalmazni a dátum és az idő elválasztásához és nem engedi meg a két számjegyből álló évszámot (míg az ISO 8601 igen). Ha valakit mélyebben érdekel a téma, ez egy jó kiindulópont. A felhasználási igénytől függ, hogy éppen melyiket használjuk, a unix idő gyakran kényelmesebb és gépek közötti kommunikációban gyorsabb, viszont az ISO formátum olvasható.
Lebegő idő
A W3C munkacsoportja az inkrementális és a mező-alapú ábrázolás mellett még egy ún. lebegő időt (floating time) is definiált. Ezeknél egy megfigyelt időérték nem kapcsolódik egy inkrementális idő adott pillanatához. Ezek adott hellyel együtt megadnak egy lehetséges inkrementális idő intervallumot, de nincsenek időzónához kapcsolva. Lebegő idejű események például: születési dátum, egy dokumentum közzétételi dátuma, a hivatalos ünnepek listája vagy egy ajánlat lejárati dátuma (ha nincs időzónához kötve). Bármely lebegő dátum egy 50 órás időperiódust fog át a Pacific/Kiritimati időzónától (UTC+14:00) a Pacific/Honolulu időzónáig (UTC-11:00).
Java dátum/idő API
A Java saját időskálát definiál, ez a Java Time-Scale. A Java időskála minden naptári napot pontosan 86400 részre oszt, ezt nevezi másodpercnek. A Java másodperc hossza eltérhet az SI másodperctől de eléggé közel van a fentebb tárgyalt de facto nemzetközi polgári időskálához. Az idővonal különböző szegmenseihez kissé eltérő Java időskála tartozik, de ezek mindegyike a polgári életben használatos nemzetközi időskálán alapul.
A JDK 8 bevezetése tájékán, 2013-ban a Java időskálának két szegmenst definiáltak. Az 1972-11-03-tól lévő szegmenshez további értesítésig a nemzetközi (szökőmásodperces) UTC időskála használatos. A Java időskála megegyezik az UTC-vel azokon a napokon ahol nincs szökőmásodperc. Ahol viszont van, a szökőmásodperc egyenletesen kiterjed a nap utolsó 1000 másodpercére, így megmarad a látszólag pontos napi 86400 másodperc. Ha az alkalmazásunknak figyelnie kell a szökőmásodpercekre akkor jó tudni, hogy a java.time API ezeket nem ismeri.
Az 1972-11-03 előtti szegmenshez használatos skála az UT1, ami megfelel a meridianon (Greenwich) lévő csillagidőnek. A két szegmens között a pontos határ az az időpillanat amikor az UT1==UTC vagyis 1972-11-03T00:00 és 1972-11-04T12:00 között.
Az alábbi táblázat összefoglalja a Java dátum/idő API osztályait (bővebben ebben a cikkben írtam róluk).
Osztály | Év | Hó | Nap | Óra | Perc | Másodperc1 | Zónaoffszet | Zóna ID | toString() kimenet |
---|---|---|---|---|---|---|---|---|---|
Instant | X | 2019-01-27T17:34:21.639Z | |||||||
LocalDate | X | X | X | 2019-01-27 | |||||
LocalDateTime | X | X | X | X | X | X | 2019-01-27T18:37:13.625 | ||
ZonedDateTime | X | X | X | X | X | X | X | X | 2019-01-27T18:38:02.224+01:00[Europe/Prague] |
LocalTime | X | X | X | 18:38:56.748 | |||||
MonthDay | X | X | --01-27 | ||||||
Year | X | 2019 | |||||||
YearMonth | X | X | 2019-01 | ||||||
OffsetDateTime | X | X | X | X | X | X | X | 2019-01-27T18:39:57.043+01:00 | |
OffsetTime | X | X | X | X | 18:40:17.382+01:00 | ||||
Duration | 2 | 2 | 2 | X | PT-10H (-10 óra) | ||||
Period | X | X | X | 3 | 3 | P2D (2 nap) |
1: A másodpercek nanoszekundumos pontossággal
2: Az osztály nem tárolja ezt az információt, de vannak olyan metódusai, amelyekkel megkapható az idő ezekben az egységekben
3: Amikor egy Period hozzáadódik egy ZonedDateTime-hoz, nyári időszámítási vagy más helyi időbeli eltérések jelentkezhetnek
A probléma rétegei
Az időzóna kezelése elég trükkös lehet nem csak az API-k számára, hanem mobilalkalmazások és elosztott webes alkalmazások számára is. Különösen a modern felhő-alapú architektúrák esetén, ahol nem szokatlan hogy az alkalmazások különböző rétegei eltérő időzónában futnak. Ráadásul vannak olyan országok is amik több időzónában fekszenek. Ilyen az Egyesült Államok, Kanada, Oroszország, Ausztrália, de például Kína nem.
Lássunk néhány példát arra, milyen gondokat okozhat az időkezelés alkalmazásokban.
- taxi foglaló alkalmazást fejlesztünk amit az USA-ban fognak használni ahol több időzóna is van. Egy felhasználó foglal egy taxit, a kocsi viszont épp egy másik időzónában van ahol az időszámítás már 1 órával a felhasználó órájához képest előrébb jár. Hibás időzóna kezelés esdetén előfordulhat, hogy a taxi azt fogja látni hogy van egy elmulasztott fuvarja, vagy rosszabb esetben soha nem is fogja látni hogy fuvarja lesz.
- egy telefonos alkalmazással két ember rendszeres telefonkonferenciát szeretne kezelni. Beállítják, hogy a konferencia a hét egy adott napján adott időben kezdődik. Ha a két fél nem ugyanabban az időzónában van és az alkalmazás rosszul kezeli az időzónákat, akkor lehet, hogy az értesítés valamelyik résztvevőnek túl korán, másnak túl későn fog érkezni. Más esetben az alkalmazás nem fogja beengedni a felhasználókat, mert úgy látja, hogy a megbeszélés „már végetért”.
- egy webshopban a felhasználónak születésnapi kedvezményt akarunk adni de amikor bejelentkezik a születésnapján, nem tudja használni a kedvezményt.
- egy weboldal a látogatói hűséget rögzíti az alapján, hogy a felhasználó mennyi időt töltött az oldalon de ezt rosszul kezeli. Amikor a felhasználó időzónák között utazik akkor az oldal nagy üres szakaszokat mutat a bejelentkezések között még akkor is ha a felhasználó egyébként minden nap bejelentkezett.
- egy üzletileg kritikus időzített háttérfolyamat futása nem történik meg de csak az év bizonyos napjain
Látható, hogy nagyon sok mindenre kell gondolni egy alkalmazás tervezésénél mert különben nagy galiba lesz az eredménye, ha a felhasználók eltérő időzónákban vannak amiket ráadásul még olyan tényezők is bonyolítanak mint a téli/nyári időszámítás.
Egy webes alkalmazás általában legalább három rétegből áll:
- böngésző (frontend)
- web szerver (backend)
- adatbázis
Ezek mindegyike eltérő időzóna szerint működhet ami a dolgot még rosszabbá teszi a szoftverfejlesztés szempontjából. Induljunk el az alaptól: az adatbázis rétegtől. A frontenddel ebben a cikkben csak érintőlegesen foglalkozok.
MySQL dátum/idő kezelés
Mielőtt egy sor Java kódot írnánk, legalább nagyvonalakban meg kell ismerni az adatbázisunk dátum/idő kezelését. Ebben a cikkben a MySQL adatbázis-kezelőn keresztül tárgyalom a problémát, más adatbázis-kezelő használata esetén nyilván az adott rendszer ismeretére van szükség.
MySQL időzónák
Még sehol sem vagyunk a Java-hoz, pláne a Hibernate-hez és a Springhez, máris az alábbi időzónákkal kell tisztában lennünk:
- adatbázisszerver időzóna: ez egy globális time_zone változóban tárolódik. Alapértelmezett értéke „SYSTEM” ami azt jelenti, hogy megegyezik a rendszer (ahol az adatbázisszerver fut) időzónájával.
- session időzóna: minden kliens (például Java alkalmazás) ami csatlakozik az adatbázishoz, egy saját ún. session időzónát kap, ez a session time_zone változóban tárolódik. Ez a változó az értékét alapértelmezetten a globális time_zone változóból veszi, de a kliens tetszőlegesen megváltoztathatja.
- kliens időzóna: annak az alkalmazásnak az időzónája (nekünk itt most lényegében a JVM időzónája) ami az adatbázisszerverhez csatlakozik.
- eredeti időzóna: azok a Java adattípusok amelyek explicit vagy implicit módon tartalmaznak időzóna információt (például a java.util.Date vagy a java.time.ZonedDateTime)
MySQL utasítás az aktuális pontos idő lekérdezésére:
SELECT NOW();
MySQL utasítás az aktuáils időzóna beállítások lekérdezésére:
SELECT @@global.time_zone, @@session.time_zone;
Az adatbázisszerver időzóna beállítására több megoldás is létezik (konfiguráció, indítási paraméter, stb), tesztelési célokra a legegyszerűbb ezt a két utasítást használni, amik az újraindításig módosítják az időzónát:
SET GLOBAL time_zone = ‘Europe/Budapest’;
SET time_zone = ‘Europe/Budapest’;
MySQL dátum és idő adattípusok
Típus | Adat | Minimum érték | Maximum érték | Időzóna kezelés |
---|---|---|---|---|
YEAR | Négy számjegyű év | 1901 | 2155 | |
TIME | Időpont (óra:perc:másodperc.törtmásodperc) | -838:59:59.000000 | 838:59:59.000000 | |
DATE | Dátum | 1000-01-01 | 9999-12-31 | |
DATETIME | Dátum és idő | 1000-01-01 00:00:00.000000 | 9999-12-31 23:59:59.499999 | X |
TIMESTAMP | Dátum és idő | 1970-01-01 00:00:01.000000 UTC | 2038-01-19 03:14:07.499999 UTC | X |
A legfontosabb dolog, hogy nincs olyan MySQL dátum/idő adattípus aminél időzóna-információ is tárolódna az adatbázisban. Vagyis az adattal esetlegesen érkező eredeti időzóna-információt az adatbázisszerver eldobja. (Az adatbázis tehát lényegében lebegő időt tárol.) Ennek még lényeges következményei lesznek a későbbiekben. (A fenti táblázatban az „időzóna kezelés” oszlop tehát nem időzóna tárolást jelent, hanem a tárolni kívánt adat valamiféle időzóna szerinti értelmezését, erről lesz szó a későbbiekben.)
A TIME, DATETIME és TIMESTAMP típus megadásakor van egy opcionális paraméter is ami 0-6 közötti érték és a törtmásodperc pontosságát mutatja. A 0 azt jelenti, hogy nem tárolunk törtmásodpercet, ez az alapértelmezett, ha nem adjuk meg. A TIMESTAMP típus esetén az 1970-01-01 00:00:00 nem ábrázolható, mert ez 0 másodpercre van az epoch-tól, viszont a nulla timestamp érték ezt jelenti: 0000-00-00 00:00:00.
Csak a DATETIME és TIMESTAMP esetén van értelme az időzóna kezelésnek, minden más típus esetén mindig azt az értéket kapjuk vissza amit mentettünk.
A MqSQL dátum/idő tárolás megértéséhez fontos tisztában lenni az instant fogalmával. Az előző fejezetben láttuk, hogy az instant egy adott időpillanat (instantaneous point) az UTC időskálán. Ez akkor tekinthető megőrzöttnek, ha mindig ugyanarra az időpontra vonatkozik ha az értéket az adatbázisban tároljuk vagy lekérjük, függetlenül attól hogy az adatbázisszerver és a kliensek milyen időzónákban működnek. MySQL-ben a TIMESTAMP az egyetlen adattípus amit instantok tárolására terveztek. Az adatbázis tárolás előtt a TIMESTAMP típusú értéket mindig átkonvertálja a session időzónájából UTC-be, kiolvasáskor pedig átkonvertálja UTC-ből a session időzónájába (DATETIME esetén nincs konvertálás). Akár időzóna ofszetet is megadhatunk a TIMESTAMP értéknek, ilyenkor az UTC-re konvertálásnál nem a session időzónáját, hanem az ofszetet veszi alapul az adatbázis. De amint a tárolás megtörtént, az eredeti időzóna/ofszet információ elvész. A TIMESTAMP típusnál látható, hogy 2038-ban túlcsordul, ez egyre erősebb limitáció, ma ezt a típust leginkább már csak események időbélyegeinek tárolására használjuk (például created_at, modified_at vagy deleted_at jellegű mezőknél).
A DATETIME típus nem instantot reprezentál és ha nincs megadva időzóna ofszet akkor nincs konvertálás sem, tehát úgy tárolódnak el ahogy az adatbázis megkapta és úgy is kapjuk vissza. Ha viszont van megadva az értékben ofszet akkor a bemenő érték a tárolás előtt a session időzónájára konvertálódik. Ezért amikor a kiolvasás egy másik session-ben másik időzónával történik, akkor a DATETIME érték el fog térni az eredetileg mentett értéktől.
A Java-val való kapcsolatot a MySQL driver végzi a JDBC-n keresztül. Általánosságban elmondható, hogy bármely MySQL adattípus konvertálható Java sztringgé (amikor lekérdezzük a ResultSet-ből), tehát a dátum/idő típusok is (teljes referencia). Az alábbi MySQL adattípusok:
- DATE, TIME, DATETIME, TIMESTAMP
mindig konvertálhatók az alábbi Java adattípusokká:
- java.lang.String, java.sql.Date, java.sql.Timestamp
Azt talán mondanom sem kell, hogy dátum/idő információt adatbáizsban soha ne tároljunk varchar-ban. Ha csak dátumot vagy csak időt kell tárolnunk akkor használjuk a külön DATE vagy külön TIME típusokat (Java-ban is ennek megfelelő külön típusokat).
A JDBC drivert a MySQL dokumentációja Connector/J-nek hívja. Korábban a maven függősége még a mysql-connector-java artifact id alatt volt, de ezt átmozgatták mysql-connector-j alá. A legutolsó aktuális verzió:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.0.0</version>
</dependency>
A JDBC az alábbi dátum/idő adattípusokat kezeli. A java.sql-ben lévő dátum/idő osztályok lényegében csak wrapper osztályok a java.util.Date API-hoz (azokhoz hasonlóan nem tárolnak például időzóna információt). A MySQL megkülönböztet a Java típusok között instant és nem-instant típusokat, az alábbiakban azt is megadom, a Java típusok milyen SQL adattípusokba menthetők a JDBC API-val:
Java típus | MySQL típus | Instant? |
---|---|---|
java.sql.Date | DATE, DATETIME, TIMESTAMP | |
java.sql.Time | TIME | |
java.sql.Timestamp | DATE, TIME, DATETIME, TIMESTAMP | X |
java.util.Calendar | DATE, TIME, DATETIME, TIMESTAMP | X |
java.util.Date | DATE, TIME, DATETIME, TIMESTAMP | X |
java.time.LocalDate | DATE, DATETIME, TIMESTAMP | |
java.time.LocalTime | TIME | |
java.time.LocalDateTime | DATE, TIME, DATETIME, TIMESTAMP | |
java.time.OffsetTime | TIME | |
java.time.OffsetDateTime | DATE, TIME, DATETIME, TIMESTAMP | X |
java.time.ZonedDateTime | DATE, TIME, DATETIME, TIMESTAMP | X |
A JDBC API-ban a három java.sql adattípus beállítására specifikus metódusok szolgálnak a PreparedStatement-ben:
- setTime(int parameterIndex, Time x);
- setTime(int parameterIndex, Time x, Calendar cal);
- setDate(int parameterIndex, Date x);
- setDate(int parameterIndex, Date x, Calendar cal);
- setTimestamp(int parameterIndex, Timestamp x);
- setTimestamp(int parameterIndex, Timestamp x, Calendar cal);
A java.time adattípusokat pedig az általános setObject metódusokkal tudjuk beállítani. Ugyanezek getterként megvannak a ResultSet-ben is. Mint írtam, a MySQL szerver nem tárol időzóna információt, tehát ha például ZonedDateTime-ot mentünk akár TIMESTAMP, akár DATETIME típusba, a ZonedDateTime-ban lévő eredeti időzóna információt nem fogjuk visszakapni. Az SQL szabvány definiál ugyan egy TIMESTAMP_WITH_TIMEZONE típust, ezt azonban a MySQL nem támogatja. Ha az eredeti időzóna információt is meg szeretnénk tartani, akkor egyedi megoldásra van szükség (például azt egy külön oszlopba mentjük el).
Mivel a TIMESTAMP-től eltérő többi adattípus nem igazi instantot tárol, a típusok keverése nem várt eredményeket adhat. Például:
- amikor java.sql.Timestamp-et tárolunk mondjuk egy DATETIME oszlopba, és azt egy olyan klienssel olvassuk ki aminek eltér az időzónája attól amelyik azt letárolta, akkor előfordulhat, hogy nem ugyanazt az instantot fogjuk visszakapni.
- amikor egy java.time.LocalDateTime értéket mentünk egy TIMESTAMP oszlopba, előfordulhat, hogy nem a megfelelő UTC-alapú érték lesz letárolva mivel az érték időzónája LocalDateTime esetén nincs megadva.
Logikailag én az alábbi módon értelmeztem a dátum/idő tárolást. Itt létezik egy „connection időzóna” is a session időzóna mellett (a driverben), és a paraméterekkel majd lényegében ezt is vezérelhetjük (a paraméterek között van is connection időzóna módosítás). Ez alapján a java.sql.Timestamp tárolásának folyamata a JVM-től az adatbázisig TIMESTAMP típusba az alábbi ábrán szemléltethető:

Ha valamely lépés előtti és utáni időzóna megegyezik akkor igazából nincs is konvertálás, hiszen az idő és a timestamp is ugyanaz marad. Az első két doboz még a JVM-ben, a második két doboz pedig már az adatbázis-szerveren van. Úgy gondolom, a „connection időzóna” azért szükséges, mivel az adatbázis nem fogad időzónát a klienstől, vagyis a középső nyíl esetén bármit is küldünk az adatbázisnak, a session-be az úgy fog átkerülni, mint ahogyan mondjuk a JVM-ben a toString-et meghívva látnánk, de időzóna információ nélkül. A „connection időzóna” bevezetésével viszont lehetséges az, hogy beküldés előtt az értéket a megfelelő formára alakítsa a JDBC driver, így igény szerint megmaradjon az instant akkor is, ha azt konkrétan nem lehet az adatbázisba beküldeni. A MySQL szerver TIMESTAMP mentéskor és lekérdezéskor feltételezi, (hacsak nincs explicit időzóna megadva) hogy az instant értéke a session időzónájára vonatkozik (amit a time_zone változó állít be).
A java.sql.Timestamp tárolásának folyamata a JVM-től az adatbázisig DATETIME típusba az alábbi ábrán szemléltethető:

Itt csak annyi változik, hogy az utolsó lépésben nincs átkonvertálás UTC-be.
A fentiek értelmében egy instant értéke csak az alábbi esetekben marad meg:
- amikor a driver ugyanabban az időzónában fut, mint a MySQL szerver, a session időzóna ugyanaz mint a JVM (és a connection) időzónája, akkor nem szükséges semmilyen konvertálás, az instant mindig ugyanaz lesz. Persze csak akkor ha az adatbázis és a JVM időzónája a jövőben is ugyanaz marad.
- amikor a driver eltérő időzónában fut mint az adatbázis, vagyis a JVM/connection időzónája eltér a szerver session időzónájától, a driver az alábbiak egyikét teheti:
a, lekérdezi az adatbázistól a session időzónát és a timestamp-eket átkonvertálja a session időzónája és a JVM időzónája között (a connection időzóna lépésben)
b, megváltoztatja a session időzónát a JVM időzónájára és így már nincs szükség konvertálásra
c, megváltoztatja a session időzónát egy felhasználó által megadott időzónára, majd a timestamp-eket átkonvertálja a JVM időzónája és a felhasználó által megadott időzóna között (a connection időzóna lépésben)
A fenti esetek beállításához a MySQL driverben connection propery-ket vezettek be amikkel a működési módokat lehet vezérelni. A driver (connector) 8.0.23-as verziójától kezdve működnek az alábbi beállítások. (A korábbi verziók esetén a működés eltérhet, azok esetén az alábbi leírás nem mérvadó.)
- connectionTimeZone={LOCAL|SERVER|egyedi}: azt mondja meg, hogy a driver milyen módon határozza meg a session és a connection időzónáját (alapértelmezett működés, ha nincs megadva: LOCAL). Lehet:
- LOCAL: a driver az alábbi két eset közül választ, ahol a session időzónát
- be kell állítani a driver JVM időzónájára
- megegyezik a driver JVM időzónájával
- LOCAL: a driver az alábbi két eset közül választ, ahol a session időzónát
A két eset közül a forceConnectionTimeZoneToSession beállítás választ: ha true, akkor az 1-es eset történik, ha false akkor a 2-es. A connection időzóna itt mindenképpen a JVM időzónája lesz.
- SERVER: a session időzónáját a driver kérdezze le az adatbázistól. Ha a session időzóna eltér a JVM időzónájától és a preserveInstants=true, a driver konvertálni fog a session és a JVM időzónája között.
- egyedi: mi adjuk meg a paraméterben az időzónát. Ez esetben a driver feltételezi, hogy a session időzónája
- be kell legyen állítva a felhasználó által megadottra
- megegyezik a felhasználó által megadott időzónával
A két eset közül a forceConnectionTimeZoneToSession beállítás választ: ha true, akkor az 1-es eset történik, ha false akkor a 2-es. A driver (connector) 8.0.23-as verziója óta a korábbi serverTimezone paraméter már csak egy alias a connectionTimeZone paraméterhez. Ennek a paraméternek nincs hatása a forceConnectionTimeZoneToSession=false&preserveInstants=false beállítás esetén.
- forceConnectionTimeZoneToSession={true|false}: megmondja, hogy a session időzónáját (time_zone változó) be kell-e állítani a connectionTimeZone paraméterben megadott értékre. (Alapértelmezett működés, ha nincs megadva: false.) Ennek a paraméternek nincs hatása a connectionTimeZone=SERVER beállítás esetén (hiszen ez esetben mindenképpen a szerver beállítását használjuk).
- preserveInstants={true|false}: ezzel vezérelhetjük, az instant értékeket konvertálni kell-e a JVM és a „connectionTimeZone” között. (Alapértelmezett működés, ha nincs megadva: true.)
- false: nincs átalakítás, a timestamp úgy megy az adatbázishoz ahogy volt és a vizuális megjelenítése, nem pedig a tulajdonképpen idő instant tárolódik.
Példa:
- időzóna: JVM: UTC; session: UTC+1
- az eredeti timestamp a klienstől (UTC-ben): 2020-01-01 01:00:00
- a driver által az adatbázisnak küldött timestamp: 2020-01-01 01:00:00 (nincs konvertálás)
- az adatbázisban belsőleg tárolt timestamp: 2020-01-01 00:00:00 UTC (a belső konvertálás után: UTC+1 -> UTC)
- egy lekérdezés során visszakapott timestamp az eredeti időzónákkal (UTC+1 session időzóna): 2020-01-01 01:00:00 (miután az adatbázisban a 2020-01-01 00:00:00 visszakonvertálódott UTC -> UTC+1)
- a korábbitól eltérő, másik JVM időzónában visszakapott timestamp (mondjuk UTC+3-ban): 2020-01-01 01:00:00
Ebben az esetben tehát az idő instant nem lett megtartva.
- true: a driver megpróbálja megtartani az idő instantokat úgy, hogy a connection paraméterekben megadott beállítás alapján konvertál (connectionTimeZone és forceConnectionTimeZoneToSession paraméterek). Tároláskor átalakítás csak akkor történik, ha a cél adattípus TIMESTAMP. Lekérdezéskor átalakítás csak akkor történik, ha az oszlop adattípusa TIMESTAMP, DATETIME vagy sztring és a Java osztály amibe lekérdezünk, instant osztály (lásd fentebb).
Ennek a beállításnak nincs hatása, ha a connectionTimeZone=LOCAL mivel ez esetben a JVM és a session időzónája ugyanaz.
Ha ezen beállításokat nem adjuk meg a connection url-ben akkor lényegében ezt a működést kapjuk alapértelmezetten:
connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=false&preserveInstants=true
Korábban még létezett egy useLegacyDatetimeCode nevű URL paraméter is, de ezt MySQL 8-nál már kivették ezért fölösleges megadni.
Ha a TIMESTAMP adattípust használjuk akkor az instant értékek megtartásához a fentebb tárgyalt esetekben az alábbi connection beállítások szükségesek:
1-es eset: vagyis a session időzóna ugyanaz mint a driver JVM időzónája és nem kell időzóna konvertálás. Két beállítás közül választhatnuk:
- preserveInstants=false
- connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=false
Példa:
- időzóna: JVM és session: UTC+1
- eredeti timestamp amit a kliens akar menteni (UTC+1): 2020-01-01 01:00:00
- a driver által az adatbázisnak küldött timestamp (konvertálás nem szükséges): 2020-01-01 01:00:00
- az adatbázisban belsőleg tárolt timestamp: UTC-ben 2020-01-01 00:00:00 (belső konvertálás után: UTC+1 -> UTC)
- a driver által lekérdezéskor visszakapott érték, ahol a session időzóna UTC+1: 2020-01-01 01:00:00 (belső konvertálás után: UTC -> UTC+1)
- a driver által az alkalmazásnak visszaadott timestamp ugyanazon a JVM időzónán amin korábban mentették: 2020-01-01 01:00:00
Vagyis az instant értéke mindenféle konvertálás nélkül megmaradt.
2a eset: a szükséges beállítás:
- preserveInstants=true&connectionTimeZone=SERVER
A driver ez esetben lekérdezi az adatbázis session időzónáját és a timestamp-et a session időzóna és a JVM időzóna között konvertálja. Példa:
- időzóna: JVM: UTC+2, adatbázis session: UTC+1
- eredeti timestamp amit a kliens akar menteni (UTC+2): 2020-01-01 02:00:00
- a driver által az adatbázishoz küldött timestamp konvertálás után (UTC+2 -> UTC+1): 2020-01-01 01:00:00
- az adatbázisban belsőleg tárolt érték: UTC-ben 2020-01-01 00:00:00 (belső konvertálás után: UTC+1 -> UTC)
- a driver által lekérdezéskor belső konvertálás után (UTC -> UTC+1) visszakapott érték, ahol a sesion időzóna UTC+1: 2020-01-01 01:00:00
- a driver által konvertálás után (UTC+1 -> UTC+2) az alkalmazásnak visszaadott timestamp ugyanabban a JVM időzónában mint korábban (UTC+2): 2020-01-01 02:00:00
- a driver által az alkalmazásnak visszaadott timestamp egy másik JVM időzónában (mondjuk UTC+3): 2020-01-01 03:00:00 (konvertálás után: UTC+1 -> UTC+3)
Vagyis az instant ez esetben is meg lett tartva.
2b eset: a szükséges beállítás:
- connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=true
A driver itt megváltoztatja a session időzónáját a JVM időzónájára, ezért sem tároláskor sem lekérdezéskor nem szükséges időzóna konvertálás. Példa:
- időzóna: JVM: UTC+1, adatbázis session eredeti: UTC+2, de a driver által módosítva erre: UTC+1
- eredeti timestamp amit a kliens akar menteni (UTC+1): 2020-01-01 01:00:00
- a driver által az adatbázishoz küldött timestamp (nincs konvertálás): 2020-01-01 01:00:00
- az adatbázisban belsőleg tárolt érték: UTC-ben 2020-01-01 00:00:00 (belső konvertálás után: UTC+1 -> UTC)
- a driver által lekérdezéskor belső konvertálás után (UTC -> UTC+1) visszakapott érték, ahol a sesion időzónát (UTC+1) a driver állítja be: 2020-01-01 01:00:00
- a driver által az alkalmazásnak visszaadott timestamp ugyanabban a JVM időzónában mint korábban (nincs konvertálás): 2020-01-01 01:00:00
- a driver által lekérdezett érték a session-ben a mentéstől eltérő időzóna beállítással (pl.: UTC+3): 2020-01-01 03:00:00 (belső konvertálás után: UTC -> UTC+3)
- a driver által az alkalmazásnak visszaadott timestamp egy másik JVM időzónában (mondjuk UTC+3): 2020-01-01 03:00:00 (konvertálás után: UTC+1 -> UTC+3)
- a driver által az alkalmazásnak visszaadott érték UTC+3 időzónába állított JVM-ben: 2020-01-01 03:00:00 (nincs konvertálás)
Az instant meg lett tartva és konvertálás sem szükséges. Viszont a session időzóna beállítás hatással van a NOW(), CURTIME() és CURDATE() MySQL függvények működésére, ezért ha nem szeretnénk, hogy ezen függvények működése megváltozzon akkor ne használjuk ezt a beállítást. Ha ezt a beállítást különböző időzónában lévő klienseken használjuk, akkor a különböző klienseken futó driverek a connection session időzónát eltérő értékekre fogják beállítani. Ha az összes kliens összes session-jében ugyanazt a sztring reprezentációt szeretnénk megtartani a mentett instanthoz, akkor ne TIMESTAMP típus használjunk a táblánkban, hanem DATETIME-ot és Java oldalon se használunk instant típusú osztályokat.
2c eset: a szükséges beállítás:
- preserveInstants=true&connectionTimeZone=egyedi&forceConnectionTimeZoneToSession=true
A driver itt megváltoztatja a session időzónáját a felhasználó által a connection paraméterekben megadottra és a timestamp értékeket konvertálja a JVM időzónája és a felhasználó által megadott időzóna között. Ez egy tipikus beállítás amikor előre tudható, hogy az adatbázis session időzónája nem értelmezhető a driver számára. (Például CST vagy CEST, amit a JVM nem ismer fel. A Java-ban a közép-európai időzóna nem CET hanem ECT…).
Példa:
- időzóna: JVM: UTC+2, session időzóna eredetileg: CET, driver beállítás a felhasználó által a connection paraméterben: Europe/Berlin
- eredeti timestamp amit a kliens akar menteni (UTC+2): 2020-01-01 02:00:00
- a driver által az adatbázishoz küldött timestamp konvertálás után (UTC+2 -> Europe/Berlin=UTC+1): 2020-01-01 01:00:00
- az adatbázisban belsőleg tárolt érték: UTC-ben 2020-01-01 00:00:00 (belső konvertálás után: UTC+1 -> UTC)
- a driver által lekérdezéskor belső konvertálás után (UTC -> UTC+1) visszakapott érték, ahol a sesion időzónát (UTC+1) a driver állítja be: 2020-01-01 01:00:00
- a driver által lekérdezéskor belső konvertálás után (UTC -> UTC+1) visszakapott érték, ahol a sesion időzónát (Europe/Berlin=UTC+1) a driver állítja be: 2020-01-01 01:00:00
- a driver által az alkalmazásnak visszaadott érték ugyanabban a JVM időzónában (UTC+2) mint korábban: 2020-01-01 02:00:00 (konvertálás után a felhasználó által megadott időzónából (UTC+1) a JVM időzónájába (UTC+2))
Az instant meg lett tartva konvertálással. A session időzónája módosul az egyedileg beállított értékre, ezért itt is ugyanazok a megkötések érvényesek mint a 2b esetnél. Ha azt szeretnénk, hogy a timestamp-ekben ugyanaz a konvertálás történjen a JVM időzónája és a felhasználó által a session-nek definiált időzóna között, mint az előző esetben, de anélkül hogy korrigálni kelljen az adatbázis session ismeretlen időzónáját, akkor ezt a beállítást használjuk:
preserveInstants=true&connectionTimeZone=egyedi&forceConnectionTimeZoneToSession=false
A teszteléseknél először én még úgy próbáltam ezeket a paramétereket, hogy a JVM alapértelmezett időzónáját a megnyitott connection közben módosítottam, ez esetben fura volt, hogy nem tudtam reprodukálni a konverziós problémákat és az instantok mindig megmaradtak. Kiderült, hogy a MySQL driver a connection nyitásakor rögzíti a JVM időzónája alapján a használt időzónát, így később annak a connection-re már nincs hatása, csak a JVM időzónájára konvertáláskor, az utolsó lépésben. Ekkor konvertálja át a driver az alkalmazás által használt időzónára a lekérdezett értéket. A connection sztringben ezt a működést a cacheDefaultTimeZone=false paraméterrel lehet kikapcsolni (alapértelmezett értéke true).
A JVM alapértelmezett időzónáját módosítani tudjuk a TimeZone.setDefault() hívással, de célszerűbb ezt parancssori opcióként megadni (ha nem a rendszer alapértelmezettet szeretnénk használni):
- Duser.timezone=SAJÁT
Így egészen biztos, hogy az indulás előtt a kívánt értékre állítódik, mert jobb nem módosítgatni a futó alkalmazás közben a JVM alapértelmezett időzónáját (hacsak nem vagyunk teljesen biztosak a dolgunkban). Azt pedig különösen nem ajánlatos, hogy már régóta működő rendszer esetén utólag módosítsuk ezeket a paramétereket, hiszen az adatbázisban tárolt értékek a korábbi beállításra vonatkoztak.
Láttuk, hogy a java.sql-es dátum/idő típusok beállításához van olyan változat is amikor egy java.util.Calendar példányt is meg lehet adni a metódus paraméterében. Ez akkor hasznos, ha a JVM időzónáját nem tudjuk megváltoztatni az adatbázishoz legjobban megfelelő beállításhoz. Ezeknél a metódusoknál ilyenkor még bejön egy plusz lépés is: a driver nem a JVM időzónáját fogja használni a dátum/idő beküldéséhez, hanem előbb a paraméterként átadott Calendar példány által meghatározott időzónába konvertál. Ez esetben a preserveInstants beállítás szükségtelen, mert így a connection időzónát fixen a megadottra módosítjuk. A JDBC URL paraméterekkel ilyenkor csak a session időzónát módosíthatjuk. A működés az alábbi ábrán látható:


Amikor Timestamp-et DATETIME oszlopba mentünk, az lényegében annyiban különbözik, hogy a mentés a „sztring reprezentációt” tárolja, nincs UTC-be konvertálás az adatbázisban. Ezért a session időzónája határozza meg a DATETIME időzónáját, de a connection beállítások egyébként ugyanúgy működnek.
Fura működést tapasztaltam viszont a Calendar paraméteres setTime metódusoknál java.sql.Time mentésekor. A Calendar nélküli változatok a használt időzónáktól és beállításoktól függetlenül mindig „sztringként” tárolják TIME típusú adatbázis mezőbe a paramétert. Ha azonban a Calendar paraméteres setTime metódusokat használjuk, akkor a driver a megadott időt a JVM időzónájából átkonvertálta a Calendar időzónájába (és vissza is a ResultSet getTime Calendar-os változatoknál). Tehát az adatbázisban nem feltétlenül azt fogjuk látni amit paraméterben megadtunk.
A setTimestamp/getTimestamp metódusok helyett a setObject/getObject metódusokat használva instant típusú java.time objektumoknál és DATETIME adatbázis-mezőnél annyi különbség van, azon kívül hogy nyilván nem kell előtte a változóinkat java.sql.Timestamp típusba konvertálni, hogy a getObject a zóna információt is tartalmazó instantokat (például ZonedDateTime) a connectionTimeZone által megadott időzónára (tehát nem a JVM időzónájára) konvertálva fogja visszaadni és azt az időzónát is fogja ezekhez beállítani. JDBC-t használva viszont érdemes utánanézni, hogy a használt driver támogat-e minden java.time adattípust, mert például a PostgreSQL a ZonedDateTime-ot nem támogatja, csak az OffsetDateTime-ot.
Adatbázis időzóna
Most akkor felmerül a kérdés, milyen időzónában fusson egy alkalmazásunk adatbázisa? Egy adatbázis használhatja a helyi időt, ahol a futtató operációs rendszer van vagy használhat egyedileg megadott időzónát (fent láttuk ennek SQL-es konfigurációját a MySQL-ben). A következetesség érdekében az a legjobb, ha az adatbázis az összes időbélyeget UTC-ben tárolja. Így bármely időzónából tárolunk adatokat, az adatbázisban mindig egyértelmű lesz, mit látunk. Sokkal könnyebb lesz kiszámítani az intervallumokat is, mivel az összes tárolt dátum/idő ugyanahhoz az időzónához tartozik. Az időértékek összehasonlítása is egyszerű lesz. Ráadásul ha egy felhasználó egy másik időzónába megy, akkor sem kell megváltoztatni a már eltárolt felhasználó-specifikus dátum/idő információt mivel a konvertálást UTC-ből fentebbi rétegekben is meg lehet tenni. (Egyébként az Oracle hivatalos ajánlása szerint is UTC-t érdemes használni.)
Tehát a lényeg: az adatbázisunk időzónája legyen UTC (MySQL-ben ez a globális time_zone beállítását jelenti általában konfigurációs fájlban). Így TIMESTAMP típus esetén még a session időzóna-UTC közötti konvertálást is megspóroljuk (mivel a session időzóna – hacsak a kliens máshogy nem állítja be – mindig UTC-ben lesz), DATETIME típus esetén pedig tudjuk, hogy minden tárolt érték szintén UTC-ben történt (szintén: hacsak valamely kliens máshogy nem állította be a session-t).
Így lényegében az alábbi eseteket különböztethetjük meg:
- a kliensünk (összes kliensünk) JVM-je is UTC-ben fut (és fog a jövőben is)
- csak egy kliensünk van és annak JVM-je nem UTC-ben fut hanem más időzónában
- több kliensünk van és azok eltérő időzónákban is futhatnak
Nézzük hogyan tudjuk ezekhez konfogurálni a drivert úgy, hogy a TIMESTAMP és DATETIME típust is használhassuk.
1. A legegyszerűbb, ha a drivert alapértelmezett állapotban hagyjuk, ami:
connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=false&preserveInstants=true
Ez esetben minden instant már JVM oldalon UTC-ben van, így megy át az adatbázishoz ahol változtatás nélkül tárolódik (TIMESTAMP és DATETIME típusban is). Olvasáskor a driver szintén UTC-ben adja vissza az instant értékeket. Ha olyan adatot akarunk menteni ahol időzóna információ is tárolódik, például egy ZonedDateTime változót ami mondjuk UTC+5-ben van, akkor a driver-ben UTC-re konvertálódik mentés előtt (tehát az instant megmarad), visszaolvasáskor pedig ezt fogjuk visszakapni (tehát az UTC-nek megfelelő időt), az eredeti időzóna információt (ahogyan korábban is írtam) nem.
2. Mindenképpen konvertálni kell UTC-re, ha ezt a driver oldalán tesszük meg, akkor az SQL dátum/idő lekérdező függvények működését nem bántjuk:
preserveInstants=true&connectionTimeZone=SERVER
Ez esetben minden instant a driverben mindig átmegy egy konvertáláson. ZonedDateTime-be való olvasáskor viszont mindig UTC időzóna fog beállítódni hozzájuk (lásd a getObject leírása fentebb). Ha nem használjuk az SQL dátum/idő függvényeit, vagy figyelembe vesszük, hogy nem UTC-ben fognak működni, akkor az alábbi beállítást is használhatjuk:
connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=true
Így a ZonedDateTime értékeket a JVM időzónájával fogjuk visszakapni.
3. Ha több kliensünk van eltérő időzónákkal akkor az a lényeg, hogy minden kliens a saját időzónájára alakítva kapja vissza az instantokat. A DATETIME formátum pedig csak akkor fog jól működni ebben az esetben, ha a session időzóna fixen UTC-ben marad. Két, azonos megoldásunk is van de mindkettőnél a ZonedDateTime olvasáskor mindig UTC-be konvertálva fogjuk visszakapni, de az instantok meg fognak maradni:
connectionTimeZone=SERVER&preserveInstants=true
connectionTimeZone=UTC&preserveInstants=true
Ez az eset a legrosszabb és mindenképpen kerülendő, nagyon gyorsan hibákba futhatunk például a téli/nyári időszámítás miatti eltérések okán.
Az UTC-ben tárolásnak egyébként egy hátránya van. Tegyük fel, hogy időpontfoglaló funkcionalitást fejlesztünk. Még több időzónát sem kezelünk, legyen mindenki ugyanabban az időzónában, mondjuk +02:00 ofszettel. Legyen most 2024. szeptember 8. Az időpontot lefoglaljuk 2025. április 4. reggel 8-ra. Az adatbázisba ez fog bekerülni: 2025-04-04 06:00:00.000000. Minden jól kezeli, a java.time API, a Hibernate és az adatbázis is. Később azonban a politikai döntéshozatal kitalálja, hogy 2025-től kezdve az ország már nem használja a nyári időszámítást, tehát „eltöröljük az óraátállítást”.
Miután frissül Java-ban az időzóna-szabály, az időpontunk elromlik. Azért, mert 2024 őszén átálltunk téli időszámításra és ott is maradtunk (+01:00 ofszet), a frissítéstől kezdve viszont az API már azt fogja mondani, hogy a foglalt időpont reggel 7 óra, a valódi 8 helyett, hiszen innentől kezdve nem állunk vissza a +02:00 ofszetre tavasszal. Írhatunk egy jó nagy update-et ami az adatbázisban lévő összes jövőbeli időpontot korrigálja. Arra ne számítsunk, hogy ilyen csak ritkán történik és jó előre bejelentik: csak 2023-ban négy ilyen döntés volt a világon, 2024-ben pedig eddig kettő. Libanon 2023-ban bejelentette, hogy elhalasztja az óraátállítást, majd négy nappal később bejelentették, hogy mégsem. Egyiptom hasonlót csinált 2016-ban. Sőt olyan is előfordul hogy egy időzónában megváltoztatják az ofszetet. 2016-ban például 8 ilyen is történt.
Időzóna-kezelés és a Hibernate
A Hibernate is a fentebb megismert JDBC funkcionalitást használja a dátum/idő kezeléséhez, csak behoz egy újabb absztrakciós réteget, ami az entitásainkból a kívánt műveletnek megfelelő SQL utasításokat tud összerakni. A dátum korlát miatt a továbbiakban a TIMESTAMP adattípussal már nem foglalkozok. Ugyancsak nem foglalkozok a régi java.util-os dátum/idő API-val, ezeket ne használjuk Hibernate-ben (a Jakarta 3.2 API is ezen a véleményen van, a korábban ezek kezelésére szolgáló @Temporal annotáció is deprecated lett).
A bemutatáshoz egyszerűen egy olyan entitást fogok használni amiben minden közkeletű java.time dátum/idő típus szerepel. Legyen az alábbi táblánk:
CREATE TABLE `tipusok` (
`id` bigint NOT NULL AUTO_INCREMENT,
`zoned_date_time` datetime(6),
`offset_date_time` datetime(6),
`local_date_time` datetime(6),
`local_date` date,
`local_time` time(6),
`offset_time` time(6),
PRIMARY KEY (`id`)
);
És legyen az alábbi entitásunk:
package hu.egalizer.timezones.domain;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZonedDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = „tipusok”)
public class Tipusok {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = „zoned_date_time”)
private ZonedDateTime zonedDateTime;
@Column(name = „offset_date_time”)
private OffsetDateTime offsetDateTime;
@Column(name = „local_date_time”)
private LocalDateTime localDateTime;
@Column(name = „local_date”)
private LocalDate localDate;
@Column(name = „local_time”)
private LocalTime localTime;
@Column(name = „offset_time”)
private OffsetTime offsetTime;
// konstruktorokat, gettereket és settereket elhagyom a rövidség kedvéért
}
Nézzük meg hogyan kezeli a Hibernate ezeknek az adattípusoknak a mentését és beolvasását.
A Hibernate-ben az SQL generálás egyik összetevője az ún. JdbcType interfész. Az ezt implementáló osztályok lényegében egy adatbázis-típust reprezentálnak és nekünk most itt két metódusa lényeges:
- getBinder: visszaad egy ún. bindert ami perzisztáláshoz egy PreparedStatement-be be tudja állítani az adott típust egy kifejezésnél. Tehát lényegében ez a binder hívja meg a setTimestamp, setDate, stb. metódusokat a PreparedStatement-en a paraméterként megkapott objektumot használva.
- getExtractor: egy ún. extractort ad vissza ami beolvasáskor egy ResultSet-ből ki tudja olvasni az adott mezőt. Tehát lényegében ez az extractor hívja meg a getTimestamp, getData, stb. metódusokat a ResultSet-en.
Mindkét metódus egy ún JavaType interfészt implementáló paramétert vár. Ezt a JavaType-ot használja arra, hogy az adott Java objektum és a JDBC által használt objektumok (a mi esetünkben a java.sql dátum/idő típusok) között konvertáljon, mentéskor az unwrap, visszaolvasáskor a wrap metódust használva. Ez tehát lényegében egy mapping, ami megmondja, hogy milyen Java osztályt hogyan és milyen JDBC osztályra kell átalakítani és vissza ahhoz, hogy azt egy PreparedStatement-be be lehessen állítani és ResultSet-ből ki lehessen olvasni. Az entitásunkból ezt a mapping párosítást és összeállítást valamint a konkrét PreparedStatemtn és ResultSet létrehozást a Hibernate más rétegei végzik el, itt most nekünk az nem lényeges. Ebben a rétegben azok már adottak.
A java.time adattípusaihoz az alábbi JdbcType és JavaType implementációk tartoznak a Hibernate-ben és az alábbi java.sql típusra fordítódnak le:
Java típus | JavaType | JDBCType | JDBC típus |
---|---|---|---|
ZonedDateTime | ZonedDateTimeJavaType | TimestampUtcAsJdbcTimestampJdbcType | java.sql.Timestamp |
OffsetDateTime | OffsetDateTimeJavaType | TimestampUtcAsJdbcTimestampJdbcType | java.sql.Timestamp |
LocalDateTime | LocalDateTimeJavaType | TimestampJdbcType | java.sql.Timestamp |
LocalDate | LocalDateJavaType | DateJdbcType | java.sql.Date |
LocalTime | LocalTimeJavaType | TimeJdbcType | java.sql.Time |
OffsetTime | OffsetTimeJavaType | TimeUtcAsJdbcTimeJdbcType | java.sql.Time |
Amikor tehát meghívódik az EntityManager.persist, a mélyben ezek az objektumok működnek. Nézzük meg például a ZonedDateTime mentését és beolvasását a Hibernate org.hibernate.type.descriptor.jdbc.TimestampUtcAsJdbcTimestampJdbcType kódjából:
private static final Calendar UTC_CALENDAR = Calendar.getInstance( TimeZone.getTimeZone(„UTC”) );
//…
protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) throws SQLException {
final Instant instant = javaType.unwrap( value, Instant.class, options );
st.setTimestamp( index, Timestamp.from( instant ), UTC_CALENDAR );
}
//…
protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
final Timestamp timestamp = rs.getTimestamp( paramIndex, UTC_CALENDAR );
return javaType.wrap( timestamp == null ? null : timestamp.toInstant(), options );
}
A hozzá tartozó org.hibernate.type.descriptor.java.ZonedDateTimeJavaType metódusai:
public <X> X unwrap(ZonedDateTime zonedDateTime, Class<X> type, WrapperOptions options) {
//…
if ( Timestamp.class.isAssignableFrom( type ) ) {
//…
return (X) Timestamp.from( zonedDateTime.toInstant() );
}
//…
}
//…
public <X> ZonedDateTime wrap(X value, WrapperOptions options) {
//…
if (value instanceof Timestamp) {
final Timestamp ts = (Timestamp) value;
//…
return ts.toInstant().atZone( ZoneId.systemDefault() );
}
//…
}
Leegyszerűsítve tehát ZonedDateTime mentésekor és beolvasásakor alapesetben ez történik:
// Bind:
final Instant instant = value.toInstant();
st.setTimestamp(index, Timestamp.from(instant), Calendar.getInstance(TimeZone.getTimeZone(„UTC”)));
// Extract:
Timestamp timestamp = rs.getTimestamp(paramIndex, Calendar.getInstance(TimeZone.getTimeZone(„UTC”)));
timestamp.toInstant().atZone(ZoneOffset.UTC);
LocalDateTime mentése és beolvasása a Hibernate org.hibernate.type.descriptor.jdbc.TimestampJdbcType kódjából::
protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) throws SQLException {
final Timestamp timestamp = javaType.unwrap( value, Timestamp.class, options );
if ( value instanceof Calendar ) {
st.setTimestamp( index, timestamp, (Calendar) value );
}
else if ( options.getJdbcTimeZone() != null ) {
st.setTimestamp( index, timestamp, Calendar.getInstance( options.getJdbcTimeZone() ) );
}
else {
st.setTimestamp( index, timestamp );
}
}
protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
return options.getJdbcTimeZone() != null ?
javaType.wrap( rs.getTimestamp( paramIndex, Calendar.getInstance( options.getJdbcTimeZone() ) ), options ) :
javaType.wrap( rs.getTimestamp( paramIndex ), options );
}
A hozzá tartozó org.hibernate.type.descriptor.java.LocalDateTimeJavaType metódusai:
public <X> X unwrap(LocalDateTime value, Class<X> type, WrapperOptions options) {
//…
if ( Timestamp.class.isAssignableFrom( type ) ) {
return (X) Timestamp.valueOf( value );
}
//…
}
//…
public <X> LocalDateTime wrap(X value, WrapperOptions options) {
//…
if (value instanceof Timestamp) {
final Timestamp ts = (Timestamp) value;
return ts.toLocalDateTime();
}
//…
}
Leegyszerűsítve:
// Bind:
st.setTimestamp(index, Timestamp.valueOf(value));
// Extract:
rs.getTimestamp(paramIndex).toLocalDateTime();
Nem fogom itt most mindegyik típust bemásolni, de a lényeg, hogy a Hibernate minden java.time típust a három java.sql típus valamelyikére alakít és azt állítja be/kérdezi le a JDBC driverben. Tehát már maga a Hibernate eldobja az időzóna információt a mentéskor (ugyanez történik az OffsetDateTime esetén az ofszettel is). ZonedDateTime beolvasáskor az eredeti instantot természetesen megtartva bár, de mindent UTC időzónában kapunk vissza. Vagyis ha például ezt akarjuk menteni:
tipusok.setZonedDateTime(ZonedDateTime.of(2020, 01, 1, 2, 0, 0, 0, ZoneId.of(„GMT+5”)));
az EntityManager.find már ezt fogja visszaadni a GMT+5 helyett:
zonedDateTime=2019-12-31T21:00Z
Ahogyan a LocalDateTime kódjában látható (de a többi típus esetén is ez történik), ezeket is átalakítja a Hibernate a java.sql típusokra. A LocalDate és LocalTime esetén nincs különösebb látnivaló, ezek végig az adatbázisig tartó úton és onnan visszafelé is úgy utaznak ahogyan megadtuk őket (bár LocalTime esetén a nanoszekundumos részt levágja a Hibernate). (Egyébként nem is olyan régen még ezzel is voltak problémák, különösen amikor az időzóna beállítást utólag módosították a JVM futása közben, de ez mára megoldódott. És mint írtam, jobb ha a JVM alapértelmezett időzónáját parancssori paraméterben adjuk meg és futás közben már nem piszkáljuk.)
A LocalDateTime, OffsetDateTime és OffsetTime esetén viszont a helyzet bonyolultabb. Amikor ezekből java.sql-es adattípus lesz, az átalakítások során a JVM alapértelmezett időzónája lesz figyelembe véve. Ezért mentéskor ezek nem egyszerűen csak „mintha sztringek lennének” kerülnek az adatbázisba, hanem előbb átmennek az időzóna-kezeléses akadálypályán, amit az előző fejezetben tárgyaltam.
A Hibernate-ben is használhatjuk a JDBC Calendar-t váró metódusait. Ezt a lehetőséget még 2016-2017-ben vezették be az akkori JDBC működési problémákra válaszul. Egy hibernate.jdbc.time_zone propertyben adhatjuk meg a kívánt időzónát (Spring alatt spring.jpa.properties.hibernate.jdbc.time_zone). A Hibernate ezután a java.sql adattípusokat beállító/lekérdező metódusok Calender-t váró változatát fogja meghívni a beállított paraméterrel (ahogy a fentebbi Hibernate forráskódban is láttuk). Ezt a működést is tárgyaltam a korábbi fejezetben. Érdemes itt is megjegyezni, hogy a LocalTime típus esetén a JDBC ez esetben időzóna-konvertálást fog végezni, tehát a JVM alapértelmezett időzónájától és a paramétertől függően nem biztos hogy azt fogjuk az adatbázisban látni amit beküldtünk.
A JDBC fejezetben tárgyalt konfigurációk a fentiek alapján tehát arra az esetre is igazak amikor Hibernate-et használunk:
- a kliensünk (összes kliensünk) JVM-je is UTC-ben fut (és fog a jövőben is)
- csak egy kliensünk van és annak JVM-je nem UTC-ben fut hanem más időzónában
- több kliensünk van és azok eltérő időzónákban is futhatnak
Hibernate esetén a fentebb felsorolt java.time adattípusok használatával tehát az alábbi beállítások célszerűek a fenti három esetben:
- A legegyszerűbb, ha a drivert alapértelmezett állapotban hagyjuk, ami:
connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=false&preserveInstants=true
A hibernate.jdbc.time_zone property-t pedig szükségtelen beállítani.
Ez esetben minden instant már JVM oldalon UTC-ben lesz, így megy át az adatbázishoz ahol változtatás nélkül tárolódik. A nem instant típusok is UTC-re konvertálva vagy „sztring alakjukban” tárolódnak. Olvasáskor a driver szintén UTC-ben adja vissza az instant értékeket. Ha olyan adatot akarunk menteni ahol időzóna információ is tárolódik, például egy ZonedDateTime változót ami mondjuk UTC+5-ben van, akkor a driver-ben UTC-re konvertálódik mentés előtt (tehát az instant megmarad), visszaolvasáskor pedig ezt fogjuk visszakapni (tehát az UTC-nek megfelelő időt), az eredeti időzóna információt (ahogyan korábban is írtam) nem.
A Hibernate egyébként azt javasolja (ebben a hibajegyben olvasható) hogy mind az adatbázis mind a backend UTC időt használjon.
2. Mindenképpen konvertálni kell UTC-re, ha ezt a driver oldalán tesszük meg, akkor a MySQL dátum/idő lekérdező függvények működését nem bántjuk:
preserveInstants=true&connectionTimeZone=SERVER
A hibernate.jdbc.time_zone property-t pedig szükségtelen beállítani (így a LocalTime értékek sem mennek át konvertáláson).
Ez esetben minden instant a driverben mindig átmegy egy konvertáláson. ZonedDateTime-be való olvasáskor viszont mindig UTC időzóna fog beállítódni hozzájuk. Ha az adatbázist másik klienssel nézzük (például MySQL Workbench-csel) ami SYSTEM time_zone beállítást használ, akkor vegyük figyelembe, hogy a LocalDateTime értékeinket UTC-re konvertálva fogjuk látni.
Ha nem használjuk a MySQL dátum/idő függvényeit, vagy figyelembe vesszük, hogy nem UTC-ben fognak működni, akkor az alábbi beállítást is használhatjuk:
connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=true
A hibernate.jdbc.time_zone property-t pedig szükségtelen beállítani.
A Hibernate fentebb tárgyalt belső konvertálása miatt így is mindent UTC-ben fogunk visszakapni, viszont az adatbázisban a LocalDateTime értékeket úgy fogjuk a másik klienssel látni, „ahogy elmentettük”.
3. Ha több kliensünk van eltérő időzónákkal akkor a fenti paraméterek itt is használhatók:
connectionTimeZone=SERVER&preserveInstants=true
connectionTimeZone=UTC&preserveInstants=true
A hibernate.jdbc.time_zone property-t pedig szükségtelen beállítani.
A ZonedDateTime, OffsetDateTime, LocalDate és LocalTime típusok jól fognak működni, mivel az utóbbi kettő időzóna-független (a LocalTime a fentebb tárgyaltak értelmében csak akkor, ha a time_zone property-t nem adjuk meg), az előző kettőt pedig mindig UTC-ben fogja visszakapni a JVM alkalmazás, bármit is mentett el. A LocalDateTime és az OffsetTime esetén viszont sajnos nem ez a helyzet. Hogyan történik ezek visszaolvasása?
LocalDateTime:
rs.getTimestamp(paramIndex).toLocalDateTime();
A java.sql.Timestamp.toLocalDateTime() metódus a Timestamp-ben tárolt (vagyis UTC) instantot a JVM időzónájára konvertálva adja vissza. Ennek értelmében ugyanarra az adatbázisban tárolt értékre a fenti kódsor két eltérő időzónában futó JVM esetén mindig két eltérő értéket fog visszaadni. Vagyis éppen a LocalDateTime nem használható „időzóna-független” típusként Hibernate esetén ha a JVM kliensek eltérő időzónában futnak.
OffsetTime:
Itt még rosszabb a helyzet. A Hibernate ezt csinálja a PreparedStatementben beállításkor:
st.setTime(index, Time.valueOf(offsetTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalTime()),Calendar.getInstance(TimeZone.getTimeZone(„UTC”)));
Itt az eredeti offsetTime értéket előbb UTC-re konvertálja az ofszet figyelembevételével, viszont a setTime metódus belül a megadott UTC Calendar példánnyal ezt még egyszer megteszi, így az adatbázisban valójában nem az UTC érték fog tárolódni, hanem előbb az ofszet, aztán a JVM időzónájához képest UTC-re konvertált érték. Legyen a JVM időzónája „GMT+5”, a tárolni kívánt OffsetTime pedig mondjuk:
08:00:00.00+03:00
Ilyenkor az adatbázisban ezt fogjuk látni a 05:00:00.00 helyett:
00:00:00.00
A dupla konvertálás alapján előbb a +03:00 ofszet miatt 5 óra, majd a JVM-es GMT+5 miatt 0 óra lesz belőle. Ugyanabban az időzónában ugyan a visszaolvasáskor ennek ellentéte is megtörténik tehát ezt fogjuk kapni:
05:00:00Z
Ami ugyan még elfogadható, viszont más időzónában futó JVM-ből olvasva már teljesen hibás eredményt kapunk (például GMT+9):
09:00:00Z
Vagyis UTC szerint 9 óra, aminek már semmi köze az eredetihez.
Ha tehát több eltérő időzónából futó JVM is csatlakozik az adatbázishoz, akkor jobb, ha a LocalDateTime és OffsetTime típusokat nem használjuk.
Azt is érdemes figyelembe venni, hogy azzal, hogy az adatbázis a tárolás során eltávolítja az eredeti időzóna információt, a nyári-téli időszámítás között már nem lehet különbséget tenni, ez szintén problémát okozhat az ide-oda konvertálások között.
Mindezen problémák és a zóna/ofszet információ tárolásának hiánya miatt van olyan javaslat is, hogy legjobb nem is használni a ZonedDateTime és OffsetDateTime típusokat Hibernate-ben hanem tároljunk inkább mindent LocalDateTime-ban.
Időzóna tárolása adatbázisban Hibernate segítségével
A Hibernate arra is nyújt megoldást, ha mégis tárolni szeretnénk az adatbázisban az időzóna információt az OffsetDateTime és ZonedDateTime típusoknál. Az időzóna kezelését kétféle módon paraméterezhetjük (az értékkészletet a TimeZoneStorageType enum definiálja):
- megadjuk a kívánt működést a hibernate.timezone.default_storage property-ben. Ennek alapértelmezett értéke DEFAULT.
- az entitásaink ZonedDateTime és OffsetDateTime típusú attribútumainál egyenként is megadhatjuk a kívánt működést a @TimeZoneStorage annotációval (ezt a 6.5-ös és 6.6-os Hibernate még incubating-nak jelöli ami azt jelenti hogy még nem teljesen kiforrott új funkció, a működés még változhat a jövőben és nem ajánlott éles rendszerben most még használni)
A példákhoz legyen az alábbi táblánk:
CREATE TABLE `zonazo` (
`zoned_date_time` datetime(6),
`offset_date_time` datetime(6)
);
És legyen az alábbi entitásunk:
@Entity
@Table(name = „zonazo”)
public class Zonazo {
@Column(name = „zoned_date_time”)
private ZonedDateTime zonedDateTime;
@Column(name = „offset_date_time”)
private OffsetDateTime offsetDateTime;
}
Az alábbi TimeZoneStorageType típusok léteznek:
NATIVE
Ez az eset egyszerű, nincs különbség más típus tárolásához képest: ha az adatbázisunk támogatja, akkor ez esetben a Hibernate megpróbálja TIMESTAMP_WITH_TIMEZONE típusú mezőben tárolni az adatot az időzónával együtt. Ha nem támogatja az adatbázis akkor is elmentődik az adat csak a NORMALIZE_UTC opciónak megfelelő módon. Egyébként még 2016-ban az volt az álláspont a Hibernate fejlesztőinél, hogy ezt a típust ne támogassák, azóta változott a helyzet.
A TimeZoneStorageType beállítást egyszerűen annotációval adhatjuk meg:
@TimeZoneStorage(TimeZoneStorageType.NATIVE)
@Column(name = „zoned_date_time”)
private ZonedDateTime zonedDateTime;
NORMALIZE
Ez esetben a JDBC driver a JVM időzónájára vagy pedig a hibernate.jdbc.time_zone propertyben megadott időzónára alakítja az adatot, az adatbázisba pedig az előző fejezetekben már látott módon, időzóna információ nélkül kerül. A különböző időzónákban futó kliens JVM-ek esetén itt is felmerülhetnek problémák.
NORMALIZE_UTC
Az előzőhöz hasonló működés, azzal az eltéréssel, hogy mindig UTC-re konvertál. Ez az alapértelmezett működés olyan adatbázisok esetén amik nem támogatják a TIMESTAMP_WITH_TIMEZONE típust.
COLUMN
Az időzóna ofszet tárolása. Az UTC-hez képesti ofszetet itt külön adatbázis oszlopban tároljuk, a dátum/idő instantját (vagyis az UTC értéket!) pedig az eredeti oszlopban. A Hibernate alapértelmezetten az adat oszlop nevét „_tz” utótaggal kiegészített oszlopba tárolja az ofszetet, de ezt felülbírálhatjuk a @TimeZoneColumn annotációval, például:
@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@TimeZoneColumn(name = „offset_date_time_offset”)
@Column(name = „offset_date_time”)
private OffsetDateTime offsetDateTime;
Jövőbeli időponthoz tartozó ofszeteket nem ajánlott így tárolni az adatbázisban mivel az időzóna szabályok gyakran változnak. Változás esetén az összes érintett adatot módosítani kellene az adatbázisban.
Az ofszet oszlop típusa lehet egész vagy szöveg, a Hibernate mindkettőt kezeli. A tárolás lényegében ennek az int értéknek a mentését jelenti:
- ZonedDateTime.getOffset().getTotalSeconds()
- OffsetDateTime.getOffset().getTotalSeconds()
Tehát csak az ofszetet menthetjük így el, ZonedDateTime esetén az eredeti időzóna információ továbbra is elvész. (Talán jobb is lett volna az annotációt OffsetColumn-nak hívni.)
AUTO
A működés ekkor az adatbázistól függ: ha az támogatja a TIMESTAMP_WITH_TIMEZONE típust, akkor NATIVE-nek megfelelő működés fog megtörténni, egyébként pedig a COLUMN. Itt is érdemes tartózkodni a jövőbeli időpontok tárolásától. A COLUMN és AUTO beállítás is megtartja a ZonedDateTime és OffsetDateTime által reprezentált instantot és az ofszetet.
DEFAULT
Ha nem adjuk meg a hibernate.timezone.default_storage beállítást akkor ez az alapértelmezett. Ez is az adatbázistól függ: ha támogatja a TIMESTAMP_WITH_TIMEZONE típust, akkor a NATIVE működést kapjuk, más esetben pedig a NORMALIZE_UTC működést.
Ha az adatbázisunk nem támogatja a TIMESTAMP_WITH_TIMEZONE típust (ilyen a MySQL) akkor az időzóna tárolására nincs lehetőség. Ezért ha az ofszet mellett az időzónára is szükségünk van akkor ezt külön kell tárolnunk. A Hibernate közvetlenül tudja kezelni a ZoneId típust, tehát ezt külön mezőben tárolhatjuk, azt pedig ezután nekünk kell tudnunk hogy a tárolt ZoneId mire vonatkozik:
@Column(name = „zone_id”)
private ZoneId zoneId;
A zone_id típusa az adatbázisban lehet varchar(256), további konverzióra nincs szükségünk, a Hibernate ezt már tudja kezelni.
Az idő tesztelése
A unit tesztekkel mindig csak a baj van. Ha pedig valamit igazán körülményes tesztelni akkor az a dátumtól/időtől függő kód. Hiszen ha valami reprodukálhatatlan (ami pedig a tesztelés alapkövetelménye lenne) az az idő. De hát annyi mindent mockoltunk már, miért épp az időt ne tennénk?
A Java 8-ban megjelent java.time.Clock osztály arra szolgál, hogy biztosítsa a rendszernek az aktuális instantot, időzónától függő dátumot és időt. Vagyis a System.currentTimeMillis() és TimeZone.getDefault() helyett egy Clock-ból is ki lehet indulni. Valójában az összes java.time dátum/idő osztály statikus now() metódusa a rendszer Clock-ot használja az alapértelmezett időzónával. Ezeknek a metódusoknak van olyan változata is ami egy Clock példányt vár, ami így már elősegíti a tesztelést. A Clock ugyanis egy absztrakt osztály és önmagában még nem biztosítja, hogy amit visszaad az tényleg az aktuális dátum/idő. A legjobb módszer ha az alkalmazásunkban minden olyan metódusnak ami az aktuális időt kéri le, egy Clock példányt adunk, Springben akár még injektálhatjuk is a Clock-ot a service-einkbe.
Példa:
@Autowired
private Clock clock;
public void process(LocalDate eventDate) {
if (eventDate.isBefore(LocalDate.now(clock))) {
// …
}
}
A konfiguráció pedig:
@Configuration
public class ClockConfiguration {
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}
Ha mindenhol így kérjük le az aktuális dátumot/időt akkor a service-einket még az időt tekintve is tetszőlegesen tudjuk majd tesztelni úgy, hogy a tesztek során az igényeinkre szabott Clock példányt injektálunk. A Clock statikus metódusai jól használható alap implementációkat biztosítanak:
systemUTC(): minden létrehozott instant UTC időzónába lesz konvertálva. Ezt akkor érdemes használni amikor csak az instantra van szükségünk dátum és idő nélkül.
systemDefaultZone(): a létrehozott instantok a JVM alapértelmezett időzónával. Lényegében ez ugyanaz mintha a now() metódusokat Clock példány nélkül hívnánk meg.
system(ZoneId zone): a létrehozott instantok a megadott időzónára lesznek konvertálva.
tickMillis(ZoneId zone), tickSeconds(ZoneId zone), tickMinutes(ZoneId zone), tick(Clock baseClock, Duration tickDuration): minden metódus olyan Clock példányt ad vissza ami a neve által jelzett értékekben növekszik csak (egész percekben, egész másodpercekben, stb).
fixed(Instant fixedInstant, ZoneId zone): mindig a megadott fix instantot adja vissza, vagyis lényegében „megállítjuk vele az időt”. Teszteléshez ideális.
offset(Clock baseClock, Duration offsetDuration): az adott időtartammal (ami lehet negatív is) eltolt órát ad vissza.
A visszaadott Clock példányok minden esetben szálbiztosak. Nézzük, hogy működik! Legyen egy service-ünk (a Clock beant a fentebb már bemutatott konfig osztályban hozzuk létre):
@Service
public class CustomClockTestService {
private static final Logger log = LoggerFactory.getLogger(CustomClockTestService.class);
@Autowired
private Clock clock;
public void logCurrentTime() {
log.info(„A pontos idő: {}”, ZonedDateTime.now(clock));
}
public ZonedDateTime getCurrentTime() {
return ZonedDateTime.now(clock);
}
}
A teszteléshez kell egy teszt konfiguráció:
public class ClockTestConfig {
Clock fixedClock() {
return Clock.fixed(Instant.parse(„2024-02-01T13:01:10.123Z”), ZoneId.of(„Europe/London”));
}
}
Itt adjuk meg a unit teszthez használandó Clock beant. A tesztünk pedig:
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = ClockTestConfig.class)
public class AppClockTest {
@Autowired
private CustomClockTestService customClockTestService;
@Test
public void searchAuditLogGroupedTest() {
customClockTestService.logCurrentTime();
}
}
Ez a teszt mindig fixen az alábbi logot fogja produkálni:
INFO 16628 — [ main] h.e.t.service.CustomClockTestService : A pontos idő: 2024-02-01T13:01:10.123Z[Europe/London]
De még azt is meg tudjuk csinálni, hogy a Clock-ot mockoljuk:
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class MockedClockTest {
@MockBean
private Clock clock;
@Autowired
private CustomClockTestService customClockTestService;
@BeforeEach
void setupClock() {
when(clock.getZone()).thenReturn(ZoneId.of(„Europe/London”));
}
@Test
void order_is_placed_at_current_time() {
when(clock.instant()).thenReturn(Instant.parse(„2020-12-01T10:05:23.653Z”));
ZonedDateTime current = customClockTestService.getCurrentTime();
assertThat(current).isEqualTo(ZonedDateTime.now(clock));
}
}
Jackson
Názzük meg, hogy JSON RPC/REST végpontoknál hogyan kezeli a Spring Boot a dátumokat. A spring-boot-starter-web dependency behozza a spring-boot-starter-json artifactot a projektünkbe amiben megtalálhatjuk a FasterXML-Jackson modult. A Jackson a Java legelterjedtebb JSON adatkezelő osztálykönyvtára, ezt haszálja a Spring is. A Jacksonnal tudunk egyszerű Java objektumokból (POJO) JSON-t szerializálni és JSON-ből POJO-t deszerializálni.
A Jackson egyedi modulokkal is bővíthető, ezek lehetővé teszik, hogy mindenféle adattípust tudjon kezelni, amiket alapból egyébként nem tudna (ezeket lehet az ObjectMapper.registerModule() metódussal regisztrálni). A spring-boot-starter-json dependency már alapértelmezetten behozza nekünk a legfontsabb ilyeneket, ezek közül most számunkra a legérdekesebb a jackson-modules-java8, ami a Java 8-ban bejelentett új adattípusokat, köztük az új dátum/idő API-t tudja kezelni.
Legyen egy REST interfészünk amihez van két DTO-nk:
public class TipusokRequestDTO {
private ZonedDateTime zonedDateTime;
private OffsetDateTime offsetDateTime;
private LocalDateTime localDateTime;
private LocalDate localDate;
private LocalTime localTime;
private OffsetTime offsetTime;
// gettereket és settereket elhagyom a rövidítés kedvéért
}
public class TipusokResponseDTO {
private ZonedDateTime zonedDateTime;
private OffsetDateTime offsetDateTime;
private LocalDateTime localDateTime;
private LocalDate localDate;
private LocalTime localTime;
private OffsetTime offsetTime;
// gettereket és settereket elhagyom a rövidítés kedvéért
}
A REST végpontokban semmi mást nem fogunk tenni, csak a JSON (de)szerializálást teszteljük:
package hu.egalizer.serialization.web;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import hu.egalizer.serialization.service.dto.TipusokRequestDTO;
import hu.egalizer.serialization.service.dto.TipusokResponseDTO;
@RestController
public class TipusokResource {
private static final Logger log = LoggerFactory.getLogger(TipusokResource.class);
@GetMapping(„/dates”)
public TipusokResponseDTO getDates() {
TipusokResponseDTO result = new TipusokResponseDTO();
result.setZonedDateTime(ZonedDateTime.of(2020, 01, 1, 2, 0, 0, 0, ZoneId.of(„GMT+2”)));
result.setLocalDate(LocalDate.of(2024, 9, 30));
result.setLocalTime(LocalTime.of(23, 0));
result.setLocalDateTime(LocalDateTime.of(2024, 9, 30, 1, 0));
result.setOffsetDateTime(OffsetDateTime.of(2024, 9, 30, 1, 0, 0, 0, ZoneOffset.of(„+02:00”)));
result.setOffsetTime(OffsetTime.of(20, 0, 0, 0, ZoneOffset.of(„+02:00”)));
return result;
}
@GetMapping(„/dates-in-utc”)
public TipusokResponseDTO getDatesInUtc() {
TipusokResponseDTO result = new TipusokResponseDTO();
result.setZonedDateTime(ZonedDateTime.of(2020, 01, 1, 2, 0, 0, 0, ZoneId.of(„UTC”)));
result.setLocalDate(LocalDate.of(2024, 9, 30));
result.setLocalTime(LocalTime.of(23, 0));
result.setLocalDateTime(LocalDateTime.of(2024, 9, 30, 1, 0));
result.setOffsetDateTime(OffsetDateTime.of(2024, 9, 30, 1, 0, 0, 0, ZoneOffset.of(„+00:00”)));
result.setOffsetTime(OffsetTime.of(20, 0, 0, 0, ZoneOffset.of(„+00:00”)));
return result;
}
@PostMapping(„/dates”)
public void postDates(@RequestBody TipusokRequestDTO request) {
log.info(„Request: {}”, request);
}
}
A Jackson szerializáció/deszerializáció paraméterekkel vezérelhető. Spring Boot alatt ezeket property (YAML) fájlban is megadhatjuk. A Jackson-Spring property leképezésekről itt található információ.
Szerializácóis beállítások
Alapértelmezetten a szerializáció a JSON-be ISO 8601 formátumban, adott típusok esetén ofszettel generálja az értékeket a DTO-ban lévő adattípusunk zóna beállításai alapján:
{
„zonedDateTime”: „2020-01-01T02:00:00+02:00”,
„offsetDateTime”: „2024-09-30T01:00:00+02:00”,
„localDateTime”: „2024-09-30T01:00:00”,
„localDate”: „2024-09-30”,
„localTime”: „23:00:00”,
„offsetTime”: „20:00+02:00”
}
Vagy pedig UTC esetén a Z jelzéssel:
{
„zonedDateTime”: „2020-01-01T02:00:00Z”,
„offsetDateTime”: „2024-09-30T01:00:00Z”,
…
„offsetTime”: „20:00Z”
}
Ha azt szeretnénk, hogy a szerializáció ne ISO formátumba történjen, hanem timestamp-eket kapjunk akkor ezt kell beállítani (alapértelmezetten ez false):
spring.jackson.serialization.write_dates_as_timestamps=true
Ez a ZonedDateTime és OffsetDateTime változók értékét inkrementális alakban adja vissza, a lebegő időket pedig tömbbe szervezve (például: [2025,1,13]).
Az egyes Java típusok esetén az alapértelmezett formátumról az itt található táblázatból informálódhatunk. Ha ZonedDateTime esetén az időzónát is szeretnénk a JSON-be íratni akkor ezzel a beállítással tehetjük meg:
spring.jackson.serialization.write_dates_with_zone_id=true
Ez a beállítás alapértelmezetten ki van kapcsolva mert az ISO 8601 nem tartalmaz zóna azonosítókkal kapcsolatos definíciót, ezért kompatibilitási problémákat okozhat ha bekapcsoljuk.
Mivel a Jackson nem ismeri az API-nk kliensének időzónáját, csak a szerveroldali beállításokat tudja használni szerializáláshoz. A Jackson kontextusában az időzónát így állíthatjuk be:
spring.jackson.time-zone=Europe/Budapest
Ezzel a beállítással szerializáció esetén azt vezérelhetjük, hogy minden ofszetet/zónát tartalmazó változó átkonvertálódjon a megadott időzónába és úgy kerüljön a JSON-be. Ez a funkció alapértelmezetten be van kapcsolva, az alábbi beállítással lehet kikapcsolni, ekkor minden változó az eredeti időzónájában kerül szerializálásra:
spring.jackson.serialization.write_dates_with_context_time_zone=false
A write_dates_with_zone_id true beállítás felülírja az időzóna konvertálást, tehát write_dates_with_zone_id=true esetben a ZonedDateTime értékek mindenképpen az eredeti időzónában fognak szerepelni a JSON-ben (az ofszetet tartalmazó típusok esetén a beállításnak nincs hatása ez esetben sem).
Ha tehát az adatbázisunk UTC időzónában fut de a backend szerverünk nem, akkor akkor a Jackson időzónát érdemes beállítani a backend szerver használni kívánt időzónájához (a time-zone beállítással) mert csak ez esetben fogja az API-nk kliense a kívánt időzónára konvertálva megkapni az adatbázisból UTC-ben visszakapott dátum/idő értékeket. Persze itt is egyedi mérlegelés szükséges, hiszen lehetnek olyan kliensek amik UTC-ben vagy akár a backend által egyedileg beállított időzónában szeretnék megkapni az értéket és a time-zone beállítás mindig konvertál. Egyedi esetekhez a lentebb tárgyalt mezőszintű annotációkat használhatjuk.
Deszerializáció
A dátum/idő deszerializációhoz is tartozik egy fontos beállítás:
spring.jackson.deserialization.adjust_dates_to_context_time_zone=false
Ez az alapértelmezetten true beállítás azt vezérli, hogy a JSON-ben megkapott dátum/idő értékeket a Jackson átkonvertálja a spring.jackson.time-zone beállításban megadott időzónába. Tehát így bármilyen időzónában is kapjuk az értékeket az API-nkon, a Jackson mindenképpen átkonvertálja a kontextus beállított időzónájára. Legyen az alábbi beállítás (most YAML formában):
spring:
jackson:
deserialization:
adjust_dates_to_context_time_zone: false
time-zone: Pacific/Kiritimati
Ez esetben például az alábbi requestnél:
{
„zonedDateTime”: „2020-01-01T02:00:00+02:00”,
„offsetDateTime”: „2024-09-30T01:00:00+02:00”,
„localDateTime”: „2024-09-30T01:00:00”,
„localDate”: „2024-09-30”,
„localTime”: „23:00:00”,
„offsetTime”: „20:00+02:00”
}
Ezt fogjuk a resource objektumban kapni:
DateTimeResponseDTO [zonedDateTime=2020-01-01T02:00+02:00, offsetDateTime=2024-09-30T01:00+02:00, localDateTime=2024-09-30T01:00, localDate=2024-09-30, localTime=23:00, offsetTime=20:00+02:00]
Ha az adjust_dates_to_context_time_zone paramétert kitöröljük vagy átírjuk az alapértelmezett true-ra, akkor már ez lesz az eredmény:
DateTimeResponseDTO [zonedDateTime=2020-01-01T14:00+14:00[Pacific/Kiritimati], offsetDateTime=2024-09-30T13:00+14:00, localDateTime=2024-09-30T01:00, localDate=2024-09-30, localTime=23:00, offsetTime=20:00+02:00]
Vagyis a kapott értékek átkonvertálódtak a megadott időzónába. Ha nincs megadva kontextus időzóna akkor az értékek UTC-be konvertálódnak:
DateTimeResponseDTO [zonedDateTime=2020-01-01T00:00Z, offsetDateTime=2024-09-29T23:00Z, localDateTime=2024-09-30T01:00, localDate=2024-09-30, localTime=23:00, offsetTime=20:00+02:00]
ObjectMapper
A Jackson arra is ad lehetőséget, hogy a kódban egyedileg tudjunk szerializálni akár REST kontrollerek nélkül is. Erre való a com.fasterxml.jackson.databind.ObjectMapper osztály. Mivel ennek a példánya konfigurálás után szálbiztos, ha a konfigurálást a létrehozáskor megcsináljuk akkor utána már a futó programunkban akár egy példány is elég, vagy osztályszintű mezőként is használhatjuk.
private final ObjectMapper mapper = new ObjectMapper();
Önmagában az ObjectMapper a java.time osztályokhoz nem használható, kézi létrehozásnál ehhez külön regisztrálni kell a használni kívánt modult (com.fasterxml.jackson.datatype.jsr310.JavaTimeModule):
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
A kívánt szerializációs és deszerializációs beállításokat is meg tudjuk adni az ObjectMapper-ünknek a létrehozáskor a disable és enable metódusokkal:
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())//
.setTimeZone(TimeZone.getTimeZone(„Pacific/Kiritimati”))//
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
Ezután a mapper-t használhatjuk szerializálásra:
ObjectWriter writer = mapper.writerWithDefaultPrettyPrinter();
String serialized = writer.writeValueAsString(result);
És deszerializálásra:
ObjectReader reader = mapper.reader();
TipusokResponseDTO result = reader.readValue(serialized, TipusokResponseDTO.class);
Annotációk
Ha nem tudjuk vagy nem akarjuk az általános beállításokat használni akkor mezőszintű annotációval is vezérelhetjük a Jackson-t a DTO-inkban. Az annotációkban mező szinten adhatjuk meg és írhatjuk felül a konfigurációt. Nagyok sokféle beállításra és szerializáció vezérlésére van lehetőség, itt most természetesen csak a dátum/idő kezeléssel kapcsolatos beállításokat sorolom fel (és nem térek ki az egyedi serializer/deserializer írására sem amivel teljesen szabad kezet kapunk arra, hogy mit csinálunk (de)szerializáláskor). A fenti beállításokat a JsonFormat annotációval adhatjuk meg:
@JsonFormat(without = { JsonFormat.Feature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE }, with = {
JsonFormat.Feature.WRITE_DATES_WITH_ZONE_ID })
private ZonedDateTime zonedDateTime;
Ebben a példában a Jackson a mezőt nem a globális beállítások szerint fogja konvertálni, hanem az annotációban megadottak szerint, a with és a without tömbök értelemszerűen működnek. Így akár mezőszinten is megadhatunk egyedi konvertálási beállítást.
De teljesen kézi beállítást is megadhatunk a formázáshoz:
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = „yyyy-MM-dd’T’HH:mm:ss.SSS’Z'”)
private OffsetDateTime offsetDateTime;
Ez esetben a Jackson előbb átkonvertálja a globális beállításoknak megfelelően a mező értékét és arra alkalmazza a formázást. A JsonFormat annotációnak van egy timezone paramétere is, de ez csak a régi java.util dátum/idő formátumoknál van használva ezért itt nem tárgyalom.
A JsonFormat annotáció minden Java 8 dátum/idő típushoz egy egyedi (de)serializer-t implementál. Ezeket a jackson-modules-java8 modul hozza magával. Ezek az implementációk mind a com.fasterxml.jackson.databind.ser.std.StdSerializer és com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer osztályból származnak. Ezek az osztályok a Jackson alap osztályai amiből származtatva tetszőleges (de)szerializálót lehet implementálni. A jackson-modules-java8 modulban vannak a Java 8 dátum/idő típusaihoz a megfelelő implementációk, például:
- ZonedDateTimeSerializer / InstantDeserializer
- OffsetDateTimeSerializer / InstantDeserializer
- LocalDateTimeSerializer / LocalDateTimeDeserializer
És így tovább. Ezekkel nem kell foglalkoznunk, mert a modul belső működéséhez kellenek. Az Instant, ZonedDateTime és OffsetDateTime deszerializálását is egy megfelelően előre paraméterezett InstantDeserializer példány végzi, nincs külön egyedi deserializer implementációjuk. Azt viszont megtudhatjuk belőlük, hogy az annotáció pattern paramétere egy java.time.format.DateTimeFormatter-hez kell, így ennek a dokumentációjából kiderül, hogyan tudjuk paraméterezni. Az annotáció pattern paraméterét minden java.time típushoz használhatjuk, értelemszerűen a csak időt vagy csak dátumot tartalmazó típusoknál megfelelő korlátokkal.
Backend kialakítási lehetőségek
Végezetül néhány általános szempontot szeretnék bemutatni, amiket egy adott rendszer tervezésekor érdemes figyelembe venni. Ebben nagy mértékben támaszkodtam a W3C ajánlásaira is, de figyelembe kell venni, hogy sok esetben az üzleti igény vagy az integrálandó külső rendszerek miatt ezeket a szempontokat nem lehet érvényesíteni.
Használati esetek
Időbélyegek (timestamp)
Ha a tárolt események nincsenek konkrét helyi időhöz kötve, akkor használhatunk egyszerű UTC időbélyeg értékeket. Vagyis ha az alkalmazásnak soha nem kell megmondania az esemény bekövetkeztekori tényleges helyi időt, hanem csak az események relatív sorrendjével kell foglalkoznia. Például ha eseményeket rögzítünk naplófájlokban vagy naplófájlokat egyesítünk akkor az UTC időbélyeg tökéletesen megfelelő (és még a téli/nyári időszámítás folytonossági problémáival sem kell foglalkoznunk). Az ilyen típusú időeseményekhez egy Instant típus megfelelő.
Az időértékeket általában a legjobb UTC-re konvertálni, hogy a különálló adatsorok könnyen összehasonlíthatók és összefűzhetők legyenek. A helyi ofszettel kapcsolatos információk értékesek lehetnek a tényleges helyi idő visszaállításához, de az időzóna-szabályok valószínűleg csak ritkán érdekesek.
Múltbeli események
A múltban bekövetkezett eseményeknél (ha jövőbeli eseményekkel szigorúan nem foglalkozunk), amelyeknél fontos, hogy mennyi volt a megfigyelt helyi idő, az esemény időzónája is kellhet további adatként. Ha egy esemény már a múltban van, az inkrementális időhöz való viszonya rögzül, és a helyi megfigyelt idő generálására vonatkozó szabályok lényegében örökre statikusak maradnak. Viszont továbbra is tudni kell, hogy egy esemény helyi idő szerint 10:00-kor, nem pedig 14:00-kor történt. Ezért legalább a zóna ofszet szükséges, bár bizonyos alkalmazásokhoz a teljes időzóna ismerete kell. A konkrét időzóna ismerete lehetővé teszi az idő rekonstrukcióját és kapcsolatát a többi zónabeli megfigyelt idővel.
Múltbeli és jövőbeli események
Ha az alkalmazás múltbeli és jövőbeli eseményekkel is foglalkozik (például naptárral vagy megbeszélés-ütemezéssel), további időzóna-információkra lesz szükség az idővel való helyes számítások biztosításához. Az ofszet ilyenkor nem elég, az időzónára is szükség van. Ennek az az oka, hogy egy jövőbeli esemény megfigyelt ideje az időzónával kapcsolatos információktól függ, például a téli/nyári időszámítástól. A jövőbeli események egyik problémája az, hogy az időzóna-szabályok változhatnak, és előfordulhat, hogy egy alkalmazásnak frissítenie kell az érintett adatrekordokat, hogy megfeleljen a felhasználói elvárásoknak. Ha a rendszer az érték időbeli részét inkrementális időként tárolja (sok esetben ez történik), akkor azt módosítani kell, ha az UTC-hez viszonyított ofszet megváltozott.
Ismétlődő események
Az ismétlődő eseményeket, például a rendszeres megbeszélések, általában egy olyan szabályrendszer határozza meg, amely kifejezi a felhasználó szándékát. Egyes esetekben a felhasználó azt szeretné, hogy az esemény egy adott helyi időben ismétlődjön (és így a helyi idő változásaihoz, például a téli/nyári időszámítási átmenetekhez is köti). Más esetekben a felhasználó egy másik időzónához, egy adott UTC-ofszethez vagy más eseményekhez szeretné kötni az időt. Így például előfordulhat, hogy egy ismétlődő heti eseménynek 167, 168 vagy 169 órát kell hozzáadnia egy esemény „múlt heti előfordulásához” az e heti kezdési időpont kiszámításához, attól függően, hogy történt-e átállás nyári vagy téli időszámításra.
Lebegő idejű események
A lebegő idejű eseményeknél elhagyjuk az időzónát vagy az ofszetet az időértékből (például a LocalDate vagy LocalDateTime típusnál). Általában fontos tudni, hogy az időérték mikor jelent lebegő időt, mert ezt az alkalmazásnak máshogy kell kezelnie.
Például ha az alkalmazás egy 2019-01-01 lebegő időértéket nulla ofszetű inkrementális idővé alakít, akkor előfordulhat, hogy a végén 2018-12-31 értéket fogunk látni, mivel a helyi ofszet miatt az érték nem megfelelően lett átalakítva.
Időzónák és ofszetek ábrázolása
Ha az alkalmazásunknak egy jövőbeli ismétlődő eseményt kell ábrázolni, mint például egy rendszeres online megbeszélés, akkor az OffsetDateTime típus egy plusz változóval kiegészítve, ami az ismétlődési információt tartalmazza (például hogy hetente) nem fogja mutatni, hogy a megbeszélésnek a következő nyári időszámításra átállás után már másik helyi időpontban kell kezdődnie. És azt sem, hogy melyik időzóna szabályait kell alkalmazni, hogy meghatározzuk a helyi időt.
Tegyük fel hogy például „2010-07-10T07:00:00-07:00” OffsetDateTime mondja meg a megbeszélések kezdetét. Ha például a „-07:00” arra szolgál hogy a US Pacific időzónát mutassa, akkor a következő emlékeztető üzenetek lehet hogy rossz időben lesznek generálva amikor a US Pacific időzóna ősszel visszaáll a nyári időszámításból télire. Ráadásul más időzónákban lévő felhasználók, ahol az átállás másik dátumban van, bizonytalanok lesznek benne hogy mikor kell bejelentkezniük a konferenciahívásra.
Irányelvek
Az adatbázis mellett a backend szervernek is érdemes UTC-ben futnia és csak a webes rétegben konvertálunk helyi időre. Webes alkalmazásban a Spring Security UserDetails megoldását is használhatjuk a felhasználó időzónájának tárolására és ha konvertálásra van szükség, onnan megkapjuk a szükséges időzónát. Adott esetben viszont a backend szerver futhat akár helyi időzónában is, ha például a rendszernek nincsenek eltérő időzónában lévő felhasználói. A W3C ajánlása szerint ha nem tudjuk eldönteni, mire van szükségünk akkor a legjobb megoldás, ha UTC-t használunk dátum/idő értékek szerializálásakor, tárolásakor és adatcserénél.
A backend-frontend közötti kommunikációban használjuk az ISO 8601 formátumot a dátum és idő továbbítására. JSON-ben általában mező alapú a kommunikáció, a Java-n belül viszont sokszor inkrementális.
Ne kezeljünk és ne tároljunk időt ha csak a dátum a lényeges. Így sok fejfájástól kímélhetjük meg magunkat. Az ISO 8601 tartalmazza a csak dátum szabványt is (pl. 2024-10-08). Érdemes megvizsgálni a használt frontendi komponenseket is mert vannak keretrendszerek, ahol a csak dátumválasztó komponensek képesek 0 óra 0 percet (vagy bármi mást) hozzáfűzni a dátumhoz. Ne tegyék! De ha az alkalmazásunkat több időzónában is használják és a lebegő idő formátumú tárolás nem elegendő, akkor sajnos mégiscsak szükség lehet az idő tárolására is (amikor az egyik időzónában már a következő nap van, könnyen lehet, hogy egy másikban még az előző). Erre sajnos nincs általános irányelv, az üzleti igénytől függ, hogy szükséges-e a dátumokhoz az időt is tárolni.
Ha ismétlődő eseményekkel dolgozunk:
- az idő és az időzóna mellett tároljuk az eredeti ofszetet és azt is, hogy alkalmazzuk-e a nyári/téli időszámítást
- számoljuk újra a jövőbeli inkrementális időket ha az időzóna szabályok megváltoznak (ehhez szükségünk lesz az eredeti ofszetekre)
Ha időzónát szeretnénk kezelni:
- tegyük lehetővé, hogy a felhasználó választhasson időzónát amit lehetőség szerint a session-höz vagy a profilhoz társítunk
- esetleg mutassunk példa városokat is, hogy a felhasználónak egyszerűbb legyen kiválasztani az időzónát, illetve mutassuk az országot útmutatóként, mivel a legtöbb országban csak egy időzóna van
- hagyjuk ki a régi, már nem létező időzónákat, ha nincs rá szükség
- időzóna meghatározásához használjuk az IP geolokációt, mobiltelefon cellaadatokat, GPS adatokat vagy egyéb más adatforrásokat ha van ilyen
- DATE, TIME és DATETIME típusokkal ha lehet, használjunk explicit időzóna ofszetet, kiegészítő mezőben pedig tároljuk az időzónát ha lehetséges
- ne végezzünk dátum- vagy időtípuson alapuló műveleteket (például indexelés) olyan adatsorokon, amelyekben egyes adatelemek tartalmaznak zóna ofszeteket, mások nem
Hogyan dolgozzunk jövőbeli és ismétlődő eseményekkel
Jövőbeli időpontokat kezelő és tároló alkalmazásnál (beleértve az ismétlődő eseményeket is), további adatmezők kellenek a helyes működéshez és hogy a jövőbeli időértékeket az időzónák és az időzóna-szabályok változásaihoz igazíthassuk (például ha megváltozik a téli/nyári időszámítás). Tárolni kell a kiinduló időzónát és az alkalmazott eredeti ofszetet is. Ha az esemény ismétlődő, akkor érdemes egy flag-et is tárolni, ami jelzi, hogy a téli/nyári időszámítási átmeneteket alkalmazni kell-e az esemény jövőbeni előfordulásaira vagy sem: ez jelzi, hogy a felhasználó helyi időt vagy inkrementális időt kívánt-e használni.
Például ha egy felhasználó egy telefonhívást 2025. augusztus 27-én, szerdán 10:30-ra közép-európai nyári időszámításra (Central European Summer Time) ütemez, akkor a tárolt mezőértékek a következők lehetnek:
2025-08-27T10:30:00+02:00 // +02:00 az eredeti ofszet érték
Europe/Budapest // ez az időzóna
false // ne módosítsuk az időt ha a téli/nyári időszámítás vagy a szabályok változnak
Ha ezután a felhasználó ehhez a híváshoz beállít egy ismétlődési szabályt is, például „hetente”, akkor kiszámítható, hogy a „10:30 CEST” „09:30 CET” lesz, amikor a közép-európai időzóna átáll a nyáriról a téli időszámításra. Ha mező alapú tárolást alkalmazunk és a közép-európai időzónára vonatkozó szabályok módosulnak, nem kell módosítani az adatokat, tehát nem kell végignyálazni az adatbázisban lévő összes rekordot és frissíteni őket, az inkrementális változóban tárolt időt azonban újra kell számolni. Az ismétlődés beállításakor:
- tároljuk el az eredeti ofszetet és hogy alkalmazni kell-e téli/nyári időszámítási szabályokat is az időn és időzónán kívül
- számoljuk újra a jövőbeli inkrementális időket ha az időzóna szabályok változnak (ehhez fog kelleni az eredeti ofszet)
Sipos Róbert (2025)
Források:
https://medium.com/@anushkadarr/guide-to-time-zone-handling-for-rest-apis-in-java-4b76b8f31c56
https://dev.mysql.com/doc/connectors/en/connector-j-time-instants.html
https://dev.mysql.com/blog-archive/support-for-date-time-types-in-connector-j-8-0/
https://dev.mysql.com/doc/connector-j/en/connector-j-connp-props-datetime-types-processing.html
https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html#time-zone-variables
https://vladmihalcea.com/time-zones-java-web-application/
https://thorben-janssen.com/hibernate-6-offsetdatetime-and-zoneddatetime/
https://www.linkedin.com/pulse/how-utc-iso-8601-can-save-your-day-jean-landercy
https://www.timeanddate.com/time/gmt-utc-time.html
https://blog.ttulka.com/how-to-test-date-and-time-in-spring-boot/