Date/Time API

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 klasa Instant z ZoneOffset.
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 klasa Instant z przydzielonym ZoneId.
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"
Źródło: Basil Bourque, https://stackoverflow.com/a/32443004/10827444

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

  1. J. Kozak, Best Moment.JS Alternatives, https://medium.com/swlh/best-moment-js-alternatives-5dfa6861a1eb
  2. 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
  3. 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
  4. Understanding PostgreSQL Timestamp Data Types, https://www.postgresqltutorial.com/postgresql-timestamp/
  5. B. Bourque, What’s the difference between Instant and LocalDateTime?, https://stackoverflow.com/a/32443004/10827444

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *