Τεχνικό Άρθρο

Εγγραφή XLSX Ενός Εκατομμυρίου Γραμμών στη Delphi με Σταθερή Μνήμη

Μια εργασία δημιουργίας αναφορών (reporting job) εκτελείται μια χαρά για έναν χρόνο. Κατασκευάζει ένα βιβλίο εργασίας, γεμίζει ένα φύλλο με ό,τι επιστρέφει το ερώτημα (query), και το αποθηκεύει. Στη συνέχεια, ένας πελάτης με ιστορικό πέντε ετών ζητά μια πλήρη εξαγωγή, ο αριθμός των γραμμών ξεπερνά το ένα εκατομμύριο, και η διαδικασία πεθαίνει με ένα σφάλμα έλλειψης μνήμης (out-of-memory error) πολύ πριν το αρχείο φτάσει στο δίσκο. Τίποτα δεν ήταν λάθος με τον κώδικα. Διατηρούσε ολόκληρο το βιβλίο εργασίας στη μνήμη RAM ώστε να μπορέσει να το σειριοποιήσει (serialise) στο τέλος, και η μνήμη που χρειαζόταν αυξανόταν συμβαδίζοντας (in lockstep) με τον αριθμό των γραμμών που του ζητήθηκε να γράψει

Η λύση (fix) δεν είναι ένα μεγαλύτερο μηχάνημα. Είναι ένα διαφορετικό μοντέλο εγγραφής (writing model). Ο άμεσος εγγραφέας ροής (streaming direct writer) στο HotXLS εκπέμπει (emits) το πακέτο OOXML σταδιακά (incrementally) καθώς καταφθάνουν οι γραμμές, οπότε η μνήμη που χρησιμοποιεί δεν εξαρτάται από το πόσες γραμμές γράφετε. Είναι το αντίστοιχο κομμάτι από την πλευρά της εγγραφής του αναγνώστη ροής (streaming reader): όπου ο αναγνώστης διατρέχει ένα τεράστιο φύλλο χωρίς να χτίσει ένα δέντρο κελιών, ο εγγραφέας παράγει ένα φύλλο χωρίς ούτε αυτός να χτίσει δέντρο κελιών

Γιατί η κανονική διαδρομή αποθήκευσης αυξάνεται με τα δεδομένα

Η κανονική διαδρομή φόρτωσης (save path) του TXLSXWorkbook χτίζει πρώτα ένα πλήρες μοντέλο αντικειμένων. Κάθε κελί, με την τιμή, τον τύπο, και την αναφορά του στυλ του, ζει ως αντικείμενο στη μνήμη μέχρι να καλέσετε την αποθήκευση (save), οπότε και ολόκληρο το δέντρο σειριοποιείται (serialised) μέσα στο πακέτο. Αυτό το μοντέλο είναι το σωστό όταν θέλετε να διαβάσετε ένα φύλλο, να το επεξεργαστείτε, να επανυπολογίσετε, και να το γράψετε πίσω, επειδή η τυχαία προσπέλαση σε οποιοδήποτε κελί είναι ακριβώς αυτό που χρειάζεται η επεξεργασία. Είναι το λάθος μοντέλο όταν απλά αδειάζετε (pouring) γραμμές προς μία κατεύθυνση και δεν κοιτάτε ποτέ πίσω, επειδή πληρώνετε για να κρατήσετε κάθε γραμμή στη μνήμη (resident) χωρίς κανένα όφελος. Ένα εκατομμύριο γραμμές αντικειμένων παραμένουν ένα εκατομμύριο γραμμές αντικειμένων είτε τα επισκεφτείτε ξανά είτε όχι

Ο εγγραφέας ροής (streaming writer) αφαιρεί το δέντρο. Μόλις γραφτεί ένα κελί, μετατρέπεται σε bytes μέσα στο τμήμα του φύλλου εργασίας (worksheet part), και αυτά τα bytes παραδίδονται στην έξοδο (output) του zip. Η ροή του φύλλου εργασίας (worksheet stream) είναι το μόνο buffer (ενδιάμεση μνήμη) που αυξάνεται, και αυξάνεται από την πλευρά της εξόδου, όχι ως ζωντανά αντικείμενα της Delphi στο σωρό (heap). Αυτό που μένει στη μνήμη (resident) είναι ένας σταθερός όγκος τήρησης στοιχείων (bookkeeping): τα ονόματα των φύλλων, μερικές σημαίες (flags), ο τρέχων αριθμός γραμμής, ένας μετρητής κελιών. Αυτό το σύνολο δεν αλλάζει μεταξύ της πρώτης και της δέκατης εκατομμυριοστής γραμμής

