Май 2016

Том 31 номер 5

Доступ к данным - Гибридные приложения Dapper и Entity Framework

Джули Лерман | Май 2016

Исходный код можно скачать по ссылке

Julie LermanВероятно, вы заметили, что я много пишу об Entity Framework — Microsoft Object Relational Mapper (ORM), который является основным API доступа к данным в .NET с 2008 года. Существуют другие .NET ORM, но конкретной их категории, микро-ORM, уделяют большое внимание из-за высокой производительности. Среди них чаще всего упоминают Dapper. Мой интерес возбудили сообщения от разных разработчиков о том, что они создали гибридные решения с помощью EF и Dapper, позволяя каждому ORM в рамках одного приложения делать то, в чем он наиболее силен.

После прочтения множества статей и публикаций в блогах, общения с разработчиками и некоторых экспериментов с Dapper я захотела поделиться своими открытиями с вами, особенно с теми, кто, как и я, возможно, слышал о Dapper, но на деле не знает, что это такое, как он работает и почему он так нравится многим людям. Помните, что я ни в коей мере не являюсь экспертом в Dapper. Скажем так: я знаю достаточно, чтобы удовлетворить свое любопытство на данный момент, и надеюсь вызвать у вас интерес к Dapper, чтобы вы потом самостоятельно углубились в изучение этого микро-ORM.

Почему Dapper?

Dapper имеет интересную историю, появившись на свет на одном ресурсе, который, по-видимому, очень хорошо известен вам: Марк Грэвелл (Marc Gravell) и Сэм Саффрон (Sam Saffron) в период своей работы в Stack Overflow создали Dapper, решая проблемы с производительностью на этой платформе. Stack Overflow — это сайт с очень интенсивным трафиком, который неизбежно вызывает озабоченности по поводу производительности. Согласно странице About на Stack Exchange, в 2015 году на Stack Overflow было отмечено 5,7 миллиардов просмотров страниц. В 2011 году Саффрон написал статью в блоге о проделанной им и Грэвеллом работе — «How I Learned to Stop Worrying and Write My Own ORM» (aka.ms/Vqpql6), в которой разъяснялись проблемы с производительностью, имевшиеся у Stack в то время; они были связаны с использованием LINQ to SQL. Затем он подробно обосновал то, почему написание собственного ORM, Dapper, было решением для оптимизации доступа к данным на Stack Overflow. Прошло пять лет, и вот Dapper широко используется как проект с открытым исходным кодом. Грэвелл и член команды Ник Крейвер по-прежнему активно руководят проектом на github.com/StackExchange/dapper-dot-net.

Dapper в общих чертах

Dapper позволяет задействовать ваши навыки в SQL для конструирования запросов и команд в том виде, в каком они должны быть по вашему мнению. Он ближе к «железу», чем стандартный ORM, облегчая усилия в интерпретации таких запросов, как LINQ to EF, в SQL. В Dapper действительно есть некоторые впечатляющие средства преобразования, например возможность разбивать список, передаваемый блоку WHERE IN. Но по большей части SQL, передаваемый вами в Dapper, готов к работе, и запросы попадают в базу данных гораздо быстрее. Если вы хорошо знаете SQL, то, безусловно, напишете настолько производительные команды, насколько это возможно. Для выполнения запросов вы должны создать какой-то тип IDbConnection, например SqlConnection с известной строкой подключения. Затем Dapper с помощью своего API может выполнять запросы за вас и (при условии, что схему результатов запроса можно соотнести со свойствами целевого типа) автоматически создавать и заполнять объекты результатами запроса. Здесь вы получаете еще один существенный выигрыш в производительности: Dapper эффективно кеширует сопоставление, которое стало ему известно, что обеспечивает очень быструю десериализацию последующих запросов. Класс, который я буду заполнять, DapperDesigner (рис. 1), определен для управления дизайнерами, которые шьют очень элегантную одежду.

Рис. 1. Класс DapperDesigner

