Tuesday, April 7, 2015

Entity Framework DateTime and UTC/Local

Сегодня мы снова немного поиграем с Entity Framework. Поговорим о том, как сохраняются поля DateTime в базу данных.
Если вы работаете с EF через OData, например, или WCF сервис, то у вас может возникнуть потребность в сохранении даты, введенной пользователем в БД. Проблем не должно возникнуть, если вся ваша система крутится вокруг одной Time Zone; если же вокруг разных, и другие пользователи могут видеть дату и время ваших изменений, – вот здесь можно столкнуться с проблемой. Ваша задача – выдавать клиенту даты в его часовом поясе; при этом нужно реализовать хранение дат на стороне сервера так, чтобы избежать дополнительных конвертаций. 
Проблема заключается в том, что по умолчанию DateTime тип данных в EF установлен Kind как Unspecified. Есть несколько вариантов решения этой проблемы, начиная с решения "в лоб"  до решений с небольшим шаманством.
Используем DateTimeOffset
Первое и самое простое решение  это использовать для хранения данных в базе данных DateTimeOffset. Но в этом подходе есть большое "но": WCF Ria Services не поддерживает тип DateTimeOffset. В итоге вам придется либо не использовать DateTimeOffset, либо написать workaround и все-таки использовать DateTimeOffset на стороне сервера, но тогда вам нужно будет написать конвертацию в DateTime, как, например, это сделано в статье 'WCF Ria Services и DateTimeOffset', в которой ее автор, пытаясь решить одну проблему, все равно пришел к решению с конвертацией и простановкой Kind в структуре DateTime. Это самое простое решение, но если у вас уже готовая система, в которой заменить DateTime на DateTimeOffset не так уж просто (например, с такой проблемой мы столкнулись в своем проекте недавно), нужно искать другой подход, который позволит все-таки играть со свойством Kind и проставлять его в Local или UTC, в зависимости от ваших потребностей. Для наших решений было достаточно использования Local, чтобы корректно отобразить на клиенте дату в часовом поясе клиента. Поэтому следующие примеры построены на установке свойства Kind в Local. Ниже мы рассмотрим, как это все работает, и напишем тест, который покроет нам всю необходимую логику.
Forcing EF to mark DateTime fields at Local or UTC with T4 generator
Следующий подход основан на написании темплейта, который для ваших DateTime полей будет проставлять поле Kind в UTC или Local, в зависимости от вашего решения.
CreateDate = DateTime.SpecifyKind(CreateDate, DateTimeKind.Local);
О томкак это сделать, есть целая статья 'Forcing Entity Framework to mark DateTime fields at UTC'Это делается очень просто, но поскольку я не люблю использовать T4 генератор в своих приложениях, то рассмотрю другие подходы, которые позволят вам сделать то же самое, только без T4 генератора.
DateTimeKingAttribute
На этом подходе остановимся детальнее. Основная задача – добавить атрибут, с помощью которого можно будет проставить для полей DateTime свойство Kind. Ниже приведен пример такой реализации, взятый со stackoverflow.
[AttributeUsage(AttributeTargets.Property)]
public class DateTimeKindAttribute : Attribute
{
    private readonly DateTimeKind _kind;

    public DateTimeKindAttribute(DateTimeKind kind)
    {
        _kind = kind;
    }

    public DateTimeKind Kind
    {
        get { return _kind; }
    }

    public static void Apply(object entity)
    {
        if (entity == null)
            return;

        var properties = entity.GetType().GetProperties()
            .Where(x => x.PropertyType == typeof(DateTime) || x.PropertyType == typeof(DateTime?));

        foreach (var property in properties)
        {
            var attr = property.GetCustomAttribute<DateTimeKindAttribute>();
            if (attr == null)
                continue;

            var dt = property.PropertyType == typeof(DateTime?)
                ? (DateTime?)property.GetValue(entity)
                : (DateTime)property.GetValue(entity);

            if (dt == null)
                continue;

            property.SetValue(entity, DateTime.SpecifyKind(dt.Value, attr.Kind));
        }
    }
}
Как это использовать, можно посмотреть ниже.
public class Build
{
    public int Id { getset; }

