План статьи:
- Подготовка к работе. Code-first и database-first подходы
- Запросы к БД
- JOIN и navigation properties
- Lazy и Async
- Транзакции
- Сравнение производительности OrmLite и Entity Framework
- Заключение
Заинтересовавшихся приглашаю под кат.
Подготовка к работе. Code-first и database-first подходы
Устанавливаем OrmLite в наш проект:
Install-Package ServiceStack.OrmLite.SqlServer
OrmLite — в первую очередь code-first ORM. Тем не менее, есть возможность генерации POCO классов на основе существующей БД. С такой генерации мы и начнем, дополнительно установив T4 шаблоны:
Install-Package ServiceStack.OrmLite.T4
Если все прошло успешно, в проект будет добавлено 3 файла:
OrmLite.Core.ttinclude
OrmLite.Poco.tt
OrmLite.SP.tt
Добавим в в app/web.config строку подключения, заполним ConnectionStringName в файле OrmLite.Poco.tt (для единственной строки в app.config необязательно), жмем на файле Run Custom Tool и получаем сгенерированные POCO классы, например:
[Alias("Order")]
[Schema("dbo")]
public partial class Order : IHasId<int>
{
[AutoIncrement]
public int Id { get; set; }
[Required]
public int Number { get; set; }
public string Text { get; set; }
public int? CustomerId { get; set; }
}
ОК, модель готова. Сделаем тестовый запрос к БД. Обращение к функционалу OrmLite происходит через экземпляр класса OrmLiteConnection, реализующего IDbConnection:
var dbFactory = new OrmLiteConnectionFactory(ConnectionString, SqlServerDialect.Provider);
using (IDbConnection db = dbFactory.Open())
{
//db.AnyMethod...
}
Давайте запомним данный паттерн, далее он подразумевается при обращении к объекту db.
Выберем все записи из таблицы Order с значением Number, большим 50:
List<Order> orders = db.Select<Order>(order => order.Number > 50);
Просто!
public override IDbConnection CreateConnection(string connectionString, Dictionary<string, string> options)
{
var isFullConnectionString = connectionString.Contains(";");
if (!isFullConnectionString)
{
var filePath = connectionString;
var filePathWithExt = filePath.ToLower().EndsWith(".mdf")
? filePath
: filePath + ".mdf";
var fileName = Path.GetFileName(filePathWithExt);
var dbName = fileName.Substring(0, fileName.Length - ".mdf".Length);
connectionString = string.Format(
@"Data Source=.\SQLEXPRESS;AttachDbFilename={0};Initial Catalog={1};Integrated Security=True;User Instance=True;",
filePathWithExt, dbName);
}
if (options != null)
{
foreach (var option in options)
{
if (option.Key.ToLower() == "read only")
{
if (option.Value.ToLower() == "true")
{
connectionString += "Mode = Read Only;";
}
continue;
}
connectionString += option.Key + "=" + option.Value + ";";
}
}
return new SqlConnection(connectionString);
}
Перейдем к подходу code-first. Последовательно выполнить DROP и CREATE для нашей таблицы можно так:
db.DropAndCreateTable<Order>();
Необходимо отметить, что ранее сгенерированные с помощью Т4 POCO классы утратили часть информации о таблицах БД (длины строковых данных, внешние ключи и др.). OrmLite предоставляет всё необходимое для добавления такой информации в наши POCO (code-first oriented же!). В следующем примере создается некластеризованный индекс, указывается тип nvarchar(20) и создается внешний ключ — для полей Number, Text, и CustomerId соответственно:
[Schema("dbo")]
public partial class Order : IHasId<int>
{
[AutoIncrement]
public int Id { get; set; }
[Index(NonClustered = true)]
public int Number { get; set; }
[CustomField("NVARCHAR(20)")]
public string Text { get; set; }
[ForeignKey(typeof(Customer))]
public int? CustomerId { get; set; }
}
В результате, при вызове db.CreateTable будет выполнен следующий SQL-запрос:
CREATE TABLE "dbo"."Order"
(
"Id" INTEGER PRIMARY KEY IDENTITY(1,1),
"Number" INTEGER NOT NULL,
"Text" NVARCHAR(20) NULL,
"CustomerId" INTEGER NULL,
CONSTRAINT "FK_dbo_Order_dbo_Customer_CustomerId" FOREIGN KEY ("CustomerId") REFERENCES "dbo"."Customer" ("Id")
);
CREATE NONCLUSTERED INDEX idx_order_number ON "dbo"."Order" ("Number" ASC);
Запросы к БД
В OrmLite доступны 2 основных способа для построения запросов к БД: лямбда-выражения и параметризованный SQL. Нижеприведенный код демонстрирует получение всех записей из таблицы Order c указанным CustomerId различными способами:
1) лямбда-выражения и SqlExpression:
List<Order> orders = db.Select<Order>(order => order.CustomerId == customerId);
List<Order> orders = db.Select(db.From<Order>().Where(order => order.CustomerId == customerId));
2) параметризованный SQL:
List<Order> orders = db.SelectFmt<Order>("CustomerId = {0}", customerId);
List<Order> orders = db.SelectFmt<Order>("SELECT * FROM [Order] WHERE CustomerId = {0}", customerId);
Построение простых insert/update/delete запросов также не должно вызвать сложностей. Под спойлером несколько примеров из официальной документации.
db.Update(new Person { Id = 1, FirstName = "Jimi", LastName = "Hendrix", Age = 27});
SQL:
UPDATE "Person" SET "FirstName" = 'Jimi',"LastName" = 'Hendrix',"Age" = 27 WHERE "Id" = 1
db.Insert(new Person { Id = 1, FirstName = "Jimi", LastName = "Hendrix", Age = 27 });
SQL:
INSERT INTO "Person" ("Id","FirstName","LastName","Age") VALUES (1,'Jimi','Hendrix',27)
db.Delete<Person>(p => p.Age == 27);
SQL:
DELETE FROM "Person" WHERE ("Age" = 27)
Подробнее мы рассмотрим более интересные случаи.
JOIN и navigation properties
Добавим к уже известной нам таблице Order связанную таблицу Customer:
class Customer
{
[AutoIncrement]
public int Id { get; set; }
public string Name { get; set; }
}
Для их внутреннего соединения (INNER JOIN) достаточно выполнить код:
List<Order> orders = db.Select<Order>(q => q.Join<Customer>());
SQL:
SELECT "Order"."Id", "Order"."Details", "Order"."CustomerId"
FROM "Order" INNER JOIN "Customer" ON ("Customer"."Id" = "Order"."CustomerId")
Соответственно, для LEFT JOIN используется метод q.LeftJoin и т.д. Для получения данных из нескольких таблиц одновременно, способ №1 — произвести маппинг результирующей выборки на следующий класс OrderInfo:
class OrderInfo
{
public int OrderId { get; set; }
public string OrderDetails { get; set; }
public int? CustomerId { get; set; }
public string CustomerName { get; set; }
}
List<OrderInfo> info = db.Select<OrderInfo>(db.From<Order>().Join<Customer>());
SQL:
SELECT "Order"."Id" as "OrderId", "Order"."Details" as "OrderDetails", "Order"."CustomerId", "Customer"."Name" as "CustomerName"
FROM "Order" INNER JOIN "Customer" ON ("Customer"."Id" = "Order"."CustomerId")
Единственное необходимое условие для класса OrderInfo — его свойства должны быть именованы по шаблону {TableName}{FieldName}.
Способ №2 в стиле EF — воспользоваться navigation properties (в терминологии OrmLite они именуются «references»).
Для этого добавим в класс Order следующее свойство:
[Reference]
Customer Customer { get; set; }
Данное свойство будет проигнорировано при любых запросах вида db.Select, что весьма удобно. Для загрузки связанных сущностей необходимо воспользоваться методом db.LoadSelect:
List<Order> orders = db.LoadSelect<Order>();
Assert.True(orders.All(order => order.Customer != null));
SQL:
SELECT "Id", "Details", "CustomerId" FROM "Order"
SELECT "Id", "Name" FROM "Customer" WHERE "Id" IN (SELECT "CustomerId" FROM "Order")
Аналогичным способом мы можем проинициализировать набор customer.Orders.
Примечание: в приведенных примерах названия внешних ключей в связанных таблицах следовали шаблону {Parent}Id, что позволило OrmLite автоматически выбрать колонки, по которым производится соединение, тем самым упростив код. Альтернативный вариант — помечать внешние ключи атрибутом:
[References(typeof(Parent))]
public int? CustomerId { get; set; }
и явно задать колонки таблиц для соединения:
SqlExpression<Order> expression = db
.From<Order>()
.Join<Order, Customer>((order, customer) => order.CustomerId == customer.Id);
List<Order> orders = db.Select(expression);
Lazy и Async
Отложенные SELECT запросы реализованы через IEnumerable. Для *Lazy-методов не поддерживаются лаконичные запросы с помощью лямбда-выражений. Так SelectLazy предполагается ТОЛЬКО использование параметризованного SQL:
IEnumerable<Product> lazyQuery = db.SelectLazy<Product>("UnitPrice > @UnitPrice", new { UnitPrice = 10 });
что при обходе перечисления аналогично следующему вызову:
db.Select<Product>(q => q.UnitPrice > 10);
Для ColumnLazy (возвращает список значений в колонке таблицы) дополнительно поддерживается SqlExpression:
IEnumerable<string> lazyQuery = db.ColumnLazy<string>(db.From<Product>().Where(x => x.UnitPrice > 10));
Не в пример lazy queries, большая часть OrmLite API имеет async-версии:
List<Employee> employees = await db.SelectAsync<Employee>(employee => employee.City == "London");
Транзакции
Поддерживаются:
db.DropAndCreateTable<Employee>();
Assert.IsTrue(db.Count<Employee>() == 0);
using (IDbTransaction transaction = db.OpenTransaction())
{
db.Insert(new Employee {Name = "First"});
transaction.Commit();
}
Assert.IsTrue(db.Count<Employee>() == 1);
using (IDbTransaction transaction = db.OpenTransaction())
{
db.Insert(new Employee { Name = "Second" });
Assert.IsTrue(db.Count<Employee>() == 2);
transaction.Rollback();
}
Assert.IsTrue(db.Count<Employee>() == 1);
Под капотом у db.OpenTransaction — вызов SqlConnection.BeginTransaction, поэтому на теме транзакций подробно останавливаться не будем.
Операции над группой строк. Сравнение производительности OrmLite и Entity Framework
В дополнение к различным вариациям выполнения SELECT-запросов, OrmLite API предлагает 3 метода для модификации группы строк в БД:
InsertAll(IEnumerable)
UpdateAll(IEnumerable)
DeleteAll(IEnumerable)
Поведение OrmLite в данном случае ничем не отличается от поведения «взрослых» ORM, в первую очередь Entity Framework — мы получаем одну INSERT/UPDATE инструкцию на строку в БД. Хотя было бы интересно посмотреть на решение для INSERT с использованием Row Constructor, но не судьба. Очевидно, что разница в скорости выполнения образуется в основном за счет архитектурных особенностей самих фреймворков. А так ли велика эта разница?
Ниже — замеры времени выполнения выборки, вставки и модификации 103 строк из таблицы Order средствами Entity Framework и OrmLite. Итерация повторяется 103 раз, и в таблице представлено суммарное время выполнения (в секундах). На каждой итерации генерируется новый набор случайных данных и происходит очистка таблицы. Код доступен на GitHub.
MS SQL Server 2012
Entity Framework 6.1.3 (Code First)
OrmLite 4.0.38
//select
context.Orders.AsNoTracking().ToList();
//insert
context.Orders.AddRange(orders);
context.SaveChanges();
//update
context.SaveChanges();
OrmLite:
//select
db.Select<Order>();
//insert
db.InsertAll(orders);
//update
db.UpdateAll(orders);
Время выполнения в секундах:
Select | Insert | Update | |
EF | 4,0 | 282 | 220 |
OrmLite | 7,3 | 94 | 88 |
OrmLite, ты серьезно? Select выполняется медленнее, чем у EF? После таких результатов было решено написать дополнительный тест, измеряющий скорость чтения 1 строки по Id.
context.Orders.AsNoTracking().FirstOrDefault(order => order.Id == id);
OrmLite:
db.SingleById<Order>(id);
Время выполнения в секундах:
Select single by id | |
EF | 1,9 |
OrmLite | 1,0 |
На этот раз у OrmLite почти двухкратный перевес, и это неплохо. О причинах падения производительности при выгрузке большого количества строк из БД рассуждать пока не берусь. В большинстве сценариев OrmLite все таки быстрее EF, как было показано — в 2-3 раза.
Заключение
В завершение статьи я бы хотел рассказать о своих (безусловно, субъективных) впечатлениях от работы с OrmLite, подытожить достоинства и недостатки этой micro-ORM.
Плюсы:
- легковесность, простота в настройке и развертывании;
- простые CRUD-запросы действительно просто написать;
Минусы:
- вариативность построения запросов (то лямбды, то параметризованный SQL, то SqlExpression) для различных методов. Нет единого универсального синтаксиса, поддерживаемого любым методом из API;
- слабая документированность: отсутствуют XML-комментарии к методам, официальная документация плохо структурирована и располагается на единственной веб-странице;
- неясный API. Попробуйте сходу догадаться, чем отличаются вызовы db.Select, db.LoadSelect, db.Where? Или db.Insert и db.Save? Приедут ли из базы navigation properties при вызове db.Join?
Перечисленные пункты повышают «порог вхождения» в технологию. Отчасти, это лишает OrmLite одного из главных преимуществ micro-ORM — простоты и легкости использования по сравнению с «взрослыми» ORM. В целом, у меня сложилось нейтральное впечатление. OrmLite безусловно пригодна к использованию, но от коммерческого продукта ожидалось большее.
Комментарии (15)
fcoder
05.05.2015 19:34+3Примерно год назад начали наблюдать LicenceException в логе после обновления на 4 версию — ввели ограничение — бесплатны только первые 10 таблиц.
По-хорошему могли хотя бы назвать коммерческий пакет по-другому чтобы не ломать код при обновлениях. В итоге перешли на Dapper, т.к. в OrmLite неизвестно что еще сломают в следующей версии.
Kefir
05.05.2015 20:57+2А чего не поддержали отечественного производителя?
Linq2Db (бывший BLToolkit) навскидку умеет все то же самое но без лицензионных заморочек. Интересно было бы сравнить его по скорости с OrmLitechumakov-ilya Автор
05.05.2015 23:40Клюнул на репутацию ServiceStack, да и первое впечатление было целиком положительным: LINQ-подобные запросы, скорость… Про отечественные корни Linq2Db узнал только сейчас, спасибо. Было бы здорово видеть больше статей на подобную тему.
trurl123
06.05.2015 08:14Эту библиотеку можно использовать не только под коммерческой лицензией, но и под GNU Affero General Public License.
github.com/ServiceStack/ServiceStack.OrmLite/blob/master/license.txt
Agaspher20
06.05.2015 11:25+2По-моему зря не поддержали родной LINQовский naming convention. Для разработчика непривычно видеть
db.Select<Product>(q => q.UnitPrice > 10)
гораздо привычнее
db.Products.Where(q => q.UnitPrice > 10)
Я конечно понимаю, что тема холиварная, но в .NET уже давно так. Зачем городить свой огород?kekekeks
06.05.2015 14:33+1Чтобы усложнить портирование куда-то ещё, когда оно начнёт просить денег за использование, очевидно.
lasalas
06.05.2015 22:27Entity Framework:
context.Orders.AsNoTracking().FirstOrDefault(order => order.Id == id);
Какой будет результат, если использовать EF грамотно?
context.Orders.Find(id);
chumakov-ilya Автор
07.05.2015 09:56Как вам должно быть известно, Find сначала ищет в контексте по Id, если не найдет — обращается к БД. Вот только в данном примере я сравнивал скорости отдельных и друг от друга независимых запросов к БД без участия кэширования. Следствие: в контексте в принципе не может быть объекта с тем же Id (создается контекст на запрос), поэтому использование Find бессмысленно.
Грамотно говоря, для большого графа объектов в контексте, использование Find может быть даже вредно, если заранее известно, что нужного объекта в контексте нет — эффективнее сразу использовать прямой запрос к БД.lasalas
07.05.2015 10:15В таком случае выборку из доморощенного орма тоже надо делать через [аналог] FirstOrDefault(), а не через некий db.SingleById(id), иначе начинают терзать смутные сомнения.
chumakov-ilya Автор
07.05.2015 12:09в OrmLite нет контекста/кэширования в принципе, а naming convention отличается от LINQ, как уже заметили в комментариях выше. Поэтому, SingleById и есть аналог FirstOrDefault, в обоих случаях будет сгенерирован схожий SQL. Вечером посмотрю точные SQL-запросы, если интересно.
centur
Что то POCO ( plain old class objects) тут и не пахнет, все эти аттрибуты и navigation property засоряют модель. Пример работы с POCO — PetaPoco, берем любой класс и в него маппим результат SQL запроса. И не надо fluent городить — SQL для общения с базой — самый правильный и родной вариант.
chumakov-ilya Автор
т.е. вам от ORM необходим только маппинг?
centur
От Object-Relational Mapper — да, мне необходим именно маппер, потому что все остальное оно делает хуже чем нативный интерфейс.
Ну и удобный способ исполнять мои запросы на языке базы данных. А не универсальный комбайн-переводчик с свистоперделками. Уже все вроде наелись с EF и признались — создание суррогатных языков, пусть даже с автокомплитами и все такое — всегда генерит худший результат (и если смотрели в профайлере — сильно худший) чем обычный SQL, который lingua franca реляционных БД.
Nagg
Да, у меня бы тоже язык не повернулся назвать класс с кучей библиотечно-зависимых атрбиутов POCO.