Sunday, March 30, 2008

Entity Framework rollback: Часть 2. Варианты решения

Продолжение. Начало: Entity Framework rollback: Часть 1. Постановка задачи

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

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

  1. освободить и уничтожить текущий контекст, для дальнейшей работы создать новый
  2. воспользоваться методами Refresh у контекста для того, чтобы обновить конкретные сущности в контексте или коллекцию сущностей

Первый вариант хорош тем, что не нужно ни о чем задумываться, но немного плох с точки зрения производительности. Мало того, что будет потрачено время на создание нового контекста, так в него нужно еще и все данные загрузить заново для работы. К слову сказать, в ADO.NET team blog выложено несколько статей по анализу производительности в EF. Вы можете обратиться к ним и узнать некоторые подробности (на данный момент вышло три статьи, но думаю, будут еще):

Exploring the Performance of the ADO.NET Entity Framework - Part 1 Exploring the Performance of the ADO.NET Entity Framework – Part 2 ADO.NET Entity Framework Performance Comparison

Второй вариант предпочтителен тогда, когда вы точно знаете, что у вас обновлялось. В этом случае вы получите всего лишь несколько запросов к базе данных для тех сущностей, которые нуждаются в откате, а остальные данные останутся нетронутыми. Для подобного отката необходимо вызвать соответствующий метод Refresh и передать ему значение RefreshMode.StoreWins в качестве первого параметра. К слову сказать, если вы не знаете, что у вас менялось, то вы все равно можете реализовать этот вариант при помощи дополнительной логики приложения. Для этого нужно обратиться к контексту (вернее, к ObjectStateManager этого контекста) и узнать у него, какие сущности и связи были модифицированы, добавлены или удалены. Далее нужно обновить лишь модифицированные/удаленные сущности/связи и удалить добавленные из контекста. Совсем кошмарным с точки зрения производительности будет вариант, если вы просто пройдетесь по всем коллекциям объектов и связям без предложенной оптимизации и начнете обновлять их все подряд, поэтому в таком случае уж лучше воспользоваться первым вариантом.

Но вернемся к нашим баранам. Для нашего сценария также есть несколько вариантов решения:

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

Эти два варианта были подтверждены Danny Simmons, который является разработчиком куска EF и активным действующим лицом на форуме EF. Также у этого парня есть замечательный блог, в котором он освещает огромное количество вопросов, связанных с EF. Именно благодаря проделанной им работе и постам на форуме мы и смогли решить проблему, с которой столкнулись.

Первый вариант решения можно реализовать при помощи имеющихся в EF событий, которые срабатывают на различные операции изменения, например, EntityObject.PropertyChanging, EntityObject.PropertyChanged (модификация сущностей), RelatedEnd.AssociationChanged (модификация связей, таких как EntityReference и EntityCollection), ObjectStateManager.ObjectStateManagerChanged (добавление/удаление сущности) и др. Этот вариант хорош, но он требует очень много кодирования и анализа, так как нам нужно не просто повесить обработчики на все сущности и связи, а потом сохранить все изменения, но и откатить их корректно и в правильной последовательности. То есть если мы «удалили» сущность из контекста (а фактически, пометили ее, как удаленную), то нам нужно потом добавить ее в контекст и пометить как Unchanged. В то же время, с точки зрения производительности и расхода памяти этот вариант является более приемлемым, чем второй.

К слову сказать, Danny в своем ответе мне дал несколько дельных советов тем, кто решит реализовать именно этот вариант.

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

  1. в точке сохранения сериализуем контекст
  2. храним сериализованный контекст до момента, когда пользователь захочет откатиться
  3. восстанавливаем состояние контекста из сериализованных данных

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

PS. Есть еще два потенциально возможных решения, но мне они кажутся сложными в реализации, не очень надежными и не отвечающими поставленной задаче, поэтому мы их не рассматриваем:

  1. сохранять все изменения в базу данных, но помечать записи особым образом (как минимум, нужно хранить состояние каждой записи и пользователя, который сделал изменение), чтобы потом можно было откатиться
  2. в точке сохранения стартовать транзакцию и делать commit/rollback позже в зависимости от того, что выбрал пользователь

