Artigo Técnico

Cursor de linhas pull para XLS, XLSX, ODS e CSV em Delphi

O HotXLS lê fontes .xls, .xlsx, .xlsm, .ods, CSV e TSV através de um único cursor de linhas pull, o TXLSRowCursor, cujos FindFirst e FindNext avançam uma linha lógica de cada vez enquanto só essa linha permanece em memória. Uma máquina de estados de seis valores separa before-first de EOF, cancelado e falhado, e o leitor de callbacks mais antigo é agora um adaptador sobre o mesmo cursor

O cenário é familiar a quem já publicou uma funcionalidade de importação. Chega um .xlsx de 200 MB, liga um handler OnCell, e o primeiro requisito depois de «ler isto» é «parar após as primeiras cem entradas revertidas». Agora a forma do seu código luta contra si: o ciclo vive dentro da biblioteca, o seu handler tem de levantar uma flag, todos os callbacks subsequentes continuam a disparar até o parser dar por isso, e o estado acumulado — quantas ocorrências até agora, que coluna correspondeu, o que fazer a seguir — tem de viver em campos numa classe que existe só para dar ao callback um sítio onde sentar-se. Nada disso é um problema de análise. É um problema de fluxo de controlo, e é o que um cursor pull remove

O que um callback push custa realmente a 200 MB

O push inverte o controlo, e a inversão é exatamente aquilo que um chamador que filtra ou junta não pode permitir-se. Com uma API de callbacks a biblioteca é dona do ciclo, pelo que o chamador não pode usar Break, não pode intercalar duas fontes, não pode entregar o leitor a uma rotina que espera ser conduzida, e não pode expressar «espreitar a próxima linha antes de decidir» sem armazenar em buffer. O custo não é o débito — um caminho de callbacks SAX bem escrito flui bem — é que todos os consumidores não triviais criam uma pequena máquina de estados própria para simular o ciclo que não lhes foi permitido escrever. Multiplique isso por quatro formatos de ficheiro, cada um historicamente com o seu próprio ponto de entrada de varrimento, e as semânticas de filtragem, fórmulas e erros começam a divergir entre eles, que é precisamente a divergência que o HotXLS se propôs fechar

Como é que um cursor pull muda o seu código chamador?

Devolve-lhe o ciclo, e com ele o fluxo de controlo Pascal ordinário. O TXLSRowCursor.Open aceita um nome de ficheiro ou um TStream, deteta o formato, carrega strings partilhadas e metadados de estilo de data uma vez, e seleciona a folha 1. O SelectSheet (base um) ou o SelectSheetByName re-targetiza outra folha de cálculo e repõe o cursor em before-first. FindFirst e FindNext posicionam então na próxima linha populada — linhas sem células descodificáveis são saltadas, pelo que o RowIndex pode saltar — e a linha atual é exposta como CellCount, Cells[] e ValueByCol[], todos base um no eixo das colunas. Sair do ciclo é um Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // salta a faixa de cabeçalho
    Cursor.IncludeColumn(1);     // descodifica só estas duas colunas
    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 ordinário; sem flag de aborto, sem sentinela
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // o destruidor termina a passagem
  end;
end;

A projeção e o intervalo definem-se antes da passagem, não se filtram depois. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates e DetectTextTypes são todos honrados dentro dos backends, pelo que uma coluna não selecionada nunca aloca o seu valor, cadeia de fórmula ou payload de rich-text em primeiro lugar — a suite de regressão prova isto com fórmulas de 16 KiB e strings em cache que nunca são materializadas quando a sua coluna não é projetada. Essas opções são deliberadamente congeladas enquanto uma passagem está ativa e voltam a ser escritáveis no EOF, em SelectSheet, ou depois de Close, pelo que uma varredura nunca pode misturar dois contratos de descodificação. Se só precisa do inventário de folhas em vez das linhas, o carregamento só de metadados e de folhas seletivas é o ponto de entrada mais barato

Um backend por formato, um ciclo de varrimento cada

