Bài viết kỹ thuật

Đọc thuộc tính tài liệu Excel trong Delphi: ba hướng đi

Hãy bảo một pipeline định tuyến mười nghìn bảng tính theo tác giả, công ty hay ngày sửa lần cuối, và điều tệ nhất nó có thể làm là mở trọn từng workbook. Câu trả lời nằm trong các thuộc tính tài liệu của tệp, thứ mà thế giới Office gọi là Document Summary Information: tầng metadata mà Windows Search lập chỉ mục, SharePoint dùng để phân loại, và Excel hiển thị trong hộp thoại Properties. Tầng đó nhiều nhất cũng chỉ vài kilobyte, và nó nằm ở một chỗ được tài liệu hóa kỹ trong cả hai định dạng Excel. Mẹo là chạm tới nó từ Delphi mà không phải trả giá cho hàng triệu ô bạn không cần

Có ba hướng đi thực sự, và chúng khác nhau ở đòi hỏi đặt lên máy chạy chúng nhiều hơn là ở thứ chúng trả về. Tự động hóa COM điều khiển chính Excel và đọc được mọi thứ, với cái giá của một máy để bàn. Định dạng .xls giữ thuộc tính trong các stream property-set OLE mà Windows sẽ phân tích giúp bạn. Định dạng .xlsx giữ chúng trong hai phần XML nhỏ bên trong một tệp zip mà RTL của Delphi tự mở được. Mã chạy được cho từng hướng nằm ngay sau đây, kèm chi phí nói thẳng

Biểu đồ ba hướng đi trong Delphi để lấy Document Summary Information của Excel: tự động hóa COM điều khiển chính Excel, stream property-set OLE cho tệp xls, và phân tích XML docProps của OOXML cho gói xlsx
Tự động hóa COM mua được độ phủ trọn vẹn bằng cái giá của một bản Excel để bàn có bản quyền và vài giây cho mỗi tệp, còn hai hướng đi bám định dạng chỉ đọc phần chứa metadata trong vài mili giây. Thứ mỗi hướng trả về gần như giống nhau — thứ nó đòi hỏi ở máy chủ nhà thì không

Hướng 1: tự động hóa COM đọc được mọi thứ, với giá của máy để bàn

Tự động hóa là hướng duy nhất có độ phủ trọn vẹn qua một mô hình đối tượng: bộ tóm tắt tiêu chuẩn, bộ mở rộng có Company và Manager, cùng các thuộc tính tùy biến do người dùng định nghĩa, tất cả đều với tới được qua BuiltinDocumentPropertiesCustomDocumentProperties. Mọi thứ đến dưới dạng OleVariant, và API này có một thói quen đáng biết trước khi nó cắn bạn: một thuộc tính dựng sẵn chưa từng được gán không trả về giá trị rỗng, nó ném ra một EOleException ngay khoảnh khắc bạn chạm vào Value. Hàm trợ giúp bên dưới coi đó là "chưa đặt" chứ không phải một thất bại

uses
  System.SysUtils, System.Variants, System.Win.ComObj;

procedure ReadPropertiesViaCom(const FileName: string);
var
  Excel, Book, Builtin, Custom: OleVariant;
  I: Integer;

  function BuiltinProp(const Name: string): string;
  begin
    try
      Result := VarToStr(Builtin.Item(Name).Value);
    except
      on EOleError do
        Result := '';   // thuộc tính có tồn tại nhưng chưa từng được gán
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // chỉ đọc
    try
      Builtin := Book.BuiltinDocumentProperties;
      Writeln('Author : ', BuiltinProp('Author'));
      Writeln('Title  : ', BuiltinProp('Title'));
      Writeln('Subject: ', BuiltinProp('Subject'));
      Writeln('Company: ', BuiltinProp('Company'));
      Writeln('Manager: ', BuiltinProp('Manager'));

      Custom := Book.CustomDocumentProperties;
      for I := 1 to Custom.Count do
        Writeln(VarToStr(Custom.Item(I).Name), ' = ',
          VarToStr(Custom.Item(I).Value));
    finally
      Book.Close(False);
    end;
  finally
    Excel.Quit;   // phải tới được đây trên mọi nhánh, nếu không EXCEL.EXE còn ở lại
    Excel := Unassigned;
  end;
