Чтение и запись с помощью Entity Framework Core без спонтанного изменения объектов сущностей

EF Core изменяет отслеживаемые объекты, устанавливая ключи и сохраняя свойства навигации.

В качестве примера того, почему это может быть проблемой, предположим, что вы запускаете задачу, которая добавит сущность в DbContext. Если вы затем немедленно пронумеруете некоторые навигационные свойства того же объекта, не дожидаясь завершения задачи, вы можете получить InvalidOperationException. Когда объект отслеживался в другом потоке, он мог получить некоторые другие данные из контекста и изменить коллекцию.

Я хотел бы избежать этих проблем, клонируя объекты, входящие и исходящие из EF Core. Но я также не хочу писать тонну неподдерживаемого и подверженного ошибкам кода для клонирования сущностей вручную.

Вот как далеко я продвинулся:

public static TEntity CloneEntity<TEntity>(this DbContext context, TEntity entity)
    where TEntity : class
{
    if (context == null)
        throw new ArgumentNullException(nameof(context));
    if (entity == null)
        throw new ArgumentNullException(nameof(entity));

    // A map for keeping track of already-cloned objects for circular references.
    var map = new Dictionary<object, object>(ReferenceEqualityComparer.Instance);

    return (TEntity)Recurse(context.Entry(entity));


    object Recurse(EntityEntry entry)
    {
        if (map.TryGetValue(entry.Entity, out var clone))
            return clone;

        clone = entry.CurrentValues.ToObject();
        map.Add(entry.Entity, clone);

        // TODO: Recursively clone and set all the navigation properties.

        return clone;
    }
}

Я, вероятно, могу понять, как решить бит TODO с помощью отражения, но EF Core уже должен был сделать все это, и у него уже должны были быть скомпилированные методы для эффективной настройки свойств навигации. Есть ли способ использовать их, похожие на entry.CurrentValues.ToObject()?


person relatively_random    schedule 19.12.2019    source источник
comment
вы можете получить исключение InvalidOperationException. Когда объект отслеживается в другом потоке, вы не можете использовать DbContext одновременно из нескольких потоков для начала. И вы всегда можете запросить объект без отслеживания, поэтому средство отслеживания изменений не отслеживает его. docs.microsoft.com/en-us/ef/core/querying/ отслеживание   -  person David Browne - Microsoft    schedule 19.12.2019
comment
@David Дэвид, я ничего не говорил об одновременном использовании DbContext из нескольких потоков. А про AsNoTracking я знаю, но он решает только часть проблемы. Я все еще хотел бы иметь возможность записывать данные в базу данных без изменения ввода. И я хотел бы сделать чтение-изменение-запись и вернуть исходные данные без двойного запроса.   -  person relatively_random    schedule 19.12.2019
comment
Не могли бы вы пояснить, что когда объект отслеживается в другом потоке, это может помочь в разработке конкретной проблемы, с которой вы столкнулись, а не проблемы с решением, которое, по вашему мнению, вам нужно для исходной проблемы. Вполне возможно, что в вашей желаемой реализации есть предположение, которое можно легко исправить, не беспокоясь о клонировании новых сущностей, чтобы избежать исключения.   -  person Steve Py    schedule 20.12.2019
comment
Я должен согласиться с @StevePy. Я думал о сценарии, в котором потребуются клонированные сущности, и не смог их найти. Он может существовать, но будет очень полезно дать больше контекста вашей проблеме.   -  person dropoutcoder    schedule 20.12.2019
comment
@Steve Вы планируете задачу в пуле потоков. Это будет выполнено в другом потоке. Задача запускает контекст, добавляет предоставленную вами сущность. При этом он изменяет коллекцию внутри объекта сущности. Вы одновременно перечисляете коллекцию в своем собственном потоке и получаете исключение, потому что она была изменена. (Это еще хуже, потому что это UB, а не простое исключение.)   -  person relatively_random    schedule 20.12.2019
comment
@dropout У вас есть пять дочерних объектов, все из которых ссылаются на одного родителя. Вы определяете отношения, просто устанавливая дочерние справочные свойства навигации для общего родителя. Когда вы добавите эти пять объектов в контекст, все свойства их родительского идентификатора будут изменены. Кроме того, свойство навигации родительской коллекции будет установлено для ссылки на пять дочерних элементов. Если вы одновременно что-то делали с сущностями в другом потоке (например, сериализовали их), теперь вы имеете неопределенное поведение.   -  person relatively_random    schedule 20.12.2019
comment
Конечно, есть способы обойти эту проблему. Очевидные из них: 1) сериализовать доступ к данным или 2) использовать разные классы для представления данных вне EF. 1) ограничивает ваши возможности и 2) эквивалентно тому, что я все равно пытаюсь сделать: он просто делает все безопасным путем копирования.   -  person relatively_random    schedule 20.12.2019
comment
@random - Да, нет .. Вам нужно избегать подхода, который берет сущность, передает ее другому потоку для обработки в соответствии с DbContext. Как только объект связан с контекстом в рабочем потоке, эта ссылка не должна использоваться из исходного (или любого другого) потока. Сущности отражают состояние данных. Если ваш рабочий поток управляет состоянием данных, он должен работать исключительно с сущностями, а ваш основной поток должен работать с моделями представлений. Библиотеки, такие как Automapper, могут выполнять преобразование и передачу между сущностью и моделью представления и обратно по мере необходимости.   -  person Steve Py    schedule 20.12.2019
comment
@ Стив, ты перефразировал то, что я уже сказал. Причина, по которой вам следует избегать этого подхода и использовать модели представления (я упомянул это как альтернативу 2), заключается в том, что EF изменяет сущности. Я также хотел бы попытаться избежать необходимости в дополнительных занятиях, поэтому я задал вопрос.   -  person relatively_random    schedule 20.12.2019
comment
Вы пытались использовать AutoMapper для клонирования объектов? Его очень легко использовать   -  person Asım Gündüz    schedule 20.12.2019
comment
@relatively_random: Спасибо за пример использования. Я пытаюсь кое-что. Я вернусь к вам.   -  person dropoutcoder    schedule 22.12.2019
comment
@relatively_random: Думаю, не идеальное решение, но рабочее.   -  person dropoutcoder    schedule 25.12.2019
comment
@Licentia Сегодня быстро попробовал, показалось, что для работы требуется взлом. См. комментарии автора на github.com/AutoMapper/AutoMapper/issues/340 и github.com/AutoMapper/AutoMapper/issues/405.   -  person relatively_random    schedule 07.01.2020