Ο πίνακας κοινόχρηστων συμβολοσειρών (shared-string table) είναι η παγίδα, και οι ενσωματωμένες συμβολοσειρές (inline strings) η διέξοδος

Οι περισσότεροι εγγραφείς ροής XLSX (streaming XLSX writers) τα πηγαίνουν καλά μέχρι να συναντήσουν κείμενο. Η μορφή OOXML συνήθως αποθηκεύει τις συμβολοσειρές σε έναν πίνακα κοινόχρηστων συμβολοσειρών (shared-string table): κάθε διακριτή συμβολοσειρά γράφεται μια φορά σε ξεχωριστό τμήμα, και κάθε κελί που περιέχει αυτήν τη συμβολοσειρά φέρει έναν δείκτη προς τον πίνακα αντί για το κείμενο. Αποτελεί μια καλή βελτιστοποίηση χώρου για αρχεία γεμάτα με επαναλαμβανόμενες ετικέτες, και είναι η προεπιλογή που χρησιμοποιεί η τυπική διαδρομή αποθήκευσης (standard save path). Το πρόβλημα για έναν εγγραφέα ροής είναι βάναυσο. Για να γίνει η αποδιπλοποίηση (deduplicate), ο πίνακας πρέπει να παραμείνει στη μνήμη για ολόκληρη την εργασία, επειδή οποιαδήποτε μελλοντική γραμμή μπορεί να επαναλάβει μια συμβολοσειρά από μια γραμμή που έχει ήδη γραφτεί, και μόνο ένας πλήρης χάρτης (map) στη μνήμη με τις ήδη ειδωμένες συμβολοσειρές μπορεί να εκχωρήσει (assign) τον σωστό δείκτη. Έτσι, η μόνη δομή που ένας εγγραφέας ροής δεν μπορεί να κάνει ροή (stream) είναι ακριβώς η δομή που υποτίθεται ότι κάνει το αρχείο μικρό. Τα δεδομένα με μεγάλο όγκο κειμένου (text-heavy data) ακυρώνουν (defeats) τη ροή για την οποία ήρθατε

Ο άμεσος εγγραφέας παρακάμπτει (sidesteps) τον πίνακα εξ ολοκλήρου. Οι συμβολοσειρές γράφονται ενσωματωμένες (inline), ως κελιά t="inlineStr" των οποίων το κείμενο κάθεται απευθείας μέσα στο κελί με ένα στοιχείο <is><t>. Δεν υπάρχει πίνακας για συσσώρευση και κανένας χάρτης (map) με είδωμένες (seen) συμβολοσειρές για να διατηρηθεί, οπότε οι στήλες κειμένου δεν κοστίζουν περισσότερη μνήμη από τις αριθμητικές. Η ανταλλαγή (trade) είναι ρητή (explicit) και αξίζει να δηλωθεί ξεκάθαρα. Οι ενσωματωμένες (inline) συμβολοσειρές επαναλαμβάνουν το ίδιο κείμενο όπου κι αν εμφανίζεται, οπότε ένα αρχείο με πολλές πανομοιότυπες ετικέτες είναι μεγαλύτερο στον δίσκο από το αντίστοιχο με κοινόχρηστες (shared) συμβολοσειρές. Ξοδεύετε μέγεθος αρχείου για να αγοράσετε σταθερή μνήμη. Για μια εξαγωγή ενός περάσματος (one-pass export), αυτή είναι η σωστή πλευρά της ανταλλαγής, και η συμπίεση zip απορροφά ούτως ή άλλως μεγάλο μέρος της επανάληψης κατά την έξοδο

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

