Использование Entity Framework Core code-first с СУБД SQLite при разработке WinForms-приложений в VisualStudio 2015

На первый взгляд нижеприведённый материал может показаться ещё одним банальным «хэллоууорлдом», «туториалом от Капитана Очевидность», коих уже предостаточно на просторах Сети, но это впечатление обманчиво. На деле чтобы добиться того же результата с нуля у WinForms-программиста, не работавшего ранее с Entity Framework Core (не путать с классической Entity Framework) и вооружённого только уже валяющимися в изобилии на просторах Сети туториалами по EF Core и документацией может уйти куда больше времени, чем он наивно ожидал до того, как взялся за дело. Так случилось и со мной. Посему хочу поделиться опытом.

Основная проблема заключается в том, что имеющиеся материалы по EF Core в подавляющем большинстве не подразумевают использование Visual Studio (вместо этого подразумевается использование легковесного кроссплатформенного тулинга .NET Core) и уж точно не берут в расчёт, что кому-то захочется использовать EF Core в дэсктопном приложении на основе фрэймворка Windows Forms (вместо этого как правило подразумевается использование ASP.NET core). Но задачи и ситуации, в которых предпочтительно (или и вовсе необходимо) решение в виде дэсктопного приложения всё-ещё встречаются, и свои преимущества (как, разумеется, и недостатки) у Entity Framework Core по сравнению с Entity Framework 6 есть. Кроме того в доступных в Сети примерах как правило рассматриваются только наиболее элементарные действия типа добавления записи в БД, при этом даже вопрос последующего извлечения данных зачастую не рассматривается, а в нём уже есть не очевидные моменты. Также можно заметить, что EF Core достаточно активно развивается и многие инструкции теряют актуальность в т.ч. по этой причине.

В своих попытках решить такую элементарную на первый взгляд задачу, как разработка минимального демонстрационного приложения WinForms с использованием EF Core в VisualStudo 2015 я натолкнулся на целый проблем типа исключений, неожиданного поведения, непонимания как сделать нечто, что в туториалах подразумевается как самоочевидное и т.п. В результате некоторые шаги из тех, что я приведу ниже пришлось нащупывать практически в слепую, гугля, задавая вопросы и экспериментируя.

В качестве платформы я выбрал .NET Framework 4.6.1 (версия 4.6.2 почему-то не появляется в списке доступных у меня в Visual Studio, но и при выборе 4.6.1 всё работает, возможно будет работать и с более ранними версиями, но я не проверял), в качестве среды разработки — Visual Studio Community Edition 2015 Update 3, в качестве СУБД — SQLite, в качестве ОС — Windows 7 64-bit.

Ниже приведу список шагов, результатом которых стало исправно работающее демонстрационное приложение и в процессе выполнения которых не было получено ни одного сообщения об ошибке.

0. Проверяем, что установлены последние версии .NET Framework 4.6.2 (web installer, offline installer) и Visual Studio 2015 Update 3 (update web installer, update offline installer ISO, web installer полной версии Visual Studio 2015 Update Community Edition 3, offline installer ISO полной версии Visual Studio 2015 Update Community Edition 3 с интергрированным обновлением)

1. Устанавливаем Windows Management Framework 5.0 чтобы обновить PowerShell до версии 5.0. У меня (после установки Windows 7 и Visual Studio 2015 и всех обновлений к ним) в системе была версия 2.0 и далее в процессе я получил сообщение об ошибке с требованием более новой версии

2. Устанавливаем .NET Core 1.0.1 tools Preview 2 (web installer, может использоваться для создания offline-дистрибутива при помощи ключа /layout).

3. Обновляем расширение Visual Studio для работы с репозиториями NuGet. Для этого либо скачиваем актуальную на данный момент версию 3.5.0.1996 по прямой ссылке либо добавляем в настройки Visual Studio соответствующий репозиторий расширений для автоматического обновления.

Visual Studio 2015 'Tools' - 'Options' - 'Extensions and Updates' dialogue

