Przetwarzanie daty i czasu w aplikacjach zawsze sprawiało programistom wiele trudności gdyż trzeba wziąć wiele rzeczy pod uwagę. Wystarczy zerknąć na tę stronę aby uzmysłowić sobie ile potencjalnych błędnych założeń możemy popełnić. Mnogość różnych formatów zapisu daty i czasu, strefy czasowe, zmiany czasu z letniego na zimowy i odwrotnie oraz inne tego typu aspekty powodują, że łatwo jest strzelić sobie w stopę. Na domiar złego wszystko komplikuje ekosystem Javy, która udostępnia kilka różnych interfejsów i mechanizmów operacji na datach, przez co szczególnie początkujący mogą poczuć się zagubieni i nieświadomi potencjalnych problemów.

W tym artykule wyjaśnię w jaki sposób przetwarzać, zapisywać i testować czas w języku Java przy okazji wspominając o potencjalnych problemach, które możemy napotkać.

Legacy Api

W języku Java mamy do dyspozycji wiele klas i interfejsów wspierających przetwarzanie dat i czasu. Gdy Java wyszła na świat w pierwszej wersji otrzymaliśmy klasę java.util.Date, która reprezentuję punkt w czasie z milisekundową dokładnością.

Date now = new Date();
Date tomorrow = new Date(now.getTime() + 1000 * 3600 * 24);
System.out.println(now.getMonth() + 1); // print month
System.out.println(now.getYear() + 1900); // print year
if (now.before(tomorrow)) {
	System.out.println("now is before tomorrow");
}
if (tomorrow.after(now)) {
	System.out.println("tomorrow is after now");
}
if (now.compareTo(now) == 0) {
	System.out.println("now is equal to now");
}

Jak widać powyżej API nie jest intuicyjne i nie należy do najprostszych. Wyciąganie takich składowych jak rok czy miesiąc lub formatowanie jest nieporęczne nie wspominając o internacjonalizacji czy strefach czasowych. Obecnie większość metod tej klasy jest oznaczona jako @Deprecated a sama klasa nie jest zalecana do użycia.

Rozwiązaniem powyższych problemów miała być klasa Calendar, którą otrzymaliśmy w wersji 1.1 wraz z klasami odpowiedzialnymi za formatowanie (DateFormat, SimpleDateFormat) i strefy czasowe (TimeZone, SimpleTimeZone).

// Calendar with default timzone
Calendar calendar = Calendar.getInstance();

// Calendar with specific timezone
Calendar calendarWithZone = Calendar.getInstance(TimeZone.getTimeZone("Europe/Warsaw"));

// get date object
Date now = calendar.getTime();

// set time object
Date later = new Date();
calendar.setTime(later);

// get date components
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH);
int day = calendar.get(Calendar.DAY_OF_MONTH);
int hour = calendar.get(Calendar.HOUR);
int minute = calendar.get(Calendar.MINUTE);
int second = calendar.get(Calendar.SECOND);
int millis = calendar.get(Calendar.MILLISECOND);

// add 24 hours
calendar.add(Calendar.HOUR, 24);

// Internationalization
Locale locale = new Locale("pl", "PL");
DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.FULL, locale);
System.out.println(dateFormat.format(later)); // czwartek, 3 listopada 2022

API jest bardziej czytelne i intuicyjne jednak obecnie również niezalecane do wykorzystywania. Interfejs był projektowany gdy w specyfikacji języka nie było jeszcze typu wyliczeniowego więc łatwo o błąd w przypadku stałych numerycznych - nie możemy liczyć na błąd kompilacji. Co więcej klasa Calendar jest narzutem na wydajność i zużywa sporo pamięci ponieważ wewnętrznie przechowuje stan na dwa różne sposoby.

Kolejnym problemem dla omówionych powyżej API jest bezpieczeństwo w przypadku przetwarzania współbieżnego tj. na wielu wątkach. Z uwagi na to, że klasy Date jak i Calendar mogą zmieniać stan (mutable) łatwo o błąd gdy dojdzie do wyścigu w związku z tym niezbędna jest synchronizacja lub używanie mechanizmu ThreadLocal.

