Все материалы, которые будут показываться в ходе данной статьи будут доступны по данной ссылке. Вполне возможно, что со временем данный репозиторий будет обновляться, или, некоторые захотят сами принять участие в его развитии.
Можно ли сегодня представить разработку, будь то десктопы или веб, без использования баз?
Ну, чисто в теории можно, есть еще старенькие проекты, использующие файловую систему, идею которых можно еще увидеть в университетских лабораторных по сей день.
В чем же так плоха файловая система? Ну на самом деле, говоря на своем опыте, можно выделить следующие пункты:
Блокировка файла, в который идет запись
Отсутствие специализированных программ для работы с файлами (аналог СУБД)
Да, в какой-то мере можно выделить еще минусы, или попытаться закрыть уже названные мной. Но в целом главная идея базы данных – это удобство для чтения данных, а также наличие огромного числа инструментов для работы с данными (возможность быстрого поиска по полям таблицы, соединение таблиц, группировка записей, индексирование и т.д.)
Будем считать, что я смог в какой-то мере убедить, или хотя бы заинтриговать тем, что базы – это крутой механизм, который надо знать и уметь использовать.
Говоря о базах, я упомянул понятие запросов. В общих чертах запрос – это команда, которую ты говоришь выполнить базе. Запросы пишутся на языке SQL, состоят из предложений, и вот основные из них:
SELECT
FROM
JOIN
WHERE
GROUP BY
HAVING
ORDER BY
Для данной статьи будем использовать базу данных состоящую из 3 таблиц:
dbo.Student
Id
Name
Course
BirthDate
dbo.Department
Id
Name
dbo.Coursework
Id
StudentId
DepartmentId
DeliveryDate
-- SELECT указывает на то, какие поля мы хотим выбрать. -- Если указать *, то это означает выбор всех полей. SELECT Student.Name, Student.BirthDate, COUNT(*) AS [Количество курсовых] -- FROM указывает на то, из какой таблицы мы хотим вытащить данные FROM Student -- JOIN предназначен для объединения таблиц по какому либо условию. -- В данном случае мы делаем связь по айдишникам студентов. JOIN Coursework ON Student.Id = Coursework.StudentId -- WHERE позволяет фильтровать выбору по какому либо условию . -- Так, в этом случае я ищу тех студентов, которые имеют в имени начало Vladzimir. WHERE Student.Name LIKE 'Vladzimir%' -- GROUP BY нужен для группировки выборки, в данном случае группируем по студентам, -- чтобы найти сколько у каждого студента сдано курсовых. GROUP BY Student.Name, Student.BirthDate -- HAVING представляет собой вторичную фильтрацию, и используется после GROUP BY. -- В данном случае нам интересны те студенты, у которой больше 1 курсовой работы. HAVING COUNT(*) > 1 -- ORDER BY служит для сортировки. Так, мы сортируем по убыванию -- по полю день рождения. -- Для сортировки по возрастанию надо убрать ключевое слово DESC. ORDER BY BirthDate DESC
Отлично! Итого мы получаем всех студентов и количество их курсовых, если их сдано больше 1. Также нас интересуют только те студенты, у которых имя начинается с Vladzimir. При этом для удобства отсортировали по убыванию даты рождения.
Немного практики с SQL и он уже не кажется таким страшным и сложным. Хотя практиковаться с ним надо много, так как существует запросы куда больше и сложнее.
Теперь хорошая возможность перейти к нашей разработке.. Как же нам подружить наше приложение с базой данных?
Первое с чем надо определиться - это сервер и непосредственно наша база. Если мы пользуемся СУБД, то узнать данные параметры очень легко:

В данном случае, так как я пользовался MS SQL Server, то использовать Microsoft SQL Server Managment Studio (SSMS) является лучшим решением. При использовании, допустим, PostgreSQL можно использовать PG Admin в качестве СУБД.
При установке SQL сервера, в зависимости от версии (Express или расширенная) будет доступно несколько серверов:
{Имя компьютера}\SQLEXPRESS,
{Имя компьютера}.
Также отдельно можно поставить (localdb)\MSSQLLocalDB.
Теперь, определившись с сервером и базой данных (название можно придумать какое угодно, но для примеров ниже база будет называться University), можно приступать к изучению платформы .NET.
На самом деле на платформе .NET есть три основных решения для данной ситуации (в реальности их будет и больше, скорее всего, но реально поддерживаемых, стабильных и проверенных только три).