    [DateTimeKind(DateTimeKind.Utc)]
    public DateTime CreateDate { getset; }

    [StringLength(255)]
    public string Name { getset; }
}
И теперь осталось самое малое: добавить трансформацию для нашего DbContext, иначе этот подход попросту не взлетит.
public class BuildContext : DbContext
{
    public BuildContext()
        : base() 
    {
        ((IObjectContextAdapter)this).ObjectContext.ObjectMaterialized +=
            (sender, e) => DateTimeKindAttribute.Apply(e.Entity);
    } 

    public virtual DbSet<Build> Builds { getset; } 

    protected override void OnModelCreating(DbModelBuilder modelBuilder) 
    { 
        Database.SetInitializer(new DropCreateDatabaseIfModelChanges<BuildContext>());
        base.OnModelCreating(modelBuilder);
    } 
}

Мы не использовали данный подход в своем проекте, хотя он довольно-таки интересный и, возможно, для вашего решения его будет достаточно.
Использование EmitMapper с разделением модели и DTO (Data Transfer Object)
Этот подход используется в том случае, когда вы используете на клиенте не напрямую объекты модели БД, а вместо них используете DTO классы, которые хранят информацию только о необходимых данных для клиентской части. Для данной реализации не обязателен какой-то мапер на подобии EmitMapperAutoMapper и другие. Приведу пример реализации подхода, который был актуальный для нужд компании, в которой я работаю.
Для этого нам понадобится вспомогательный класс, который будет проставлять для наших DateTime полей свойство Kind.
public class DateKindHelper
{
    public static DateTime DefaultToLocal(DateTime date)
    {
        return date.Kind != DateTimeKind.Local ? DateTime.SpecifyKind(date, DateTimeKind.Local) : date;
    }

    public static DateTime? DefaultToLocal(DateTime? date)
    {
        return date.HasValue && date.Value.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(date.Value, DateTimeKind.Local) : date;
    }

    public static DateTime DefaultToUtc(DateTime date)
    {
        return date.Kind != DateTimeKind.Utc ? DateTime.SpecifyKind(date, DateTimeKind.Utc) : date;
    }

    public static DateTime? DefaultToUtc(DateTime? date)
    {
        return date.HasValue && date.Value.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(date.Value, DateTimeKind.Utc) : date;
    }
}
Подобную реализацию я выдел когда-то на stackoverflow, так что если вам вдруг понадобится что-то подобное, вы сможете легко найти его.
Теперь перейдем к нашей модели Build.
public class Build
{
    public int Id { getset; }

    [DateTimeKind(DateTimeKind.Utc)]
    public DateTime CreateDate { getset; }

    [StringLength(255)]
    public string Name { getset; }
}
Также нам нужно реализовать Dto модель, которая будет использоваться на клиенте.
public class BuildDTO
{
    public int Id { getset; }

    public DateTime CreateDate { getset; }
        
    public string Name { getset; }
}
Для данного примера DTO объект практически одинаковый с основный классом Build, но на практике ваша модель может иметь очень много данных, а DTO класс будет хранить только самые необходимые. Теперь осталось написать сам конвертор, который будет конвертировать наши данные.
internal static class DateTimeConverter
{
    public static TDTO ToDTO<TDTO, TEntity>(TEntity entity)
    {
        return ObjectMapperManager.DefaultInstance
            .GetMapper<TEntity, TDTO>(new DefaultMapConfig()
            .ConvertUsing<DateTimeDateTime>(DateKindHelper.DefaultToLocal))
            .Map(entity);
    }
}
Как это использовать, показано в примере ниже.
var build = new Build
        {
            Id = 1,
            CreateDate = new DateTime(2014, 12, 16),
            Name = "Test"
        };