Istnieją również klasy rozszerzające klasę java.util.Date (wrappery) m.in. java.sql.Date, java.sql.Time, java.sql.Timestamp stworzone na potrzeby jdbc-api. Mają one zastosowanie gdy integrujemy się z bazą danych w związku z tym nie powinniśmy ich używać w kodzie domenowym a jedynie do odczytu/zapisu czy też mapowania relacyjno-obiektowego.

Nowe API od Java 8

W wersji 8 doczekaliśmy się nowego API związanego z obsługą dat i czasu kojarzone również jako JSR-310. Nowa specyfikacja jest o wiele bardziej intuicyjna i pozwala zastąpić stare API omówione w poprzednim punkcie jak i inne zewnętrzne biblioteki usprawniające prace z czasem np. joda-time - najczęściej używana przed wydaniem tej wersji.

Nowe API zostało usytuowane w pakiecie java.time i zawiera m.in. następujące klasy:

Insant - Reprezentuje określony punkt w czasie z nanosekundową precyzją.

Duration - Odcinek czasu z nanosekundową precyzją np. 34.5 sekundy.

LocalDateTime - Data i czas bez strefy czasowej w standardzie ISO-8601 z nanosekundową precyzją
np. 2022-12-03T14:15:30.

LocalDate - Data bez strefy czasowej w standardzie ISO-8601 np. 2022-10-03.

LocalTime - Czas bez strefy czasowej w standardzie ISO-8601 z nanosekundową precycją np. 12:30:45.

ZonedDateTime - Data i czas z strefą czasową w standardzie ISO-8601 z nanosekundową precyzją
np. 2022-11-14T14:15:30+01:00 Europe/Warsaw.

ZoneId - Identyfikator strefy czasowej np. Europe/Warsaw.

Year - Rok w standardzie ISO-8601 np. 2022.

MonthDay - Miesiąc i dzień w standardzie ISO-8601 np. 11-28.

YearMonth - Rok i miesiąc w standardzie ISO-8601 np. 2022-12.

ZoneOffset - Offset od czasu Greenwich/UTC z sekundową precyzją np. +02:00.

OffsetDateTime - Data i czas z przesunięciem od czasu Greenwich/UTC w standardzie ISO-8601
z nanosekundową precyzją np. 2022-11-06T12:15:30+01:00.

OffsetTime - Czas z przesunięciem od czasu Greenwich/UTC w standardzie ISO-8601
z nanosekundową precyzją np. 11:15:30+01:00.

Period - Odcinek czasu jako ilość lat, miesięcy i dni w standardzie ISO-8601 np.
'2 years, 3 months and 4 days'.

Clock - Pozwala na dostęp do aktualnego czasu i strefy czasowej.

Klas jest dość sporo więc na początku może być trudno się zorientować, których użyć w jakim przypadku natomiast niebagatelną zaletą tego jest to, że każda klasa ma swoje przeznaczenie zgodnie z zasadą SRP w przeciwieństwie do omawianej wcześniej klasy Date. Warto również zwrócić uwagę na to, że każda z klas jest immutable & thread safe co z pewnością wpływa na bezpieczeństwo i jakość oprogramowania.

Zanim przejdę do omówienia najważniejszych klas bardziej szczegółowo warto zerknąć na obrazek poniżej, który jest pomocny w odnalezieniu się w tym gąszczu klas.

Instant

Reprezentuje określony moment w czasie. Początek został wyznaczony na północ 1 stycznia 1970 roku. Począwszy od tego punktu czas jest mierzony w sekundach po 86400 sekund na dobę w tył oraz w do przodu z dokładnością do nanosekund. Wartości klasy Instant mogą określać czas sięgający miliarda lat wstecz (Instant.MIN). Instant.MAX odpowiada dacie 31 grudnia 1 000 000 000, jeżeli przekroczymy tę wartość otrzymamy wyjątek DateTimeException.

