Pull to refresh

Расширения Entity Framework 6, о которых вы могли и не знать

Reading time 10 min
Views 26K

Многие программисты делают записи, описывают трудности, красивые и не очень решения, с которыми приходится сталкиваться по долгу службы. Это может быть собственный технический блог, рабочая вики, или даже обычный блокнот — суть одна. Постепенно, из маленьких 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 First From Database", доступный в Visual Studio (VS2015 точно). Но, на практике — очень неудобно накатывать изменения БД на полученную модель через этот инструмент. Далее, кто работает с EF достаточно давно — может помнить расширение Entity Framework Power Tools, решающее схожие задачи — но, увы, проект более не развивается (на VS2015 без хака не поставить), а часть разработчиков этого инструмента ныне работает непосредственно в команде EF.


Казалось бы, все плохо — и тут мы нашли EntityFramework Reverse POCO Generator. Это Т4-шаблон для генерации POCO на основе существующей БД с большим количеством настроек и открытым исходным кодом. Поддерживаются все основные фичи EDMX, и есть ряд дополнительных вкусностей: генерация FakeDbContext/FakeDbSet для юнит-тестирования, покрытие моделей атрибутами (напр. DataContract/DataMember) и др. Благодаря Т4, есть полный контроль за генерацией кода. Резюмируя: работает стабильно, команде нравится, легко мигрировать существующие проекты.


Работа с отсоединенными графами


Прикрепить к DbContext новый, либо ранее полученный в другом контексте единичный объект обычно не составляет труда. Проблемы начинаются в случае графов, т.е. сущностей со связями — EF "из коробки" не отслеживает изменения в содержимом navigation properties вновь присоединяемой к контексту сущности. Для отслеживания изменений, для каждого объекта-сущности во время жизни контекста должен существовать соответствующий entry — объект со служебной информацией, в том числе состоянием сущности (Added, Modified, Deleted и т.п.). Заполнить entries для присоединения графа — возможно как минимум 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);

В этом случае нам не требуются дополнительные обращения к БД, но решение получается объемное, хрупкое, и потенциально нерабочее для отношений "многие-ко-многим". Еще и модели замусорили (к слову, требование реализации интерфейса можно решить путем модификации Т4-шаблонов из предыдущего раздела статьи).


Рассмотрим решение #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(text, "${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)
    {
        string expected = 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 Server приводит к повторной отправке 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, contact customer
// 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 client 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 тестовыми данными. Приведу несколько способов решения задачи.
Cпособ #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


На этом у меня всё. Делитесь своими рецептами и хитростями в комментариях =)

Tags:
Hubs:
+21
Comments 4
Comments Comments 4

Articles