public class DapperDesigner
{
  public DapperDesigner() {
    Products = new List<Product>();
    Clients = new List<Client>();
  }
  public int Id { get; set; }
  public string LabelName { get; set; }
  public string Founder { get; set; }
  public Dapperness Dapperness { get; set; }
  public List<Client> Clients { get; set; }
  public List<Product> Products { get; set; }

  public ContactInfo ContactInfo { get; set; }
}

Проект, где я выполняю запросы, имеет ссылку на Dapper, который я получила через NuGet (командой install-package dapper). Вот пример вызова из Dapper для выполнения запроса на получение всех строк из таблицы DapperDesigners:

var designers = sqlConn.Query<DapperDesigner>(
  "select * from DapperDesigners");

Заметьте, что в листингах кода в этой статье я использую select * вместо явного проецирования полей для запросов, когда мне нужны все столбцы из таблицы. Переменная sqlConn — это существующий объект SqlConnection, экземпляр которого я уже создала наряду с его строкой подключения, но пока не открыла.

Query — метод расширения, предоставляемый Dapper. Когда выполняется эта строка, Dapper открывает соединение, создает DbCommand, выполняет запрос именно так, как я написала его, создает экземпляр объекта DapperDesigner для каждой строки в результатах и помещает значения из результатов запроса в свойства этих объектов. Dapper может соотносить значения результатов со свойствами посредством нескольких шаблонов, даже если имена свойств не совпадают с именами полей и даже если свойства не находятся в том же порядке, что и совпадающие поля. Но читать мысли он не умеет, поэтому не ждите от него понимания задействованных сопоставлений, например многочисленных строковых значений, где порядок или имена полей и свойств не согласованы. Я пыталась проделать несколько замысловатых экспериментов, чтобы понять, как он будет реагировать на такие вещи; кроме того, имеются глобальные параметры, управляющие тем, как Dapper может логически распознавать сопоставления.

Dapper и реляционные запросы

Мои тип DapperDesigner имеет ряд отношений: «один ко многим» (с Products), «один к одному» (ContactInfo) и «многие ко многим» (Clients). Я экспериментировала с выполнением запросов через эти отношения, и Dapper оказался способен обрабатывать их. Это определенно не столь легко, как выражать запрос LINQ to EF с помощью метода Include или даже проекции. Однако мои навыки в TSQL были исчерпаны до предела, потому что за прошедшие годы EF позволила мне так облениться.

Вот пример выдачи запроса через отношение «один ко многим» на SQL, который я использовала бы прямо в базе данных:

var sql = @"select * from DapperDesigners D
           JOIN Products P
           ON P.DapperDesignerId = D.Id";

var designers = conn.Query<DapperDesigner,
  Product, DapperDesigner>
(sql,(designer, product) => { designer.Products.Add(product);
                              return designer; });

Заметьте, что метод Query требует указать оба типа, которые должны быть сконструированы, а также задать возвращаемый тип, выражаемый заключительным параметром-типом (DapperDesigner). Я использую многострочную лямбду, чтобы сначала сконструировать графы, добавить релевантные товары (products) в их родительские объекты дизайнеров, а затем вернуть каждый дизайнер в IEnumerable, возвращаемый методом Query.

Недостаток такого варианта, потребовавшего от меня максимальных усилий в SQL, заключается в том, что результаты становятся плоскими, как при использовании EF-метода Include. Я получу одну строку на каждый продукт с продублированными дизайнерами. В Dapper есть метод MultiQuery, способный возвращать несколько наборов результатов. В сочетании с GridReader из Dapper производительность этих запросов безусловно превосходит таковую у EF-методов Include.

Труднее в кодировании, быстрее в выполнении

Выражение SQL-кода и заполнение релевантных объектов — задачи, которые я позволила EF обрабатывать в фоне, так что это определенно требует больше усилий в кодировании. Но если вы работаете с большими объемами данных и вам важна производительность исполняющей среды, это, разумеется, стоит таких усилий. В моем примере базы данных около 30 000 дизайнеров. Лишь у нескольких из них есть товары. Я проделала некоторые простые эталонные тесты (benchmark tests), где убедилась, что сравниваю одинаковые вещи. Прежде чем рассматривать результаты тестов, обсудим несколько важных моментов, относящихся к тому, как я выполняла эти замеры.

