Omówienie projektu
Będziemy dzisiaj również pracować na projekcie Bookstore, na którym pracowaliśmy już w poprzednich materiałach (Wprowadzenie Do Entity Framework Core oraz 5 Najlepszych Praktyk z Entity Framework Core). W tym projekcie są już zainstalowane wymagane pakiety dla Entity Framework Core.
Kontekst:
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"]);
}
}
}
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; }
}
}
using System.Collections.Generic;
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:
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();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
}
}
1. Problem N+1
Na początek chciałem Ci pokazać Problem N+1, który jest połączony z Lazy Loading (leniwe ładowanie). Domyślnie mechanizm Lazy Loading jest zablokowany w Entity Framework Core, aby go włączyć, musimy zrobić kilka zmian w naszej aplikacji.
Wcześniej jednak chciałem Ci jeszcze pokazać, jak działa ładowanie elementów powiązanych. Pobierzemy teraz wszystkie nasze książki z bazy danych i później będziemy chcieli wyświetlić informacje o ich kategoriach. W takim przypadku w naszym kodzie zostanie rzucony wyjątek, ponieważ nasze kategorie będą null'ami.
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();
foreach (var item in books)
{
Console.WriteLine(item.Category.Name);//wyjątek
}
stopwatch.Stop();
}
Console.WriteLine($"Długość wykonania zapytania: {stopwatch.ElapsedMilliseconds} MS.");
}
Obecnie w bazie danych każda książka ma przypisaną 1 kategorię, jednak jeżeli chcemy się do kategorii odwołać w ten sposób, to zostanie rzucony wyjątek, ponieważ to nasze zapytanie nie pobiera żadnych informacji o obiektach powiązanych.
Jeżeli teraz włączymy mechanizm Lazy Loading, to nasz kod zadziała już prawidłowo, ale będziemy mieli wspomniany Problem N+1. Na początek, aby Ci pokazać jak działa mechanizm Lazy Loading, musimy go włączyć. W tym celu musimy zainstalować pakiet o nazwie: Microsoft.EntityFrameworkCore.Proxies przez NuGet'a.
To jest pierwszy krok. Następnie w klasie ApplicationDbContext w naszej konfiguracji musimy wywołać metodę UseLazyLoadingProxies().
optionsBuilder
.UseLazyLoadingProxies()
.UseLoggerFactory(_loggerFactory)
.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)
.EnableSensitiveDataLogging()
.UseSqlServer(config["ConnectionString"]);
A także oznaczyć wszystkie właściwości nawigacyjne słowem kluczowym virtual. Czyli kod encji będzie wyglądał w ten sposób:
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 virtual Category Category { get; set; }
}
}
using System.Collections.Generic;
namespace Bookstore
{
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public virtual ICollection<Book> Books { get; set; } = new HashSet<Book>();
}
}
Jeżeli teraz uruchomimy naszą aplikację, po włączeniu mechanizmy Lazy Loading, to zauważ, że wszystkie kategorie zostaną prawidłowo wyświetlone.
Jednak niestety, jak widzisz mamy tutaj problem. Dzięki temu, że logujemy sobie wszystkie zapytanie w konsoli, to możesz zauważyć, że za każdym razem, gdy chcemy wyświetlić nazwę kategorii, to wykonywany jest dodatkowy select na bazie danych, który pobiera właśnie informacji o kategorii z bazy danych, w sytuacji, gdy nie została ona jeszcze wcześniej wczytana. Czyli mamy najpierw zapytanie o wszystkie książki i to jest zapytanie, które zostanie wywołane w tym miejscu:
books = await context.Books.ToListAsync();
Oraz dodatkowo mamy n zapytań w każdej iteracji w pętli o nową kategorię.
foreach (var item in books)
{
Console.WriteLine(item.Category.Name);
}
Czyli jeżeli chcemy tutaj wyświetlić dodatkowe informacje o kategorii, to wykonywane jest dodatkowe zapytanie i to jest właśnie Problem N+1. Za każdym razem, gdy potrzebujemy wyświetlić nową kategorię, wykonywane jest dodatkowe zapytanie, czyli będziemy mieli tych zapytań n, to znaczy tyle ile jest iteracji plus pierwsze zapytanie o wszystkie książki.
Jak w takim razie można teraz poprawić to zapytanie? Zamiast mechanizmu Lazy Loading możemy w tym miejscu użyć mechanizmu Eager Loading (zachłanne ładowanie) i pobrać wszystkie kategorie w jednym zapytaniu. Wystarczy dodać w naszym pierwszym zapytanie wywołanie metody Include() i dołączyć od razu wszystkie kategorie.
books = await context.Books
.Include(x => x.Category)
.ToListAsync();
Jeżeli teraz uruchomisz aplikację, to zauważ, że zostało wykonane tylko 1 zapytanie. Tak naprawdę został wykonany od razu left join, który pobrał dla każdej książki informacje o jej kategorii. Dzięki temu, w pętli poniżej nie było potrzeby wykonywania za każdym razem nowej kwerendy.
Oczywiście mechanizm Lazy Loading może być w niektórych przypadkach przydatny, natomiast nie w tym konkretnym. W tym momencie odinstaluje sobie pakiet, który przed chwilą instalowaliśmy Microsoft.EntityFrameworkCore.Proxies. Usunę wywołanie metody UseLazyLoading() w metodzie konfiguracyjnej ApplicationDbContext oraz słowa kluczowe w encjach Book i Category. Na szczęście w Entity Framework Core mechanizm Lazy Loading jest domyślnie zablokowany.
2. SQL Injection
Drugim często spotykanym i bardzo groźnym problemem jest SQL Injection, z którym również, jeżeli nie znasz dobrze Entity Framework Core, to możesz mieć czasem problem. Pisząc zwykłe zapytania za pomocą składni metod (czyli tak jak mamy w naszym przypadku) jesteśmy zabezpieczeni przed atakami SQL Injection, ale w Entity Framework Core możemy również pisać własne SQL'e i tutaj musimy być uważni.
Przeanalizujmy teraz inne zapytanie. Zamiast pobierać książki za pomocą składni metody, to znaczy:
books = await context.Books
.ToListAsync();
Pobierzemy wszystkie książki z bazy danych za pomocą czystego SQL'a.
var name = "Książka 0";
books = await context.Books
.FromSqlRaw(
$"SELECT * FROM Books WHERE Name='{name}'")
.ToListAsync();
Załóżmy, że mamy zmienną name i jest to wartość przekazywana do zapytania przez użytkownika. Zazwyczaj to wygląda tak, że mamy jakieś pole tekstowe do filtrowania danych i tam użytkownik może sobie wpisać nazwę książki, która go interesuje i my później bezpośrednio wartość tej zmiennej przekazujemy do naszego zapytania. Poniżej wyświetlimy sobie nazwy książek.
foreach (var item in books)
{
Console.WriteLine(item.Name);
}
Teraz uruchomię aplikację i zobacz na wynik.
Niby wszystko wydaje się ok, został zwrócony odpowiedni rekord, zapytanie też wydaje się w porządku, ale zobacz, co jest przekazywane jako nazwa. Jest tam bezpośrednio właśnie wklejana wartość naszej zmiennej name. To znaczy, jeżeli użytkownik będzie chciał przeprowadzić atak SQL Injection, to wystarczy, że poda taką bardziej dostosowaną wartość, które zostanie bez żadnych zabezpieczeń przekazana do zapytania. Wystarczy, że użytkownik poda taką wartość:
var name = "Książka 0' OR ''='";
I tak naprawdę zostanie wykonane takie zapytanie:
SELECT * FROM Books WHERE Name='Książka 0' OR ''=''
Które zawsze będzie prawdą, w związku z tym zostaną zwrócone wszystkie rekordy z tabeli Books.
Zauważ, że faktycznie tak się stało. Do zapytania została wklejona wartość, którą wpisał użytkownik i zostały z bazy danych zwrócone wszystkie rekordy. Oczywiście takie ataki mogą być bardzo niebezpieczne i musimy na nie uważać, ponieważ możemy udostępnić wszystkie dane niepowołanym osobom.
Jak w takim razie powinno wyglądać to zapytanie? Możesz je napisać na 2 sposoby. W naszym przypadku zamiast metody FromSqlRaw() możemy użyć metody FromSqlInterpolated(). Apostrofy w zapytaniu wtedy możemy usunąć, ponieważ używamy interpolacji string'ów i jeżeli teraz uruchomimy aplikację, to zauważ, że żaden rekord nie został zwrócony.
var name = "Książka 0' OR ''='";
books = await context.Books
.FromSqlInterpolated(
$"SELECT * FROM Books WHERE Name={name}")
.ToListAsync();
Parametr został zabezpieczony i został prawidłowo przekazany do zapytania. Jeżeli w zapytaniu używamy FromSqlInterpolated(), to jak najbardziej możemy budować zapytanie poprzez interpolację string'ów i wtedy jesteśmy zabezpieczeni przed atakami SQL Injection. Jeżeli zostanie przekazana prawidłowa wartość, czyli na przykład Książka 0, to zostanie prawidłowo zwrócony ten 1 rekord, którego szukamy.
var name = "Książka 0";
Drugim sposobem jest używanie FromSqlRaw() i łączenie go z string format (a nie tak jak wcześniej z interpolacją stringów).
var name = "Książka 0' OR ''='";
books = await context.Books
.FromSqlRaw(
"SELECT * FROM Books WHERE Name={0}", name)
.ToListAsync();
Jak widzisz, tym razem parametr został przekazany prawidłowo i nie został zwrócony żaden rekord z bazy danych. Jeżeli natomiast przekazałbym prawidłowo nazwę książki, to odpowiedni rekord zostanie zwrócony.
var name = "Książka 0";
To są 2 dobre sposoby przekazywania parametrów do czystych SQL'i w Entity Framework Core. Pamiętaj również, żeby nie przekazywać bezpośrednio całego wcześniej zbudowanego SQL'a do metody FromSqlRaw().
var name = "Książka 0' OR ''='";
var sql = $"SELECT * FROM Books WHERE Name='{name}'";
books = await context.Books
.FromSqlRaw(sql)
.ToListAsync();
W takim przypadku parametry również zostaną przekazane bez żadnego zabezpieczenia do zapytania i będą możliwe ataki SQL Injection.
3. Nieznajomość IQueryable oraz IEnumerable
Trzecim często popełnianym błędem przez początkujących jest nieznajomość IQueryable oraz IEnumerable, przez co są wtedy robione wolniejsze zapytania. Jeżeli chcesz stosować filtrowanie danych, to w takim przypadku powinieneś bazować na interfejsie IQueryable, który zastosuje wszystkie filtry jeszcze po stronie serwera. Jeżeli zastosujesz interfejs IEnumerable, to doklejane filtry zostaną wykonane na kolekcji w pamięci. To znaczy, najpierw zostaną pobrane wszystkie rekordy z bazy danych, a filtry wykonają się na całej kolekcji w pamięci.
Zobacz na taki przykład. Załóżmy, że chcemy pobrać wszystkie książki z kategorią o Id równym 1, czyli teraz mamy w bazie danych 1 taki rekord. Możemy to zrobić na kilka sposobów, to znaczy możemy wynik przypisać do zmiennej typu IQueryable:
var categoryId = 1;
IQueryable<Book> books = context.Books;
Dzięki temu, w tym momencie to zapytanie nie zostanie jeszcze wykonane na bazie danych, zostanie ono dopiero wykonane przy pierwszym odwołaniu do tych książek. Także, jeżeli będę sobie chciał dodać jeszcze kolejne filtrowanie. Na przykład chciałbym, żeby w przypadku gdy użytkownik wybrał kategorię, to ten filtr został uwzględniony w zapytaniu.
var categoryId = 1;
IQueryable<Book> books = context.Books;
if (categoryId > 0)
books = books.Where(x => x.CategoryId == categoryId);
foreach (var item in books)
{
Console.WriteLine(item.Name);
}
Czyli tutaj operujemy na interfejsie IQueryable, dzięki czemu wszystkie filtry, które są zastosowane przed odwołaniem do tych książek, zostaną wykonane na bazie danych, tylko 1 raz. Zobacz jak wygląda to zapytanie w konsoli po uruchomieniu aplikacji.
Zostało wykonane tylko 1 zapytanie na bazie danych już z zastosowaniem filtra. Jeżeli byłoby więcej filtrów, to tak samo wszystkie zostałyby zastosowane do pierwszego zapytania.
Jeżeli natomiast zamiast intefejsu IQueryable pracowalibyśmy na interfejsie IEnumerable, to w takim przypadku najpierw zostałyby pobrane wszystkie rekordy z bazy danych, a dopiero później pozostałe filtry zostałyby zastosowane i wykonane już na całej kolekcji w pamięci.
var categoryId = 1;
IEnumerable<Book> books = context.Books;
if (categoryId > 0)
books = books.Where(x => x.CategoryId == categoryId);
foreach (var item in books)
{
Console.WriteLine(item.Name);
}
Jak widzisz, zostało wykonane 1 zapytanie na bazie danych bez żadnych filtrów, a dopiero później filtry zostały zastosowane na kolekcji w pamięci (dlatego nie widzimy tego w konsoli) i został zwrócony tylko 1 prawidłowy rekord.
Podsumowując, jeżeli chcemy budować filtrowanie w ten sposób, czyli rozłożone na więcej linii kodu, to wtedy warto zastosować interfejs IQueryable i przefiltrować wszystkie dane jeszcze po stronie serwera.
Jeżeli w naszym zapytaniu wykonałbyś ToListAsync(), to również wtedy to zapytanie zostanie wykonane w tym momencie na bazie danych i ewentualne późniejsze filtry zostaną wykonane na kolekcji w pamięci.
var categoryId = 1;
var books = await context.Books.ToListAsync();
if (categoryId > 0)
books = books.Where(x => x.CategoryId == categoryId).ToList();
foreach (var item in books)
{
Console.WriteLine(item.Name);
}
Co nie zmienia faktu, że jeżeli dodamy wszystkie filtry na pierwszym zapytaniu i nawet zastosujemy IEnumerable, czy Listę, to w takim przypadku oczywiście wszystkie filtry zostaną zastosowane prawidłowo.
var categoryId = 1;
var books = await context.Books
.Where(x => x.CategoryId == categoryId)
.ToListAsync();
foreach (var item in books)
{
Console.WriteLine(item.Name);
}
Problem pojawia się dopiero w momencie rozbijania tych filtrów na kolejne instrukcje.
4. Nieużywanie transakcji, gdy jest to konieczne
Czwarty często popełniany błąd to nieużywanie transakcji, gdy jest to konieczne. Wyobraź sobie taką sytuację, że chcesz dodać zamówienie z pozycjami. Powiedzmy, że takie zamówienie bez pozycji nie ma sensu. A co w sytuacji, gdy nagłówek zamówienia zostanie dodany, a przy próbie dodania pozycji zostanie rzucony wyjątek? Wtedy będzie to zamówienie bez pozycji. Aby zabezpieczyć się przed taką sytuacją, powinieneś dodawać wszystkie te dane w pojedynczej transakcji. Jeżeli zostanie rzucony wyjątek przy dodawaniu pozycji, to żadne zmiany nie zostaną zapisane w bazie danych i to jest właśnie dobre rozwiązanie.
Zobaczmy, jak to może wyglądać w naszym przypadku. Spróbujmy sobie dodać najpierw kategorię, następnie zapisać zmiany w bazie danych. Później dodajmy książkę i ponownie zapisujemy zmiany w bazie danych.
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
var categoria = new Category { Name = "Kat. X" };
context.Categories.Add(categoria);
await context.SaveChangesAsync();
var book = new Book { Name = "K. X", CategoryId = categoria.Id, Price = 999 };
context.Books.Add(book);
await context.SaveChangesAsync();
stopwatch.Stop();
}
Załóżmy, że teraz interesuje nas sytuacja, gdzie kategoria powinna zostać dodana tylko w takim przypadku, gdy również zostanie dodana książka. Jednak my tutaj mamy 2 osobne transakcje.
To jest pierwsza transakcja:
context.Categories.Add(categoria);
await context.SaveChangesAsync();
A to jest druga transakcja:
context.Books.Add(book);
await context.SaveChangesAsync();
Czyli jeżeli w tej naszej drugiej transakcji wystąpi jakiś wyjątek, to i tak pierwsza transakcja zostanie zapisana na bazie danych.
Możemy sobie to sprawdzić, dodamy sztuczny wyjątek w drugiej transakcji.
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
var categoria = new Category { Name = "Kat. X" };
context.Categories.Add(categoria);
await context.SaveChangesAsync();
var book = new Book { Name = "K. X", CategoryId = categoria.Id, Price = 999 };
context.Books.Add(book);
throw new Exception("invalid data");
await context.SaveChangesAsync();
stopwatch.Stop();
}
Jak możesz zauważyć, kategoria została dodana do bazy danych, a książka (przez to, że został rzucony wyjątek) nie została dodana do bazy danych, czyli niestety jest to sytuacja, przed którą chcieliśmy się zabezpieczyć.
Jeżeli chcemy, aby zostały zapisane wszystkie zmiany, albo żadne, to wtedy cały kod musimy wykonać w 1 transakcji. Także metoda SaveChangesAsync() powinna zostać wywołana tylko 1 raz po wykonaniu wszystkich zmian na bazie.
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
var categoria = new Category { Name = "Kat. X" };
context.Categories.Add(categoria);
var book = new Book { Name = "K. X", Category = categoria, Price = 999 };
context.Books.Add(book);
throw new Exception("invalid data");
await context.SaveChangesAsync();
stopwatch.Stop();
}
Jeżeli teraz uruchomimy naszą aplikację, to zostanie rzucony ten nasz sztuczny błąd i żadne zmiany nie zostaną zapisanie na bazie danych i właśnie o to nam chodziło.
Dopiero, jeżeli usuniemy wyjątek, to wszystkie zmiany zostaną zapisane na bazie danych. Zostanie dodana kategoria i książka.
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
var categoria = new Category { Name = "Kat. X" };
context.Categories.Add(categoria);
var book = new Book { Name = "K. X", Category = categoria, Price = 999 };
context.Books.Add(book);
await context.SaveChangesAsync();
stopwatch.Stop();
}
Podsumowując, jeżeli chcemy być zabezpieczenie przed taką sytuacją, to znaczy potrzebujemy dodać zarówno kategorię, jak i książkę, nie interesuje nas scenariusz, gdzie podczas wystąpienia jakiegoś wyjątku zostanie dodana tylko sama kategoria, to musimy całość wykonać w 1 transakcji.
5. Niestosowanie projekcji danych
Piąty i ostatni błąd początkujących, na który chciałem zwrócić uwagę w tym odcinku to pobieranie niepotrzebnych danych, to znaczy niestosowanie projekcji danych.
Jeżeli napiszemy takie zapytanie, to pobierze ono wszystkie kolumny z tabeli books.
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
var books = await context.Books.ToListAsync();
stopwatch.Stop();
}
W niektórych sytuacjach oczywiście takie zapytanie będzie prawidłowe, bo może będziemy potrzebować tych wszystkich danych, ale jeżeli potrzebujemy tylko np. nazwy tych książek, to warto w takich przypadkach zastosować projekcję danych, dzięki czemu to zapytanie będzie znacząco szybsze, tym bardziej, jeżeli tych danych będzie bardzo dużo.
Także, aby pobrać samą nazwę tych książek, wystarczy wywołać metodę Select() i wskazać, że interesuje nas tylko kolumna z nazwą tych książek.
using (var context = new ApplicationDbContext())
{
stopwatch.Start();
var books = await context.Books
.Select(x => x.Name)
.ToListAsync();
stopwatch.Stop();
}
Zauważ, że teraz na bazie danych został wykonany Select, który pobiera tylko nazwę tych książek, dzięki temu to zapytanie będzie dużo szybsze, niż jeżeli pobieralibyśmy wszystkie kolumny z tej tabeli. Także zawsze warto pobierać tylko takie dane, których faktycznie w danym przypadku potrzebujemy, a nie na siłę pobierać wszystko z bazy danych.
PODSUMOWANIE
To tyle najczęstszych popełnianych błędów przez początkujących związanych z Entity Framework Core. Zwróć na nie uwagę i na pewno wtedy Twoje zapytania będą lepsze. Pamiętaj przede wszystkim o:
- Problemie N+1.
- SQL Injection.
- Rozróżnianiu interfejsów IQueryable i IEnumerable.
- Używaniu transakcji, gdy jest to potrzebne.
- Pobieraniu tylko danych, których faktycznie potrzebujesz.
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ł - 5 Najlepszych Praktyk z Entity Framework Core.
Następny artykuł - Programowanie – 10 Pomysłów Na Aplikację Do Portfolio Przed Rozmową Kwalifikacyjną.