บทความเทคนิค

อ่านคุณสมบัติเอกสาร Excel ใน Delphi: สามเส้นทาง

ลองสั่งให้ไปป์ไลน์จัดเส้นทางสเปรดชีตหนึ่งหมื่นไฟล์ตามผู้เขียน บริษัท หรือวันที่แก้ไขล่าสุด สิ่งที่แย่ที่สุดที่มันทำได้คือการเปิด 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 เปิดได้ด้วยตัวเอง โค้ดที่ใช้งานได้จริงของแต่ละเส้นทางอยู่ถัดจากนี้ พร้อมต้นทุนที่ระบุไว้อย่างตรงไปตรงมา

แผนภูมิสามเส้นทางของ Delphi สู่ Document Summary Information ของ Excel ได้แก่ COM automation ที่สั่งงานตัว Excel เอง สตรีม property-set ของ OLE สำหรับไฟล์ xls และการแยกวิเคราะห์ XML ใน docProps สำหรับแพ็กเกจ xlsx
COM automation ซื้อความครอบคลุมทั้งหมดมาด้วยราคาของ Excel เดสก์ท็อปที่มีสัญญาอนุญาตและเวลาหลายวินาทีต่อไฟล์ ขณะที่สองเส้นทางที่อ่านรูปแบบไฟล์โดยตรงแตะเฉพาะคอนเทนเนอร์ metadata ในระดับมิลลิวินาที สิ่งที่แต่ละเส้นทางคืนกลับมาเกือบเหมือนกัน — สิ่งที่มันเรียกร้องจากเครื่องที่โฮสต์ต่างหากที่ไม่เหมือน

เส้นทางที่ 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 ที่สองซึ่งอยู่หลังพจนานุกรมชื่อ

กายวิภาคของไฟล์ประกอบ xls แบบ BIFF8 ใน Delphi ที่วางสตรีม Workbook ไว้ข้างชุดคุณสมบัติ SummaryInformation และ DocumentSummaryInformation พร้อมสายการเข้าถึงจาก StgOpenStorageEx สู่ IPropertySetStorage
ไฟล์ xls เก็บข้อมูลเซลล์และคุณสมบัติเอกสารเป็นสตรีมพี่น้องกันในไฟล์ประกอบ OLE ซึ่ง Windows จะแยกวิเคราะห์ชุดคุณสมบัติแบบไบนารีให้คุณเอง โค้ด Delphi จึงไม่ต้องแตะทั้งผัง MS-OLEPS และหน้ารหัสด้วยมือ

ข่าวดีคือบน 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 ทั้งคู่ ทำงานนี้ได้ครบทั้งหมด

Delphi: ผังของแพ็กเกจ zip แบบ xlsx ที่แสดงสมาชิก XML ของ docProps ทั้ง core, app และ custom เคียงข้างชิ้นส่วนเวิร์กชีต พร้อมกฎการทำงานจริงสำหรับตรวจหาชิ้นส่วนที่เป็นทางเลือกและการจับคู่ namespace
ข้อมูลเวิร์กชีตกินพื้นที่ส่วนใหญ่ของแพ็กเกจ xlsx แต่ metadata กลับอยู่ในสมาชิกที่เป็นทางเลือกขนาดเล็กสามชิ้นข้าง ๆ กัน การเข้าถึงแบบสุ่มผ่านสารบัญกลางของ zip ทำให้ต้นทุนการอ่านแปรตามคุณสมบัติ ไม่ใช่ตามขนาด 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;

รายละเอียดสองข้อทำให้โค้ดนี้ทนทานในระบบจริง ข้อแรก ชิ้นส่วนเหล่านี้เป็นทางเลือก แพ็กเกจขั้นต่ำที่ไม่มี 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