Розширення Entity Framework 6, про які ви могли і не знати


Багато програмісти роблять записи, описують труднощі, красиві і не дуже рішення, з якими доводиться стикатися з обов'язку служби. Це може бути власний технічний блог, робоча вікі, або навіть звичайний блокнот — суть одна. Поступово, з маленьких Evernote-нотаток може вирости ціла стаття на Хабр. Але час йде, зміна місця роботи обіцяє зміни в стеку розробки, так і технології не стоять на місці (до речі, EF Core вже пару місяців як версії 1.1). З іншого боку, Entity Framework 6 був і залишається "робочою конячкою" для доступу до даних в корпоративних додатках на стеку .net, не в останню чергу завдяки своїй стабільності, низького порогу входу і широкої популярності. Тому, я сподіваюся, стаття все ще виявиться комусь корисною.
Зміст:
  1. Database First без EDMX
  2. Робота з отсоединенными графами
  3. Модифікація SQL. Додавання табличних вказівок
  4. Кешування даних за межами часу життя DbContext
  5. Retry при помилках від SQL Server
  6. Підміняємо DbContext, ізолюючи від реальної БД
  7. Швидка вставка

Database First без EDMX
Не хотілося б починати черговий раунд спору "Code First vs. Database First". Краще просто розповім, як полегшити собі життя, якщо ви віддаєте перевагу Database First. Багато розробники, що використовують цей підхід, відзначають незручність роботи з громіздким EDMX-файлом. Цей файл може перетворити в пекло командну розробку, сильно ускладнюючи злиття паралельних змін внаслідок постійного "перемішування" своєї внутрішньої структури. Серед інших недоліків, для моделей з кількома сотнями сутностей (звичайний такий legacy-моноліт), ви можете зіткнутися з сильним падінням швидкості будь-якої дії, працюючи зі стандартним EDMX-дизайнером.
Рішення здається очевидним — необхідно відмовитися від EDMX на користь альтернативних засобів генерації POCO і зберігання метаданих. Завдання нескладна, і в EF є "з коробки" — це пункт "Generate Code From First Database", доступний в Visual Studio (VS2015 точно). Але, на практиці — дуже незручно налаштовувати і оновлювати отриману модель, використовуючи цей інструмент. Далі, хто працює з EF досить давно — може пам'ятати розширення Entity Framework Power Tools, вирішальне схожі завдання — але, на жаль, проект більше не розвивається (на VS2015 без хака не поставити), а частина розробників цього інструменту нині працює безпосередньо в команді EF.
Здавалося б, все погано — і тут ми знайшли EntityFramework Reverse POCO Generator. Це Т4-шаблон для генерації POCO на основі існуючої БД з великою кількістю налаштувань і відкритим кодом. Підтримуються всі основні фічі EDMX, і є ряд додаткових смакоти: генерація FakeDbContext/FakeDbSet для юніт-тестування, покриття моделей атрибутами (напр. DataContract/DataMember) та ін. Не кажучи вже про повний контроль за генерацією коду, яку дає Т4 (який є і в EDMX, звичайно ж). Резюмуючи: працює стабільно, команді подобається, легко мігрувати існуючі проекти.
Робота з отсоединенными графами
Прикріпити до DbContext новий, або раніше отриманий в іншому контексті одиничний об'єкт зазвичай не складає труднощів. Проблеми починаються у разі, власне, від'єднаних графів — EF "з коробки" не відстежує зміни в navigation properties знову приєднуваної до контексту сутності. Для відстеження змін, для кожної сутності під час життя контексту повинен існувати відповідний entry — об'єкт зі службовою інформацією, в тому числі станом сутності (Added, Modified, Deleted тощо). Заповнити entry для приєднання графа — можливо як мінімум 2 шляхами:
  1. Зберігати стан в самих сутності, самостійно відстежуючи зміни. Таким чином, наш від'єднаний граф буде містити в собі всю необхідну інформацію.
  2. Нічого не робити заздалегідь, а при приєднанні від'єднаного графа — підтягнути з БД вихідний граф і проставити стану entry на підставі порівняння двох графів.
