Monday, April 7, 2008

Entity Framework rollback: Часть 3. Описание реализации библиотеки

Продолжение. Начало:

Entity Framework rollback: Часть 1. Постановка задачи Entity Framework rollback: Часть 2. Варианты решения

В данной части я хотел бы дать ссылку на рабочий пример реализации механизма отката в EF и вкратце пояснить его.

Архив с примером можно найти здесь. К слову, пытался создать проект на code.msdn.microsoft.com, но у меня вылетело два exception'а, через которые я пройти не смог. Возможно, чуть позже выложу проект и там.

Теперь пройдемся по коду. Как я уже говорил, в качестве основы я взял код из проекта Perseus: Entity Framework EntityBag, который был написан Danny Simmons. В его блоге содержится достаточно большое и подробное описание проекта и входящих в него классов, поэтому останавливаться на этом я не буду. Расскажу лишь об особенностях своей реализации и с какими сложностями я столкнулся.

Класс ContextSnapshotManager

Проект RollbackLibrary содержит 5 классов. Основным из них является ContextSnapshotManager, который содержит методы MakeSnapshot и RollbackToSnapshot для создания точки сохранения (снимка контекста) и отката до нее:

public class ContextSnapshotManager
{
    public void MakeSnapshot(ObjectContext context, string snapshotName);
    
    public void RollbackToSnapshot(ObjectContext context, string snapshotName, ref ObjectContext newContext);
}

Метод MakeSnapshot принимает контекст и уникальное имя точки, чтобы к ней можно было обратиться в дальнейшем для отката. Метод RollbackToSnapshot принимает текущий контекст, имя точки и новый контекст, в который будет записано контекст по состоянию на момент создания точки сохранения.

Класс ContextSnapshot

Класс ContextSnapshot был практически полностью скопирован у Danny, я лишь добавил метод DetachFromContext и изменил сериализацию:

internal class ContextSnapshot
{
    internal ContextSnapshot(ObjectContext context)
        : this(context, context.Connection.ConnectionString);

    internal ContextSnapshot(ObjectContext context, string connectionString);

    public void ApplyToContext(ObjectContext oldContext, ObjectContext newContext);

    private void DetachFromContext(ObjectContext oldContext);
    private void SerializeSnapshot(ContextSnapshotState snapshot);
    private ContextSnapshotState DeserializeSnapshot();
}

Класс является внутренним (internal), поэтому создать его экземпляр изнутри сборки не получится. Класс ContextSnapshotManager создает экземпляр класса ContextSnapshot, когда мы вызываем метод ContextSnapshotManager.MakeSnapshot. В конструкторе класса ContextSnapshot происходит создание экземпляра класса ContextSnapshotState, в который помещаются все сущности из контекста, и который затем сериализуется. При вызове метода ApplyToContext экземпляр класса ContextSnapshotState десериализуется и его содержимое при помощи дополнительной логики переносится в объект newContext. Сериализацией и десериализацией занимаются приватные методы SerializeSnapshot и DeserializeSnapshot. Еще один вспомогательный метод DetachFromContext необходим для того, чтобы сделать Detach всем сущностям перед тем, как помещать их в новый контекст. Таким образом, после вызова метода ApplyToContext (который вызывает DetachFromContext) использовать старый контекст уже нельзя. Он отжил свое и мы теперь пользуемся новым.

Сериализация

Здесь нужно сделать небольшое отступление и рассказать про метод сериализации. Для сериализации нашего снимка в данный момент используется DataContractSerializer. Это новый тип сериализаторов, который был добавлен в .NET 3.0 (если я не ошибаюсь) специально для работы с WCF. В некотором приближении можно считать, что это продвинутый XmlSerializer. Сериализирует он в свой внутренний формат XML, что означает, что мы получаем данных больше, чем в случае с BinaryFormatter'ом, но в то же время DataContractSerializer более удобен в использовании, чем XmlSerializer. Выбрал я DataContractSerializer по нескольким причинам:

  • Danny использует его в своем примере, но у него работа идет как раз с WCF, у нас задача немного другая, но все равно интересно было посмотреть, что это за зверь такой
  • в сгенерированный набор классов EF (тот, который генерируется по edmx-схеме) уже добавлена правильная поддержка XmlSerializer, SoapSerializer и DataContractSerializer, а между этими двумя лучше все же выбирать второй

