Jak zacząć z EntityFramework (core)
Wstęp
Witaj podróżniku. Skoro już tu jesteś, prawdopodobnie wiesz czym jest EntityFramework. Jeśli jednak nie, to przeczytaj kolejny akapit. W tym artykule postaram szybko wprowadzić Cię w jego świat, żebyś w ciągu kilku minut był w stanie zacząć z nim wspólną przygodę.
Czym jest Entity Framework? – rozwiń jeśli nie wiesz
EntityFramework Core zwany również EfCore to narzędzie typu ORM – Object Relational Mapper. Prostymi słowami to mechanizm, który umie zamienić (mapować) rekordy znajdujące się w bazie danych, na obiekty modelowe. Po co to? To jest zwykły „pomagacz”. Bo oczywiście możesz napisać w starym dobrym ADO.NET kod w stylu:
//pseudokod
var resultList = new List<Employee>();
string sql = "SELECT * from employees";
var reader = _db.OpenQuery(sql);
while(reader.Read())
{
var employee = new Employee();
employee.Id = reader.GetGuid("id");
employee.Name = reader.GetString("name");
employee.Salary = reader.GetDecimal("salary");
resultList.Add(employee);
reader.Next();
}
return resultList;
ale ostatecznie większość programistów, pisząc w ADO.NET i tak kończyła z jakąś ubogą formą własnego ORMa.
Dlatego powstały właśnie takie narzędzia, żeby ułatwić pracę. Programista wie jak ma pobrać dane i wie jaki obiekt chce zwrócić. Nie musi się zajmować czarną robotą w stylu mapowania tak jak wyżej.
Jest sporo różnych ORMów na rynku. Myślę, że najpopularniejsze z nich (przynajmniej w mojej subiektywnej ocenie) to Dapper, nHibernate i właśnie EfCore. Przy czym Dapper zalicza się do grupy tzw. micro orm. Dlatego, że nie potrafi tyle, co EfCore, czy nHibernate. Wciąż musisz pisać własnego SQLa, ale za to dostajesz obiekt już elegancko zmapowany. No i Dapper jest zdecydowanie szybszy.
Kwestia nazwy
Pewnie zobaczysz nie raz obok siebie nazwy w stylu „Entity Framework”, „EfCore”, „Entity Framework Core”. Czy to jest ten sam produkt? Nie do końca. Jeśli chodzi o „Entity Framework” jest on używany w .NET Framework (czyli w tej starszej technologii przed .NET Core). Natomiast „EfCore”, czy po prostu „Entity Framework Core” to wersja dla .Net Core i nowszych. Czasem możesz widzieć te nazwy zamiennie. W tym artykule, pisząc „Ef”, „Entity Framework” zawsze będę miał na myśli „EfCore”.
Kwestia prędkości
Oczywiście, że ORMy są wolniejsze niż ręczne mapowanie. Jednak to zwolnienie w większości przypadków jest niezauważalne. Jeśli jednak faktycznie w niektórych miejscach potrzebujesz większej szybkości, możesz tylko te miejsca traktować inaczej – bez ORMa.
Instalacja narzędzi EfCore
Na początek sprawdź, czy masz już zainstalowane narzędzia do EF. Wklep taką komendę do command line’a:
dotnet ef --version
Jeśli Ci się wykrzaczyło, znaczy że nie masz. W takim przypadku zainstaluj:
dotnet tool install --global dotnet-ef
Powyższe polecenie zainstaluje globalnie narzędzia do EF, a poniższe zaktualizuje do najnowszej wersji:
dotnet tool update --global dotnet-ef
Przykładowa aplikacja
Jak zwykle, przygotowałem małą przykładową aplikację do tego artykułu. Możesz ją pobrać z GitHuba.
Aplikacja to prosty programik w stylu ToDoList. W ramach ćwiczeń polecam Ci go wykończyć.
Wymagane NuGety:
EfCore nie jest standardową biblioteką. Musisz pobrać sobie minimum dwa NuGety:
Microsoft.EntityFrameworkCore
– cały silnik dla EfCoreMicrosoft.EntityFrameworkCore.Design
– to jest zestaw narzędzi potrzebny programiście podczas „projektowania” aplikacji. Są to narzędzia, które np. pomagają utworzyć migracje (o tym później). Nie są jednak dostępne w czasie działania aplikacji (runtime). Więc jeśli nie będziesz robił migracji, nie musisz tego instalować. Ale lepiej mieć, niż nie mieć.- Biblioteka do obsługi konkretnego typu bazy danych (DBMS) – inna jest dla MSSQL, inna dla Sqlite, inna dla Oracle itd. Ich nazwy określają do jakiej bazy danych się odnoszą, np:
Microsoft.EntityFrameworkCore.SqlServer
– do obsługi MSSQL.
Modele bazodanowe
Na początek stwórzmy sobie podstawowy model bazodanowy do aplikacji.
public enum ToDoItemStatus
{
NotStarted,
Running,
Done
}
public class ToDoItem: BaseDbItem
{
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
Mamy tutaj zwykły ToDoItem
, który może być w 3 stanach:
- Nierozpoczęty (NotRunning)
- Rozpoczęty (Running)
- Zakończony (Done)
Zauważysz też, że mój model dziedziczy po BaseDbItem
. To zwykła klasa abstrakcyjna, która zawiera Id:
public abstract class BaseDbItem
{
public Guid Id { get; set; }
}
Oczywiście nie musisz jej tworzyć i możesz umieścić w każdym modelu pole Id. Ja jednak wolę to zrobić, bo to pomaga i może uprościć wiele rzeczy w późniejszym utrzymaniu takiego systemu. Dlatego też zachęcam Cię, żebyś stosował taką klasę bazową.
Kontekst bazy danych
To jest najważniejszy element EfCore. Technicznie to klasa, która dziedziczy po DbContext
. Odzwierciedla całą zawartość bazy danych – wszystkie tabele, które chcesz mieć. Dlatego też musisz napisać taką klasę. W swojej najprostszej i NIEWYSTARCZAJĄCEJ postaci może wyglądać tak:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
}
Właściwie to zbiór właściwości typu DbSet
. DbSet
odnosi się do tabeli w bazie danych. Dlatego też dla każdej tabeli będziesz miał osobne właściwości DbSet
. Stwórzmy zatem drugi model – użytkownik. Dodamy też od razu go do kontekstu:
public class User: BaseDbItem
{
public string Name { get; set; }
}
//dodajmy go też jako właściciela konkretnego ToDoItem:
public class ToDoItem: BaseDbItem
{
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
//no i do kontekstu:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
public DbSet<User> Users { get; set; }
}
Połączenie z bazą danych
Mając stworzony DbContext
, możemy teraz utworzyć połączenie do bazy danych. Sprowadza się to do trzech kroków:
Przechowywanie ConnectionString
ConnectionString
do bazy będzie przechowywany oczywiście w ustawieniach – appsettings
. Ja trzymam w głównym pliku: appsettings.json
zamiast appsettings.Development.json
, ponieważ jest mi prościej (za chwilę dowiesz się dlaczego).
Pamiętaj, że ten plik ląduje w repozytorium na serwerze, więc jeśli umieszczasz tam jakieś hasło, to pamiętaj, że to zła praktyka i powinieneś posłużyć się sekretami. W przeciwnym razie każdy z dostępem do repozytorium zobaczy Twoje hasło.
To teraz jak utworzyć ConnectionString
do bazy lokalnej? Bardzo prosto. Najpierw otwórz sobie okienko SQL Server Object Explorer:
To okienko pozwala Ci zarządzać lokalnymi bazami. Teraz odnajdź bazę master na lokalnym serwerze i wejdź w jej właściwości (ważne, żebyś rozwinął bazę master. Jeśli jej nie rozwiniesz, nie zobaczysz connection stringa):
Z właściwości możesz odczytać connection stringa:
Skopiuj go i wklej do ustawień w pliku appsettings.json
lub w secrets.json
:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"MainDbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=master;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
}
}
Teraz zwróć uwagę, że w connection string
jest zaszyta nazwa bazy – master
. Dlatego też powinieneś ją zmienić na swoją docelową bazę. Ta baza nie musi istnieć, EfCore sam ją sobie utworzy, ale zmień tą nazwę. Nigdy nie pracuj na bazie master
(traktuj ją tylko jako do odczytu):
"ConnectionStrings": {
"MainDbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=todo;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
}
Tutaj bazę nazwałem todo
.
Przekazywanie ConnectionString
Musimy teraz w jakiś sposób powiedzieć EfCore’owi z jaką bazą chcemy się łączyć. Robimy to poprzez kontekst bazy danych. Wystarczy oprogramować konkretną wersję konstruktora w taki sposób:
public class ApplicationDbContext: DbContext
{
public DbSet<ToDoItem> ToDoItems { get; set; }
public DbSet<User> Users { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
I to w zasadzie wystarczy. Oczywiście nic Ci nie broni, żebyś w tym konstruktorze posiadał jakiś kod. Ale to w domyślnym mechanizmie wystarczy.
Pamiętaj tylko, że jeśli używasz EfCore z dependency injection, stwórz konstruktor z generyczną wersją
DbContextOptions
– tak jak wyżej:DbContextOptions<ApplicationDbContext>
zamiast samegoDbContextOptions
.Dlaczego? Jeśli masz w aplikacji jeden DbContext, to nie ma żadnej różnicy. Jednak, jeśli masz ich kilka, to użycie generycznej wersji
DbContextOptions<T>
zapewni, że do odpowiednich kontekstów zawsze trafią odpowiednie opcje. W przeciwnym razie dependency injection może coś pochrzanić.Więc lepiej wyrób sobie taki nawyk.
Teraz dwa słowa wyjaśnienia. EfCore działa w taki sposób, że to kontekst bazy danych musi wiedzieć z jaką bazą się łączy. I ten connection string
przekazuje się w konstruktorze. Jednak nie możesz go tam zahardkodować, bo to zła praktyka z punktu widzenia bezpieczeństwa i utrzymania takiego kodu.
Oczywiście mógłbyś posłużyć się mechanizmem konfiguracji i wstrzyknąć tutaj jakieś IOptions
. Ale to już jest domyślnie. Ten domyślny sposób korzysta z tego przeciążenia konstruktora – wstrzykuje do kontekstu odpowiednie opcje.
Podczas rejestracji kontekstu, rejestrowane są też jego opcje (DbContextOptions
), w którym connection string jest już obecny. Jeśli dobrze zarejestrujemy 🙂
Rejestracja kontekstu bazy danych
Kontekst rejestrujemy tak jak każdy inny serwis, dodatkowo podając connection stringa i mówiąc mu, jakiej bazy chcemy użyć.
var config = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});
Uwaga! GetConnectionString to metoda pomocnicza, która pobiera sekcję z ustawień aplikacji o nazwie „ConnectionStrings”. Więc jeśli Twoja sekcja nazywa się inaczej lub w innym miejscu masz tego connection stringa, wtedy posłuż się standardowym wywołaniem w stylu config[„MojaSekcja:Connection”].
Pamiętaj, że UseSqlServer
to extension method pochodzące z NuGet: Microsoft.EntityFrameworkCore.SqlServer
. Jeśli zainstalujesz inny NuGet, np. do obsługi SQLite, wtedy będziesz używał metody UseSqlite
. Analogicznie z innymi bazami danych.
Inne sposoby
Są też inne prawilne sposoby rejestracji takiego kontekstu. Np. za pomocą DbContextFactory
, o którym za chwilę.
Czym są migracje w EfCore?
Nieodłącznym mechanizmem EfCore są migracje. EfCore jest w stanie sam utworzyć bazę danych. Co więcej, jest w stanie pilnować, żeby struktura bazy danych odpowiadała strukturze w Twoich modelach (choć pamiętaj, że świat relacyjny i obiektowy to zupełnie dwa różne miejsca). Co więcej, od jakiegoś czasu EfCore potrafi też stworzyć modele na podstawie tabel istniejących w bazie danych.
Na pewno spotkałeś się z pojęciami Code First i Database First. To właśnie opisuje sposób tworzenia bazy danych. Podejście Code First tworzy bazę danych z istniejących modeli obiektowych. Database First z kolei na odwrót – tworzy modele obiektowe na podstawie struktury bazy danych. W EfCore jeszcze do niedawna było możliwe użycie jedynie podejścia CodeFirst.
EfCore, aby to wszystko było możliwe, posługuje się migracjami. Z technicznego punktu widzenia, migracja to zwykła klasa, która posiada przepis na utworzenie konkretnej wersji bazy danych.
Gdy zmieniasz swój model i uznajesz go za pewną skończoną wersję, powinieneś zrobić migrację. Robi się to za pomocą narzędzi EfCore. Wystarczy, że w konsoli przejdziesz do folderu, w którym jest Twój projekt i wpiszesz tam takie polecenie:
dotnet ef migrations add "InitialDbCreate"
InitialDbCreate
to oczywiście nazwa Twojej migracji. Ta jest standardową nazwą na pierwszą migrację. Poza tym przyjęło się, że nazwa migracji powinna mówić o tym, co migracja zawiera, np. „AddedClientName” albo „RemovedOwnerModel” itd.
Wykonanie tego polecenia stworzy folder Migrations
i dwa pliki w środku. Pierwszy to migracja (klasa dziedzicząca po Migration
), a drugi to snapshot aktualnej wersji – powstaje na podstawie migracji. Jeśli chcesz od nowa zbudować bazę danych, po prostu możesz usunąć cały folder i bazę danych.
Mając taką migrację, możesz teraz uaktualnić / stworzyć bazę danych poleceniem:
dotnet ef database update
To polecenie po pierwsze połączy się z bazą danych, a po drugie utworzy lub odczyta specjalną tabelę, w której są zapisane nazwy migracji, z których powstaje baza danych. Patrzy na ostatni wpis i wykonuje kolejną migrację – aktualizuje strukturę bazy danych.
Oczywiście dokładnie tak samo, jak tworzyłbyś skrypty SQL aktualizujące strukturę – tutaj też to może się nie udać. Przykładowo, jeśli istnieją rekordy i jakaś kolumna ma wartości NULL, a teraz chcesz zrobić, żeby ta kolumna nie mogła być NULLem, to się nie uda. Więc trzeba na takie rzeczy zwracać uwagę.
Jak to działa?
Zarówno tworzenie migracji jak i aktualizacja bazy uruchamia Twoją aplikację. Najpierw apka jest budowana (choć to można pominąć, dodając do polecenia argument --no-build
, ale uważaj na to, bo może się okazać że migracja nie zostanie przeprowadzona), potem uruchamiana w specjalny sposób. I tutaj może pojawić się problem z odczytem connection stringa. W .NET6 narzędzia szukają connection stringa na produkcyjnej wersji środowiska (chyba że masz globalnie ustawioną zmienną środowiskową ASPNET_ENVIRONMENT
z wartością Development
) – dlatego wcześniej pisałem o tym, że dla ułatwienia connection string trzymany jest w głównym appsettings.json
(lub secrets.json).
Pewnie można to jakoś zmienić, ale jeszcze nie rozkminiłem jak 🙂
Konfiguracja EfCore
EfCore jest na tyle „mądry”, że dużo potrafi wywnioskować na temat modeli będących w DbContext
. I tak naprawdę jeśli w ogóle nie skonfigurujesz swoich modeli, to on i tak rozkimi, że pole o nazwie Id jest kluczem głównym; że pole OwnerId jest jakimś kluczem obcym. Co więcej nawet jak zobaczy właściwość wskazującą na inny obiekt, też stworzy z tego relację.
Jednak nie wszystko jest w stanie wydedukować. Poza tym mamy w naszym przypadku jeden poważny problem. Kolumna Status
w tabeli ToDoItems
domyślnie została utworzona jako int (EfCore tak domyślnie mapuje enumy). Czy to dobrze? Zdecydowanie nie. W tym momencie mamy możliwe 3 wartości:
public enum ToDoItemStatus
{
NotStarted,
Running,
Done
}
NotStarted
zostało zmapowane do 0, Running
to 1, a Done
to 2. Fajnie. A jak ktoś dołoży kolejną?
public enum ToDoItemStatus
{
NotStarted,
Running,
Paused,
Done
}
Wtedy w bazie wszystko się pop…rzy. Bo nagle Paused
przyjmie wartość 2, a Done
3. Z bazy będzie wynikało, że żadne zadanie nie zostało ukończone. Za to te, co były ukończone będą uważane za wstrzymane. Dlatego mimo wszystko lepiej enumy trzymać jako string.
To wszystko i wiele więcej możemy uzyskać dzięki konfiguracji modeli. Konfigurację można przeprowadzić na dwa sposoby:
- za pomocą adnotacji
- używając fluentowej konfiguracji
Osobiście jestem zwolennikiem tej drugiej, bo dzięki temu modele bazodanowe są czyste. Poza tym adnotacje nie pozwolą na wszystko.
Przykład konfiguracji za pomocą adnotacji może wyglądać tak:
public class ToDoItem: BaseDbItem
{
[Required]
public Guid OwnerId { get; set; }
[ForeignKey(nameof(OwnerId)]
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
[MaxLength(30)]
public string Title { get; set; }
public string Description { get; set; }
}
Tutaj mówimy do ORMa trzy rzeczy:
- Pole
OwnerId
ma być wymagane (not null) Owner
to klucz obcy do tabeliUsers
, odpowiada mu właściwośćOwnerId
- Pole
Title
ma mieć maksymalnie 30 znaków
Tych adnotacji jest oczywiście więcej, ale moim skromnym zdaniem szkoda na nie czasu. Przejdźmy od razu do Fluent Configuration.
Konfiguracja typu Fluent
Możesz ją przeprowadzić na dwa sposoby. Czysto – tzn. oddzielne klasy odpowiadają za konfigurację konkretnych modeli lub brudno – wszystkie modele w jednej metodzie. Najpierw pokażę Ci tę „brudną” konfigurację.
Przejdźmy do naszego DbContext
. Tam jest do przeciążenia taka metoda jak OnModelCreating
, w której możemy te modele pokonfigurować. Ale zanim do tego dojdzie, zróbmy małą, ale cholernie istotną zmianę w modelu:
public class ToDoItem: BaseDbItem
{
public Guid OwnerId { get; set; }
public User Owner { get; set; }
public DateTimeOffset? StartDate { get; set; }
public ToDoItemStatus Status { get; set; } = ToDoItemStatus.NotStarted;
public DateTimeOffset? EndDate { get; set; }
public string Title { get; set; }
public string Description { get; set; }
}
Dodałem właściwość OwnerId
. Dzięki temu będziemy mogli przeszukiwać tabelę ToDoItems
pod kątem konkretnych użytkowników z użyciem Linq. Jednocześnie nie będziemy musieli robić joina z tabelą Users
. Skonfigurujmy to teraz:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var todoItemBuilder = modelBuilder.Entity<ToDoItem>();
todoItemBuilder.Property(x => x.Title)
.HasMaxLength(30);
todoItemBuilder.Property(x => x.Status)
.HasConversion(new EnumToStringConverter<ToDoItemStatus>());
todoItemBuilder.Property(x => x.OwnerId)
.IsRequired();
todoItemBuilder.HasOne(x => x.Owner)
.WithMany()
.HasForeignKey(x => x.OwnerId);
}
Ta konfiguracja fluentowa również sama się świetnie opisuje 🙂 Założę się, że bez tłumaczenia od razu widzisz jakie masz możliwości i co robi powyższy kod.
Generalnie, posługując się metodą Property
, możemy ustawiać ograniczenia, konwertery itd. dla konkretnych właściwości.
Zwróć uwagę szczególnie na linijki 10 i 11, gdzie konwertujemy enum do stringa.
Czy warto bawić się w konfigurację, skoro EfCore robi dużo za nas? Moim zdaniem warto. Ja nie za bardzo lubię oddawać kontrolę nad kodem, są rzeczy które wolę jasno określić. Jednak, jeśli Ty jesteś bardziej otwarty lub nie potrzebujesz niektórych ograniczeń, możesz spróbować dać EfCore wolną wolę i zobaczyć co się stanie.
Ignorowanie właściwości
EfCore domyślnie umieszcza w bazie wszystkie właściwości z modelu (w przeciwieństwie do nHibernate). Tutaj też możemy mu powiedzieć jakie właściwości ma ominąć. Np. jeśli bym nie chciał, żeby do bazy trafiał czas zakończenia zadania, mógłbym to skonfigurować tak:
todoItemBuilder.Ignore(x => x.EndDate);
Jak widzisz sam konkretny builder danego modelu ma w sobie też różne ciekawe elementy. Pozwala między innymi na zmianę nazwy tabeli.
Klucze
Możesz ustawić dodatkowy klucz na jednym lub na kilku polach. Przy czym jeśli chodzi o klucz ustawiany na kilku polach, jest to możliwe tylko za pomocą fluent configuration. Nie można tego zrobić adnotacjami:
todoItemBuilder.HasKey(x => x.Status);
todoItemBuilder.HasKey(x => new {x.OwnerId, x.Status});
Tutaj utworzyłem dwa klucze – jeden na pole Status, drugi jest kluczem na dwóch polach – OwnerId
i Status
. Jak widzisz, jest to banalnie proste.
Relacje w EfCore
Relacje to dość ważny aspekt w bazach relacyjnych 😀 W EfCore konfiguruje się je bardzo prosto, trzeba pamiętać tylko o kilku rzeczach i pojęciach:
- relację konfigurujemy tylko z jednej strony. Tzn. możemy ustawić, że
ToDoItem
ma jednego ownera alboUser
ma wieleToDoItem'ów
. Nie musimy konfigurować dwóch modeli. Tzn. nie musimy wUser
podawać, że ma wieleToDoItem'ów
i jednocześnie wToDoItem
, że ma jednego usera. Wystarczy, że skonfigiujemy to po jednej stronie - encja zależna (dependent entity) – to jest ta encja, która zawiera klucz obcy. Czyli w naszym przypadku encją zależną jest
ToDoItem
– ponieważ to w tej klasie zdefiniowaliśmy klucz obcy - encja główna (principal entity) – to jest ta encja, która jest „rodzicem” encji zależnej. W naszym wypadku będzie to
User
. No, jeśli usuniemyUsera
, jego encje zależne też powinny zostać usunięte (czyli jego wszystkie zadania). - właściwość nawigacyjna (navigation property) – to jest właściwość w encji zależnej i/lub głównej, która wskazuje na tę drugą stronę. Bardziej po ludzku, w modelu
ToDoItems
naszym navigation property jest Owner – to ta właściwość wskazuje na konkretny model encji głównej. Możemy dodać takie navigation property również do modeluUser
, dodając np. listę todo itemów:
public class User: BaseDbItem
{
public string Name { get; set; }
public List<ToDoItem> Items { get; set; }
}
Generalnie navigation property może posiadać kilka typów:
- kolekcję zależnych encji (jak wyżej właściwość Items)
- właściwość wskazującą na jedną powiązaną encję (np. w naszym modelu
ToDoItems
jest to Owner)
Czasem mówimy też o inverse property. To jest zwykłe navigation property tyle, że po drugiej stronie relacji. Jakby to powiedzieć prościej… Jeśli nasza encja User
miałaby listę Itemów jak w powyższym przykładzie, wtedy ta lista Itemów byłaby takim „inverse property”. Kiedy to się przydaje i po co?
Generalnie EfCore potrafi sam wykminić wiele rzeczy na podstawie konwencji nazewnictwa, zależności itd. Ale w pewnych sytuacjach nie ogranie. Czasem możesz dostać błąd, że nie może „wydedukować” rodzaju relacji. Wtedy, żeby mu ułatwić możesz zastosować ten inverse property. I konfiguracja takiej relacji wyglądałaby tak:
todoItemBuilder.HasOne(x => x.Owner)
.WithMany(y => y.Items)
.HasForeignKey(x => x.OwnerId);
Jeśli nie masz inverse property, możesz po prostu skonfigurować to tak:
todoItemBuilder.HasOne(x => x.Owner)
.WithMany()
.HasForeignKey(x => x.OwnerId);
Konfiguracja „czysta”
Mam tu na myśli taką konfigurację, że poszczególne klasy są konfigurowane w osobnych plikach – tak jak ma to miejsce przy nHibernate. Najpierw warto utworzyć sobie konfiguracyjną klasę bazową, np:
public abstract class BaseModelConfig<TModel> : IEntityTypeConfiguration<TModel>
where TModel : BaseDbItem
{
public virtual void Configure(EntityTypeBuilder<TModel> builder)
{
builder.Property(x => x.Id)
.IsRequired()
.ValueGeneratedOnAdd();
}
}
Zwróć uwagę, że nasza klasa bazowa implementuje interfejs IEntityTypeConfiguration. Tutaj właśnie przyda się też bazowy model, o czym pisałem wcześniej. Naprawdę warto go mieć.
Następnie konfiguracja pozostałych modeli polega na dziedziczeniu po klasie BaseModelConfig:
public class TodoItemConfig : BaseModelConfig<ToDoItem>
{
public override void Configure(EntityTypeBuilder<ToDoItem> builder)
{
base.Configure(builder);
builder.Property(x => x.Title)
.HasMaxLength(30);
builder.Property(x => x.Status)
.HasConversion(new EnumToStringConverter<ToDoItemStatus>());
builder.Property(x => x.OwnerId)
.IsRequired();
builder.HasOne(x => x.Owner)
.WithMany()
.HasForeignKey(x => x.OwnerId);
}
}
Oczywiście nie musisz tworzyć abstrakcyjnej klasy bazowej. Ważne, żeby konkretne konfiguracje implementowały interfejs IEntityTypeConfiguration
. Jednak taka klasa bazowa dużo ułatwia.
Na koniec musisz jeszcze powiedzieć w kontekście, gdzie ma szukać konfiguracji. DbContext
będzie szukał implementacji interfejsu IEntityTypeConfiguration
w Assembly, które mu podasz. W tym przypadku wykorzystujemy aktualne, główne:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
Zapytania
Przede wszystkim pamiętaj, że EfCore pełni rolę repozytorium. Dlatego nie twórz repozytorium (mówię tutaj o wzorcu projektowym), używając EfCore – to jest częsty błąd w tutorialach na YouTubie i zbyt wielu artykułach. Pamiętaj – EfCore to repozytorium.
Jeśli chodzi o programowanie webowe, to najczyściej jest używać EfCore w serwisach. Kontroler odwołuje się do serwisu, serwis ma wstrzyknięty DbContext
. A teraz zobaczmy, w jaki sposób można dodać rekord. Spójrz na poniższy serwis:
public class UserService
{
private readonly ApplicationDbContext _dbContext;
public UserService(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task AddOrUpdateUser(User data)
{
_dbContext.Users.Update(data);
await _dbContext.SaveChangesAsync();
}
}
Najpierw wstrzykujemy do konstruktora ApplicationDbContext
. Następnie możemy się nim posługiwać tak jakbyśmy używali zwykłego wzorca repozytorium. A nawet lepiej, bo można używać Linq do pobierania danych.
Zwróć tylko uwagę na to, że dodawanie/modyfikacja danych w poszczególnych DbSet'ach
jeszcze niczego nie zmienia. Na tym poziomie działa taki sprytny wewnętrzny mechanizm EfCore (change tracker), który oznacza odpowiednie rekordy w odpowiedni sposób (ten rekord będzie zmodyfikowany, ten trzeba dodać, a tu jest jakiś do usunięcia).
Faktyczne operacje do bazy danych pójdą dopiero po wywołaniu SaveChanges
. I do tego pójdą w transakcji.
Czas życia DbContext – ważne
DbContext
nie jest thread-safe. Nie powinien żyć długo. Domyślnie jest wstrzykiwany jako scope (choć to można zmienić podczas konfiguracji). Zalecam zostawić domyślne zachowanie, chyba że pojawiają się problemy, to wtedy można zmienić na transient
:
builder.Services.AddDbContext<ApplicationDbContext>(o =>
{
o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
}, ServiceLifetime.Transient);
Jeśli używasz zwykłego WebApplication albo WebApi, to takie konstrukcje ze wstrzykiwaniem DbContext
są jak najbardziej poprawne. Ale problem pojawia się przy Blazor lub aplikacjach desktopowych.
Inaczej dla Blazor i aplikacji desktopowych
Dlaczego? Jak już mówiłem, DbContext
jest standardowo wstrzykiwany jako scoped. Możesz też zmienić na transient. Jednak co to oznacza dla Blazor? Tam, jeśli masz serwis oznaczony jako scope, to właściwie zachowuje się to jak singleton. Dokładnie tak samo jest w aplikacjach desktopowych.
Nie oznacza to teraz, że w Blazor wszystkie serwisy korzystające pośrednio lub bezpośrednio z bazy danych, mają być wstrzykiwane jako transient. O nie. Jest lepszy sposób. Spójrzmy na fabrykę.
Fabryka DbContext
Zamiast rejestrować DbContext
, możesz zarejestrować fabrykę:
builder.Services.AddDbContextFactory<ApplicationDbContext>(o =>
{
o.UseSqlServer(config.GetConnectionString("MainDbConnection"));
});
Przyznasz, że wielkiej różnicy nie ma. Różnica pojawia się w momencie używania tego kontekstu. Teraz wstrzykujemy fabrykę do serwisu:
public class UserService
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
public UserService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task AddOrUpdateUser(User data)
{
using var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Users.Update(data);
await dbContext.SaveChangesAsync();
}
}
Tutaj do dostępu do bazy danych wykorzystujemy fabrykę. Po prostu ona nam tworzy ten kontekst. Pamiętaj, że musisz tworzyć to przez using
. DbContext
implementuje interfejs IDisposable
i jest cholernie ważne, żeby po sobie posprzątał.
Nie ma nic złego w takim tworzeniu kontekstu. Co więcej – to naturalny sposób w przypadku aplikacji desktopowych no i we wspomnianym Blazor.
Pamiętaj, że jeśli chodzi o WebApplication lub WebApi – gdzie każdy request tworzy i usuwa scope, to możesz posłużyć się tą wersją ze wstrzykiwaniem samego kontekstu.
Zapytania SELECT
Oczywiście jest to tak samo proste jak pobieranie danych ze zwykłej listy. Zwróć tylko uwagę na to, czym jest IQueryable
. Spójrz na poniższy kod:
var data = dbContext.Users.Where(x => x.Name.Length > 3);
Tutaj pobieramy użytkowników, których imię ma więcej niż 3 znaki. Czy to zapytanie się wykona?
Nie wykona się. Dlatego, że metoda Where
zwraca obiekt IQueryable
. A to taki twór, który możesz uznać za zapytanie SQL. Możesz mu zatem dodać inne warunki, np:
var data = dbContext.Users.Where(x => x.Name.Length > 3);
data = data.Where(x => x.Name.Contains("A"));
A kiedy IQueryable
zostanie wysłane do bazy danych? We wszystkich tych metodach, które zwracają coś innego niż IQueryable
(a konkretnie nasz model lub jego listę), np:
var list = await data.ToListAsync();
var first = await data.FirstAsync();
var fod = await data.FirstOrDefaultAsync();
Itd. Generalnie zawsze spójrz na to, co zwraca konkretna metoda. Tam, gdzie masz IQueryable
, to nie ma jeszcze żadnej pracy na bazie danych.
Join
A teraz spójrz na takie zapytanie:
using var ctx = _dbContextFactory.CreateDbContext();
var data = await ctx.ToDoItems.ToListAsync();
Co zostanie pobrane?
Same zadania. Bez ich właścicieli. To super wiadomość. EfCore domyślnie zwróci tylko te dane, o które pytamy. Nie jest to wprawdzie takie „lazy loading” jak w nHibernate (choć to też da się ustawić), jednak do bazy idą tylko najbardziej potrzebne zapytania.
Oczywiście możemy do tego zrobić joina, żeby uzyskać zadania wraz z właścicielami. Ale musimy to jasno powiedzieć. W taki sposób:
using var ctx = _dbContextFactory.CreateDbContext();
var data = await ctx.ToDoItems
.Include(x => x.Owner)
.ToListAsync();
Metoda Include
powie EfCore’owi, że chcemy pobrać również właściciela zadania. Istnieje również metoda ThenInclude
, która pobiera dane z kolejnej encji (w naszym przypadku byłby to User
).
Uważaj na podwójny INSERT
Spójrz na tan kod:
public async Task AddOrUpdateTask(ToDoItem item)
{
User owner;
using var ctx = _dbContextFactory.CreateDbContext();
owner = await ctx.Users.FirstAsync();
item.Owner = owner;
ctx.ToDoItems.Add(item);
await ctx.SaveChangesAsync();
}
Tak naprawdę nie jest ważne skąd pochodzi owner, ważne jest że ten owner znajduje się już w bazie danych. Taki kod wybucha błędem o niepoprawnym kluczu. EfCore myśli, że skoro dodajemy nowy rekord (ToDoItem
), to dodajemy go z całym inwentarzem, czyli chcemy też dodać jego ownera (technicznie – INSERT owner
i INSERT todoItem
). Ale User
o takim id już istnieje w bazie danych.
Czemu tak się stało?
EfCore ma taki sprytny mechanizm, który nazywa się ChangeTracker. On po prostu sprawdza, co się zmieniło w encji podczas życia kontekstu bazy danych. Akurat ta encja (TodoItem
) jest zupełnie nowa. Więc w ChangeTrackerze
, który żyje razem z kontekstem bazy danych ma oznaczenie „New”. Wszystko co ma oznaczenie „New” musi zostać dodane do bazy przez INSERT
. EfCore uznaje również, że wszystkie encje zależne też są nowe.
Ale to nie koniec niespodzianki. Jeśli stworzymy jeden DbContext
i pobierzemy z niego TodoItem
, następnie kontekst zostanie ubity, to po utworzeniu drugiego kontekstu ten TodoItem
, który został pobrany też będzie widoczny jako nowy – w tym nowym DbContext
. To jest pułapka, na którą często sam się łapałem i na którą zwracam Tobie szczególną uwagę.
Więc jak sobie z czymś takim poradzić? Po prostu:
public async Task AddOrUpdateTask(ToDoItem item)
{
User owner;
using var ctx = _dbContextFactory.CreateDbContext();
owner = await ctx.Users.FirstAsync();
item.OwnerId = owner.Id;
ctx.ToDoItems.Add(item);
await ctx.SaveChangesAsync();
}
Czyli zamiast obiektu, przypisuję Id. Czasem nawet będziesz musiał zrobić takie cudo:
item.Owner = null;
ctx.ToDoItems.Update(item);
await ctx.SaveChangesAsync();
Musisz znullować ownera jako obiekt, ale zostawiasz jego Id. To oczywiście dotyczy tej samej sytuacji, czyli jedna encja pochodzi z jednego kontekstu, a druga z drugiego. Może się tak zdarzyć chociażby przy WebApi. Dlatego też pomocne może okazać się przesyłanie między końcówkami modelu DTO, zamiast bazodanowego (i mocno Cię do tego zachęcam, żebyś nie przesyłał modeli bazodanowych).
Różne bazy danych, różne możliwości
Możliwości EfCore zależą trochę od rodzaju bazy danych, na jakiej pracujesz. Niektóre operacje nie będą dozwolone np. na Sqlite. Zazwyczaj pracujemy na jednym typie bazy. Ale nie zawsze. Czasem zdarza się, że potrzebujesz dwóch rodzajów. Dlatego ważne jest, żeby przetestować wszystkie funkcje na wszystkich DBMS, które wykorzystujesz.
Dziękuję Ci za przeczytanie tego artykułu. To oczywiście nie wyczerpuje tematu. To są podstawy podstaw jeśli chodzi o EfCore. Na tym blogu mam jeszcze kilka tekstów na ten temat, jeśli Cię zainteresowało, to polecam:
Jeśli czegoś nie rozumiesz lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu. No i oczywiście udostępnij artykuł osobom, które powinny wiedzieć jak podejść do EfCore.