Сегодня утром мой приятель kirillkos столкнулся с проблемой.
Проблемный код
Вот его код:
class Event {
public string Message {get;set;}
public DateTime EventTime {get;set;}
}
interface IEventProvider {
IEnumerable<Event> GetEvents();
}
И дальше много-много реализаций IEventProvider
, достающие данные из разных таблиц и баз.
Проблема: во всех этих базах все в разных временных зонах. Соответственно, при попытке вывести события на UI все ужасно перепутано.
Слава Хейлсбергу, у нас есть типы, пусть они спасут нас!
Попытка 1
class Event {
public string Message {get;set;}
public DateTimeOffset EventTime {get;set; }
}
DateTimeOffset
замечательный тип, он хранит информацию о смещении относительно UTC. Он прекрасно поддерживается MS SQL и Entity Framework (а в версии 6.3 будет поддерживаться еще лучше). У нас в code style он обязательный для всего нового кода.
Теперь мы можем собрать информацию с этих самых provider и консистентно, полагаясь на типы, вывести все на UI. Победа!
Проблема: DateTimeOffset
умеет неявно преобразовываться из DateTime
.
Следующий код прекрасно скомпилируется:
class Event {
public string Message {get;set;}
public DateTimeOffset EventTime {get;set; }
}
IEnumerable<Event> GetEvents()
{
return new[] {
new Event() {EventTime = DateTime.Now, Message = "Hello from unknown time!"},
};
}
Это потому, что у DateTimeOffset
определен оператор неявного приведения типов:
// Local and Unspecified are both treated as Local
public static implicit operator DateTimeOffset (DateTime dateTime);
Это совсем не то, что нам нужно. Мы-то хотели, чтобы программист при написании кода был вынужден задуматься: «а в какой собственной временной зоне случилось это событие? Откуда взять зону?». Часто совсем из других полей, иногда из связанных таблиц. А тут совершить ошибку не задумавшись очень легко.
Проклятые неявные преобразования!
Попытка 2
С тех пор, как я услышал про молоток статические анализаторы, мне все кажется гвоздями подходящими случаями для них. Нам надо написать статический анализатор, который запрещает это неявное преобразование, и объясняет почему… Выглядит, как многовато работы. Да и вообще, это работа компилятора, проверять типы. Пока отложим эту идею, как многословную.
Попытка 3
Вот если мы были бы в мире F#, сказал kirillkos.
Мы бы тогда:
type DateTimeOffsetStrict = Value of DateTimeOffset
И дальше не придумал импровизируй какая-то магия нас спасла бы. Жаль, что у нас в конторе не пишут на F#, да и мы с kirillkos его толком не знаем :-)
Попытка 4
Неужели что-то такое нельзя сделать на C#? Можно, но замучаешься преобразовывать туда-сюда. Стоп, но ведь мы только что видели, как можно сделать неявные преобразования!
/// <summary>
/// Same as <see cref="DateTimeOffset"/>
/// but w/o implicit conversion from <see cref="DateTime"/>
/// </summary>
public readonly struct DateTimeOffsetStrict
{
private DateTimeOffset Internal { get; }
private DateTimeOffsetStrict(DateTimeOffset @internal)
{
Internal = @internal;
}
public static implicit operator DateTimeOffsetStrict(DateTimeOffset dto)
=> new DateTimeOffsetStrict(dto);
public static implicit operator DateTimeOffset(DateTimeOffsetStrict strict)
=> strict.Internal;
}
Самое интересное в этом типе, что он неявно преобразуется туда-сюда из DateTimeOffset
, а вот попытка неявно преобразовать его из DateTime
вызовет ошибку компиляции, преобразования из DateTime возможны только явные. Компилятор не может вызвать «цепочку» неявных преобразований, если они определены в нашем коде, это ему запрещает стандарт (цитата на SO). То есть, вот так работает:
class Event {
public string Message {get;set;}
public DateTimeOffsetStrict EventTime {get;set; }
}
IEnumerable<Event> GetEvents()
{
return new[] {
new Event() {EventTime = DateTimeOffset.Now, Message = "Hello from unknown time!"},
};
}
а вот так нет:
IEnumerable<Event> GetEvents()
{
return new[] {
new Event() {EventTime = DateTime.Now, Message = "Hello from unknown time!"},
};
}
Что нам и требовалось!
Итог
Пока не знаем, будем ли внедрять. Только всех приучили к DateTimeOffset, и теперь его заменять на наш тип — стремновато. Да и наверняка всплывут проблемы на уровне EF, ASP.NET parameter binding и еще в тысяче мест. Но самое решение кажется мне интересным. Аналогичные трюки я использовал, чтобы следить за безопасностью пользовательского ввода — делал тип UnsafeHtml
, который неявно преобразуется из строки, а вот обратно его преобразовать в строку или IHtmlString
можно только путем вызова sanitizer.
Комментарии (25)
96467840
05.02.2019 15:05не совсем понял проблему. тип DateTime имеет информацию о TimeZone и корректно будет преобразован в тип DateTimeOffset что не так?
leotsarev Автор
05.02.2019 15:17Нет, тип
DateTime
не имеет информации оTimeZone
. Он может быть трех вариантов: локальный (таймзоны сервера), UTC, unspecified. В нашей задаче встречаются времена со всех таймзон.
Самый простой пример — пусть вы пишете календарь для многонациональной корпорации. В офисе в Хабаровске есть ежедневный стендап команды уборщиков в 8 утра. Это время не таймзоны сервера (сервер в Москве) и не UTC. Его никак не преобразовать вDateTimeOffset
без дополнительной инфы о том, что это именно Хабаровск (то есть, собственно Offset). В DateTime этой инфы нет.sentyaev
05.02.2019 17:22+1без дополнительной инфы о том, что это именно Хабаровск (то есть, собственно Offset)
В этом предложении вы смешали TimeZone (Хабаровск) и TimeZoneOffset (ваше: «собственно Offset», которое выражается как +5:00 например), а это разные вещи.
Time Zone != Offset
Rambalac
05.02.2019 15:19DateTime имеет только тип времени utc, местное или неопределённое.
DateTime.Now возвращает местное время по установкам системы.
drcolombo
05.02.2019 16:33Боюсь, что закидают тапками, но что мешает в базе хранить и на бэкенде обрабатывать все в UTC, а клиенту отображать (а также запрашивать от него) в его локальной зоне?
IMHO, попахивает изобретением проблемы и попытками её решить…indestructable
05.02.2019 16:39+2Тем, что невозможно узнать локальное время события в таймзоне источника события.
GreenNinja
05.02.2019 19:25-2Боюсь, что закидают тапками, но что мешает узнать это? Если офис в Хабаровске, то мы же знаем каким-то образом что он в Хабаровске… Может быть у пользователя или у аккаунта офисса выставленна таймзона?
На крайний склучай давайте указывать время по МСК…
IMHO, Вместо того, что бы узнать таймзону с клиента, автор изобретает велосипед на бэке. И да, попахивает изобретением проблемы и попытками её решить.leotsarev Автор
05.02.2019 19:25+1Все правильно, ее нужно узнать и после этого хранить. Правильный способ это делать — хранить в базе DateTimeOffset
BloodUnit
05.02.2019 21:05+1Ну дак и DateTimeOffset тут не поможет, потому что как уже писали выше, TimeZone и Offset разные вещи.
UTC+3 это может быть и MSK, а может быть и нет, а может MSK это UTC+4, до 2011 года, вдруг MSK станет UTC+5 в 2019?
Все что даст узнать DateTimeOffset, это оффсет в котором произошло событие, но никак и время в какой-либо таймзоне.
Пример выше про календарь опять же не решается с помощью DateTimeOffset, потому как запланированная встреча на 2020 год в 8 утра по MSK может поломаться, т.к. правительство решит опять переходить на летнее/зимнее время, и часовой пояс для MSK изменится, а на момент создания вы полагаетесь просто на +3. Посмотрите диалог создания событий в гугл календаре, там выбирается не оффсет, а именно таймзона.
Таким образом, чтобы узнать локальное время в таймзоне, надо хранить, таймзону, как ни странно!
leotsarev Автор
05.02.2019 17:33Надо в базе и бекенде хранить DateTimeOffset. В новых местах мы так и делаем. Почему недостаточно UTC — см выше про хабаровск и уборщиков.
sentyaev
05.02.2019 17:28+1Учитывая, что вы работаете с событиями в разных таймзонах, почему не выбрали готовое решене — например NodaTime?
leotsarev Автор
05.02.2019 17:36NodaTime интересное решение, но пока нас почти всем устраивал DateTimeOffset. В целом выглядит что NodaTime лучше, но тяжеловеснее. Лишняя зависимость со своими особенностями. Почитаю подробности, подумаю, есть ли смысл в миграции.
Meloman19
05.02.2019 19:26-1Я так понимаю проблема в сохранении в базу в правильном формате? Просто неявное преобразование из DateTime в DateTimeOffset и так учитывает локальную зону компьютера, где идёт преобразование.
И вообще я тут проблемы не вижу. Смотрим исходный код:
Преобразованиеpublic static implicit operator DateTimeOffset (DateTime dateTime) { return new DateTimeOffset(dateTime); }
leotsarev Автор
05.02.2019 19:27так учитывает локальную зону компьютера, где идёт преобразование.
Да, и это нас не устраивало. В нашем кейсе DateTime могут быть не только в этой локальной зоне, а еще и в другой. В какой — в каждом случае разной, программист должен подумать. Чтобы он не забыл подумать, нам важно, чтобы неявного преобразования не было, а было только явное.
Meloman19
05.02.2019 21:08Вы же сами в Попытке 4 пишете, что DateTimeOffsetStrict EventTime = DateTimeOffset.Now вам подходит, а это абсолютно то же самое, что и DateTimeOffset EventTime = DateTime.Now. Да и сломать это всё равно можно: DateTimeOffsetStrict EventTime = new DateTimeOffset(new DateTime()).
Я вам и не говорю, что нужно использовать DateTime. Использование DateTimeOffset полностью оправдано и вы храните в базах именно этот тип, но как возможность локального неявного преобразования может подпортить вам данные? При добавлении в базу новых данных всё равно будет учитываться временная зона локального компьютера, откуда добавляется.leotsarev Автор
05.02.2019 21:23Я понял, я запутал вас своими DateTimeOffset.Now.
Там имелась ввиду просто возможность присвоить туда DateTime/DateTimeOffset. На самом деле он поднимается из базы и высчитывается
gurux13
06.02.2019 02:20+1Как человек с опытом работы с временем и зонами, могу высказать имхо. Храните всё в utc. Всегда. Никогда не храните локальное время ни в какой базе. Utc отражает момент времени, единый для всей Земли (минус релятивизм).
Если нужно отобразить время пользователю, используйте перевод в локальное время на клиенте.
В исключительно редком случае, когда нужно хранить ощущаемое пользователем время (показания на часах на стене пользователя; в стиле календаря в конкретной таймзоне, что само по себе немного странно), храните локальное время и таймзону, в совершенно отдельном типе. Эта конструкция не задаёт момент времени, потому что её смысл может меняться вместе с оффсетом таймзоны.
P.S. Опыт с двух сторон: разработка мобильного приложения для продажи авиабилетов — народ пару раз приезжал к вылету на час раньше/позже из-за забавных особенностей таймзон и Российского законодательства; и работы в международной компании, где я скорее назову текущее время utc, чем локальное, в ответ на вопрос "который час?".
sentyaev
06.02.2019 11:11Да, работа с таймзонами не самое тривиальное занятие, если меня про это спрашивают, я всегда даю эту ссылку: stackoverflow.com/questions/2532729/daylight-saving-time-and-time-zone-best-practices.
Там как раз есть объяснение почему «Don't tell people to „always use UTC everywhere“.
Просто у вас первый абзац комментария слишком категоричен.drcolombo
07.02.2019 11:25+1По той же ссылке: «Whenever you are referring to an exact moment in time, persist the time according to a unified standard that is not affected by daylight savings». Так сказать «use» и «persist» — вещи таки разные, согласитесь.
Лично я на 100% согласен с gurux13 — в базе только в UTC.
Выше тоже писали про локальное время компьютера (так понимаю, сервера), где происходит преобразование. Хорошо, если у вас один сервер, стоящий в соседней кладовке, а если система распределённая? Вы не можете предсказать, где и когда будет обработан данный запрос пользователя: может в Штатах, может в Европе, может в Японии…
Другой пример: в одной конторе я был в команде, которая занималась представлением демо и PoC потенциальным клиентам по всему миру. Приходилось летать с системой, установленной на ноут, чтобы всё показывать (а порой и допиливать или даже реализовывать совершенно новый функционал, который только что стукнул в голову потенциальному клиенту). Ну прилетел я, положим, в Сингапур — локальное время «перевелось»… а потом возвращаюсь домой и нужно проанализировать логи или «проиграть» то, что было показано ранее, на месте. А время-то опять «переехало». И начинается ад.
Так что не — в базе хранить всё только в UTC и на бэкенде оперировать тоже только им.
indestructable
07.02.2019 23:14-1DateTimeOffset — это не локальное время. Это абсолютное плюс оффсет (указывающий локальное время источника).
ibes
06.02.2019 11:36DateTimeOffset… прекрасно поддерживается MS SQL и Entity Framework
Как решена проблема хранения DateTimeOffsetStrict в БД?leotsarev Автор
06.02.2019 11:39Тут есть дальше варианты. Если сделать его class/[ComplexType], то EF будет его сохранять.
Ещё вариант, иметь его на уровне Domain/Dto, а в базу сохранять DateTimeOffset
dmitry_dvm
Вариант со статическим анализатором мне кажется правильнее, т.к. гибче и заставит разработчиков быть внимательнее. А с новым типом вы правильно написали — неизвестно где это всплывет и есть вероятность накопления костылей.
Мне кажется научить рослин ругаться на неявное преобразование DateTime в DateTimeOffset не так уж и сложно, ибо вполне штатная ситуация.