Tuesday, April 15, 2008

Entity Framework rollback: Часть 4. Пример приложения

Продолжение. Начало: Entity Framework rollback: Часть 1. Постановка задачи Entity Framework rollback: Часть 2. Варианты решения Entity Framework rollback: Часть 3. Описание реализации библиотеки

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

Для примера я решил написать простенькое консольное приложение, которое работает с моделью, построенной на всеми любимой базе данных Northwind. Для того, чтобы приложение у вас запустилось, необходимо выполнить 3 условия:

  1. установить SQL Server 2005 (наверно, можно попробовать запустить и под 2000 или 2008, но я не пробовал, поэтому советовать не буду)
  2. установить базу данных Northwind, если она у вас еще не установлена
  3. исправить строку подключения к базе данных, введя свой путь к SQL Server и другие параметры

Строку подключения нужно поменять и в App.config проекта Client, и в App.config проекта Northwind. Насколько я понимаю, первая используется клиентским приложением, вторая – студией, когда вы работаете с моделью, но я могу и ошибаться. Перейдем к коду примера. Здесь все довольно просто. Сначала объявляем две константы, это наши уникальные имена для точек отката:

private const string FirstSnapshot = "FirstSavePoint";
private const string SecondSnapshot = "SecondSavePoint";

В функции Main создаем контекст NorthwindEntities и экземпляр класса ContextSnapshotManager, который и будет отвечать за процедуру отката:

NorthwindEntities entities = new NorthwindEntities();
ContextSnapshotManager snapshotManager = new ContextSnapshotManager();

Ставим первую точку и выводим на консоль сообщение:

snapshotManager.MakeSnapshot(entities, FirstSnapshot);
PrintLogMessage("First save point is created");

Затем получаем коллекцию сущностей типа Customer, которые проживают в славном городе Лондоне, выводим их на экран и меняем им место жительства на не менее славный город Харьков :)

List<Customers> londonCustomers = GetCustomers(entities, "London");
PrintCustomers(londonCustomers);

// change customer's cities to Kharkov
foreach (Customers customer in londonCustomers)
{
    customer.City = "Kharkov";
}
PrintLogMessage("London -> Kharkov");
PrintCustomers(londonCustomers);

Ставим вторую точку сохранения перед тем, как переселить наших заказчиков еще дальше:

// second save point
snapshotManager.MakeSnapshot(entities, SecondSnapshot);
PrintLogMessage("Second save point is created");

И переселяем их в Полтаву (а что, тоже отличный город, я там родился, им тоже понравится :)):

// change customer's cities to Poltava
foreach (Customers customer in londonCustomers)
{
    customer.City = "Poltava";
}

PrintLogMessage("Kharkov -> Poltava");
PrintCustomers(londonCustomers);

После того, как мы проверили, что все заказчики переселились в Полтаву, мы сделаем первый откат. Откатываться будем сначала до второй точки, хотя можно и сразу до первой, если это необходимо. Прелесть тех снимков контекста, которые мы делаем в том, что нам становится не очень важно, куда откатываться. Результат выводим на консоль. Как видите, заказчики прыгнули в прошлое и снова оказались в Харькове:

// rollback to the second save point
entities = RollbackToSavePoint(snapshotManager, entities, SecondSnapshot);
PrintLogMessage("Rolling back to the second save point");

// after rollback to the second save point we should get customers in Kharkov
// we have to get customers list again because the previous list is outdated
londonCustomers = GetCustomers(entities, "Kharkov");
PrintCustomers(londonCustomers);

Здесь очень важным моментом является то, что после отката нам нужно получить коллекцию сущностей еще раз, так как после сериализации и десериализации мы получаем совершенно новый контекст с новыми объектами, а старый контекст и объекты становятся устаревшими. Будьте внимательны, если после отката у вас остаются ссылки на сущности из старого контекста – они не будут работать, как раньше, о них ничего не знает новый контекст. Для себя мы решили эту проблему, создав событие OnRolledBack, на которое подписываются все долгоживущие объекты, которые так или иначе хранят ссылки на сущности контекста. В этом событии мы просто освежаем ссылки, вытаскивая объекты из базы еще раз. Да, немного накладно, но в принципе терпимо, если этих объектов немного.

Дальше делаем второй откат, теперь уже до первой точки и выводим на консоль то, что получилось:

// rollback to the first save point
entities = RollbackToSavePoint(snapshotManager, entities, FirstSnapshot);
PrintLogMessage("Rolling back to the first save point");

