Łukasz Olender
Shiji Poland
Łukasz OlenderLead Backend Developer @ Shiji Poland

Problemy z DateTime(Offset) w .NET

Dowiedz się, jak rozwiązać problemy związane z użyciem typu DateTime(Offset) w .NET.
17.03.20226 min
Problemy z DateTime(Offset) w .NET

Są domeny w których czas jest bardzo ważny a pomyłki - kosztowne. Czy typy i funkcje, które dają nam koledzy z Microsoftu wraz ze standardowymi bibliotekami C#, są dla Ciebie wystarczające?


Kierując się prawem nagłówków Betteridge’a, odpowiedź na pytanie zawarte we wstępie artykułu powinno brzmieć nie. Czy jest tak w rzeczywistości? Odpowiedź w dużej mierze zależy od domeny i stopnia złożoności rozwiązywanego problemu. Zapraszam na krótką podróż do świata turystyki.

Wymagania funkcjonalne

Zamodelujmy sobie system dla hoteli - zarządzanie rezerwacjami.

Jakie mogą być scenariusze użycia?

Z pewnością przyda się nam funkcjonalność wyświetlania daty i czasu w hotelu, która może aktualnie być różna od daty i czasu serwera. Gdzieś w konfiguracji samego hotelu będziemy potrzebowali zapisać także standardowe godziny zameldowania i wymeldowania (check-in/check-out). Potrzebujemy też zapisać datę przybycia i planowaną date opuszczenia hotelu oraz wyliczać długość pobytu. Jeśli gość przybywa o niestandardowej porze, przyda się informacja o godzinię przyjazdu i wyjazdu, szczególnie w przypadku organizowania transferów.

No i jeszcze jedno - logi z systemu też muszą mieć datę i czas, a sam system musi być testowalny.

Jak możemy to zamodelować?

Zacznijmy od końca - logi z systemu. Pomimo, że serwery fizycznie gdzieś się znajdują, możemy przyjąć, że system działa niezależnie od stref czasowych hoteli i operatorów. Chcemy porównywać czas logów, żeby rozwiązując problemy, móc odtworzyć kolejność zdarzeń oraz umieścić je na osi czasu. Użyjmy uniwersalnego czasu koordynowanego UTC. Typ w .NET? DateTimeOffset. Jak go pobierzemy? DateTimeOffset.UtcNow.

Pobierać czas będziemy potrzebować również dla funkcjonalności wyświetlania daty i czasu w hotelu. Tu będziemy potrzebowali już czasu lokalnego. Mając strefę czasową (TimeZoneInfo) przechowaną w zmiennej timeZone, możemy dostać ją poprzez TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone).

Bezpośrednie odwoływania się do statycznej wartości DateTimeOffset.UtcNow są problematyczne z perspektywy testowania, gdyż wyniki nie są deterministyczne. Dobrze jest więc zastąpić je funkcją, którą będziemy mogli w prosty sposób podmienić w testach. Func<DateTimeOffset>.

Przejdźmy do biznesu. Zapis godzin zameldowania i wymeldowania w hotelu. Hmm... Potrzebujemy zapisać godzinę... Być może godzinę i minutę... Ogólnie - czas, bez konkretnej daty. Jakie mamy opcję? Zgodnie z MSDN, naszym rozwiązaniem będzie TimeSpan wyrażający interwał czasu. Dla hotelu z check-outem o 14:30 byłby to new TimeSpan(14, 30, 0).

No i czas na samą rezerwację. Potrzebujemy zapisać daty i opcjonalnie czasy początku i końca pobytu. Typ DateTimeOffset pozwala na zapis daty i czasu, więc użyjmy go. Długość rezerwacji to różnica pomiędzy jej końcem a początkiem - TimeSpan.

Poniżej nasze zestawienie:

Wymaganie Typ
Logi systemowe DateTimeOffset
Lokalny czas hotelu DateTimeOffset
Daty i czasy rezerwacji DateTimeOffset
Długość rezerwacji TimeSpan
Godziny check-in/check-out TimeSpan
Pobieranie aktualnego czasu Func<DateTimeOffset>

Wady?

Powyższe podejście ma jedną wielką wadę - niejednoznaczność typów, z której wynika szereg problemów. Gdy system tworzysz w pojedynkę i jest on niewielki, być może tego nie odczujesz, ale współpracując z innymi programistami, jasne przekazywanie naszych intencji jest niesamowicie ważne.

Mając sygnaturę funkcji zwracającą DateTimeOffset lub TimeSpan, mamy niewielkie pojęcie o semantyce zwracanego obiektu. Czy zwracany DateTimeOffset jest w UTC? Czy może oznacza datę i czas w strefie czasowej hotelu? Czy strefa czasowa jest ustawiona w tej konkretnej instancji obiektu?

A może nasz DateTimeOffset (lub DateTime) zawiera samą date? Być może tak jest, jeśli czas ustawiony jest na 00:00 - północ. Jednak gdy modelujemy czas przyjazdu rezerwacji, północ może oznaczać dwie rzeczy:

  1. System nie zna godziny przyjazdu
  2. System zna godzinę przyjazdu i jest to północ


Przejdźmy do TimeSpan - czy funkcja zwraca jakiś czas w ciągu dnia (check-in/check-out)? Czy może długość rezerwacji? Lub być może, przesunięcie DateTimeOffset względem UTC?

Niejednoznaczność typów

DateTime może przechowywać datę ORAZ czas ORAZ strefę czasową. TimeSpan potrafi równie dobrze zamodelować czas w ciągu dnia jak i odległości pomiędzy zdarzeniami w czasie. Typy potrafią wiele - to chyba dobrze? Im więcej tym lepiej, prawda?