4. Создаём новый проект типа Windows Forms Application. Я назвал его «Vs2015WinFormsEfcSqliteCodeFirst20170304Example» (актуальный на 04.03.2017 пример WinForms-приложения с использованием Entity Framework Core, подхода «code-first» и СУБД SQLite в Visual Studio 2015). В качестве target framework выбираем .NET Framework 4.6.1.

5. Нажимаем правой кнопкой мыши по нашему проекту в панели Solution Explorer, выбираем Manage NuGet Packages..., переходим на вкладку Browse, устанавливаем Microsoft.EntityFrameworkCore.Sqlite.Design и Microsoft.EntityFrameworkCore.Tools. Для установки актуальных версий может потребоваться установить галочку «Include prerelease», в стабильных версиях может чего-то не хватать или наличествовать неисправленные баги. Я установил последние на данный момент Microsoft.EntityFrameworkCore.Sqlite.Design 1.1.0 и Microsoft.EntityFrameworkCore.Tools 1.1.0-preview4-final. Если возникнут проблемы с установкой Microsoft.EntityFrameworkCore.Tools можно попробовать сделать это через командную строку NuGet: в меню выбрать Tools — NuGet Package Manager — Package Manager Console, в появившейся консоли (которая, кстати, ещё понадобится нам далее) после приглашения «PM>» ввести «Install-Package Microsoft.EntityFrameworkCore.Tools -Pre»

PM> Install-Package Microsoft.EntityFrameworkCore.Tools -Pre

6. Создаём файлы исходного кода классов модели данных. Для порядка я поместил их в подпапку «Model» (некоторые называют её «Entities», некоторые кидают все классы в корень проекта, а некоторые и вовсе в один файл). Модель описывает учебный пример базы данных, хранящей список городов и людей в/из них. Каждый человек может быть связан только с одним городом, может быть неизвестно из какого он города вообще.

Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model
{
    public class Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext : DbContext
    {
        public DbSet<City> Cities { get; set; }

        public DbSet<Person> People { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlite("Filename=Vs2015WinFormsEfcSqliteCodeFirst20170304Example.sqlite");
        }
    }
}

City.cs

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;

namespace Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model
{
    public class City
    {
        public int Id { get; set; }

        public string Name { get; set; }

        [InverseProperty("City")]
        public virtual ICollection<Person> People { get; set; }
    }
}

Person.cs

using System.ComponentModel.DataAnnotations.Schema;

namespace Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model
{
    public class Person
    {
        public int Id { get; set; }

        public string Name { get; set; }

        public string Surname { get; set; }

        public int? CityId { get; set; }

        [InverseProperty("People")]
        public virtual City City { get; set; }
    }
}

7. Разрешаем скрипты PowerShell, если этого не сделать может возникнуть ошибка "...\packages\Microsoft.EntityFrameworkCore.Tools.1.1.0-preview4-final\tools\init.ps1 cannot be loaded because running scripts is disabled on this system." Для этого переходим в командную строку NuGet (выбрать Tools — NuGet Package Manager — Package Manager Console в меню) и выполняем следующую команду

PM> Set-ExecutionPolicy RemoteSigned

8. Создаём «миграции». Для этого, сохранив и откомпилировав наш код (просто чтобы удостовериться в отсутствии явных опечаток) переходим в командную строку NuGet и выполняем следующую команду.

PM> Add-Migration -Name "Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleMigration" -Context "Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext"

в результате у нас в проекте должна появиться папка «Migrations» и два файла в ней: «Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContextModelSnapshot.cs» и «20170304204355_Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleMigration.cs» (разумеется число в начале последнего у вас будет другое — это ни что иное, как дата и время в момент генерации в очевидном формате, я её потом вообще удалил оставив только «Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleMigration.cs»).

9. Редактируем сгенерированные файлы чтобы добавить условие уникальности (unique constraint, также известное как вторичный ключ) на имя города (в реальности, конечно, бывают города с одинаковыми именами, но для примера будет не лишним)

Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleMigration.cs

using Microsoft.EntityFrameworkCore.Migrations;

