Технічна стаття

Pull Row Cursor для XLS, XLSX, ODS і CSV у Delphi

HotXLS читає джерела .xls, .xlsx, .xlsm, .ods, CSV і TSV через єдиний pull row cursor, TXLSRowCursor, чий FindFirst і FindNext просувають по одному логічному рядку за раз, поки в памʼяті лишається лише цей рядок. Машина станів на шість значень відділяє before-first від EOF, cancelled і faulted, а старіший callback-читач тепер є адаптером над тим самим курсором

Сценарій знайомий кожному, хто випускав функцію імпорту. Приходить .xlsx на 200 МБ, ви підключаєте обробник OnCell, а перша вимога після «прочитай це» звучить як «зупинись після першої сотні сторнованих проведень». Тепер форма вашого коду воює проти вас: цикл живе всередині бібліотеки, ваш обробник мусить піднімати прапорець, кожен наступний callback усе ще спрацьовує, доки парсер не помітить, а накопичений стан — скільки вже збігів, який стовпець збігся, що робити далі — мусить жити в полях класу, що існує лише щоб дати callback десь сидіти. Нічого з цього не є проблемою розбору. Це проблема потоку керування, і саме її прибирає pull-курсор

Скільки насправді коштує push callback на 200 МБ

Push вивертає керування навиворіт, і саме вивертання — те, чого викликач із фільтруванням чи зʼєднанням дозволити не може. З callback-API циклом володіє бібліотека, тож викликач не може використати Break, не може чергувати два джерела, не може передати читача підпрограмі, що очікує, щоб її рухали, і не може висловити «глянь на наступний рядок перед рішенням» без буферизації. Ціна не у пропускній здатності — добре написаний SAX callback-шлях стрімениться нормально — вона в тому, що кожен нетривіальний споживач вирощує власну маленьку машину станів, щоб імітувати цикл, який йому не дозволили написати. Помножте це на чотири файлові формати, кожен історично з власною точкою входу сканування, і семантика фільтрування, формул та помилок починає розповзатися між ними — це рівно той дрейф, який HotXLS мав на меті закрити

Як pull-курсор змінює ваш код виклику?

Він повертає цикл вам, а з ним і звичайний потік керування Pascal. TXLSRowCursor.Open приймає імʼя файла або TStream, визначає формат, одноразово завантажує спільні рядки та метадані стилів дат і вибирає аркуш 1. SelectSheet (з одиниці) або SelectSheetByName перенацілює на інший робочий аркуш і скидає курсор у before-first. FindFirst і FindNext потім позиціонуються на наступному заповненому рядку — рядки без декодовних клітинок пропускаються, тож RowIndex може стрибати — а поточний рядок виставлено як CellCount, Cells[] і ValueByCol[], усе з одиниці на осі стовпців. Вихід із циклу — це Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // пропустити смугу заголовка
    Cursor.IncludeColumn(1);     // декодувати лише ці два стовпці
    Cursor.IncludeColumn(7);
    if not Cursor.Open('postings-200mb.xlsx') then
      Exit;
    if not Cursor.SelectSheetByName('Ledger') then
      Exit;

    Hits := 0;
    if Cursor.FindFirst then
      repeat
        if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
        begin
          Inc(Hits);
          if Hits = 100 then
            Break;               // звичайний Break; без прапорця переривання, без сентинела
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // деструктор завершує прохід
  end;
end;

Проєкція і діапазон задаються до проходу, а не фільтруються після. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates і DetectTextTypes усі честуються всередині бекендів, тож невибраний стовпець ніколи спершу й не виділяє свого значення, рядка формули чи rich-text навантаження — регресійний набір доводить це формулами на 16 КіБ і кешованими рядками, які ніколи не матеріалізуються, коли їхній стовпець не спроєктовано. Ці опції свідомо заморожуються, поки прохід активний, і знову стають доступними для запису на EOF, при SelectSheet чи після Close, тож одне сканування ніколи не змішає два контракти декодування. Якщо вам потрібна лише опись аркушів, а не рядки, завантаження лише метаданих і вибіркових аркушів — дешевша точка входу

Один бекенд на формат, по одному циклу сканування кожному

