Jak automatycznie zmieniać czas lokalny na UTC w ASP.NET MVC?

Wprowadzenie

W bardzo wielu aplikacjach w bazie danych zapisujemy ten sam czas, jaki przyszedł do nas od użytkownika w formularzu. W przypadku gdy tworzysz aplikację na rynek polski, takie podejście może okazać się bezproblemowe, bo wszyscy jesteśmy w jednej strefie czasowej i mamy ten sam czas. Problemy zaczynają się, gdy tworzymy aplikację, w której użytkownicy znajdują się w różnych strefach. Wtedy takie podejście może się okazać bardzo problematyczne.

Z tego powodu zaleca się, aby podczas zapisu daty do bazy danych zmienić jest strefę czasową z lokalnej na UTC, a następnie podczas odczytu zrobić odwrotną rzecz, czyli zamienić czas UTC na lokalny dla danego użytkownika. Dzięki takiemu podejściu dwóch użytkowników w różnych strefach czasowych zobaczy różną datę, ale ten sam moment w czasie.

Najlepiej taką zamianę zautomatyzować w aplikacji, abyśmy mieli pewność, że wszystkie daty w bazie danych są podane w UTC. Myślę, że od braku zamiany daty na UTC gorsza jest tylko jej częściowa zamiana (część dat zamieniamy na UTC, a część nie). Poniżej pokażę, jak zrobić automatyczną zamianę daty w aplikacji ASP.NET MVC. Podejście można również przenieść na inne typy aplikacji.

Gdzie jaki czas?

W pierwszej kolejności zaczniemy od określenia, jaki czas znajduje się w której części aplikacji. Jak już wiele razy wspominałem, w swoich aplikacjach wykorzystuję ideę viewmodeli. Służą one do przekazywania danych do widoków, a dane od użytkownika są do nich bindowane. Dlatego w viewmodelach będzie znajdował się czas lokalny.

W pozostałych częściach aplikacji używam klas modeli (na podstawie których między innymi generowana jest baza danych przez Entity Framework). W modelach będzie się znajdował czas w UTC. Sama zamiana czasu lokalnego na UTC oraz w drugą stronę będzie odbywała się podczas zmiany modelu na viewmodel i na odwrót. Wykorzystamy do tego automapper i konwerter typów.

DateTime.Kind

Type DateTime posiada właściwość Kind, którą wykorzystamy podczas konwersji czasu. Właściwość może przyjąć jedną z wartości:

  • Local – informacja, że czas jest lokalny
  • Utc – informacja, że czas jest w UTC
  • Unspecified – informacja, że nie można określić, w jakim formacie jest czas.

Dla dwóch pierwszych przypadków logika zamiany czasu jest prosta. Problem pojawia się w przypadku ostatniej opcji. Dodatkowo gdy sprawdzimy, to okaże się, że domyślny model binder w ASP.NET MVC podczas bindowania danych również ustawia właściwość Kind na Unspecified. Podobnie jest w przypadku Entity Framework. Również tam DateTime ma właściwość Kind ustawioną na Unspecified.

Dlatego potrzebujemy jakiegoś założenia, które ułatwi nam pracę. Jak wspomniałem wyżej, chcę mieć czas lokalnytylko w viewmodelach, więc możemy nieco zmodyfikować domyślny model binder i w nim ustawiać właściwość Kind na Local. Natomiast w każdym innym miejscu aplikacji będziemy traktować Unspecified jako UTC.

Model Binder dla DateTime

Zmiana zachowania domyślnego model bindera nie jest trudna. Wystarczy utworzyć nową klasę, która dziedziczy po DefaultModelBinder, i w niej nadpisać metodę BindModel:

public class DateTimeBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = base.BindModel(controllerContext, bindingContext);
if (bindingContext.ModelType == typeof(DateTime))
{
var date = (DateTime) value;
return DateTime.SpecifyKind(date, DateTimeKind.Local);
}
if (bindingContext.ModelType == typeof(DateTime?))
{
var date = value as DateTime?;
if (date.HasValue)
{
return DateTime.SpecifyKind(date.Value, DateTimeKind.Local) as DateTime?;
}
}
return value;
}
}

