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
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 BuiltinDocumentProperties và CustomDocumentProperties. 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 CreateOleObject và Quit 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 là $02 và PIDSI_AUTHOR là $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
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:created và dcterms: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. TZipFile và IXMLDocument, cả hai đều có sẵn trong RTL, làm trọn công việc
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: và 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