Електронна таблица с милион реда и дузина колони е напълно обикновен експорт от задача за отчитане на база данни. Отворете я по обичайния начин, като заредите цялата работна книга в TXLSWorkbook, и процесът трябва да материализира всяка една от тези дванадесет милиона клетки като активен обект, преди да се изпълни първият ред от вашата бизнес логика. Файлът на диска може да бъде шестдесет мегабайта компресиран XML. Дървото на обектите, в което се разширява, е няколко пъти по-голямо и всичко трябва да бъде налично едновременно, защото моделът е с произволен достъп по проект. За отчет, който възнамерявате да прочетете от горе до долу и да изхвърлите, това е голямо количество памет, изразходвано за структура, която никога не ви е била необходима
Има и втори път през същия файл. Вместо да изграждате модел, вие сканирате XML на работния лист само напред, клетка по клетка, и оставяте всяка клетка да премине, след като сте я прегледали. Нищо не се натрупва. Паметта остава почти постоянна, независимо дали листът има хиляда реда или десет милиона, защото четецът никога не задържа повече от частта, която анализира в момента, плюс няколко малки таблици за търсене. Това прави директният четец на HotXLS и останалата част от тази статия е за това защо той остава малък и какво ви дава в замяна
Защо моделът в паметта не се мащабира
XLSX файлът е ZIP пакет от XML части, описани от ECMA-376. Всеки работен лист е своя собствена част, xl/worksheets/sheetN.xml, и вътре в него всеки ред е елемент <row>, който държи <c> елементи на клетките. Стандартният път на зареждане чете тази част и конструира адресируем обект за всяка клетка, така че по-късно да можете да поискате Cells[12345, 7] и да получите отговор за постоянно време. Произволният достъп е целият смисъл на модела на работната книга и точно това прави редактирането, изчисляването на формули и стилизирането удобни
Цената е, че произволният достъп изисква всичко да присъства едновременно. Не можете да индексирате структура, която сте изградили само частично. Така че пиковата памет на пълното зареждане е функция от броя на клетките, и на лист с милиони попълнени клетки тази функция попада някъде, където вашата услуга не иска да бъде, особено ако няколко такива задачи се изпълняват едновременно на споделена машина. Когато моделът на достъп, от който действително се нуждаете, е последователен, плащането за произволен достъп е плащане за възможност, която няма да използвате
SAX сканиране само напред, което не изгражда дърво
Директният четец отваря ZIP пакета и обхожда всяка част от работния лист с SAX-стил pull парсер. SAX тук означава, че парсерът отчита събития за разбор, докато се натъква на тях, начален елемент, текстов цикъл, краен елемент и след това продължава. Той не задържа никакво дърво от възли зад себе си. Четецът проследява текущия ред и колона от атрибутите r, събира типа на клетката, индекса на стила, стойността и текста на формулата при пристигането на събитията и когато види затварящия таг </c>, излъчва една клетка и я забравя. Следващата клетка използва повторно същата шепа локални променливи
Тъй като нищо не се запазва между клетките, отпечатъкът на паметта не расте с броя на клетките. Това е свойството, за което си струва да се държите. Лист с двеста реда и лист с двадесет милиона реда струват на четеца една и съща резидентна памет, а разликата между тях е само колко дълго продължава сканирането. Отказвате се от произволния достъп, водещата функция на модела, и в замяна получавате таван на паметта, през който броят на клетките не може да премине
Какво остава резидентно и защо тези две части
Сканирането не е напълно без състояние и изключенията са поучителни. Две малки таблици трябва да се съхраняват в паметта за времетраенето, защото клетка сама по себе си не носи достатъчно информация, за да бъде интерпретирана без тях
Първата е таблицата със споделени низове. В SpreadsheetML текстовата клетка не съхранява собствения си текст. Тя носи t="s" и числов товар, който е индекс в xl/sharedStrings.xml, единствен дедупликиран списък на всеки отделен низ в работната книга. Това е добър компромис за пространство за файлове, където едни и същи етикети се повтарят в хиляди редове, но това означава, че четецът трябва да зареди предварително тази таблица с низове и да я запази резидентна, защото всяка клетка навсякъде във всеки лист може да препраща към всеки запис в нея. Размерът на таблицата се определя от броя на различните низове, а не от броя на клетките, така че остава скромен дори на огромни листове
Второто е картографирането на цифровия формат от частта със стилове. Числова клетка и клетка с дата са байт по байт едни и същи по мрежата: и двете са обикновено число, защото датата в SpreadsheetML е просто сериен брой дни. Единственото нещо, което ги отличава, е стилът на клетката, който сочи през cellXfs в xl/styles.xml към идентификатор на числов формат. За да отчете дата като дата, а не като суров сериен номер, четецът зарежда тази таблица стил-към-формат и я запазва резидентна. Всичко останало във файла, действителните данни от клетките, които съставляват по-голямата част от байтовете, преминава, без да бъде съхранявано
Всяка клетка отчита вид и стойност
Всяка излъчена клетка пристига като запис TXLSDirectCell. Той носи индекса и името на листа, 1-базиран ред и колона, семантичен Kind, Value като Variant, текста Formula без водещия му знак за равенство и суровия StyleIndex. Видът е един от xdkNumber, xdkString, xdkBoolean, xdkDate или xdkError, така че можете да се разклонявате въз основа на това какво означава клетката, вместо да я извеждате отново от атрибути. Клетка с формула отчита вида на кеширания си резултат, заедно с текста на формулата до него, така че изчислената обща сума идва като число, което също ви казва как е произведена
type
TReportScan = class
procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
end;
procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
begin
case Cell.Kind of
xdkString: AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
xdkNumber: AddToTotals(Cell.Col, Double(Cell.Value));
xdkDate: NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
xdkError: LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
end;
end;
Различаване на дата от число
Въпросът с датите заслужава по-отблизо да бъде разгледан, защото там повечето наивни скенери се объркват. Няма тип дата в числова клетка. Клетка, съдържаща серийна стойност 46000, може да бъде количество, цена или 17 февруари 2025 г., и файлът ви казва кое от тях е само чрез идентификатора на числовия формат, достигнат чрез стила на клетката. ECMA-376 резервира блок от вградени идентификатори на формати, чието значение е фиксирано във всеки съответстващ производител, и идентификаторите, носещи дати, се намират в два диапазона: 14 до 22 за стандартните формати за дата и час и 45 до 47 за форматите на изминало време като [h]:mm:ss. Когато DetectDates е включено, което е по подразбиране, четецът резолира стила на всяка числова клетка към нейния идентификатор на формат, и клетка, чийто идентификатор попада в тези резервирани диапазони, се отчита като xdkDate с нейното Value вече преобразувано в Delphi TDateTime. Потребителските формати също се проверяват, чрез инспектиране на кода на формата за токени за дата и час, но резервираните диапазони са надеждният гръбнак. Изключете DetectDates и таблицата със стилове дори не се зарежда, всяка числова клетка преминава като xdkNumber и сканирането е фракционно по-леко
Пропускане на листове и ранно прекъсване
Последователното сканиране има тихо предимство, с което произволният достъп не може да се мери: можете да спрете. Събитието OnSheet се задейства преди отварянето на всеки работен лист и ви дава два превключвателя. Задайте SkipSheet и тази цялата част никога не се парсва, което е начинът, по който сканирате само листовете, които ви интересуват, в многолистна работна книга, без да плащате за четене на останалите. Задайте Abort и цялото сканиране приключва незабавно. Събитието OnCell носи свое собствено Abort, така че можете да спрете в момента, в който намерите това, което търсите, определен ред, контролна стойност, края на блок с хедъри, без да четете останалите милиони клетки. При сканиране само напред, прекъсването е наистина безплатно, защото работата, която пропускате, е работа, която все още не се е случила
procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
// Scan only the "Data" sheet; leave the rest unread
SkipSheet := SheetName <> 'Data';
end;
Броене на клетки без манипулатор
Едно скорошно подобрение си струва да се спомене, защото превръща често срещан въпрос в едно евтино извикване. Четецът брои всяка попълнена клетка, която подминава, и прави това независимо дали е прикрепен манипулатор на OnCell или не. Преди това, без зададен манипулатор, броят на попълнените клетки се връщаше като нула, тъй като броенето беше страничен ефект от излъчването. Сега броят е независим от излъчването. Това означава, че можете да зададете един въпрос, колко попълнени клетки действително съдържа тази работна книга, и да получите отговора на цената на едно сканиране без никакви обратни извиквания (callbacks). ReadFile и ReadStream връщат тази обща сума като Int64, и същото число е налично след това като свойството CellCount. Връщане на -1 сигнализира, че файлът не е могъл да бъде отворен или не е OOXML пакет
var
Reader: TXLSDirectReader;
Populated: Int64;
begin
Reader := TXLSDirectReader.Create;
try
// No OnCell handler: a pure populated-cell census, still near-constant memory
Populated := Reader.ReadFile('quarterly_export.xlsx');
if Populated < 0 then
raise Exception.Create('Not a readable XLSX package')
else
Writeln(Format('%d populated cells (CellCount = %d)',
[Populated, Reader.CellCount]));
finally
Reader.Free;
end;
end;
За пълно сканиране прикрепяте манипулатора и извиквате ReadFile по абсолютно същия начин. Контрастът с пълното зареждане е целият смисъл: където зареждането на quarterly_export.xlsx в работна книга би разширило всяка клетка в резидентен обект и би задържало всичко това, директният четец запазва само споделените низове и таблицата със стилове, докато дванадесетте милиона клетки преминават през вашия OnCell една по една. Аритметиката, която се изпълнява за клетка, не оставя нищо след себе си, така че пиковата памет се задава от броя на различните низове в работната книга, а не от броя на редовете ѝ
Директният четец е правилният инструмент, когато задачата е да прочетете голяма работна книга веднъж и да я извлечете или обобщите. Когато вместо това се нуждаете от произволния достъп на пълния модел, но искате той да се държи добре с големи файлове, настройките в нашите бележки за производителността на големи работни книги в Delphi покриват този път. А когато посоката е обърната, произвеждайки голям изход вместо да го консумира, ръководството за стрийминг писане за сървърни партидни задачи прилага същата дисциплина за постоянна памет към писането. Всички три се доставят като част от компонента HotXLS за Delphi и C++Builder, заедно с API-тата за четене, писане, формули и форматиране, разгледани на други места в този блог