Une tâche de création de rapports s'exécute correctement pendant un an. Elle crée un classeur, remplit une feuille avec tout ce que la requête renvoie et l'enregistre. Ensuite, un client ayant cinq ans d'historique demande une exportation complète, le nombre de lignes dépasse le million, et le processus meurt avec une erreur de mémoire insuffisante bien avant que le fichier n'atteigne le disque. Il n'y avait aucun problème avec le code. Il conservait l'intégralité du classeur dans la RAM afin de pouvoir le sérialiser à la fin, et la mémoire dont il avait besoin augmentait en parallèle avec le nombre de lignes qu'on lui demandait d'écrire
La solution n'est pas une machine plus puissante. Il s'agit d'un modèle d'écriture différent. L'écrivain direct en continu de HotXLS émet le package OOXML de manière incrémentielle au fur et à mesure que les lignes arrivent, de sorte que la mémoire qu'il utilise ne dépend pas du nombre de lignes que vous écrivez. Il est l'équivalent côté écriture du lecteur de flux : là où le lecteur parcourt une immense feuille sans construire d'arborescence de cellules, l'écrivain en produit une sans construire d'arborescence de cellules non plus
Pourquoi le chemin d'enregistrement normal augmente avec les données
Le chemin habituel de TXLSXWorkbook construit d'abord un modèle objet complet. Chaque cellule, avec sa valeur, son type et sa référence de style, vit en tant qu'objet en mémoire jusqu'à ce que vous appeliez l'enregistrement, moment auquel l'arborescence entière est sérialisée dans le package. Ce modèle est le bon lorsque vous souhaitez lire une feuille, la modifier, la recalculer et la réécrire, car l'accès aléatoire à n'importe quelle cellule est exactement ce dont l'édition a besoin. C'est le mauvais lorsque vous versez des lignes dans une seule direction et ne revenez jamais en arrière, car vous payez pour garder chaque ligne résidente sans aucun avantage. Un million de lignes d'objets reste un million de lignes d'objets, que vous y reveniez un jour ou non
L'écrivain de flux supprime l'arborescence. Dès qu'une cellule est écrite, elle devient des octets dans la partie feuille de calcul, et ces octets sont transmis à la sortie zip. Le flux de la feuille de calcul est le seul tampon qui croît, et il croît du côté de la sortie, non pas sous forme d'objets Delphi actifs sur le tas. Ce qui reste résident est une quantité fixe de tenue de livres : les noms des feuilles, quelques drapeaux, le numéro de la ligne en cours, un compteur de cellules. Cet ensemble ne change pas entre la ligne un et la ligne dix millions
La table des chaînes partagées est le piège, et les chaînes en ligne sont la porte de sortie
La plupart des écrivains XLSX de flux fonctionnent bien jusqu'à ce qu'ils rencontrent du texte. Le format OOXML stocke normalement les chaînes dans une table de chaînes partagées : chaque chaîne distincte est écrite une fois dans une partie séparée, et chaque cellule contenant cette chaîne porte un index dans la table au lieu du texte. C'est une bonne optimisation de l'espace pour les fichiers remplis d'étiquettes répétées, et c'est la valeur par défaut utilisée par le chemin d'enregistrement standard. Le problème pour un écrivain de flux est brutal. Pour dédupliquer, la table doit rester résidente pendant tout le travail, car toute ligne à venir pourrait répéter une chaîne d'une ligne déjà écrite, et seule une carte complète en mémoire des chaînes vues peut attribuer le bon index. Ainsi, la seule structure qu'un écrivain de flux ne peut pas diffuser est la structure même qui est censée réduire la taille du fichier. Les données riches en texte mettent en échec le flux pour lequel vous êtes venu
L'écrivain direct contourne entièrement la table. Les chaînes sont écrites en ligne, sous forme de cellules t="inlineStr" dont le texte se trouve directement à l'intérieur de la cellule avec un élément <is><t>. Il n'y a pas de table à accumuler ni de carte de chaînes vues à conserver, de sorte que les colonnes de texte ne coûtent pas plus de mémoire que les colonnes numériques. L'échange est explicite et mérite d'être clairement énoncé. Les chaînes en ligne répètent le même texte partout où il apparaît, un fichier avec de nombreuses étiquettes identiques est donc plus volumineux sur le disque que l'équivalent de la chaîne partagée. Vous dépensez la taille du fichier pour acheter une mémoire constante. Pour une exportation en un seul passage, c'est le bon côté de l'échange, et la compression zip absorbe de toute façon une grande partie de la répétition à la sortie
La table de styles arrive à la fin, avec un format de date
Les styles présentent la même tension que les chaînes. Un classeur référence sa mise en forme via une partie styles, et un écrivain de flux ne peut pas maintenir une palette croissante de styles en phase avec les cellules qu'il a déjà vidées. L'écrivain direct répond à cela en gardant la table de styles petite et fixe, et en l'émettant à la fermeture plutôt qu'à l'avance. Un format de cellule par défaut couvre les cellules ordinaires. Un format de nombre de dates couvre les dates, enregistré avec un code de format de yyyy-mm-dd à une position connue dans la liste des formats de cellules
Ce format de date est la raison pour laquelle WriteDateTime existe en tant qu'appel propre. Excel n'a pas de type de date natif ; une date est un nombre portant un format de date. WriteDateTime écrit la valeur comme un simple numéro de série et balise la cellule avec le seul style de date, de sorte que la feuille de calcul la rend comme une date au lieu d'un nombre entier à cinq chiffres. Le numéro de série qu'il écrit est important pour l'aller-retour. Il stocke la valeur TDateTime directement sous le système de date de 1900, qui est la même convention utilisée par le chemin d'enregistrement normal TXLSXWorkbook. Étant donné que les deux chemins s'accordent sur le numéro de série, un fichier produit par l'écrivain de flux se relit via le lecteur HotXLS et s'ouvre dans Excel avec des dates qui correspondent à ce que vous vouliez, sans surprise de décalage d'un jour ou d'époque entre l'écrivain et le lecteur
L'ordre est obligatoire, car les octets ont déjà disparu
Le streaming achète son profil de mémoire avec une règle que vous devez respecter. La sortie est émise au fur et à mesure et ne peut pas être revisitée, tout doit donc être écrit dans l'ordre où il apparaît dans le fichier. Dans une ligne, les cellules vont dans l'ordre croissant des colonnes. Dans une feuille, les lignes vont dans l'ordre croissant. Il n'y a pas de tampon qui permet à l'écrivain de trier vos cellules après coup, car la ligne que vous avez fermée il y a un instant est déjà composée d'octets dans le flux zip et n'est plus accessible. Transmettez-lui la colonne 5, puis la colonne 2 dans la même ligne et la sortie est mal formée, car l'écrivain émet simplement ce que vous lui donnez dans l'ordre dans lequel vous le lui donnez
L'API de ligne a une petite commodité pour le cas courant. AddRow prend un index de ligne basé sur 1, mais passer 0 signifie prendre la ligne suivante après la précédente, de sorte qu'un remplissage séquentiel n'a pas à suivre et à passer un compteur qui s'incrémente. Chaque AddRow ferme la ligne qui le précède, et chaque AddSheet ferme la feuille qui la précède, vous ne terminez donc jamais explicitement une ligne ou une feuille. Vous commencez la suivante et l'écrivain finalise la structure ouverte pour vous
L'échappement est géré là où le texte entre dans le XML
Tout texte que vous écrivez devient une partie d'un document XML, les cinq entités XML prédéfinies doivent donc être échappées, sinon le package n'est pas valide dès qu'une valeur contient une esperluette ou un chevron. L'écrivain échappe &, <, >, " et ' pour vous à la fois sur le texte de la chaîne en ligne et sur le texte de la formule, les deux endroits où les caractères fournis par l'appelant atterrissent à l'intérieur du balisage. Vous passez une WideString brute et l'écrivain la rend sûre. Un nom de produit comme Smith & Co <Ltd> ou une formule référençant un nom de feuille entre guillemets ressort comme un XML bien formé sans aucun échappement de votre côté
Cycle de vie, et pourquoi Destroy ferme quand même
La fin du package est ce qui écrit la partie classeur, la partie styles, les parties types de contenu et relations, et enfin le répertoire central zip. Ce travail a lieu dans Close. Un package qui n'est jamais fermé est un zip incomplet qu'aucun tableur n'ouvrira, la fermeture n'est donc pas un nettoyage optionnel, c'est l'étape qui rend le fichier valide. Pour se prémunir contre un Close oublié dans un chemin d'erreur, Destroy effectue une fermeture au mieux si le package est toujours ouvert, de sorte que la libération de l'écrivain ne fuite pas l'objet zip sous-jacent même si une exception a sauté l'appel explicite. Le modèle fiable reste le modèle Delphi ordinaire : écrivez dans un try, appelez Close, et libérez dans le finally
Diffuser une grande feuille de bout en bout
La forme du travail est commencer, ajouter une feuille, verser des lignes, fermer. L'exemple ci-dessous écrit une ligne d'en-tête, puis une longue série de lignes de données typées, mélangeant des chaînes, des nombres, une formule sans résultat en cache et une date. La mémoire qu'il utilise pour dix lignes et pour dix millions de lignes est la même, car chaque cellule part vers le flux zip dès qu'elle est écrite
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;
Une deuxième feuille est simplement un autre AddSheet avant de continuer, et l'écrivain ferme la première feuille alors qu'il ouvre la seconde. Les indicateurs booléens utilisent WriteBoolean, qui écrit une cellule booléenne typée plutôt que le texte "True". Si vous voulez confirmer que le fichier est sain et effectue un aller-retour, la propriété CellCount indique combien de cellules ont été écrites, et la relecture du résultat avec le lecteur de flux devrait rapporter le même total
// 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]));
L'écriture dans un flux au lieu d'un fichier est le même code avec BeginStream à la place de BeginFile, ce qui permet à un serveur d'envoyer le classeur vers une réponse HTTP ou un flux de mémoire sans fichier temporaire sur le disque. L'écrivain ne possède pas le flux que vous passez, vous gardez donc le contrôle de sa durée de vie
Lorsque le travail est un point de terminaison de serveur qui génère des classeurs à la demande, les modèles de diffusions en continu pour les travaux de serveur et de traitement par lots montrent comment intégrer cela dans un gestionnaire de requêtes et une exportation planifiée. Lorsque la question est le coût plus large de très gros classeurs, à la fois en lecture et en écriture, les performances des grands classeurs dans Delphi couvrent la destination réelle du temps et de la mémoire. L'écrivain direct en continu est livré dans le composant HotXLS pour Delphi et C++Builder, aux côtés des API complètes de lecture, de modification et d'enregistrement couvertes ailleurs sur ce blog