@Test
void testInstant() {
	Instant now = Instant.now();
	Instant later = now.plusSeconds(3600);
	System.out.println(now + ", " + later); // 2022-11-05T17:56:40.669420Z, 2022-11-05T18:56:40.669420Z
	assertTrue(later.isAfter(now));
}

Instant idealnie nadaje się do rejestrowania czasu różnego rodzaju zdarzeń lub logów.

Duration

Reprezentuje odcinek czas pomiędzy dwoma punktami w sekundach z dokładnością do nanosekund gdzie 24h = 86400 sekund. Długość Duration możemy wygodnie pobierać używając takich metod jak toNanos, toMillis, toSeconds, toMinutes, toHours, toDays. Przydaje się w wyliczaniu różnego rodzaju odległości w czasie.

@Test
void testDuration() {
	Instant now = Instant.now();
	Instant later = now.plus(4, ChronoUnit.HOURS);
	var duration = Duration.between(now, later);
	assertEquals(4, duration.toHours());
}

Używając Duration można również w łatwy sposób „skakać”
w czasie definiując odpowiednią długość w czasie.

Instant now = Instant.now();
Instant tomorrow = now.plus(Duration.ofDays(1));

Warto wiedzieć, że klasa Duration obsługuje parsowanie w standardzie ISO-8601 w związku z tym można przekazać do metody odpowiedni łańcuch znaków. Dzięki temu możemy w łatwy sposób wyrazić odległość w czasie za pomocą jednej zmiennej, która może być wyniesiona do jakiejś zewnętrznej konfiguracji.

Duration duration = Duration.parse("P2DT3H4M"); // 2 days, 3 hours and 4 minutes

LocalDateTime

Zawiera informację o dacie oraz czasie z nanosekundową precyzją natomiast nie udostępnia żadnych informacji na temat strefy czasowej. Z tego względu czas wyrażony z użyciem LocalDateTime nie może być rozpatrywany jako bezwględny ponieważ może być inny w zależności od tego gdzie się znajdujemy w przeciwieństwie do Instant, który jest wyliczany bez względu na strefę czasową.

LocalDateTime now = LocalDateTime.now();

Domyślnie lokalna data jest wyliczana na podstawie domyślnej strefy czasowej ustawionej w systemie lub na maszynie wirtualnej. Warto pamiętać, że w przypadku LocalDateTime nie należy polegać na systemowej strefie czasowej
gdyż wyniki mogą być różne w zależności od tego gdzie zostanie zainstalowana nasza aplikacja. Na szczęście mamy nad tym kontrolę i możemy do metody statycznej przekazać strefę czasową, na podstawie której zostanie wygenerowana data.

LocalDateTime now = LocalDateTime.now(ZoneId.of("Europe/Warsaw"));

Jeżeli chcemy globalnie ustawić lokalną strefę czasową dla aplikacji możemy przekazać odpowiednią wartość do maszyny wirtualnej w następujący sposób.

java -Duser.timezone="Europe/Warsaw" -jar app.jar

LocalTime

Zawiera informację o lokalnym czasie z pominięciem strefy czasowej z nanosekundową precyzją i nie zwraca uwagi na podział AM/PM, tym zajmują się mechanizmy odpowiedzialne za formatowanie.

Utworzenie obiektu tej klasy sprowadza się do pobrania aktualnego czasu poprzez LocalTime.now() lub z góry ustalonego czasu LocalTime.of(hour, minute, second, nanoOfSecond) gdzie:

  • hour - liczba z zakresu 0-23
  • minute - liczba z zakresu 0-59
  • second - liczba z zakresu 0-59
  • nanoOfSecond - liczba z zakresu 0-999 999 999

@Test
void localTimeTest() {
	LocalTime now = LocalTime.now(ZoneId.of("Europe/Warsaw"));
	LocalTime later = now.plusHours(4);
	assertTrue(later.isAfter(now));
	assertEquals(4, Duration.between(now, later).toHours());	
}

LocalDate

Reprezentuje rok, miesiąc i dzień bez informacji o czasie i strefie czasowej. Idealnie nadaje się np. do zapisania daty urodzin lub innych wydarzeń gdzie czas nie ma znaczenia.