W pierwszej kolejności wywołujemy bazową metodę BindModel, aby DateTime został poprawnie zbindowany. Następnie sprawdzamy, czy bindowany typ jest DateTime (lub nullowalną wersją), i odpowiednio za pomocą metody SpecifyKind ustawiamy informację, że czas jest lokalny.

Na końcu trzeba jeszcze dodać tak utworzony model binder do ASP.NET MVC:

public class ModelBindersConfig
{
public static void Configure()
{
var dateBinder = new DateTimeBinder();
ModelBinders.Binders.Add(typeof(DateTime), dateBinder);
ModelBinders.Binders.Add(typeof(DateTime?), dateBinder);
}
}
public class MvcApplication : System.Web.HttpApplication
{
protected void Application_Start()
{
AutofacConfig.Configure();
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
ModelBindersConfig.Configure();
}
}

W dedykowanej klasie dla konfiguracji model binderów dodajemy DateTimeBinder dla DateTime oraz DateTime?. Następnie wywołujemy metodę Configure z ModelBindersConfig w klasie MvcApplication z pliku Global.asax.cs.

W ten sposób czas w viewmodel będzie miał informację, że jest czasem lokalnym.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Jaka strefa czasowa użytkownika?

Najtrudniejszym elementem całej układanki jest określenie, w jakiej strefie czasowej znajduje się użytkownik. Niestety w żądaniu HTTP nie otrzymujemy automatycznie takiej informacji. Musimy sami ją ustalić.

Rozwiązuje się to na różne sposoby. W niektórych aplikacjach jest to po prostu ustawienie, które użytkownik może zmienić i dostosować do swoich potrzeb. Czasami strefę czasową odgaduje się na podstawie adresu IP użytkownika. Jeszcze innym rozwiązaniem jest bazowanie na strefie czasowej wykrytej w javascript i przesłanie jej do serwera. W przykładzie wykorzystam ten ostatni sposób.

Przykładowa aplikacja została utworzona z domyślnego szablonu, w którym dodane jest jquery (dodałem jeszcze plugin do jquery służący do obsługi plików cookie), dlatego je wykorzystam. Cała logika wykrywania strefy czasowej znajduje się w pliku core.js, który jest ładowany w pliku layout, przez co będzie wykonywał się na każdej stronie aplikacji:

$(document).ready(function () {
setTimezone();
});
function setTimezone()
{
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
$.cookie("timezone", tz);
}
view raw core.js hosted with ❤ by GitHub

Po załadowaniu strony wykona się metoda setTimezone, która wykrywa z przeglądarki strefę czasową i zapisuje ją w pliku cookie, który później będzie odczytany w aplikacji ASP.NET MVC podczas konwersji czasu. To rozwiązanie ma jeden minus. W pierwszym żądaniu nie mamy informacji o strefie czasowej i zostanie użyta domyślna strefa. Z drugiej strony na ogół to nie jest duży problem, ponieważ w większości aplikacji użytkownik musi się najpierw zalogować, więc w widoku logowania już tę informację uzyskamy. Możemy również, w przypadku gdy nie mamy informacji o strefie czasowej z javascript, posłużyć się informacją o strefie na podstawie adresu IP użytkownika.

Mając już informację o strefie użytkownika, możemy przejść do właściwej konwersji dat.

TypeConverter dla DateTime

Do automatycznej konwersji wykorzystamy TypeConvertery z automappera. W tym przypadku konwerter będzie trochę specyficzny, bo mapowanie będzie z DateTime na DateTime:

public class DateTimeToDateTimeConverter : ITypeConverter<DateTime, DateTime>
{
private IDateService _dateService;
public DateTimeToDateTimeConverter(IDateService dateService)
{
_dateService = dateService;
}
public DateTime Convert(DateTime source, DateTime destination, ResolutionContext context)
{
if (source.Kind == DateTimeKind.Unspecified)
{
source = DateTime.SpecifyKind(source, DateTimeKind.Utc);
}
if (source.Kind == DateTimeKind.Local)
{
return _dateService.ConvertToUtc(source);
}
return _dateService.ConvertToLocal(source);
}
}