Усередині HotXLS у кожного формату рівно один forward-сканер, і pull-курсор, і callback-читач керують тим самим сканером. TXLSXForwardRowBackend — єдина SAX-машина станів робочих аркушів для частин аркушів ECMA-376 Part 1 §18.3, що тримає XML-читача, таблицю спільних формул і парсер rich-text, і за один виклик просувається рівно до однієї фізичної межі <row>. TXLSBiffForwardParser володіє глобальними даними, вибором аркуша і просуванням рядків для потоку записів [MS-XLS]; зробити його зупинюваним дало найжорсткіше обмеження всієї конструкції, бо кешована рядкова формула — це запис Formula, за яким одразу йде запис String, тож точка призупинення на рядок ніколи не повинна опинятися між ними. TXLSForwardTextBackend тримає читача з обізнаністю про BOM, активний роздільник і один логічний запис — CSV внюхує кому, крапку з комою, табуляцію чи вертикальну риску з першого запису, ігноруючи символи в лапках, а багаторядкові поля в лапках зʼєднуються через #10, тож номер рядка простежує логічні записи, а не фізичні нові рядки. TXLSForwardOdsBackend тримає один фізичний шаблон рядка для таблиць OpenDocument §9, трактує table:number-rows-repeated як залишкову кількість, а не розгортання, і просувається повз покриті клітинки без випромінювання значень. Потоковий прямий читач поділяє той самий завантажувач спільних рядків і стилів дат

Pull row cursor HotXLS диспетчеризує на один forward-сканер на формат: SAX-бекенд для XLSX, парсер записів для BIFF, текстовий бекенд із внюхуванням роздільника та шаблон рядка ODS, а зверху як адаптер налаштований callback-читач
У кожного формату рівно один forward-сканер, і pull-курсор, і callback-читач керують тим самим сканером, тож семантика фільтрування та помилок не може розійтися

Чому шість станів замість одного прапорця Eof?

Бо один boolean робить чотири різні ситуації нерозрізненними, і викликачі вгадують неправильно в усіх. TXLSRowCursorState називає їх явно

  • xrcsClosed — жодне джерело не відкрите
  • xrcsBeforeFirst — відкрито або перенацілено, жодного рядка ще не прочитано
  • xrcsActive — стоїмо на валідному рядку
  • xrcsEof — аркуш спожито до кінця
  • xrcsCancelled — викликач свідомо зупинив прохід
  • xrcsFaulted — прохід зазнав невдачі й початковий виняток було піднято

Останнє розрізнення — те, що має значення в продакшені. Відсутня частина аркуша чи невдалий старт проходу зберігає свій EReadError і переводить курсор у xrcsFaulted; його ніколи не понижують до простого False, який викликач прочитав би як «цей аркуш був порожній». Cancel свідомо вужчий за Close: він закриває бекенд поточного аркуша і його inflate-субпотік та інвалідує поточний рядок, але не вивільняє ZIP-архів і вихідний потік, а подвійний виклик — no-op. Після скасування ви відновлюєтеся явним викликом SelectSheet — курсор не перезапустить прохід мовчки за вас. Володіння потоком слідує тому ж оборонному правилу: xsoBorrowed — типовий випадок і відновлює позицію потоку при закритті, xsoOwned передає володіння лише після того, як Open уже встиг успішно завершитися, тож невдалий open ніколи не звільняє потік, який викликач ще тримає

Шість станів row cursor HotXLS із переходами між ними: Cancel переводить активний прохід у cancelled, невдалий старт проходу — у faulted, і як обидва лишаються відмінними від кінця аркуша
Шість іменованих станів тримають порожній аркуш, свідому зупинку та невдалий прохід розрізненними, чого один boolean Eof зробити не може
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: курсор ніколи не звільняє Src, а Close відновлює
      // позицію, яку потік мав на момент виклику Open
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // закриває лише бекенд аркуша та його
            Break;           // inflate-субпотік; ідемпотентно
          end;
        until not Cursor.FindNext;

      case Cursor.State of
        xrcsEof:       Log('sheet consumed to the end');
        xrcsCancelled: Log('stopped by the operator');
        xrcsFaulted:   Log('pass failed; the EReadError was already raised');
      end;
    finally
      Cursor.Free;
    end;
  finally
    Src.Free;                // все ще наш, все ще валідний, позиція відновлена
  end;