Cada formato tem exatamente um scanner para a frente dentro do HotXLS, e tanto o cursor pull como o leitor de callbacks conduzem esse mesmo scanner. O TXLSXForwardRowBackend é a única máquina de estados SAX de folhas de cálculo para partes de folha da ECMA-376 Parte 1 §18.3, guardando o leitor XML, a tabela de fórmulas partilhadas e o parser de rich-text, e avança para exatamente um limite físico de <row> por chamada. O TXLSBiffForwardParser é dono dos globais, da seleção de folha e do avanço de linha para o fluxo de registos [MS-XLS]; torná-lo pausável produziu a restrição mais aguda de todo o design, porque uma fórmula de string em cache é um registo Formula imediatamente seguido de um registo String, pelo que um ponto de suspensão por linha nunca pode cair entre os dois. O TXLSForwardTextBackend mantém um leitor consciente de BOM, o delimitador ativo e um registo lógico — o CSV deteta vírgula, ponto e vírgula, tab ou pipe a partir do primeiro registo enquanto ignora caracteres entre aspas, e campos entre aspas multilinha são unidos com #10 para que o número de linha acompanhe registos lógicos em vez de quebras de linha físicas. O TXLSForwardOdsBackend mantém um único modelo físico de linha para tabelas OpenDocument §9, trata table:number-rows-repeated como uma contagem restante em vez de uma expansão, e avança para além das células cobertas sem emitir valores. O leitor direto em fluxo partilha o mesmo carregador de strings partilhadas e estilos de data

O cursor de linhas pull do HotXLS despachando para um scanner para a frente por formato, um backend SAX para XLSX, um parser de registos para BIFF, um backend de texto que deteta delimitadores e um modelo de linhas ODS, com o leitor de callbacks configurado por cima como um adaptador
Cada formato tem exatamente um scanner para a frente, e tanto o cursor pull como o leitor de callbacks conduzem esse mesmo scanner, pelo que as semânticas de filtragem e de erros não podem divergir

Porque é que seis estados em vez de uma flag Eof?

Porque um único booleano torna quatro situações diferentes indistinguíveis, e os chamadores erram o palpite sobre todas. O TXLSRowCursorState nomeia-as explicitamente

  • xrcsClosed — nenhuma fonte está aberta
  • xrcsBeforeFirst — aberto ou re-targetizado, nenhuma linha lida ainda
  • xrcsActive — posicionado numa linha válida
  • xrcsEof — a folha foi consumida até ao fim
  • xrcsCancelled — o chamador parou a passagem deliberadamente
  • xrcsFaulted — a passagem falhou e a exceção original foi lançada

Essa última distinção é a que importa em produção. Uma parte de folha em falta ou um início de passagem falhado mantém o seu EReadError e move o cursor para xrcsFaulted; nunca é rebaixado para um simples False que um chamador lería como «esta folha estava vazia». O Cancel é deliberadamente mais estreito que o Close: fecha o backend da folha de cálculo atual e o seu subfluxo de inflate e invalida a linha atual, mas não liberta o arquivo ZIP nem o fluxo de origem, e chamá-lo duas vezes é um no-op. Depois de um cancelamento retoma chamando SelectSheet explicitamente — o cursor não reiniciará silenciosamente uma passagem por sua conta. A posse de fluxos segue a mesma regra defensiva: xsoBorrowed é a predefinição e restaura a posição do fluxo ao fechar, xsoOwned transfere a posse só depois de o Open já ter tido sucesso, pelo que uma abertura falhada nunca liberta um fluxo que o chamador ainda mantém

Os seis estados do cursor de linhas do HotXLS com as transições entre eles, mostrando o Cancel a mover uma passagem ativa para cancelado, um início de passagem falhado a movê-la para falhado, e como ambos permanecem distintos do fim da folha
Seis estados nomeados mantêm uma folha vazia, uma paragem deliberada e uma passagem falhada distinguíveis, o que um único booleano Eof não consegue
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: o cursor nunca liberta o Src, e o Close restaura a
      // posição que o fluxo tinha quando o Open foi chamado
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // fecha o backend da folha de cálculo e o
            Break;           // seu subfluxo de inflate apenas; idempotente
          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;                // ainda nosso, ainda válido, posição restaurada
  end;
end;

Emprestar a linha atual sem a copiar