Приклад вирішення #1 можна знайти, наприклад, в свіжому Pluralsight-курсі від Julie Lerman, відомого фахівця з EF. Для його самостійної generic-реалізації необхідно велика кількість рухів. Всі сутності повинні реалізувати інтерфейс IStateObject:
public interface IStateObject
{
ObjectState State { get; set; }
}

Тим або іншим способом, необхідно забезпечити актуальність значень State, щоб після приєднання всіх сутностей в графі до контексту:
context.Foos.Attach(foo);

пройти по всіх entry, редагуючи стан:
IStateObject entity = entry.Entity;
entry.State = ConvertState(entity.State);

В цьому випадку нам не потрібні додаткові звернення до БД, але рішення виходить об'ємне, крихке, і потенційно неробочий для відносин "багато-до-багатьом". Ще й моделі засмітили (до речі, вимога реалізації інтерфейсу можна вирішити шляхом модифікації шаблонів генерації POCO з попереднього розділу статті).
Розглянемо рішення #2. Буду краток:
context.UpdateGraph(root, map => map.OwnedCollection(r => r.Childs));

цей виклик — додасть в контекст сутність root, відновивши при цьому navigation property з колекцією дочірніх об'єктів Childs, ціною SELECT-а до БД лише одного. Що стало можливим завдяки бібліотеці GraphDiff, автор якої зробив всю чорну роботу і виловив основні баги.
Модифікація SQL. Додавання табличних вказівок
Генерація, здавалося б, простий SELECT… FROM Table WITH (UPDLOCK) не підтримується EF. Зате є interceptor'и, що дозволяють модифікувати генерується SQL будь-яким підходящим способом, наприклад, за допомогою регулярних виразів. Наприклад, додамо UPDLOCK на кожен генерується SELECT в межах часу життя контексту (природно, гранулярність — не обов'язково контекст, все залежить від вашої реалізації):
using (var ctx = new MyDbContext().With(SqlLockMode.UpdLock)) {}

Для цього, оголосимо метод With всередині контексту і зареєструємо interceptor:
public interface ILockContext
{
SqlLockMode LockMode { get; set; }

MyDbContext With(SqlLockMode lockMode);
}

public class MyDbConfig : DbConfiguration
{
public MyDbConfig()
{
AddInterceptor(new LockInterceptor());
}
}

[DbConfigurationType(typeof(MyDbConfig))]
public partial class MyDbContext : ILockContext
{
public SqlLockMode LockMode { get; set; }

public MyDbContext With(SqlLockMode lockMode)
{
LockMode = lockMode;
return this;
}

private static void MyDbContextStaticPartial() { }
}

LockInterceptor
public class LockInterceptor : DbCommandInterceptor
{
public override void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
{
AddLockStatement(command, interceptionContext);
}

public override void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
{
AddLockStatement(command, interceptionContext);
}

private void AddLockStatement<T>(DbCommand command, DbCommandInterceptionContext<T> interceptionContext)
{
var lockMode = GetLock(interceptionContext);

switch (lockMode)
{
case SqlLockMode.UpdLock: command.CommandText = SqlModifier.AddUpdLock(command.CommandText);
break;
}
}

private SqlLockMode GetLock<T>(DbCommandInterceptionContext<T> interceptionContext)
{
if (interceptionContext == null) return SqlLockMode.None;

ILockContext lockContext = interceptionContext.DbContexts.First() as ILockContext;

if (lockContext == null) return SqlLockMode.None;

return lockContext.LockMode;
}
}

Регулярний вираз може бути таким:
public static class SqlModifier
{
private static readonly Regex _regex = new Regex(@"(?<tableAlias>SELECT\s.*FROM\s.*AS \[Extent\d+])", 
RegexOptions.Multiline | RegexOptions.IgnoreCase);

public static string AddUpdLock(string text)
{
return _regex.Replace(текст, "${tableAlias} WITH (UPDLOCK)");
}
}