Ответы (1)


Насколько вы можете предоставить решение самостоятельно (я думаю), я просто поместил сюда то, на что потратил время. Это рабочий проект. Это может удовлетворить или не удовлетворить ваши потребности и может помочь другим найти хоть что-то в качестве решения.

Я люблю выражения, поэтому это было сделано с их помощью, но все же вы можете пойти чисто с отражением.

class Program
{
    static void Main(string[] args)
    {
        #region Create data

        using (var context = new ConsoleDbContext())
        {
            context.Add(new Person()
            {
                Id = 1,
                Name = "Relatively Random",
                Animals = new List<Animal>
                    {
                        new Cat {Id= 1, Name = "Relatively" },
                        new Dog {Id = 2, Name = "Random" }
                    },
                Address = new Address
                {
                    Street = "Sesame street",
                    City = "London",
                    Country = "United Kingdom"
                }
            }).State = EntityState.Added;

            context.Add(new Person()
            {
                Id = 2,
                Name = "Random Relatively",
                Animals = new List<Animal>
                    {
                        new Cat {Id= 3, Name = "Relatively" },
                        new Dog {Id = 4, Name = "Random" }
                    }
            });

            #endregion

            #region Save data

            context.SaveChanges();

            #endregion

            // Create empty clone map
            IDictionary<EntityEntry, object> cloneMap = new Dictionary<EntityEntry, object>(context.ChangeTracker.Entries().Count());

            Func<Dog> getDogFunc = () => context.Set<Dog>().Last();

            // Get entry we want to clone
            var dog = context.Entry(getDogFunc());

            // Clone entry
            Dog dogClone = CloneEntity<Dog>(dog, cloneMap);

            // Change name of tracked entity
            dog.Entity.Name = "New name";

            // Compare against entity entry entity value and entity itself(which is non sense as those are same reference, but just to be sure it works
            var result = (dogClone.Name == dog.Entity.Name) || (dogClone.Name == getDogFunc().Name);
        }
    }

    public static TEntity CloneEntity<TEntity>(EntityEntry entityEntry, IDictionary<EntityEntry, object> cloneMap)
        where TEntity : class
    {
        if (entityEntry == null)
        {
            throw new ArgumentNullException(nameof(entityEntry));
        }

        if (cloneMap is null)
        {
            throw new ArgumentNullException(nameof(cloneMap));
        }

        //Try get existing clone
        if (cloneMap.TryGetValue(entityEntry, out var clone))
        {
            return (TEntity)clone;
        }

        var cloneMapConstant = Expression.Constant(cloneMap);
        var entityEntryType = typeof(EntityEntry);
        var bindings = new List<MemberBinding>(typeof(TEntity).GetProperties().Length);
        var entryParameter = Expression.Parameter(entityEntryType, "entry");

        // Create property binding expressions e.g. Name = entry.Entity.Name
        bindings.AddRange(CreatePropertyBinding<TEntity>(ref entityEntry, ref entryParameter));
        // Create reference binding expression e.g. Owner = new Owner() { ..initialization... }
        bindings.AddRange(CreateReferenceBinding(entityEntry, cloneMapConstant));

        // Get existing clone or create new one based on binding expressions created earlier 
        var result = GetOrCreateNewEntity<TEntity>(ref entityEntry, ref entryParameter, ref bindings);

        cloneMap.Add(entityEntry, result);

        return result;
    }