Τα στυλ παρουσιάζουν την ίδια ένταση με τις συμβολοσειρές. Ένα βιβλίο εργασίας παραπέμπει (references) στη μορφοποίησή του μέσω ενός τμήματος στυλ (styles part), και ένας εγγραφέας ροής δεν μπορεί να διατηρήσει μια αυξανόμενη παλέτα (palette) στυλ σε συγχρονισμό με τα κελιά που έχει ήδη ξεπλύνει (flushed). Ο άμεσος εγγραφέας το απαντά αυτό διατηρώντας τον πίνακα στυλ (style table) μικρό και σταθερό, και εκπέμποντάς τον κατά το κλείσιμο (close) αντί εξαρχής (up front). Μία προεπιλεγμένη μορφοποίηση κελιού καλύπτει τα συνηθισμένα κελιά. Μία αριθμητική μορφοποίηση ημερομηνίας καλύπτει τις ημερομηνίες, καταχωρημένη (registered) με κωδικό μορφοποίησης yyyy-mm-dd σε γνωστή θέση στη λίστα μορφοποιήσεων κελιών

Αυτή η μορφοποίηση ημερομηνίας είναι ο λόγος που υπάρχει η WriteDateTime ως δική της κλήση. Το Excel δεν έχει εγγενή (native) τύπο ημερομηνίας· μια ημερομηνία είναι ένας αριθμός που φοράει (wearing) μια μορφοποίηση ημερομηνίας. Το WriteDateTime γράφει την τιμή ως έναν απλό σειριακό αριθμό και βάζει ετικέτα (tags) στο κελί με το ένα στυλ ημερομηνίας, έτσι το υπολογιστικό φύλλο την αποδίδει (renders) ως ημερομηνία αντί για έναν πενταψήφιο ακέραιο. Ο σειριακός αριθμός που γράφει έχει σημασία για την αμφίδρομη μετάβαση (round-tripping). Αποθηκεύει την τιμή TDateTime απευθείας (directly) στο σύστημα ημερομηνίας 1900 (1900 date system), το οποίο είναι η ίδια σύμβαση που χρησιμοποιεί η κανονική διαδρομή αποθήκευσης TXLSXWorkbook. Επειδή και οι δύο διαδρομές (paths) συμφωνούν ως προς τον σειριακό αριθμό (serial), ένα αρχείο που παράγει ο εγγραφέας ροής διαβάζεται πίσω από τον αναγνώστη του HotXLS και ανοίγει στο Excel με ημερομηνίες που ταιριάζουν με αυτές που σκοπεύατε, χωρίς εκπλήξεις σφάλματος κατά ένα (off-by-one) ή εποχής (epoch surprise) μεταξύ του εγγραφέα και του αναγνώστη

Η σειρά είναι υποχρεωτική, επειδή τα bytes έχουν ήδη φύγει

Η ροή (streaming) αγοράζει το προφίλ μνήμης της (memory profile) με έναν κανόνα που πρέπει να τηρήσετε. Η έξοδος (output) εκπέμπεται καθώς προχωράτε και δεν μπορείτε να την επισκεφτείτε ξανά, επομένως όλα πρέπει να γραφτούν με τη σειρά που εμφανίζονται στο αρχείο. Μέσα σε μια γραμμή, τα κελιά πηγαίνουν με αύξουσα σειρά (ascending order) στηλών. Μέσα σε ένα φύλλο, οι γραμμές πηγαίνουν με αύξουσα σειρά. Δεν υπάρχει buffer (ενδιάμεση μνήμη) που να επιτρέπει στον εγγραφέα να ταξινομήσει (sort) τα κελιά σας εκ των υστέρων (after the fact), επειδή η γραμμή που κλείσατε πριν από λίγο είναι ήδη bytes στη ροή zip (zip stream) και δεν είναι πλέον προσβάσιμη. Δώστε του τη στήλη 5 και μετά τη στήλη 2 στην ίδια γραμμή και η έξοδος είναι κακοσχηματισμένη (malformed), αφού ο εγγραφέας απλώς εκπέμπει (emits) αυτό που του δίνετε με τη σειρά που του το δίνετε