W pierwszej kolejności sprawdzamy, czy Kind nie jest czasem ustawiona na Unspecified. Gdy tak jest, to ustawiamy ją na UTC (według wcześniejszego założenia). Później w zależności od tego, czy czas jest lokalny, czy UTC wywołujemy odpowiednią metodę, która dokona konwersji czasu. Samą konwersję wykona specjalna usługa do operacji na datach (kod niżej).

Na koniec pozostaje jeszcze dodanie konwertera do automappera:

public class DatesProfile : Profile
{
public DatesProfile()
{
CreateMap<DateTime, DateTime>()
.ConvertUsing<DateTimeToDateTimeConverter>();
}
}

Cała logika konwersji jest zamknięta DateService. Kod usługi wygląda następująco:

public class DateService : IDateService
{
public DateTime ConvertToUtc(DateTime dateTime)
{
TimeZoneInfo timeZone = TimeZoneInfo.FindSystemTimeZoneById(GetTimezone());
var date = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified);
return TimeZoneInfo.ConvertTimeToUtc(date, timeZone);
}
public DateTime ConvertToLocal(DateTime dateTime)
{
return TimeZoneInfo.ConvertTimeBySystemTimeZoneId(
dateTime, GetTimezone());
}
private string GetTimezone()
{
var tzdbId = "";
if (HttpContext.Current != null)
{
var cookie = HttpContext.Current.Request.Cookies.Get("timezone");
if (cookie != null && string.IsNullOrEmpty(cookie.Value) == false)
{
tzdbId = HttpUtility.UrlDecode(cookie.Value);
}
}
if (string.IsNullOrEmpty(tzdbId) == false)
{
var mappings = TzdbDateTimeZoneSource.Default.WindowsMapping.MapZones;
var map = mappings.FirstOrDefault(x =>
x.TzdbIds.Any(z => z.Equals(tzdbId, StringComparison.OrdinalIgnoreCase)));
if (map != null)
{
return map.WindowsId;
}
}
return "Central Standard Time";
}
}
view raw DateService.cs hosted with ❤ by GitHub

Prywatna metoda GetTimeZone służy do pobrania informacji o strefie czasowej użytkownika. Tutaj właśnie odczytujemy informację z pliku cookie i zgodnie z wartościami z niego wyszukujemy odpowiednią strefę czasową. Po drodze musimy jeszcze rozwiązać problem z formatem nazw stref czasowych. Wartość z javascript ma inny format niż ten, którego oczekuje klasa TimeZoneInfo. Do konwersji wykorzystałem bibliotekę NodeTime (https://nodatime.org/). Jest to bardzo fajna biblioteka przydatna do operacji na data w .NET. Planuję powstanie w przyszłości kilku wpisów na jej temat.

Sama konwersja jest wykonywana przez klasę TimeZoneInfo, gdzie przekazujemy informacje o strefie czasowej.

Sam DateService może służyć również do innych celów. Kiedyś opisywałem, jak użyć DateService do pobierania aktualnego czasu oraz podróży w czasie.

Events – przykład użycia

W testowej aplikacji na githubie (https://github.com/danielplawgo/WorkWithDataInAspNetMvc) przygotowałem prosty przykład użycia powyższego mechanizmu.

Stworzyłem bardzo prosty mechanizm dodawania wydarzeń, na których przetestujemy automatyczną konwersję czasu dla początku oraz końca wydarzenia. Kod wygląda tak:

public class Event
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime Start { get; set; }
public DateTime End { get; set; }
}
view raw Event.cs hosted with ❤ by GitHub
public class EventViewModel
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime Start { get; set; }
public DateTime End { get; set; }
}
public partial class EventsController : Controller
{
private Lazy<IMapper> _mapper;
protected IMapper Mapper
{
get { return _mapper.Value; }
}
private Lazy<DataContext> _dataContext;
protected DataContext DataContext
{
get { return _dataContext.Value; }
}
public EventsController(Lazy<IMapper> mapper,
Lazy<DataContext> dataContext)
{
_mapper = mapper;
_dataContext = dataContext;
}
// GET: Events
public virtual ActionResult Index()
{
var viewModels = Mapper.Map<List<EventViewModel>>(DataContext.Events);
return View(viewModels);
}
public virtual ActionResult Create()
{
var viewModel = new EventViewModel()
{
Start = DateTime.Today.AddHours(12),
End = DateTime.Today.AddHours(13)
};
return View(viewModel);
}
[HttpPost]
public virtual ActionResult Create(EventViewModel viewModel)
{
if (ModelState.IsValid == false)
{
return View(viewModel);
}
var eventModel = Mapper.Map<Event>(viewModel);
DataContext.Events.Add(eventModel);
DataContext.SaveChanges();
return RedirectToAction(MVC.Events.Index());
}
}