Saturday, March 29, 2008

Entity Framework rollback: Часть 1. Постановка задачи

Этим постом я бы хотел начать серию сообщений о реализации отката (rollback) контекста в Entity Framework (EF). Я столкнулся с этим на своем текущем проекте и хотел бы поделиться тем, что у меня получилось и, возможно, получить дельные советы от тех, кто это реализовал по-другому или у кого есть идеи по улучшению реализации.

Итак, начнем с того, что на данный момент в EF (beta 3) нет полноценной возможности сохранять изменения, которые содержаться в контексте в памяти и потом откатываться к ним. Есть варианты отката изменений из контекста до текущего состояния базы данных, но и только.

Теперь постановка задачи: представьте себе приложение ASP.NET или WinForms, в котором пользователь может взять какой-нибудь набор связанных или не связанных объектов (Поставщики, Заказы, пр.), отредактировать эти объекты и потом нажать кнопочку Save, которая сохранит данные в базу данных. Или не нажать эту кнопочку, а нажать вместо нее Cancel, что отменит изменения. Ничего сложного, скажете вы. Можно: а) дать пользователю ввести все изменения, нигде их не сохраняя, а потом просто занести в базу по нажатию кнопки, б) если же все же нужно сохранять промежуточные изменения (допустим, если нужно добавить несколько объектов подряд, или нескольких объектов в разных диалогах), то их можно сделать в контексте в памяти, а потом по нажатию кнопки одним махом сохранить или не сохранить в базу. Возражения правильные и предложенные решения работают. Однако существует еще один сценарий, который не покрывается этими двумя вариантами. По этому сценарию пользователь делает промежуточные изменения, переходит к следующим, снова делает какие-то промежуточные изменения, а потом решает откатиться до точки между этими изменениями. Например, он открыл окно редактирования объекта, увидел список связанных объектов, потом добавил пару объектов в список (первое промежуточное изменение), потом кликнул на кнопке редактирования одного из объектов из списка и получил новое диалоговое окно, в котором находится список связанных объектов, где он также может добавить несколько объектов (второе промежуточное изменение) и нажать Cancel. По нажатию на Cancel мы должны откатить второе промежуточное изменение и выйти на точку, где у нас есть только первое изменение. А вот уже эта задача, к сожалению, не решается стандартными средствами EF. Вы не можете откатиться до произвольной точки в памяти, вы можете лишь откатиться до состояния базы данных. Зато эта задача решается с помощью терпения и напильника :)

Перед тем, как идти дальше, нужно разобраться в терминологии и предмете. Контекст в EF является отсоединенным источником данных (как DataSet или DataTable). То есть по-хорошему он кэширует однажды полученные из базы данных результаты и в дальнейшем по запросам лишь проверяет, нет ли новых записей (подробнее об этом механизме я, возможно, расскажу после того, как закончу текущую серию). Таким образом, если мы выбрали данные в контекст, то в грубом приближении мы можем считать, что после этого мы работаем лишь с данными, которые находятся в памяти. Повторяю: это не так в реальности, но на данном этапе нам этого хватит. Изменения, произведенные в памяти, не сохраняются в базу до тех пор пока мы явно не вызовем метод SaveChanges у контекста. Понимание этого факта необходимо для дальнейшего разбора.Также замечу, что здесь и в дальнейшем, когда я говорю «контекст», я понимаю экземпляр класса ObjectContext или унаследованного от него вашего класса.

Кому интересно узнать больше про EF уже сейчас, отправляю сюда:

The ADO.NET Entity Framework (MSDN) ADO.NET Entity Framework (Wikipedia) Продолжение в следующих постах.

Tuesday, March 25, 2008

Особенности TextBox.MaxLength