Помните, что по умолчанию EF отслеживает объекты, являющиеся результатами запросов. Это означает, что она создает дополнительные отслеживающие объекты (tracking objects), вызывающих некоторые издержки, и что ей также требуется взаимодействовать с этими отслеживающими объектами. Dapper, напротив, просто помещает результаты в память. Поэтому важно вывести из игры отслеживание изменений EF при любых сравнениях производительности. Для этого я определяю все свои EF-запросы с методом AsNoTracking. Кроме того, сравнивая производительность, вы должны применять ряд стандартных шаблонов эталонных тестов, такие как подготовка («разогрев») базы данных, многократное повторение запроса и отбрасывание самых низких и самых высоких результатов. Все детали того, как я создавала свои эталонные тесты, вы увидите в пакете исходного кода, сопутствующем этой статье. Тем не менее, я считаю эти эталонные тесты «облегченными», так как они дают лишь некоторое представление о различиях. Для серьезных эталонных тестов вам понадобилось бы гораздо больше итераций, чем мои 25 (от 500 и выше), и учет производительности системы, в которой вы работаете. Я выполняла эти тесты на лэптопе, используя экземпляр SQL Server LocalDB, поэтому мои результаты пригодны лишь для сравнения.

Показатели, которые я отслеживала в своих тестах, — это время выполнения запроса и время формирования результатов. Создание соединений или объектов DbContext не учитывалось. DbContext используется повторно, чтобы не принимать во внимание время, затрачиваемое EF на создание модели в памяти, так как это происходило бы лишь раз для каждого экземпляра приложения, а не для каждого запроса.

На рис. 2 показаны тесты «select *» для запросов Dapper и EF LINQ, так что вы можете понять базовую конструкцию моих шаблонов тестирования. Заметьте, что, помимо сбора самих показателей, я записываю время каждой итерации в список (с именем times) для последующего анализа.

Рис. 2. Тесты для сравнения EF и Dapper при запросе всех DapperDesigner

[TestMethod,TestCategory("EF"),TestCategory("EF,NoTrack")]
public void GetAllDesignersAsNoTracking() {
  List<long> times = new List<long>();
  for (int i = 0; i < 25; i++) {
    using (var context = new DapperDesignerContext()) {
      _sw.Reset();
      _sw.Start();
      var designers =
        context.Designers.AsNoTracking().ToList();
      _sw.Stop();
      times.Add(_sw.ElapsedMilliseconds);
      _trackedObjects =
        context.ChangeTracker.Entries().Count();
    }
  }
  var analyzer = new TimeAnalyzer(times);
  Assert.IsTrue(true);
}