LocalDate birthday = LocalDate.of(2022, 11, 14);

Aby obliczyć datę urodzin w przyszłym roku wystarczy użyć wyrażenia w postaci birthday.plus(Period.ofYears(1)) lub po prostu birthday.plusYears(1). Należy pamiętać, że w roku przestępnym wywołanie w postaci birthday.plus(Period.ofDays(365)) zwróci nieprawidłowy wynik.

Warto również wspomnieć, że stworzenie daty 29 lutego w roku nieprzestępnym np. LocalDate.of(2023, 2, 29) objawi się wyjątkiem typu DateTimeException.

Jak wyliczyć dzień programisty

Dzień programisty wypada na 256 dzień w roku. Jak go wyliczyć biorąc pod uwagę, że w roku przestępnym jest o jeden dzień więcej? Możemy to zrobić w następujący sposób.

LocalDate programmersDayInCurrentYear = 
				LocalDate.now()
				.withMonth(1)
				.withDayOfMonth(1)
				.plusDays(255);

ZonedDateTime

ZonedDateTime reprezentuje datę i czas wraz z strefą czasową z nanosekundową precyzją. Bazę danych wszystkich stref czasowych na całym świecie przechowuje agencja IANA i aktualizuje ją kilka razy w roku. Java korzysta z tej bazy danych.

Każda strefa czasowa ma swój identyfikator np. America/New_York czy Europe/Berlin i aktualizuje ją kilka razy w roku. Listę wszystkich dostępnych stref czasowych możemy pobrać przy użyciu ZoneId.getAvailableZoneIds().

Dysponując tym identyfikatorem można przekształcić obiekt LocalDateTime na ZonedDateTime.

LocalDateTime now = LocalDateTime.now();
ZonedDateTime znow = now.atZone(ZoneId.of("Europe/Warsaw"));
System.out.println(now); // 2022-11-13T02:38:03.934777
System.out.println(znow); // 2022-11-13T02:38:03.934777+01:00[Europe/Warsaw]

Możemy również pobrać aktualny czas na podstawie domyślnej strefy czasowej tj. ZonedDateTime.now() lub bezpośrednio przekazać identyfikator strefy ZonedDateTime.now(ZoneId.of("Europe/Warsaw")).

Wywołanie LocalDateTime.atZone(...) dodaję strefę czasową tworząc obiekt typu ZonedDateTime jednak nie zmienia czasu na ten wskazany przez strefę czasową (LocalDateTime nie ma informacji o strefie czasowej więc nawet nie byłoby to możliwe). Jeżeli naszym celem jest również zmiana czasu możemy zrobić to w następujący sposób.

LocalDateTime someDate = LocalDateTime.parse("2022-11-10T18:40:00.000"); // time in Europe/Warsaw zone
ZonedDateTime convertedToSpecificZone = ZonedDateTime.ofInstant(
someDate.atZone(ZoneId.of("Europe/Warsaw")).toInstant(),
ZoneId.of("America/New_York"));

W przypadku konwersji ZonedDateTime -> ZonedDateTime robimy podobnie.

ZonedDateTime someDateWithZone = ZonedDateTime.now(ZoneId.of("Europe/Warsaw"));
ZonedDateTime newYorkTime = ZonedDateTime.ofInstant(someDateWithZone.toInstant(), ZoneId.of("America/New_York"));

lub zwięźlej

ZonedDateTime newYorkTime = someDateWithZone.withZoneSameInstant(ZoneId.of("America/New_York"))

OffsetDateTime

Reprezentuję datę i czas wraz z przesunięciem od czasu UTC/Greenwich z nanosekundową precyzją. Upraszczając OffsetDateTime jest to Instant + ZoneOffset i w taki też sposób możemy utworzyć obiekt tej klasy.

OffsetDateTime offsetDateTime = Instant.now().atOffset(ZoneOffset.of("+01:00"));
// lub
OffsetDateTime now = OffsetDateTime.now();

