Un trabajo de reportes funciona bien durante un año. Construye un libro de trabajo, llena una hoja con lo que devuelva la consulta y lo guarda. Después un cliente con cinco años de historial pide una exportación completa, el número de filas cruza el millón y el proceso muere con un error de memoria agotada mucho antes de que el archivo llegue al disco. No había nada malo en el código. Mantenía el libro de trabajo entero en RAM para poder serializarlo al final, y la memoria que necesitaba crecía al mismo paso que el número de filas que se le pedía escribir
La solución no es una máquina más grande. Es un modelo de escritura distinto. El escritor directo en streaming de HotXLS emite el paquete OOXML de forma incremental a medida que llegan las filas, así que la memoria que usa no depende de cuántas filas escriba usted. Es la contraparte del lado de escritura del lector en streaming: donde el lector recorre una hoja enorme sin construir un árbol de celdas, el escritor produce una sin construir tampoco un árbol de celdas
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, su tipo y su referencia de estilo, vive como un objeto en memoria hasta que usted llama a guardar, momento en el que todo el árbol se serializa dentro del paquete. Ese modelo es el correcto cuando quiere leer una hoja, editarla, recalcular y volver a escribirla, porque el acceso aleatorio a cualquier celda es justo lo que la edición necesita. Es el equivocado cuando vierte filas en una sola dirección y nunca mira atrás, porque paga por mantener cada fila residente sin ningún beneficio. Un millón de filas de objetos son un millón de filas de objetos, las vuelva a visitar o no
El escritor en streaming elimina el árbol. En cuanto se escribe una celda se convierte en bytes dentro de 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 del lado de la salida, no como objetos Delphi vivos en el heap. Lo que permanece residente es una cantidad fija de contabilidad: los nombres de las hojas, unas cuantas 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 andan bien hasta que se topan con texto. El formato OOXML normalmente guarda las cadenas en una tabla de cadenas compartidas: cada cadena distinta se escribe una vez en una parte aparte, y cada celda que contiene esa cadena lleva un índice hacia la tabla en lugar del texto. Es una buena optimización de espacio para archivos llenos de etiquetas repetidas, y es lo que la ruta de guardado estándar usa de forma predeterminada. El problema para un escritor en streaming es brutal. Para deduplicar, la tabla tiene que permanecer residente durante todo el trabajo, porque cualquier fila que aún falte podría repetir una cadena de una fila ya escrita, y solo un mapa completo en memoria de las cadenas vistas puede asignar el índice correcto. Así que la única estructura que un escritor en streaming no puede transmitir es justamente la estructura que debería achicar el archivo. Los datos con mucho texto derrotan al streaming por el que vino
El escritor directo esquiva la tabla por completo. Las cadenas se escriben en línea, como celdas t="inlineStr" cuyo texto va directamente dentro de la celda con un elemento <is><t>. No hay tabla que acumular ni mapa de cadenas vistas que sostener, así que las columnas de texto no cuestan más memoria que las numéricas. El intercambio es explícito y vale la pena decirlo con claridad. Las cadenas en línea repiten el mismo texto donde sea que aparezca, así que un archivo con muchas etiquetas idénticas es más grande en disco que su equivalente con cadenas compartidas. Usted gasta tamaño de archivo para comprar memoria constante. Para una exportación de una sola pasada ese es el lado correcto del intercambio, y de todos modos la compresión zip absorbe buena parte de la repetición a la salida
La tabla de estilos llega al final, con un solo formato de fecha
Los estilos presentan la misma tensión que las cadenas. Un libro de trabajo referencia su formato a través de una parte de estilos, y un escritor en streaming no puede mantener una paleta creciente de estilos en sincronía con celdas que ya vació. 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 predeterminado cubre las celdas ordinarias. Un formato numérico de fecha cubre las fechas, registrado con el código de formato yyyy-mm-dd en una posición conocida de la lista de formatos de celda
Ese formato de fecha es la razón de que WriteDateTime exista como llamada propia. Excel no tiene un tipo de fecha nativo; una fecha es un número vestido con un formato de fecha. WriteDateTime escribe el valor como un número de serie simple y marca la celda con el único estilo de fecha, así que la hoja de cálculo lo representa como fecha y no como un entero de cinco dígitos. El número de serie que escribe importa para el ida y vuelta. Guarda el valor TDateTime directamente bajo el sistema de fechas de 1900, que es la misma convención que usa la ruta de guardado habitual de TXLSXWorkbook. Como ambas rutas coinciden en el número de serie, un archivo producido por el escritor en streaming se vuelve a leer con el lector de HotXLS y se abre en Excel con las fechas que usted quería, sin sorpresas de un día de diferencia ni de época entre el escritor y el lector
El orden es obligatorio, porque los bytes ya se fueron
El streaming compra su perfil de memoria con una regla que usted tiene que respetar. La salida se emite sobre la marcha y no se puede volver a visitar, así 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 le permita al escritor ordenar sus celdas después del hecho, porque la fila que cerró hace un momento ya son bytes en el flujo zip y ya no se puede alcanzar. Entréguele la columna 5 y luego la columna 2 en la misma fila y la salida queda malformada, porque el escritor simplemente emite lo que usted le da en la secuencia en que se lo da
La API de filas tiene una pequeña comodidad para el caso común. AddRow toma un índice de fila con base 1, pero pasar 0 significa tomar la fila siguiente a la anterior, así que un llenado secuencial no tiene que rastrear ni pasar un contador que se incrementa. Cada AddRow cierra la fila anterior, y cada AddSheet cierra la hoja anterior, así que usted nunca termina de forma explícita una fila ni una hoja. Empieza la siguiente y el escritor finaliza por usted la estructura abierta
El escapado se resuelve donde el texto entra al XML
Cualquier texto que escriba pasa a formar parte de un documento XML, así que las cinco entidades XML predefinidas tienen que escaparse o el paquete queda inválido en el momento en que un valor contiene un ampersand o un corchete angular. El escritor escapa &, <, >, " y ' por usted tanto en el texto de las cadenas en línea como en el de las fórmulas, los dos lugares donde los caracteres suministrados por quien llama aterrizan dentro del marcado. Usted pasa un WideString crudo y el escritor lo vuelve seguro. Un nombre de producto como Smith & Co <Ltd> o una fórmula que referencia un nombre de hoja entrecomillado sale como XML bien formado sin ningún escapado de su parte
Ciclo de vida, y por qué Destroy igual cierra
Terminar el paquete es lo que escribe la parte del libro de trabajo, la parte de estilos, las partes de tipos de contenido y de relaciones y, por último, el directorio central del zip. Ese trabajo ocurre en Close. Un paquete que nunca se cierra es un zip incompleto que ningún programa de hojas de cálculo abrirá, así que cerrar no es una limpieza opcional, es el paso que vuelve válido al archivo. Para protegerse de un Close olvidado en una ruta de error, Destroy hace un cierre de mejor esfuerzo si el paquete sigue abierto, así que liberar el escritor no filtra el objeto zip subyacente aunque una excepción se haya saltado la llamada explícita. El patrón confiable sigue siendo el ordinario de Delphi: escriba dentro de un try, llame a Close y libere en el finally
Transmitir una hoja grande de principio a fin
La forma del trabajo es iniciar, agregar una hoja, verter filas y cerrar. El ejemplo de abajo escribe una fila de encabezado y luego una larga tanda de filas de datos tipados, mezclando cadenas, números, una fórmula sin resultado en caché y una fecha. La memoria que usa para diez filas y para diez millones de filas es la misma, porque cada celda parte hacia el flujo zip en cuanto 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');
// Fila de encabezado, escrita en orden ascendente de columna
W.AddRow(1);
W.WriteString(1, 'Item');
W.WriteString(2, 'Qty');
W.WriteString(3, 'Price');
W.WriteString(4, 'Total');
W.WriteString(5, 'Date');
// Filas de datos; pase 0 a AddRow para tomar la fila siguiente automáticamente
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; // finaliza el paquete
finally
W.Free;
end;
end;
Una segunda hoja es simplemente otro AddSheet antes de continuar, y el escritor cierra la primera hoja al abrir 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á sano y hace ida y vuelta, la propiedad CellCount informa cuántas celdas se escribieron, y leer el resultado de vuelta con el lector en streaming debería informar el mismo total
// Una segunda hoja de banderas tipadas después de la hoja de datos de arriba
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 a un flujo en lugar de a un archivo es el mismo código con BeginStream en lugar de BeginFile, lo que permite que un servidor envíe el libro de trabajo a una respuesta HTTP o a un flujo en memoria sin un archivo temporal en disco. El escritor no es dueño del flujo que usted le pasa, así que usted conserva el control de su tiempo de vida
Cuando el trabajo es un endpoint de servidor que construye libros de trabajo bajo demanda, los patrones de escrituras en streaming para servidores y trabajos por lotes muestran cómo conectar esto a un manejador de solicitudes y a una exportación programada. Cuando la pregunta es el costo más amplio de los libros de trabajo muy grandes, tanto al leer como al escribir, el rendimiento con libros de trabajo grandes en Delphi cubre a dónde se van realmente el tiempo y la memoria. El escritor directo en streaming se distribuye como parte de HotXLS Delphi Component para Delphi y C++Builder, junto con las APIs completas de lectura, edición y guardado tratadas en otros artículos de este blog