end;

Giờ tới hóa đơn. Excel phải được cài trên mọi máy chạy đoạn mã này, riêng điều đó đã loại phần lớn máy chủ, và chính sách hỗ trợ của Microsoft nói rõ rằng Office không được thiết kế lẫn không được cấp phép cho tự động hóa phía máy chủ không người trông. CreateOleObject khởi chạy một EXCEL.EXE đầy đủ và Workbooks.Open phân tích trọn workbook, nên hãy trông đợi khoảng hai tới bốn giây cho mỗi tệp trước khi thuộc tính đầu tiên quay về. Và khối try..finally bọc quanh Quit không phải để trang trí: một ngoại lệ lọt ra giữa CreateOleObjectQuit sẽ để lại một EXCEL.EXE mồ côi đang giữ khóa trên tệp, vô hình cho tới khi lần chạy sau vấp phải nó. Dùng lại một thực thể Excel cho cả lô giúp phân bổ chi phí khởi động nhưng lại dồn rủi ro vào một chỗ, bởi chỉ một hộp thoại lạc trên desktop ẩn cũng đủ làm nghẽn mọi tệp xếp hàng phía sau

Hướng 2: .xls lưu thuộc tính trong các stream property-set OLE

Một workbook BIFF8 là một tệp hợp thành OLE, một hệ thống tệp thu nhỏ gồm các storage và stream. Dữ liệu ô nằm trong stream Workbook; metadata nằm ngay cạnh trong hai stream property-set có tên bắt đầu bằng ký tự điều khiển #5: \005SummaryInformation cho các trường cổ điển và \005DocumentSummaryInformation cho các trường mở rộng và tùy biến. Bên trong mỗi stream là một property set nhị phân theo bố cục MS-OLEPS, với các section được khóa bằng một định danh định dạng (FMTID) và các thuộc tính được khóa bằng một property ID dạng số nguyên. Section tóm tắt có FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, nơi PIDSI_TITLE$02PIDSI_AUTHOR$04; Company ($0F) và Manager ($0E) nằm trong section document-summary, còn thuộc tính tùy biến nằm trong một section thứ hai phía sau một từ điển tên

Giải phẫu trong Delphi của một tệp hợp thành xls BIFF8 đặt stream Workbook cạnh các property set SummaryInformation và DocumentSummaryInformation, kèm chuỗi truy cập từ StgOpenStorageEx tới IPropertySetStorage
Một tệp xls lưu dữ liệu ô và thuộc tính tài liệu thành các stream anh em trong một tệp hợp thành OLE. Windows sẽ phân tích các property set nhị phân giúp bạn, nên mã Delphi không phải tự tay đụng tới bố cục MS-OLEPS lẫn code page

Tin tốt là trên Windows bạn không bao giờ phải tự phân tích đống byte đó. Structured storage phơi bày các stream qua IPropertySetStorage, và đoạn dưới đây biên dịch được y như hiện ra với các unit RTL nguyên bản

uses
  System.SysUtils, Winapi.Windows, Winapi.ActiveX, System.Win.ComObj;

const
  FMTID_SummaryInfo: TGUID = '{F29F85E0-4FF9-1068-AB91-08002B27B3D9}';
  PIDSI_TITLE    = $02;
  PIDSI_AUTHOR   = $04;
  STGFMT_STORAGE = 0;

function ReadXlsSummaryString(const FileName: string; PropId: TPropID): string;
var
  Unk: IUnknown;
  Stg: IStorage;
  PropSetStg: IPropertySetStorage;
  PropStg: IPropertyStorage;
  Spec: TPropSpec;
  Value: TPropVariant;
