1万個のスプレッドシートを作成者、会社、最終更新日で振り分けるようパイプラインに求めたとき、最悪の手はブックを1つずつ完全に開くことです。答えはファイルの文書プロパティ、Officeの世界でDocument Summary Informationと呼ばれるものの中にあります。Windows Searchがインデックス化し、SharePointが整理に使い、Excelがプロパティダイアログに表示するメタデータ層です。この層はせいぜい数キロバイトで、しかもExcelの両方の形式で十分に文書化された場所に置かれています。要は、必要のない100万個のセルの代金を払わずに、Delphiからそこへ到達することです
現実的な経路は3つあり、違いは返す内容よりも、実行するマシンに要求するものの方に表れます。COMオートメーションはExcel本体を動かしてすべてを読みますが、値段はデスクトップ並みです。.xls形式はプロパティをOLEプロパティセットストリームに保持しており、Windowsが代わりに解析してくれます。.xlsx形式はzipの中の2つの小さなXMLパートに保持しており、Delphiのランタイムライブラリだけで開けます。以下、それぞれの動くコードと、その代償を包み隠さず示します
経路1:COMオートメーションはすべてを読むが値段はデスクトップ並み
オートメーションは、1つのオブジェクトモデルで完全な網羅性を得られる唯一の経路です。標準のサマリー一式、CompanyとManagerを含む拡張一式、ユーザー定義のカスタムプロパティのすべてに、BuiltinDocumentPropertiesとCustomDocumentPropertiesから手が届きます。値はすべてOleVariantとして届き、このAPIには痛い目に遭う前に知っておく価値のある癖が1つあります。一度も代入されたことのない組み込みプロパティは空で返るのではなく、Valueに触れた瞬間にEOleExceptionを発生させます。下のヘルパーはそれを失敗ではなく「未設定」として扱います
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が無人のサーバーサイドオートメーション向けに設計もライセンスもされていないと明言しています。CreateOleObjectは完全なEXCEL.EXEを起動し、Workbooks.Openはブック全体を解析するので、最初のプロパティが返るまでにファイルあたりおよそ2秒から4秒を見込んでください。そしてQuitを囲むtry..finallyは飾りではありません。CreateOleObjectとQuitの間で例外が抜け出すと、孤児になったEXCEL.EXEがファイルのロックを握ったまま残り、次の実行がそれにぶつかって失敗するまで目に見えません。1つのExcelインスタンスをバッチ全体で使い回せば起動コストは薄まりますが、リスクは集中します。隠れたデスクトップ上に迷子のダイアログが1つ出れば、その後ろに並んだすべてのファイルが止まるからです
経路2:.xlsはプロパティをOLEプロパティセットストリームに置く
BIFF8のブックはOLE複合ファイル、つまりストレージとストリームからなる小さなファイルシステムです。セルのデータはWorkbookストリームに入り、メタデータはその隣、名前が制御文字#5で始まる2つのプロパティセットストリームに入ります。古典的なフィールド用の\005SummaryInformationと、拡張フィールドおよびカスタムフィールド用の\005DocumentSummaryInformationです。それぞれの中にはMS-OLEPSの配置に従うバイナリのプロパティセットが座っており、セクションは書式識別子(FMTID)で、プロパティは整数のプロパティIDで引かれます。サマリーのセクションはFMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}で、PIDSI_TITLEが$02、PIDSI_AUTHORが$04です。Company($0F)とManager($0E)は文書サマリーのセクションにあり、カスタムプロパティは名前辞書の後ろに続く2つ目のセクションにあります
良い知らせは、Windowsではそのバイト列を自分で解析する必要が一切ないことです。構造化ストレージがIPropertySetStorageを通じてストリームを公開しており、次のコードは標準のランタイムユニットのままこの形でコンパイルできます
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として格納されているので、上のキャストが厳密に正しいのはそれがシステムのものと一致するときだけです。タイムスタンプはUTCのVT_FILETIMEで返ります。カスタムプロパティを読むということは、FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}のユーザー定義セクションを開き、その名前辞書をたどるということです。WindowsではIPropertyStorageがそのすべてを吸収してくれます。構造化ストレージのない環境向けにMS-OLEPSのパーサーを自作するのは、午後のひとときではなく本物のプロジェクトです
経路3:.xlsxはdocPropsをzipの中のXMLとして持つ
実際のところ、たいていのパイプラインが必要としているのはこの経路です。新しく作られるファイルは20年近く前から.xlsxだからです。OOXMLのブックはzipパッケージで、そのプロパティは用途ごとに小さなパートへ分かれています。docProps/core.xmlにはDublin Coreのフィールド、つまりdc:title、dc:creator、cp:lastModifiedByに加え、UTCのW3CDTFタイムスタンプであるdcterms:createdとdcterms:modifiedが入ります。docProps/app.xmlにはCompanyやAppVersionといったアプリケーションレベルのフィールドが入り、docProps/custom.xmlにはカスタムプロパティが入ります。zipのセントラルディレクトリが各パートの位置を直接指しているので、ブックがどれほど大きくても読み取りにかかるのは数キロバイトだけです。TZipFileとIXMLDocumentはどちらも同梱のランタイムライブラリにあり、この仕事を丸ごとこなします
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;
本番でこれを頑丈に保つ細部が2つあります。1つめ、これらのパートは任意です。docPropsを一切持たない最小限のパッケージもECMA-376のもとでは完全に妥当であり、だからこそコードは決めつけずにIndexOfで探りを入れています。2つめ、要素は上のFindNodeのようにローカル名と名前空間URIで照合し、決してリテラルの接頭辞では照合しないことです。dc:やcp:はExcelの書き出し側の慣習にすぎず、他の生成器が作ったファイルは別の接頭辞を自由に選べます。環境面の注意を1つ。IXMLDocumentの既定のベンダーはMSXMLなので、コンソールアプリケーションやワーカースレッドではLoadXMLDataの前にCoInitializeを呼ばなければ、最初の解析がCOMのエラーで落ちます
コスト表、そしてライブラリが両方のパーサーに勝つとき
ごく普通の開発マシンで計測すると、オートメーションのセッションをファイルごとに作る場合のCOM経路はファイルあたりおよそ2秒から4秒に着地し、そのほとんどはEXCEL.EXEの起動とブック全体の解析です。しかも実行するあらゆる場所にインストール済みでライセンスされたExcelを必要とします。直接読む2つの経路はメタデータの容器だけを読み、ファイルあたり1桁ミリ秒で終わり、Delphiの実行ファイルが既にリンクしているもの以外には何のインストールも要りません。1万個のファイルを抱える共有フォルダー全体では、これは丸1日近くと1分未満との差であり、しかもOfficeの配備という問題が付いてきません
直接読む経路の難点は、それが2つあることです。両方の形式を受け付けるパイプラインは、互いに交わらない失敗の仕方を持つ2つのパーサーを保守することになります。一方にはコードページとPROPVARIANTの型、もう一方には名前空間と任意のパートがあり、どちらも相手の形式を読めません。この保守の負荷こそがネイティブライブラリを選ぶ理由になります。losLabがWindows上のDelphiとC++Builder向けに提供するObject Pascalのスプレッドシートライブラリ、HotXLSは、Title、Author、Company、Createdをはじめとする同じフィールドを素直なブックのプロパティとして公開し、.xlsでも.xlsxでも同じOpenがそれらを埋めます。Excelのインストールも、上に出てきた容器まわりの配管も要りません。メタデータだけの探りではなくブックを完全に開く一環としてプロパティを読むので、どのみちセルのデータにも触れるパイプラインによく合います。両方のファサードにおけるプロパティの全体像は、書き込み側も含めてHotXLSでExcelの文書プロパティを設定する記事で扱っています
注:Excelの完全な解析とメタデータ抽出のツールはHotXLS Delphi VCLコンポーネントで利用できます