Το API γραμμών έχει μια μικρή ευκολία (convenience) για την πιο συνηθισμένη περίπτωση. Το AddRow παίρνει έναν δείκτη γραμμής με βάση το 1 (1-based), αλλά το να περάσετε 0 σημαίνει πάρτε την επόμενη γραμμή μετά την προηγούμενη, ώστε ένα διαδοχικό γέμισμα (sequential fill) να μην χρειάζεται να παρακολουθεί και να περνάει έναν αυξανόμενο μετρητή. Κάθε AddRow κλείνει τη γραμμή πριν από αυτήν, και κάθε AddSheet κλείνει το φύλλο πριν από αυτό, επομένως δεν τερματίζετε ποτέ ρητά (explicitly end) μια γραμμή ή ένα φύλλο. Ξεκινάτε το επόμενο και ο εγγραφέας οριστικοποιεί (finalises) την ανοιχτή δομή για εσάς

Η διαφυγή (escaping) χειρίζεται εκεί που το κείμενο εισέρχεται στην XML

Οποιοδήποτε κείμενο γράφετε γίνεται μέρος ενός εγγράφου XML, επομένως οι πέντε προκαθορισμένες οντότητες (entities) XML πρέπει να υποστούν διαφυγή (escaped) αλλιώς το πακέτο είναι άκυρο (invalid) τη στιγμή που μια τιμή περιέχει ένα σύμβολο συμπλοκής (ampersand) ή μια γωνιώδη αγκύλη (angle bracket). Ο εγγραφέας κάνει διαφυγή (escapes) τα &, <, >, ", και ' για εσάς τόσο στο κείμενο των ενσωματωμένων (inline) συμβολοσειρών όσο και στο κείμενο των τύπων, τα δύο μέρη όπου χαρακτήρες που παρέχονται από τον καλούντα (caller-supplied) προσγειώνονται (land) μέσα στη σήμανση (markup). Εσείς περνάτε ένα ακατέργαστο WideString και ο εγγραφέας το καθιστά ασφαλές (safe). Ένα όνομα προϊόντος όπως το Smith & Co <Ltd> ή ένας τύπος που αναφέρεται σε ένα όνομα φύλλου μέσα σε εισαγωγικά βγαίνει ως καλά σχηματισμένη XML χωρίς καμία διαφυγή από την πλευρά σας

Κύκλος ζωής (Lifecycle), και γιατί το Destroy εξακολουθεί να κλείνει

Ολοκληρώνοντας (finishing) το πακέτο είναι αυτό που γράφει το τμήμα του βιβλίου εργασίας (workbook part), το τμήμα των στυλ, τους τύπους περιεχομένου (content-types) και τα τμήματα συσχετίσεων (relationship parts), και τέλος τον κεντρικό κατάλογο (central directory) του zip. Αυτή η εργασία συμβαίνει στο Close. Ένα πακέτο που δεν κλείνει ποτέ είναι ένα ημιτελές zip που κανένα πρόγραμμα υπολογιστικών φύλλων δεν θα ανοίξει, επομένως το κλείσιμο (closing) δεν είναι προαιρετικός καθαρισμός, είναι το βήμα που καθιστά το αρχείο έγκυρο. Για να προστατευτεί από ένα ξεχασμένο Close σε μια διαδρομή σφάλματος (error path), η μέθοδος Destroy εκτελεί ένα κλείσιμο βέλτιστης προσπάθειας (best-effort close) εάν το πακέτο είναι ακόμα ανοιχτό, οπότε η απελευθέρωση (freeing) του εγγραφέα δεν διαρρέει (leak) το υποκείμενο αντικείμενο zip ακόμη και όταν μια εξαίρεση (exception) παρέλειψε τη ρητή (explicit) κλήση. Το αξιόπιστο (reliable) μοτίβο εξακολουθεί να είναι το συνηθισμένο μοτίβο της Delphi: γράψτε (write) μέσα σε ένα try, καλέστε το Close, και απελευθερώστε (free) στο finally

Ροή (Streaming) ενός μεγάλου φύλλου από άκρη σε άκρη (end to end)

Το σχήμα (shape) της εργασίας είναι: αρχή (begin), προσθήκη φύλλου (add a sheet), άδειασμα γραμμών (pour rows), κλείσιμο (close). Το παρακάτω παράδειγμα γράφει μια γραμμή κεφαλίδας (header row) και στη συνέχεια μια μεγάλη σειρά από γραμμές τυποποιημένων δεδομένων (typed data rows), αναμειγνύοντας συμβολοσειρές, αριθμούς, έναν τύπο χωρίς αποθηκευμένο αποτέλεσμα (cached result), και μια ημερομηνία. Η μνήμη που χρησιμοποιεί για δέκα γραμμές και για δέκα εκατομμύρια γραμμές είναι η ίδια, επειδή κάθε κελί φεύγει για τη ροή zip μόλις γραφτεί

