Ιnclude, ThenInclude και Projection – Αναλυτική Διδακτική Προσέγγιση
Όταν εργαζόμαστε με το Entity Framework Core, ένα από τα σημαντικότερα ζητήματα που καλούμαστε να αντιμετωπίσουμε είναι ο τρόπος με τον οποίο φορτώνουμε τα δεδομένα από τη βάση. Κάθε φορά που εκτελούμε ένα ερώτημα (query), πρέπει να αποφασίσουμε αν θα φέρουμε ολόκληρα αντικείμενα με όλες τις συσχετίσεις τους ή αν θα περιοριστούμε μόνο στα απαραίτητα δεδομένα. Η απόφαση αυτή επηρεάζει άμεσα την απόδοση της εφαρμογής, την κατανάλωση μνήμης και τη συμπεριφορά του συστήματος σε κλίμακα.
Για να μελετήσουμε το θέμα, ας χρησιμοποιήσουμε ένα απλό αλλά ρεαλιστικό παράδειγμα από ένα σύστημα παραγγελιών. Υποθέτουμε ότι έχουμε οντότητες όπως Order, Customer, OrderItem, Product και Payment. Ένα Order σχετίζεται με έναν Customer, έχει πολλά OrderItems και πολλά Payments. Κάθε OrderItem σχετίζεται με ένα Product. Πρόκειται για ένα κλασικό μοντέλο με σχέσεις ένα-προς-πολλά και ένα-προς-ένα.
Η έννοια του Include
Η μέθοδος Include χρησιμοποιείται όταν θέλουμε να φορτώσουμε μια βασική οντότητα μαζί με μια σχετική οντότητα. Αν γράψουμε:
var orders = context.Orders
.Include(o => o.Customer)
.ToList();
ζητάμε από το EF Core να φέρει όλες τις εγγραφές του πίνακα Orders και ταυτόχρονα να φέρει και τα αντίστοιχα δεδομένα από τον πίνακα Customers. Αυτό υλοποιείται στη βάση δεδομένων με έναν μηχανισμό που ονομάζεται JOIN. Το JOIN ενώνει δύο πίνακες βάσει ενός κοινού πεδίου, συνήθως ενός ξένου κλειδιού (foreign key). Στην περίπτωσή μας, το CustomerId του Order συνδέεται με το Id του Customer.
Η βάση δεδομένων επιστρέφει γραμμές που περιέχουν τα πεδία και των δύο πινάκων. Το EF Core στη συνέχεια δημιουργεί αντικείμενα Order και Customer στη μνήμη και τα συνδέει σωστά μεταξύ τους. Από προεπιλογή, τα αντικείμενα αυτά παρακολουθούνται από τον μηχανισμό παρακολούθησης αλλαγών του EF Core. Ο μηχανισμός αυτός καταγράφει ποιες ιδιότητες τροποποιούνται, ώστε όταν κληθεί η μέθοδος SaveChanges(), να παραχθούν οι κατάλληλες εντολές UPDATE στη βάση δεδομένων. Αυτή η παρακολούθηση όμως έχει κόστος σε μνήμη και υπολογιστική ισχύ.
Το Include είναι κατάλληλο όταν σκοπεύουμε να τροποποιήσουμε τα δεδομένα ή όταν η επιχειρησιακή λογική (business logic) βασίζεται σε πλήρη αντικείμενα του domain.
Η έννοια του ThenInclude
Η μέθοδος ThenInclude χρησιμοποιείται όταν θέλουμε να συνεχίσουμε τη φόρτωση σε βαθύτερο επίπεδο σχέσεων. Για παράδειγμα:
var orders = context.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ToList();
Σε αυτό το παράδειγμα ζητάμε από το EF Core να φέρει τα Orders, μαζί με όλα τα OrderItems κάθε Order, και για κάθε OrderItem να φέρει και το αντίστοιχο Product. Η βάση δεδομένων εκτελεί πολλαπλά JOINs ώστε να συνδυάσει τα δεδομένα από τρεις πίνακες.
Εδώ εμφανίζεται ένα σημαντικό φαινόμενο. Αν ένα Order έχει πολλά Items και ταυτόχρονα έχει και πολλά Payments, τότε όταν κάνουμε Include και των δύο συλλογών, η βάση δημιουργεί πολλαπλασιασμό γραμμών. Αν, για παράδειγμα, ένα Order έχει 5 Items και 3 Payments, το αποτέλεσμα του JOIN μπορεί να περιέχει έως και 15 γραμμές για το ίδιο Order. Οι πληροφορίες επαναλαμβάνονται, επειδή η βάση πρέπει να συνδυάσει κάθε Item με κάθε Payment.
Αυτό αυξάνει τον όγκο των δεδομένων που μεταφέρονται από τη βάση προς την εφαρμογή και αυξάνει το κόστος δημιουργίας αντικειμένων στη μνήμη. Σε μικρά σύνολα δεδομένων το πρόβλημα μπορεί να μην είναι εμφανές, αλλά σε μεγάλα συστήματα μπορεί να επηρεάσει σημαντικά την απόδοση.
Η έννοια του Projection
Το projection αποτελεί διαφορετική φιλοσοφία προσέγγισης. Αντί να ζητάμε ολόκληρα αντικείμενα με όλες τις ιδιότητές τους, ζητάμε μόνο τα δεδομένα που πραγματικά χρειαζόμαστε. Αυτό επιτυγχάνεται με τη μέθοδο Select.
Παράδειγμα:
var result = context.Orders
.Select(o => new
{
o.Id,
CustomerName = o.Customer.Name
})
.ToList();
Σε αυτήν την περίπτωση δεν δημιουργούνται αντικείμενα Order και Customer όπως ορίζονται στο domain model. Αντί γι’ αυτό, δημιουργείται ένα νέο αντικείμενο που περιέχει μόνο τα δύο συγκεκριμένα πεδία. Η βάση δεδομένων επιστρέφει μόνο τις αντίστοιχες στήλες, μειώνοντας τον όγκο των δεδομένων που μεταφέρονται.
Επιπλέον, επειδή δεν δημιουργούνται κανονικές οντότητες του μοντέλου, δεν ενεργοποιείται ο μηχανισμός παρακολούθησης αλλαγών. Αυτό μειώνει σημαντικά τη χρήση μνήμης και τον χρόνο επεξεργασίας.
Το projection είναι ιδιαίτερα κατάλληλο σε περιπτώσεις όπου θέλουμε απλώς να εμφανίσουμε δεδομένα, όπως σε ένα API, σε μια αναφορά ή σε ένα dashboard, χωρίς να σκοπεύουμε να τα τροποποιήσουμε.
Προχωρημένο Παράδειγμα Projection
Ας εξετάσουμε ένα πιο σύνθετο παράδειγμα:
var orders = context.Orders
.Select(o => new
{
o.Id,
CustomerName = o.Customer.Name,
TotalAmount = o.Items.Sum(i => i.Quantity * i.UnitPrice),
Items = o.Items
.Select(i => new
{
ProductName = i.Product.Name,
LineTotal = i.Quantity * i.UnitPrice
})
.ToList()
})
.ToList();
Εδώ ο συνολικός υπολογισμός της αξίας της παραγγελίας πραγματοποιείται στη βάση δεδομένων, πριν τα δεδομένα φτάσουν στην εφαρμογή. Δεν φορτώνουμε όλα τα OrderItems ως πλήρη αντικείμενα. Ζητάμε μόνο τις απαραίτητες πληροφορίες για την παρουσίαση. Η μεταφορά δεδομένων είναι μικρότερη και δεν υπάρχει παρακολούθηση αλλαγών.
Πότε να επιλέξουμε κάθε προσέγγιση
Αν η εφαρμογή μας πρόκειται να τροποποιήσει δεδομένα και να καλέσει SaveChanges(), τότε η χρήση Include είναι συνήθως κατάλληλη, διότι χρειαζόμαστε πλήρη αντικείμενα που παρακολουθούνται για αλλαγές.
Αν όμως η χρήση είναι καθαρά αναγνωστική (read-only), τότε το projection είναι συνήθως πιο αποδοτική λύση. Μειώνει τον όγκο δεδομένων, αποφεύγει τον πολλαπλασιασμό γραμμών και μειώνει την κατανάλωση μνήμης.
Τελικό Διδακτικό Συμπέρασμα
Η επιλογή μεταξύ Include και Projection δεν είναι θέμα προτίμησης αλλά θέμα σεναρίου. Ο σωστός σχεδιασμός απαιτεί να κατανοούμε τι δεδομένα χρειάζεται κάθε χρήση της εφαρμογής. Η ώριμη προσέγγιση δεν είναι «φέρνω ό,τι υπάρχει», αλλά «φέρνω μόνο ό,τι χρειάζομαι».
Αυτός ο τρόπος σκέψης αποτελεί βασικό στοιχείο αρχιτεκτονικής ωριμότητας και διαφοροποιεί τον προγραμματιστή που απλώς γράφει κώδικα από τον μηχανικό λογισμικού που σχεδιάζει συστήματα.
Projection στο Read Side ενός CQRS Συστήματος
Σε αρχιτεκτονική CQRS (Command Query Responsibility Segregation) διαχωρίζουμε ξεκάθαρα το κομμάτι που τροποποιεί δεδομένα (Commands) από το κομμάτι που διαβάζει δεδομένα (Queries). Στο read side, ο στόχος δεν είναι να διαχειριστούμε domain οντότητες ούτε να εκτελέσουμε επιχειρησιακή λογική που θα οδηγήσει σε αποθήκευση αλλαγών, αλλά να επιστρέψουμε δεδομένα όσο το δυνατόν πιο αποδοτικά και προσαρμοσμένα στην ανάγκη της οθόνης ή του API. Σε αυτό το πλαίσιο, το projection είναι η φυσική και συνειδητή επιλογή.
Για παράδειγμα, σε ένα CQRS σύστημα παραγγελιών, το command side μπορεί να φορτώνει ένα Order με Include για να προσθέσει ένα νέο OrderItem και να καλέσει SaveChanges(). Αντίθετα, στο query side, όταν υλοποιούμε ένα endpoint όπως “GetOrderDashboard”, δεν μας ενδιαφέρει το πλήρες αντικείμενο Order με όλες τις σχέσεις και μηχανισμούς παρακολούθησης αλλαγών. Μας ενδιαφέρει να επιστρέψουμε ένα ειδικά διαμορφωμένο αποτέλεσμα που να περιέχει, για παράδειγμα, το όνομα πελάτη, το συνολικό ποσό και τον αριθμό προϊόντων. Εκεί χρησιμοποιούμε projection με Select, ώστε ο υπολογισμός του συνολικού ποσού να γίνει στη βάση δεδομένων και να μεταφερθούν μόνο τα απαραίτητα πεδία.
Με αυτόν τον τρόπο, το read model είναι βελτιστοποιημένο για ανάγνωση, δεν φορτώνει περιττές οντότητες και δεν επιβαρύνει το σύστημα με μηχανισμούς που αφορούν την τροποποίηση δεδομένων.
Γιατί το CQRS ταιριάζει με Projection στο Read Side
Η αρχιτεκτονική CQRS βασίζεται στον διαχωρισμό της εφαρμογής σε δύο ξεχωριστά κομμάτια: το Command Side, που διαχειρίζεται τις αλλαγές (δηλαδή εισαγωγή, τροποποίηση ή διαγραφή δεδομένων), και το Query Side, που απλώς διαβάζει δεδομένα. Αυτός ο διαχωρισμός δημιουργεί μια φυσική ευκαιρία για βελτιστοποίηση: στο query side δεν χρειάζεσαι ολόκληρες οντότητες με πλήρη παρακολούθηση αλλαγών, γιατί δεν πρόκειται να τις τροποποιήσεις.
Εδώ μπαίνει το projection. Το projection σου επιτρέπει να ζητάς ακριβώς τα πεδία που χρειάζεσαι, να υπολογίζεις συνολικά ποσά ή άλλες συνάθροισεις στη βάση δεδομένων, και να επιστρέφεις ένα DTO (Data Transfer Object) έτοιμο για εμφάνιση ή API. Δεν δημιουργείς ολόκληρα domain αντικείμενα, δεν ενεργοποιείς τον μηχανισμό παρακολούθησης αλλαγών, και μειώνεις τον όγκο των δεδομένων που μεταφέρονται στη μνήμη.
Με λίγα λόγια, το projection στο read side αξιοποιεί πλήρως το πλεονέκτημα του CQRS: διαχωρίζει το read από το write, επιτρέπει βελτιστοποιημένη ανάγνωση και μειώνει την πολυπλοκότητα και το κόστος σε μνήμη και επεξεργαστική ισχύ. Έτσι, το CQRS και το projection ταιριάζουν φυσικά γιατί επιτρέπουν στο read model να είναι γρήγορο, αποδοτικό και εστιασμένο στα δεδομένα που χρειάζεται η εφαρμογή, χωρίς περιττά αντικείμενα ή διαδικασίες παρακολούθησης αλλαγών.
Παράδειγμα Projection με DTO
Έχουμε ένα σύστημα παραγγελιών με οντότητες Order, Customer, OrderItem και Product. Θέλουμε να φτιάξουμε ένα DTO που να περιέχει:
- Το ID της παραγγελίας
- Το όνομα του πελάτη
- Το συνολικό ποσό (TotalAmount)
- Τα στοιχεία των προϊόντων κάθε item
1 Δημιουργία DTOs
public class OrderItemDto
{
public string ProductName { get; set; }
public decimal LineTotal { get; set; }
}
public class OrderDto
{
public int Id { get; set; }
public string CustomerName { get; set; }
public decimal TotalAmount { get; set; }
public List<OrderItemDto> Items { get; set; }
}
2 Projection με Select
var orderDtos = context.Orders
.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30))
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
TotalAmount = o.Items.Sum(i => i.Quantity * i.UnitPrice),
Items = o.Items
.Select(i => new OrderItemDto
{
ProductName = i.Product.Name,
LineTotal = i.Quantity * i.UnitPrice
})
.ToList()
})
.AsNoTracking()
.ToList();
3 Πού γίνεται το Mapping
Σε αυτό το παράδειγμα, το mapping από entity σε DTO γίνεται μέσα στο Select. Δηλαδή:
Το EF Core παίρνει τα entities (Order, Customer, OrderItem, Product) από τη βάση.
Απευθείας στο SQL query, μετατρέπει κάθε Order σε ένα OrderDto.
Για κάθε OrderItem δημιουργείται ένα OrderItemDto επίσης στο SQL επίπεδο.
Δεν χρειάζεται mapper ούτε μετάβαση από entities στη μνήμη για να φτιάξουμε τα DTO.
Με άλλα λόγια, η μετατροπή γίνεται μέσα στο query, πριν τα δεδομένα φτάσουν στην εφαρμογή. Αυτό σημαίνει ότι:
Η βάση επιστρέφει μόνο τα πεδία που χρειαζόμαστε.
Δεν δημιουργούνται full entities που καταναλώνουν μνήμη.
Ο υπολογισμός του TotalAmount γίνεται στη βάση με SUM()
4 Διαφορά από Include + Mapper
Αν αντί για projection χρησιμοποιούσαμε Include:
var orders = context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ToList();
var orderDtos = orders.Select(o => MapToDto(o)).ToList();
Τότε:
Φορτώνουμε ολόκληρα entities στη μνήμη.
Ο mapper τρέχει μετά τη μνήμη, δηλαδή μετατρέπουμε τα entities σε DTO αφού τα έχουμε ήδη φέρει όλα.
Αυτό κοστίζει περισσότερο σε μνήμη και χρόνο.
Στην πραγματική projection με Select, η βάση φτιάχνει τα DTO κατευθείαν, οπότε είναι πιο αποδοτικό.
Διάγραμμα Ροής – Projection με Select
Τι δείχνει αυτό το flow
Το mapping από Order → OrderDto και OrderItem → OrderItemDto γίνεται μέσα στο Select, δηλαδή στην SQL πριν φτάσει στη μνήμη.
Δεν δημιουργούμε full entities (Order, Customer, OrderItem) στη μνήμη — φέρνουμε μόνο τα πεδία που χρειάζεται το DTO.
Δεν χρειάζεται mapper ούτε custom μέθοδος, όλα γίνονται από το EF Core στο query.
Το αποτέλεσμα είναι μικρότερο σε μνήμη, πιο γρήγορο και έτοιμο για χρήση στο read side ή CQRS.

Top comments (0)