Entity Framework (EF) существует уже более восьми лет и за это время подверглась множеству изменений. Это означает, что разработчики изучают, как получить выигрыш от более новых средств в приложениях, использующих ранние версии EF. Я работала со многими группами разработчиков ПО, проходящих этот процесс, и делюсь с вами извлеченными уроками в статье из двух частей. В первой части мы обсуждали обновление до EF6, используя старое EF4-приложение в качестве примера (
bit.ly/1jqu5yQ). Я также давала советы насчет того, как лучше разбивать крупные модели (EDMX или Code First) на меньшие модели Entity Data Models, поскольку рекомендовала работать со множеством ориентированных на специфические задачи моделей, а не использовать одну большую модель на все случаи жизни, которая перегружена зачастую излишними связями.
На этот раз я воспользуюсь специфическим примером, чтобы подробнее рассказать о разбиении на малые модели и последующем их применении для решения некоторых проблем, встречающихся при переходе с ObjectContext API на более новый DbContext API.
Для этой статьи я взяла существующее приложение. Оно представляет собой несколько большее, чем просто демонстрационное приложение. Так вы получите представление о реальных проблемах, с которыми можно столкнуться при рефакторинге своих решений. Это небольшое приложение-пример из моей книги «Programming Entity Framework, 2nd Edition» (O’Reilly Media, 2010), написанной по использованию EF4 и ObjectContext API. Его модель EDMX была сгенерирована с помощью Database First, а затем настроена в EF Designer. В этом решении я ушла от шаблона T4 для генерации кода по умолчанию, который создает классы сущностей; причем все они наследуют от класса EntityObject. Вместо этого я применила шаблон T4, создающий POCO-объекты (plain old CLR objects), которые поддерживались, начиная с EF4, и немного модифицировала этот шаблон. Должна заметить, что это приложение для клиентской стороны, и поэтому в нем не возникают некоторые трудные задачи, как в отключенном приложении, где отслеживать состояние сложнее. Тем не менее, многие из проблем, которые вы увидите здесь, применимы к обоим сценариям.
В предыдущей статье я обновила это решение на использование EF6 и Microsoft .NET Framework 4.5, но не внесла никаких изменений в свою кодовую базу. Оно по-прежнему использует исходный шаблон T4 для генерации POCO-классов и класса ObjectContext, управляющего сохранением.
Клиентская часть приложения использует Windows Presentation Foundation (WPF) для своего UI. Хотя его архитектура многоуровневая, мои соображения по проектированию архитектуры приложений с тех пор определенно изменились. Тем не менее, я воздержусь от полного рефакторинга, от которого мое сердце радостно забилось бы при виде модифицированного кода.
Мои цели в этой статье таковы:
- извлечь меньшую модель, сосредоточенную на одной задаче;
- изменить генерацию кода меньшей модели так, чтобы получать POCO-классы в более новом стиле и класс DbContext для управления сохранением;
- исправить любой существующий код, использующий ObjectContext и разрушенный из-за переключения на DbContext. Во многих случаях это означает создание дублирующих методов с последующей модификацией под логику DbContext. Тем самым я не разрушу остальной код, который все еще использует исходную модель и ее ObjectContext;
- найти возможности для замены логики более простой и эффективной функциональностью, введенной в EF5 и EF6.
Создание модели обслуживания туристических поездок
В прошлой статье я дала рекомендации по поводу того, как идентифицировать и извлекать меньшую модель из большой. Следуя им, я изучила модель этого приложения, которая не очень велика, но содержит сущности для управления разнообразными задачами, выполняемыми любым турагентством. В ней есть сущности для обслуживания туристических поездок, определяющие места назначения, услуги, места проживания, даты и другие подробности, а также клиентов, их предварительные заказы, платежи и разнообразную контактную информацию. Полная модель показана на левой стороне рис. 1.
Рис. 1. Полная модель (слева), ограниченная модель (справа), созданная с помощью сущностей, показанных в более крупной модели темно-серым цветом
Одна из задач приложения — дать возможность пользователям определять новые поездки, а также поддерживать существующие через WPF-форму, показанную на рис. 2.
Рис. 2. WPF-форма для управления подробностями поездок
WPF-форма обрабатывает определение поездок: выбор места назначения (destination) и отеля, даты начала и окончания, а также множество услуг. Поэтому я могу представить себе модель, ограниченную только этим набором задач. Я создала новую модель (она тоже показана на рис. 1), используя мастер Entity Data Model и выбрав только релевантные таблицы. Кроме того, модели известно о таблице join, отвечающей за отношение «многие ко многим» между мероприятиями (events) и услугами (activities).
Далее я применила к этим сущностям в новой модели, ранее определенным в исходной модели, одинаковые модификации (от которых зависит мой код). В большинстве своем это были изменения в именах сущностей и их свойств, например Event стал Trip, а Location — Destination.
Я работаю с EDMX, но вы можете определить новую модель для Code First, создав новый класс DbContext, ориентированный только на четыре сущности. Однако в этом случае вам понадобится либо определить новые типы, не включающие лишние отношения, либо задействовать сопоставления Fluent API, чтобы игнорировать конкретные отношения. В выпуске своей рубрики за январь 2013 года (статья «Shrink Models with DDD Bounded Contexts», bit.ly/1isIoGE) я давала рекомендации по варианту с Code First.
Приятно разделаться с проблемами поддержки отношений, которые в данном контексте никого не волнуют. Например, бронирования и все прочее, что связано с этим (например, платежи и клиенты), теперь исчезло.
Я также урезала Lodging и Activity, потому что на деле мне требуется лишь их идентификация и название для обслуживания туристических поездок. К сожалению, некоторые правила сопоставления с полями баз данных, не поддерживающими null-значения, вынудили меня оставить CustomerID и DestinationID. Но мне больше не нужны навигационные свойства от Destination или Lodging обратно к Trip, поэтому я удалила их из модели.
Затем мне пришлось подумать, что делать с генерируемыми классами. У меня уже есть классы, сгенерированные из другой модели, но они относятся к связям, которые мне не нужны в этой модели или их просто нет в ней. Так как я уже довольно давно использую подход Domain-Driven Design (DDD), я имею набор классов, специфичных для новой модели, и использую их отдельно от других сгенерированных классов. Эти классы находятся в отдельном проекте и в другом пространстве имен. Поскольку они сопоставлены с общей базой данных, мне незачем беспокоиться о том, что изменения, внесенные в одном месте, не появляются в другом. Классы, сфокусированные только на одной задаче обслуживания поездок, упростят кодирование, хотя в моем решении это означает появление некоторых дубликатов. Избыточность — это компромисс, к которому я готова и уже пришла в предыдущих проектах. В левой части рис. 3 показан шаблон (BreakAway.tt) и сгенерированные классы, связанные с большой моделью. Они находятся в собственном проекте, BreakAwayEntities. О правой части рис. 3 я расскажу чуть позже.
Рис. 3. Исходные классы, сгенерированные из шаблона T4 для большой модели, в сравнении с новой моделью и сгенерированными для нее классами
В прошлой статье, когда я создавала новую модель, я использовала свой исходный шаблон генерации кода, чтобы единственной проблемой на этом этапе была гарантия того, что мое приложение функционирует, используя малую модель, и что мне не придется постоянно волноваться об изменившихся EF API. Я сумела добиться этого, заменив код в файлах шаблонов по умолчанию (TripMaintenance.Context.tt и TripMaintenance.tt) кодом из своих файлов BreakAwayEntities.tt. Кроме того, мне пришлось изменить файловые пути в коде шаблонов, чтобы указать на новый файл EDMX.
В общей сложности на изменение ссылок и пространств имен ушло около часа, после чего я смогла запустить приложение и свои тесты, использующие новую модель, и не просто получить данные, но и отредактировать или вставить новые графы данных.
Закрываем проблему: переходим на DbContext API
Теперь у меня есть меньшая модель, гораздо менее сложная; она упростит задачу взаимодействия с этими данными в моем WPF-коде. Я готова взять более трудный аккорд: заменить ObjectContext на DbContext, чтобы можно было удалить код, написанный мной для работы напрямую с ObjectContext. Я рада, что буду иметь дело лишь с меньшей областью кода, относящейся к новой модели. Чтобы не сломать заново сломанную руку, все нужно проделать очень аккуратно, и тогда остальные кости моего приложения останутся в целости и сохранности.
Обновление шаблона генерации кода для моей EDMX Я продолжу использовать EDMX, поэтому, чтобы переключить Trip Maintenance на DbContext API, я должна выбрать новый шаблон генерации кода. Я могу без проблем обращаться к одному из таких шаблонов: EF 6.x DbContext Generator; он устанавливается вместе с Visual Studio 2013. Сначала я удалю два TT-файла, связанные с файлом TripMaintenance.EDMX, а затем с помощью инструмента Add Code Generation Item в дизайнере выберу новый шаблон. (Если вы не знакомы с использованием шаблонов генерации кода, см. в MSDN документ «EF Designer Code Generation Templates» по ссылке bit.ly/1i7zU3Y).
Вспомните, что я модифицировала исходный шаблон. Критически важное изменение, которое мне понадобится продублировать в новом шаблоне, — удаление кода, который вводит ключевое слово virtual в навигационные свойства. Это гарантирует, что отложенная загрузка (lazy loading) не будет инициироваться, так как мой код зависит от этого поведения. Если вы используете шаблон T4 и что-то настроили в нем для своей исходной модели, это очень важный этап, о котором не следует забывать. (Вы можете посмотреть старый видеоролик, созданный мной для MSDN, где демонстрируется редактирование шаблона T4 для EDMX, по ссылке bit.ly/1jKg4jB.)
Наконец, нужно позаботиться о частичных классах, создаваемых мной для некоторых сущностей, и о ObjectContext. Я скопировала релевантные классы в проект новой модели и убедилась, что они связаны с только что сгенерированными классами.
Исправление методов AddObject, AttachObject и DeleteObject Пора оценить нанесенный урон. Хотя большая часть разрушенного кода относится к тому, что я кодировала в расчете на ObjectContext API, есть набор легко модифицируемых методов, общих для большинства приложений, поэтому ими я занялась в первую очередь.
ObjectContext API предлагает несколько способов для добавления, подключения и удаления объектов из ObjectSet. Например, чтобы добавить элемент, такой как экземпляр trip с именем newTrip, вы могли бы использовать ObjectContext напрямую:
context.AddObject("Trips",newTrip)
или сделать это из ObjectSet:
_context.Trips.AddObject(newTrip)
Метод ObjectContext слегка громоздкий; ему требуется строка, чтобы идентифицировать, к какому набору принадлежит сущность. Метод ObjectSet попроще, поскольку набор уже определен, но все равно использует конструкции: AddObject, AttachObject и DeleteObject. В случае DbContext способ только один: через DbSet. Имена методов были упрощены до Add, Attach и Remove, чтобы эмулировать методы наборов, например:
_context.Trips.Add(newTrip);
Заметьте, что DeleteObject стал Remove. Это может сбить с толку: хотя Remove больше соответствует наборам, это имя не отражает предназначение метода, который в конечном счете удаляет запись из базы данных. Я уже видела, как разработчики ошибочно полагали, будто Collection.Remove дает тот же результат, что и DbSet.Remove. Но это не так. Поэтому будьте внимательны.
Остальные проблемы специфичны для того способа, которым я использовала ObjectContext в своем исходном приложении. Они не обязательно встретятся вам, но их описание и устранение поможет лучше подготовиться к переходу на DbContext в ваших решениях.
Исправление кода в пользовательских частичных классах контекста Эти исправления я начала с создания проекта, который содержит мою новую модель. Приведя этот проект в порядок, можно браться за проекты, которые зависят от него. Частичный класс, изначально созданный мной для расширения ObjectContext, был первым.
Один из пользовательских методов в этом частичном классе — ManagedEntities, который помогал мне понять, какие сущности отслеживаются контекстом. ManagedEntities опирался на созданный мной метод расширения: на непараметризованную перегруженную версию GetObjectStateEntries. Использование этой перегруженной версии стало причиной ошибки при компиляции:
public IEnumerable<T> ManagedEntities<T>() {
var oses = ObjectStateManager.GetObjectStateEntries();
return oses.Where(entry => entry.Entity is T)
.Select(entry => (T)entry.Entity);
}
Вместо исправления нижележащего метода расширения GetObjectStateEntries я могу просто исключить его и метод ManagedEntities, так как в DbContext API есть метод Local в классе DbSet, который их заменяет.
В рефакторинге этого кода можно использовать один из двух подходов. Один из них — найти весь код, использующий новую модель и вызывающий ManagedEntities, и заменить его методом DbSet.Local. Вот пример кода, который использует ManagedEntities для прохода по всем Trip, отслеживаемым контекстом:
foreach (var trip in _context.ManagedEntities<Trip>())
Я могла бы заменить его на:
foreach (var trip in _context.Trips.Local.ToList())
Заметьте, что Local возвращает ObservableCollection, поэтому я добавляю ToList, чтобы извлекать из него сущности.
В качестве альтернативы, если в коде много вызовов ManagedEntities, можно было бы просто изменить логику ManagedEntities и избежать редактирования всех мест, где он вызывается. Поскольку этот метод обобщенный, он не столь прямолинеен, как Trips DbSet, но изменения все равно достаточно просты:
public IEnumerable<T> ManagedEntities<T>() where T : class {
return Set<T>().Local.ToList();
}
Самое важное, что моя логика Trip Management больше не зависит от перегруженного метода расширения GetObjectStateEntries. Я могу оставить этот метод нетронутым, чтобы мой исходный ObjectContext по-прежнему использовал его.
В долгосрочной перспективе многие из фокусов с методами расширения, которые мне пришлось придумывать в свое время, станут ненужными при использовании DbContext API. Эти методы были настолько распространены, что в DbContext API включены простые методы вроде Local, позволяющие делать то же самое.
Следующий разрушенный метод, обнаруженный мной в частичном классе, был тот, который я использовала для задания состояния отслеживания сущности в Modified. И вновь в те времена я была вынуждена прибегнуть к написанию далеко не очевидного EF-кода для выполнения этой задачи. Строка кода, которая теперь дает ошибку:
ObjectStateManager.ChangeObjectState(entity, EntityState.Modified);
Я могу заменить ее более простым свойством DbContext.Entry().State. Поскольку этот код находится в частичном классе, который расширяет мой DbContext, я могу обращаться к методу Entry напрямую, а не из экземпляра TripMaintenanceContext. Новая строка кода выглядит так:
Entry(entity).State = EntityState.Modified;
Последний метод в моем частичном классе, тоже разрушенный, вызывает метод ObjectSet.ApplyCurrentValues, чтобы фиксировать состояние отслеживаемой сущности, используя значения другого (не отслеживаемого) экземпляра того же типа. ApplyCurrentValues принимает значение идентификации экземпляра, находит соответствующую сущность в средстве отслеживания изменений (change tracker), а затем обновляет ее с помощью значений, переданных в объекте.
Для ApplyCurrentValues в DbContext нет эквивалента. DbContext позволяет выполнять похожую замену значений через Entry().CurrentValues().Set, но это требует, чтобы у вас уже был доступ к отслеживаемой сущности. Простого способа создать обобщенный метод для поиска такой отслеживаемой сущности, который заменил бы ту функциональность, нет. Однако не все потеряно. Вы можете просто продолжить использование специального метода ApplyCurrentValues благодаря возможности доступа к логике ObjectContext из DbContext. Помните, что DbContext — это оболочка ObjectContext и что этот API предоставляет способ обращаться к нижележащему ObjectContext в особых случаях, например к IObjectContextAdapter. Я добавила простое свойство Core в свой частичный класс, чтобы упростить его повторное использование:
public ObjectContext Core {
get {
return (this as IObjectContextAdapter).ObjectContext;
}}
Затем я модифицировала релевантный метод в частичном классе, чтобы по-прежнему использовать ApplyCurrentValues, вызывая CreateObjectSet из свойства Core; это дает мне ObjectSet:
public void UpdateEntity<T>(T modifiedEntity) where T : class {
var set = Core.CreateObjectSet<T>();
set.ApplyCurrentValues(modifiedEntity);
}
После этого последнего изменения проект с моей моделью удалось скомпилировать. Теперь пора заняться разрушенным кодом на уровнях между UI и моделью.
Замена в запросах MergeOption.NoTracking на AsNoTracking и поддержка лямбд Возможность сообщить EF о том, что она не должна отслеживать результаты, крайне важна для того, чтобы избежать лишней обработки и повысить производительность. В моем приложении есть несколько мест, где я запрашиваю данные, которые будут использоваться только как справочные. Рассмотрим пример, где я получаю список Trip только для чтения вместе с их информацией Destination и отображаю в ListBox в UI. В случае старого API вам пришлось бы установить MergeOption в запросе перед его выполнением. Вот корявый код, который я была вынуждена писать:
var query = _context.Trips.Include("Destination");
query.MergeOption = MergeOption.NoTracking;
_trips = query.OrderBy(t=>t.Destination.Name).ToList();
В DbSet есть более простой способ сделать это, используя его метод AsNoTracking. В этом случае я могу избавиться от строки в методе Include, так как в DbContext наконец-то добавили возможность применить лямбда-выражение. Вот переработанной код:
_trips= _context.Trips.AsNoTracking().Include(t=>t.Destination)
.OrderBy(t => t.Destination.Name)
.ToList();
DbSet.Local снова спешит на помощь Ряд мест в моем коде требовали определять, отслеживается ли та или иная сущность, когда я располагала не более чем значением его идентификации. Я написала вспомогательный метод для этого (рис. 4), и вы видите, почему я хотела инкапсулировать этот код. Не забивайте себе голову его расшифровкой — он отправляется в мусорную корзину.
Рис. 4. Мой вспомогательный метод IsTracked стал лишним благодаря новому методу DbSet.Local
public static bool IsTracked<TEntity>(this ObjectContext context,
Expression<Func<TEntity, object>> keyProperty, int keyId)
where TEntity : class
{
var keyPropertyName =
((keyProperty.Body as UnaryExpression)
.Operand as MemberExpression).Member.Name;
var set = context.CreateObjectSet<TEntity>();
var entitySetName = set.EntitySet.EntityContainer.Name +
"." + set.EntitySet.Name;
var key = new EntityKey(entitySetName, keyPropertyName, keyId);
ObjectStateEntry ose;
if (context.ObjectStateManager.TryGetObjectStateEntry(key, out ose))
{
return true;
}
return false;
}
Вот пример кода в моем приложении, который вызывал IsTracked с передачей значения сущности Trip, которую я хотела найти:
_context.IsTracked<Trip>(t => t.TripID, tripId)
Благодаря тому же методу DbSet.Local я смогла заменить это на:
_context.Trips.Local.Any(d => d.TripID == tripId))
А после этого у меня появилась возможность удалить метод IsTracked! Вы заметили, от какого количества кода я уже избавилась к этому моменту?
Другой метод, который я писала для этого приложения, — AddActivity, который делал нечто большее простой проверки отслеживания сущности. Он был нужен для получения этой сущности, и это еще одна задача, в решении которой помог Local. Метод AddActivity (рис. 5) добавляет Activity в конкретный Trip, используя уродливый и неочевидный код, который я писала в расчете на ObjectContext API. Здесь учитывается и отношение «многие ко многим» между Trip и Activity. Подключение экземпляра Activity к отслеживаемому Trip заставляет EF начать отслеживание и Activity, поэтому мне требовалось защитить контекст от дубликата, а свое приложение — от исключения. В своем методе я попыталась получить ObjectStateEntry сущности. TryGetObjectStateEntry выполняет сразу два трюка. Во-первых, он возвращает булево значение, если запись (entry) найдена, и, во-вторых, возвращает либо найденную запись, либо null. Если запись не была null, я использовала эту сущность для подключения к Trip, а иначе я подключала ту, которая была передана в мой метод AddActivity. Простое описание и то звучит ужасно.
Рис. 5. Исходный метод AddActivity, использующий ObjectContext API
public void AddActivity(Activity activity)
{
if (_context.GetEntityState(activity) == EntityState.Detached)
{
ObjectStateEntry existingOse;
if (_context.ObjectStateManager
.TryGetObjectStateEntry(activity, out existingOse))
{
activity = existingOse.Entity as Activity;
}
else {
_context.Activities.Attach(activity);
}
}
_currentTrip.Activities.Add(activity);
}
Я долго и упорно думала об эффективном способе выполнения этой логики, но в итоге получила код примерно той же длины. Помните, что здесь есть отношение «многие ко многим», а такое отношение требует к себе больше внимания. Однако этот код легче писать и читать. Вам вообще не придется возиться с ObjectStateManager. Вот часть метода, которую я обновила для использования того же шаблона, что и ранее в случае Destination:
var trackedActivity=_context.Activities.Local
.FirstOrDefault(a => a.ActivityID == activity.ActivityID);
if (trackedActivity != null) {
activity = trackedActivity;
}
else {
_context.Activities.Attach(activity);
}
Задание выполнено
Внеся это последнее исправление, я смогла выполнить все тесты и успешно задействовать все возможности формы Trip Maintenance. Теперь самое время подумать о том, как получить выигрыш от новых средств EF6.
Еще важнее, что дальнейшее сопровождение этой части моего приложения будет проще, так как многие задачи, трудно решавшиеся в случае ObjectContext, теперь при переходе на DbContext API решаются гораздо проще. Сосредоточившись на этой малой модели, я многому научилась, что пригодится мне в любых других случаях перехода с ObjectContext на DbContext.
Не менее важно с умом выбрать код, который следует обновить, и код, который следует оставить в покое. Обычно я занимаюсь теми средствами, которые, как мне известно, придется поддерживать в будущем, и стараюсь не заботиться о взаимодействии с более сложным API. Если у вас есть код, который вы не собираетесь когда-либо модифицировать, и он сможет работать по мере развития EF, то на вашем месте я дважды подумала бы о том, стоит ли его вообще трогать. Даже если это повысит производительность, помните, что разрушенный код может выйти вам боком.