O IXLSRowCursorView entrega uma linha a outra rotina sem duplicar o array de células. A vista guarda um guarda partilhado que segura o ponteiro do cursor mais um contador de geração UInt64; avançar, selecionar uma folha, cancelar, fechar e destruir o cursor incrementam todos essa geração, e a destruição além disso limpa o dono do guarda. Por isso uma vista obsoleta não pode ler memória libertada: Valid é uma sondagem sem exceções que pode chamar a qualquer momento, enquanto todos os outros membros validam primeiro e lançam EXLSRowCursorViewInvalidated. Seja honesto quanto ao que este contrato é — é fail-fast de tempo de vida, não uma garantia de segurança entre threads, e não licença para ler uma linha a partir de uma segunda thread enquanto a primeira avança o cursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // empresta; nenhum array de células é copiado
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // leituras BIFF forward mantêm o
        else if Cell.Kind = xdkEmpty then //   resultado em cache, não os tokens
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank são células reais
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // A interface sobrevive ao ciclo, mas a linha atrás dela não
  if not View.Valid then    // Valid nunca lança; Cells[] agora lançaria
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes, e o que lhe é permitido provar

O PeakRowBufferedBytes existe para demonstrar que a memória acompanha a largura da linha em vez da contagem de linhas. Acumula os registos de células, Variants, cadeias de fórmula e payloads de rich-text da linha de saída atual e integra o conjunto de trabalho específico do formato — o registo lógico CSV, o modelo físico de linhas ODS, o pico de registos BIFF, ou a célula em bruto XLSX atualmente a ser descodificada. Leia-o em conjunto com o SheetPassesStarted, que conta quantas passagens de folhas de cálculo começaram realmente. Duas ressalvas mantêm isto honesto: o valor é uma estimativa, não uma contabilidade exata do heap, e é monotónico desde o Open mais recente, pelo que é um instrumento de depuração e regressão em vez de um medidor em vivo. Para o quadro mais alargado de para onde vão o tempo e os bytes em livros muito grandes, veja o desempenho de livros grandes em Delphi

Uma comparação do HotXLS mostrando um carregamento de folha inteira a manter todas as linhas residentes contra o cursor pull a segurar só a linha atual mais um conjunto de trabalho do formato, que é o que o PeakRowBufferedBytes acumula e reporta
O PeakRowBufferedBytes acumula a linha de saída atual mais o conjunto de trabalho específico do formato, pelo que a memória acompanha quão larga é uma linha em vez de quantas linhas a folha tem

O leitor push tornou-se um adaptador, e o que o cursor não fará

O TXLSForwardReader já não transporta pontos de entrada de varrimento separados para XLSX, BIFF e texto. Configura um cursor, percorre-o, e traduz a linha atual em eventos OnSheet e OnCell, que é a razão pela qual as duas fachadas já não podem divergir na filtragem, no estado de fórmulas ou no tratamento de erros. Duas consequências valem a pena conhecer antes de atualizar: o SheetIndex dos callbacks é agora uniformemente base um no TXLSForwardReader (o TXLSDirectReader mantém o seu contrato de eventos existente base zero), e o OnSheet dispara antes de SelectSheet, pelo que definir SkipSheet significa que a parte da folha de cálculo nunca é aberta nem descomprimida de todo. Os limites são igualmente explícitos: o livro não deve ser modificado enquanto uma passagem está ativa, cancelar exige um reinício explícito, e o caminho BIFF forward nunca descompila tokens de fórmulas, pelo que as células de fórmulas clássicas reportam HasFormula verdadeiro com FormulaTextAvailable falso e entregam-lhe o resultado em cache em vez de inventar uma cadeia de fórmula vazia. O cursor de linhas e o seu adaptador passaram 1,298 verificações em Delphi Win32 e Win64 mais o pacote estático Win64 do C++Builder 37.0

Se está a pesar um cursor pull contra o carregador que tem agora, a questão a fazer não é qual analisa mais depressa mas qual lhe deixa escrever a condição de saída de que realmente precisa. Os detalhes completos do componente, as versões de IDE suportadas e o licenciamento estão na página do componente de folhas de cálculo HotXLS para Delphi