DEV Community

Cover image for Expression Mapping, Projection και Specification με ένα απλό παράδειγμα
nikosst
nikosst

Posted on

Expression Mapping, Projection και Specification με ένα απλό παράδειγμα

Όταν αναπτύσσουμε ένα API με EF Core, συνήθως ξεκινάμε από μια απλή ανάγκη, να διαβάσουμε κάποια δεδομένα και να τα επιστρέψουμε στον client. Γράφουμε το query, φορτώνουμε τα entities και τα μετατρέπουμε σε DTOs, ώστε η απάντηση να περιέχει τα πεδία που χρειάζεται η συγκεκριμένη λειτουργία.

Όταν χρειαζόμαστε πληροφορίες από σχετικές οντότητες, προσθέτουμε και ένα Include. Για παράδειγμα, μια λίστα παραγγελιών μπορεί να χρειάζεται το όνομα του πελάτη. Η φόρτωση της παραγγελίας μαζί με τον πελάτη φαίνεται μια φυσική επιλογή.

Σε αυτό το σημείο, όμως, υπάρχει μια λεπτομέρεια που εύκολα περνά απαρατήρητη, το ότι επιστρέφουμε ένα μικρό DTO δεν σημαίνει ότι διαβάσαμε και λίγα δεδομένα από τη βάση. Αν η μετατροπή γίνει αφού φορτωθούν τα entities, μπορεί να έχουμε ήδη μεταφέρει πεδία που η συγκεκριμένη απάντηση δεν χρησιμοποιεί.

Εδώ αποκτά αξία το projection, περιγράφουμε τη μορφή του αποτελέσματος μέσα στο ίδιο το query, ώστε το EF Core να γνωρίζει ποια δεδομένα χρειαζόμαστε πριν το εκτελέσει. Ένα mapping expression μάς επιτρέπει να κρατήσουμε αυτή την περιγραφή επαναχρησιμοποιήσιμη.

Με μια απλή λίστα παραγγελιών, θα δούμε πώς λειτουργεί αυτή η προσέγγιση, τι αλλάζει στο SQL και πώς συνδέεται με το Specification pattern. Θα εξετάσουμε επίσης πότε αξίζει να χρησιμοποιούμε αυτά τα εργαλεία, τι γίνεται όταν τα Include είναι πολλά και αν έχουν νόημα ακόμη και όταν τα entities μας είναι μικρά.

Ένα endpoint χρειάζεται να επιστρέψει τρία πεδία. Το query, όμως, φορτώνει δύο ολόκληρα entities, τα μετατρέπει σε DTOs και τελικά χρησιμοποιεί μόνο ένα μικρό μέρος των δεδομένων.

Ο κώδικας λειτουργεί. Η βάση και η εφαρμογή, όμως, κάνουν περισσότερη δουλειά από όση απαιτεί η συγκεκριμένη λειτουργία.

Ας δούμε πώς το projection αλλάζει αυτή τη συμπεριφορά, τι προσφέρει ένα mapping expression και πού συνδέεται το Specification pattern.

Το παράδειγμα θα είναι μια λίστα παραγγελιών

Έχουμε δύο entities:

  • Order: Id, Total, CustomerId, CreatedAt, InternalNotes.
  • Customer: Id, Name, Email, Phone.

Κάθε παραγγελία συνδέεται υποχρεωτικά με έναν πελάτη μέσω του navigation property Order.Customer.

Για τη λίστα μας χρειαζόμαστε μόνο:

public sealed record OrderSummaryDto(
    int Id,
    decimal Total,
    string CustomerName);
Enter fullscreen mode Exit fullscreen mode

Μια συνηθισμένη υλοποίηση είναι η εξής:

decimal minimumTotal = 100m;

var orders = await db.Orders
    .AsNoTracking()
    .Include(o => o.Customer)
    .Where(o => o.Total >= minimumTotal)
    .ToListAsync();