// after rollback to the first save point we should get customers in London
// we have to get customers list again because the previous list is outdated
londonCustomers = GetCustomers(entities, "London");

PrintCustomers(londonCustomers);

Вот так и закончились невероятные приключения англичан в Украине. Они вернулись в Лондон и даже не помнят, что видели здание Госпрома и Поле Полтавской битвы.

Отдельного упоминания заслуживают несколько методов. Метод RollbackToSavePoint производит процедуру отката к точке snapshotName. В нем мы создаем новый объект контекста, в который во время отката переливаются сохраненные сущности, вызываем процедуру отката и затем возвращаем ссылку на новый контекст:

static NorthwindEntities RollbackToSavePoint(ContextSnapshotManager snapshotManager, NorthwindEntities currentEntities, string snapshotName)
{
    // create new context
    ObjectContext newEntities = new NorthwindEntities();
    // rolblack to save point (snapshot)
    snapshotManager.RollbackToSnapshot((ObjectContext)currentEntities, snapshotName, ref newEntities);
    // restore the status quo
    return (NorthwindEntities)newEntities;
}

Метод GetCustomers имеет два перегруженных варианта. Первый из них просто возвращает все сущности типа Customer, а второй возвращает лишь тех, кто проживает в определенном городе:

static List<Customers> GetCustomers(NorthwindEntities entities)
{
    var result =
        (from o in entities.GetCustomers()
        select o).ToList<Customers>();
    return result;
}

static List<Customers> GetCustomers(NorthwindEntities entities, string city)
{
    var result =
        (from o in entities.GetCustomers()
         where o.City == city
         select o).ToList<Customers>();
    return result;
}
Вот, собственно говоря, и все. Берите, пользуйтесь, любые пожелания и предложения приветствуются. На данный момент этот код уже прошел проверку в реальной работе, но не факт, что в нем нет багов.

