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
intervalwyraż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
InstantzLocalDateTimeskończy się błędem kompilacji - nie możemy tego porównać, gdyż nie znamy strefy czasowej drugiego składnika. - Wynik dodania
LocalDateiLocalTimeto typLocalDateTimei w drugą stronę - składoweLocalDateTimeto odpowiednioLocalDateiLocalTime. - 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
LocalDateTimeotrzymamy 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.