var result = orders
    .Select(o => new OrderSummaryDto(
        o.Id,
        o.Total,
        o.Customer.Name))
    .ToList();
Enter fullscreen mode Exit fullscreen mode

Στο ToListAsync() εκτελείται το query και δημιουργούνται τα Order και Customer objects στη μνήμη. Το mapping που ακολουθεί δουλεύει πάνω σε αυτά τα objects.

Το SQL έχει σχηματικά την παρακάτω μορφή:

SELECT
    o.Id,
    o.Total,
    o.CustomerId,
    o.CreatedAt,
    o.InternalNotes,
    c.Id,
    c.Name,
    c.Email,
    c.Phone
FROM Orders AS o
INNER JOIN Customers AS c ON o.CustomerId = c.Id
WHERE o.Total >= @minimumTotal;
Enter fullscreen mode Exit fullscreen mode

Χρησιμοποιούμε τρία πεδία, αλλά παραλαμβάνουμε περισσότερα.

Μια απαραίτητη διευκρίνιση, το Include δεν φέρνει όλες τις εγγραφές των πινάκων. Το Where εξακολουθεί να περιορίζει τις παραγγελίες. Το Include ζητά να φορτωθούν και οι σχετικοί πελάτες ως entities, με τις χαρτογραφημένες scalar ιδιότητές τους.

Επίσης, δεν φορτώνει αυτόματα κάθε άλλη σχέση του Customer. Αφορά τη σχέση που ζητήσαμε να συμπεριληφθεί.

Τεκμηρίωση: Eager Loading

Projection: ορίζουμε το αποτέλεσμα μέσα στο query

Projection είναι η επιλογή και διαμόρφωση των δεδομένων που θα αποτελέσουν το αποτέλεσμα. Στο LINQ εκφράζεται με το Select.

Αντί να φορτώσουμε πλήρη entities και μετά να τα μετατρέψουμε, περιγράφουμε το DTO όσο το query παραμένει IQueryable:

var result = await db.Orders
    .Where(o => o.Total >= minimumTotal)
    .Select(o => new OrderSummaryDto(
        o.Id,
        o.Total,
        o.Customer.Name))
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Το EF Core μπορεί πλέον να μεταφράσει το projection σε SQL που επιλέγει τις απαραίτητες στήλες:

SELECT
    o.Id,
    o.Total,
    c.Name AS CustomerName
FROM Orders AS o
INNER JOIN Customers AS c ON o.CustomerId = c.Id
WHERE o.Total >= @minimumTotal;
Enter fullscreen mode Exit fullscreen mode

Το SQL είναι ενδεικτικό· η ακριβής μορφή εξαρτάται από τον provider και το μοντέλο.

Το JOIN παραμένει, επειδή χρειαζόμαστε το όνομα του πελάτη. Η διαφορά είναι ότι δεν μεταφέρουμε τα υπόλοιπα πεδία και δεν δημιουργούμε πλήρη Order και Customer entities.

Το DTO κατασκευάζεται στη .NET από τις τιμές που επιστρέφει το query.

Δεν χρειαζόμαστε Include για αυτό το projection. Η έκφραση o.Customer.Name περιγράφει ήδη ποια πληροφορία της σχέσης απαιτείται.

Ας το δούμε πιο αναλυτικά. Το Include έχει νόημα όταν ζητάμε από το EF να φορτώσει ολόκληρο το σχετικό entity, για παράδειγμα όταν θέλουμε να το τροποποιήσουμε και να καλέσουμε SaveChanges. Στο projection δεν ζητάμε entities, ζητάμε συγκεκριμένες τιμές. Το EF διαβάζει την έκφραση, καταλαβαίνει ότι χρειάζεται το Customer.Name, δημιουργεί το JOIN μόνο του και επιλέγει μόνο τις στήλες που χρησιμοποιούνται.

Αν προσθέσουμε Include μαζί με ένα Select που αλλάζει το σχήμα του αποτελέσματος, το EF Core το αγνοεί. Δεν προσφέρει τίποτα και προσθέτει μόνο θόρυβο στον κώδικα.