Тестуємо
public class SqlModifier_Tests
{
[TestCase("SELECT [Extent1].[Name] AS [Name] FROM [dbo].[Customer] AS [Extent1]")]
[TestCase("SELECT * FROM [dbo].[Customer] AS [Extent999]")]
public void AddUpdLock_ValidEfSelectStatement_addlockaftertablealias(string text)
{
expected string = text + " WITH (UPDLOCK)";

string actual = SqlModifier.AddUpdLock(text);

Assert.AreEqual(expected, actual);
}

[TestCase("SELECT [Extent1].[Extent1] AS [Extent1]")]
[TestCase("SELECT * FROM Order")]
[TestCase(" AS [Extent111]")]
public void AddUpdLock_InvalidEfSelectStatement_nochange(string text)
{
string actual = SqlModifier.AddUpdLock(text);

Assert.AreEqual(text, actual);
}
}

Кешування даних за межами часу життя DbContext
EF кешує такі речі, як:
  • Query Plan.
  • Metadata.
  • Compiled Queries.
Кешування даних є лише в межах контексту життя (згадаймо метод Find), так і повноцінним кешем це язик назвати не повернеться. Як нам організувати єдиний для всіх контекстів, керований кеш пам'яті процесу? Використовуємо EntityFramework.Plus, або її "бідну" альтернативу EntityFramework.Cache:
public void SelectWithCache()
{
using (var ctx = new MyDbContext())
{
ctx.Customers.FromCache().ToList();
}
}

[Test]
public void SelectWithCache_Test()
{
TimeSpan expiration = TimeSpan.FromSeconds(5);
var options = new CacheItemPolicy() { SlidingExpiration = expiration };
QueryCacheManager.DefaultCacheItemPolicy = options;

SelectWithCache(); //запит до БД
SelectWithCache(); //з кешу
Thread.Sleep(expiration);
SelectWithCache(); //запит до БД
}

Досить запустити SQL-профайлер, щоб переконатися — 2-й виклик SelectWithCache() не зачіпає БД. Lazy-звернення також будуть кешованими.
Більш того, можлива інтеграція EF і з розподіленим кешем. Наприклад, через самописний cache manager на базі Sytem.Runtime.Caching.ObjectCache, підключений до EntityFramework.Plus. У той же час, NCache підтримує інтеграцію з EF "з коробки" (тут конкретикою не можу поділитися — не пробував).
Retry при помилках від SQL Server
public class SchoolConfiguration : DbConfiguration
{
public SchoolConfiguration()
{
SetExecutionStrategy("System.Data.SqlClient", () => 
new SqlAzureExecutionStrategy(maxRetryCount: 3, maxDelay: TimeSpan.FromSeconds(10)));
}
}

SqlAzureExecutionStrategy — дана стратегія міститься в EF6 (відключена за замовчуванням). При її використанні — отримання певного коду помилки у відповіді сервера призведе до повторної відправки SQL-інструкції на сервер.
Коди помилок для SqlAzureExecutionStrategy
// SQL Error Code: 40197
// The service has encountered an error processing your request. Please try again.
case 40197:
// SQL Error Code: 40501
// The service is currently busy. Retry the request after 10 seconds.
case 40501:
// SQL Error Code: 10053
// A transport-level error has occurred when receiving results from the server.
// An established connection was aborted by the software in your host machine.
case 10053:
// SQL Error Code: 10054
// A transport-level error has occurred when sending the request to the server.
// (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.)
case 10054:
// SQL Error Code: 10060
// A network-related or instance-specific error occurred while establishing a connection to SQL Server.
// The server was not found or was not accessible. Verify that the instance name is correct and that SQL Server
// is configured to allow remote connections. (provider: TCP Provider, error: 0 - A connection attempt failed
// because the connected party did not properly respond after a period of time or established connection failed
// because connected host has failed to respond.)"}
case 10060:
// SQL Error Code: 40613
// Database XXXX on server YYYY is not currently available. Please retry the connection later. If the problem persists, customer contact
// support, and provide them the session tracing ID of ZZZZZ.
case 40613:
// SQL Error Code: 40143
// The service has encountered an error processing your request. Please try again.
case 40143:
// SQL Error Code: 233
// The system was unable to establish a connection because of an error during connection initialization process before login.
// Possible causes include the following: the client tried to connect to an unsupported version of SQL Server; the server was too busy
// to accept new connections; or there was a resource limitation (insufficient memory or maximum allowed connections) on the server.
// (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.)
case 233:
// SQL Error Code: 64
// A connection was successfully established with the server, but then an error occurred during the login process.
// (provider: TCP Provider, error: 0 - The specified network name is no longer available.)
case 64:
// DBNETLIB Error Code: 20
// The instance of SQL Server you attempted to connect to does not support encryption.
case (int)ProcessNetLibErrorCode.EncryptionNotSupported:
return true;

