Bài viết kỹ thuật

Viết XLSX triệu dòng trong Delphi với bộ nhớ không đổi

Một job báo cáo chạy tốt suốt một năm. Nó xây dựng workbook, điền sheet với kết quả truy vấn, rồi lưu. Sau đó một khách hàng có năm năm lịch sử yêu cầu export đầy đủ, số dòng vượt triệu, và tiến trình chết với lỗi hết bộ nhớ từ lâu trước khi file đến được đĩa. Không có gì sai với code. Nó giữ toàn bộ workbook trong RAM để serialize nó vào cuối, và bộ nhớ nó cần tăng theo tỷ lệ với số dòng nó được yêu cầu viết

Giải pháp không phải là máy lớn hơn. Mà là mô hình viết khác. Streaming direct writer trong HotXLS phát gói OOXML tăng dần khi các dòng đến, vì vậy bộ nhớ nó dùng không phụ thuộc vào số dòng bạn viết. Đây là phần bổ sung viết cho streaming reader: trong khi reader duyệt một sheet khổng lồ mà không xây dựng cây ô, writer tạo ra một cây mà không cần xây dựng cây ô

Tại sao đường save thông thường tăng theo dữ liệu

Đường TXLSXWorkbook thông thường xây dựng mô hình đối tượng đầy đủ trước. Mỗi ô, với giá trị, kiểu và tham chiếu style của nó, tồn tại như một đối tượng trong bộ nhớ cho đến khi bạn gọi save, lúc đó toàn bộ cây được serialize vào gói. Mô hình đó là đúng khi bạn muốn đọc một sheet, chỉnh sửa nó, tính toán lại, rồi ghi lại, vì truy cập ngẫu nhiên vào bất kỳ ô nào là chính xác điều chỉnh sửa cần. Nó là sai khi bạn đang đổ các dòng theo một chiều và không bao giờ nhìn lại, vì bạn trả tiền để giữ mỗi dòng thường trú mà không được hưởng lợi. Triệu dòng đối tượng là triệu dòng đối tượng dù bạn có bao giờ xem lại chúng hay không

Streaming writer loại bỏ cây. Ngay khi một ô được viết nó trở thành byte trong phần worksheet, và các byte đó được chuyển đến zip output. Stream worksheet là buffer duy nhất phát triển, và nó phát triển ở phía output, không phải là các đối tượng Delphi sống trên heap. Cái thường trú là một lượng cố định bookkeeping: tên sheet, vài cờ, số dòng hiện tại, bộ đếm ô. Tập hợp đó không thay đổi giữa dòng một và dòng mười triệu

Shared-string table là bẫy, và inline string là lối thoát

Hầu hết streaming XLSX writer làm tốt cho đến khi gặp text. Format OOXML thông thường lưu trữ chuỗi trong shared-string table: mỗi chuỗi riêng biệt được viết một lần vào một phần riêng, và mỗi ô chứa chuỗi đó mang một chỉ mục vào bảng thay vì text. Đây là một tối ưu hóa không gian tốt cho các file đầy nhãn lặp đi lặp lại, và đó là mặc định mà đường save thông thường sử dụng. Vấn đề cho streaming writer là tàn bạo. Để loại trùng lặp, bảng phải thường trú suốt toàn bộ job, vì bất kỳ dòng nào vẫn còn đến có thể lặp lại chuỗi từ dòng đã viết, và chỉ một map đầy đủ trong bộ nhớ của các chuỗi đã thấy mới có thể gán đúng chỉ mục. Vì vậy cấu trúc duy nhất mà streaming writer không thể stream là chính cấu trúc được cho là làm cho file nhỏ. Dữ liệu nặng text đánh bại streaming bạn muốn

Direct writer tránh bảng hoàn toàn. Chuỗi được viết inline, như các ô t="inlineStr" mà text nằm trực tiếp bên trong ô với phần tử <is><t>. Không có bảng nào để tích lũy và không có map các chuỗi đã thấy nào để giữ, nên các cột text không tốn bộ nhớ hơn các cột số. Sự đánh đổi là rõ ràng và đáng nêu rõ. Inline string lặp lại cùng text ở bất cứ đâu nó xuất hiện, nên một file với nhiều nhãn giống hệt nhau lớn hơn trên đĩa so với tương đương shared-string. Bạn chi phí kích thước file để mua bộ nhớ không đổi. Đối với một export một lần đó là đúng bên của giao dịch, và nén zip hấp thụ nhiều sự lặp lại trên đường ra anyway

Bảng style đến cuối, với một format ngày tháng

Style đặt ra cùng sức căng như chuỗi. Một workbook tham chiếu định dạng của nó qua phần styles, và streaming writer không thể giữ bảng style đang phát triển theo kịp các ô nó đã flush. Direct writer trả lời điều này bằng cách giữ bảng style nhỏ và cố định, rồi phát nó khi đóng thay vì ở đầu. Một format ô mặc định bao phủ các ô thông thường. Một format số ngày tháng bao phủ ngày tháng, được đăng ký với code format là yyyy-mm-dd tại một vị trí đã biết trong danh sách format ô

Format ngày tháng đó là lý do tại sao WriteDateTime tồn tại như lời gọi riêng của nó. Excel không có kiểu ngày tháng gốc; một ngày tháng là một số mang format ngày tháng. WriteDateTime viết giá trị như một số tuần tự thuần túy và gắn thẻ ô với style ngày tháng duy nhất, vì vậy bảng tính hiển thị nó như ngày tháng thay vì số nguyên năm chữ số. Tuần tự nó viết quan trọng cho round-tripping. Nó lưu trữ giá trị TDateTime trực tiếp dưới hệ thống ngày 1900, đây là cùng quy ước mà đường save TXLSXWorkbook thông thường sử dụng. Vì cả hai đường đều đồng ý về tuần tự, một file mà streaming writer tạo ra đọc lại qua HotXLS reader và mở trong Excel với các ngày tháng khớp với những gì bạn dự định, không có sai lệch one-off hay ngạc nhiên epoch giữa writer và reader

