DateTimeOffset(strict)
Сегодня утром мой приятель @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.