Завдання звітування працює чудово протягом року. Воно створює робочу книгу, заповнює аркуш усім, що повертає запит, і зберігає її. Потім клієнт із п'ятирічною історією просить повний експорт, кількість рядків перевищує мільйон, і процес гине з помилкою нестачі пам'яті задовго до того, як файл досягає диска. З кодом не було нічого поганого. Він тримав усю робочу книгу в оперативній пам'яті, щоб серіалізувати її в кінці, і необхідна йому пам'ять зростала пропорційно кількості рядків, які його просили записати
Рішення полягає не в більшій машині. Це інша модель запису. Прямий потоковий записувач у HotXLS видає пакет OOXML поступово по мірі надходження рядків, тому використовувана ним пам'ять не залежить від того, скільки рядків ви записуєте. Це відповідник потоковому зчитувачу на стороні запису: якщо зчитувач обходить величезний аркуш без побудови дерева клітинок, то записувач створює його також без побудови дерева клітинок
Чому звичайний шлях збереження зростає з даними
Звичайний шлях TXLSXWorkbook спочатку будує повну об'єктну модель. Кожна клітинка зі своїм значенням, типом та посиланням на стиль живе як об'єкт у пам'яті до тих пір, поки ви не викличете збереження, після чого все дерево серіалізується в пакет. Ця модель є правильною, коли ви хочете прочитати аркуш, відредагувати його, переобчислити та записати назад, оскільки довільний доступ до будь-якої клітинки — це саме те, що потрібно для редагування. Вона є неправильною, коли ви вливаєте рядки в одному напрямку і ніколи не озираєтеся назад, оскільки ви платите за збереження кожного рядка резидентним без жодної вигоди. Мільйон рядків об'єктів — це мільйон рядків об'єктів незалежно від того, чи ви коли-небудь до них повернетеся
Потоковий записувач прибирає дерево. Щойно клітинка записана, вона стає байтами у частині аркуша, і ці байти передаються до zip-виводу. Потік аркуша — це єдиний буфер, який зростає, і він зростає на стороні виводу, а не як живі об'єкти Delphi в купі. Резидентною залишається фіксована кількість облікових даних: назви аркушів, кілька прапорців, поточний номер рядка, лічильник клітинок. Цей набір не змінюється між першим і десятьмільйонним рядком
Таблиця спільних рядків — це пастка, а вбудовані рядки — це вихід
Більшість потокових записувачів XLSX добре працюють, поки не стикаються з текстом. Формат OOXML зазвичай зберігає рядки в таблиці спільних рядків: кожен унікальний рядок записується один раз в окрему частину, і кожна клітинка, яка містить цей рядок, несе індекс до таблиці замість самого тексту. Це хороша оптимізація простору для файлів, повних повторюваних міток, і це значення за замовчуванням, яке використовує стандартний шлях збереження. Проблема для потокового записувача є жорстокою. Щоб здійснити дедуплікацію, таблиця має залишатися резидентною протягом усього завдання, оскільки будь-який рядок, що ще надійде, може повторити рядок із вже записаного рядка, і лише повна карта побачених рядків у пам'яті може призначити правильний індекс. Отже, єдина структура, яку потоковий записувач не може передавати в потоковому режимі — це саме та структура, яка повинна зробити файл маленьким. Дані з великою кількістю тексту зводять нанівець потокове передавання, заради якого ви прийшли
Прямий записувач повністю уникає цієї таблиці. Рядки записуються вбудовано (inline), як клітинки t="inlineStr", чий текст знаходиться безпосередньо всередині клітинки з елементом <is><t>. Немає таблиці для накопичення та карти побачених рядків для зберігання, тому текстові стовпці коштують не більше пам'яті, ніж числові. Компроміс є явним, і про нього варто сказати прямо. Вбудовані рядки повторюють той самий текст усюди, де він зустрічається, тому файл з великою кількістю однакових міток на диску є більшим, ніж еквівалент зі спільними рядками. Ви витрачаєте розмір файлу, щоб купити постійну пам'ять. Для одноетапного експорту це правильна сторона компромісу, і zip-стиснення в будь-якому разі поглинає більшу частину повторень на виході
Таблиця стилів надходить наприкінці з одним форматом дати
Стилі створюють таку ж напруженість, як і рядки. Робоча книга посилається на своє форматування через частину стилів, і потоковий записувач не може підтримувати зростаючу палітру стилів синхронно з клітинками, які він уже скинув. Прямий записувач вирішує це, зберігаючи таблицю стилів невеликою і фіксованою, та видаючи її при закритті, а не на початку. Один формат клітинки за замовчуванням охоплює звичайні клітинки. Один числовий формат дати охоплює дати, зареєстрований із кодом формату yyyy-mm-dd на відомій позиції у списку форматів клітинок
Цей формат дати є причиною того, що WriteDateTime існує як окремий виклик. Excel не має рідного типу дати; дата — це число в одязі формату дати. WriteDateTime записує значення як звичайний серійний номер і помічає клітинку єдиним стилем дати, тому електронна таблиця відображає її як дату замість п'ятизначного цілого числа. Серійний номер, який він записує, має значення для кругового перетворення (round-tripping). Він зберігає значення TDateTime безпосередньо у системі дат 1900 року, що є тією самою конвенцією, яку використовує звичайний шлях збереження TXLSXWorkbook. Оскільки обидва шляхи узгоджують серійний номер, файл, створений потоковим записувачем, читається назад через зчитувач HotXLS і відкривається в Excel із датами, що відповідають вашим намірам, без жодних сюрпризів зі зсувом на одиницю чи епохою між записувачем і зчитувачем
Порядок є обов'язковим, оскільки байти вже зникли
Потокове передавання купує свій профіль пам'яті за одне правило, яке ви повинні поважати. Вивід видається по мірі вашого просування і не може бути переглянутий повторно, тому все має записуватися в тому порядку, в якому воно з'являється у файлі. У межах рядка клітинки йдуть у порядку зростання стовпців. У межах аркуша рядки йдуть у порядку зростання. Немає буфера, який дозволяє записувачу сортувати ваші клітинки постфактум, оскільки рядок, який ви щойно закрили, вже є байтами в zip-потоці і більше не доступний. Передайте йому стовпець 5, а потім стовпець 2 у тому самому рядку, і вивід буде неправильно сформований, оскільки записувач просто видає те, що ви йому даєте, у тій послідовності, яку ви йому даєте
API рядків має невелику зручність для типового випадку. AddRow приймає індекс рядка, що починається з 1, але передача 0 означає взяти наступний рядок після попереднього, тому послідовному заповненню не потрібно відстежувати та передавати лічильник збільшення. Кожен AddRow закриває рядок перед ним, і кожен AddSheet закриває аркуш перед ним, тому ви ніколи явно не завершуєте рядок або аркуш. Ви починаєте наступний, і записувач фіналізує відкриту структуру за вас
Екранування обробляється там, де текст потрапляє до XML
Будь-який текст, який ви записуєте, стає частиною XML-документа, тому п'ять попередньо визначених сутностей XML мають бути екрановані, інакше пакет стане недійсним у той момент, коли значення міститиме амперсанд або кутову дужку. Записувач екранує &, <, >, " та ' за вас як на тексті вбудованого рядка, так і на тексті формули — у двох місцях, де символи, надані тим, хто викликає, потрапляють усередину розмітки. Ви передаєте сирий WideString, і записувач робить його безпечним. Назва продукту, як-от Smith & Co <Ltd>, або формула, що посилається на назву аркуша в лапках, виходить як добре сформований XML без жодного екранування з вашого боку
Життєвий цикл і чому Destroy все ще закриває
Завершення пакету — це те, що записує частину робочої книги, частину стилів, частини типів вмісту та зв'язків, і, нарешті, центральний каталог zip. Ця робота відбувається в Close. Пакет, який ніколи не закривався, є неповним zip-архівом, який не відкриє жодна програма для роботи з електронними таблицями, тому закриття не є необов'язковим очищенням, це крок, який робить файл дійсним. Для захисту від забутого Close на шляху з помилкою, Destroy виконує закриття з максимально можливими зусиллями, якщо пакет усе ще відкритий, тому звільнення записувача не призводить до витоку базового об'єкта zip, навіть коли виняток пропустив явний виклик. Надійним шаблоном усе ще залишається звичайний шаблон Delphi: запис усередині try, виклик Close та звільнення у finally
Потокове передавання великого аркуша від початку до кінця
Форма завдання полягає в тому, щоб почати, додати аркуш, влити рядки, закрити. Наведений нижче приклад записує рядок заголовка, а потім довгу серію рядків типізованих даних, змішуючи рядки, числа, формулу без кешованого результату та дату. Пам'ять, яку він використовує для десяти рядків і для десяти мільйонів рядків, є однаковою, оскільки кожна клітинка відправляється в 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 перед продовженням, і записувач закриває перший аркуш, коли відкриває другий. Для логічних прапорців використовується WriteBoolean, який записує типізовану логічну клітинку, а не текст «True». Якщо ви хочете переконатися, що файл є цілісним і здійснює кругове перетворення, властивість CellCount повідомляє, скільки клітинок було записано, і читання результату назад за допомогою потокового зчитувача має повідомити такий самий підсумок
// 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]));
Запис у потік замість файлу — це той самий код із BeginStream замість BeginFile, що дозволяє серверу відправляти робочу книгу до HTTP-відповіді або потоку в пам'яті без тимчасового файлу на диску. Записувач не володіє потоком, який ви передаєте, тому ви зберігаєте контроль над його життєвим циклом
Коли робота — це серверна кінцева точка, яка будує робочі книги на вимогу, шаблони в потокових записах для серверних і пакетних завдань показують, як підключити це до обробника запитів і запланованого експорту. Коли питання полягає в ширшій вартості дуже великих робочих книг, як для читання, так і для запису, продуктивність великих робочих книг у Delphi охоплює те, куди насправді йдуть час і пам'ять. Прямий потоковий записувач постачається як частина компонента HotXLS для Delphi та C++Builder, поряд із повними API для читання, редагування та збереження, які висвітлені в інших статтях цього блогу