Omówienie projektu
Będziemy dzisiaj również pracować na projekcie z poprzedniego materiału (Wprowadzenie Do Entity Framework Core), to znaczy Bookstore. W tym projekcie są już zainstalowane wymagane pakiet dla Entity Framework Core.
Kontekst:
namespace Bookstore
{
class ApplicationDbContext : DbContext
{
public DbSet<Book> Books { get; set; }
public DbSet<Category> Categories { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var builder = new ConfigurationBuilder()
.AddJsonFile($"appsettings.json", true, true);
var config = builder.Build();
optionsBuilder
.UseSqlServer(config["ConnectionString"]);
}
}
}
Encje:
namespace Bookstore
{
public class Book
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int? CategoryId { get; set; }
public Category Category { get; set; }
}
}
namespace Bookstore
{
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Book> Books { get; set; } = new HashSet<Book>();
}
}
Klasa główna aplikacji konsolowej:
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
}
}
}
1. Sprawdzanie wygenerowanych przez Entity Framework Core zapytań
Dobrą praktyką jest sprawdzanie wygenerowanych przez Entity Framework Core zapytań. Można to zrobić na kilka sposobów. Najprościej, możesz wyświetlać te zapytania w konsoli. Wystarczy w konfiguracji, czyli w naszym przypadku w klasie ApplicationDbContext wywołać 2 dodatkowy metody. Po pierwsze, metodę LogTo, gdzie jako parametry przekazujemy Console.WriteLine, a także informacje o danych, które chcemy logować.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var builder = new ConfigurationBuilder()
.AddJsonFile($"appsettings.json", true, true);
var config = builder.Build();
optionsBuilder
.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)
.UseSqlServer(config["ConnectionString"]);
}
A także, drugą metodę EnableSensitiveDataLogging(), dzięki której będą również wyświetlane wszystkie szczegółowe, bardziej wrażliwe dane, między innymi wartości parametrów itp.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var builder = new ConfigurationBuilder()
.AddJsonFile($"appsettings.json", true, true);
var config = builder.Build();
optionsBuilder
.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)
.EnableSensitiveDataLogging()
.UseSqlServer(config["ConnectionString"]);
}
Teraz po wywołaniu zapytania na bazie danych za pomocą Entity Framework Core, zostanie ono wyświetlone w naszej konsoli.
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var books = new List<Book>();
using (var context = new ApplicationDbContext())
{
books = await context.Books.ToListAsync();
}
foreach (var item in books)
{
Console.WriteLine($"Id: {item.Id}. Książka: '{item.Name}' - {item.Price:0.00} PLN.");
}
}
}
}
Oprócz tego możesz te zapytania podglądać w SQL Server Profiler, a nawet możesz je zapisywać do pliku. W tej aplikacji konsolowej mam już zainstalowany framework do logowania danych NLog i możemy teraz łatwo skonfigurować logowanie wszystkich zapytań do pliku przez Entity Framework Core. Jeżeli nie wiesz jak zainstalować NLog, to odsyłam Cię do tego artykułu NLog, gdzie pokazywałem krok po kroku jak to zrobić. Także na początek w klasie, w której definiujemy konfigurację, potrzebuje zainicjalizować fabrykę logger i następnie przekazać ją w konfiguracji jako parametr do metody UseLoggerFactory(). Całość będzie wyglądać w ten sposób:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NLog.Extensions.Logging;
using System;
namespace Bookstore
{
class ApplicationDbContext : DbContext
{
public static readonly ILoggerFactory _loggerFactory = new NLogLoggerFactory();
public DbSet<Book> Books { get; set; }
public DbSet<Category> Categories { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var builder = new ConfigurationBuilder()
.AddJsonFile($"appsettings.json", true, true);
var config = builder.Build();
optionsBuilder
.UseLoggerFactory(_loggerFactory)
.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)
.EnableSensitiveDataLogging()
.UseSqlServer(config["ConnectionString"]);
}
}
}
Jeżeli teraz uruchomisz aplikację, to w pliku powinien pojawić się mniej więcej taki zapis:
Także w zależności od tego, co potrzebujesz, możesz wybrać dowolną opcję. Warto sobie dodać takie zapisywanie zapytań do swojej aplikacji.
2. Monitorowanie długości wykonywania zapytań
Drugą dobrą praktyką jest monitorowanie również długości wykonywania zapytań. Możesz dodać takie monitorowanie tylko do pojedynczych zapytań, ale jeszcze lepiej zrobić sobie jakiegoś wrappera, który będzie w ten sposób monitorował wszystkie zapytania w Twojej aplikacji. W celu zweryfikowania długości zapytania skorzystamy z instancji klasy Stopwatch:
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var stopwatch = new Stopwatch();
var books = new List<Book>();
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
books = await context.Books.ToListAsync();
stopwatch.Stop();
}
foreach (var item in books)
{
Console.WriteLine($"Id: {item.Id}. Książka: '{item.Name}' - {item.Price:0.00} PLN.");
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
Najpierw inicjalizujemy nowy obiekt stopwatch. Następnie bezpośrednio przed zapytaniem wywołujemy metodę Start() na tym obiekcie, a po wykonaniu zapytania metodę Stop(). Dzięki temu możemy obliczyć czas wykonania zapytania i wyświetlić go np. w Milisekundach poniżej.
3. Blokowanie śledzenia obiektów w zapytaniach tylko do odczytu
Trzecią dobrą praktyką jest blokowanie śledzenia obiektów (czyli change tracking) dla zapytań tylko do odczytu. Jeżeli wykonujemy dowolne zapytanie na bazie danych w Entity Framework Core, to domyślnie pobrane obiekty są śledzone. Możemy to wyłączyć globalnie dla wszystkich zapytań, ale lepszą praktyką jest blokowanie tego śledzenia dla zapytań read only. Aby to zrobić, wystarczy wywołać metodę AsNoTracking() na naszym zapytaniu.
books = await context.Books
.AsNoTracking()
.ToListAsync();
Dzięki temu nasze zapytanie przede wszystkim będzie dużo szybsze. W szczególności można to zauważyć na większych zapytaniach, gdzie jest więcej danych. Także, jeżeli wiesz, że dane zapytanie jest tylko do odczytu. To znaczy, tylko chcesz wyświetlić użytkownikowi jakieś dane do przeglądu, nie będziesz na nich robił żadnych zmian, to najlepszą praktyką jest nieblokowanie śledzenia obiektów.
4. Filtrowanie danych po stronie serwera
Czwarta dobra praktyka to filtrowanie danych po stronie serwera. Czasem spotykam się z zapytaniami, które najpierw pobierają wszystkie rekordy z bazy danych, a dopiero później są filtrowane w pamięci. Zobacz na ten przykład:
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var stopwatch = new Stopwatch();
var books = new List<Book>();
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
books = await context.Books
.AsNoTracking()
.ToListAsync();
books = books
.Where(x => x.Name == "Książka 1")
.ToList();
stopwatch.Stop();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
Czyli najpierw pobieramy sobie wszystkie dane, a następnie filtrujemy je po nazwie. Takie filtrowanie jak najbardziej zadziała, ale tak jak wspomniałem, z bazy danych zostaną pobrane najpierw wszystkie rekordy, a dopiero później zostaną one dodatkowo przefiltrowane w pamięci. Oczywiście takie zapytanie będzie dużo wolniejsze od tego, gdy przeprowadzimy takie filtrowanie od razu na bazie danych, a nie na kolekcji w pamięci.
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var stopwatch = new Stopwatch();
var books = new List<Book>();
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
books = await context.Books
.AsNoTracking()
.Where(x => x.Name == "Książka 1")
.ToListAsync();
stopwatch.Stop();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
Jeżeli zapytanie wywołamy w ten sposób, to na bazie danych zostanie wywołane jedno zapytanie, które od razu będzie filtrowało wszystkie rekordy już po stronie serwera. To zapytanie będzie wtedy dużo szybsze (również jeszcze bardziej to będzie zauważalne przy większej ilości danych). Tak będzie wyglądać zapytanie:
5. Minimalizowanie wysyłanych komend do bazy danych
Piątą dobrą praktyką, o której chciałbym wspomnieć, to minimalizowanie wysyłanych komend do bazy danych. Szczególnie dotyczy to dodawania wiele rekordów w pętli za pomocą metody Add() lub za pomocą AddRange(), obie wersje są złe. Zobacz na ten przykład:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var stopwatch = new Stopwatch();
var books = new List<Book>();
for (int i = 0; i < 10000; i++)
{
books.Add(new Book { Name = $"Książka {i}", Price = 120 });
}
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
foreach (var item in books)
{
context.Books.Add(item);
}
await context.SaveChangesAsync();
stopwatch.Stop();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
Jak możesz zauważyć, na bazie danych zostaje wykonanych mnóstwo zapytań, te dane są dodawane partiami, także trwa to bardzo długo i nie jest zbyt optymalne.
Możesz również skorzystać z metody AddRange, ale jak się za chwilę przekonasz, nie jest to wcale dużo lepsze rozwiązanie.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var stopwatch = new Stopwatch();
var books = new List<Book>();
for (int i = 0; i < 10000; i++)
{
books.Add(new Book { Name = $"Książka {i}", Price = 120 });
}
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
context.Books.AddRange(books);
await context.SaveChangesAsync();
stopwatch.Stop();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
Jak widzisz, zapytanie jest już trochę szybsze, ale tylko trochę. Dalej te dane są przekazywane partiami, co prawda w trochę bardziej optymalny sposób, ale mnóstwo komend jest wywoływanych na bazie danych. Zarówno metoda Add oraz AddRange nie jest optymalna.
W celu dodania wielu rekordów, najlepiej skorzystać z jednego z wielu dostępnych darmowych rozszerzeń do Entity Framework Core, na przykład takiego o nazwie EFCore.BulkExtensions. Tutaj jest dostępna cała dokumentacja: EFCore.BulkExtensions . Wystarczy tylko zainstalować pakiet przez nugeta: EFCore.BulkExtensions.
Następnie możesz wywołać komendę w ten sposób:
using EFCore.BulkExtensions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Bookstore
{
class Program
{
static async Task Main(string[] args)
{
var stopwatch = new Stopwatch();
var books = new List<Book>();
for (int i = 0; i < 10000; i++)
{
books.Add(new Book { Name = $"Książka {i}", Price = 120 });
}
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
await context.BulkInsertAsync(books);
stopwatch.Stop();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
Jak widzisz, zapytanie jest dużo szybsze, całe zapytanie jest bardziej optymalne i nie wykonuje oddzielne tylu komend. Dzięki temu pakietowi również możemy w podobny sposób aktualizować i usuwać wiele danych w Entity Framework Core.
PODSUMOWANIE
Przedstawiłem Ci w tym materiale 5 moim zdaniem najlepszych praktyk przy pracy z Entity Framework Core. Pamiętaj, żeby:
- Sprawdzać wygenerowane zapytania.
- Monitorować długość wykonywania zapytań.
- Zablokować śledzenie obiektów dla zapytań tylko do odczytu.
- Filtrować dane już po stronie serwera.
- Minimalizować wysyłanie komend do bazy danych
Jeżeli taki artykuł Ci się spodobał, to koniecznie dołącz do mojej społeczności. Zapisz się na darmowy newsletter, gdzie co tydzień dzielę się wartościowymi materiałami w szczególności dotyczącymi C# i platformy .NET (darmowy zapis – newsletter).
Poprzedni artykuł - Wprowadzenie Do Entity Framework Core – Współpraca z Bazą Danych w C#.
Następny artykuł - 5 Najczęstszych Błędów w Entity Framework Core.