结构化存储 是一项Windows技术,它通过COM接口(主要是 IStorage 和 IStream )对文件和目录的概念进行抽象。它的核心目的是在单个物理文件内部构建出一套文件系统层级结构。
结构化存储已有多年历史,最广为人知的应用场景是微软Office的旧版文件(.doc、.ppt、.xls等)——在Office切换到扩展格式(.docx、*.pptx等)之前就已广泛使用。当然,这些旧格式至今仍获得完善支持。
结构化存储的接口中,IStorage代表目录,IStream代表文件,它们本身只是抽象接口。要实际使用这些接口,必须有对应的具体实现。Windows提供了一套结构化存储的实现,名为 复合文件 。这两个术语有时会被混用,但二者的区别十分关键:复合文件只是结构化存储的其中一种实现,理论上还可以存在其他实现。 复合文件并未覆盖结构化存储接口定义的全部功能,但它实现了绝大多数核心能力,完全足以支撑实际使用。
你可以下载一款老旧但至今仍能稳定运行的工具SSView,它可以以图形化方式查看通过复合文件实现创建的物理文件的内部内容。下图是SSView查看某DOC文件时的界面截图:
这里有一个更有意思的示例——通过Sysinternals Autoruns 工具持久化存储的信息(后续会对该工具展开讨论):
一个更有意思的层级结构清晰可见——尽管这一切都存放在同一个物理文件之中!
核心接口
IStorage接口代表一个“目录”,它可以包含其他目录,以及由IStream接口实现所代表的“文件”。
你可以通过 StgCreateStorageEx 创建一个物理复合文件,或是通过 StgOpenStorageEx 打开一个已有的复合文件,二者调用成功后都会返回一个IStorage指针。之后就可以调用IStorage的相关方法,创建或打开其他子目录(存储对象)和/或文件(流对象)。
IStorage中最常用的方法是CreateStorage、CreateStream、OpenStorage和OpenStream,还可以通过EnumElements方法枚举当前存储对象下的所有子存储和流对象。下面是一个以只读模式打开复合文件的示例(文件名来自命令行参数):
CComPtr<IStorage> spStg;
auto hr = ::StgOpenStorageEx(argv[1], STGM_READ | STGM_SHARE_EXCLUSIVE,
STGFMT_STORAGE, 0, nullptr, nullptr, __uuidof(IStorage), reinterpret_cast<void**>(&spStg));
if (FAILED(hr)) {
printf("Failed to open file (0x%X)\n", hr);
return hr;
} 复制代码 以下示例演示了如何递归地枚举给定存储对象的层级结构:
void EnumItems(IStorage* stg, int indent = 0) {
CComPtr<IEnumSTATSTG> spEnum;
stg->EnumElements(0, nullptr, 0, &spEnum);
if (spEnum == nullptr)
return;
STATSTG stat;
while (S_OK == spEnum->Next(1, &stat, nullptr)) {
if (indent)
printf(std::string(indent, ' ').c_str());
printf("%ws", stat.pwcsName);
if (stat.type == STGTY_STORAGE) {
printf(" [DIR]\n");
CComPtr<IStorage> spSubStg;
stg->OpenStorage(stat.pwcsName, nullptr,
STGM_READ | STGM_SHARE_EXCLUSIVE, 0, 0, &spSubStg);
if (spSubStg)
EnumItems(spSubStg, indent + 1);
}
else
printf(" (%u bytes)\n", stat.cbSize.LowPart);
::CoTaskMemFree(stat.pwcsName);
}
} 复制代码 每个条目都有一个名称,但流(即“文件”)可以包含实际数据。STATSTG结构体中的cbSize成员会返回该数据的大小。流本质上只是一组字节的抽象。要真正对流进行读写操作,必须先调用IStorage::OpenStream将其打开,随后才能使用IStream::Read、IStream::Write等方法访问其中的数据。
深入探讨流(Streams)
IStream 接口在 Windows API 中有着广泛的应用,并不仅仅局限于结构化存储(Structured Storage)。它代表了对缓冲区的一种抽象,理论上该缓冲区可以位于任何地方——这正是抽象机制的优势所在。只要拥有一个 IStream 指针,你就可以执行读取、写入、定位(seek)、复制到另一个流、克隆流,甚至在实现支持的情况下提交或回滚事务等操作。顺便提一下,复合文件(Compound Files)的实现并不支持流级别的事务处理。
除了结构化存储之外,还可以通过以下几种方式获取流对象:
基于内存的流
CreateStreamOnHGlobal API 可以在一个可选的 HGLOBAL 内存句柄之上创建内存缓冲区(如果传入 NULL,则会自动分配一个新的句柄),并返回指向该内存缓冲区的 IStream 指针。
应用场景:这在处理剪贴板时非常有用,因为剪贴板操作通常要求使用 HGLOBAL,直接操作 HGLOBAL 可能不太方便。通过获取 IStream 指针,代码可以更便捷地处理数据(例如从其他流读取数据,或手动填充数据),然后在将数据传递给剪贴板(如调用 SetClipboardData)之前,调用 GetHGlobalFromStream 获取底层的 HGLOBAL 句柄。
基于文件的流
可以直接调用 SHCreateStreamOnFile 来获取与文件关联的流。这提供了一种访问文件数据的便捷方式,将文件内容抽象为 IStream 接口,从而简化了文件读写操作。
ActiveX 控件持久化
IStream 还出现在 ActiveX 控件的持久化(persistence)机制中,用于保存和加载控件的状态数据。
COM 对象状态封送
IStream的另一类典型用法,是为COM对象的状态做“封装打包”:通过调用 CoMarshalInterThreadInterfaceInStream (堪称名称最长的COM API),将对象状态捕获为IStream格式,以此实现跨线程单元创建对象代理;后续在目标线程单元中,可调用对应的 CoGetInterfaceAndReleaseStream ,按需生成原对象的代理。
案例研究:Autoruns
2021年,我在 Sysinternals 团队工作期间,任务之一是从图形用户界面(GUI)的角度对 Autoruns 进行现代化改造。我想借此机会进行一次重大的重写,以便更容易维护该工具并根据需要进行改进。幸运的是,Mark Russinovich 支持这一想法,尽管我对该项目的时间预估偏差很大 🙂 不过这是题外话了。
Autoruns 的功能之一是能够保存工具提供的信息,以便稍后加载,甚至可能在另一台机器上加载。这并非易事,因为某些信息(例如图标)并不容易持久化存储。我不记得旧版 Autoruns 是否持久化了这些图标,但我确实希望实现这一功能。
旧版 Autoruns 的文件格式本质上是顺序的,以线性方式在文件中存储数据结构。任何需要添加的新属性都会导致偏移量变化,从而迫使更改格式“版本”,并在读取各种“旧”格式文件时做出正确的处理决策。
我希望使持久化更加灵活,因此决定将格式完全改为复合文件(compound file)。在这种方案下,添加新属性不会引起任何问题——可以添加新的流(stream),而其他流不受影响。代码只需忽略它不认识的属性(可以是存储区 storages 或流 streams)。这使得该格式天生具有可扩展性,不受任何偏移量变化的影响,并且使用 SSView 等工具查看非常方便。上面的截图展示了一个由 Autoruns 持久化保存的文件。
顺便提一下,持久化图标变得相当容易,因为 Autoruns 用于持有图标集合的 ImageList 对象可以通过单次函数调用 ImageList_Write 持久化到流中,非常便捷!
结论
结构化存储的设计思路十分强大,Windows 提供的复合文件实现方案兼具出色的实用性与灵活性。微软当初推动 Office 切换至新格式,核心原因之一是需要缩小文件体积,因此新的扩展格式采用了 ZIP 压缩机制。 其内部结构也随之调整,绝大多数场景下不再沿用复合文件格式。实际上,结构化存储文件完全可以在实现压缩、节省磁盘空间的同时,依然保留通过存储区和流进行便捷访问的特性。
原文链接