namespace Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Migrations
{
    public partial class Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleMigration : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Cities",
                columns: table => new
                {
                    Id = table.Column<int>(nullable: false)
                        .Annotation("Sqlite:Autoincrement", true),
                    Name = table.Column<string>(nullable: true)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Cities", x => x.Id);

                    // Эта строка добавлена вручную
                    table.UniqueConstraint("UQ_Cities_Name", x => x.Name);
                });

            migrationBuilder.CreateTable(
                name: "People",
                columns: table => new
                {
                    Id = table.Column<int>(nullable: false)
                        .Annotation("Sqlite:Autoincrement", true),
                    CityId = table.Column<int>(nullable: true),
                    Name = table.Column<string>(nullable: true),
                    Surname = table.Column<string>(nullable: true)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_People", x => x.Id);
                    table.ForeignKey(
                        name: "FK_People_Cities_CityId",
                        column: x => x.CityId,
                        principalTable: "Cities",
                        principalColumn: "Id",
                        onDelete: ReferentialAction.Restrict);
                });

            migrationBuilder.CreateIndex(
                name: "IX_People_CityId",
                table: "People",
                column: "CityId");
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropTable(
                name: "People");

            migrationBuilder.DropTable(
                name: "Cities");
        }
    }
}

Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContextModelSnapshot.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model;

namespace Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Migrations
{
    [DbContext(typeof(Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext))]
    partial class Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContextModelSnapshot : ModelSnapshot
    {
        protected override void BuildModel(ModelBuilder modelBuilder)
        {
            modelBuilder
                .HasAnnotation("ProductVersion", "1.1.0-rtm-22752");

            modelBuilder.Entity("Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model.City", b =>
                {
                    b.Property<int>("Id")
                        .ValueGeneratedOnAdd();

                    b.Property<string>("Name");

                    b.HasKey("Id");

                    // Эта строка добавлена вручную
                    b.HasIndex("Name").IsUnique();

                    b.ToTable("Cities");
                });

            modelBuilder.Entity("Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model.Person", b =>
                {
                    b.Property<int>("Id")
                        .ValueGeneratedOnAdd();

                    b.Property<int?>("CityId");

                    b.Property<string>("Name");

                    b.Property<string>("Surname");

                    b.HasKey("Id");

                    b.HasIndex("CityId");

                    b.ToTable("People");
                });

            modelBuilder.Entity("Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model.Person", b =>
                {
                    b.HasOne("Vs2015WinFormsEfcSqliteCodeFirst20170304Example.Model.City", "City")
                        .WithMany("People")
                        .HasForeignKey("CityId");
                });
        }
    }
}

10. Генерируем файл БД. Для этого в командной строке NuGet выполняем следующую команду

PM> Update-Database -Context "Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext"

Файл БД создастся в той же директории, где находится скомпилированный exe-шник нашего приложения, у меня это "...\Vs2015WinFormsEfcSqliteCodeFirst20170304Example\bin\Debug\Vs2015WinFormsEfcSqliteCodeFirst20170304Example.sqlite".

На данном этапе мы уже можем заглянуть внутрь созданного файла, например с помощью официального консольного клиента sqlite3 или бесплатной кроссплатформенной GUI оболочки DB Browser for SQLite, и удостовериться, что таблицы создались корректно. Помимо наших таблиц «Cities» и «People» мы также найдём там таблицу «__EFMigrationsHistory» со служебной информацией EF Core и «sqlite_sequence» со служебной информацией SQLite.

11. Теперь перейдём к дизайнеру нашей формы, разместим на ней кнопочку, щёлкнем и на ней дважды чтобы создать обработчик события нажатия на кнопку и перейти к редактированию его кода. Ниже привожу свой код, демонстрирующий основные действия с записями БД. Я ещё имею привычку всегда переименовывать Form1 в MainForm и контролы аналогично по смыслу (в данном случае единственный контрол button1 в mainButton), но это дело вкуса и принятых в вашей команде стандартов именования.

MainForm.cs