Новая рубрика: «Знаете ли вы, что?»

Скорее всего, с этим интересным моментом из жизни .NET Framework уже многие сталкивались, но для тех, кто еще не успел, пишу этот пост.

Итак, проблема: нужно не дать пользователю ввести в какое-нибудь поле ввода на странице больше символов, чем определено в базе данных, иначе вполне очевидно вылетит database exception. Имеется в виду, конечно же, случай, когда мы используем nvarchar(xx) и различные вариации на тему.

Глобально решать эту проблему можно двумя путями: не дать пользователю ввести больше символов, чем нужно, или просто урезать введенную строку (на сервере или на клиенте). Второй вариант является не очень красивым, так как пользователь ожидает, что все введенные им данные будут сохранены приложением, а они будут сохранены в урезанном виде. Кроме того, если пытаться урезать данные на сервере, то пользователь может запросто ввести больше 100 Мб текста в поле ввода, что спровоцирует exception еще на подступах к приложению (IIS 6 по умолчанию имеет ограничение в 4 Мб на передачу запросов/ответов, решение этой проблемы хорошо описано в интернете на форумах). Так что более правильным вариантом является явное ограничение размера поля на клиенте.

Казалось бы, что может быть проще. В TextBox'е есть замечательное свойство MaxLength, которое нам и нужно установить для всех наших полей ввода. В сгенерированном HTML-элементе это свойство просто становится атрибутом maxLength. Проставляем, запускаем, пробуем и радуемся. Все работает просто замечательно :). Устанавливаем баги в баг-трекинг системе и забываем о проблеме. Не тут-то было. Через время часть багов возвращается с feedback'ами. Для некоторых полей свойство почему-то не срабатывает. Перепроверяем и сразу же понимаем, в чем проблема – не срабатывают многострочные текстбоксы, которые транслируются в <textarea>, а не в <input type="text">. Смотрим MSDN и видим замечательный note от Microsoft:

Note. This property is applicable only when the TextMode property is set to TextBoxMode.SingleLine or TextBoxMode.Password.

Смотрим <input type="text"> и <textarea> в MSDN – так и есть, атрибут maxLength определен для первого и не определен для второго элемента. Почему так сделано – загадка, но нам нужно как-то с этим бороться. Быстрый поиск дает 2 варианта решения:

  1. Javascript, который не дает ввести больше элементов, чем нужно (вариантов много, например, вот и вот)
  2. Regex-валидатор, который будет проверять размер введенного текста
  3. Custom-валидатор с client-скриптом

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

Выражение для проверки длины текста выглядит очень просто: "^[\s\S]{0,XX}$", где XX – максимально допустимая длина нашего текста. Для более удобного использования можно реализовать свой валидатор на базе стандартного, например, так:

public class MaxLengthValidator : RegularExpressionValidator
{
    private int maxLength = int.MaxValue;

    public MaxLengthValidator()
    {
        int configMaxLength;
        if (int.TryParse(Config.TextBoxMultilineMaxLength, out configMaxLength))
        {
            MaxLength = configMaxLength;
        }
    }

    public int MaxLength
    {
        get { return maxLength; }
        set { maxLength = value; }
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        ValidationExpression = @"^[\s\S]{0," + MaxLength + "}$";
        if (ErrorMessage.Contains("{0}"))
        {
            ErrorMessage = string.Format(ErrorMessage, MaxLength);
        }
    }
}

Как нетрудно догадаться, Config.TextBoxMultilineMaxLength возвращает длину полей ввода из конфигурационного файла. Это удобно, если мы хотим контролировать максимальную длину текста для полей типа ntext и хотим сделать эту настройку system wide. Если вам это не нужно, то можно просто убрать этот код из конструктора и определять MaxLength явно. Также, если мы хотим выдавать сообщение об ошибке, которое будет содержать установленный нами лимит, то это тоже не сложно сделать в событии OnLoad, как сделано в примере. Пользоваться этим валидатором так же просто, как и другими.