Цікавинки:
  • На базі вихідного коду SqlAzureExecutionStrategy можливо написати свою стратегію, якщо перевизначити коди помилок, що призводять до retry.
  • Використання retry-стратегій, включаючи SqlAzureExecutionStrategy — накладає ряд обмежень, самим серйозним з яких є несумісність з користувацькими транзакціями. Для явного оголошення транзакції — стратегію відключаємо через звернення до System.Runtime.Remoting.Messaging.CallContext:
  • Стратегію можна навіть покрити інтеграційними тестами (знову спасибі Julie Lerman, докладно освітила це питання).
Підміняємо DbContext, ізолюючи від реальної БД
В цілях тестування, подменим DbContext прозоро для вхідного коду, і заповнити підроблений DbSet тестовими даними. Наведу кілька способів вирішення завдання.
Спосіб #1 (довгий): вручну створити заглушки для IMyDbContext і DbSet, явно прописати необхідну поведінку. Ось як це може виглядати з використанням бібліотеки Moq:
MockDbSet
public IMyDbContext Create()
{
var mockRepository = new MockRepository(MockBehavior.Default);

var mockContext = mockRepository.Create<IMyDbContext>();

mockContext.Setup(x => x.SaveChanges()).Returns(int.MaxValue);

var mockDbSet = MockDbSet<Customer>(customers);

mockContext.Setup(m => m.Customers).Returns(mockDbSet.Object);

return mockContext.Object;
}

private Mock<DbSet<T>> MockDbSet<T>(List<T> data = null)
where T : class
{
if (data == null) data = new List<T>();

var queryable = data.AsQueryable();

var mock = new Mock<DbSet<T>>();

mock.As<IQueryable < T>>().Setup(m => m.Provider)
.Returns(queryable.Provider);
mock.As<IQueryable < T>>().Setup(m => m.Expression)
.Returns(queryable.Expression);
mock.As<IQueryable < T>>().Setup(m => m.ElementType)
.Returns(queryable.ElementType);
mock.As<IQueryable < T>>().Setup(m => m.GetEnumerator())
.Returns(queryable.GetEnumerator());

return mock;
}

З цієї теми є базова стаття з MSDN: Entity Framework Testing with a Mocking Framework (EF6 onwards). А мене цей підхід колись настільки вразив, що вийшов демо-проект на гітхабі (з використанням EF6 DbFirst, SQL Server, Moq, Ninject). До речі, у вже згадуваному курсі Entity Framework in the Enterprise тестування присвячена ціла глава.
Спосіб #2 (короткий): використати вже згадуваний Reverse POCO Generator, який за замовчуванням створює заглушки для ваших DbContext і всіх DbSet (всередині FakeDbSet буде звичайна in-memory колекція).

Швидка вставка
Для одночасної вставки в БД SQL Server тисяч нових записів — вкрай ефективно використовувати BULK-операції замість стандартного порядкового INSERT. Проблематику я висвітлював детальніше в окремій статті, тому наведу нижче тільки готові до використання рішення на основі SqlBulkCopy:
EntityFramework.Utilities
Entity Framework Extensions
На цьому у мене все. Діліться своїми рецептами і хитрощами в коментарях =)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.