begin
  Result := '';
  OleCheck(StgOpenStorageEx(PWideChar(FileName),
    STGM_READ or STGM_SHARE_DENY_WRITE, STGFMT_STORAGE, 0, nil, nil,
    @IID_IStorage, Unk));
  Stg := Unk as IStorage;
  PropSetStg := Stg as IPropertySetStorage;
  OleCheck(PropSetStg.Open(FMTID_SummaryInfo,
    STGM_READ or STGM_SHARE_EXCLUSIVE, PropStg));
  Spec.ulKind := PRSPEC_PROPID;
  Spec.propid := PropId;
  if PropStg.ReadMultiple(1, @Spec, @Value) = S_OK then  // S_FALSE: không có mặt
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

// cách dùng: Writeln('Author: ', ReadXlsSummaryString('ledger.xls', PIDSI_AUTHOR));

Một lời thành thật về những gì đoạn mã che giấu. Chuỗi có thể đến dưới dạng VT_LPWSTR hoặc VT_LPSTR, và trong trường hợp ANSI thì các byte được mã hóa theo code page của chính property set, vốn được lưu làm thuộc tính 1 của section, nên phép ép kiểu ở trên chỉ chính xác khi code page đó trùng với code page của hệ thống. Dấu thời gian quay về dưới dạng VT_FILETIME theo giờ UTC. Thuộc tính tùy biến đồng nghĩa với việc mở section do người dùng định nghĩa, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, rồi duyệt từ điển tên của nó. IPropertyStorage hấp thụ toàn bộ chuyện đó trên Windows; viết bộ phân tích MS-OLEPS của riêng bạn cho một môi trường không có structured storage là một dự án thực thụ, không phải việc của một buổi chiều

Hướng 3: .xlsx giữ docProps dưới dạng XML bên trong tệp zip

Đây là hướng mà phần lớn pipeline thực sự cần, bởi tệp mới đã ở dạng .xlsx suốt gần hai thập niên. Một workbook OOXML là một gói zip, và các thuộc tính của nó được tách vào những phần nhỏ theo mục đích: docProps/core.xml chứa các trường Dublin Core là dc:title, dc:creator, cp:lastModifiedBy, cộng thêm dcterms:createddcterms:modified dưới dạng dấu thời gian W3CDTF theo UTC, còn docProps/app.xml chứa các trường ở mức ứng dụng như Company và AppVersion, và docProps/custom.xml chứa các thuộc tính tùy biến. Vì thư mục trung tâm của tệp zip định vị từng phần một cách trực tiếp, việc đọc chúng chỉ tốn vài kilobyte bất kể workbook lớn cỡ nào. TZipFileIXMLDocument, cả hai đều có sẵn trong RTL, làm trọn công việc

Delphi: bố cục một gói zip xlsx cho thấy các thành phần XML docProps core, app và custom nằm cạnh các phần worksheet, kèm quy tắc dò các phần tùy chọn và khớp namespace khi chạy thật
Dữ liệu worksheet chiếm phần lớn một gói xlsx, thế nhưng metadata lại nằm trong ba thành phần tùy chọn nhỏ bé bên cạnh. Truy cập ngẫu nhiên qua thư mục trung tâm của zip giữ cho chi phí đọc tỉ lệ với thuộc tính chứ không phải với workbook
uses
  System.SysUtils, System.Classes, System.Zip, Xml.XMLDoc, Xml.XMLIntf;

const
  NsDC    = 'http://purl.org/dc/elements/1.1/';
  NsTerms = 'http://purl.org/dc/terms/';
  NsCore  = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties';
  NsApp   = 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties';

function PartToXml(Zip: TZipFile; const PartName: string): IXMLDocument;
var
  Bytes: TBytes;
begin
  Zip.Read(PartName, Bytes);
  Result := LoadXMLData(TEncoding.UTF8.GetString(Bytes));
end;

function Field(const Doc: IXMLDocument; const LocalName, Ns: string): string;
var
  Node: IXMLNode;
begin
  Node := Doc.DocumentElement.ChildNodes.FindNode(LocalName, Ns);
  if Node <> nil then
    Result := Node.Text
  else
    Result := '';
end;

procedure ReadXlsxProperties(const FileName: string);
var
  Zip: TZipFile;
  Doc: IXMLDocument;
