Technical Article

Escribir XLSX de un millón de filas en Delphi con memoria constante

Un trabajo de informes funciona bien durante un año. Construye un libro de trabajo, llena una hoja con lo que devuelva la consulta, y lo guarda. Entonces un cliente con cinco años de historial pide una exportación completa, el recuento de filas cruza el millón, y el proceso muere con un error de falta de memoria mucho antes de que el archivo llegue al disco. No había nada malo en el código. Mantenía todo el libro de trabajo en RAM para poder serializarlo al final, y la memoria que necesitaba crecía al unísono con el número de filas que se le pedía que escribiera

La solución no es una máquina más grande. Es un modelo de escritura diferente. El escritor directo en streaming en HotXLS emite el paquete OOXML incrementalmente a medida que llegan las filas, por lo que la memoria que utiliza no depende de cuántas filas se escriban. Es la contrapartida del lado de escritura al lector de transmisión: donde el lector recorre una hoja enorme sin construir un árbol de celdas, el escritor produce una sin construir un árbol de celdas tampoco

Por qué la ruta de guardado normal crece con los datos

La ruta habitual de TXLSXWorkbook construye primero un modelo de objetos completo. Cada celda, con su valor, tipo y referencia de estilo, vive como un objeto en memoria hasta que se llama a guardar, momento en el que todo el árbol se serializa en el paquete. Ese modelo es el correcto cuando se quiere leer una hoja, editarla, recalcular y volver a escribirla, porque el acceso aleatorio a cualquier celda es exactamente lo que necesita la edición. Es el equivocado cuando se vierten filas en una dirección y nunca se mira atrás, porque se paga por mantener cada fila residente sin ningún beneficio. Un millón de filas de objetos es un millón de filas de objetos independientemente de si se vuelve a ellas o no

El escritor de transmisión elimina el árbol. Tan pronto como se escribe una celda, se convierte en bytes en la parte de la hoja de cálculo, y esos bytes se entregan a la salida zip. El flujo de la hoja de cálculo es el único búfer que crece, y crece en el lado de la salida, no como objetos Delphi vivos en el montón. Lo que se mantiene residente es una cantidad fija de contabilidad: los nombres de las hojas, unas pocas banderas, el número de fila actual, un contador de celdas. Ese conjunto no cambia entre la fila uno y la fila diez millones

La tabla de cadenas compartidas es la trampa, y las cadenas en línea son la salida

La mayoría de los escritores XLSX en streaming lo hacen bien hasta que se encuentran con el texto. El formato OOXML normalmente almacena las cadenas en una tabla de cadenas compartidas: cada cadena distinta se escribe una vez en una parte separada, y cada celda que contiene esa cadena lleva un índice a la tabla en lugar del texto. Es una buena optimización de espacio para archivos llenos de etiquetas repetidas, y es la opción por defecto que utiliza la ruta de guardado estándar. El problema para un escritor de transmisión es brutal. Para deduplicar, la tabla tiene que mantenerse residente durante todo el trabajo, porque cualquier fila que aún esté por venir podría repetir una cadena de una fila ya escrita, y solo un mapa en memoria completo de las cadenas vistas puede asignar el índice correcto. Así que la única estructura que un escritor en streaming no puede transmitir es la misma estructura que se supone que debe hacer el archivo pequeño. Los datos con mucho texto anulan el streaming que venía a buscar

El escritor directo elude la tabla por completo. Las cadenas se escriben en línea, como celdas t="inlineStr" cuyo texto se asienta directamente dentro de la celda con un elemento <is><t>. No hay ninguna tabla que acumular ni ningún mapa de cadenas vistas que retener, de modo que las columnas de texto no cuestan más memoria que las numéricas. El intercambio es explícito y vale la pena afirmarlo claramente. Las cadenas en línea repiten el mismo texto dondequiera que aparezca, por lo que un archivo con muchas etiquetas idénticas es más grande en disco que el equivalente de cadena compartida. Se gasta tamaño de archivo para comprar memoria constante. Para una exportación de una sola pasada, esa es la cara correcta del intercambio, y la compresión zip absorbe gran parte de la repetición a la salida de todos modos

La tabla de estilos llega al final, con un formato de fecha

Los estilos presentan la misma tensión que las cadenas. Un libro de trabajo hace referencia a su formato a través de una parte de estilos, y un escritor de transmisión no puede mantener una paleta creciente de estilos a la par con las celdas que ya ha vaciado. El escritor directo responde a esto manteniendo la tabla de estilos pequeña y fija, y emitiéndola al cerrar en lugar de por adelantado. Un formato de celda por defecto cubre las celdas ordinarias. Un formato numérico de fecha cubre las fechas, registrado con un código de formato yyyy-mm-dd en una posición conocida en la lista de formatos de celda

Ese formato de fecha es la razón por la que WriteDateTime existe como su propia llamada. Excel no tiene un tipo de fecha nativo; una fecha es un número que lleva un formato de fecha. WriteDateTime escribe el valor como un simple número de serie y etiqueta la celda con el único estilo de fecha, para que la hoja de cálculo lo represente como una fecha en lugar de como un entero de cinco dígitos. El número de serie que escribe es importante para el viaje de ida y vuelta. Almacena el valor TDateTime directamente bajo el sistema de fechas de 1900, que es la misma convención que utiliza la ruta de guardado habitual de TXLSXWorkbook. Puesto que ambas rutas coinciden en el número de serie, un archivo que produce el escritor de transmisión se lee de nuevo a través del lector de HotXLS y se abre en Excel con las fechas que usted pretendía, sin ninguna sorpresa de fuera por uno ni de época entre el escritor y el lector

El orden es obligatorio, porque los bytes ya han desaparecido

La transmisión compra su perfil de memoria con una regla que debe cumplir. La salida se emite a medida que se avanza y no se puede revisar, por lo que todo debe escribirse en el orden en que aparece en el archivo. Dentro de una fila, las celdas van en orden ascendente de columna. Dentro de una hoja, las filas van en orden ascendente. No hay ningún búfer que permita al escritor ordenar las celdas después de los hechos, porque la fila que cerró hace un momento ya son bytes en el flujo zip y ya no es accesible. Si le entrega la columna 5 y luego la columna 2 en la misma fila, la salida estará mal formada, ya que el escritor simplemente emite lo que usted le da en la secuencia que le da

La API de filas tiene una pequeña comodidad para el caso común. AddRow toma un índice de fila de base 1, pero pasar 0 significa tomar la fila siguiente a la anterior, de forma que un relleno secuencial no tiene que rastrear y pasar un contador que se incrementa. Cada AddRow cierra la fila que le precede, y cada AddSheet cierra la hoja que le precede, por lo que nunca termina explícitamente una fila o una hoja. Usted inicia la siguiente y el escritor finaliza la estructura abierta por usted

El escape se gestiona donde el texto entra en el XML

Cualquier texto que escriba se convierte en parte de un documento XML, por lo que las cinco entidades XML predefinidas deben ser escapadas o el paquete es inválido en el momento en que un valor contenga un ampersand o un paréntesis angular. El escritor escapa &, <, >, ", y ' por usted tanto en el texto de cadena en línea como en el texto de fórmula, los dos lugares donde los caracteres suministrados por la persona que llama aterrizan dentro del marcado. Usted pasa un WideString en bruto y el escritor lo hace seguro. Un nombre de producto como Smith & Co <Ltd> o una fórmula que hace referencia a un nombre de hoja entre comillas sale como XML bien formado sin ningún escape por su parte

Ciclo de vida, y por qué Destroy todavía cierra

Terminar el paquete es lo que escribe la parte del libro de trabajo, la parte de los estilos, las partes de los tipos de contenido y de relación, y finalmente el directorio central zip. Ese trabajo ocurre en Close. Un paquete que nunca se cierra es un zip incompleto que ningún programa de hoja de cálculo abrirá, así que cerrar no es una limpieza opcional, es el paso que hace que el archivo sea válido. Para protegerse de un Close olvidado en una ruta de error, Destroy realiza un cierre con el mejor esfuerzo si el paquete sigue abierto, por lo que liberar el escritor no filtra el objeto zip subyacente incluso cuando una excepción se saltó la llamada explícita. El patrón fiable sigue siendo el ordinario de Delphi: escribir dentro de un try, llamar a Close, y liberar en el finally

Transmitir una hoja grande de principio a fin

La forma del trabajo es comenzar, añadir una hoja, verter filas, cerrar. El siguiente ejemplo escribe una fila de cabecera y luego una larga serie de filas de datos tipados, mezclando cadenas, números, una fórmula sin resultado almacenado en caché, y una fecha. La memoria que utiliza para diez filas y para diez millones de filas es la misma, porque cada celda sale hacia el flujo zip tan pronto como se escribe

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;

Una segunda hoja es simplemente otro AddSheet antes de continuar, y el escritor cierra la primera hoja cuando abre la segunda. Las banderas booleanas usan WriteBoolean, que escribe una celda booleana tipada en lugar del texto "True". Si quiere confirmar que el archivo está bien y hace el viaje de ida y vuelta, la propiedad CellCount informa de cuántas celdas se escribieron, y volver a leer el resultado con el lector de transmisión debería informar del mismo 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]));

Escribir en un flujo en lugar de en un archivo es el mismo código con BeginStream en lugar de BeginFile, lo que permite a un servidor enviar el libro de trabajo a una respuesta HTTP o a un flujo de memoria sin un archivo temporal en disco. El escritor no posee el flujo que usted le pasa, de modo que usted mantiene el control de su tiempo de vida

Cuando el trabajo es un punto final de servidor que construye libros de trabajo bajo demanda, los patrones en escrituras en streaming para servidores y trabajos por lotes muestran cómo conectar esto en un manejador de peticiones y en una exportación programada. Cuando la cuestión es el costo más amplio de los libros de trabajo muy grandes, tanto de lectura como de escritura, rendimiento de libros de trabajo grandes en Delphi cubre adónde va realmente el tiempo y la memoria. El escritor directo en streaming se distribuye como parte del HotXLS Component para Delphi y C++Builder, junto a las API completas de lectura, edición y guardado cubiertas en otras partes de este blog