ลองสั่งให้ไปป์ไลน์จัดเส้นทางสเปรดชีตหนึ่งหมื่นไฟล์ตามผู้เขียน บริษัท หรือวันที่แก้ไขล่าสุด สิ่งที่แย่ที่สุดที่มันทำได้คือการเปิด workbook ทุกไฟล์แบบเต็ม คำตอบที่ต้องการนั้นเดินทางมากับคุณสมบัติเอกสารของไฟล์ ซึ่งโลกของ Office เรียกว่า Document Summary Information คือชั้น metadata ที่ Windows Search ทำดัชนี ที่ SharePoint ใช้จัดแฟ้ม และที่ Excel แสดงในกล่องโต้ตอบ Properties ชั้นนี้ใหญ่อย่างมากก็ระดับกิโลไบต์ และอยู่ในตำแหน่งที่มีเอกสารกำกับชัดเจนในรูปแบบไฟล์ Excel ทั้งสองแบบ เคล็ดลับคือการเข้าถึงมันจาก Delphi โดยไม่ต้องจ่ายค่าเซลล์นับล้านที่คุณไม่ได้ต้องการ
เส้นทางที่ใช้ได้จริงมีอยู่สามเส้น และความต่างของทั้งสามอยู่ที่สิ่งที่มันเรียกร้องจากเครื่องที่รันมัน มากกว่าสิ่งที่มันคืนกลับมา COM automation สั่งงานตัว Excel เองและอ่านได้ทุกอย่าง ด้วยราคาระดับเดสก์ท็อป รูปแบบ .xls เก็บคุณสมบัติไว้ในสตรีม property-set ของ OLE ที่ Windows จะแยกวิเคราะห์ให้คุณเอง ส่วนรูปแบบ .xlsx เก็บไว้ในชิ้นส่วน XML ขนาดเล็กสองชิ้นภายในไฟล์ zip ที่ RTL ของ Delphi เปิดได้ด้วยตัวเอง โค้ดที่ใช้งานได้จริงของแต่ละเส้นทางอยู่ถัดจากนี้ พร้อมต้นทุนที่ระบุไว้อย่างตรงไปตรงมา
เส้นทางที่ 1: COM automation อ่านได้ทุกอย่าง ด้วยราคาระดับเดสก์ท็อป
COM automation เป็นเส้นทางเดียวที่ครอบคลุมทั้งหมดผ่าน object model เดียว ทั้งชุดสรุปมาตรฐาน ชุดขยายที่มี Company และ Manager และคุณสมบัติกำหนดเองของผู้ใช้ ล้วนเข้าถึงได้ผ่าน BuiltinDocumentProperties และ CustomDocumentProperties ทุกค่ามาถึงในรูป OleVariant และ API ตัวนี้มีนิสัยหนึ่งที่ควรรู้ก่อนจะโดนมันกัด: คุณสมบัติในตัวที่ไม่เคยถูกกำหนดค่าจะไม่คืนค่าว่างกลับมา แต่จะโยน EOleException ทันทีที่คุณแตะ Value ตัวช่วยด้านล่างถือว่านั่นคือกรณี "ยังไม่ได้ตั้งค่า" ไม่ใช่ความล้มเหลว
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 := ''; // คุณสมบัตินี้มีอยู่ แต่ไม่เคยถูกกำหนดค่า
end;
end;
begin
Excel := CreateOleObject('Excel.Application');
try
Excel.DisplayAlerts := False;
Book := Excel.Workbooks.Open(FileName, 0, True); // อ่านอย่างเดียว
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; // ต้องมาถึงบรรทัดนี้ทุกเส้นทาง ไม่อย่างนั้น EXCEL.EXE จะค้างอยู่
Excel := Unassigned;
end;
end;
ทีนี้มาถึงใบเรียกเก็บเงิน Excel ต้องถูกติดตั้งบนทุกเครื่องที่โค้ดนี้รัน ซึ่งข้อนี้ข้อเดียวก็ตัดเซิร์ฟเวอร์ส่วนใหญ่ออกไปแล้ว และนโยบายการสนับสนุนของ Microsoft ระบุชัดว่า Office ไม่ได้ถูกออกแบบและไม่ได้ให้สัญญาอนุญาตสำหรับ automation ฝั่งเซิร์ฟเวอร์แบบไม่มีคนดูแล CreateOleObject เปิด EXCEL.EXE เต็มตัวขึ้นมา และ Workbooks.Open แยกวิเคราะห์ workbook ทั้งไฟล์ จึงคาดได้ราวสองถึงสี่วินาทีต่อไฟล์กว่าคุณสมบัติตัวแรกจะกลับมา และ try..finally ที่ล้อม Quit ไม่ใช่ของประดับ: exception ที่หลุดออกไประหว่าง CreateOleObject กับ Quit จะทิ้ง EXCEL.EXE กำพร้าที่ยังล็อกไฟล์นั้นค้างไว้ มองไม่เห็นจนกว่าการรันรอบถัดไปจะล้มเหลวเพราะมัน การใช้อินสแตนซ์ Excel ตัวเดียวซ้ำตลอดทั้งชุดงานช่วยเฉลี่ยต้นทุนการเริ่มระบบ แต่ก็รวมความเสี่ยงไว้ที่จุดเดียว เพราะกล่องโต้ตอบที่โผล่ขึ้นมาบนเดสก์ท็อปที่ซ่อนอยู่เพียงกล่องเดียว ก็หยุดทุกไฟล์ที่ต่อคิวอยู่ข้างหลังได้
เส้นทางที่ 2: .xls เก็บคุณสมบัติไว้ในสตรีม property-set ของ OLE
workbook แบบ BIFF8 คือไฟล์ประกอบ OLE ซึ่งเป็นระบบไฟล์ขนาดจิ๋วของ storage และ stream ข้อมูลเซลล์อยู่ในสตรีม Workbook ส่วน metadata อยู่ข้าง ๆ กันในสตรีม property-set สองตัวที่ชื่อขึ้นต้นด้วยอักขระควบคุม #5 ได้แก่ \005SummaryInformation สำหรับฟิลด์ดั้งเดิม และ \005DocumentSummaryInformation สำหรับฟิลด์ขยายและฟิลด์กำหนดเอง ภายในแต่ละตัวคือ property set แบบไบนารีตามผัง MS-OLEPS ที่แบ่งเป็น section ซึ่งคีย์ด้วยตัวระบุรูปแบบ (FMTID) และคุณสมบัติซึ่งคีย์ด้วยเลข property ID section สรุปคือ FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9} โดยที่ PIDSI_TITLE คือ $02 และ PIDSI_AUTHOR คือ $04 ส่วน Company ($0F) และ Manager ($0E) อยู่ใน section document-summary และคุณสมบัติกำหนดเองอยู่ใน section ที่สองซึ่งอยู่หลังพจนานุกรมชื่อ
ข่าวดีคือบน Windows คุณไม่ต้องแยกวิเคราะห์ไบต์เหล่านั้นเอง structured storage เปิดสตรีมเหล่านั้นออกมาผ่าน IPropertySetStorage และโค้ดต่อไปนี้คอมไพล์ผ่านตามที่เห็นด้วยยูนิต RTL มาตรฐาน
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: ไม่มีค่านี้
try
case Value.vt of
VT_LPSTR: Result := string(AnsiString(Value.pszVal));
VT_LPWSTR: Result := Value.pwszVal;
end;
finally
PropVariantClear(Value);
end;
end;
// วิธีใช้: Writeln('Author: ', ReadXlsSummaryString('ledger.xls', PIDSI_AUTHOR));
ขอพูดตรง ๆ ถึงสิ่งที่ตัวอย่างนี้ซ่อนไว้ สตริงอาจมาถึงในรูป VT_LPWSTR หรือ VT_LPSTR และในกรณี ANSI ไบต์เหล่านั้นถูกเข้ารหัสด้วยหน้ารหัสของชุดคุณสมบัตินั้นเอง ซึ่งเก็บไว้เป็นคุณสมบัติหมายเลข 1 ของ section การแปลงชนิดข้างต้นจึงตรงเป๊ะเฉพาะเมื่อหน้ารหัสนั้นตรงกับของระบบ ส่วนค่าเวลาจะกลับมาเป็น VT_FILETIME ในเขตเวลา UTC คุณสมบัติกำหนดเองหมายถึงการเปิด section ที่ผู้ใช้กำหนด FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE} แล้วเดินไล่พจนานุกรมชื่อของมัน IPropertyStorage รับภาระทั้งหมดนี้ไว้แทนบน Windows ส่วนการเขียนตัวแยกวิเคราะห์ MS-OLEPS ของคุณเองสำหรับสภาพแวดล้อมที่ไม่มี structured storage นั้นคือโครงการจริงจัง ไม่ใช่งานบ่ายเดียว
เส้นทางที่ 3: .xlsx เก็บ docProps เป็น XML อยู่ในไฟล์ zip
นี่คือเส้นทางที่ไปป์ไลน์ส่วนใหญ่ต้องการจริง ๆ เพราะไฟล์ใหม่เป็น .xlsx มาเกือบสองทศวรรษแล้ว workbook แบบ OOXML คือแพ็กเกจ zip และคุณสมบัติของมันถูกแยกไปตามชิ้นส่วนเล็ก ๆ ตามหน้าที่: docProps/core.xml เก็บฟิลด์ Dublin Core ได้แก่ dc:title, dc:creator, cp:lastModifiedBy พร้อมกับ dcterms:created และ dcterms:modified ที่เป็นค่าเวลาแบบ W3CDTF ในเขตเวลา UTC ขณะที่ docProps/app.xml เก็บฟิลด์ระดับแอปพลิเคชันอย่าง Company และ AppVersion ส่วน docProps/custom.xml เก็บคุณสมบัติกำหนดเอง และเพราะสารบัญกลางของ zip ชี้ตำแหน่งของแต่ละชิ้นส่วนได้โดยตรง การอ่านจึงกินเพียงไม่กี่กิโลไบต์ไม่ว่า workbook จะใหญ่แค่ไหน TZipFile และ IXMLDocument ซึ่งมาพร้อม RTL ทั้งคู่ ทำงานนี้ได้ครบทั้งหมด
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;
รายละเอียดสองข้อทำให้โค้ดนี้ทนทานในระบบจริง ข้อแรก ชิ้นส่วนเหล่านี้เป็นทางเลือก แพ็กเกจขั้นต่ำที่ไม่มี docProps เลยก็ยังถูกต้องสมบูรณ์ตาม ECMA-376 นั่นคือเหตุผลที่โค้ดตรวจด้วย IndexOf แทนการเดา ข้อสอง จับคู่อิลิเมนต์ด้วยชื่อท้องถิ่นและ URI ของ namespace อย่างที่ FindNode ทำข้างต้น อย่าจับคู่ด้วยคำนำหน้าตัวอักษร เพราะ dc: และ cp: เป็นเพียงธรรมเนียมของตัวเขียนไฟล์ฝั่ง Excel และไฟล์ที่ผลิตโดยโปรแกรมอื่นมีสิทธิ์เลือกคำนำหน้าต่างออกไป มีข้อสังเกตด้านสภาพแวดล้อมอีกข้อ: ผู้ให้บริการเริ่มต้นของ IXMLDocument คือ MSXML แอปพลิเคชันคอนโซลหรือเธรดทำงานจึงต้องเรียก CoInitialize ก่อน LoadXMLData ไม่อย่างนั้นการแยกวิเคราะห์ครั้งแรกจะตายพร้อมข้อผิดพลาดจากฝั่ง COM
ใบแจกแจงต้นทุน และจุดที่ไลบรารีชนะตัวแยกวิเคราะห์ทั้งสอง
เมื่อวัดบนเครื่องนักพัฒนาทั่วไป เส้นทาง COM ตกอยู่ราวสองถึงสี่วินาทีต่อไฟล์เมื่อสร้างเซสชัน automation ใหม่ต่อหนึ่งไฟล์ โดยเวลาเกือบทั้งหมดหมดไปกับการเปิด EXCEL.EXE บวกกับการแยกวิเคราะห์ workbook ทั้งไฟล์ และมันต้องการ Excel ที่ติดตั้งพร้อมสัญญาอนุญาตทุกที่ที่มันรัน ส่วนสองเส้นทางตรงอ่านเฉพาะคอนเทนเนอร์ metadata จบงานในระดับหลักหน่วยมิลลิวินาทีต่อไฟล์ และไม่ต้องติดตั้งอะไรเพิ่มนอกจากสิ่งที่ไฟล์ปฏิบัติการ Delphi ลิงก์ไว้แล้ว เมื่อเทียบกันบนแชร์ที่มีหนึ่งหมื่นไฟล์ นั่นคือความต่างระหว่างเวลาเกือบทั้งวันทำงานกับเวลาไม่ถึงหนึ่งนาที โดยไม่มีคำถามเรื่องการติดตั้ง Office พ่วงมาด้วย
จุดติดของสองเส้นทางตรงคือมันมีสองเส้นทาง ไปป์ไลน์ที่รับทั้งสองรูปแบบต้องดูแลตัวแยกวิเคราะห์สองตัวที่มีโหมดความล้มเหลวคนละชุด ฝั่งหนึ่งคือหน้ารหัสและชนิดของ PROPVARIANT อีกฝั่งคือ namespace และชิ้นส่วนที่เป็นทางเลือก และไม่มีตัวไหนอ่านรูปแบบของอีกตัวได้ ภาระการดูแลนี้คือเหตุผลที่ทำให้ไลบรารีเนทีฟน่าใช้: HotXLS ซึ่งเป็นไลบรารีสเปรดชีตภาษา Object Pascal ของ losLab สำหรับ Delphi และ C++Builder บน Windows เปิดฟิลด์ชุดเดียวกันออกมาเป็นคุณสมบัติของ workbook ตรง ๆ ทั้ง Title, Author, Company, Created และที่เหลือ โดยเติมค่าให้ตอน Open ทั้งกับ .xls และ .xlsx เหมือนกัน ไม่ต้องติดตั้ง Excel และไม่ต้องยุ่งกับท่อคอนเทนเนอร์ทั้งหมดข้างต้น มันอ่านคุณสมบัติเป็นส่วนหนึ่งของการเปิด workbook เต็มรูปแบบ ไม่ใช่การตรวจเฉพาะ metadata จึงเหมาะกับไปป์ไลน์ที่จะไปแตะข้อมูลเซลล์ต่ออยู่แล้ว ส่วนพื้นผิวคุณสมบัติทั้งหมดบนทั้งสองหน้ารูปแบบ รวมถึงฝั่งการเขียน มีอธิบายไว้ในบทความของเราเรื่องการตั้งค่าคุณสมบัติเอกสาร Excel ด้วย HotXLS
หมายเหตุ: เครื่องมือแยกวิเคราะห์ Excel และดึง metadata แบบเต็มรูปแบบมีให้ใน HotXLS Delphi VCL Component