W tym artykule skupię się nad pracą z datami w aplikacjach internetowych. Przejdę od reprezentacji w bazie danych, poprzez przetwarzanie na serwerze, a kończąc na deserializacji / serializacji i operowaniu na datach w części klienckiej.
W tym artykule znajdziesz odpowiedzi na poniższe pytania:
- Jakie mamy dostępne typy reprezentacji daty w części serwerowej i klienckiej?
- Jak wygląda serializacja / deserializacja dat w Javascript?
- Jak zintegrować część kliencką z częścią serwerową?
Wpis będzie bazował na przykładach. Załóżmy że, część serwerowa jest napisana w Java 8+, a część kliencka w Angular.
Część serwerowa (Java)
Omawiając część serwerową należy zacząć od komponentu w którym przechowywane są dane, czyli bazy danych. Załóżmy, że operujemy na bazie danych PostgreSQL
. W bazie danych możemy zapisać datę na przynajmniej 3 sposoby:
- typ
date
– określający samą datę2022-01-08
- typ
time /z
– określający sam czas z/bez uwzględnieniem strefy czasowej - typ
timestamp /z
– określający datę wraz z znacznikiem czasu z/bez uwzględnienia strefy czasowej
Tak naprawdę mamy 5 typów, ponieważ dwa ostatnie typy należy rozbić na 2 rodzaje, w których różnica polega na przechowywaniu dodatkowej informacji o strefie czasowej.
W ostatnim punkcie rozróżniamy timestampz
i timestamp
. Typ Timestampz
oprócz daty i znacznika czasu przechowuje dodatkowo informację o strefie czasowej. PostgreSQL
domyślnie przechowuje wartość timestamptz
w UTC. To oznacza, że podczas odczytu, baza danych konwertuje datę z UTC na datę z lokalnej strefy czasowej, a podczas zapisu konwertuje na czas UTC. Zaś typ Timestamp
nie przechowuje informacji o strefie czasowej i odpowiedzialność za przestawienie czasu należy do samej aplikacji.
SHOW TIME ZONE; > UTC SELECT '2022-01-09 12:00:00 +02:00'::timestamp as "Timestamp without time zone"; > 2022-01-08 12:00:00 SELECT '2022-01-09 12:00:00 +02:00'::timestampz as "Timestamp with time zone"; > 2022-01-08 10:00:00+00
Przechodząc do warstwy aplikacji, dostępne api w Java 8+ oferuje nam kilka typów danych do reprezentacji daty i czasu. Pozwala nam uwzględnić wszystkie przypadki i przechowywać datę na kilka sposobów:
Instant
– klasa reprezentuje konkretny punkt na osi czasu UTC, czyli wartość całkowita oznaczająca liczbę nanosekund od 1 stycznia 1970, godz. 00:00:00 UTC.
Instant instant = Instant.now(); // "2022-01-09T11:00:00.000000000Z"
OffsetDateTime
– klasa reprezentuje moment, czyli datę, znacznik czasu i konkretne przesunięcie względem czasu UTC. Inaczej klasaInstant
zZoneOffset
.
ZoneOffset zoneOffset = ZoneOffset.of("+01:00"); OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(instant, zoneOffset); // "2022-01-09T12:00:00.000000000+01:00"
ZonedDateTime
– klasa reprezentuje moment, czyli datę, znacznik czasu i strefę czasową. Inaczej klasaInstant
z przydzielonymZoneId
.
ZoneId zoneId = ZoneId.of("Europe/Warsaw"); ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, zoneId); // "2022-01-09T12:00:00.000000000+01:00[Europe/Warsaw]"
LocalDateTime
– klasa reprezentuje datę i znacznik czasu. Nie posiada informacji o strefie czasowej ani przesunięciu względem UTC.
LocalDateTime localDateTime = LocalDateTime.now(); // "2022-01-09T12:00:00.000000000"
LocalDate
– klasa reprezentuje konkretną date.
LocalDate localDate = LocalDate.now(); // "2022-01-09"
Format reprezentacji
W przypadku aplikacji internetowych format JSON stał się dość powszechnym formatem serializacji. Używany jest do serializacji, zarządzania konfiguracją i przechowywania wszystkiego co jest związane z REST. Poruszam temat formatu JSON, ponieważ jest bardzo powiązany z problemem, który istnieje w aplikacjach części klienckiej, a dokładnie problemem serializacji / deserializacji dat. Specyfikacja JSON nie określa formatu wymiany dat, dlatego istnieje wiele sposobów, aby to zrobić.
Często spotykane formaty:
– wartość całkowita reprezentująca liczbę milisekund od 1 stycznia 1970, godz. 00:00:00 UTC. np. 1641726000000
– format zgodny z ISO8601 np. '2022-01-09T11:00:00.000Z’ (najwygodniejsza forma reprezentacji daty)
Część kliencka (Angular)
Temat reprezentacji, parsowania, serializacji i deserializacji dat w części klienckiej poruszany jest od dawna. Po części można uznać, że JavaScript dobrze serializuje daty, ale nie koniecznie deserializuje .
Warto w tym miejscu zaznaczyć, że w bibliotekach części klienckiej nie ma specjalnych typów dla samej daty jak LocalDate
lub daty z znacznikiem czasu jak LocalDateTime
. Istnieje jedna reprezentacja jak w starym api Javy, a więc operujemy na jednym typie – Date
.
Serializacja
Javascript standardowo serializuje daty do format zgodnego z ISO8601.
JSON.stringify({value: new Date()}); > '{"value":"2022-01-09T11:00:00.000Z"}'
Jeśli zależy nam na reprezentowaniu dat jako liczba milisekund, ponieważ część serwerowa tego oczekuje to należy samemu przekonwertować taką datę na typ number
przed wysłaniem do api.
JSON.stringify({value: new Date().getTime()}); > '{"value":1641726000000}'
Łatwo zauważyć, że podczas domyślnego serializowania daty dodawany jest znak Z
(Zulu time) na koniec wartości. To oznacza, że data jest reprezentowana w formacie UTC. Wysyłając wartość 2022-01-09T11:00:00.000Z
do serwera i będąc w Polsce w okresie zimowym to tak naprawdę informujemy serwer, że została wybrana data 2022-01-09T12:00:00.000
. Na pierwszy rzut oka, ten problem nie wygląda tak strasznie, ponieważ nie spotkałem się z sytuacją, aby część kliencka tworzyła znacznik czasu i wysyłała go do części serwerowej. Zazwyczaj w części klienckiej wysyłana jest konkretna data bez części uwzględniającej czas. Zobaczmy na przykładzie:
var date = new Date(2022, 0, 9); > Sat Jan 09 2022 00:00:00 GMT+0100 (czas środkowoeuropejski standardowy) JSON.stringify({value: date}); '{"value":"2022-01-08T23:00:00.000Z"}'
Użytkownik wybiera dzień 2022-01-09
w datepicker
, a aplikacja wysyła do api serwera wartość 2022-01-08T23:00:00.000Z
. Serwer nie analizuje wartości po znaku T
, ponieważ oczekuje tylko daty typu LocalDate
, a więc otrzymuje dzień 2022-01-08
. W tym miejscu pojawia się problem. Podobna sytuacja ma miejsce, gdy serwer oczekuje wartości typu LocalDateTime
. Ten typ nie posiada informacji o strefie czasowej, więc będzie przechowywał wartość 2022-01-08T23:00:00.000
.
Istnieją 2 popularne obejście tego problemu. Pierwsze rozwiązanie to kontrola serializacji dat w serwisach, czyli przed wysłaniem formularza do api należy przekonwertować datę na typ number
/ string
. Drugie rozwiązanie zdejmuje obowiązek pilnowania tego przez programistę i polega na nadpisaniu metody odpowiadającej za serializacje daty. W tym przypadku, należy na początku ustalić konwencje reprezentowania dat, która będzie utrzymywana w całym projekcie.
Date.prototype.toJSON = function() { return DateConverter.toJSON(this); };
Deserializacja
W przypadku deserializacji dat nie ma tyle problemów jak w procesie odwrotnym. Głównie dlatego, że proces deserializacji nie istnieje dla dat 🙂 . W polu gdzie oczekujemy wartości typu Date
otrzymujemy to co dostaniemy z api. Istnieje kilka obejść i rozwiązań. Jedną z nich jest rozszerzenie parsera JSON. Takie podejście ma pewne konsekwencje jak na przykład dodatkowy nakład czasowy. Jeśli operujemy na złożonych obiektach i listach, to musimy przejść po każdym elemencie z listy oraz rekurencyjnie po zagnieżdżonych obiektach w celu przekonwertowania string
/ number
na typ Date
.
Pytanie, czy zawsze potrzebujemy daty w typie Date? Często chcemy tylko wyświetlić taką datę w tabelce, więc robimy tylko dodatkowy nakład czasowy konwertując na typ Date, aby finalnie ponownie przekonwertować za pomocą DatePipe na string i wyświetlić. Warto zaznaczyć, że DatePipe przyjmuje wartości typu: number, string i Date.
W projektach przy których pracowałem zazwyczaj dopuszczaliśmy, aby w polu o typie Date istniała wartość taka jaka przyszła z backendu. Dopiero w przypadku jakichkolwiek operacji konwertowaliśmy na typ Date poprzez własny konwerter.
Drugie podejście bazuje na „lazy value”, które dopuszcza wartości typu string
lub number
, ale konwertuje w razie potrzeby.
Podczas własnej deserializacji należy być ostrożnym, ponieważ nie każdą datę możemy utworzyć poprzez konstruktor. Do takiego celu zaleca się utworzyć osobną klasę narzędziową.
new Date(2022, 0, 9); > Sat Jan 09 2022 00:00:00 GMT+0100 (czas środkowoeuropejski standardowy) new Date(2022, 0, 9, 0, 0, 0) > Sat Jan 09 2022 00:00:00 GMT+0100 (czas środkowoeuropejski standardowy) new Date('2022-01-09T00:00:00') > Sat Jan 09 2022 00:00:00 GMT+0100 (czas środkowoeuropejski standardowy) new Date('2022-01-09') > Sat Jan 09 2022 01:00:00 GMT+0100 (czas środkowoeuropejski standardowy)
W listingu powyżej należy zwrócić uwagę na ostatni przykład. W konstruktorze została dostarczona data w formacie ISO bez znacznika czasu. Teoretycznie oczekiwalibyśmy rezultatu Sat Jan 09 2022 00:00:00 GMT+0100 (czas środkowoeuropejski standardowy)
, czyli daty z godziną wskazującą na północ. Problem polega na tym, że konstruktor interpretuje wartość jako czas w UTC i przestawia czas na lokalny.
Podsumowanie
Post ma na celu pokazania częstych problemów i błędów pojawiających się podczas operacji na datach. Nie ma jednego dobrego rozwiązania. Warto pochylić się nad tematem reprezentowaniu i przesyłania dat już na etapie projektowania aplikacji. Ogólnie rzecz biorąc, najlepiej jest używać formatu UTC, chyba że mamy konkretną potrzebę ustalenia strefy czasowej, przykładowo jeśli wytwarzamy aplikację dla konkretnego klienta w konkretnej strefie czasowej.
Ciekawe artykuły
- J. Kozak, Best Moment.JS Alternatives, https://medium.com/swlh/best-moment-js-alternatives-5dfa6861a1eb
- H. Maui, JavaScript JSON Date Parsing and real Dates, https://weblog.west-wind.com/posts/2014/jan/06/javascript-json-date-parsing-and-real-dates
- O. Rayward, How to store dates and times in PostgreSQL, https://medium.com/building-the-system/how-to-store-dates-and-times-in-postgresql-269bda8d6403
- Understanding PostgreSQL Timestamp Data Types, https://www.postgresqltutorial.com/postgresql-timestamp/
- B. Bourque, What’s the difference between Instant and LocalDateTime?, https://stackoverflow.com/a/32443004/10827444