Pull to refresh

Обзор ServiceStack.OrmLite — micro-ORM для .NET

Reading time 9 min
Views 17K
OrmLite — это дружелюбная micro-ORM с открытым исходным кодом и коммерческой лицензией (бесплатна для небольших проектов с ограничением в 10 таблиц). Входит в состав известного фреймворка ServiceStack (и обладает высокой производительностью — взгляните на benchmark от разработчиков Dapper). В данной статье мы рассмотрим основы работы с OrmLite в связке с SQL Server. Если сравнивать OrmLite и Entity Framework, то сразу бросается в глаза отсутствие контекста и отслеживания изменений (change tracking). И это далеко не единственные отличия.

План статьи:

Заинтересовавшихся приглашаю под кат.

Подготовка к работе. 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);

Просто!

А что внутри OrmLiteConnection?
Обычный SqlConnection:
    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 запросов также не должно вызвать сложностей. Под спойлером несколько примеров из официальной документации.
Простой CRUD
    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.
Тестовое окружение
.NET 4.5
MS SQL Server 2012
Entity Framework 6.1.3 (Code First)
OrmLite 4.0.38

Код
Entity Framework:
    //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.
Код
Entity Framework:
    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 раза.

О измерениях и систематической погрешности
Так как измерялось общее время выполнения кода на клиенте, то время выполнения SQL-инструкции на сервере вносит в измерения систематическую погрешность. При использовании высоконагруженного «боевого» экземпляра SQL Server мы бы получили «примерно одинаковые» времена выполнения для обоих ORM. Очевидно, что сервер должен быть как можно более производительным и не загруженным для получения более точных результатов.

Заключение


В завершение статьи я бы хотел рассказать о своих (безусловно, субъективных) впечатлениях от работы с 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 безусловно пригодна к использованию, но от коммерческого продукта ожидалось большее.
Tags:
Hubs:
+8
Comments 15
Comments Comments 15

Articles