private void mainButton_Click(object sender, EventArgs e)
{
    // Удаляем все записи из обеих таблиц
    using (var context = new Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext())
    {
        foreach (var person in context.People)
            context.Remove(person);

        foreach (var city in context.Cities)
            context.Remove(city);

        context.SaveChanges();
    }

    // Добавляем новые записи в таблицу городв и затем
    // новые записи в таблицу людей, ссылаясь на добавленные города
    using (var context = new Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext())
    {
        var praha = new City { Name = "Praha" };

        var london = new City { Name = "London" };

        var madrid = new City { Name = "Madrid" };

        var jan = new Person { Name = "Jan", City = praha };

        var john = new Person { Name = "John", City = london };

        var juan = new Person { Name = "Juan", City = madrid };

        context.Cities.AddRange(praha, london, madrid);

        context.People.AddRange(jan, john, juan);
        
        context.SaveChanges();
    }

    // Загружаем запись о добавленном ранее городе из БД,
    // добавляем нового человека в этот город,
    // изменяем добавленную ранее запись о человеке (добавляем фамилию)
    using (var context = new Vs2015WinFormsEfcSqliteCodeFirst20170304ExampleContext())
    {
        // Обратите внимание на конструкцию Include(city => city.People)
        // если написать просто context.Cities.Single(city => city.Name == "London");
        // то город найдётся, но его список .People будет равен null.
        // В production коде при использовании .Single также необходимо добавить обработку случаев,
        // когда удовлетворяющих запросу записей нет или когда их болше одной
        var london = context.Cities.Include(city => city.People)(city => city.Name == "London");

        var peter = new Person { Name = "Peter", City = london };

        var john = london.People.Single(person => person.Name == "John");

        john.Surname = "Smith";

        context.Add(peter);

        context.Update(john);

        context.SaveChanges();
    }
}

Разумеется в реальной жизни вы реализуете в приложении более богатый интерфейс, более осмысленную логику, добавите обработку исключений и валидацию данных для их предотвращения, приведённого же примера достаточно для понимания того, как это сделать.

