Все материалы, которые будут показываться в ходе данной статьи будут доступны по данной ссылке. Вполне возможно, что со временем данный репозиторий будет обновляться, или, некоторые захотят сами принять участие в его развитии.
Можно ли сегодня представить разработку, будь то десктопы или веб, без использования баз?
Ну, чисто в теории можно, есть еще старенькие проекты, использующие файловую систему, идею которых можно еще увидеть в университетских лабораторных по сей день.
В чем же так плоха файловая система? Ну на самом деле, говоря на своем опыте, можно выделить следующие пункты:
Блокировка файла, в который идет запись
Отсутствие специализированных программ для работы с файлами (аналог СУБД)
Да, в какой-то мере можно выделить еще минусы, или попытаться закрыть уже названные мной. Но в целом главная идея базы данных – это удобство для чтения данных, а также наличие огромного числа инструментов для работы с данными (возможность быстрого поиска по полям таблицы, соединение таблиц, группировка записей, индексирование и т.д.)
Будем считать, что я смог в какой-то мере убедить, или хотя бы заинтриговать тем, что базы – это крутой механизм, который надо знать и уметь использовать.
Говоря о базах, я упомянул понятие запросов. В общих чертах запрос – это команда, которую ты говоришь выполнить базе. Запросы пишутся на языке 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-репозитория, который будет в открытом доступе.