COM 互操作难行
一个 COM 新手在 DLL 冲突中的旅程。
引言
我为一个客户进行的一个项目出现了意想不到的转折,这让我这位主要经验是编写 JAVA 代码的软件工程师,面临着一项挑战:开发一个主要关注 C++、C# 和 COM 互操作的应用程序。我虽然有一些 C++ 和 C# 的经验,但 COM 互操作对我来说是 uncharted terrain,当时我还天真地不知道“DLL hell”这样的术语以及具有类似含义的定义。浏览互联网上各种 COM 博客并没有让我为几天后将要面临的问题做好准备,是的,确实有一些问题需要考虑,但嘿……只需使用这段出色的代码,您的 Office 自动化就会像魔术一样工作!故事中提供的示例代码看起来足够简单,因此利用 Office 自动化强大功能的承诺似乎很有吸引力。
现在我已经三个月过去了,也变得更聪明了,不,我并不后悔选择投入一个需要连接非托管代码和托管代码的项目,该项目不仅包含与 Microsoft Office 应用程序的 COM 调用,还包含与专有软件的 COM 调用。然而,我希望在开始之前就能知道我现在知道的所有信息,并且考虑到我不得不通过许多论坛和新闻组才能找到许多模糊的错误和异常的答案,我决定写这篇文章,希望能帮助其他人绕过我欣然跳进去的所有陷阱。我以艰辛的方式完成了 COM 互操作,希望这篇文章能让其他新手避免重蹈我的覆辙。
问题
我受雇的项目旨在为现有的专有应用程序提供 Microsoft Word 中的简单报告功能。该应用程序是一个数学建模工具(有点像 Mat lab),但我的客户缺少将图形、表格和位图导出到 Word 处理器的功能。该应用程序支持一种插件结构,该结构包含一个带有若干 DLL 的子目录,这些 DLL 符合特定结构。因此,我的报告工具将被实现为一个附加插件,它将向应用程序添加一个菜单项,点击该菜单项将打开一个表单,允许用户准备报告并将所需的图形和表格导出到 Microsoft Word。作为一项额外优势,该应用程序提供了一个类型库,允许其作为 COM 服务器工作。这个服务器相当庞大,因此可以让我对应用程序中包含的信息进行精细控制。当然,COM 互操作也是 Office 自动化方面的首选,因此全局架构很快就确定了。
图 1:报告工具的全局架构
这个简单计划的第一个挫折是当我无法在托管 C++ 中运行插件时。该插件需要一些库,导致各种陌生的编译器和链接器错误,因此很明显这必须用非托管(即老式 C++)代码来完成。由于我不想放弃 .NET 中提供的精美功能,我决定实现一个非托管插件和实际报告功能之间的接口,而报告功能将用(托管).NET 编写,使用 C#。接口将尽可能简单,并将由命令结构(字符串)组成,该结构将向报告工具发出请求。报告工具本身将实现为一个 COM 对象,该对象将协调对 Microsoft Office 和应用程序 COM 服务器的调用。因此,该应用程序将由一个非托管 DLL(插件)和一个提供实际报告功能的托管 DLL(程序集)组成。以下各节将描述为了使此工作正常运行而处理的各种问题,包括连接各个部分的所有模糊错误和异常。这包括在客户的目标计算机上的部署问题,该计算机运行的是不同的 Windows 版本,并使用了不同版本的 Microsoft Office。
1.1. 头痛 1:非托管代码和托管代码
我项目的第一个步骤是实现一个非常简单的接口,它将菜单事件传递到托管环境,以打开一个漂亮的表单。通过购买一本有关 COM 互操作的优秀书籍,我成功绕过了第一个潜在的陷阱,该书包含有关结合使用托管和非托管代码的良好讨论。Andrew Troelsen 的《COM and .NET Interoperability》一书通常受到高度推荐,并且确实被证明是一个有价值的辅助工具,尤其是因为互联网上的论坛文章(例如在Code Project)通常针对非常具体的应用程序。这些文章的总体趋势非常乐观;照此操作,“它就能工作”。
需要掌握的第一个问题是,开发托管域和非托管域之间的接口是从托管域开始的。这很容易,因为它意味着在 .NET 中定义一个接口 IMyInterface
(例如使用 C#),并添加允许方法注册到 COM 的属性。
namespace MyNamespace
{
[Guid("D4660088-308E-49fb-AB1A-72724F3F8F51")]
[ComVisible(true)]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IMyInterface
{
/// <summary>
/// Open the form
/// </summary>
void openForm();
}
[ClassInterface(ClassInterfaceType.None)]
[Guid("46A951AC-C2D9-48e0-97BE-9F13C9E70B65")]
[ComVisible(true)]
public class MyImplementingClass : IMyInterface
{
ManagedForm form;
// Need a public default constructor for COM Interop.
public MyImplementingClass ()
{
}
/// <summary>
/// open the form
/// </summary>
public void openForm()
{
if (this.form != null)
return;
try{
this.form = new ManagedForm();
this.form.Disposed += new EventHandler(form_Disposed);
this.form.Show();
}
catch(Exception ex ){
MessageBox.Show( ex.Message + "\n" + ex.StackTrace );
}
}
/// <summary>
/// Clear the form if it is no longer used
/// </summary>
void form_Disposed(object sender, EventArgs e)
{
this.form.Disposed -= new EventHandler(form_Disposed);
this.form = null;
}
}
}
代码片段 1:示例接口和实现
由于我专注于陷阱,我将不解释代码或各种COM 属性,因为互联网上有大量关于 COM 互操作的文章。最重要的属性是所谓的“guid
”,它必须是一个用于在 Windows 注册表中注册接口(及其实现)的唯一 ID。这些通常是从您基于实现的示例代码中复制粘贴的。我通常会交换四个随机数字,以防止现有 DLL 也基于我使用的相同示例代码的几率极低。以前,Microsoft 提供了一个工具(我相信它随早期版本的 Visual Studio 一起提供),名为 *guid.exe*,它会创建唯一的 GUID
,但我发现很难找到它(实际上它是 *guidgen.exe*,请参阅下面的回复……这就是为什么这么难找到!)。此外,交换策略虽然不推荐,但如果创建 COM 库不是常规活动,那么它相当安全。如果接口位于 C# 程序集中的类库类型(Visual Studio 中的项目属性 => 应用程序,解决方案应生成一个其他人可以访问的 DLL……理论上)。
第一个遇到的问题是如何让 DLL 向其他 DLL 表明其存在。一个选项(也是推荐的选项)是“升级”您的 DLL,使其本身成为一个功能齐全的 COM 对象。由于托管代码和 GUID
的组合已经完成了实现此目的的大部分必要步骤,因此所需的唯一额外步骤是将您的 DLL 注册到 Windows 的全局程序集缓存 (GAC)。是的……这是一个 Windows 文件夹,但不,我不会试图解释它在哪里,原因很简单,因为它会给人们带来错误的关于如何注册的看法。相反,依赖于 Visual Studio 提供的gacutil.exe 工具(例如 *c:\Program Files\Microsoft Visual Studio 8\SDK\bin\gacutil.exe*)。设置正确的路径后,打开命令提示符并输入
gacutil /i MyLibrary.dll /f
您的程序集将被添加到缓存中。很简单,不是吗?嗯……问题现在就开始了。
软件开发的一个口号是“松耦合”,COM 互操作是创建不同软件库之间松耦合的一个很好的例子。我们大多数经验丰富的程序员将是这一设计原则的热情倡导者,但我们经常忘记,在松耦合和完全没有耦合之间存在一个有趣的二分点!当您将 DLL 注册到 GAC 并遇到奇怪且意外的行为时,请将自己视为幸运的,因为至少您能看到一些事情发生!您注册后可能什么也看不到的可能性要大得多。此时存在一些陷阱:
- 不要信任“注册 COM 互操作”选项(项目属性 => 生成)。虽然检查此选项很好,但我见过它经常无法注册程序集。Gacutil 更可靠(前面示例中的
/f
选项会强制新 DLL 覆盖 GAC 中可能存在的具有相同guid
的任何 DLL,这在开发阶段非常有用)。但即使在那里,如果 DLL 被另一个应用程序使用,它也可能“卡”在 GAC 中。如果发生这种情况,Gacutil 不一定会通知失败,让您产生一切顺利的错误印象。 - 使用 ComVisible 属性。代码片段一展示了
ComVisible
在接口定义和实现中的使用。关于此属性存在很多混乱,但事实是,较新版本的 Visual Studio 在您项目属性文件夹的 *AssemblyInfo.cs* 文件中默认将其设置为“false
”。结果是,如果您不将ComVisible
属性添加到您的接口,您的接口将不会暴露给其他 DLL。 - 在方法中广泛使用异常。当 DLL 调用您的接口方法时发生异常,该异常不会路由到默认日志,因此添加系统跟踪或像代码片段 1 中那样显示消息框将非常有助于在托管代码中精确定位异常。
- 请记住签名您的代码(项目属性 => 签名)。这可以防止一些运行时错误,这些错误会警告您 DLL 无法使用。
提示
开发类库程序集时,最好在解决方案中添加第二个测试项目(例如 Windows 或控制台应用程序项目),该项目调用您正在开发的接口。
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
[SecurityPermission(SecurityAction.Demand,
UnmanagedCode = true)]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run( new MyTestApplication.TestInterfaceForm() );
}
在 Visual Studio 中运行此代码时,以下警告表明新生成的程序集尚未添加到 GAC(还):
构建项目后运行前面的 gacutil(从项目debug / release 目录或任何其他包含最新程序集的位置)并且警告消息将消失。忽略这一点通常(并非总是)会导致您运行的是先前版本的程序集。
Visual Studio 中也提供的 oleview.exe 工具(例如 *c:\Program Files\Microsoft Visual Studio 8\Common7\tools\bin\oleview.exe*)可以帮助检查 DLL 是否已成功添加到 GAC。
头痛 2:集成托管 DLL 和非托管环境
现在程序集已准备就绪,但非托管代码仍需与其连接。为了实现这一点,非托管代码需要知道已创建接口的结构。这可以通过创建与实现的接口对应的所谓“类型库”来完成。Visual Studio 附带的 tlbexp.exe 工具(例如 *C:\Program Files\Microsoft Visual Studio 8\SDK\bin\tlbexp.exe*)或 .NET 框架提供的 regasm.exe(*c:\windows\Microsoft.NET\framework\v…\regasm.exe*)可以从现有 DLL 创建类型库。
regasm MyLibrary.dll /tlb: MyTypeLibrary.tlb
这将在当前活动文件夹中创建一个名为 *MyTypeLibrary.tlb* 的类型库。如果旧版本的 DLL 已注册到 Windows,这通常会引起问题,因为届时将使用旧类型库而不是新类型库。可以通过 oleview 的“view typelib”选项来检查这一点,该选项由带有三个红箭头的按钮表示。由于类型库显示了实现的接口结构,因此该结构将在 oleview 中显示。如果存在不匹配,则 *regasm.exe* 或 *tlbexp.exe* 可能没有使用新构建的 DLL。*Regasm* 和 *tlbexp* 不会提供任何有用的警告或错误消息来指示注册失败。防止类型库问题的最佳方法是关闭所有可能连接到您的库的应用程序,然后执行注销操作,再注册新 DLL。
regasm MyLibrary.dll /u
显然,这只需要在更改接口结构时进行,但既然我们每次构建程序集后都要进行大量输入,我们不妨创建一个批处理文件(例如 *register.bat*),并在每次构建程序集时调用它。
regasm MyLibrary.dll /u
regasm MyLibrary.dll /tlb: MyTypeLibrary.tlb
gacutil /i MyLibrary.dll /f
代码片段 2:register.bat
这种方法是确保类型库始终与我们构建的每个更新的 DLL 对应的最佳方式。现在可以将类型库导入 Visual Studio C++ 项目。
#ifndef MY_INTERFACE_H
#define MY_INTERFACE_H
class MyInterface
{
public:
long OpenForm();
};
#endif
代码片段 3:mylib.h
#import ".\VC8\managed\MyLibrary.tlb" raw_interfaces_only named_guids
#include "resource.h"
#include "mylib.h"
MyNamespace::IMyInterfacePtr pDotNetCOMPtr;
//Optional method to check if the DLL is loaded or unloaded
bool APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call,
LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
MessageBox
(NULL, "Dll is loading!", "DllMain() says...", MB_OK);
break;
case DLL_PROCESS_DETACH:
MessageBox
(NULL, "Dll is UNloading!", "DllMain() says...", MB_OK);
break;
}
return true;
}
long MyInterface::OpenForm( void )
{
CoInitialize(NULL); //Initialize all COM Components
HRESULT hRes = pDotNetCOMPtr.CreateInstance
(MyNamespace::CLSID_MyInterface );
if (hRes == S_OK)
hRes = pDotNetCOMPtr->openForm();
pDotNetCOMPtr = NULL;
CoUninitialize (); //UnInitialize all COM Components
return hRes;
}
代码片段 4:mylib.cpp
当此项目在 Visual Studio C++ 中构建时,类型库会被转换为 * \*.tlh* 文件,该文件表示接口。在上面的示例中,接口被封装在一个与接口非常相似的类中。C++ 项目需要重建,每次接口结构发生变化(实际上,当新的类型库被复制到 C++ 项目时)。构建此项目会创建一个(非托管)DLL,该 DLL 被用作专有应用程序的插件。
有了所有工具和工作结构到位,托管和非托管环境之间的交叉开发相当稳定和良好。有一两次,有必要完全从 Windows 注册表中删除我库的所有引用(使用 Windows 提供的regedit.exe),但只要我严格遵守关闭所有可能连接到我的 DLL 的应用程序(在开发 Office 自动化应用程序时,这显然包括 Office 应用程序)的例程,并在构建库后始终使用批处理文件,一切都还算顺利。可惜我花了三周时间才达到这一点。
1.1. 头痛 3:COM 互操作
到目前为止,我已经设法通过点击专有应用程序中的菜单项打开了一个 .NET 表单。下一步是开发报告功能,该功能包括对 Microsoft Office 应用程序和专有应用程序的 COM 接口的 COM 调用。老实说,开发这个相当直接。互联网上有很多关于自动化 Microsoft Word 的好例子,而且专有应用程序的 COM 接口也运行良好。测试功能是一个相当缓慢的过程,但幸运的是,对 Microsoft Office 的大多数调用都可以从测试项目中进行测试,因此在测试期间不需要我不断地打开和关闭应用程序。因此,此阶段的头痛问题都是小问题。
Microsoft 对 Office 自动化的首选策略是打开一个新的文档、工作表等,然后在其中进行报告。因此,连接到已打开的文档或工作表的替代方法在互联网上很难找到。System.Runtime.InteropServices.Marshal.GetActiveObject 方法可用于实现此目的,例如在查找打开的 Office 文档或工作表、找到后连接,或者在未找到时打开新应用程序对象的“connect”方法中。或者,“Disconnect
”方法使用 Marshal.ReleaseComObject
来释放对象。与 COM 互操作一致地使用“connect
”和“disconnect
”方法极大地改善了开发周期,因为当新 DLL 注册到 GAC 时,其他应用程序连接到程序集的可能性大大减小。我还决定将包含这些方法的类实现为单例(每个 COM 库一个),这也大大降低了程序集在 GAC 中被阻止的可能性。
1.2. 分裂的头痛:部署
部署应用程序时,'DLL Hell' 这个术语会淋漓尽致地体现出来。粗略地说,我一直在 Windows XP 操作系统上开发我的应用程序,使用的是 Visual Studio 2005 和 Microsoft Office 2003。客户使用的是 Windows 2000 和 Microsoft Office 2000。我们都使用了相同的专有应用程序。我相信许多经历过这种痛苦的读者已经开始嘲笑这种前景……
安装报告工具包括安装 .NET,将插件添加到专有应用程序的插件目录,最后将报告工具添加到 GAC。这看起来很简单,也应该很简单,但唉,事实并非如此。
本节将重点介绍我遇到的各种模糊错误消息的部署。我在各种 Internet 论坛上看到许多软件开发人员在处理类似的错误消息,大多数人会得到关于这些错误含义的回复,而不会提及这些错误的根本原因。也就是说,如果他们能得到回复的话!
模糊异常:“Mscorlib80.dll 未找到”
我遇到的第一个模糊异常是指一个神秘的 *mscorlib80.dll*。此错误的原因是 Visual Studio C++ 2005 包含对 Visual Studio C++ 2005 中包含的多个库的引用。如果客户端计算机上没有这些库(通常是这种情况),它就会开始请求这些 DLL。*Mscorlib80.dll* 是它最可能请求的库(如果应用程序是在调试模式下构建的,则会请求 *mscorlib80d.dll*)。
虽然互联网上提出了许多补救措施,但最实用的方法是从 Microsoft 下载所需的库(对于 PC,这是 vcreditst_x86.exe)。此可执行文件会将所需的库文件复制到其指定位置,但……在 Windows 2000 等 Windows XP 之前的操作系统上,它不会立即起作用。
在过去,DLL 被添加到 Windows 根目录下的 *Sytem32* 文件夹中。在 XP 中,这一策略已改变。取而代之的是,引入了一个 *WinSxS* 文件夹,它是包含多个应用程序特定子目录的树状结构的根目录。*Vcredist.exe* 符合此新约定,这导致在 Windows 2000 操作系统上安装完成后什么都不会发生。显然,Windows 2000 不识别 *winsxs*,因此找不到库。最好的规避方法是添加指向 *winsxs* 中新添加文件夹的路径(推荐)或将它们复制到 *system32* 文件夹。可以在此处找到关于这些问题的详细描述。
模糊的 System.AccessViolationException
“尝试读取或写入受保护的内存。这通常表明其他内存已损坏。”
当进行 COM 调用到 Microsoft Office 时发生此错误。经过广泛的 Internet 研究,发现此错误与使用的 Microsoft Office 版本有关。与直觉相反,Microsoft Office DLL 不向下兼容。如果您开发了一个自动化项目并使用了比目标计算机更新的 Microsoft Office 产品的 COM 互操作(因此您在 .NET 项目中添加的引用指向这些较新的 COM 对象),那么在部署应用程序时,系统很可能会引发上述异常或类似的异常。Office 应用程序在 COM 对象方面是向上兼容的,因此较新版本的 Microsoft Office 将接受旧版本 Windows 的自动化库(这也意味着您受限于该旧版本的功能)。因此,重要的是您开发的应用程序使用的 COM 对象代表了它应该支持的最旧版本的 Office。
一个额外的复杂性是,*Microsoft.Office.Interop* DLL 自 Office 2003 起才发布。对于较旧版本的 Microsoft Office,您需要使用安装中包含的类型库自己生成 DLL。这些库的扩展名为 .olb(例如 *excel8.olb*、*msword9.olb* 等),可以使用 Visual Studio 附带的 tlbimp.exe 工具(例如 *c:\Program Files\Microsoft Visual Studio 8\SDK\bin\tlbimp.exe*)生成相应的 DLL。
Tlbimp Excel8.tlb /keyfile=MyApplicationKeyPair.snk
/out:Microsoft.Office.Interop.Excel
请注意,在这种情况下,使用 *MyApplicationKeyPair.snk* 文件中的信息创建了一个签名 DLL。这是因为此 DLL 被 .NET 使用,而 .NET 需要签名(或强类型)库。可以这样创建所需的 DLL。请记住,所有这些 DLL 都必须添加到目标计算机的 GAC 中。添加这些 DLL 到 .NET 项目后,源代码也可能需要更改,因为某些 COM 调用在较新版本中可能已更改。
模糊异常:“类型初始化程序…引发了异常”
是的,这个异常实际上是在托管环境中由您在某处编程捕获的异常引发的。您甚至可能将此异常定位到 C# 项目中的某一行代码。如果这样做,您可能会注意到应用程序正在尝试调用我们刚刚从类型库创建的那些库之一。
异常实际上是在目标计算机的 GAC 中未注册库时引发的,或者不太可能,当它们需要更新时。对于 Office DLL 来说,这种情况很少发生,但我使用的专有应用程序是一个 COM 服务器,因此每次重新构建项目时,.NET 项目都会创建一个新的 *Interop.ProprietaryApplication.dll*。我原以为在应用程序安装到目标机器上时,专有软件已将其 COM 服务器注册到 GAC,但实际上只有类型库已注册到 Windows 注册表。
当我开始使用我刚创建的 Microsoft Office 2000 DLL 并忘记注册其中一个时,这个错误变得很明显。突然,我得到了一个困扰了我好几天的异常(但当时它不在我的优先事项列表的靠前位置),它与另一个库有关,而且我立刻就知道是什么问题。
这表明有时粗心大意也会带来回报,我猜。
在这里学到的教训是,要记住将应用程序所需的所有互操作库注册到目标计算机的 GAC,并在极少数情况下需要更新它们。由于这不需要经常执行(通常每个目标机器一次),我决定创建一个名为 *install.bat* 的批处理文件,它基本上与 *register.bat* 相同,但增加了对 *Gacutil.exe* 的一些调用。
regasm MyLibrary.dll /u
regasm MyLibrary.dll /tlb: MyTypeLibrary.tlb
gacutil /i MyLibrary.dll /f
gacutil /i Interop.ProprietaryApplication.dll /f
gacutil /i Microsoft.Office.Interop.Word.dll /f
gacutil /i Microsoft.Office.Interop.Excel.dll /f
gacutil /i Microsoft.Office.Interop.PowerPoint.dll /f
就这样,我终于让一切都如愿运行……比我最初估计的晚了四周。
最后的 remarks
我通常在 JAVA 环境中工作,因此我可以想象,非常有经验的 .NET 和 COM 程序员可能会对这里给出的一些解释或我提出的解决方案感到不屑。我也可以想象,其他曾经历过 DLL Hell 艰辛旅程的程序员可能会遇到这里未描述的其他问题。我无意成为 .NET 或 COM 专家,事实上,本文反映了一个人第一次面对 COM 互操作,在该领域经验很少,发现自己在“它就能工作”的欢呼声和关于 DLL Hell 的庞大零散论坛讨论之间存在巨大的差距,特别是与我必须处理的异常有关。通过专注于错误和异常而不是编程,我希望为所有需要处理 COM 互操作的其他人员缩小这个差距。
因为,老实说,一旦一切都运行起来,它确实为您的程序增加了极大的功能。