I mit arbejde med backend og database i QuestGame, er jeg stødt på et spørgsmål: hvordan sparer jeg mest muligt på ressourcerne, når jeg henter data fra databasen?
Med Entity Framework Core kan jeg eksempelvis hente relaterede entiteter ved hjælp af Include(). Men i mange situationer har klienten kun brug for ganske få felter, og så kan Projection med Select() være et bedre valg.
De to teknikker kan således bruges forskelligt, for at optimere effektiviteten og lette ressourceforbruget. Include handler primært om at indlæse et objekt og dets relationer, mens Projection handler om at forme præcis det resultat, som en bestemt operation har brug for.
Include – hent relationerne med
Hvis jeg f.eks. henter en Quest, vil Entity Framework Core som udgangspunkt ikke automatisk hente alle relaterede objekter.
Hvis en Quest eksempelvis indeholder QuestItems, kan jeg hente dem sammen med Questen ved hjælp af Include:
var quest = await dbContext.Quests
.Include(q => q.QuestItems)
.FirstOrDefaultAsync(q => q.Id == questId);Code language: C# (cs)
EF Core sørger dermed for, at både Questen og dens QuestItems bliver indlæst.
Hvis der findes yderligere relationer, kan ThenInclude() bruges:
var quest = await dbContext.Quests
.Include(q => q.QuestItems)
.ThenInclude(p => p.Element)
.FirstOrDefaultAsync(q => q.Id == questId);Code language: C# (cs)
Fordelen er især, at jeg får en rigtig entitetsgraf, som jeg efterfølgende kan arbejde videre med i domænet.
Det kan eksempelvis være relevant, når en underviser åbner en Quest til redigering, og backenden har brug for selve Questen og dens relaterede data.
Include er derfor ikke i sig selv en dårlig eller langsom løsning. Problemet opstår først, hvis jeg begynder at hente betydeligt mere data, end operationen faktisk har brug for. I store systemer med mange operationer gælder ordsproget “mange bække små gør en stor å” – tænker man slet ikke på optimering i nogen af lagene, kan performanceproblemer pludselig vokse sig store.
Projection – hent kun det nødvendige
Projection betyder, at jeg med Select() beskriver præcis, hvilke data databasen skal returnere.
Det passer særligt godt til read-operationer, hvor resultatet alligevel skal omdannes til en DTO.
Et godt eksempel i QuestGame er kortet.
Når spilleren åbner kortet, behøver mobilappen ikke nødvendigvis hele objektstrukturen for hvert QuestItem. Den har eksempelvis kun brug for:
- QuestItem ID
- koordinater
- typen af element
- status
Jeg kunne derfor definere en DTO:
public record QuestItemMapDto(
Guid Id,
double Latitude,
double Longitude,
string QuestItemType,
string Status);Code language: C# (cs)
Og derefter bruge Projection:
var questitems = await dbContext.QuestItems
.Where(p => p.QuestId == questId)
.Select(p => new QuestItemMapDto(
p.Id,
p.Location.Y,
p.Location.X,
p.QuestItem.QuestItemType,
p.Status))
.ToListAsync();Code language: C# (cs)
Her henter EF Core ikke først komplette QuestItem-objekter og mapper dem bagefter.
I stedet bliver Select() oversat til SQL, så databasen kun returnerer de kolonner, der er nødvendige for at konstruere QuestItemMapDto.
Det er en vigtig forskel.
Hvorfor ikke bare bruge Include?
Jeg kunne i princippet lave noget i denne retning:
var questitems = await dbContext.QuestItems
.Include(p => p.QuestItem)
.Where(p => p.QuestId == questId)
.ToListAsync();Code language: C# (cs)
Og derefter mappe resultatet:
var result = questitems.Select(p =>
new QuestItemMapDto(
p.Id,
p.Location.Y,
p.Location.X,
p.QuestItem.QuestItemType,
p.Status));Code language: C# (cs)
Det virker selvfølgelig. Men databasen har allerede sendt komplette entiteter til applikationen, selvom API’et måske kun skal bruge fem værdier.
Hvis QuestItem og de tilknyttede elementer senere får flere properties og relationer, kan forskellen blive endnu større.
Med Projection bliver selve forespørgslen derimod bygget ud fra det resultat, jeg faktisk ønsker.
Man kan lidt forsimplet se forskellen sådan:
Include:
Database
↓
Quest + QuestItems + relaterede entiteter
↓
Backend
↓
DTOCode language: PHP (php)
Hvor Projection i højere grad bliver:
Database
↓
De nødvendige felter
↓
DTO
Projection erstatter ikke Include
Det betyder dog ikke, at man skal erstatte alle Include()-kald med Projection.
Hvis jeg eksempelvis skal hente en Quest, ændre dens QuestItems og derefter gemme ændringerne, giver det god mening at arbejde med de egentlige EF Core-entiteter.
Eksempelvis:
var quest = await dbContext.Quests
.Include(q => q.QuestItems)
.FirstAsync(q => q.Id == questId);
quest.UpdateName("Ny titel");
await dbContext.SaveChangesAsync();Code language: C# (cs)
Her er formålet ikke blot at vise data, men at arbejde med Questen som domæneobjekt og efterfølgende gemme ændringerne.
Til gengæld har et endpoint som:
GET /quests/{id}/questitemsCode language: C# (cs)
typisk kun ét formål: at levere data til klienten.
Her vil Projection ofte være mere naturligt.
Include og AsNoTracking
Hvis jeg bruger Include til en ren read-operation, kan AsNoTracking() også være relevant:
var quest = await dbContext.Quests
.AsNoTracking()
.Include(q => q.QuestItems)
.FirstAsync(q => q.Id == questId);Code language: C# (cs)
EF Core behøver dermed ikke holde styr på entiteterne med henblik på senere ændringer.
Det kan reducere noget overhead.
Men AsNoTracking() ændrer ikke på, hvilke kolonner der bliver hentet. Hvis jeg kun skal bruge enkelte felter, vil Projection stadig være den mere målrettede løsning.
Mit valg i QuestGame
I QuestGame betragter jeg ikke Include og Projection som konkurrenter, hvor den ene løsning altid er bedst.
Jeg vil i stedet vælge ud fra operationens formål.
- Include giver mening, når jeg faktisk skal arbejde med entiteter og deres relationer. Det kan eksempelvis være ved redigering af en Quest.
- Projection giver især mening ved read-endpoints, hvor klienten kun skal bruge et bestemt udsnit af data.
Kortvisningen er et godt eksempel. Her er det vigtigere at sende præcis de nødvendige informationer om QuestItems end at konstruere komplette domæneobjekter i backenden.
Det hænger også godt sammen med den Vertical Slice Architecture, vi anvender i QuestGame. Hver feature kan definere det output, den selv har brug for, i stedet for at alle endpoints nødvendigvis arbejder med den samme store objektstruktur.
Min vigtigste læring er derfor, at spørgsmålet ikke kun er:
Hvordan får jeg EF Core til at hente relationerne?
Det mere interessante spørgsmål er:
Hvilke data har denne operation faktisk brug for?
Når svaret kun er et udsnit af entiteten, er Projection værd at overveje.