10 comments:

  1. Подлипенский ПавелApril 15, 2008 at 10:05 PM

    Замечательный walkthrough Entity Framework! Возможно тебе будет интересно, что скоро выходит его релиз - буквально в следующем сервис-паке для VS 2008. Об этом можно почитать тут: http://blogs.msdn.com/adonet/archive/2008/04/09/entity-framework-ado-net-data-services-to-ship-with-vs-2008-sp1-net-3-5-sp1.aspx

    ReplyDelete
  2. Ага, спасибо, я уже в курсе :) Вот только когда эта радость наступит, пока неизвестно.

    ReplyDelete
  3. Hi Alexander!

    I found your article and read it using google-translation :-)

    I found the need of using context snapshots, and you approach seems great.

    I was wondering though, how your library work in real life scenarios (you said you've tested it for real work), and I was hoping you could give me some numbers of performance, how heavy is to take snapshots (more important than the rollback performance), etc.

    Thanks a lot,
    Shlomi

    ReplyDelete
  4. Hi Shlomik,

    I am really surprised that somebody translated this blog post to get info. If I knew this before I would create an English mirror of it. Sorry for that :)

    Actually, this code works in my current project. It behaves well from the correctness point of view. However, if you have any problems with it - please let me know. I've changed the real code a little bit to store snapshots in file system to avoid overuse of memory - that's probably the only change I've made. As for the performance point of you it depends on the amount of data you are storing in your EF context. In our project we store a lot of data therefore process of serialization/deserialization is quite slow. For serialization it takes about a second. Deserialization and rolling back to the previous snapshot can take about 4-5 seconds. That's acceptable in our case however it might be too big for you. In such case you can think of 2 additional performance tricks: 1) use BinaryFormatter instead of DataContractSerializer, 2) serialize not the whole context but only modified/added/deleted entities/relations to it and then restore only them (unchanged items will be restored on their own when you first access to them from code). I've checked the second approach on slightly different task and it worked fine.

    Please let me know your situation and the number of data - probably I will be able to give you some advices.

    ReplyDelete
  5. Вопрос - что можно сделать в таком случае : открыли одно окно для редактирование сущности, открыли второе окно для редактирования другой сущности, поменяли первую сущность, поменяли вторую сущность, в первом окне жмем отмена, а во втором - сохранить?

    ReplyDelete
  6. Я так понимаю, обе сущности были из одного и того же контекста? Мы сталкивались с этой проблемой, собственно, поэтому я и писал вот эту логику отката. У нас было все намного сложнее - вложенные диалоговые окна, по нажатию OK в окне более высокого уровня нужно обновлять данные в нижних окнах, что требует изменения данных в контексте. Единственное - вариант реализации в блоге уже морально устарел, я его доработал в реальном проекте (пофиксил пару багов, значительно улучшил производительность за счет сериализации только изменений, а не всего графа объектов). Так что можно пользоваться.

    Но есть и несколько более простых вариантов решения (может, повезет отделаться легким испугом):

    1) Самый простой, но не факт, что подходит в вашем случае: Не обновлять реальные сущности до нажатия кнопки OK или Save в диалоговых окнах. Если у вас нет вложенных диалоговых окон - может сработать

    2) Использовать разные контексты для разных окон. Таким образом, вам абсолютно не важно, кто и где там параллельно что делает (вернее, важно, если идет редактирование одной и той же сущности параллельно, но там есть свои решения)

    3) Временно сохранять изменения в сущности, не привязанной к контексту (POCO или просто detached EF entity). Таким образом, если нажата кнопка Отмена - ничего страшного не произошло. Если ОК - тогда переносите изменения в контекстную сущность (копирование полей или можно попробовать реализовать Attach)

    4) Ну, и тяжелая артиллерия: если изменения были в одной сущности или нескольких, но вы точно знаете, в каких, то можно просто откатить в них изменения. Не помню точно API, что-то вроде Refresh() и там внутри параметры, откуда брать данные при конфликте, ставить StoreWin - и все хорошо.

    ReplyDelete
  7. Здравствуйте, Александр. У меня при использовании вашей библиотеки падает с исключением An object with the same key already exists in the ObjectStateManager. The ObjectStateManager cannot track multiple objects with the same key - на моменте IEnumerable newUnchangedEntities = newContext.GetEntities(EntityState.Unchanged);
    foreach (IEntityWithKey entity in snapshot.ModifiedOriginalEntities)
    {
    newContext.Attach(entity);
    }

    сразу же первым добавлением

    ReplyDelete
  8. Все возможно. Как я писал, библиотека уже старовата. Вы какую версию EF используете: 1.0 или 4.0? Если 4.0, то там уже все совсем по-другому. Во-первых, данный подход, возможно, не будет работать совершенно. Во-вторых, там есть свои, более простые способы, так как появились POCO-объекты и другие улучшения.

    ReplyDelete
  9. Мне, грубо говоря, нужно решить следующую задачу. У записи есть подзаписи. У записей и подзаписей есть поля-ссылки на справочники.

    Создается новая запись, открыта форма создания, для заполнения ее полей. В этой форме есть кнопки "создать подзапись" и "редактировать подзапись". Мы находимся в процессе создания новой записи, "Сохранить" еще не нажимали, т.е. SaveChanges еще не происходило (поскольку пользователь может нажать "отмена"). Создаем подзапись у записи - открывается форма создания подзаписи. Заполнили поля, нажали "ок" - подзапись добавилась к создаваемой записи, но ОК еще НЕ НАЖАТА - запись еще не сохранена в базу.

    Нажимаем на созданную подзапись "редактировать". Открывается форма редактирования полей подзаписи. Меняем несколько полей и нажимаем ОТМЕНА. А т.к. форма прибиндена к подзаписи (WPF) - несмотря на отмену - поля подзаписи изменились. А по логике - не должны.

    Я заложился на вашу библиотеку, думал сделать слепок всей записи при нажатии на "редактировать подзапись" и "откат" в случае "отмены редактирования подзаписи. А если я редактирую поля и записи, и подзаписи - возникает искл-е, которое я описал - не получается использовать вашу библиотеку :(


    Подскажите пожалуйста, в какую сторону можно дернуться при решении задачи, у меня по EF опыта пока мало. Может у последних EF (используется последний) есть "из-коробки" решения задачи отката.

    ReplyDelete
  10. Если вы используете биндинг в WPF, я бы попробовал использовать не приаттаченные к контексту сущности, и лишь в случае сохранения изменений аттачить их и вызывать SaveChanges(). Таким образом вы будете работать с сущностями, которые ничего не знают о EF и вам не придется ничего откатывать.

    В EF 4.0 для этих целей также можно использовать POCO сущности.

    То есть, когда вы создавете новую запись, вы просто создаете ее с нуля и биндите к нужному контролу. Создается что-то еще внутри - без проблем. Удаляется - удаляете. Когда нужно сохранить, собираете все это добро воедино, аттачите к контексту и сохраняете.

    Еще я бы советовал использовать MVVM в этом случае, т.к. тогда вы сможете реализовать именно тот ViewModel для биндинга, который вам нужен.

    ReplyDelete