W przeciwieństwie do klasy ZonedDateTime nie mamy tu informacji o strefie czasowej a tym samym o przybliżonej lokalizacji geograficznej, jedynie sztywno zdefiniowany offset. Warto o tym pamiętać szczególnie gdy następuje zmiana czasu, różnicę możemy zobaczyć w kodzie poniżej, gdzie w dzień zmiany czasu z letniego na zimowy przesunęliśmy się w czasie o 4 godziny.

LocalDateTime dateTime = LocalDateTime.parse("2022-10-30T00:00:00.000");
ZonedDateTime zonedDateTime = dateTime.atZone(ZoneId.of("Europe/Warsaw")).plusHours(4);
OffsetDateTime offsetDateTime = dateTime.atOffset(ZoneOffset.of("+02:00")).plusHours(4);
System.out.println(zonedDateTime); // 2022-10-30T03:00+01:00[Europe/Warsaw]
System.out.println(offsetDateTime); // 2022-10-30T04:00+02:00

Period

Klasa mierząca odcinek czasu jako ilość lat, miesięcy i dni w odróżnieniu od klasy Duration omówionej wcześniej, gdzie najmniejszą jednostką, która mierzy upływ czasu były sekundy/nanosekundy.

Z tego względu Period użyjemy raczej do wyliczenia różnicy między dwoma datami typu LocalDate.

var now = LocalDate.parse("2022-11-10");
var later = now.plusDays(64);
var period = Period.between(now, later);
System.out.println(period.getMonths()); // 2
System.out.println(period.getDays()); // 3

Warto zwrócić uwagę, że mimo iż powyżej dodaliśmy 64 dni metoda getDays() zwraca nam 3 gdyż reszta dni została skonsolidowana do ilości miesięcy.

Period różni się także od Duration sposobem traktowania czasu letniego/zimowego w przypadku gdy dodajemy czas do ZonedDateTime. Jak wspomnieliśmy wcześniej Duration traktuje dzień zawsze jako 24h czyli 86400 sekund więc dokładnie o tyle przesuniemy czas w tył lub w przód, w przeciwieństwie do Period gdzie koncepcja dnia stara się zachować czas lokalny. Najłatwiej będzie zobaczyć to na przykładzie.

var dateTime = ZonedDateTime.parse("2022-10-29T09:00+02:00[Europe/Warsaw]");
System.out.println(dateTime.plus(Duration.ofDays(7))); // 2022-11-05T08:00+01:00[Europe/Warsaw]
System.out.println(dateTime.plus(Period.ofDays(7))); // 2022-11-05T09:00+01:00[Europe/Warsaw]

Ustawiłem datę na dzień przed zmianą czasu z letniego na zimowy. Dodając równo 7 dni do tej daty używając Duration godzina również się zmieniła z 9 na 8. Inaczej jest w przypadku Period gdzie godzina została zachowana (w przypadku LocalDateTime w obu przypadkach byłaby taka sama godzina tj. 9 gdyż ta klasa nie bierze pod uwagę zmiany czasu). Warto o tym pamiętać gdy np. chcemy zaplanować jakieś spotkanie w przyszłości.

TimeAdjusters

Często mamy potrzebę wyliczenia pierwszego/ostatniego dnia miesiąca/roku/tygodnia lub innych tego typu wyliczeń. Z pomocą przychodzi klasa TemporalAdjusters. Przykładowo wyliczenie pierwszego dnia miesiąca może wyglądać następująco.

LocalDate firstDayOfMonth = now.with(TemporalAdjusters.firstDayOfMonth());
// lub
LocalDate firstDayOfMonth =  (LocalDate) TemporalAdjusters.firstDayOfMonth().adjustInto(now);

Dostępnych jest wiele innych modyfikatorów m.in.

TemporalAdjusters.firstDayOfMonth();
TemporalAdjusters.lastDayOfMonth();
TemporalAdjusters.firstDayOfYear();
TemporalAdjusters.firstDayOfNextMonth();
TemporalAdjusters.firstDayOfNextYear();
TemporalAdjusters.firstInMonth(DayOfWeek.SATURDAY);
TemporalAdjusters.previous(DayOfWeek.FRIDAY);