Во втором пункте я упомянул, что в наборе классов EF есть встроенная правильная поддержка XmlSerializer, SoapSerializer и DataContractSerializer. Это означает, что для всех классов и свойств этих классов уже проставлены атрибуты DataContract и DataMember, а также XmlIgnore и SoapIgnore, причем проставлены они с умом. Navigation properties помечены как игнорируемые, то есть мы будем сериализировать лишь этот конкретный объект, а не все объекты, связанные с ним, и т.д. Это необходимо помнить, т.к. если вы в процессе работы извлекли из базы в контекст связанные объекты, то после отката они будут потеряны и их нужно извлекать заново. Мы на нашем проекте уже сделали автоматическую генерацию кода, которые реализует lazy load при первом обращении к navigation property, поэтому для нас этой проблемы не существует.

В принципе, никто не мешает изменить сериализатор на BinaryFormatter, но это требует немного больше кода. К сожалению, разработчики EF не предусмотрели установку атрибута NotSerialized для игнорирования navigation properties (почему, для меня до сих остается загадкой), а в partial классе установить его не представляется возможным. Если не игнорировать navigation properties, то вы можете столкнуться с несколькими проблемами:

  • будут сериализоваться связанные объекты, что увеличит объем сериализованных данных
  • при откате в некоторых случаях будет происходить exception, так как метод Attach в EF работает очень хитро: вы вызываете его для одной сущности, а приаттачатся к контексту все, с ней связанные, что приведет к ошибке логики восстановления контекста

Таким образом, остается один выход: реализация интерфейса ISerializable в partial-классах для каждого класса-сущности, что несколько трудозатратно. Конечно, можно реализовать это в одном базовом классе, в котором выяснять, что мы сериализуем, а что нет при помощи рефлексии, но тут, боюсь, мы утратим одно из основных преимуществ BinaryFormatter – скорость. Второе преимущество – размер – можно также нивелировать, если хранить сериализованные данные не в памяти (как в моем примере), а в файлах на диске.

В любом случае, право выбора сериализатора остается за вами.

Класс ContextSnapshotState

Продолжим обзор классов. Класс ContextSnapshotState выглядит очень просто:

internal class ContextSnapshotState
{
    // serializable

    [DataMember]
    string connectionString;

    [DataMember]
    List<IEntityWithKey> unchangedEntities;
    [DataMember]
    List<IEntityWithKey> modifiedEntities;
    [DataMember]
    List<IEntityWithKey> modifiedOriginalEntities;
    [DataMember]
    List<IEntityWithKey> deletedEntities;
    [DataMember]
    List<IEntityWithKey> addedEntities;

    [DataMember]
    List<RelationshipEntry> addedRelationships;
    [DataMember]
    List<RelationshipEntry> deletedRelationships;
    [DataMember]
    List<RelationshipEntry> unchangedManyToManyRelationships;

    // not serializable

    Dictionary<EntityKey, int> addedEntityKeyToIndex;
    
    ...

    // constructor, properties and one method
}

В классе ContextSnapshotState мы отдельно храним и сериализуем сущности и связи между ними в зависимости от текущего состояния сущности или связи. То есть у нас есть коллекции неизменных, добавленных, измененных и удаленных сущностей, а также неизмененных, добавленных и удаленных связей. Стоит еще отметить, что для измененных сущностей мы храним по 2 копии сущности: текущую и оригинальную (это нужно для правильного восстановления сущности). Отдельным полем мы сохраняем также строку подключения. Однако здесь есть одна хитрость, связанная с выбранным типом сериализации. В первую очередь, не все поля класса помечены атрибутом DataMember. Dictionary addedEntityKeyToIndex мы не сериализуем, т.к. он нам нужен лишь для построения правильных связей. Во-вторых, и это очень важно, нам нужно вручную прописать атрибуты KnownType для всех типов объектов, которые могут попасть в снимок. А это значит, что если вы добавляете новую таблицу в базу и соответственно новый класс в контекст, то нужно не забыть добавить его в список известных типов, иначе при десериализации будет exception. Выглядит это все примерно так:

[DataContract]

[KnownType(typeof(Alphabetical_list_of_products))]
[KnownType(typeof(Categories))]
[KnownType(typeof(Category_Sales_for_1997))]
[KnownType(typeof(Current_Product_List))]
...
[KnownType(typeof(Territories))]

internal class ContextSnapshotState
{
...
}

Класс RelationshipEntry

Класс RelationshipEntry призван помочь в сериализации связей между двумя любыми сущностями. Каждый экземпляр класса содержит одну связь, которая характеризуется именем связи, ее состоянием и параметрами двух концов связи. Здесь мы также сериализуем не все.

[DataContract]
internal class RelationshipEntry
{
    // serializable

    [DataMember]
    string RelationshipName { get; set; }
    [DataMember]
    EntityState State { get; set; }

    [DataMember]
    string Role1 { get; set; }
    [DataMember]
    EntityKey Key1 { get; set; }
    [DataMember]
    int AddedEntityIndex1 { get; set; }

    [DataMember]
    string Role2 { get; set; }
    [DataMember]
    EntityKey Key2 { get; set; }
    [DataMember]
    int AddedEntityIndex2 { get; set; }

    // not serializable

    IEntityWithRelationships Entity1 { get; set; }
    IEntityWithRelationships Entity2 { get; set; }

    internal RelationshipEntry(ObjectStateEntry stateEntry, ObjectContext context, Dictionary<EntityKey, int> addedEntityKeyToIndex);

    internal void AddRelationship(ObjectContext context, List<IEntityWithKey> addedEntities);
    internal void AttachRelationship(ObjectContext context);
    internal void DeleteRelationship(ObjectContext context);

    void ResolveEntitiesAndKeys(ObjectContext context, List<IEntityWithKey> addedEntities);
}

Конструктор класса принимает на вход связь, контекст и наш вспомогательный индекс, о котором знает ContextSnapshotState. Методы AddRelashionship, AttachRelationship и DeleteRelationship предназначены для добавления, связывания и удаления связей. Подробнее об этом классе можно узнать в блоге у Danny.

Класс UtilityExtensionMethods

Этот класс сложно назвать классом в полной мере этого слова, т.к. он просто является контейнером для вспомогательных методов расширения (extension methods), которые активно используются для работы с EF в проекте. Класс почти полностью скопирован, за исключением двух методов в конце:

public static IEnumerable<EntityObject> GetEntities(this NorthwindEntities context, string entitySetName, EntityState state);

public static IEnumerable<Customers> GetCustomers(this NorthwindEntities context);

Метод GetEntities позволяет получить набор сущностей определенного типа entitySetName в определенном состоянии state. Метод GetCustomers получает все неизмененные, измененные и добавленные сущности типа Customer из контекста. Мне пришлось написать его, т.к. EF по умолчанию не возвращает сущности, которые были добавлены в контекст, но не были сохранены в базу данных. Также EF возвращает уже удаленные в контексте сущности, поэтому метод GetCustomers отфильтровывает их.

public static IEnumerable<Customers> GetCustomers(this NorthwindEntities context)
{
    List<Customers> unfilteredItems =
        (from o in context.Customers
         select o).ToList();

    List<Customers> items =
        (from o in unfilteredItems
         where o.EntityState != EntityState.Deleted && o.EntityState != EntityState.Detached
         select o).ToList();

    foreach (Customers addedItem in context.GetEntities("Customers", EntityState.Added))
    {
        items.Add(addedItem as Customers);
    }

    return items;
}

Для тестового примера нам будет достаточно метода GetCustomers, так как мы работаем только с сущностями типа Customer. Для более сложных случаев вам придется реализовать методы для остальных сущностей. Постарайтесь запомнить об этой особенности EF, это поможет вам в будущем.

В следующем посте я расскажу про использование данной библиотеки и дам описание коду примера, который был выложен сегодня в тестовом проекте.

No comments:

Post a Comment