    private static TEntity GetOrCreateNewEntity<TEntity>(ref EntityEntry entityEntry, ref ParameterExpression entryParameter, ref List<MemberBinding> bindings)
        where TEntity : class
    {
        // new TEntity()
        var newEntity = Expression.New(typeof(TEntity));

        // new TEntity() { ...initialization...  }
        var initilizer = Expression.MemberInit(newEntity, bindings);

        // entry => new TEntity() { ...initialization...  }
        var lambda = Expression.Lambda(initilizer, entryParameter);

        // Compile expression to function
        var function = lambda.Compile();

        // Invoke function and cast result
        var result = (TEntity)function.DynamicInvoke(entityEntry);

        // Return result
        return result;
    }

    private static IEnumerable<MemberBinding> CreateCollectionBinding(EntityEntry entityEntry, ConstantExpression cloneMapConstant)
    {
        // Get all collection properties
        var collections = entityEntry
            .Collections;
        //.Where(n => n.IsLoaded);

        var cloneEntityMethod = typeof(Program).GetMethod(nameof(CloneEntity));

        foreach (var collection in collections)
        {
            // ICollection<SomeType>
            var clrType = collection.Metadata.ClrType;

            var elementType = clrType.GenericTypeArguments[0];

            var hashSetType = typeof(HashSet<>).MakeGenericType(elementType);

            // new HashSet<SomeType>()
            var hashSet = Expression.New(hashSetType);

            var converted = Expression.TypeAs(hashSet, clrType);

            var result = Expression.Bind(collection.Metadata.PropertyInfo, converted);

            yield return result;
        }
    }

    private static IEnumerable<EntityEntry> GetCollectionItemEntries(DbContext context, CollectionEntry collection)
    {
        foreach (var item in collection.CurrentValue)
        {
            yield return context.Entry(item);
        }
    }

    private static IEnumerable<MemberBinding> CreateReferenceBinding(EntityEntry entityEntry, ConstantExpression cloneMapConstant)
    {
        var references = entityEntry
            .References
            .Where(n => n.IsLoaded);

        var result = new List<MemberBinding>(references.Count());

        var cloneEntityMethod = typeof(Program).GetMethod(nameof(CloneEntity));

        foreach (var reference in references)
        {
            var referenceEntityEntry = Expression.Constant(reference.EntityEntry.Context.Entry(reference.TargetEntry.Entity));

            var genericCloneEntityMethod = cloneEntityMethod.MakeGenericMethod(reference.Metadata.ClrType);

            var callCloneEntityMethod = Expression.Call(null, genericCloneEntityMethod, new Expression[] { referenceEntityEntry, cloneMapConstant });

            yield return Expression.Bind(reference.Metadata.PropertyInfo, callCloneEntityMethod);
        }
    }

    private static IEnumerable<MemberAssignment> CreatePropertyBinding<TEntity>(ref EntityEntry entityEntry, ref ParameterExpression parameter)
    {
        var entityEntryType = parameter.Type.GetProperty(nameof(EntityEntry.Entity), typeof(object));
        var entityProperty = Expression.MakeMemberAccess(parameter, entityEntryType);
        var convertedEntity = Expression.Convert(entityProperty, typeof(TEntity)); ;

        var result = entityEntry
            .Properties
            .Where(p => p.Metadata.IsShadowProperty() == false)
            .Select(p => Expression.Bind(p.Metadata.PropertyInfo, Expression.MakeMemberAccess(convertedEntity, p.Metadata.PropertyInfo)));

        return result;
    }
}

public class ConsoleDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseInMemoryDatabase("inmemory");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Owned<Address>();

        modelBuilder.Entity<Person>(person =>
        {
            person.HasKey(e => e.Id);
            person.Property(e => e.Name);
            person.OwnsOne(e => e.Address);
        });

        modelBuilder.Entity<Animal>(animal =>
        {
            animal.HasKey(e => e.Id);
            animal.HasDiscriminator();
            animal.Property(e => e.Name);
            animal.HasOne(e => e.Owner)
                .WithMany(e => e.Animals);
        });

        modelBuilder.Entity<Dog>(dog =>
        {
            dog.HasBaseType<Animal>();
        });

        modelBuilder.Entity<Cat>(cat =>
        {
            cat.HasBaseType<Animal>();
        });
    }
}
public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string Country { get; set; }
}

public abstract class Animal : Entity<int>
{
    public string Name { get; set; }
    public Person Owner { get; set; }
}

public class Cat : Animal
{
}

public class Dog : Animal
{
}

public abstract class Entity<TKey>
{
    public TKey Id { get; set; }
}

public class Person : Entity<int>
{
    public Person()
    {
        //Animals = new HashSet<Animal>();
    }

    public string Name { get; set; }
    public Address Address { get; set; }
    public virtual ICollection<Animal> Animals { get; set; }
}
person dropoutcoder    schedule 25.12.2019