Formatowanie, parsowanie i wyświetlanie

Aplikacje nad którymi pracujemy, często wymagają wyświetlenia a tym samym formatowania i parsowania daty i czasu na różne sposoby - czasami celem jest serializacja i przesłanie wartości przez sieć a czasem po prostu zwykłe wyświetlenie.

W najprostszym przypadku możemy skorzystać z domyślnego formatu tj. zgodnego z standardem ISO-8601 wywołując metodę toString() na obiekcie daty.

String now = LocalDate.now().toString();
System.out.println(now); // 2022-11-11
LocalDate localDate = LocalDate.parse(now);

Jednak najczęściej to nam nie wystarczy. Chcielibyśmy mieć większą kontrolę nad formatowaniem i parsowaniem czasu. Z pomocą przychodzi klasa DateTimeFormatter, która pozwala bardzo precyzyjnie to wyrazić.

Zdefiniowanie formatu sprowadza się do stworzenia
obiektu tej klasy na podstawie odpowiedniego wzorca.

LocalDate localDate = LocalDate.parse("2022-10-14");
var formatter = DateTimeFormatter.ofPattern("dd LLLL, yyyy");
String formatted = localDate.format(formatter); // lub formatter.format(localDate);
System.out.println(formatted); // 14 October, 2022

W celu wyświetlenia daty w rodzimym języku podczas
tworzenia formattera należy przekazać odpowiedni Locale.

LocalDate localDate = LocalDate.parse("2022-10-14");
Locale pl = new Locale("pl", "PL");
var formatter = DateTimeFormatter.ofPattern("dd LLLL, yyyy", pl);
System.out.println(localDate.format(formatter)); // 14 październik, 2022

Istnieje również wiele predefiniowanych formatów zgodnych
z standardem ISO-8601, które możemy wykorzystać m.in.

DateTimeFormatter.ISO_LOCAL_DATE
DateTimeFormatter.ISO_LOCAL_DATE_TIME
DateTimeFormatter.ISO_INSTANT
...

Testowanie

Aby napisane przez nas oprogramowanie było solidne warto zadbać o dobre testy jednostkowe i integracyjne.

W przypadku gdy testowany przez nas kod pobiera aktualny czas (np. Instant.now()) i na jego podstawie wykonuje pewną logikę nasz kod staje się trudno testowalny, gdyż nie mamy kontroli nad upływającym czasem, który za każdym uruchomieniem testu będzie inny.

Z pomocą przychodzi klasa Clock i jej pochodne, dzięki którym możemy w prosty sposób skonfigurować źródło, na podstawie którego czas będzie pobierany.

Clock clock = Clock.system(ZoneId.of("Europe/Warsaw"));
Instant instant = Instant.now(clock);

Jeżeli korzystamy z mechanizmu wstrzykiwania zależności np. w Spring'u możemy pójść o krok dalej i stworzyć komponent, który będzie wstrzykiwany tam gdzie nastąpi potrzeba pobierania czasu systemowego.

@Configuration
class AppConfig {

	@Bean
	public Clock clock() {
		return Clock.system(ZoneId.of("Europe/Warsaw"));
	}
}

@Service
class SomeService {

	private final Clock clock;

	public SomeService(Clock clock) {
		this.clock = clock;
	}

	public String bar() {
        return "Hello world " + Instant.now(clock);
    }
}

Następnie możemy przejść do przetestowania funkcjonalności podmieniając implementację Clock'a aby odzyskać kontrolę w testach.

@Test
void barTest() {
    // given
    Clock clock = Clock.fixed(Instant.parse("2022-11-12T10:39:33.307871Z"), ZoneId.of("Europe/Warsaw"));
    var subject = new SomeService(clock);

    // when
    String result = subject.bar();

    // then
    assertEquals("Hello world 2022-11-12T10:39:33.307871Z", result);
}

Pułapki

Planowanie spotkań