Скачать весь приведённый выше и сопутствующий код можно тут.
Метки:
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 16
  • 0
    спасибо
    • 0

      Субъективно насколько ef core тормознутый? Вроде быстрее чем на 4.5? Но тоже вопрос на сколько? Иной раз кажется что орм по джаву все равно быстрее! Так как там очень экосистема развита!

      • 0
        Быстрее чем EF 6, но на простых запросах ему ещё далеко до ADO.NET или LINQ2DB.
        • +1
          Dapper бы еще в сравнение ;)
          • 0
            А смысл? Быстрее чем ADO.NET не будет. Если LINQ2DB использовать только как мэппер (писать запросы руками текстом), то он не медленнее ADO.NET. Да и даже если использовать LINQ-запросы то LINQ2DB почти так же быстр как ADO.NET. Выбор очевиден :).
            • 0
              Смысл посмотреть, сможет ли LINQ2DB потягаться с Dapper на сценариях с raw sql запросами. Судя по тестам Dapper он очень близок к ADO.
              • +1
                LINQ2DB не только может потягаться но и как минимум не хуже, т.к. не проигрывает ADO.NET (и дапперу значит тоже) — видно по результату «LINQ to DB raw SQL query».
                Я бы поставил вопрос наоборот — может ли даппер потягаться с LINQ2DB? На мой взгляд не может — даже если даппер так же быстр, он не умеет LINQ. Используя linq2db можно использовать LINQ не думая о производительности, а в крайнем случае всегда можно написать raw SQL.
                • 0
                  Вот тесты бы живые посмотреть :)
                  LINQ не думая о производительности? :) Может в BenchmarkDotNet пару тестов накидаете LINQ vs Raw SQL;)
                  Ну и неплохо было бы для начала поддержку NetStandard увидеть.
                  • 0
                    Было бы неплохо для начала поддержку асинхронности сделать. Я врядли бы взял нычне билиотеку, которая работает с i\o и не имеет асинхронного API. все остальные бенефиты нивелируються на фоне этого минуса.
                    Если сравнивать с Dapper, его движок работает с reflection emit, linqtodb с lazy expression — которые он использует для сиквела и для маппинга, скорость приблизительно одинакова — кромме первой прогонки, когда linqtodb будет сливать колосально из-за необходимости построить sql и скомпилировать делегат для мапера IDataRecord -> IEnumerable.
                    Но как я сказал на фоне такого глобального недостатка как отсуствие асинхронной работы с бд и повсеместное блокирование потоков — это колосальный минус и пережиток прошлого, тут и dapper и даже медленный EF во многих сценариях остануться предпочтительней.
                    • 0
                      Вот интересно посмотреть как на микробенчмакре будут показывать себя LINQ запросы. Выражение то все равно собирается в юзер коде. Тоесть есть какой-то оверхед на GC. Поправьте если не прав.
                      И NetStandard не наблюдается чет в Nuget…
                      • +1
                        А можете указать в коде linq2db где они блочат потоки и нет асинхронной работы с бд?
                        судя по коду они асинхронно вызывают ADO.NET ридеры…
                        • 0
                          Заходим на доку:
                          https://github.com/linq2db/linq2db

                          return query.ToList();

                          products.Count();

                          Ничего об асинхронности.

                          Смотрим дальше — что-то есть AsyncExtensions.cs…

                          Заглядываем внутрь:
                          public static Task<List> ToListAsync(this IQueryable source, CancellationToken token)
                          {
                          return GetTask(
                          () => source.AsEnumerable().TakeWhile(_ => !token.IsCancellationRequested).ToList(),
                          token);
                          }

                          Cихнронный блокирующий код запущен в отдельном потоке — антипатерн. Синхронная блокирующая работа с I\O замаскированная под асинхронность. В итоге имеем истощение тред пулла — и общие тормоза приложения. Лучше чем писать такое, просто писать синхронный блокирующий код и не запускать его на тред пуле с блокированием.

                      • 0
                        Я уже накидал тесты по ссылке выше :). В том числе и [LINQ via LINQ2DB] vs [raw SQL via LINQ2DB] vs [raw SQL via ADO.NET]. Причём raw SQL через LINQ2DB ещё и маппит результаты а не вручную как с ADO.NET и всё равно не отстаёт.
                        Да, если использовать LINQ2DB то можно не думать (или почти не думать) о производительности. Некоторые вообще EF используют — с ним конечно надо думать. Хотя у EF Core уже намного лучше с этим.
                        • 0
                          Чет все в одну кучу намешали :)
                          1. Как dotnetdonik заметил, нет async.
                          2. Все тесты последовательные с одним запросом. Сделайте хотя бы 32 потока.
                          3. Важно сколько объектов генерится при обработке каждого запроса. Поэтому и просил BenchmarkDotNet со статистикой GC ;)
                          • 0
                            Как спросили всё вместе так и ответил :).
                            1) Это к автору linq2db. Я не использую, но вот вроде есть — AsyncTests.cs.
                            2) Я сделал тесты, которые интересовали меня, а вы можете сделать те, которые интересуют вас и поделиться результатами :). Код в паблике если вдруг нужен.
                            3) Мне более важно чтобы быстро работало. Вот когда появится ORM которая будет быстрее чем LINQ2DB, тогда автор (а не я) пусть и смотрит статистику GC или ищет другую причину почему так :).
          • +1
            Тоесть есть какой-то оверхед на GC. Поправьте если не прав.

            Основной оверхед при
            скомпилировать делегат для мапера IDataRecord -> IEnumerable
            — очень медленно
            и при
            построить sql из expression

            Когда это делается в первый раз это тоже медленней чем собрать sql и строки. т.е. каждое новое linq выражение будет давать небольшой оверхед — это проход по дереву выражений естественно с доп обьектами и операциями т.д.
            Синтетические тесты плохой пример — они работают с одним и тем же типом и одним и тем же запросом, который linqtodb закеширует и не будет показывать реальной картины.

            В целом это нормальный подход при наличии кеша, а он есть у linqtodb. Основной недостаток этой библиотеки блокирующий синхронный API для работы с базой — все миллисекунды выигрыша полностью нивелирует этот подход с точки зрения вреда для приложения.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.