Thứ tự là bắt buộc, vì các byte đã đi rồi

Streaming mua profile bộ nhớ của nó với một quy tắc bạn phải tuân thủ. Output được phát khi bạn đi và không thể xem lại, nên mọi thứ phải được viết theo thứ tự nó xuất hiện trong file. Trong một dòng, các ô đi theo thứ tự cột tăng dần. Trong một sheet, các dòng đi theo thứ tự tăng dần. Không có buffer nào cho phép writer sắp xếp các ô của bạn sau thực tế, vì dòng bạn đóng một lúc trước đã là byte trong zip stream và không còn có thể tiếp cận được. Đưa cho nó cột 5 rồi cột 2 trong cùng một dòng và output bị dạng sai, vì writer đơn giản phát ra những gì bạn cho nó theo trình tự bạn cho nó

Row API có một tiện lợi nhỏ cho trường hợp phổ biến. AddRow nhận chỉ mục dòng 1-based, nhưng truyền 0 có nghĩa là lấy dòng tiếp theo sau dòng trước, nên điền tuần tự không cần theo dõi và truyền bộ đếm tăng dần. Mỗi AddRow đóng dòng trước nó, và mỗi AddSheet đóng sheet trước nó, nên bạn không bao giờ kết thúc rõ ràng một dòng hay một sheet. Bạn bắt đầu cái tiếp theo và writer hoàn thiện cấu trúc mở cho bạn

Escaping được xử lý ở nơi text vào XML

Bất kỳ text nào bạn viết trở thành một phần của tài liệu XML, nên năm entity XML được định nghĩa trước phải được escape hoặc gói sẽ không hợp lệ ngay khi một giá trị chứa dấu và hay dấu ngoặc góc. Writer escape &, <, >, ", và ' cho bạn trên cả text inline string và text công thức, hai nơi mà các ký tự do caller cung cấp nằm trong markup. Bạn truyền WideString thô và writer làm cho nó an toàn. Tên sản phẩm như Smith & Co <Ltd> hay công thức tham chiếu tên sheet được quote đều ra dưới dạng XML hợp lệ mà không cần escape từ phía bạn

Vòng đời và tại sao Destroy vẫn đóng

Hoàn thành gói là điều ghi phần workbook, phần styles, phần content-types và relationship, và cuối cùng là zip central directory. Công việc đó xảy ra trong Close. Một gói không bao giờ được đóng là zip không hoàn chỉnh mà không có chương trình bảng tính nào mở được, nên đóng không phải là dọn dẹp tùy chọn, mà là bước làm cho file hợp lệ. Để bảo vệ chống lại Close bị quên trong đường lỗi, Destroy thực hiện đóng best-effort nếu gói vẫn mở, vì vậy giải phóng writer không rò rỉ đối tượng zip cơ bản ngay cả khi một exception đã bỏ qua lời gọi rõ ràng. Pattern đáng tin cậy vẫn là pattern Delphi thông thường: viết trong try, gọi Close, và giải phóng trong finally

Stream một sheet lớn từ đầu đến cuối

Hình dạng của job là bắt đầu, thêm sheet, đổ dòng, đóng. Ví dụ dưới đây viết một dòng tiêu đề rồi một loạt dài các dòng dữ liệu có kiểu, trộn chuỗi, số, công thức không có kết quả được cache, và ngày tháng. Bộ nhớ nó dùng cho mười dòng và cho mười triệu dòng là như nhau, vì mỗi ô rời đến zip stream ngay khi nó được viết

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');

    // Hàng tiêu đề, được viết theo thứ tự cột tăng dần
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // Các hàng dữ liệu; truyền 0 vào AddRow để tự động lấy hàng tiếp theo
    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;                       // hoàn tất gói
  finally
    W.Free;
  end;
end;

Sheet thứ hai chỉ đơn giản là AddSheet khác trước khi bạn tiếp tục, và writer đóng sheet đầu tiên khi nó mở sheet thứ hai. Cờ boolean dùng WriteBoolean, viết ô boolean có kiểu thay vì text "True". Nếu bạn muốn xác nhận file tốt và round-trip, thuộc tính CellCount báo cáo bao nhiêu ô đã được viết, và đọc kết quả lại bằng streaming reader sẽ báo cáo cùng tổng

  // 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]));

Viết vào stream thay vì file là cùng code với BeginStream thay cho BeginFile, cho phép server gửi workbook đến HTTP response hay memory stream mà không cần file tạm trên đĩa. Writer không sở hữu stream bạn truyền vào, nên bạn giữ quyền kiểm soát vòng đời của nó

Khi công việc là server endpoint tạo workbook theo yêu cầu, các pattern trong streaming write cho server và batch job chỉ cách kết nối điều này vào request handler và scheduled export. Khi câu hỏi là chi phí tổng thể của workbook rất lớn, cả đọc và viết, hiệu suất workbook lớn trong Delphi đề cập đến nơi thời gian và bộ nhớ thực sự đi. Streaming direct writer được cung cấp như một phần của HotXLS Component cho Delphi và C++Builder, cùng với các API đọc, chỉnh sửa và lưu đầy đủ được đề cập ở nơi khác trên blog này