[TestMethod,TestCategory("Dapper")
public void GetAllDesigners() {
  List<long> times = new List<long>();
  for (int i = 0; i < 25; i++) {
    using (var conn = Utils.CreateOpenConnection()) {
      _sw.Reset();
      _sw.Start();
      var designers = conn.Query<DapperDesigner>(
        "select * from DapperDesigners");
      _sw.Stop();
      times.Add(_sw.ElapsedMilliseconds);
      _retrievedObjects = designers.Count();
    }
  }
  var analyzer = new TimeAnalyzer(times);
  Assert.IsTrue(true);
}

Стоит отметить еще один момент, касающийся сравнения одинаковых вещей. Dapper принимает чистый SQL. По умолчанию EF-запросы выражаются с помощью LINQ to EF и должны подвергаться некоторой обработке для создания SQL-кода за вас. Как только этот SQL создан, даже если он полагается на параметры, он кешируется в памяти приложения, из-за чего при повторении издержки уменьшаются. Кроме того, EF способна выполнять запросы, использующие чистый SQL, поэтому я приняла во внимание оба подхода. В табл. 1 перечислены сравнительные результаты четырех наборов тестов. В сопутствующем этой статье пакете исходного кода содержится еще больше тестов.

Табл. 1. Среднее время (в мс) выполнения запроса и заполнения объекта на основе 25 итераций с исключением самого быстрого и самого медленного результата

{Для верстки: в шапке таблицы первым идет примечание под звездочкой, которое относится только к самой шапке, где есть звездочки; к телу таблицы это не имеет никакого отношения. Я не знаю, как это оформить по-человечески, а то в оригинале это сделано как-то через одно место}

* Запросы с AsNoTracking Отношение LINQ to EF* Чистый SQL, EF* Чистый SQL, Dapper
Все дизайнеры (30 000 строк) 96 98 77
Все дизайнеры с товарами (30 000 строк) 1 : * 251 107 91
Все дизайнеры с клиентами (30 000 строк) * : * 255 106 63
Все дизайнеры с Contact (30 000 строк) 1 : 1 322 122 116

В сценариях, показанных в табл. 1, легко убедиться в преимуществе использования Dapper по сравнению с LINQ to Entities. Но небольшие различия между чистыми SQL-запросами могут не всегда оправдывать переход на Dapper для конкретных задач в системе, где вы в остальных случаях используете EF. Естественно, ваши требования будут другими и могут повлиять на меру различий между EF-запросами и Dapper. Однако в системе с интенсивным трафиком вроде Stack Overflow даже пара миллисекунд экономии на каждом запросе может оказаться крайне существенной.

Dapper и EF для других требований

До сих пор я замеряла простые запросы, где просто извлекаются все столбцы из таблицы, которые точно соответствуют свойствам возвращаемых типов. А как быть, если вы проецируете запросы на типы? Пока схема результатов совпадает с типом, Dapper не видит никакой разницы при создании объектов. Но EF приходится поработать больше, если результаты проекции не совпадают с типом, который является частью модели.

DapperDesignerContext имеет DbSet для типа DapperDesigner. В моей системе есть другой тип, MiniDesigner, который имеет подмножество свойств DapperDesigner:

public class MiniDesigner {
    public int Id { get; set; }
    public string Name { get; set; }
    public string FoundedBy { get; set; }
  }

MiniDesigner не является частью моей EF-модели данных, поэтому DapperDesignerContext ничего не знает об этом типе. Я обнаружила, что запрос всех 30 000 строк и их проецирование на 30 000 объектов MiniDesigner на 25% быстрее в случае Dapper, чем в случае EF, использующей чистый SQL. И вновь для принятия решений конкретно в вашей системе я рекомендую выполнить собственное профилирование производительности.

Dapper также можно использовать для передачи данных в базу данных с помощью методов, позволяющих вам идентифицировать, какие свойства следует применять для параметров, указанных командой, будь то чистая SQL-команда INSERT/UPDATE или вызов функции/хранимой процедуры в базе данных. Для этих задач я не делала никаких сравнений по производительности.

Гибрид «Dapper плюс EF» в реальном мире

Существует очень много систем, которые на 100% используют Dapper для сохранения данных. Но вспомните, что у меня интерес возник из-за того, что разработчики говорили о гибридных решениях. В некоторых случаях это системы с EF, где нужно оптимизировать конкретные проблемные области. В других случаях группы предпочитают использовать Dapper для всех запросов, а EF для всех операций сохранения.

В ответ на мои вопросы по этому поводу в Twitter я получила самые разнообразные отклики.

@garypochron сказал мне, что его группа «собирается задействовать Dapper в областях, где наблюдается высокая интенсивность вызовов и очень частое использование файлов ресурсов». Я изумилась, узнав, что Саймон Хьюз (Simon Hughes) (@s1monhughes), автор популярного проекта EF Reverse POCO Generator, движется в противоположном направлении: по умолчанию везде применяет Dapper и использует EF только для решения изощренных задач. Он сказал мне, что «использует Dapper везде, где только можно. Если же я имею дело со сложной операцией обновления, то перехожу на EF».

Я также видела множество дискуссий о том, где применение гибридного подхода стимулируется разделением обязанностей, а не повышением производительности. Чаще всего в решениях используется надежность ASP.NET Identity в EF, а Dapper применяется для всех остальных операций с базами данных.

Более прямая работа с базой данных имеет и другие преимущества помимо производительности. Роб Салливан (Rob Sullivan) (@datachomp) и Майк Кэмпбелл (Mike Campbell) (@angrypets), эксперты в области SQL Server, любят Dapper. Роб указывает, что вы получаете возможность задействовать такую функциональность баз данных, к которой EF не дает доступа, например полнотекстовый поиск. В конечном счете эта функциональность на самом деле связана с производительностью.

С другой стороны, есть вещи, которые позволяет делать EF, а Dapper — нет, и это не только отслеживание изменений. Хороший пример — возможность выполнять миграции базы данных по мере изменения модели с помощью EF Code First Migrations (чем я и воспользовалась при создании решения для этой статьи).

Однако Dapper не подходит всем и каждому. @damiangray сказал мне, что Dapper не годится для его решения, поскольку ему нужно возвращать IQueryable-объекты из одной части системы в другую, а не сами данные. Эта тематика, отложенное выполнение запросов (deferred query execution), поднималась в репозитарии GitHub для Dapper по ссылке bit.ly/22CJzJl, если вас интересуют подробности. При проектировании гибридной системы хорошей идеей является использование какой-либо разновидности архитектуры Command Query Separation (CQS), где вы создаете раздельные модели для конкретных типов транзакций (как раз то, к чему я всегда стремлюсь). Тем самым вы не пытаетесь создавать код для доступа к данным, достаточно универсальный для работы как с EF, так и с Dapper, в котором зачастую приходится жертвовать преимуществами каждого из этих ORM. Когда я работала над этой статьей, Курт Доусвелл (Kurt Dowswell) опубликовал статью «Dapper, EF and CQS» (bit.ly/1LEjYvA). Очень удачно и для меня, и для вас.

Для тех, кто ожидает CoreCLR и ASP.NET Core, полезно знать, что в Dapper включена и их поддержка. Более подробную информацию вы найдете в репозитарии GitHub для Dapper по ссылке bit.ly/1T5m5Ko.

Итак, я посмотрела Dapper. Что я думаю?

Каковы мои мысли насчет Dapper? Я сожалею, что не нашла время посмотреть Dapper раньше, и счастлива, что наконец-то сделала это. Я всегда рекомендовала применение AsNoTracking или использование представлений либо процедур в базе данных для смягчения остроты проблем с производительностью. Это никогда не подводило ни меня, ни моих клиентов. Но теперь у меня появился другой туз в рукаве, который я намерена рекомендовать разработчикам, заинтересованным выжать еще капельку производительности из своих систем, использующих EF. Это не панацея на все случаи жизни. Моя рекомендация будет заключаться в том, чтобы исследовать Dapper, замерить разницу в производительности (при больших масштабах) и найти баланс между производительностью и тяжестью кодирования. Примите во внимание очевидный шаблон использования Stack Overflow: запросы вопросов, комментариев и ответов, затем возврат графов одного вопроса с комментариями и ответами наряду с некоторыми метаданными (правками) и информацией о пользователе. Они выполняют одни и те же типы запросов и сопоставляют одну и ту же форму результатов снова и снова. Dapper блистает при таком типе повторяющихся запросов, с каждым разом становясь все интеллектуальнее и быстрее. Даже если у вас нет системы с невероятным количеством транзакций, на которое рассчитан Dapper, вы скорее всего обнаружите, что гибридное решение дает вам как раз то, что нужно.


Джули Лерман (Julie Lerman) — Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog и является автором серии книг «Programming Entity Framework» (O’Reilly Media, 2010), в том числе «Code First Edition» (2011) и «DbContext Edition» (2012), также выпущенных издательством O’Reilly Media. Вы можете читать ее заметки в twitter.com/julielerman и смотреть ее видеокурсы для Pluralsight на juliel.me/PS-Videos.

Выражаю благодарность за рецензирование статьи экспертам Stack Overflow Нику Крейверу (Nick Craver) и Марку Грэвеллу (Marc Gravell).


Discuss this article in the MSDN Magazine forum