Załóżmy, że chcemy zaplanować spotkanie co tydzień o 14:30 czasu w strefie Europe/Warsaw. W tym celu musimy dodać do ostatniego czasu strefowego 7 dni czyli 7 * 4 * 3600 sekund, jednak jeśli przy tym przekroczymy granice zmiany czasu z letniego na zimowy okaże się, że spotkanie wypadnie o godzinę za wcześnie lub za późno.

ZonedDateTime dateTime = ZonedDateTime.of(LocalDateTime.of(2022, 10, 29, 14, 30), ZoneId.of("Europe/Warsaw"));
System.out.println(dateTime.plusSeconds(7 * 24 * 3600)); // 13:30 !!!

Z tego powodu projektanci API zalecają aby godzin z informacjami o strefie czasowej używać jedynie wtedy gdy posługiwanie się bezwzględnymi momentami czasu jest absolutnie niezbędne. Urodziny, święta, zaplanowane spotkania najlepiej zapisywać przy użyciu lokalnych dat i godzin.

LocalDateTime localDateTime = LocalDateTime.of(2022, 10, 29, 14, 30);
System.out.println(localDateTime.plusSeconds(7 * 24 * 3600)); // 14:30 OK

Używanie LocalDateTime gdy musimy mieć więcej informacji

Jak wiemy klasa LocalDateTime nie posiada informacji o żadnym offsecie od czasu UTC a tym samym o strefie czasowej. Jeżeli przykładowo tworzymy aplikację, która ma za zadanie przypominać użytkownika o różnych wydarzeniach i datę rozpoczęcia wydarzenia zapiszemy w postaci LocalDateTime tak naprawdę utracimy informację o faktycznym czasie rozpoczęcia (chyba, że zakładamy, że używamy tylko jednej globalnej strefy czasowej). Z tego względu warto mieć w takim przypadku dodatkową informację tj. strefę czasową w jakiej operuje użytkownik.

Błędy związane z formatowaniem

  • Używanie "mm" dla miesięcy i "MM" dla minut i odwrotnie
  • Używanie "DD" jako dzień miesiąca zamiast roku, w rzeczywistości dzień miesiąca to "dd"
  • Używanie "hh" (1-12) jako godzina w danym dniu zamiast "HH" (0-23)
  • Używanie "YYYY" do formatowania roku zamiast "yyyy"

Podsumowanie

Java 8 przyniosła liczne i długo wyczekiwane zmiany w kontekście date & time API. Nowe API jest bezpieczniejsze i prostsze w użyciu, z pewnością więc warto jest nim zastąpić poprzednie implementacje. Na koniec kilka dobrych praktyk, które mogą Ci się przydać przy pracy z datami i czasem:

  • Unikaj używania konstrukcji daty i czasu, które są zbyt szczegółowe lub zbyt ogólne
  • Nie polegaj na systemowej strefie czasowej lub tej ustawionej w bazie danych
  • Używaj Clock w celu konfiguracji źródła, na podstawie którego czas będzie pobierany
  • Staraj się używać formatu w standardzie ISO-8601 w API, które oferujesz
  • Używaj ZonedDateTime jeśli musisz uwzględniać strefy czasowe i zmiany czasu
  • Używaj Instant jeśli chcesz zapisać czas wystąpienia jakiegoś zdarzenia jako punkt na osi czasu
  • Przechowuj czas w UTC i konwertuj na czas lokalny po stronie frontendu jeżeli musisz wyświetlać daty z uwzględnieniem różnych stref czasowych
  • Unikaj używania legacy date-time API w Java na rzecz nowego API
  • Pamiętaj o walidacji pól wpisywanych przez użytkownika
  • Pamiętaj o testach jednostkowych szczególnie przypadków brzegowych tj.
    - początek/koniec miesiąca/roku
    - dni powszednie i weekendy
    - 29 lutego
    - strefy czasowe, czas letni

Po więcej szczegółowych informacji zachęcam do przejrzenia dokumentacji a także do zerknięcia na świetną prezentację autorstwa Tomasza Nurkiewicza. A tymczasem zostawiam Cię z utworem i do następnego 👋