begin
  Zip := TZipFile.Create;
  try
    Zip.Open(FileName, zmRead);
    if Zip.IndexOf('docProps/core.xml') >= 0 then
    begin
      Doc := PartToXml(Zip, 'docProps/core.xml');
      Writeln('Title   : ', Field(Doc, 'title', NsDC));
      Writeln('Creator : ', Field(Doc, 'creator', NsDC));
      Writeln('Modifier: ', Field(Doc, 'lastModifiedBy', NsCore));
      Writeln('Modified: ', Field(Doc, 'modified', NsTerms));  // W3CDTF, UTC
    end;
    if Zip.IndexOf('docProps/app.xml') >= 0 then
    begin
      Doc := PartToXml(Zip, 'docProps/app.xml');
      Writeln('Company : ', Field(Doc, 'Company', NsApp));
      Writeln('App     : ', Field(Doc, 'Application', NsApp), ' ',
        Field(Doc, 'AppVersion', NsApp));
    end;
  finally
    Zip.Free;
  end;
end;

Hai chi tiết giữ cho đoạn này vững vàng khi chạy thật. Thứ nhất, các phần là tùy chọn: một gói tối giản không có docProps nào vẫn hoàn toàn hợp lệ theo ECMA-376, đó là lý do đoạn mã dò bằng IndexOf thay vì cứ thế giả định. Thứ hai, hãy khớp phần tử theo tên cục bộ và URI namespace, như FindNode làm ở trên, đừng bao giờ khớp theo tiền tố nguyên văn; dc:cp: là quy ước của bộ ghi trong Excel, còn tệp do các bộ sinh khác tạo ra có quyền chọn tiền tố khác. Một lưu ý về môi trường: nhà cung cấp mặc định của IXMLDocument là MSXML, nên một ứng dụng console hay một luồng thợ phải gọi CoInitialize trước LoadXMLData, nếu không lần phân tích đầu tiên sẽ chết với một lỗi COM

Bảng chi phí, và khi nào một thư viện thắng cả hai bộ phân tích

Đo trên một máy lập trình viên bình thường, hướng COM rơi vào khoảng hai tới bốn giây mỗi tệp khi phiên tự động hóa được tạo cho từng tệp, gần như toàn bộ là thời gian khởi động EXCEL.EXE cộng với một lần phân tích workbook trọn vẹn, và nó đòi hỏi một bản Excel đã cài và có bản quyền ở bất cứ nơi nào nó chạy. Hai hướng trực tiếp chỉ đọc phần chứa metadata, xong trong vài mili giây mỗi tệp, và không cần cài thêm gì ngoài những gì một tệp thực thi Delphi đã liên kết sẵn. Trên một thư mục chia sẻ mười nghìn tệp, đó là khác biệt giữa gần trọn một ngày làm việc và chưa tới một phút, lại chẳng kèm câu hỏi nào về việc triển khai Office

Điểm vướng của các hướng trực tiếp là có tới hai hướng. Một pipeline nhận cả hai định dạng phải nuôi hai bộ phân tích với hai kiểu hỏng hóc rời nhau, một bên là code page và kiểu PROPVARIANT, bên kia là namespace và các phần tùy chọn, và không bên nào đọc được định dạng của bên kia. Gánh nặng bảo trì đó chính là lý lẽ cho một thư viện gốc: HotXLS, thư viện bảng tính Object Pascal của losLab cho Delphi và C++Builder trên Windows, phơi bày đúng những trường ấy dưới dạng thuộc tính workbook thông thường là Title, Author, Company, Created và phần còn lại, được điền bởi Open cho cả .xls lẫn .xlsx, không cần cài Excel và không có chút ống dẫn vỏ chứa nào như ở trên. Nó đọc thuộc tính như một phần của việc mở trọn workbook chứ không phải một lần dò chỉ lấy metadata, nên nó hợp với những pipeline dù sao cũng sẽ đụng tới dữ liệu ô; toàn bộ bề mặt thuộc tính trên cả hai mặt tiền, gồm cả phía ghi, được trình bày trong bài viết của chúng tôi về việc đặt thuộc tính tài liệu Excel bằng HotXLS

Lưu ý: Các công cụ phân tích Excel đầy đủ và trích xuất metadata có trong HotXLS Delphi VCL Component