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:
- System nie zna godziny przyjazdu
- 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
zLocalDateTime
skończy się błędem kompilacji - nie możemy tego porównać, gdyż nie znamy strefy czasowej drugiego składnika. - Wynik dodania
LocalDate
iLocalTime
to typLocalDateTime
i w drugą stronę - składoweLocalDateTime
to odpowiednioLocalDate
iLocalTime
. - Sam
LocalTime
- czas dnia - nigdy nie będzie większy lub równy 24 godzinom czym fundamentalnie różni się od typuDuration
. - Również operacje ze strefami czasowymi pozostawiają mniej niedomówień - dodając strefę czasową do
LocalDateTime
otrzymamy typZonedDateTime
.
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.