uses
  lxDirectWrite;

procedure StreamReport(const Path: string; RowCount: Integer);
var
  W: TXLSDirectWriter;
  I: Integer;
begin
  W := TXLSDirectWriter.Create;
  try
    W.BeginFile(Path);
    W.AddSheet('Sales');

    // Header row, written in ascending column order
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // Data rows; pass 0 to AddRow to take the next row automatically
    for I := 1 to RowCount do
    begin
      W.AddRow(0);
      W.WriteString(1, 'Item ' + IntToStr(I));
      W.WriteNumber(2, I);
      W.WriteNumber(3, 1.5 + (I mod 10));
      W.WriteFormula(4, Format('B%d*C%d', [I + 1, I + 1]));
      W.WriteDateTime(5, EncodeDate(2026, 1, 1) + I);
    end;

    W.Close;                       // finalises the package
  finally
    W.Free;
  end;
end;

Ένα δεύτερο φύλλο είναι απλώς ένα άλλο AddSheet πριν συνεχίσετε, και ο εγγραφέας κλείνει το πρώτο φύλλο καθώς ανοίγει το δεύτερο. Οι λογικές (boolean) σημαίες χρησιμοποιούν το WriteBoolean, το οποίο γράφει ένα τυποποιημένο (typed) κελί boolean (λογικής τιμής) αντί για το κείμενο "True". Εάν θέλετε να επιβεβαιώσετε ότι το αρχείο είναι υγιές (sound) και υφίσταται αμφίδρομη μετάβαση (round-trips), η ιδιότητα CellCount αναφέρει πόσα κελιά γράφτηκαν, και διαβάζοντας (reading) το αποτέλεσμα πίσω με τον αναγνώστη ροής θα πρέπει να αναφέρει το ίδιο σύνολο

  // A second sheet of typed flags after the data sheet above
  W.AddSheet('Flags');
  W.AddRow(1);
  W.WriteString(1, 'Name');
  W.WriteString(2, 'Active');
  W.AddRow(0);
  W.WriteString(1, 'alpha');
  W.WriteBoolean(2, True);

  WriteLn(Format('wrote %d cells', [W.CellCount]));

Η εγγραφή σε μια ροή (stream) αντί σε ένα αρχείο είναι ο ίδιος κώδικας με το BeginStream στη θέση του BeginFile, κάτι που επιτρέπει σε έναν διακομιστή να στείλει το βιβλίο εργασίας σε μια απόκριση (response) HTTP ή σε μια ροή μνήμης (memory stream) χωρίς ένα προσωρινό αρχείο (temporary file) στον δίσκο. Ο εγγραφέας (writer) δεν κατέχει (own) τη ροή που περνάτε, επομένως διατηρείτε τον έλεγχο της διάρκειας ζωής (lifetime) της

Όταν η εργασία (work) είναι ένα τελικό σημείο (endpoint) διακομιστή που κατασκευάζει βιβλία εργασίας κατ' απαίτηση (on demand), τα μοτίβα στο άρθρο εγγραφές ροής (streaming writes) για εργασίες διακομιστή και δέσμης (server and batch jobs) δείχνουν πώς να το συνδέσετε (wire) σε έναν χειριστή αιτημάτων (request handler) και σε μια προγραμματισμένη εξαγωγή. Όταν το ερώτημα είναι το ευρύτερο (wider) κόστος των πολύ μεγάλων βιβλίων εργασίας, τόσο όσον αφορά την ανάγνωση όσο και την εγγραφή, η απόδοση μεγάλων βιβλίων εργασίας στη Delphi καλύπτει πού πραγματικά πηγαίνει (go) ο χρόνος και η μνήμη. Ο άμεσος εγγραφέας ροής (streaming direct writer) αποστέλλεται ως μέρος του στοιχείου HotXLS για Delphi και C++Builder, μαζί με τα πλήρη API ανάγνωσης, επεξεργασίας και αποθήκευσης που καλύπτονται αλλού σε αυτό το ιστολόγιο