end;

Позичення поточного рядка без копіювання

IXLSRowCursorView передає рядок іншій підпрограмі без дублювання масиву клітинок. Подання зберігає спільну варту, що тримає вказівник курсора плюс лічильник поколінь UInt64; просування, вибір аркуша, скасування, закриття й знищення курсора збільшують це покоління, а знищення додатково очищує власника варти. Тож застаріле подання не може читати звільнену памʼять: Valid — проба без винятків, яку можна кликати будь-коли, тоді як кожен інший член спершу валідується і кидає EXLSRowCursorViewInvalidated. Будьте чесними щодо того, що цей контракт є — це fail-fast часу життя, а не гарантія потокобезпечності, і він не ліцензує читання рядка з другого потоку, поки перший просуває курсор

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // позичає; масив клітинок не копіюється
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // читання BIFF forward тримають
        else if Cell.Kind = xdkEmpty then //   кешований результат, а не токени
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank — справжні клітинки
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // Інтерфейс переживає цикл, але рядок за ним — ні
  if not View.Valid then    // Valid ніколи не кидає; Cells[] тепер кинула б
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes і те, що він має право доводити

PeakRowBufferedBytes існує, щоб продемонструвати: памʼять слідкує за шириною рядка, а не за кількістю рядків. Він акумулює записи клітинок, Variants, рядки формул і rich-text навантаження поточного вихідного рядка та вбирає специфічний для формату робочий набір — логічний запис CSV, фізичний шаблон рядка ODS, пік записів BIFF або XLSX-клітинку, яку зараз декодують. Читайте його разом із SheetPassesStarted, що рахує, скільки проходів аркушів справді почалися. Два застереження тримають це чесним: число — оцінка, а не точний облік купи, і воно монотонне від останнього Open, тож це інструмент налагодження та регресії, а не живий датчик. Про ширшу картину того, куди йдуть час і байти на дуже великих книгах, дивіться продуктивність великих книг у Delphi

Порівняння HotXLS: завантаження цілого аркуша тримає резидентним кожен рядок, тоді як pull-курсор тримає лише поточний рядок плюс один робочий набір формату — саме це PeakRowBufferedBytes акумулює і звітує
PeakRowBufferedBytes акумулює поточний вихідний рядок плюс специфічний для формату робочий набір, тож памʼять слідкує за тим, наскільки рядок широкий, а не за тим, скільки рядків має аркуш

Push-читач став адаптером — і чого курсор не робитиме

TXLSForwardReader більше не несе окремих точок входу сканування XLSX, BIFF і тексту. Він налаштовує курсор, проходить його і перекладає поточний рядок у події OnSheet та OnCell, саме тому дві фасади більше не можуть розійтися у фільтруванні, стані формул чи обробці помилок. Два наслідки варто знати до оновлення: callback SheetIndex тепер скрізь на TXLSForwardReader нумерується з одиниці (TXLSDirectReader зберігає свій наявний контракт подій з нуля), а OnSheet спрацьовує до SelectSheet, тож установка SkipSheet означає, що частина аркуша взагалі ніколи не відкривається й не розпаковується. Межі так само явні: книгу не можна модифікувати, поки прохід активний, скасування вимагає явного перезапуску, а BIFF forward-шлях ніколи не декомпілює токени формул, тож класичні клітинки з формулами звітують HasFormula true при FormulaTextAvailable false і віддають кешований результат замість вигадувати порожній рядок формули. Row cursor і його адаптер пройшли 1 298 перевірок на Delphi Win32 і Win64 плюс статичний пакет C++Builder 37.0 Win64

Якщо ви зважуєте pull-курсор проти завантажувача, який маєте зараз, питання не в тому, котрий парсить швидше, а в тому, котрий дозволяє вам написати умову виходу, яка вам справді потрібна. Повні деталі компонента, підтримувані версії IDE та ліцензування — на сторінці компонента електронних таблиць HotXLS для Delphi