Σενάριο Τι χρησιμοποιούμε
Queries που επιστρέφουν DTOs ή read μοντέλα Select, χωρίς Include
Commands που τροποποιούν entities Include μόνο για τις σχέσεις που χρειάζονται

Τεκμηρίωση: Project only properties you need

Τι είναι το mapping expression;

Αν το ίδιο projection χρησιμοποιείται σε περισσότερα σημεία, μπορούμε να το ορίσουμε μία φορά:

using System.Linq.Expressions;

public static class OrderMappings
{
    public static readonly Expression<Func<Order, OrderSummaryDto>>
        ToSummary = o => new OrderSummaryDto(
            o.Id,
            o.Total,
            o.Customer.Name);
}
Enter fullscreen mode Exit fullscreen mode

Και να το χρησιμοποιήσουμε απευθείας στο query:

var query = db.Orders
    .Where(o => o.Total >= minimumTotal)
    .Select(OrderMappings.ToSummary);

var result = await query.ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Ο όρος expression mapper περιγράφει εδώ ένα mapping γραμμένο ως expression tree.

Η διάκριση έχει σημασία:

  • Το Func<Order, OrderSummaryDto> είναι ένας delegate που εκτελείται πάνω σε ένα object.
  • Το Expression<Func<Order, OrderSummaryDto>> αναπαριστά τον κώδικα ως δομή δεδομένων, την οποία το EF Core μπορεί να αναλύσει και να μεταφράσει σε SQL.

Δεν σημαίνει ότι κάθε C# έκφραση μεταφράζεται. Πρέπει να υποστηρίζεται από το EF Core και τον συγκεκριμένο provider.

Το ξεχωριστό expression δεν προσφέρει καλύτερο SQL από το ισοδύναμο inline Select. Προσφέρει επαναχρησιμοποίηση, αναγνωσιμότητα και κοινό σημείο συντήρησης.

Το όφελος στη μεταφορά δεδομένων προκύπτει επειδή το projection συμμετέχει στο query πριν από την εκτέλεσή του.

Τεκμηρίωση: Expression Trees

Τι γίνεται όταν τα Include είναι πολλά;

Ας υποθέσουμε ότι ένα query χρειάζεται δέκα Include. Το πρόβλημα δεν βρίσκεται στο mapping expression. Βρίσκεται στο SQL και στον όγκο των δεδομένων που μεταφέρονται:

  1. Cartesian explosion. Όταν περισσότερα από ένα Include αφορούν collections (σχέσεις ένα-προς-πολλά), το JOIN πολλαπλασιάζει τις γραμμές. Με τρία collections των 100 στοιχείων η καθεμία, μία παραγγελία επιστρέφει 100 × 100 × 100 = 1.000.000 γραμμές. Τα πεδία του κύριου entity επαναλαμβάνονται σε κάθε γραμμή.
  2. Πλατιά αποτελέσματα. Με δέκα JOIN, ακόμα και σε απλές σχέσεις many-to-one, κάθε γραμμή περιέχει τις στήλες έντεκα πινάκων. Αυξάνονται η κίνηση στο δίκτυο, η μνήμη και ο χρόνος δημιουργίας των objects.
  3. Δυσκολότερο query plan. Πολλά JOIN δυσκολεύουν τον optimizer και την αξιοποίηση των indexes, και η απόδοση γίνεται λιγότερο προβλέψιμη όσο μεγαλώνουν τα δεδομένα.
  4. Change tracking. Όλα τα entities παρακολουθούνται, κάτι που κοστίζει σε μνήμη και CPU. Στα reads αυτό αποφεύγεται με AsNoTracking().