var buildDto = DateTimeConverter.ToDTO<BuildDTOBuild>(build);
Console.WriteLine(build.CreateDate.Kind);
Console.WriteLine(buildDto.CreateDate.Kind);
Результат можно посмотреть на рисунке ниже.
Ручная установка для полей свойства Kind
Это самый ленивый и "прямолобый" способ, который работает по принципу ручной установки свойства Kind для полей типа DateTime. Этот способ, конечно, больше всего подвержен возникновению непредусмотренных ситуаций, потому что вы можете забыть проставить свойство Kind для какого-то поля DateTime. Но это решается очень просто, если вы покрываете свой код с помощью юнит-тестов. Для начала приведу простой пример, как нужно проставлять свойство Kind напрямую в классе.
public class Build
{
    public int Id { getset; }
    private DateTime _createTime;

    public DateTime CreateDate
    {
        get { return _createTime; }
        set
        {
            _createTime = DateKindHelper.DefaultToLocal(value);
        }
    }

    [StringLength(255)]
    public string Name { getset; }
}
Выглядит это, возможно, не очень красиво, зато это решение самое простое. Правда, в нем больше всего есть вероятность допустить ошибки. Чтобы этого избежать, достаточно написать простой юнит-тест, который возьмет всю заботу о валидности данных на себя.
Например, тест, который проверяет возможность установки для класса в вашем DbContext свойства Kind в Local.
[TestMethodTestCategory("Unit")]
public void TestSettingLocalZonesOnServer()
{
    var sets =
        from p in typeof(BuildContext).GetProperties()
        where p.PropertyType.IsGenericType
        && p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)
        select p;

    foreach (var set in sets)
    {
        Type type = set.PropertyType;
        var returnType = type.GetMethods(BindingFlags.Instance | BindingFlags.Public).First(x => x.Name == "Create").ReturnType;
        object o = Activator.CreateInstance(returnType);
        var obj = o.GetType();
        var dateTimeProperties = obj.GetProperties().Where(x => x.PropertyType == typeof(DateTime) || x.PropertyType == typeof(DateTime?)).ToList();
        if (dateTimeProperties.Count > 0)
        {
            foreach (var prop in dateTimeProperties)
            {
                var dateTime = new DateTime(2014, 12, 12);
                prop.SetValue(o, dateTime, null);

                var dt = (DateTime)prop.GetValue(o, null);
                Assert.AreEqual(dt.Kind, DateTimeKind.Local);
            }
        }
    }
}
Ну и более специфический тест, который позволяет проверить всю сборку на валидность проставления свойства Kind.
[TestMethodTestCategory("Unit")]
public void AllServerEntities_set_datetime_properties_to_local()
{
    var assembly= AppDomain.CurrentDomain.GetAssemblies().
        FirstOrDefault(assm => assm.GetName().Name == "Entities");

    Assert.IsNotNull(assembly);

    var types = assembly.GetExportedTypes().Where(x => x.IsSubclassOf(typeof (Entity))
        && !x.ContainsGenericParameters 
        && !x.IsAbstract).ToList();
    foreach (var type in types)
    {
        var count =
            type.GetProperties()
                .Count(p => p.PropertyType == typeof (DateTime) || p.PropertyType == typeof (DateTime?));

        if(count == 0)
            continue;

        object o = Activator.CreateInstance(type);
        var obj = o.GetType();

        var dateTimeProperties = obj.GetProperties().Where(x => x.PropertyType == typeof(DateTime)).ToList();
        if (dateTimeProperties.Count > 0)
        {
            foreach (var prop in dateTimeProperties)
            {
                var dateTime = new DateTime(2014, 12, 12);
                prop.SetValue(o, dateTime, null);

                var dt = (DateTime)prop.GetValue(o, null);
                Assert.AreEqual(dt.Kind, DateTimeKind.Local);
            }
        }
    }
}
В данном примере у нас есть базовый класс Entity, от которого наследуются все остальные классы. Как покрыть этот класс с помощью юнит-тестов и рефлексии, показано выше.
Итоги
В статье мы рассмотрели разные способы работы с DateTime в контексте EF, если нужно отображать дату в локали пользователя. В свое время поиск решения для конкретной задачи занял прилично времени, поэтому я решил собрать все в одном месте, чтобы вы могли использовать понравившееся вам решение в своих проектах.


No comments:

Post a Comment