Поговорим немного о теории.
У нас есть 3 основных понятия, от которых и будет опираться:
ORM - это расшифровывается как Object/Relational Mapping, предоставляет большой перечень работы с базой, и базовые удобства, например как, CRUD операции из под коробки, также поддержка Change Tracker, Unit of Work и т.д.
Micro-ORM - представляет собой возможность сопоставления данных из таблиц с классами C#. На этом всё.
Провайдер базы данных - предоставляет возможность установить соединение с базой данных и отправить туда запрос. Всё остальное лежит на плечах программиста.
Поэтому, когда говорим об ADO.NET - это провайдер, EntityFramework (EF) - ORM, а Dapper - micro-ORM.
ADO.NET
Необходимо наличие следующих пакетов:
System.Data.SqlClient
Первым, и главным столпом является ADO.NET. Данный посредник между приложением и базой данных является самым старым, и предоставляет наибольшую свободу при работе с данными. Что предоставляет нам данная технология? На самом деле не так много, он позволяет открыть соединение с базой, и возможность отправки запроса в базу, а дальше.. Ну делайте что хотите в общем, его это уже не касается. В связи с этим большая необходимость в том, чтобы самим отлавливать все исключения, и самим закрыть соединение с базой после выполнения запроса (да, для особо ленивых придумана конструкция using еще).
Основные классы, используемые для работы с ADO.NET:
SqlConnection
SqlCommand
SqlDataReader
SqlParameter
Так, напишем консольное приложение, для того, чтобы попробовать работу с ADO.NET:
Первое, что мы делаем - создаем объект класса SqlConnection, в который передаем connectionString, эта переменная в которой содержится строка подключения к нашей базе
В моем случае она будет такая:
var connectionString = "Data Source=.\\SQLEXPRESS;Initial Catalog=University;Integrated Security=True";
// Объявлем соединение с определенной строкой подключения. var sqlConnection = new SqlConnection(connectionString);
В дальнейшем можем приступать в работе с базой:
try { sqlConnection.Open(); Console.WriteLine("SQL соединение открыто."); // Добавление (аналогичный код для обновления / удаления). var sqlCommand = sqlConnection.CreateCommand(); sqlCommand.CommandText = "INSERT INTO Student VALUES ('TestUser', 1, '20220101')"; var affectedRows = sqlCommand.ExecuteNonQuery(); Console.WriteLine($"Число затронутых строк: {affectedRows}"); // Чтение. var sqlCommandForRead = sqlConnection.CreateCommand(); sqlCommandForRead.CommandText = "SELECT * FROM Student"; SqlDataReader reader = sqlCommandForRead.ExecuteReader(); if (reader.HasRows) { while (reader.Read()) { // При использовании reader[""] - мы получаем object, // если хотим конкретный тип, // то используем reader.GetString() / reader.GetInt() и т.д. Console.WriteLine($"Студент с Id: {reader["Id"]}, " + $"с курсом: {reader["Course"]}, " + $"с именем: {reader["Name"]}, " + $"с датой рождения: {reader["BirthDate"]}"); } } reader.Close(); // Получение результата агрегатной функции var sqlCommandForCount = sqlConnection.CreateCommand(); sqlCommandForCount.CommandText = "SELECT COUNT(*) FROM Student"; var count = sqlCommandForCount.ExecuteScalar(); Console.WriteLine($"Полное число студентов: {count}"); // Есть два варианта параметризации запросов. var name = "Some Student Name"; // Плохой вариант, так как позволяет получать и изменять данные //при помощи механизма SQL-инъекций. var sqlString = $"INSERT INTO Student VALUES ('{name}', 1, '20220101')"; var sqlCommandForInsertBadPractice = new SqlCommand(sqlString) { Connection = sqlConnection }; affectedRows = sqlCommandForInsertBadPractice.ExecuteNonQuery(); // Хороший вариант, добавление SQL параметров. sqlString = $"INSERT INTO Student VALUES (@name, 1, '20220101')"; var sqlParamForName = new SqlParameter("@name", name); var sqlCommandForInsertGoodPractice = new SqlCommand(sqlString); // Добавление параметра. sqlCommandForInsertGoodPractice.Parameters.Add(sqlParamForName); affectedRows = sqlCommandForInsertBadPractice.ExecuteNonQuery(); } catch (Exception ex) { Console.WriteLine($"Ошибка: {ex.Message}"); throw; } finally { sqlConnection.Close(); Console.WriteLine("SQL соединение закрыто."); }
В целом тут собрано несколько запросов, но надо отметить что основная идея тут одна:
Пишем конструкцию try catch finally
В try открываем соединение, в finally его закрываем
Затем происходит выбор: что нам нужно - чтение или изменение?
Так, в случае изменения данных создается экземпляр класса SqlCommand, затем указывается SQL запрос и выполняется метод ExecuteNonQuery(), который возвращает нам число затронутых строк:
var sqlCommand = sqlConnection.CreateCommand(); sqlCommand.CommandText = "INSERT INTO Student VALUES ('TestUser', 1, '20220101')"; var affectedRows = sqlCommand.ExecuteNonQuery(); Console.WriteLine($"Число затронутых строк: {affectedRows}");
В данном случае мы делаем вставку 1 записи, а значит на консоли увидим, что число затронутых строк также ровно 1.
В случае чтения данных необходимо создать также создать создать команду, однако вместо того, чтобы вызвать ExecuteNonQuery(), надо будет вызвать ExecuteReader(), который вернет нам экземпляр SqlDataReader.
var sqlCommandForRead = sqlConnection.CreateCommand(); sqlCommandForRead.CommandText = "SELECT * FROM Student"; SqlDataReader reader = sqlCommandForRead.ExecuteReader();
После получения данного экземпляра, проверяем, вернул ли он какие-то либо строки, и если вернул, то тогда начинаем их читать
if (reader.HasRows) { while (reader.Read()) { // При использовании reader[""] - мы получаем object, // если хотим конкретный тип, // то используем reader.GetString() / reader.GetInt() и т.д. Console.WriteLine($"Студент с Id: {reader["Id"]}, " + $"с курсом: {reader["Course"]}, " + $"с именем: {reader["Name"]}, " + $"с датой рождения: {reader["BirthDate"]}"); } }
После того, как мы выйдем из цикла while - обязательно закрываем reader.
reader.Close();
В случае необходимости получения данных путем вычисления агрегатной функции (COUNT, MIN, MAX, AVG, SUM) - применяют метод ExecuteScalar(), который возвращает первый столбец первой строки (чтоб в целом нам и нужно).
var sqlCommandForCount = sqlConnection.CreateCommand(); sqlCommandForCount.CommandText = "SELECT COUNT(*) FROM Student"; var count = sqlCommandForCount.ExecuteScalar(); Console.WriteLine($"Полное число студентов: {count}");
Теперь переходим наверное к самому интересному, а именно параметризация запросов.
В общем случае это можно сделать двумя способами: конкатенация строк и SQL-параметры, поговорим про каждый из этих методов по отдельности.
Пусть у нас будет переменная name, содержащая некоторую строку:
var name = "Some Student Name";
Интерполяция строк
К плюсам этого способа можно выделить более простой способ написания, который просто встраивает переменные в строку при помощи интерполяции строк
var sqlString = $"INSERT INTO Student VALUES ('{name}', 1, '20220101')"; var sqlCommandForInsertBadPractice = new SqlCommand(sqlString) { Connection = sqlConnection }; affectedRows = sqlCommandForInsertBadPractice.ExecuteNonQuery();
В чем минус этого метода? В тот, что входная строка никак не валидируется, а значит если внешний код никак об этом не позаботиться, то имеет место быть всякие SQL-инъекции, лишние добавления записей и т.д.
SQL-параметры
Тут ситуация гораздо лучше и не пропускает невалидные ситуации, которые могут быть в ситуации выше, однако приходится написать больше кода:
sqlString = $"INSERT INTO Student VALUES (@name, 1, '20220101')"; var sqlParamForName = new SqlParameter("@name", name); var sqlCommandForInsertGoodPractice = new SqlCommand(sqlString); sqlCommandForInsertGoodPractice.Parameters.Add(sqlParamForName); affectedRows = sqlCommandForInsertBadPractice.ExecuteNonQuery();
На этом основные возможности ADO.NET заканчиваются. В целом основная идея - следит за ошибками со стороны провайдера, и писать SQL код.
Dapper
Необходимо наличие следующих пакетов:
Dapper
System.Data.SqlClient
Много лишней теории тут говорить не буду. В целом Dapper - это посредник, которому всё еще нужен SqlConnection, однако открытие и закрытие уже будет автоматическим и в общем случае будет использоваться оператор using. Также один из важных плюсов Dapper - это сопоставление результатов запроса с классами C#, а значит не придется не придется делать страшные манипуляции с reader, как это было в случае с ADO.NET
Классы, используемые при работы с Dapper
SqlConnection
И используемые от него методы: .Query<T>() и .Execute().
Чтобы долго не тянуть - перейдем сразу к написанию консольного приложения по работе с Dapper:
using (var sqlConnection = new SqlConnection(connectionString)) { // Добавление (аналогичный код для обновления / удаления). sqlConnection.Execute( "INSERT INTO Student VALUES ('TestUserDapper', 1, '20220101')" ); // Чтение данных. var students = sqlConnection.Query<Student>("SELECT * FROM Student").ToList(); foreach (var student in students) { Console.WriteLine($"Студент с Id: {student.Id}, " + $"с курсом: {student.Course}, " + $"с именем: {student.Name}, " + $"с датой рождения: {student.BirthDate}"); } // Получение результата агрегатной функции // В данном случае необходимо использование .FirstOrDefault(), так как // .Query<T> возвращает IEnumerable<T>, что является коллекцией. // И так как мы знаем что результатом будет 1 запись, то без зазрений совести // можем применить .FirstOrDefault(), чтобы получить число записей. var count = sqlConnection.Query<int>("SELECT COUNT(*) FROM Student") .FirstOrDefault(); Console.WriteLine($"Общее число записей в таблице студентов: {count}"); // Использование параметров. sqlConnection.Execute("INSERT INTO Student VALUES (@Name, @Course, @BirthDate)", new Student { Name = "SomeParamName", Course = 2, BirthDate = new DateTime(2022, 04, 04) }); // Анонимные объекты new { }. sqlConnection.Execute("DELETE FROM Student WHERE Name = @name", new { name = "TestUserDapper" }); }
Даже сравнивания по объему кода уже видно, насколько Dapper проще в использовании.
В целом использование у Dapper следующее:
В конструкции using создать экземпляр SqlConnection с переданной в него строкой подключения
В зависимости от того, хотим ли получить данные, или их изменить - написать .Query<T> или .Execute
При чтении данных мы используем .Query<T>, где T - класс, в который будут мапиться результатами из базы. Так, вся работа которую мы делали руками, получая каждое значение каждой строки руками - Dapper делает за нас, и на выходе мы получаем IEnumerable<T>.
var students = sqlConnection.Query<Student>("SELECT * FROM Student").ToList(); // В случае единственного перечисления по коллекции students приведение к ToList() // является избыточным и сделано только в учебных целях.
Для получения результата агрегатной функции в общем случае используется также Query<T>, где в T передается тип данных (int, double, float и т.д), а затем берется первая запись из полученной коллекции, так как такая выборка на стороне базы возвращает 1 строку с 1 столбцом.
var count = sqlConnection.Query<int>("SELECT COUNT(*) FROM Student") .FirstOrDefault();
В случае, когда мы работаем с параметрами, мы можем передавать напрямую экземпляр класса, или анонимный объект.
Так, например, при добавлении записи мы можем написать следующую запись:
sqlConnection.Execute("INSERT INTO Student VALUES (@Name, @Course, @BirthDate)", new Student { Name = "SomeParamName", Course = 2, BirthDate = new DateTime(2022, 04, 04) });
Dapper сам произведет необходимый маппинг по имени. Если типы не соответствует - будет выброшено исключение.
И пример использования анонимного объекта, если не хотим создавать какой-то класс:
sqlConnection.Execute("DELETE FROM Student WHERE Name = @name", new { name = "TestUserDapper" });
На этом основные возможности Dapper заканчиваются. Он прост в использовании, и как посмотрим далее, достаточно производителен.
EntityFramework
Необходимо наличие следующих пакетов:
Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools (Не обязательный)
Я даже не знаю с чего начать, данный фреймворк является самый настоящим монстром, и обладает огромным количеством возможностей. О некоторых из них мы поговорим в этой статье, однако если будет необходимость - то EF можно обсудить более детально в отдельной статье.
Вот несколько сильных сторон от EF:
Поддержка разных способов синхронизации (Code First, Database First)
Миграции
LINQ To Entities (и расширения напрямую из пакета EntityFrameworkCore)
AsNoTracking
CRUD операции
Начнем с самого начала - EF является достаточно большой системой, которая требует много подготовительной работы, но сполна награждает за неё. Так, например, создадим все классы для нашей базы данных (Student, Department, Coursework):
public class Student { public int Id { get; set; } public int Course { get; set; } [StringLength(90)] public string Name { get; set; } public DateTime BirthDate { get; set; } } public class Department { public int Id { get; set; } [StringLength(90)] public string Name { get; set; } } public class Coursework { public int Id { get; set; } public int StudentId { get; set; } public int DepartmentId { get; set; } public DateTime DeliveryDate { get; set; } }
После создания данных классов (что в целом достаточно легко), нам необходимо создать её один класс - который обычно называется - НазваниеБазыContext, так в данном случае это будет UniversityContext.
public class UniversityContext : DbContext { public UniversityContext() { } public UniversityContext(DbContextOptions options) : base(options) { } public DbSet<Student> Student { get; set; } public DbSet<Department> Department { get; set; } public DbSet<Coursework> Coursework { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var dbConfig = new DbConfiguration(); optionsBuilder.UseSqlServer(dbConfig.GetConnectionString("connString")); } }
Тут уже немного сложнее, поэтому пройдемся более детально по этому классу.
Наличие DbSet<T>. В целом, для простого понимания - DbSet представляет собой коллекцию (но не в памяти, а удаленную), которая представляет каждую отдельную таблицу. Так, например, DbSet<Student> Student - говорит о том, что у нас "есть" таблица со столбцами такими как поля в классе Student, и название у такой таблицы Student.
Также есть переопределение метода OnConfiguring(DbContextOptionsBuilder optionsBuilder). Это нужно для того, чтобы определить, с какой базой данный будет связан наш контекст. В целом там гораздо больше различных настроек, но на данный момент это основная.
Немного про конструкторы: в данном случае их 2, хотя для наших целей достаточно и одного. В общем случае при разработке веб-приложений и использования механизма DI нам будет достаточно второго конструктора, который принимает параметры. Однако в данном кейсе мы делаем все настройки в методе OnConfiguring, поэтому нам достаточно просто создавать контекст с пустым конструктором. Но не всё так просто.
Есть одна необходимость - это миграции. Чуть ниже мы обсудим что это такое, но для того, чтобы это механизм работал - нам нужно сделать одно из двух условий:
Иметь конструктор без параметров
Иметь класс, который реализует интерфейс IDesignTimeDbContextFactory<T>, где T - наш контекст. Вот пример реализации этого интерфейса:
public class UniversityContextFactory : IDesignTimeDbContextFactory<UniversityContext> { public UniversityContext CreateDbContext(string[] args) { var dbConfig = new DbConfiguration(); var optionsBuilder = new DbContextOptionsBuilder<UniversityContext>(); optionsBuilder.UseSqlServer(dbConfig.GetConnectionString("connString")); return new UniversityContext(optionsBuilder.Options); } }
Миграции
Миграции - магическое слово EF и одна из самых сильных его сторон. Что такое миграции?
Это механизм, который позволяет нам создать некоторое подобие гита, только для базы данных. Фактически, внося изменения в какую-нибудь из моделей или контекст, вы “фиксируете” эти изменения и создаете миграцию. Она имеет два метода: Up и Down. Соответственно при помощи данных методов вы можете двигаться “вверх” или “вниз”. Немало важный плюс миграций - это то, что они не удаляют данные, когда накатываются на базу. Работа с миграциями всегда будет идти по циклу: внесли изменения в C# классы, создали миграцию, применили миграцию.
Для создания миграции нам нужно открыть Package Manager Console в Visual Studio и написать следующую команду:
Add-Migration <Название Миграции>
В результате выполнения этой команды появится 2 класса (только при первой миграции, потом будет 1 класс).
Один из классов - ModelSnapshot, которую является некоторым сборщиком миграций, и знает, в каком порядке они должны применяться.
Второй класс - это непосредственно наша миграция и имеет данный класс следующий вид:
public partial class Initial : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "Coursework", columns: table => new { Id = table.Column<int>(type: "int", nullable: false) .Annotation("SqlServer:Identity", "1, 1"), StudentId = table.Column<int>(type: "int", nullable: false), DepartmentId = table.Column<int>(type: "int", nullable: false), DeliveryDate = table.Column<DateTime>(type: "datetime2", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Coursework", x => x.Id); }); migrationBuilder.CreateTable( name: "Department", columns: table => new { Id = table.Column<int>(type: "int", nullable: false) .Annotation("SqlServer:Identity", "1, 1"), Name = table.Column<string>(type: "nvarchar(90)", maxLength: 90, nullable: false) }, constraints: table => { table.PrimaryKey("PK_Department", x => x.Id); }); migrationBuilder.CreateTable( name: "Student", columns: table => new { Id = table.Column<int>(type: "int", nullable: false) .Annotation("SqlServer:Identity", "1, 1"), Course = table.Column<int>(type: "int", nullable: false), Name = table.Column<string>(type: "nvarchar(90)", maxLength: 90, nullable: false), BirthDate = table.Column<DateTime>(type: "datetime2", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Student", x => x.Id); }); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "Coursework"); migrationBuilder.DropTable( name: "Department"); migrationBuilder.DropTable( name: "Student"); } }
Как и говорилось выше - миграция имеет два метода, один применяется, если мы применяем нашу миграцию (то есть идем "вверх"), а второй применяется, когда мы отменяем миграцию (то есть идем "вниз"). Так, в нашем примере мы создали миграцию с именем Initial, и при применении миграции у нас появятся три таблицы, а при отмене миграции - удалятся 3 таблицы.
Можете попробовать удалить все таблицы из базы (даже саму базу), и ввести в Package Manager Console следующую команду:
Update-Database
После этого можете удивляться результату.
На самом деле, Миграции являются частью принципа Code First, в котором мы пишем код, а потом говорим, что EF применил этот код для базы. Однако существует и второй принцип - Database First, который, по названию, означает, что сначала мы создаем базу, а потом только C# код. На самом деле реализуется это достаточно простым механизмом, что называется одной командой, которая имеет следующий вид, и вводится в всё тот же Package Manager Console:
Scaffold-DbContext "Server=(localdb)\mssqllocaldb;Database=University;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer
И так, как и я сказал, это один из самых важных и популярных механизмов, который предоставляет EF, но не единственный.
LINQ To Entities
Еще одна классная возможность EF - поддержка построения запросов без знания SQL, так как EF сам делает все преобразования C# кода в SQL.
Например, если вернёмся к тому SQL скрипту, который был в самом начале:
SELECT Student.Name, Student.BirthDate, COUNT(*) AS [Количество курсовых] FROM Student JOIN Coursework ON Student.Id = Coursework.StudentId WHERE Student.Name LIKE 'Vladzimir%' GROUP BY Student.Name, Student.BirthDate HAVING COUNT(*) > 1 ORDER BY BirthDate DESC
На языке LINQ это будет выглядеть так:
var sqlQueryToLinq = dbContext.Student .Where(student => student.Name.Contains("Vladzimir")) .Join(dbContext.Coursework, student => student.Id, coursework => coursework.StudentId, (student, coursework) => student) .GroupBy(student => new { student.Name, student.BirthDate }) .Where(grouped => grouped.Count() > 1) .OrderByDescending(grouped => grouped.Key.BirthDate) .Select(grouped => new { grouped.Key.Name, grouped.Key.BirthDate, Count = grouped.Count() }) .ToList();
Выглядит сложно для первого понимания, но в целом любая часть соответствует предложениям из SQL. А трактовка значка '=>' несколько сложная, если не понять что такое делегаты. Но если пояснение, то его можно посмотреть тут:
Пояснение по поводу LINQ запроса
// Обращаюсь к базе данных, в частности к таблице Student dbContext.Student // Из всех студентов выбрать такого студента, // у которого имя содержит "Vladzimir" .Where(student => student.Name.Contains("Vladzimir")) // Сделать объединение с таблицей Coursework .Join(dbContext.Coursework, // Со стороны таблицы Student связь по полю Id student => student.Id, // Со стороны таблицы Coursework связь по полю StudentId coursework => coursework.StudentId, // В результате объединения остальные данные только по студенту (student, coursework) => student) // Сделать группировку по имени и дате рождения .GroupBy(student => new { student.Name, student.BirthDate }) // Выбрать те записи группировки, // у которых результат группировки больше 1 // (это мы про количество курсовых говорили) .Where(grouped => grouped.Count() > 1) // Отсортировать записи по убыванию дня рождения .OrderByDescending(grouped => grouped.Key.BirthDate) // Из результата группировки создать коллекцию анонимных обьектов, // состоящих из имени, дня рождения и количество курсовых .Select(grouped => new { grouped.Key.Name, grouped.Key.BirthDate, Count = grouped.Count() }) // Команда выполнения запроса .ToList();
AsNoTracking
Еще одна возможность EntityFramework, о которой немного подробнее надо рассказать:
Внутри EntityFramework содержится много разных способ отслеживания изменений, кеширования и т.д.
Так вот один из явных инструментов является Change Tracker, который создает некоторую связь между данными из таблиц и объектами C#. Также, если нам это механизм кажется излишним, то мы его может отключить, при помощи метода AsNoTracking().
Рассмотрим такой пример для большей наглядности этого механизма:
var studentVladzimir = dbContext.Student .Where(student => student.Name.Contains("Vladzimir")) .FirstOrDefault(); studentVladzimir.Course = 999; dbContext.SaveChanges(); var studentVladzimirWithoutTracking = dbContext.Student .Where(student => student.Name.Contains("Vladzimir")) .AsNoTracking() .FirstOrDefault(); studentVladzimirWithoutTracking.Course = 777; dbContext.SaveChanges();
Что мы ожидаем увидеть в таблице, если найдем такую запись? Результат будет 999.
Почему так? Потому что когда мы получаем данные при помощи нашего контекста, то он не создаем полностью независимый объект, а ставит между ним и записью из базы связь, которая будет обновлять запись в базе при применении SaveChanges().
В случае применения AsNoTracking() - контекст не будет устанавливать связь, а просто создать новый объект, как будто сделали просто new Student().
CRUD-операции
Напоследок, небольшой бонус, который дает EF - готовые реализации для добавления, удаления и изменения записи.
Так, например, для добавления используется следующий код:
dbContext.Student.Add( new Student() { Name = "SomeStudent For EF Test", Course = 3, BirthDate = new DateTime(2022, 4, 7) });
Аналогичный код используется и для обновления (Update), и удаления (Remove).
Также, один из важных плюсов, поддержка - AddRange, UpdateRange, RemoveRange, они являются возможность делать bulk-операции.
Немного тестов и результаты
В целом, мы познакомились с основными провайдерами баз данных. На самом деле на проектах можно встретить каждый из этих провайдеров, а иногда и несколько сразу.
Я решил написать парочку тестов, в частности для получения данных, так как в целом любое изменение данных во всех провайдерах примерно одинаково.
Так, например, вот такие результаты показали провайдеры на 20000 данных.
Method | Time | Allocated Memory |
GetAll_EF_WithTracking | 51.79 ms | 22 MB |
GetAll_EF_WithNoTracking | 16.52 ms | 6 MB |
GetAll_ADO | 7.351 ms | 2 MB |
GetAll_Dapper | 13.07 ms | 4 MB |
Как видим, EF показываем не самые лучшие результаты, и по большей части это связано с тем, что мы всячески блокируем в тестах попытки кеша каких либо результатов. В реальных условиях ситуация будет такая, что разница между Dapper и EF может быть до 5%. Однако тяжеловесность EF показывает то, как много памяти он кушает.
В большинстве своем - получение такого числа данных - очень редкий кейс, и в среднем надо вытягивать от 1 до 100 записей за раз, и на таких данных разница во времени будет минимальна между ними.
Вот некоторые выводы к которым можно прийти, прочитав эту статью:
.NET предоставляет различные механизмы работы, и выбор достаточно внушительный, каждый из представленных механизмов отличается от двух других.
Знание SQL необходимо, но не обязательно. Влияние EntityFramework с годами увеличивается, как и его производительность.
Мы всегда смотрим не только на производительность, но и на то насколько быстро мы можем написать наш код. В таких случаях зачастую выбор остается между Dapper и EntityFramework.
На этом наверное всё, огромное спасибо за прочтение этой статьи!
Как и говорилось в начале, вы можете попробовать сделать всё сами, при помощи github-репозитория, который будет в открытом доступе.