Τι κάνουμε σε αυτή την περίπτωση:

  • Για reads, projection. Ζητάμε μόνο τις στήλες που χρειάζεται το DTO, ακόμα κι αν το μοντέλο έχει δέκα σχέσεις.
  • Όταν χρειάζονται entities και υπάρχουν πολλά collections, AsSplitQuery(). Το EF εκτελεί ξεχωριστό query για κάθε collection και αποφεύγεται το καρτεσιανό γινόμενο. Το αντίτιμο είναι περισσότερα round trips και πιθανή ασυνέπεια αν τα δεδομένα αλλάξουν ανάμεσα στα queries.
  • Όταν ένα DTO χρειάζεται πολλά collections, χωρίζουμε το αποτέλεσμα σε περισσότερα queries (π.χ. κύρια δεδομένα και ξεχωριστό query για τις γραμμές) και τα ενώνουμε στην εφαρμογή.

Προσοχή και στο projection με πολλά πεδία. Το EF μεταφράζει σε SQL ό,τι μπορεί, και η τελική κατασκευή του DTO γίνεται στη .NET. Δεν βάζουμε μέσα στο Select δικές μας μεθόδους ή πολύπλοκη λογική που δεν μεταφράζεται σε SQL, γιατί το EF είτε θα πετάξει exception είτε θα φέρει περισσότερα δεδομένα από όσα χρειάζεται. Κρατάμε το Select απλό και κάνουμε το υπόλοιπο mapping μετά το ToListAsync().

Τεκμηρίωση: Single vs. split queries

Πού μπαίνει το Specification;

Το Specification pattern οργανώνει κανόνες σε μια ονομασμένη, επαναχρησιμοποιήσιμη μονάδα.

Στην κλασική του μορφή περιγράφει ένα κριτήριο: για παράδειγμα, «παραγγελίες με ποσό μεγαλύτερο από ένα όριο».

Σε query-oriented υλοποιήσεις μπορεί να συγκεντρώνει επίσης ταξινόμηση, pagination, includes και projection.

Έννοια Τι περιγράφει
Expression tree Τον κώδικα σε μορφή που μπορεί να αναλυθεί και να μεταφραστεί.
Projection Τη μορφή και τα πεδία του αποτελέσματος.
Query specification Τους κανόνες ενός συγκεκριμένου query, ενδεχομένως μαζί με το projection.

Για παράδειγμα, με τη βιβλιοθήκη Ardalis.Specification:

using Ardalis.Specification;

public sealed class OrdersAboveTotalSpec
    : Specification<Order, OrderSummaryDto>
{
    public OrdersAboveTotalSpec(decimal minimumTotal)
    {
        Query
            .Where(o => o.Total >= minimumTotal)
            .Select(OrderMappings.ToSummary);
    }
}
Enter fullscreen mode Exit fullscreen mode

Εδώ η specification συγκεντρώνει το κριτήριο αναζήτησης και χρησιμοποιεί το mapping expression για το αποτέλεσμα.

Τα δύο εργαλεία συνεργάζονται. Δεν χρειάζεται όμως να υιοθετήσουμε specifications για να χρησιμοποιήσουμε projection, ούτε κάθε specification πρέπει να επιστρέφει DTO.

Τεκμηρίωση: Select στο Ardalis.Specification

Έχει νόημα ακόμη και με μικρά entities;

Ναι. Δεν είναι κακή πρακτική να χρησιμοποιούμε expressions επειδή το entity έχει λίγα πεδία.

Για ένα read endpoint, το projection αποτυπώνει καθαρά τι χρειάζεται η συγκεκριμένη λειτουργία. Αν αργότερα προστεθούν πεδία στο entity, το συγκεκριμένο DTO projection συνεχίζει να ζητά τις ίδιες τιμές.

Το μετρήσιμο όφελος εξαρτάται από το μέγεθος των πεδίων, τον αριθμό των αποτελεσμάτων και τη συχνότητα εκτέλεσης. Σε ένα μικρό και σπάνιο query, η διαφορά μπορεί να είναι αμελητέα.