Przykład jest dość prosty. W kontrolerze są trzy akcje. Index, która wyświetla dane zapisane w bazie (tutaj już nie rozbudowywałem przykładu i w kontrolerze bezpośrednio korzystam z data contextu, co na dłuższą metę nie jest dobrym rozwiązaniem). Dwie kolejne to obsługa dodania nowego wydarzenia.

W widoku Create wybieramy początek i koniec wydarzenia. Czas jest w formacie lokalnym:

datetime converter create view

Po zapisaniu formularza w bazie czas już jest w UTC (widać to po przesunięciu czasu o dwie godziny, które mamy w Polsce latem, w stosunku do UTC):

datetime converter database

Po wyświetleniu widoku Index z wszystkimi wydarzeniami w bazie znowu widzimy czas lokalny:

datetime converter index view

Przykład

Na githubie znajduje się przykład (https://github.com/danielplawgo/WorkWithDataInAspNetMvc). Aby go uruchomić,, należy w web.config ustawić poprawny connection string do bazy. Pierwsze uruchomienie spowoduje wygenerowanie struktury bazy oraz dodanie testowych wydarzeń.

WebApi?

W tym wpisie opisuję sytuacje, gdy w aplikacji po stronie serwera generujemy widoku. Zupełnie inna sytuacja ma miejsce, gdy na przykład aplikacja jest podzielona na dwie części: backend w web api, natomiast frontend w np. angularze (lub jakaś inna podobna sytuacja). Wtedy jest dużo prościej po stronie backend – tak naprawdę zawsze pracujemy na czasie UTC. Sama konwersja czasu powinna być wykonana po stronie frontendu. Ale to już temat na zupełnie nowy wpis.

Podsumowanie

Warto być świadomym problemu różnych stref czasowych, którego możemy doświadczyć podczas rozwoju aplikacji. Dlatego warto już wcześniej przygotować takie rozwiązanie, które najlepiej w sposób automatyczny będzie rozwiązywało ten problem. Jak widzisz, takie rozwiązanie w ASP.NET MVC nie musi być czymś skomplikowanym. Kolejny raz z pomocą przychodzi automapper i konwertery typów (podobnie jak w przypadku lokalizacji enumów). Zachęcam do pobrania przykładu z githuba i przetestowania rozwiązania.

A jak Ty rozwiązujesz ten problem z datami? Też masz jakieś automatyczne rozwiązanie?

3 thoughts on “Jak automatycznie zmieniać czas lokalny na UTC w ASP.NET MVC?

  • Pingback: dotnetomaniak.pl
    • Hej Łukaszu!

      Dzięki za komentarz. Masz oczywiście rację, dużo lepszym rozwiązaniem jest skorzystanie w aplikacji z DateTimeOffset (planuje do tego wrócić w przyszłości jako alternatywny sposób rozwiązania z artykułu).

      Z drugiej strony liczba aplikacji, która używa DateTime jako główny typ daty jest tak duża, że zdecydowałem się napisać ten artykuł. Myślę, że czasami łatwiej jest dodać mechanizm z artykułu niż wszystko od razu zmieniać.

      Szkoda, że praktycznie we wszystkich materiałach edukacyjnych pokazywany jest DateTime jako główny typu daty i przez to prawie wszyscy go używają.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.