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;
}
Вот, собственно говоря, и все. Берите, пользуйтесь, любые пожелания и предложения приветствуются. На данный момент этот код уже прошел проверку в реальной работе, но не факт, что в нем нет багов.

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, это поможет вам в будущем.

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

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, как сделано в примере. Пользоваться этим валидатором так же просто, как и другими.

Saturday, November 10, 2007

Жизнеутверждающе: притча про осла и колодец

Однажды осел фермера провалился в колодец. Пока фермер думал, как ему поступить, животное часами издавало жалобные звуки. Наконец фермер принял решение, он посчитал, что осел уже старый, а колодец нужно было закрывать в любом случае. Просто не стоило тратить тех усилий ради того чтобы вытаскивать старого осла. Он пригласил всех своих соседей помочь ему закопать колодец. Все дружно взялись за лопаты и принялись копать и забрасывать землю в колодец. Осел сразу же понял к чему идет дело и начал издавать страшный визг. Затем ко всеобщему удивлению он притих. После нескольких бросков земли фермер решил проверить и посмотреть как там внизу. Он был изумлен от того, что он увидел там. С каждым куском земли, падавшим на его спину, ослик проделывал что-то совершенно невероятное. Он встряхивался и становился поверх сброшенной земли. Пока соседи фермера продолжали забрасывать землю в колодец, каждый раз животное встряхивалось и становилось поверх насыпленной земли. Очень скоро все удивились, потому что увидели, как ослик поднялся наверх, перепрыгнул через край колодца и умчался вперед как угорелый! В жизни вам будет встречаться много всякой грязи и каждый раз жизнь будет посылать вам все новую и новую порцию. Всякий раз, когда упадет ком земли, встряхнись и поднимайся наверх и только так ты сможешь выбраться из колодца. Каждая из возникающих проблем - это как камень для перехода на ручье. Если не останавливаться и не сдаваться, то можно выбраться из любого самого глубокого колодца. Встряхнись и поднимайся наверх. Чтобы быть счастливым запомни пять простых правил : 1. Освободи свое сердце от ненависти - прости. 2. Освободи свое сердце от волнений - большинство из них не сбываются. 3. Веди простую жизнь и цени то, что имеешь. 4. Отдавай больше. 5. Ожидай меньше.

Sunday, November 4, 2007

Панорамирование

Сегодня речь пойдет не о программировании или менеджменте, а о более приятном занятии - фотографии :) Решил наконец-то поэкспериментировать с программами, создающими панорамные изображения. Несмотря на то, что за лето-осень отщелкал около 10 Гб фотографий, панорамных заготовок сделал всего две: лужок возле неизвестного поселка на берегу Сев. Донца и Красную площадь в Москве. Сразу оговорюсь, что обе панорамы делались при полнейшем незнании законов панорамной фотографии и не на самый лучший фотоаппарат (Canon A620). И если второй фактор является достаточно важным, но все же не критическим, то первый заставил меня попотеть, чтобы сшить все это безобразие воедино.

Почитал и попробовал в действии несколько утилит и остановился на одной из них - Panorama Factory. Работа с ней оказалась на редкость простой и интуитивной. Основные шаги, как и следовало ожидать, следующие:

  • выбираем фотографии для сшивания
  • выбираем режим сшивания: автоматический, полуавтоматический и ручной
  • настраиваем некоторые параметры (фокусное расстояние, вид панорамы, контраст и т.д.)
  • даем программе подумать минутку-другую
  • сохраняем результат
Ну, что, поехали? Я уже было обрадовался, что можно сейчас все поставить на автомат, и мы получим классную панораму, на которую с восхищением и придыханием будут смотреть друзья, но не тут-то было. С лужком все прошло более-менее гладко. Там половина картинки - голубое небо, а вторая - желто-зеленый луг и деревья. Артефакты есть, но их почти не видно - хоть на выставку отдавай. Однако Красная площадь охладила мой пыл. Картинка получилась ужасная: дома размылись, российский флаг на Кремле расшатало, а Спасскую башню увенчало две звезды вместо одной. Не говоря уже о людях, которые на местах сшива стали полупрозрачными :) Пришлось отматывать шаги мастера назад и выбирать полуавтоматический режим. В случае выбора этого режима к уже перечисленным шагам добавляется еще один - размещение точек сшива. В принципе, этот шаг программа умеет делать и автоматически, но, видимо, это работает лишь для фотографов с более прямыми руками или штативом. В противном случае приходится делать ручную поправку на кривизну рук и фотоаппарата (надо сказать, для моего фотоаппарата это актуально - он по умолчанию удаляет картинку, что увеличивает "трудности перевода") :) Как оказалось, если грамотно подойти к делу, то ручной сшив фотографии можно сделать делом простым и удобным. Расставляем точки соответствия на соседних кадрах панорамы, а программа сама видоизменяет кадры таким образом, чтобы хоть что-то да получилось. В результате получаем более-менее сносную панораму с все теми же полупрозрачными людьми, которым не стоялось на месте, пока я фотографировал :) Думаю, с ними тоже можно успешно разобраться, подолбавшись в фотошопе, но на это меня уже не хватило. К слову, с этими людьми вообще беда - вечно норовят залезть в какой-нибудь обалденный кадр, либо, наоборот, вылезти из него, когда их фотографируют...

Результаты:

1) Лужок


2) Красная площадь


В целом, Panorama Factory со своей задачей справилась. Единственный "недостаток" программы - ее цена :) Может, есть фришные аналоги? Поделитесь, пожалуйста. Да и этот триальный текст по центру фотографии не радует, к сожалению.

PS. Еще порадовало, что у разработчиков Panorama Factory есть свой талисман - дракончик :) Приятно, наверно, работать в такой команде.