Otóż, nie.

Nie, ponieważ informacje pozwalające nam rozróżnić o co właściwie chodzi w danym kontekście musimy zawrzeć gdzieś indziej. Nazwa zmiennej, konwencje w projekcie, dodatkowe metadane (np. flagi logiczne lub enumy), walidacje, komentarze, testy lub jakaś kombinacja wyżej wymienionych. No i dyscyplina by się tego trzymać - będąca odpowiedzialnością człowieka.

Na człowieku również spoczywa weryfikacja, czy ktoś czasem nie dodaje TimeSpan oznaczającego czas w dniu do TimeSpan oznaczającego długość rezerwacji... Lub czy TimeSpan oznaczający czas w dniu nie jest czasem większy niż 24 godziny.

Jesteśmy w stanie upilnować dużo, ale może zawieść czynniki ludzki. Sugerowanie upilnowania wszystkiego jest rozwiązaniem takim samym jak sugerowanie sprawdzania za każdym razem czy obiekt jest różny od null - dalekie od ideału. W większych projektach po prostu... Prędzej czy później nastąpi pomyłka. Jeśli jest alternatywa to jest ona conamjmniej godna rozważenia.

Rozwiązanie?

Daty i czas zostały zaadresowane w wielu językach i technologiach - z różnym powodzeniem. W standardzie SQL mamy na przykład:

  • osobno typy na datę oraz na czas (w wersji ze strefą lub bez),
  • osobno datę z czasem (również w dwóch wersjach),
  • typ interval wyrażający różnice w czasie.


Z perspektywy tego artykułu, interesująco wygląda sytuacja w świecie Javy. Otóż kilka lat temu, w wersji 8, API do dat i czasu przeszło gruntowną modernizację w dobrym kierunku. I być może kiedyś w świecie .NET również doczekamy się nowego API w tym obszarze, jednak póki co pozostają nam dostępne biblioteki osób i firm trzecich.

Sytuacja z Javą jest o tyle interesująca, że współtwórcą nowego API dat i czasu był Stephen Colebourne, twórca biblioteki Joda-Time. A z kolei na Joda-Time wzorował się Jon Skeet tworząc dotnetową implementację nazwaną Noda Time.

Noda Time

Noda Time zawiera alternatywne (dla biblioteki standardowej) typy do pracy z datami i czasem. Typy te pozwalają precyzyjniej wyrażać nasze intencje, co przekłada sie na lepsze zrozumienie modelowanego systemu oraz mniejszą liczbę błędów.

Przestawiony na początku artykułu problem można zamodelować przy użyciu poniższych typów.

Wymaganie Typ(y) Opis typu (typów)
Logi systemowe Instant Punkt na osi czasu
Lokalny czas hotelu LocalDateTime Data i czas dnia bez strefy czasowej
Daty i czasy rezerwacji LocalDate + LocalTime Data i osobno lokalny czas dnia
Długość rezerwacji Duration Długość czasu
Godziny check-in/check-out LocalTime Czas dnia
Pobieranie aktualnego czasu IClock Interfejs będący zamiennikiem statycznego odwołania DateTime.UtcNow



Co daje nam ich użycie? Przede wszystkim, kod jest semantycznie bardziej poprawny. Zawieramy więcej ilości informacji w samych typach - informacji na temat intencji. Lepiej przekazane intencje to także mniej błędów ponieważ kompilator pomoże nam, gdy próbujemy zrobić coś, co nie ma sensu. Przykłady?

  • Operacja porównania Instant z LocalDateTime skończy się błędem kompilacji - nie możemy tego porównać, gdyż nie znamy strefy czasowej drugiego składnika.
  • Wynik dodania LocalDate i LocalTime to typ LocalDateTime i w drugą stronę - składowe LocalDateTime to odpowiednio LocalDate i LocalTime.
  • Sam LocalTime - czas dnia - nigdy nie będzie większy lub równy 24 godzinom czym fundamentalnie różni się od typu Duration.
  • Również operacje ze strefami czasowymi pozostawiają mniej niedomówień - dodając strefę czasową do LocalDateTime otrzymamy typ ZonedDateTime.


Typy z Noda Time dobrze mapują się także na typy w bazie SQL - każdy z wyżej wymienionych typów ma swój odpowiednik na bazie.

Noda Time - używać, czy nie?

Przedstawiony w artykule przykład pokazuje, że nawet tworząc aplikację której domena tylko lekko zahacza o kwestie związane z czasem, natkniemy się na problemy niejednoznaczności dotnetowego API. Pracując osobiście z o wiele bardziej złożonymi problemami z obszaru systemów dla hotelarstwa, użycie Noda Time wychodzi zdecydowanie na plus. Korzystanie z typów z Noda Time uważam za dobrą praktykę, gdyż pozwala, jak wspomniałem:

  • wyłapać więcej błędów na etapie kompilacji,
  • precyzyjniej wyrazić intencje,
  • jaśniej myśleć o samym problemie.


Noda Time jest dojrzałą i stabilną biblioteką. Znajdziesz też szereg dodatkowych paczek ułatwiających jej użycie w wielu projektach (Entity Framework, ASP.NET MVC, JSON.NET i inne). Na początek gorąco polecam zapoznanie się z niezwykle przystępną i interesującą dokumentacją, oraz odpowiedzenie sobie na pytania:

Jak wysokie mogą być koszty pomyłek i poprawy błędów w moim systemie w kodzie obsługującym daty i czas?

oraz

czy zyski płynące z posiadania tej zależności w moim programie przewyższają koszty jej utrzymania?

Choć z całego serca rekomenduję tą bibliotekę, decyzję musisz (musicie?) podjąć samodzielnie.

<p>Loading...</p>