Η χρήσιμη διάκριση είναι:

  • Χρησιμοποιούμε Select για να διαμορφώσουμε ένα read result.
  • Εξάγουμε mapping expression όταν επαναχρησιμοποιείται ή κάνει τον κώδικα σαφέστερο.
  • Προσθέτουμε specification όταν οι κανόνες αναζήτησης χρειάζονται δική τους οργάνωση.

Δεν χρειάζεται ένα απλό query να αποκτήσει πολλές abstractions για να θεωρηθεί σωστό.

Οι λεπτομέρειες που κάνουν τη διαφορά

Το projection πρέπει να παραμένει μέσα στο IQueryable. Αν προηγηθεί ToList(), το query έχει ήδη εκτελεστεί. Αν περάσουμε σε AsEnumerable(), οι επόμενες LINQ πράξεις εκτελούνται στην εφαρμογή.

Αντίστοιχα, αποφεύγουμε αυτό:

db.Orders.Select(OrderMappings.ToSummary.Compile());
Enter fullscreen mode Exit fullscreen mode

Το .Compile() μετατρέπει το expression σε delegate. Στη συγκεκριμένη κλήση επιλέγεται το Enumerable.Select, οπότε το mapping εκτελείται στη μνήμη.

Επίσης, μια custom μέθοδος μέσα σε expression δεν αποκτά αυτόματα SQL μετάφραση. Στο τελικό projection μπορεί να εκτελεστεί στην εφαρμογή, ενώ μια μη μεταφράσιμη συνθήκη στο Where συνήθως προκαλεί exception.

Τεκμηρίωση: Client vs. Server Evaluation

Το AsNoTracking() αφορά διαφορετικό θέμα: το change tracking. Δεν μειώνει τις επιλεγμένες στήλες. Στο δικό μας projection, που επιστρέφει μόνο τιμές μέσα σε DTO και κανένα entity instance, δεν υπάρχουν entities για tracking.

Τεκμηρίωση: Tracking and custom projections

Αν χρησιμοποιούμε AutoMapper, η αντίστοιχη δυνατότητα για query projection είναι το ProjectTo<TDto>(). Το mapper.Map πάνω σε ήδη φορτωμένα entities δεν μπορεί να αλλάξει τα δεδομένα που παραλάβαμε από τη βάση.

Τεκμηρίωση: AutoMapper Queryable Extensions

Για αναγνώσεις που επιστρέφουν DTOs, το projection είναι μια πρακτική προεπιλογή. Όταν χρειάζεται να φορτώσουμε entities για αλλαγές και εκτέλεση domain behavior, η φόρτωση του κατάλληλου entity graph εξακολουθεί να έχει θέση.

Και για να ελέγξουμε τι ζητά πραγματικά το query μας από τη βάση:

var sql = query.ToQueryString();
Enter fullscreen mode Exit fullscreen mode

Η αξία της επιλογής φαίνεται στο παραγόμενο SQL και στις ανάγκες της λειτουργίας που υλοποιούμε.


Να θυμάσαι..!

Το Specification οργανώνει τα κριτήρια αναζήτησης, το Expression Mapping επιτρέπει τη μεταφορά εκφράσεων ανάμεσα σε διαφορετικά μοντέλα και το Projection καθορίζει τη μορφή των δεδομένων που επιστρέφουμε.

Συνδυάζοντάς τα, μπορούμε να χτίσουμε queries με σαφείς ευθύνες και λιγότερη επανάληψη, που προσαρμόζονται ευκολότερα στις αλλαγές της εφαρμογής. Η πραγματική τους αξία φαίνεται όταν προσθέτουμε ένα νέο φίλτρο ή αλλάζουμε ένα DTO και η υπάρχουσα δομή μάς βοηθά να κάνουμε την αλλαγή σε συγκεκριμένα σημεία. Αυτό είναι και το ζητούμενο, κώδικας που παραμένει κατανοητός και εύκολος στην εξέλιξη, όσο οι απαιτήσεις μεγαλώνουν.

Top comments (0)