65.9K
CodeProject 正在变化。 阅读更多。
Home

在 .NET 中构建 COM 服务器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (92投票s)

2006年1月3日

CPOL

98分钟阅读

viewsIcon

486096

downloadIcon

4014

学习使用 .NET 语言构建 COM DLL 和 EXE 服务器的基本原理。

引言

COM/.NET 互操作是一种众所周知、文档完善且广受欢迎的技术。它旨在帮助我们桥接现有的基于 COM 的系统与 .NET 组件,使我们能够利用 .NET 类库提供的丰富功能。另一方面,COM/.NET 互操作还允许 .NET 应用程序使用现有的 COM 对象和控件,从而实现与传统 COM 组件的向后兼容性。

自从本世纪初 .NET 首次推出以来,多年来已经撰写了许多关于 COM/.NET 互操作技术的文章。许多“如何做”甚至“为什么”的问题都得到了解答。

在本文中,我旨在专注于将 .NET 对象暴露给 COM 的技术。我将尽可能深入地解释 .NET 系统如何通过语言结构和各种工具实现互操作。为此,我将提供几个 COM 服务器示例源代码,其中大部分用 C# 编写(作为 .NET 语言的示例),一个用 C++ 编写(用于交叉比较)。我还很高兴演示如何通过用 C# 实现的特殊 .NET 类工厂,将 .NET EXE 程序集中的对象(包括静态实例和运行实例)暴露给 COM。

我假设读者已经具备以下先验知识:

  1. 一般的 COM 开发。
  2. C++ 和 ATL。
  3. C# 和各种 .NET 工具,如 tlbimp.exegacutil.exe

在我的整篇文章中,将重点放在 C# 实现代码、工具使用示例以及各种配置设置上,这些共同演示了如何构建可以转换为未托管客户端可用的 COM 组件的 C# 组件。C++ 实现代码会提及一些,但我假设读者在这方面有丰富的开发经验。我还将特别关注 COM 组件的发现加载机制。对此进行探讨是为了交叉比较、理解和欣赏 .NET 组件(伪装成 COM 对象)如何被未托管客户端应用程序发现和加载的方式。

总纲

以下是本文主体部分的组织结构概述

  • COM 平台

    我将对 COM 进行总结性评估,这项技术受到许多程序员的喜爱。我非常想与大家分享我对 COM 如何实现面向对象开发原则的看法。还将简要介绍 IDL,其中强调了其在语言独立性方面的重要性。

  • 一个简单的 COM 接口

    我们将定义一个简单 COM 接口,它将用于实现具体的代码。此 COM 接口由一个“空”ATL 项目维护。所谓“空”,是指此 ATL 项目不包含任何有意义的实现代码。其目的是帮助我们维护一个中心 IDL,其中包含上述简单 COM 接口以及其他抽象接口的定义。ATL 项目还可以用于帮助我们编译并生成一个类型库文件 (.TLB),该文件将有助于生成一个名为主互操作程序集的东西。

  • 进程内服务器 (DLL) 实现

    接下来,我们将开始对 COM 服务器实现的实践研究,从进程内 (DLL) 服务器开始。我们将逐步完成简单 COM 接口的两个独立具体实现的开发过程:一个用 C++ 实现,另一个用 C# 实现。我们将检查 C++ 实现的各个方面,以展示 C# 实现如何公平地实现这些方面。将包含两个客户端应用程序(一个用 VB 编写,另一个用 VC++ 编写)。这些将演示 C++ 和 C# 具体实现的实例化以及它们方法的调用。

  • 进程外 (EXE) 实现

    在对标准 .NET/COM 互操作的内部结构打下坚实基础之后,我们将继续探索将 .NET EXE 程序集用作 COM 服务器的方法。我们将探讨它们用作进程内服务器的可能性,展示 .NET 引擎以统一方式处理程序集(无论它们是可执行文件还是类库)的多功能性。我们将探讨一个完全用托管代码 (C#) 实现的示例 COM EXE 服务器。我还将介绍一个特殊的 IDotNetClassFactory 接口和实现,通过它我们研究将 .NET EXE 程序集转换为进程外(或本地)COM 服务器的技术。

COM 平台

COM 是一个真正出色的编程模型,用于开发基于接口的集成组件。COM 的一些基本原则植根于面向对象哲学。它是一个实现面向对象开发和部署的绝佳平台。

COM 对 Windows 开发世界的重大贡献之一是对接口与实现分离概念的认识。毫无疑问,这种认识深刻地影响了当今程序员构建系统的方式。这个基本概念的延伸是:一个接口,多个实现。这意味着在运行时,COM 客户端可以选择从众多不同的具体实现之一实例化一个接口。每个具体实现都可以用任何支持 COM 组件开发的编程语言编写,例如 C++、Visual Basic、Delphi、PowerBuilder 等。

现在,借助 .NET/COM 互操作,.NET 组件也可以部署为 COM 组件。这意味着 COM 接口实现也可以用 C# 等 .NET 语言开发。

IDL 简要回顾

COM 开发中的常见做法是使用 IDL接口定义语言)开始接口的定义。在此,重要的是要掌握 IDL 的预期目的并理解它如何实现面向对象原则。IDL 文件不仅仅是微软的专有文件类型之一。它值得更深入的理解。在本节中,我将解释 IDL 和 coclass(一个 IDL 关键字)背后的一些更重要的概念,特别是。

一个 IDL 文件是 COM 提供的一种方式,允许开发人员定义与语言无关的面向对象类。IDL 文件由 MIDL 编译器编译成一个类型库.TLB 文件),它是一个 IDL 文件的二进制形式,旨在由各种语言编译器(例如 VB、VC++、Delphi 等)处理。这种 .TLB 处理的最终结果是特定语言编译器生成了语言特定的构造(VB 的 VB 类,C++ 的 C++ 类、各种结构、宏和 typedef),它们代表 .TLB 中(最终是原始 IDL 文件中)定义的 coclass。

coclass 是 COM 定义类(面向对象意义上的类)的一种(语言无关)方式。让我们来看一个 IDL 中 coclass 定义的示例

coclass MyObject 
{ 
  [default] interface IMyObject; 
  [default, source] dispinterface _IMyObjectEvents; 
};

上述代码片段声明了一个名为 MyObject 的 COM 类,它必须实现一个名为 IMyObject 的接口,并且支持(而不是实现)事件接口 _IMyObjectEvents

忽略事件接口部分,这在概念上等同于定义一个像这样的 C++ 类:

class CSomeObject : public ISomeInterface 
{ 
  ... 
  ... 
  ... 
};

其中 ISomeInterface 是一个 C++ 虚类。

再次参照 MyObject COM 类:一旦为其 coclass 定义在 IDL 中形式化,并从中编译出类型库,责任就落在各个语言编译器身上,它们需要读取并适当地解释此类型库,然后生成开发人员所需的所有代码(以特定编译器的语言),以实现并最终生成 COM 认为属于 coclass MyObject 的二进制可执行代码。

一旦 COM coclass 的实现构建完成并在系统中可用,接下来就是如何实例化它的问题。现在,在 C++ 等语言中,我们可以使用 CoCreateInstance() API,其中我们指定 coclass 的 CLSID 以及我们想用于与该 coclass 交互的接口。像这样调用 CoCreateInstance()

CoCreateInstance 
( 
  CLSID_MyObject, 
  NULL, 
  CLSCTX_INPROC_SERVER, 
  IID_IMyObject, 
  (void**)&m_pIMyObject 
);

在概念上等同于以下 C++ 代码

ISomeInterface* pISomeInterface = NULL;
pISomeInterface = new CSomeObject();

在第一种情况下,我们向 COM 子系统表示,我们希望获得一个实现 IMyObject 接口的对象的指针,并且我们希望 CLSID_MyObject 这个 coclass 的特定实现。在第二种情况下,我们表示我们希望创建一个实现接口 ISomeInterface 的 C++ 类的实例,并且我们使用 CSomeObject 作为该 C++ 类。

你看到等价性了吗?那么,coclass 就是 COM 世界中的面向对象类。coclass 的主要特点是它具有

  1. 二进制性质,因此
  2. 与编程语言无关。

一个简单的 COM 接口

现在我们开始实际的代码研究,首先介绍一个名为 ISimpleCOMObject 的简单 COM 接口的定义。使用 Visual Studio .NET,我们创建一个名为 SimpleCOMObject.sln 的 ATL 项目。该项目的完整源代码包含在可下载的示例代码中。解压缩后,它将位于以下目录中:

<main folder>\SimpleCOMObject\interfaces\SimpleCOMObject

其中 <main folder> 是你复制源代码压缩文件的位置。

这个 SimpleCOMObject.sln 项目不会包含任何有用的实现代码。它的目的只是允许我们自动化 ISimpleCOMObject 接口的创建和维护。这是通过使用 ATL 向导实现的。项目文件文件夹还作为存储各种 .NET 相关资源(例如,主互操作程序集、强命名密钥文件等)的中心存储库。稍后会详细介绍这些。

下面列出的是从 SimpleCOMObject.idl 中提取的代码片段,显示了 ISimpleCOMObject 接口

[
  object,
  uuid(9EB07DC7-6807-4104-95FE-AD7672A87BD7),
  dual,
  nonextensible,
  helpstring("ISimpleCOMObject Interface"),
  pointer_default(unique)
]
interface ISimpleCOMObject : IDispatch
{
  [propget, id(1), helpstring("property LongProperty")]
    HRESULT LongProperty([out, retval] LONG* pVal);  
  [propput, id(1), helpstring("property LongProperty")]
    HRESULT LongProperty([in] LONG newVal);
  [id(2), helpstring("method Method01")]
    HRESULT Method01([in] BSTR strMessage);
};

ISimpleCOMObject 包含一个属性(LongProperty)和一个方法(Method01())。我们规定 ISimpleCOMObject 旨在由一个接收 long 值(LongProperty)的对象实现,然后在调用 Method01() 时显示此值。BSTR 参数“strMessage”旨在与 LongProperty 值一起显示在调用 Method01() 时。

请注意,我们将 ISimpleCOMObject 定义为双接口。因此,客户端应用程序可以使用 IUnknown (vtable) 接口指针或 IDispatch 接口指针调用其方法。尽管可以将 ISimpleCOMObject 定义为直接派生自 IUnknown,但我选择将其派生自 IDispatch,以确保其方法的参数和返回值严格地是自动化兼容的(即,可以存储在 VARIANT 结构中的类型)。这样做是为了保持简单。

我假设读者已经非常熟悉使用 C++ 等非托管语言实现 COM 接口。我打算在本文中详细解释使用 C# 等 .NET 语言的实现。使用自动化兼容类型无疑将有助于使这个过程尽可能简单。

现在,尽管 SimpleCOMObject.sln 不包含 ISimpleCOMObject 的任何有用实现,但我们仍然需要编译它以生成两个重要文件

  1. 一个类型库 (SimpleCOMObject.tlb)。
  2. 一个 DLL (SimpleCOMObject.dll)。

这些文件将在项目包含文件夹的 Debug 目录中创建。如果您已下载我的示例代码,并且尚未修改任何内容,这些文件将存储在以下路径中:

<main folder>\SimpleCOMObject\interfaces\SimpleCOMObject\Debug

其中 <main folder> 是你复制源代码压缩文件的位置。

SimpleCOMObject.tlb 将用于生成称为主互操作程序集的东西。更多内容将在下一小节中介绍。请注意,SimpleCOMObject.dll 本身将内部包含 SimpleCOMObject.tlb(作为二进制资源嵌入)。SimpleCOMObject.dll 将被(由 Visual Studio .NET IDE)注册为包含 SimpleCOMObject.idl 中定义的coclass接口的二进制定义的类型库。

进行此类型库注册是为了让 COM 在需要对 SimpleCOMObject.idl 中定义的接口执行封送处理时知道去哪里查找类型信息。这种形式的封送处理更广为人知的是类型库封送处理

注册表中存储的 SimpleCOMObject.tlb 类型库数据位于 "HKEY_CLASSES_ROOT\TypeLib\{5830FDB2-10E1-427E-B967-515F4DC05F58}" 子键下,其中 "{5830FDB2-10E1-427E-B967-515F4DC05F58}" 是类型库的 LIBID

\1.0\0\win32”子键的默认字符串值是 SimpleCOMObject.dll 的完整路径。我们稍后在讨论主互操作程序集的注册时会重新访问此类型库键条目。

主互操作程序集的创建和注册

如前所述,COM 类型库文件是 COM 独立于语言的方式,用于暴露在原始 IDL 文件中定义的类型。这对于在 Visual C++ 和 Visual Basic 等非托管语言编译器中工作非常有效,但对于 .NET 编译器则不然。这可能会让读者感到惊讶,但像 Visual C# 这样的 .NET 编译器实际上没有任何处理 COM 类型库的内在能力。

.NET 编译器只将 .NET 元数据理解为类型信息的来源。将 COM 类型信息暴露给 .NET 世界的关键是获取 COM 类型库并为其生成等效的 .NET 元数据。此过程通过使用类型库导入器TLBIMP.exe)工具完成。

现在,在一个普通的 Visual Studio .NET 项目中,当我们添加对 COM 组件的引用时,类型库导入器会在后台被调用,以生成一个新的程序集,其中包含以 .NET 元数据形式的 COM 类型信息。项目实际引用的是这个新程序集,而不是 COM 组件中包含的类型库。

由类型库导入器生成的程序集称为互操作程序集。它包含 COM 类型的 .NET 等效定义,可以从 .NET 语言代码中引用。与包含元数据和 IL中间语言)代码的典型程序集不同,互操作程序集只包含元数据。

互操作程序集有两个一般用途

  1. 它使方法调用和类型定义在编译时得以解析。
  2. 它使公共语言运行时能够在运行时为 COM 组件生成一个运行时可调用包装器。

第一个目的是对我们很重要的,而第二个只有在我们实例化托管代码中的 COM 组件时才相关。为了正确的发布目的,需要一个称为主互操作程序集 (PIA) 的东西。PIA 与非主互操作程序集基本相同,除了以下几点:

  • 它由 COM 组件的作者进行数字签名。
  • 它带有一个特殊的 PIA 特定自定义属性。

然而,其预期用途非常重要,开发人员应该重视。PIA 旨在被指定为 COM 组件类型定义的单一元数据标识。让我更详细地解释这一点。

在 COM 世界中,类型由 GUID全局唯一标识符)标识。如果多个类型库包含单个已发布类型的定义,这并不重要。就 COM 而言,只要 GUID 的数字匹配,它们都指的是同一事物。COM 类型与其 GUID 密不可分,不多不少。

.NET 类型并非如此。.NET 类型的标识与其包含的程序集相关联。换句话说,包含程序集构成了 .NET 类型标识的一部分。因此,如果多个程序集包含某个类型的定义(即使是源自同一个 COM 类型库的类型),它们都被 .NET 视为独立不相关的类型。因此,当项目需要类型定义时,它引用哪个程序集很重要。

现在,如果每个客户端项目都通过 Visual Studio .NET 添加对 COM 类型库的引用(从而导致生成一个新的互操作程序集),或者使用 TLBIMP 手动创建互操作程序集,我们将最终得到 COM 类型的多个独立定义。

因此,常见的做法是 COM 类型库发布者创建一个并数字签名一个主互操作程序集,供所有客户端通用使用。

要创建 PIA,TLBIMP 实用程序与 /primary 标志一起用于 COM 类型库,以生成主互操作程序集。primary 标志将导致 TLBIMP 使用一个特殊的 PIA 特定自定义属性标记 PIA:System.Runtime.InteropServices.PrimaryInteropAssemblyAttribute

此后,在新创建的 PIA 上调用 REGASM,以将更多信息注册到注册表。PIA 也应该注册到全局程序集缓存 (GAC) 中,以便在所有客户端之间全局共享。

从技术上讲,没有任何东西可以强制开发人员使用 PIA。这是一种惯例。如果 PIA 已在系统中正确注册,.NET 开发工具(如 Visual Studio .NET 和 TLBIMP.EXE)可以检测到它们,并且它们会做出适当的反应,例如:

  1. 如果用户尝试为某个类型库创建互操作程序集,而该类型库的 PIA 已在当前计算机中注册,则 TLBIMP.EXE 将打印一条警告。
  2. 当项目尝试在“添加引用”对话框的“COM”选项卡上添加对类型库的引用时,如果当前系统上存在已注册的 PIA,Visual Studio .NET 将使用该 PIA,而不是生成新的互操作程序集。

我的示例源代码包含一个批处理文件 CreateAndRegisterPrimaryInteropAssembly.bat,其中包含将为 SimpleCOMObject.tlb 生成 PIA 的命令,然后将输出的互操作程序集(即 Interop.SimpleCOMObject.dll)注册到注册表和 GAC。为了使 PIA 能够进行数字签名,我还预先创建了一个强名称密钥文件 SimpleCOMObject.snk 供 TLBIMP 使用。另请注意,为了将任何程序集注册到 GAC 中,该程序集也必须进行数字签名。

CreateAndRegisterPrimaryInteropAssembly.bat 的内容如下

echo off
echo Generating Primary Interop Assembly for Debug\SimpleCOMObject.tlb ...
tlbimp Debug\SimpleCOMObject.tlb /out:Interop.SimpleCOMObject.dll
  /keyfile:SimpleCOMObject.snk /primary
echo Registering PIA Interop.SimpleCOMObject.dll to the Registry
regasm Interop.SimpleCOMObject.dll
echo Registering Primary Interop Assembly Interop.SimpleCOMObject.dll
  into the Global Assembly Cache...
gacutil -i Interop.SimpleCOMObject.dll

编译 SimpleCOMObject.sln 后,请在 Visual Studio .NET 命令提示符窗口中调用此批处理文件。编译 SimpleCOMObject.sln 后,此批处理文件至少需要调用一次。但是,如果向项目添加了新接口或修改了现有接口,则可以根据需要多次调用它。

请注意,如果 CreateAndRegisterPrimaryInteropAssembly.bat 被第二次(或更多次)调用,TLBIMP 将发出警告,通知我们 SimpleCOMObject.tlb 类型库的 PIA 已经注册。尽管如此,TLBIMP 仍将继续生成一个新的 PIA。这在下面 illustrated

请注意,一旦主互操作程序集已注册到注册表,一个名为 "PrimaryInteropAssemblyName" 的特殊字符串条目将在原始类型库的注册表项下创建(我们已为此调用了 TLBIMP)。字符串值包含有关已注册 PIA 的基本信息

SimpleCOMObject.tlb 生成和 GAC 注册 PIA 将使我们能够在以后开发 ISimpleCOMObject 的 C# 实现。我在介绍部分提到,我们还将提供一个 C++ 实现。因此,在探索我们的 C# 实现之前,我们将简要介绍我们的 C++ 实现。这将在下一节中描述。

进程内服务器 (DLL) 实现

C++ 实现

C++ 实现的完整源代码包含在 zip 文件中。解压缩后,它将存储在以下文件夹中

<main folder>\SimpleCOMObject\implementations\CPP\SimpleCOMObject_CPPImpl

其中 <main folder> 是你复制 zip 文件的位置。以下小节简要概述了此项目的重要方面。

SimpleCOMObject_CPPImpl.sln 是一个 ATL 项目

ATLActive Template Library)是使用 C++ 开发 COM 组件的绝佳工具。我们之前看到 SimpleCOMObject.sln 也是使用 ATL 开发的。请注意,我们的 SimpleCOMObject_CPPImpl 项目是一个无属性的 ATL 项目。

Coclass SimpleCOMObject_CPPImpl

SimpleCOMObject_CPPImpl.idl 文件包含以下 coclass 定义

[
  uuid(5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B),
  helpstring("SimpleCOMObject_CPPImpl Class")
]
coclass SimpleCOMObject_CPPImpl
{
  [default] interface ISimpleCOMObject;
            interface ISimpleCOMObject_CPPImpl;
  [default, source] dispinterface _ISimpleCOMObject_CPPImplEvents;
};

这是一个 COM 类(CLSID 为 {5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B})的声明,它将包含 ISimpleCOMObject 接口的实现。这个 coclass(及其唯一的 CLSID)是 COM 所识别的。就 COM 而言,用于开发此类 COM 类的特定编程语言并不重要。

在运行时,称为类工厂(针对特定 coclass)的东西执行动态创建所需对象的行为,通过从与实现编程语言相关的特定类构造中实例化它。有关更多信息,请参阅下面的后续小节。

ISimpleCOMObject 由 CSimpleCOMObject_CPPImpl C++ 类实现

CSimpleCOMObject_CPPImpl 是一个 C++ 类的名称(在 SimpleCOMObject_CPPImpl_.h 中定义),它为接口 ISimpleCOMObject 提供实现代码。

请注意,更准确的说法是 CSimpleCOMObject_CPPImpl C++ 类是 coclass SimpleCOMObject_CPPImpl 的实现。而且,由于 coclass SimpleCOMObject_CPPImpl 已被声明(在 IDL 中)实现接口 ISimpleCOMObject,因此 CSimpleCOMObject_CPPImpl C++ 类将提供接口 ISimpleCOMObject 的实现。

现在,既然 COM 不知道 C++ 类 CSimpleCOMObject_CPPImpl 是 coclass SimpleCOMObject_CPPImpl 的实现,那么在运行时,CSimpleCOMObject_CPPImpl C++ 类的一个实例是如何最终被用作 coclass SimpleCOMObject_CPPImpl 的实现代码的呢?

答案是:coclass SimpleCOMObject_CPPImpl类工厂。在运行时,响应像下面这样的 API 调用时

CoCreateInstance
(
  <SOME_CLSID>,  
  NULL,  
  CLSCTX_INPROC_SERVER,  
  <SOME_IID>,  
  (void**)&m_pIMyObject
);

COM 子系统将查找注册表以发现包含其 CLSID 与 SOME_CLSID 匹配的 coclass 实现代码的 DLL(或 EXE)模块。这可以在以下注册表项之一中找到:

HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\InprocServer32 (for DLLs)
HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\LocalServer32 (for EXEs)

无论哪种方式,一旦发现可执行模块的路径,COM 就会加载它,然后尝试调用其 CLSID 为 SOME_CLSID 的 coclass 的类工厂。正是这个类工厂对要实例化的类构造(特定编程语言上下文中的类)具有特定知识。

coclass SimpleCOMObject_CPPImpl 的 ProgID 列在 SimpleCOMObject_CPPImpl1.rgs 中

在我们的客户端应用程序代码中,我们将使用 ProgID 来识别特定的 COM coclass 实现。ProgID 是组件 CLSID 的人类可读等效项。它们可以被视为“类名”。它们不像 GUID 那样具有普遍唯一性,但对于大多数目的来说已经足够。SimpleCOMObject_CPPImpl1.rgs 文件包含 coclass SimpleCOMObject_CPPImpl 的 ProgID 定义(如下粗体字所示):

HKCR
{
  SimpleCOMObject_CPPImpl.SimpleCOMObje.1
    = s 'SimpleCOMObject_CPPImpl Class'
  {
    CLSID
    = s '{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}'
  }
  SimpleCOMObject_CPPImpl.SimpleCOMObject
    = s 'SimpleCOMObject_CPPImpl Class'
  {
    CLSID = s '{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}'
    CurVer = s 'SimpleCOMObject_CPPImpl.SimpleCOMObject.1'
  }
  NoRemove CLSID
  {
    ForceRemove {5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}
      = s 'SimpleCOMObject_CPPImpl Class'
    {
      ProgID = s 'SimpleCOMObject_CPPImpl.SimpleCOMObject.1'
      VersionIndependentProgID
        = s 'SimpleCOMObject_CPPImpl.SimpleCOMObject'
      ForceRemove 'Programmable'
      InprocServer32 = s '%MODULE%'
      {
        val ThreadingModel = s 'Apartment'
      }
      val AppID = s '%APPID%'
      'TypeLib' = s '{881D7F1E-698B-40B9-9347-860B5E00A9AD}'
    }
  }
}

coclass SimpleCOMObject_CPPImpl 的 ProgID 是 "SimpleCOMObject_CPPImpl.SimpleCOMObject"。然而,COM 系统只处理 GUID,并且将加载由实际 CLSID 标识的 COM coclass。因此,在运行时,ProgID 必须转换为其实际的 CLSID 等效项。此信息也可以在注册表的 HKEY_CLASSES_ROOT 键下找到。因此,对于我们的 coclass SimpleCOMObject_CPPImpl COM 对象,注册表中将找到以下条目:

HKEY_CLASSES_ROOT\SimpleCOMObject_CPPImpl.SimpleCOMObject

在此注册表项中,将有一个 CLSID 子项,其中记录了 "SimpleCOMObject_CPPImpl.SimpleCOMObject" ProgID 的 GUID 等效项

HKEY_CLASSES_ROOT\SimpleCOMObject_CPPImpl.SimpleCOMObject\CLSID = 
                                {5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}

这通过上述键的注册表设置截图进行说明

现在,一旦发现 CLSID,COM 子系统将通过查找以下注册表项来发现 COM 组件的二进制可执行文件:

HKEY_CLASSES_ROOT\CLSID\{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}

从这里,COM 将知道二进制可执行文件是一个 DLL,因为将找到以下子项

HKEY_CLASSES_ROOT\CLSID\{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}\InprocServer32

此子键的字符串值将是 DLL 的路径

从这个注册表项中,我们将注意到 DLL 是 SimpleCOMObject_CPPImpl.dll。此 DLL 的完整路径记录在注册表项字符串值中。

C# 实现

C# 实现的完整源代码包含在 zip 文件中。解压缩后,它将存储在以下文件夹中

<main folder>\SimpleCOMObject\implementations\C#\SimpleCOMObject_CSharpImpl

其中 <main folder> 是你复制 zip 文件的位置。

我们的目标是创建一个典型的 C# 类库,它公开一个实现 COM 定义的 ISimpleCOMObject 接口的 .NET 类。以下小节提供了如何构建此项目的分步指南。

SimpleCOMObject_CSharpImpl.sln 是一个 C# 类库项目

SimpleCOMObject_CSharpImpl.sln 最初被定义为类库项目。为了生成可以作为 COM 进程内服务器加载的 .NET 模块,.NET 模块可以创建为类库或 EXE。

我们将在本文稍后研究作为 COM 进程内服务器的 .NET EXE。然而,大多数用于 COM/.NET 互操作的 .NET 模块通常是类库,这就是我们实现 SimpleCOMObject_CSharpImpl.sln 的方式。

SimpleCOMObject_CSharpImpl.sln 项目中的主要源文件是 SimpleCOMObject.cs。这里定义了命名空间 SimpleCOMObject_CSharpImpl 和 C# 类 SimpleCOMObject

namespace SimpleCOMObject_CSharpImpl
{
  public class SimpleCOMObject : ISimpleCOMObject
  {
      ...
      ...
      ...
  }
}

C# 类 SimpleCOMObject_CSharpImpl.SimpleCOMObject 提供了 ISimpleCOMObject 接口的实现。请注意,这里有一个转折:这个 ISimpleCOMObject 接口与 SimpleCOMObject.tlb 类型库公开的 ISimpleCOMObject 接口不同。它的全名实际上是 Interop.SimpleCOMObject.ISimpleCOMObject。这是因为它属于从 Interop.SimpleCOMObject.dll 主互操作程序集引用的 Interop.SimpleCOMObject 命名空间。我们将在下面的下一小节中进一步探讨这一点。

SimpleCOMObject_CSharpImpl.sln 引用了 Interop.SimpleCOMObject.dll PIA

SimpleCOMObject_CSharpImpl.sln 项目引用了 Interop.SimpleCOMObject.dll PIA。这可以在解决方案资源管理器中看到

要引用 PIA,我们首先调用“添加引用”对话框,然后选择“COM”选项卡。此后,我们可以搜索其描述在“组件名称”列下显示的相应类型库

另一种方法是使用“浏览”按钮直接导航到 SimpleCOMObject.tlbSimpleCOMObject.dll。无论哪种方式,请注意,引用的是 PIA(注册在 GAC 中)(参见路径的属性值)。

通过引用 Interop.SimpleCOMObject.dll 主互操作程序集,SimpleCOMObject_CSharpImpl 能够访问和解析源自 SimpleCOMObject.tlb 类型库的类型定义。这样,C# 类 SimpleCOMObject_CSharpImpl.SimpleCOMObject 能够继承 Interop.SimpleCOMObject.ISimpleCOMObject 接口并实现其所需的属性和方法。这些实现如下所示

public int LongProperty
{
  get
  {
    return m_iLongProperty;
  }
  set
  {
    m_iLongProperty = value;
  }
}
public void Method01 (String strMessage)
{
  StringBuilder sb = new StringBuilder(strMessage);
 
  sb.Append(LongProperty.ToString());
  MessageBox.Show(sb.ToString(),
    "SimpleCOMObject_CSharpImpl.SimpleCOMObject");
}

尽管接口 Interop.SimpleCOMObject.ISimpleCOMObject 是一个成熟的 .NET 类型,但它与原始 COM 接口 ISimpleCOMObject 紧密相连。这是因为 Interop.SimpleCOMObject.dll 互操作程序集包含原始 COM 接口 ISimpleCOMObject 的 GUID。

事实上,在运行时,Interop.SimpleCOMObject.dll 互操作程序集中包含的元数据是互操作封送处理过程所必需的。让我下面更详细地解释这一点。

尽管 SimpleCOMObject.tlb COM 类型库和 Interop.SimpleCOMObject.dll 互操作程序集中定义的类型在逻辑上等效,但它们不能直接互换。当从非托管代码到托管代码进行方法调用时,通过参数传入和传出的数据需要从一种表示形式转换为另一种表示形式。这描述了封送处理的一般过程。跨非托管和托管边界封送处理数据的过程专门称为互操作封送处理

因此,在运行时,Interop.SimpleCOMObject.dll 模块是必需的,并且必须可用。确保这一点的一种方法是将其注册到 GAC 中,我们已经完成了此操作。对于将引用 Interop.SimpleCOMObject.dll 的任何项目,还要采取的另一步是确保对 Interop.SimpleCOMObject.dll 的引用的“复制本地”属性设置为“False”(在上图中,该设置用红色下划线)。这将确保在运行时,输出可执行文件(例如,SimpleCOMObject_CSharpImpl.dll)将加载共享资源 Interop.SimpleCOMObject.dll,而不是加载其自己的副本。

SimpleCOMObject_CSharpImpl.dll 已注册到注册表和 GAC

SimpleCOMObject_CSharpImpl.sln 项目将生成一个 SimpleCOMObject_CSharpImpl.dll 模块。这本质上是一个 .NET 程序集,而不是 COM 组件 DLL。同样,它不能被 COM 客户端应用程序直接加载和使用。为了弥合这一差距,Microsoft .NET 运行时执行引擎mscoree.dll)充当中间人角色。

我们将在下面更详细地讨论这一点。目前,只需说 mscoree.dll 为 COM 客户端应用程序提供了一个足够的前端接口或代理,以访问实现 COM 接口的 .NET 组件。在以下小节中,我们将讨论 .NET 与 COM 的运行时关系,以便理解我们必须采取的额外步骤,才能将 .NET 组件完全转换为可用的 COM 对象。

.NET 支持常见的 COM 协议

为了使 COM 客户端应用程序能够透明地使用 .NET 组件,所有已知的 COM 协议都必须保持生效。微软在这方面做得非常出色。COM 创建 .NET 对象所需的所有信息(COM 认为 .NET 对象是普通的 COM 对象)都必须可用。COM 获取这些信息的方法必须保持不变。

现在,回想我们在“C++ 实现”一节中的讨论,当 COM 客户端应用程序想要创建 COM coclass 的实例时,它必须提供以下内容

  • coclass 的 CLSID 或 ProgID。
  • coclass 接口的 IID,用于调用属性和方法。

为了适应这个协议,.NET 组件必须拥有自己的 CLSID。当然,原始 COM 类型库中定义的任何接口的 IID 将保持不变。

C# 类 SimpleCOMObject_CSharpImpl.SimpleCOMObject 拥有自己的 CLSID

如前所述,在“C++ 实现”一节中,当 COM 需要加载 CLSID 为 SOME_CLSID 的 coclass 时,它会查找注册表以发现包含该 coclass 实现代码的 DLL(或 EXE)模块。DLL 或 EXE 的完整路径记录在以下注册表项之一中

HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\InprocServer32 (for DLLs)
HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\LocalServer32 (for EXEs)

这种基本发现机制也延伸到 .NET 组件。 .NET Framework 无缝地融入了这种协议,并且没有发明任何新的查找方法。

现在,为您的 .NET 类生成 CLSID 有两种方法

  1. 让 Visual Studio .NET 自动为您生成一个。
  2. 通过 GuidAttribute
自动生成

默认情况下,Visual Studio .NET 可以自动为组件生成所有必需的 GUID(例如 LIBID、CLSID 和 IID)。除了 IID,这些 GUID 的生成主要基于程序集的标识(即其名称、公钥和版本)。

源自 COM 类型库并导入到互操作程序集中的 IID(接口 ID)无需重新生成。它们保持不变并将被重用。这是合乎逻辑的,因为当 COM 客户端从 COM 对象请求特定接口时,它们会期望相同的 IID 数字。

COM 类的 CLSID 理所当然地只与类本身相关联。每个 coclass 都将有一个唯一的 CLSID。这也适用于作为 COM 类公开的 .NET 类。当自动生成时,.NET 类的 CLSID 基于完全限定类名和包含该类的程序集标识的哈希值。程序集的标识包括程序集的名称、公钥、版本和区域性,但区域性不用于生成 CLSID。

GuidAttribute 生成

您可以手动通过 GuidAttribute 设置此值,而不是让 Visual Studio .NET 为您的 .NET 类生成 CLSID。您需要包含 System.Runtime.InteropServices 命名空间。以下是为我们的 C# 类 SimpleCOMObject_CSharpImpl.SimpleCOMObject 生成此值的示例

     using System.Runtime.InteropServices;
     namespace SimpleCOMObject_CSharpImpl
     {
         [Guid("62D910F8-1BBA-4c16-8C86-445F9A7AD007")]
         public class SimpleCOMObject : ISimpleCOMObject
         {
         ...
         ...
         ...
         }
     }

为了让读者进行实验,我已在本文附带的示例代码中包含了此属性的使用(已注释掉)。

SimpleCOMObject_CSharpImpl.SimpleCOMObject 类的 ProgID

要封装到 COM 可调用 coclass 的 .NET 类的 ProgID 默认情况下是其完全限定名。这将在下一节中探索 .NET 系统如何将其类注册到注册表时变得明显。C# 类 SimpleCOMObject_CSharpImpl.SimpleCOMObject 的 ProgID 只是 "SimpleCOMObject_CSharpImpl.SimpleCOMObject"。

SimpleCOMObject_CSharpImpl 模块需要注册

就像普通的 COM DLL 或 EXE 一样,作为 COM 模块封装的 .NET 模块需要将其信息写入注册表,以便 COM 子系统能够定位它并将其加载到内存中供客户端使用。

这是通过 REGASM.EXE 实用程序实现的。REGASM 的功能类似于众所周知的 REGSVR32.EXE 实用程序,后者用于注册 COM 模块。REGASM 使用 .NET 程序集中包含的元数据生成 COM 等效信息,然后用于将条目插入注册表。我们将为我们的 C# 组件 SimpleCOMObject_CSharpImpl.dll 调用 regasm.exe,如下所示

regasm .\bin\Debug\SimpleCOMObject_CSharpImpl.dll /tlb

这假定我们是从项目目录调用(因此指定了 ".\bin\Debug" 子目录)。/tlb 标志将指示 regasm.exeSimpleCOMObject_CSharpImpl.dll 生成一个类型库。尽管此类型库未在我们的示例代码中使用,但如果非托管客户端应用程序希望导入它,它仍然有用。

写入注册表的条目包括作为 COM 类公开的 .NET 类的 CLSID 和 ProgID。此注册过程对于 COM 客户端在发现和加载过程中很重要。

现在让我们检查一下 REGASM 为我们的 C# 类 SimpleCOMObject_CSharpImpl.SimpleCOMObject 写入注册表的一些重要条目

[HKEY_CLASSES_ROOT\CLSID\{8E8F26E5-D9FA-3ADC-9F0D-2A70C831E547}]
  @="SimpleCOMObject_CSharpImpl.SimpleCOMObject"

SimpleCOMObject_CSharpImpl.SimpleCOMObject 生成的 CLSID 在 HKEY_CLASSES_ROOT\CLSID 键下有自己的条目。从这里我们可以看出,生成的 CLSID 是 "8E8F26E5-D9FA-3ADC-9F0D-2A70C831E547"。默认字符串值 "SimpleCOMObject_CSharpImpl.SimpleCOMObject" 是 C# 类的 COM ProgID。

[HKEY_CLASSES_ROOT\CLSID\{8E8F26E5-D9FA-3ADC-9F0D-2A70C831E547}\
  InprocServer32]
  @="mscoree.dll"
  "ThreadingModel"="Both"
  "Class"="SimpleCOMObject_CSharpImpl.SimpleCOMObject"
  "Assembly"="SimpleCOMObject_CSharpImpl, Version=1.0.0.0, 
     Culture=neutral, PublicKeyToken=8aacfb3a580209ca"   
  "RuntimeVersion"="v1.1.4322"

InprocServer32 键包含迄今为止最重要和最有趣的一组值。让我们详细检查一下

  • 默认字符串值

    这被设置为不直观的值 "mscoree.dll"。这是 Microsoft .NET 运行时执行引擎。为什么将此 DLL 设置为 SimpleCOMObject_CSharpImpl.SimpleCOMObject 的进程内服务器?如本节前面所述,它充当 COM 客户端与 .NET CLR 之间的“中间人”,后者负责加载 SimpleCOMObject_CSharpImpl.SimpleCOMObject .NET 类对象。回想一下,我们的 C# 类不能被非托管客户端应用程序直接使用。

    运行时引擎 mscoree.dll 将使用 InprocServer32 键的其他字符串值(特别是“Assembly”和“Class”字符串值)来内部加载适当的程序集,然后实例化适当的类。

  • “Assembly”字符串值

    Assembly”对应的字符串值包含包含 C# 类“SimpleCOMObject_CSharpImpl.SimpleCOMObject”的程序集的完整标识。请注意,没有写入路径信息,只有程序集的名称。因此,在运行时,将应用定位程序集的通常规则。

    为了方便多个客户端应用程序访问,我选择将输出的 SimpleCOMObject_CSharpImpl.dll 模块注册到全局程序集缓存 (GAC) 中。为了使模块注册到 GAC 中,它需要进行数字签名。这需要一个强命名密钥 (SNK) 文件。我已预先创建了这个 SNK 文件 KeyFile.snk

    另一个需要采取的步骤是在 AssemblyInfo.cs 文件中的 AssemblyKeyFile 属性中设置 SNK 文件的路径

    [assembly: AssemblyKeyFile("..\\..\\KeyFile.snk")]

    要将程序集注册到 GAC,我们使用 GACUTIL.EXE 实用程序。

  • “Class”字符串值

    Class”的字符串值表示与 CLSID 关联的 C# 类的名称。这毫不意外地设置为“SimpleCOMObject_CSharpImpl.SimpleCOMObject”。

  • “ThreadingModel”值

    ThreadingModel”的字符串值是“Both”。这意味着我们的组件被指定为能够作为 STA(单线程单元)或 MTA(多线程单元)对象生存。我的经验观察让我相信,.NET 运行时可能默认内置了内部锁定机制,以确保即使 .NET 对象存在于 MTA 中,每次也只访问一个方法或属性。一旦实现这一点,该对象也可以存在于 STA 中。

我已将 COM 注册和 GAC 注册这两个步骤合并到一个批处理文件 RegisterAssemblyToRegistryAndGAC.bat

echo off
echo Registering Assembly SimpleCOMObject_CSharpImpl.dll to the Registry...
regasm .\bin\Debug\SimpleCOMObject_CSharpImpl.dll /tlb
echo Install Assembly SimpleCOMObject_CSharpImpl.dll into
echo the Global Assembly Cache...
gacutil -i .\bin\Debug\SimpleCOMObject_CSharpImpl.dll

编译 SimpleCOMObject_CSharpImpl.sln 后,请务必运行此批处理文件。

客户端应用程序

我编写了两个示例客户端应用程序,它们将测试迄今为止讨论的概念。一个客户端应用程序用 Visual Basic 6.0 编写,另一个用 Visual C++ 7.0 编写。以下小节简要概述了这些应用程序。

Visual Basic 6.0 客户端应用程序

VB6 代码位于文件夹 <main folder>\SimpleCOMObject\clients\VB\VBClient01 中,其中 <main folder> 是你复制源代码 zip 文件的位置。它包含一个名为 FormMain 的简单窗体,其中定义了两个对象

Dim SimpleCOMObject_CPPImpl As SimpleCOMObject
Dim SimpleCOMObject_CSharpImpl As SimpleCOMObject 

类型 SimpleCOMObjectSimpleCOMObject.dll 中包含的类型库引用,该类型库由接口 ATL 项目 SimpleCOMObject.sln 编译。我们通过 References 对话框包含此类引用,该对话框可以通过 Project|References... 菜单调用。请注意,我们只引用了 SimpleCOMObject.dll 中包含的类型库,没有引用其他任何东西。

我们不需要引用我们知道将要加载的实现 DLL 的类型库。在运行时,我们将通过 CreateObject() VB API 绑定到实际的实现 DLL。使用此 VB API,我们可以通过指定适当的 ProgID 来选择要实例化的实际实现。稍后会详细介绍。

FormMain 还定义了一个简单的编辑框(Text_LongPropertyValue)和一个按钮(Command_Invoke

FormMain

Text_LongPropertyValue 编辑框用于用户输入长整型数值。Command_Invoke 按钮用于调用我们将绑定的实际 SimpleCOMObject 实现(即 SimpleCOMObject_CPPImplSimpleCOMObject_CSharpImpl)的方法和属性。

加载 FormMain 时,我初始化 Text_LongPropertyValue 编辑框,然后实例化两个 SimpleCOMObject 对象

Private Sub Form_Load()
  Text_LongPropertyValue = "0"
  Set SimpleCOMObject_CPPImpl _
     = CreateObject("SimpleCOMObject_CPPImpl.SimpleCOMObject")
  Set SimpleCOMObject_CSharpImpl _
     = CreateObject("SimpleCOMObject_CSharpImpl.SimpleCOMObject")
End Sub

注意 CreateObject() VB API 的强大和优雅。它实例化一个 ProgID 作为参数的 COM coclass,然后返回一个 IID 与左侧对象匹配的接口指针。

另请注意,当我们实例化与 SimpleCOMObject_CSharpImpl.SimpleCOMObject ProgID 关联的 coclass 时,.NET 运行时引擎与位于 GAC 中的 SimpleCOMObject_CSharpImpl.dll 模块一起加载。所有这些都是通过 COM/.NET 互操作透明完成的。

实践是检验真理的唯一标准,我们必须通过调用两个对象(特别是 SimpleCOMObject_CSharpImpl)的属性和方法来证明 COM/.NET 互操作的功能。当单击 Command_Invoke 按钮时,这在 Sub Command_Invoke_Click() 中完成

Private Sub Command_Invoke_Click()
  SimpleCOMObject_CPPImpl.LongProperty = Val(Text_LongPropertyValue)
  SimpleCOMObject_CPPImpl.Method01 _
        "From CPP Impl. The Long Property Value is : "
  SimpleCOMObject_CSharpImpl.LongProperty = _
        Val(Text_LongPropertyValue)
  SimpleCOMObject_CSharpImpl.Method01 _
        "From CSharp Impl. The Long Property Value is : "
End Sub

每个 SimpleCOMObject 实例(SimpleCOMObject_CPPImplSimpleCOMObject_CSharpImpl)都为其 LongProperty 分配一个值。此值取自 Text_LongPropertyValue 编辑框。假设此值为“1001”。然后调用每个实例的 Method1()。在运行时,应先后出现两个消息框

第二个实际上是从 C# 代码调用的。

Visual C++ 7.0 客户端应用程序

Visual C++ 7.0 客户端应用程序位于文件夹 <main folder>\SimpleCOMObject\clients\CPP\CPPClient01 中,其中 <main folder> 是你复制源代码 zip 文件的位置。它是一个简单的控制台应用程序。它导入了 SimpleCOMObject.tlb 类型库。在 CPPClient01.cpp 源文件中,我包含了以下语句来导入类型库

#import "SimpleCOMObject.tlb"

此类型库存储在相对路径 "..\..\..\interfaces\SimpleCOMObject\Debug" 中。我已在项目属性的 "Additional Include Directories" 设置中设置了此相对路径。请注意,就像在 VB 客户端应用程序中一样,我们不需要导入任何其他类型库。SimpleCOMObject.tlb 中包含的定义足以满足我们的目的。在运行时,我们通过指定适当的 ProgID 来选择要实例化的实际实现。

我编写了一个模板函数 CreateInstance() 作为辅助函数,以简化通过 ProgID 实例化 COM coclass 的过程

template <class SmartPtrClass>
bool CreateInstance
(
  LPCTSTR lpszProgID,  
  SmartPtrClass& spSmartPtrReceiver,  
  DWORD dwClsContext = CLSCTX_ALL
);

此功能的完整文档将在下面提供。目前,请注意第三个参数 dwClsContext 的默认值为 CLSCTX_ALL。我们大部分时间都将使用此默认值,除了在稍后的某个部分中,我们将尝试使用 CLSCTX_LOCAL_SERVER。使用 CLSCTX_ALL 将确保使用适当的服务器类型。

_tmain() 函数是所有操作开始的地方。与 Visual Basic 客户端应用程序类似,我定义了两个智能指针对象来表示两个指向 ISimpleCOMObject 接口的指针

// ISimpleCOMObjectPtr is a smart pointer class which will manage
// a pointer to the COM interface ISimpleCOMObject for us.

ISimpleCOMObjectPtr spISimpleCOMObject_CPPImpl = NULL;
ISimpleCOMObjectPtr spISimpleCOMObject_CSharpImpl = NULL;

我们的目标与 VB 客户端应用程序中的目标类似:即实例化两个公开 ISimpleCOMObject 接口的对象,一个用 C++ 实现,另一个用 C# 实现。

两个智能指针对象中包含的 ISimpleCOMObject 接口指针现在通过我们的模板函数 CreateInstance() 实例化

// We create an instance of an implementation of the
// ISimpleCOMObject interface as provided by the
// COM class whose CLSID is synonymous
// with the ProgID "SimpleCOMObject_CPPImpl.SimpleCOMObject".

CreateInstance<ISimpleCOMObjectPtr>
(
 "SimpleCOMObject_CPPImpl.SimpleCOMObject",
 spISimpleCOMObject_CPPImpl
);

// We create an instance of an implementation of the
// ISimpleCOMObject interface as provided by the
// COM class whose CLSID is synonymous with the ProgID
// "SimpleCOMObject_CSharpImpl.SimpleCOMObject".

CreateInstance<ISimpleCOMObjectPtr>
(
 "SimpleCOMObject_CSharpImpl.SimpleCOMObject",
 spISimpleCOMObject_CSharpImpl
); 

请注意,我们已选择将 CreateInstance() 的两次调用中的第三个参数设置为默认值 CLSCTX_ALL。这将导致 COM 系统使用被认为适合于预期 coclass(其 CLSID 分别与 ProgID SimpleCOMObject_CPPImpl.SimpleCOMObjectSimpleCOMObject_CSharpImpl.SimpleCOMObject 关联)的任何服务器类型。

现在,我们从前面章节的讨论中得知,第一个 ProgID 的服务器已被指定为进程内服务器,并且是 SimpleCOMObject_CPPImpl.dll。此 DLL 的完整路径记录在相应 CLSID 的注册表设置中。因此,SimpleCOMObject_CPPImpl.dll 将加载到内存中。

我们还知道第二个 ProgID 的服务器也是一个进程内服务器,它实际上是 .NET 运行时引擎 mscoree.dll。它将加载 .NET 类库 SimpleCOMObject_CSharpImpl.dll 并继续实例化 SimpleCOMObject_CSharpImpl.SimpleCOMObject 类。

此后,spISimpleCOMObject_CPPImplspISimpleCOMObject_CSharpImpl 可用于调用 ISimpleCOMObject 接口的属性和方法

spISimpleCOMObject_CPPImpl -> put_LongProperty(1000);
spISimpleCOMObject_CPPImpl -> Method01
(
  _bstr_t("CPP Implementation. The Long Property Value Is : ")
);
spISimpleCOMObject_CSharpImpl -> put_LongProperty(1000);
spISimpleCOMObject_CSharpImpl -> Method01
(
  _bstr_t("C# Implementation. The Long Property Value Is : ")
);

我们将值 1000 设置为 ISimpleCOMObject 接口的两个实现(LongProperty)的值。在运行时,将显示两个对话框,类似于我们在 VB 客户端应用程序中看到的对话框

单一接口、多重实现的思想倡导了接口指针背后的每个对象可能因实际实现方式而异的可能性。在我们的示例应用程序中,最重要的区别在于 spISimpleCOMObject_CSharpImpl 背后的对象实际上是用 C# 编写的 .NET 组件。

CreateInstance() 函数

此函数的完整源代码如下所示

template <class SmartPtrClass>
bool CreateInstance
(
  LPCTSTR lpszProgID,
  SmartPtrClass& spSmartPtrReceiver,
  DWORD dwClsContext = CLSCTX_ALL
)
{
  HRESULT hrRetTemp = S_OK;
  _bstr_t bstProgID(lpszProgID);
  CLSID  clsid;
  bool  bRet = false;
 
  hrRetTemp = CLSIDFromProgID
  (
    (LPCOLESTR)bstProgID, 
    (LPCLSID)&clsid
  );

  if (hrRetTemp == S_OK)
  {
    if
    (
      SUCCEEDED
      (
        spSmartPtrReceiver.CreateInstance(clsid, NULL, dwClsContext)
      )
    )
    {
      bRet = true;
    }
    else
    {
      bRet = false;
    }
  }

  return bRet;
}

模板的参数(类 SmartPtrClass)旨在指示要通过 coclass 实例化初始化的特定智能指针类。智能指针的类很重要,因为它指示其内部接口指针将指向的特定接口类型。函数的参数是

  • 预期的 ProgID 字符串(第一个参数 lpszProgID)。

    ProgID 指示要实例化的 coclass 的 CLSID。

  • 一个 _com_ptr_t 智能指针对象引用(第二个参数 spSmartPtrReceiver)。

    实例化 coclass 后,coclass 的结果接口指针将存储在此智能指针对象中。

  • 一个 DWORD,指示要使用的 COM 服务器类型(第三个参数 dwClsContext)。

    此值可用于指定用于提供目标 COM 对象的实例并管理目标 COM 对象的服务器类型。最常见的常量值为:

    • CLSCTX_INPROC_SERVER

      这意味着对象的服务器是一个 DLL,它与客户端应用程序在同一进程中运行。

    • CLSCTX_INPROC_HANDLER

      这意味着对象的服务器是一个进程内处理程序,即一个在客户端进程中运行的 DLL,它为对象实现客户端结构,而对象的实际代码驻留在远程机器上。

    • CLSCTX_LOCAL_SERVER

      这意味着对象的服务器是一个 EXE,它在同一台机器上运行并在单独的进程空间中加载。

    实际使用的值可以是上述值(以及其他未列出的值)的任意组合,这表示我们希望 COM 为我们决定要使用的最佳服务器类型。常量 CLSCTX_ALL(默认值)定义为所有三个的组合。

CreateInstance() 函数首先使用 CLSIDFromProgID() API 将输入的 ProgID 翻译为 CLSID。如果成功,我们调用智能指针类(实际上是 _com_ptr_t 模板类的实例化)的 CreateInstance() 方法,传入新发现的 CLSID 值和输入的 CLSCTX 值。此方法将在内部调用 CoCreateInstance() API 执行实际的创建过程。如果创建过程成功,我们返回 true 值。对于所有其他情况,我们返回 false

进程外 (EXE) 实现

现在我非常高兴在本节中介绍从 .NET EXE 中定义的 C# 类实例化 COM 接口的技术。这是我自己和 CodeProject 成员 mav.northwind 合作研究的结果。这项研究工作的重点是确定我们是否真的可以在 .NET 中创建进程外 COM 服务器,以及从静态(非运行) .NET EXE 程序集实例化对象。

在本节中,我们将探索我所知道的各种方法,用于实例化 .NET 类(包含在 .NET EXE 程序集中),然后将其作为 COM 对象传递给非托管客户端应用程序。现在让我们确定这到底意味着什么。它可能意味着以下三件事之一

  1. 使用标准 COM/.NET 互操作技术实例化包含在 .NET EXE 程序集中的 COM "coclass"。
  2. 加载静态 .NET EXE 文件中定义的类型,在托管代码中实例化它,然后将其作为 COM 对象封送给非托管客户端。
  3. 从正在运行的 .NET EXE 实例化 .NET 对象,将其代理封送到托管代码,然后将此代理作为 COM 对象封送到非托管客户端。

上述每个概念都很有用,并且每个概念都需要特定的技术来完成。在下面的小节中,我们将借助示例代码仔细研究每个概念。

标准 COM/.NET 互操作

这可能会让一些读者感到惊讶,但确实可以使用标准 COM/.NET 互操作技术实例化定义在 .NET EXE 程序集中的 .NET 类。然而,有一个问题:EXE 程序集既不会被执行,也不会使用其运行实例来实例化所需的 .NET 类

相反,EXE 程序集就像 .NET 类库 DLL 一样加载到客户端应用程序的地址空间中,然后由 .NET 运行时实例化目标类。这让人联想到我们之前通过 .NET 类将接口实现公开为进程内 (DLL) 服务器的方式。

我已包含一个 Visual Studio 解决方案集作为此概念的示例。这些项目包含在本文的源代码 zip 文件中。解压缩后,可以在目录 <main folder>\CSharpExeCOMServers\UsingStandardCOMInterop 中找到,其中 <main folder> 是您复制 zip 文件的位置。此文件夹中包含两个独立的项目:

  • .\implementations\SimpleCOMObject_CSharpExeImpl\SimpleCOMObject_CSharpExeImpl.sln
  • .\clients\CPP\CPPClient01\CPPClient01.sln

SimpleCOMObject_CSharpExeImpl.sln 解决方案

这是一个 .NET 控制台 EXE 项目,包含 ISimpleCOMObject 接口(类 SimpleCOMObject)的实现以及用作应用程序启动点的 Main() 函数

namespace SimpleCOMObject_CSharpExeImpl
{
  public class SimpleCOMObject : ISimpleCOMObject
  {
    ...
    ...
    ...
  }

  class SimpleCOMObject_CSharpExeImpl
  {
    [STAThread]
    static void Main(string[] args)
    {
      int i;
      int iCount = args.Length;

      for (i = 0; i < iCount; i++)
      {
        Console.Write ("Argument : ");
        Console.WriteLine (args[i]);
      }

      Console.WriteLine("Press [ENTER] to exit.");
      Console.ReadLine();
    }
  }
}

请注意关于此解决方案的以下几点

  • 它引用了 Interop.SimpleCOMObject 互操作程序集

    就像之前的 SimpleCOMObject_CSharpImpl 项目一样,此解决方案也引用了 Interop.SimpleCOMObject 互操作程序集。这样,ISimpleCOMObject 的方法和属性对此项目可见。因此,请确保首先成功编译 <main folder>\SimpleCOMObject\interfaces\SimpleCOMObject 中包含的早期 SimpleCOMObject 项目,并调用 CreateAndRegisterPrimaryInteropAssembly.bat 批处理文件。

  • 它是强命名的

    此项目是强命名的。这样我们就可以将输出的 EXE SimpleCOMObject_CSharpExeImpl.exe 注册到 GAC。这样做是为了方便客户端应用程序引用。

  • Class SimpleCOMObject 必须是公共的

    ISimpleCOMObject 实现类 SimpleCOMObject 必须是一个 public 类。否则,它将不会被 regasm.exe 作为 COM 对象公开。

  • Main() 函数显示命令行参数

    Main() 函数非常简单。它只显示程序的命令行参数,以及一行提示用户按回车键以终止程序。程序命令行参数的显示将在稍后显示其一些实用性。

SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 的实现代码很简单。唯一特别之处在于它在 Method01() 实现中使用的消息框标题,该标题指示代码从 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 类执行

public void Method01 (String strMessage)
{
  StringBuilder sb = new StringBuilder(strMessage);
   
  sb.Append(LongProperty.ToString());
   
  MessageBox.Show
  (
    sb.ToString(),
    "SimpleCOMObject_CSharpExeImpl.SimpleCOMObject"
  );
}

一旦 SimpleCOMObject_CSharpExeImpl.sln 成功编译,我们需要将它的 COM 信息注册到注册表,并将其注册为 GAC 中的 .NET 共享资源。我已经准备了一个 RegisterAssemblyToRegistryAndGAC.bat 批处理文件来执行这两个任务。请在成功编译 SimpleCOMObject_CSharpExeImpl.sln 后调用此批处理文件。

以下截图显示了注册表中将记录的内容

SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 生成的 CLSID 在 HKEY_CLASSES_ROOT\CLSID 键下有自己的条目。生成的 CLSID 是 3ED697FF-F9EA-3910-AFE6-BCF305572FFB。默认字符串值 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 是 C# 类的 COM ProgID。请注意,存在 InprocServer32 子键和 ProdId 子键,但没有 LocalServer32 子键。此观察结果稍后将再次提及。

注册表中为 SimpleCOMObject_CSharpExeImpl.exe 记录的 COM 信息将由客户端应用程序在运行时用于实例化 C# 类 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject。ProgID 也是 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject

CPPClient01.sln 解决方案

这是一个 C++ 控制台客户端应用程序,其行为与我们之前看到的 C++ 客户端应用程序非常相似。_tmain() 函数非常简短简单。它列在下面

int _tmain(int argc, _TCHAR* argv[])
{
  ::CoInitialize(NULL);
 
  if (1)
  {
    // ISimpleCOMObjectPtr is a smart pointer class which will manage
    // a pointer to the COM interface ISimpleCOMObject for us.

    ISimpleCOMObjectPtr spISimpleCOMObject_CSharpExeImpl = NULL;

    // We create an instance of an implementation of the ISimpleCOMObject
    // interface as provided by the COM class whose CLSID is synonymous
    // with the ProgID "SimpleCOMObject_CSharpExeImpl.SimpleCOMObject".

    CreateInstance<ISimpleCOMObjectPtr>
    (
      "SimpleCOMObject_CSharpExeImpl.SimpleCOMObject",
      spISimpleCOMObject_CSharpExeImpl,
      CLSCTX_INPROC_SERVER /* CLSCTX_LOCAL_SERVER */
    );

    if (spISimpleCOMObject_CSharpExeImpl)
    {
      spISimpleCOMObject_CSharpExeImpl -> put_LongProperty(1000);
      spISimpleCOMObject_CSharpExeImpl -> Method01
      (_bstr_t("C# Exe Implementation. The Long Property Value Is : "));
    }
  }
 
  ::CoUninitialize();
 
  return 0;
}

_tmain() 函数实例化一个包含在 ISimpleCOMObjectPtr 智能指针对象 spISimpleCOMObject_CSharpExeImpl 中的 ISimpleCOMObject 接口指针。要实例化的具体实现是由 COM coclass 提供的,其 CLSID 与 ProgID SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 同义。我们知道这个 COM coclass 是由 SimpleCOMObject_CSharpExeImpl.exe 实现的。

现在请注意,CreateInstance() 的第三个参数已设置为特定值,而不是默认值 CLSCTX_ALL。将 COM/.NET 互操作的默认值 CLSCTX_ALL 用于 COM/.NET 互操作将导致使用 CLSCTX_INPROC_SERVER CLSCTX 值,因为要实例化的 coclass 的 CLSID 键中包含 InprocServer32 子键。回想一下,对于要转换为 COM 对象的 .NET 类,此 InprocServer32 字符串值将是 mscoree.dll

现在,正如稍后将看到的:无论实现 .NET 程序集是类库 DLL 还是可执行文件,此程序集始终以进程内方式加载到客户端应用程序的地址空间中。而且,在 EXE 程序集的情况下,回想一下本节开头我们的断言,即它既不会被执行,也不会使用其运行实例来实例化所需的 .NET 类

在当前客户端应用程序中,CreateInstance() 的参数专门设置为 CLSCTX_INPROC_SERVER。使用此常量将确保 CreateInstance() 函数成功。随着程序继续执行,将显示以下消息框

现在,使用 SysInternalsProcess Explorer 工具(一个很棒的实用程序,允许我们列出正在运行的应用程序的加载模块),我们做出了以下值得注意的观察(注意用红色下划线的项目)

  1. Interop.SimpleCOMObject 互操作程序集已加载到客户端的地址空间中。
  2. SimpleCOMObject_CSharpExeImpl.exe 可执行映像已加载到客户端的地址空间中。

第二点是一个令人难以置信的现象。一个 EXE 文件被加载到另一个 EXE 的地址空间中,这显示了 .NET 令人难以置信的灵活性。

现在,让我们做一个小实验,看看如果我们将 CreateInstance() 的第三个参数使用 CLSCTX_LOCAL_SERVER 常量会发生什么。请注释掉 CLSCTX_INPROC_SERVER 常量的使用。然后,插入 CLSCTX_LOCAL_SERVER 的使用

CreateInstance<ISimpleCOMObjectPtr>
(
  "SimpleCOMObject_CSharpExeImpl.SimpleCOMObject",
  spISimpleCOMObject_CSharpExeImpl,
  /* CLSCTX_INPROC_SERVER */ CLSCTX_LOCAL_SERVER
);

当内部调用 CoCreateInstance() API 时,返回值为 0x80040154(类未注册),这导致 CreateInstance() 函数失败。此错误代码表明我们的(据称)进程外服务器 SimpleCOMObject_CSharpExeImpl.exe 未为我们的“coclass”SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 注册类工厂。

请注意,一个正常的进程外 COM EXE 服务器将寻求为其服务的每个 coclass 创建一个类工厂对象(它实现了 IClassFactory 接口),然后通过调用 CoRegisterClassObject() 注册该类工厂对象。客户端应用程序最终通过类工厂对象获取对象实例化。

然而,我们甚至没有 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 的类工厂,更不用说我们如何进行有意义的 CoRegisterClassObject() 调用了。简单地说,我们的 SimpleCOMObject_CSharpExeImpl.exe 不是一个真正的 COM EXE 服务器。它不会在任何 C# 类上调用 CoRegisterClassObject()

我们还必须记住,SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 的 CLSID 的注册表条目不包含任何 LocalServer32 子项,这意味着即使我们请求进程外服务器,COM 也无法确定为此目的启动哪个 EXE 服务器。因此,直到今天,这个返回值 0x80040154(类未注册)的含义对我来说仍然不清楚。

让我们做个小实验,看看如果我们手动向 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 的 CLSID 注册表项添加一个 LocalServer32 子项,如下所示,会发生什么情况

此子键的字符串值应该是 SimpleCOMObject_CSharpExeImpl.exe 的完整路径。

现在,再次尝试运行 CPPClient01.exe。这次,当我们执行第三个参数为 CLSCTX_LOCAL_SERVERCreateInstance() 函数时,SimpleCOMObject_CSharpExeImpl.exe 程序实际上被 COM 启动了

请注意,一个参数 "-Embedding" 实际上被传递给了程序。我们知道这是 COM 指示进程外 EXE 服务器作为 COM 服务器(即应用程序)启动的方式,因此,其 GUI 主窗口不应可见。SimpleCOMObject_CSharpExeImpl.exe 程序不是 COM 进程外 EXE 服务器,因此它不了解此协议。它只是按照 COM 的命令启动并保持运行。

在由 CreateInstance() 函数启动的调用堆栈深处,当调用 CoCreateInstance() API 时,SimpleCOMObject_CSharpExeImpl.exe 程序被调用。CoCreateInstance() 似乎长时间挂起而不返回。当它返回时,返回 0x80080005(服务器执行失败),并且 SimpleCOMObject_CSharpExeImpl.exe 自行终止。此返回值符合 COM 标准,因为逻辑上可以假设在启动本地 EXE 服务器后,COM 将等待所需协同类的类工厂通过 CoRegisterClassObject() 注册,然后从该类工厂的 IClassFactory 接口实例化所需对象。很可能在 COM 等待类工厂可用时发生超时,因此返回错误代码。

[此时,请不要忘记删除我们手动添加到 SimpleCOMObject_CSharpExeImpl.SimpleCOMObject 的 CLSID 注册表项的“LocalServer32”子项。]

我们尝试使用标准 COM/.NET 互操作将 .NET EXE 程序集用作进程外 COM 服务器的努力是徒劳的,这一点不足为奇。事实上,任何 COM .NET 程序集(无论是 EXE 还是类库 DLL)如果不是借助 mscoree.dll 和所有 COM 相关的注册表信息,甚至无法作为进程内 COM 服务器运行。

我们需要一些其他非正统的方法来模拟将 .NET EXE 程序集用作进程外 COM 服务器。我们将在下一节中开始探索这一点。

激活和反射

除了使用典型的 .NET/COM 互操作,实例化 .NET EXE 程序集内包含的 .NET 类,然后将其提供给非托管客户端的另一种方法是通过激活反射加载类类型,在托管代码中实例化它,然后将其作为 COM 对象封送到非托管客户端。

托管代码充当类工厂的角色,用于从 .NET 类创建对象,然后通过标准 COM/.NET 互操作将它们交付给非托管世界。像往常一样,我为此目的定义了一个名为 IDotNetClassFactory(在 C# 中)的接口。我们还将研究这种接口的实现,它必须用 .NET 语言开发,以便作为托管代码运行。

然而,就像使用标准 COM/.NET 互操作的技术一样,使用激活和/或反射不会导致目标 EXE 程序集执行,也不会使用其正在运行的实例来实例化所需的 .NET 类

基本思想很简单,如下图所示

在之前关于从类库程序集创建 .NET 对象以及使用标准 COM/.NET 互操作从 .NET EXE 程序集实例化对象的讨论中,我们使用了 COM API CoCreateInstance() 作为对象创建的一站式函数调用。我们无需关心目标对象背后的实现代码是真正的 COM 对象还是封装在 COM 可调用包装器中的 .NET 对象。

这次,我们必须注意,对象创建过程增加了一个新层:必须使用 .NET 类工厂来执行 .NET 对象的创建。然后,新创建的对象将返回给非托管客户端应用程序。.NET 类工厂现在介于客户端应用程序和 .NET 运行时引擎 (MSCOREE.DLL) 之间,后者仍然是主要的 COM/.NET 互操作提供程序。

操作序列如下

  1. 非托管客户端应用程序实例化 IDotNetClassFactory 接口实现。

    IDotNetClassFactory 接口最初将在 .NET 中定义。它将被注册到注册表并通过 regasm.exe 转换为 COM 接口。此接口充当非托管客户端实例化一般 .NET 对象的网关,无论它们是来自 EXE 还是类库 DLL。此接口中将至少定义两个方法,一个用于通过激活创建对象,另一个用于通过反射创建对象。

  2. IDotNetClassFactory 接口实现是一个 .NET 对象。

    我们将使用托管代码来创建其他托管代码,这将由 IDotNetClassFactory 接口实现执行。IDotNetClassFactory 接口实现将是一个 .NET 类库,它将在非托管客户端中使用。因此,将需要 COM/.NET 互操作。这就是 MSCOREE.DLL 参与的地方。

  3. 非托管客户端使用 IDotNetClassFactory 对象来实例化 EXE 程序集中的 .NET 类。

    IDotNetClassFactory 对象,作为主要生活在 .NET 世界中的托管对象,可以轻松使用 .NET 类库对象来加载 EXE 程序集并创建在该 EXE 程序集中定义的类的实例。IDotNetClassFactory 接口的两个创建方法必须都接受目标 .NET EXE 程序集的路径。在内部,每个方法分别使用 Activator 和 Reflection 类来执行创建过程。

  4. IDotNetClassFactory 对象返回新创建的 .NET 程序集类对象。

    IDotNetClassFactory 对象的每个创建方法都将把一个新创建的 .NET 程序集类对象通过 COM 可调用包装器返回给非托管客户端。每个创建方法的返回类型必须是 object。创建的对象将作为封装在 VARIANT 结构中的 COM 接口指针封送到非托管客户端。

我已将上述概念的示例与一组 Visual Studio 解决方案一起包含在内。这些项目包含在本文的源代码 zip 文件中。但是,在我们研究源代码之前,我们需要介绍实例化 .NET EXE 程序集中包含的 .NET 类的下一个方法。这是因为这种下一个技术的源代码与我们刚刚讨论的源代码是密不可分的。

.NET 远程处理

这次,我们将研究一个在激活和连接外部 EXE 程序集方面与 COM 非常相似的解决方案。我们谈论的是 .NET Remoting。基本思想与激活和反射类似,不同之处在于我们将连接到程序集的正在运行的实例。

操作序列如下

  1. 非托管客户端应用程序实例化 IDotNetClassFactory 接口实现。

    IDotNetClassFactory 接口实现将由非托管客户端代码实例化。IDotNetClassFactory 接口将包含一个单独的方法来创建 .NET 类的实例,特别是通过远程处理。

  2. 创建方法包括远程处理参数和 EXE 调用参数。

    此“通过远程处理创建”方法将包含用于促进 .NET 远程处理的参数以及用于启动目标程序集的新运行实例的参数。

  3. IDotNetClassFactory 对象可能会被请求启动程序集 EXE。

    程序集调用参数在模拟 COM 的 REGCLS_SINGLEUSEREGCLS_MULTIPLEUSE 标志方面很重要。如果 EXE 程序集启动一次,随后不再被其他客户端启动,我们模拟 REGCLS_MULTIPLEUSE。如果每次调用创建方法时都启动一个新的 EXE 运行实例,则模拟 REGCLS_SINGLEUSE。无论选择哪种方式,远程处理对象的服务器都必须处于运行状态才能建立连接。稍后当我们研究源代码时,将详细介绍这一点。

  4. IDotNetClassFactory 对象注册 TCP 和 HTTP 通道以连接远程处理对象。

    IDotNetClassFactory 对象将注册 TCP 和 HTTP 客户端通道(不带任何端口号)。远程处理对象的服务器也必须注册带有特定端口号的 TCP 和/或 HTTP 服务器通道。客户端应用程序还必须提供远程处理对象的 URL。

  5. IDotNetClassFactory 对象返回新创建的远程处理对象。

    一旦创建方法与远程处理对象建立连接,它将把一个指向该对象的代理通过 COM 可调用包装器返回给非托管客户端。此创建方法的返回类型也是 object。远程处理代理将作为封装在 VARIANT 结构中的 COM 接口指针封送到非托管客户端。

我们将从下一节开始深入研究相关源代码。

IDotNetClassFactory 系统

IDotNetClassFactory 系统相关的源代码包含在本文随附的源代码 zip 文件中。解压缩后,可以在目录 <main folder>\CSharpExeCOMServers\IDotNetClassFactory 中找到,其中 <main folder> 是您复制 zip 文件的位置。以下小节将讨论此文件夹中包含的每个独立项目

IDotNetClassFactory

此项目位于目录 <main folder>\CSharpExeCOMServers\ IDotNetClassFactory\interfaces\IDotNetClassFactory 中。

这是另一个“空”项目,旨在维护一个接口,这次是:IDotNetClassFactory 接口。此接口如下所示

public interface IDotNetClassFactory
{
  object CreateInstance_ByActivation
  (
    string strAssemblyName,
    string strTypeName
  );

  object CreateInstance_ByReflection
  (
    string strAssemblyName,
    string strTypeName
  );

  object CreateInstance_ByRemoting
  (
    string strAssemblyName,
    string strTypeName,
    string strURL,
    bool bExecute,
    string strAssemblyFullPath,
    string strArgumentString
  );
}

此接口中定义了三个方法。它们的功能应该不言自明。我们之前已经讨论过这些。以下是我对这些方法的一些评论

  • 与 COM API 的等效性

    这些方法与 COM 的 CoCreateInstance() API 以及我们的模板 CreateInstance() 函数之间存在等效性。选择要实例化的特定 COM 类的概念持续存在(回想 ProgID 和 CLSID)。这通过使用程序集名称 (strAssemblyName) 和类型名称 (strTypeName) 参数来实现。

    回想之前的讨论,.NET 类型通过其名称及其包含的程序集来标识。因此,我们可以将使用这两个参数视为选择适当的 .NET 类进行实例化的关键。

  • 缺点

    这些方法的缺点是返回的接口指针是 IUnknownIDispatch 接口指针(取决于 .NET 类的定义和属性)。我们必须专门将其 QueryInterface() 到所需的接口。这必须由客户端应用程序手动完成,而 CoCreateInstance() 将代表其调用者执行此操作。

  • 所有三个方法的通用参数 strAssemblyName

    所有三个方法的共同参数是 strAssemblyNamestrTypeName。请注意,CreateInstance_ByActivation()CreateInstance_ByReflection() 方法的 strAssemblyName 参数必须是加载 EXE 程序集的路径。但是,CreateInstance_ByRemoting() 方法的 strAssemblyName 参数只是一个程序集名称(不带 .exe 扩展名)。我们稍后将探讨这种差异。

  • CreateInstance_ByRemoting() 方法的特殊参数

    CreateInstance_ByRemoting() 方法还带四个额外参数。strURL 参数用于保存唯一标识由远程处理服务器公开的 .NET 类类型的 URL 字符串。bExecute 参数用于指示 IDotNetClassFactory 接口实现是否启动目标 EXE 程序集。strAssemblyFullPath 参数指示要启动的 .NET EXE 程序集的完整路径(如果 bExecutetrue),而 strArgumentString 参数是要传递给要启动的程序集的参数。

    请注意我们之前关于第一个参数 strAssemblyName 的观点,即与其他两个创建方法不同,它必须只包含包含要实例化类的程序集的纯名称(不带任何扩展名)。这就是为什么我们必须提供 strAssemblyFullPath 参数,因为我们不能使用 strAssemblyName 来帮助我们启动目标程序集。

IDotNetClassFactory 项目也已强命名,并且应该注册到注册表(包含 COM 相关信息)并插入到 GAC 中。我准备了一个批处理文件 RegisterAssemblyToRegistryAndGAC.bat(包含在项目文件夹中),它将执行此操作。请在成功编译项目后运行此批处理文件。

注册表注册过程由 regasm.exe 工具完成,该工具还将帮助生成一个 .TLB(类型库)文件,客户端应用程序可以导入该文件以定义 IDotNetClassFactory 接口的 COM 解释。GAC 注册是必要的,因为输出的 IDotNetClassFactory 类库将在运行时成为依赖程序集,因此我们必须确保它是一个共享资源。

IDotNetClassFactory_Impl01

此项目包含在目录 <main folder>\CSharpExeCOMServers\ IDotNetClassFactory\implementations\IDotNetClassFactory_Impl01 中。

此项目是一个类库项目。它提供了一个名为 IDotNetClassFactory_Impl01 的类,它是 IDotNetClassFactory 接口的实现。让我们看一下

public object CreateInstance_ByActivation
(
  string strAssemblyName,
  string strTypeName
)
{
  ObjectHandle object_handle = null;
  object objRet = null;

  // During the following call to Activator.CreateInstance(),

  // the constructor of the class that implements the type

  // indicated in strTypeName will be invoked.

  object_handle = Activator.CreateInstanceFrom
  (
    strAssemblyName,
    strTypeName
  );

  // Unwrap the delivered object and cast it

  // to the "object" type.

  objRet = (object)(object_handle.Unwrap());

  return objRet;
}

上面列出了 CreateInstance_ByActivation() 方法的完整源代码。如您所见,它非常简单。我们只需使用 Activator 类的静态方法 CreateInstanceFrom() 从目标程序集文件创建所需类型的实例。然后,解包返回的 ObjectHandle 值,将其转换为 object 类型实体,然后返回。

public object CreateInstance_ByReflection
(
  string strAssemblyName,
  string strTypeName
)
{
  Assembly assembly;
  object   objRet = null;

  assembly = Assembly.LoadFrom(strAssemblyName);
 
  // During the following call to Activator.CreateInstance(),

  // the constructor of the class that implements the type

  // indicated in strTypeName will be invoked.

  objRet = assembly.CreateInstance
  (
    strTypeName
  );

  return objRet;
}

上面列出了 CreateInstance_ByReflection() 方法的完整源代码。它也非常简单。Assembly 类用于加载目标程序集(通过静态 LoadFrom() 方法)。然后,加载的程序集由 assembly 对象(类型 Assembly)表示。此后,我们创建目标类型 (strTypeName) 的实例并返回它。

public  object CreateInstance_ByRemoting
(
  string strAssemblyName,
  string strTypeName,
  string strURL,
  bool bExecute,
  string strAssemblyFullPath,
  string strArgumentString
)
{
  UrlAttribute[] attr = { new UrlAttribute(strURL) };
  ObjectHandle  object_handle = null;
  object  objRet = null;

  if (bExecute)
  {
    Process.Start
    (strAssemblyFullPath, strArgumentString);
  }

  // Create a client tcp channel from which

  // to receive the Remote Object.

  TcpClientChannel tcp_channel = new TcpClientChannel();
  // Register the channel.

  ChannelServices.RegisterChannel(tcp_channel);
  // Create a client http channel from which

  // to receive the Remote Object.

  HttpClientChannel http_channel = new HttpClientChannel();
  // Register the channel.

  ChannelServices.RegisterChannel(http_channel);

  // During the following call to Activator.CreateInstance(),

  // the constructor of the class that implements the type

  // indicated in strTypeName will be invoked.

  object_handle = Activator.CreateInstance
  (
    strAssemblyName,
    strTypeName,
    attr
  );

  // Unwrap the delivered object and cast it

  // to the "object" type.

  objRet = (object)(object_handle.Unwrap());

  return objRet;
}

上面列出了 CreateInstance_ByRemoting() 方法的完整源代码。与前两个创建方法相比,此方法稍微复杂一些。请注意,IDotNetClassFactory_Impl01CreateInstance_ByRemoting() 的实现专门处理客户端激活的远程处理对象。

使用传入的 strURL 定义了一个单元素 UrlAttribute 数组对象 attr。此 attr 对象将在稍后的 Activator.CreateInstance() 静态方法中使用。

关于客户端激活的远程处理对象的一些说明

客户端激活的远程处理对象是真正的远程对象。远程意味着它们在外部服务器进程上实例化,而不是在客户端。一旦创建,它们是私有的离散对象,正常存活直到客户端不再需要引用它们,之后对象通过垃圾回收器正常销毁(通过一个称为租约分布式垃圾回收的过程)。每个创建的对象不共享给客户端。

要创建客户端激活的远程对象,我们使用 Activator.CreateInstance() 方法的一个版本,其中可以传入激活属性。MSDN 文档将此类激活属性描述为:“一个或多个可以参与激活的属性数组”。

通过 Activator.CreateInstance() 实例化的此远程对象将保持活动状态,直到租约时间到期(从而导致垃圾回收发生并因此销毁远程对象)。

关于客户端激活的远程对象创建,另一点需要注意的是,从 CreateInstance() 调用返回一个 ObjectHandle。MSDN 文档将 ObjectHandle 最佳描述为“...一个由远程处理生命周期服务跟踪的远程 MarshalByRefObject...”

最后,从 CreateInstance() 方法返回的 ObjectHandle 对象必须被解包以检索其中包含的实际远程对象。实际上,其中包含的远程对象仍然是外部服务器进程上创建的真实远程对象的代理。

现在,如果 bExecutetrue,我们使用 Process.Start() 静态方法启动目标程序集,该程序集将用作远程处理服务器。

然后,我们实例化并注册 TcpClientChannelHttpClientChannel 通道,以便能够连接到使用 TcpServerChannelHttpServerChannel 或两者兼有的正在运行的远程处理服务器。

然后我们使用 Activator.CreateInstance() 静态方法连接到远程处理对象服务器,该服务器公开一个其标识符在 strURL 参数中指定的对象。strURL URL 字符串还必须指定通道协议(TCP 或 HTTP)以及目标远程处理服务器的 TCP 或 HTTP 通道使用的端口号。

如果对 CreateInstance() 的调用成功,将返回一个 ObjectHandle 对象,我们通过 ObjectHandleUnwrap() 方法从中提取一个 object 类型实体。然后返回提取的“object”实体。

IDotNetClassFactory_Impl01 经过强命名,应注册到注册表(包含 COM 信息)并插入到 GAC 中。我准备了一个批处理文件 RegisterAssemblyToRegistryAndGAC.bat(包含在项目文件夹中),它将执行此操作。请在成功编译项目后运行此批处理文件。

注册表注册过程并非严格要求。这是因为我们的非托管客户端不会实例化 IDotNetClassFactory_Impl01 接口实现。相反,客户端应用程序将实例化 IDotNetClassFactory 接口实现,因此客户端源需要导入 IDotNetClassFactory.tlb 类型库。

GAC 注册是必要的,因为输出的 IDotNetClassFactory 类库将在运行时成为依赖程序集,因此我们必须确保它是一个共享资源。

测试 IDotNetClassFactory 系统

我们现在将对我们的 IDotNetClassFactory 系统进行测试。为此,我准备了一个客户端应用程序,它将使用 IDotNetClassFactory 接口实现的方法。我还编写了一个测试 .NET EXE 应用程序。此应用程序将用作测试用例。

测试用例应用程序

为了加快对客户端应用程序的理解,我们首先研究测试用例 .NET EXE 应用程序的源代码。测试用例是 C# 编写的 Visual Studio .NET 项目的集合。以下小节将提供更多详细信息

ITestCSharpObjectInterfaces

这包含在目录 <main folder>\CSharpExeCOMServers\ IDotNetClassFactory\testobjects\interface\ITestCSharpObjectInterfaces 中。

它是一个“空”类库项目,用于定义 ITestCSharpObjectInterfaces 接口。我定义了一个接口,以便始终遵循我们运行时将纯接口引用绑定到实际实现的原则。也就是说,我们的客户端代码必须使用 ITestCSharpObjectInterfaces 接口的方法,而无需明确知道它将在运行时加载哪个实际 .NET 程序集。

此接口如下所示

public interface ITestCSharpObjectInterfaces
{
  string stringProperty
  {
    get;
    set;
  }

  bool DisplayMessage();
}

我将其保持非常简单以阐明原理。此接口定义了一个 stringProperty 字符串属性,客户端可以设置/获取。DisplayMessage() 方法用于在消息框中显示 stringProperty 字符串。

ITestCSharpObjectInterfaces 项目也已强命名,并且需要注册到注册表(COM 注册)并插入到 GAC 中。我们的客户端应用程序将需要导入为此程序集生成的类型库(即 ITestCSharpObjectInterfaces.tlb)。输出的 ITestCSharpObjectInterfaces.dll 类库程序集也需要注册到 GAC 中,使其成为共享资源,因为它可能会在运行时被多个依赖程序集加载。

ITestCSharpObjectInterfacesImpl01

ITestCSharpObjectInterfacesImpl01 项目位于目录 <main folder>\CSharpExeCOMServers\IDotNetClassFactory\testobjects\ implementations\ITestCSharpObjectInterfacesImpl01 中。

它是一个 EXE 程序集项目,它提供了 ITestCSharpObjectInterfaces 接口的实现。这由 TestCSharpObject01 类提供。此类的片段如下所示

public class TestCSharpObject01 : MarshalByRefObject,
       ITestCSharpObjectInterfaces.ITestCSharpObjectInterfaces
{
  private string m_stringProperty;

  public TestCSharpObject01()
  {
    m_stringProperty = "";
    Console.WriteLine("TestCSharpObject01 constructor.");
  }

  public string stringProperty
  {
    get
    {
      return m_stringProperty;
    }

    set
    {
      m_stringProperty = value;
    }
  }

  public bool DisplayMessage (  )
  {
    if (m_stringProperty != "")
    {
      MessageBox.Show(m_stringProperty, "TestCSharpObject01");
      return true;
    }
    else
    {
      return false;
    }
  }
  ...
  ...
  ...

TestCSharpObject01 类被指定为从 MarshalByRefObject 派生的 public 类。MarshalByRefObject 是可以由远程客户端创建,然后跨应用程序域边界传输到远程客户端的对象的基类。换句话说,我们已将 TestCSharpObject01 类指定为可用于 .NET 远程处理。

远程客户端与 MarshalByRefObject 之间的通信通过使用代理交换消息来实现。正是代理使对象“按引用封送”。通过“按引用封送”,远程服务器对象对客户端应用程序来说变得“有状态”。当(远程)客户端应用程序第一次访问 MarshalByRefObject 时,代理被传递给远程客户端应用程序。

客户端使用此代理对远程对象进行方法调用。这些方法调用及其参数实际上跨应用程序域边界封送到远程服务器对象本身。其中大部分都让人想起 COM 调用远程服务器函数的方式。

TestCSharpObject01 也派生自 ITestCSharpObjectInterfaces,这使其成为 ITestCSharpObjectInterfaces 接口的实现者。

实现很简单,不言自明。但值得一提的是,每当创建 TestCSharpObject01 类的一个实例时,字符串“TestCSharpObject01 constructor.”就会打印到控制台输出。此功能将有助于稍后阐明一些重要点。

ITestCSharpObjectInterfacesImpl01Main() 函数是 .NET 远程处理发生作用的地方。它如下所示

static void Main(string[] args)
{
  string strChannelType = "TCP";
  int iPort = 9000;

  if (args.Length > 0)
  {
    for (int i = 0; i < args.Length; i++)
    {
      Console.Write("Argument : ");
      Console.WriteLine(args[i]);
    }

    strChannelType = args[0];
    iPort = Convert.ToInt32(args[1]);
  }

  if (strChannelType == "TCP")
  {
    TcpServerChannel tcp_channel = new TcpServerChannel(iPort);
    ChannelServices.RegisterChannel(tcp_channel);
  }
  else
  {
    HttpServerChannel http_channel = new HttpServerChannel(iPort);
    ChannelServices.RegisterChannel(http_channel);
  }

  ActivatedServiceTypeEntry remObj =
    new ActivatedServiceTypeEntry(typeof(TestCSharpObject01));

  string strApplicationName = "TestCSharpObject01";

  RemotingConfiguration.ApplicationName = strApplicationName;
  RemotingConfiguration.RegisterActivatedServiceType(remObj);

  Console.WriteLine("Press [ENTER] to exit.");

  Console.ReadLine();
}

ITestCSharpObjectInterfacesImpl01 应用程序接受个或两个参数。第一个参数指示要注册的服务器通道类型。这可以是 "TCP""HTTP",分别表示 TCP 和 HTTP 通道。第二个参数指示服务器通道要使用的端口号。如果未使用任何参数,则默认情况下将使用端口号为 9000TCP 服务器通道。

然后,将创建指定的服务器通道并将其与指定的端口号一起注册。然后,我们创建一个 ActivatedServiceTypeEntry 对象,在构造函数中指定要公开的远程对象的类型,然后通过 RemotingConfiguration.RegisterActivatedServiceType() 方法调用对其进行注册。

我们还通过 RemotingConfiguration.ApplicationName 属性指定远程对象的 URI。在我们的服务器中,此 URI 设置为 "TestCSharpObject01"。从现在开始,名称 "TestCSharpObject01" 将与类类型 TestCSharpObject01 相关联。

WriteLine()ReadLine() API 调用旨在确保 ITestCSharpObjectInterfacesImpl01 应用程序(将充当远程处理服务器)保持活动状态,直到有人按下 Enter 键。

请注意应用程序两个参数的意义。指定通道服务器类型和端口号可以帮助确保不注册重复的通道类型加端口号。这将导致抛出异常。例如,我们不能两次注册端口为 9000 的 TCP 通道。这是 ITestCSharpObjectInterfacesImpl01 远程处理服务器的一个有用功能。事实上,任何参与 IDotNetClassFactory 系统的远程处理服务器都应该意识到可能被启动多次。由于每次启动时都必须注册 TCP 或 HTTP 通道,因此确保不注册重复的通道类型和端口号非常重要。

ITestCSharpObjectInterfacesImpl01 应用程序一旦编译,无需注册到注册表或 GAC。需要的是它可用于客户端应用程序进行运行时类型加载和启动。请注意这个重要点:.NET 远程处理的性质决定了在运行时,客户端必须提供包含远程处理公开类型定义的程序集文件的副本。

因此,ITestCSharpObjectInterfacesImpl01.exe 的副本必须与任何客户端应用程序位于同一路径。有关此内容的更多信息,我们将在下一节中研究我们的客户端应用程序时进行讨论。

测试客户端应用程序 (CPPClient01)

此项目包含在目录 <main folder>\CSharpExeCOMServers\IDotNetClassFactory\clients\CPPClient01 中。

此项目是一个 C++ 控制台应用程序。在此应用程序中,我试图演示 .NET 对象创建的三种技术。这总结在 _tmain() 函数中

int _tmain(int argc, _TCHAR* argv[])
{
    ::CoInitialize(NULL);
   
    if (1)
    {
      IDotNetClassFactoryPtr spIDotNetClassFactory;
     
      // First instantiate a IDotNetClassFactory implementation.

      CreateInstance
      (
        "IDotNetClassFactory_Impl01.IDotNetClassFactory_Impl01",
        spIDotNetClassFactory
      );
     
      // Demonstrate the creation of a .NET object via

      // IDotNetClassFactory.CreateInstance_ByActivation().

      Demonstrate_CreationInstance_ByActivation
      (spIDotNetClassFactory);     
 
      // Demonstrate the creation of a .NET object via

      // IDotNetClassFactory.CreateInstance_ByReflection().

      Demonstrate_CreationInstance_ByReflection
      (spIDotNetClassFactory);     
 
      // Demonstrate the creation of a .NET object via

      // IDotNetClassFactory.CreateInstance_ByRemoting().

      Demonstrate_CreationInstance_ByRemoting
      (spIDotNetClassFactory);           
    }
   
    ::CoUninitialize();
   
    return 0;
}

我们首先定义一个 IDotNetClassFactoryPtr 类型的智能指针对象,一旦我们在 CPPClient01.cpp 源文件开头 #import 了 IDotNetClassFactory.tlb 类型库,该对象就会被定义

#import "IDotNetClassFactory.tlb" raw_interfaces_only

然后,我们使用模板化的 CreateInstance() 函数来实例化一个 IDotNetClassFactory 接口,该接口由 CLSID 由 ProgID IDotNetClassFactory_Impl01.IDotNetClassFactory_Impl01 表示的协同类实现。

然后,我们调用三个演示函数,每个函数演示了对 IDotNetClassFactory 接口的三个创建方法的单独调用。让我们依次检查每个函数

void Demonstrate_CreationInstance_ByActivation
(
  IDotNetClassFactoryPtr& spIDotNetClassFactory
)
{
  _bstr_t _bstrAssemblyName
  (
  "... bin\\Debug\\ITestCSharpObjectInterfacesImpl01.exe"
  );
  _bstr_t _bstrTypeName
  (
    "ITestCSharpObjectInterfacesImpl01.TestCSharpObject01"
  );
  VARIANT varRet;
  ITestCSharpObjectInterfacesPtr spITestCSharpObjectInterfaces = NULL;
 
  VariantInit(&varRet);
  VariantClear(&varRet);
 
  spIDotNetClassFactory -> CreateInstance_ByActivation
  (
    (BSTR)_bstrAssemblyName,
    (BSTR)_bstrTypeName,
    &varRet
  );
 
  if ((V_VT(&varRet) != VT_EMPTY) && (V_UNKNOWN(&varRet) != NULL))
  {
    V_UNKNOWN(&varRet) -> QueryInterface
    (
      __uuidof(ITestCSharpObjectInterfacesPtr),
      (void**)&spITestCSharpObjectInterfaces
    );
  }
 
  if (spITestCSharpObjectInterfaces)
  {
    spITestCSharpObjectInterfaces -> put_stringProperty
    (
      _bstr_t
      (
        "...Created using IDotNetClassFactory.CreateInstance_ByActivation()"
      )
    );
    spITestCSharpObjectInterfaces -> DisplayMessage();
  }

  return;
}

第一个演示函数是 Demonstrate_CreationInstance_ByActivation(),它使用传入的 spIDotNetClassFactory 智能指针对象创建 ITestCSharpObjectInterfaces 接口实现的实例,然后调用其属性和方法。

首先,请注意,ITestCSharpObjectInterfaces 接口在我们的客户端项目中定义,因为我们已在 CPPClient01.cpp 源文件的早期 #import 了“TestCSharpObjectInterfaces.tlb”类型库文件

#import "TestCSharpObjectInterfaces.tlb"

智能指针类 ITestCSharpObjectInterfacesPtr 也因我们的 #import 而定义。

现在请注意我们如何调用 CreateInstance_ByActivation() 方法。我们传入 ITestCSharpObjectInterfacesImpl01.exe 文件的路径,该路径是相对于当前客户端应用程序项目文件夹的(请参阅实际源文件中设置的路径 - 代码片段中显示的路径由于其长度而被截断)。请注意,我的相对路径仅在您未修改我的源代码中包含的任何源文件文件夹且正在进行调试时才有效。如果相对路径不适合您,请将其修改为完整的绝对路径。

第二个参数包含类型名称“ITestCSharpObjectInterfacesImpl01.TestCSharpObject01”,它定义在上述 EXE 程序集中。请注意,我们没有使用 ITestCSharpObjectInterfacesImpl01.exe 应用程序中编程的任何远程处理功能。我们只是实例化在该程序集中定义的 .NET 类。

一旦 CreateInstance_ByActivation() 方法调用成功,一个 IUnknown(或 IDispatch)接口指针将在“out”参数“varRet”中返回。然后我们对返回的 IUnknown 接口指针执行 QueryInterface() 调用,以获取一个 ITestCSharpObjectInterfaces 接口指针。

此后,设置字符串属性和调用此接口的 DisplayMessage() 方法是直截了当的。以下消息框应该出现

接下来我们将研究 Demonstrate_CreationInstance_ByReflection() 函数

void Demonstrate_CreationInstance_ByReflection
    (IDotNetClassFactoryPtr& spIDotNetClassFactory)
{
  _bstr_t _bstrAssemblyName
  ("... ITestCSharpObjectInterfacesImpl01.exe"); 
  _bstr_t _bstrTypeName
  ("ITestCSharpObjectInterfacesImpl01.TestCSharpObject01");
  VARIANT varRet;
  ITestCSharpObjectInterfacesPtr spITestCSharpObjectInterfaces = NULL;
 
  VariantInit(&varRet);
  VariantClear(&varRet);
 
  spIDotNetClassFactory -> CreateInstance_ByReflection
  (
    (BSTR)_bstrAssemblyName,
    (BSTR)_bstrTypeName,
    &varRet
  );
 
  if ((V_VT(&varRet) != VT_EMPTY) && (V_UNKNOWN(&varRet) != NULL))
  {
    V_UNKNOWN(&varRet) -> QueryInterface
    (
      __uuidof(ITestCSharpObjectInterfacesPtr),
      (void**)&spITestCSharpObjectInterfaces
    );
  }
 
  if (spITestCSharpObjectInterfaces)
  {
    spITestCSharpObjectInterfaces -> put_stringProperty
    (
      _bstr_t
      (
        "... Created using IDotNetClassFactory.CreateInstance_ByReflection()"
      )
    );
    spITestCSharpObjectInterfaces -> DisplayMessage();
  }

  return;
}

此函数几乎与 Demonstrate_CreationInstance_ByActivation() 函数相同,除了进行了一些细微更改,其中包括调用 CreateInstance_ByReflection() 方法(而不是调用 CreateInstance_ByActivation()),并且字符串属性设置使得指示调用 CreateInstance_ByReflection()

将显示以下消息框

Demonstrate_CreationInstance_ByActivation()Demonstrate_CreationInstance_ByReflection() 调用中,目标程序集将被加载到客户端应用程序的内存空间中,就好像它们是类库一样,尽管它们实际上是 .NET EXE 程序集。

在这两种情况下,我们都没有使用 ITestCSharpObjectInterfacesImpl01.exe 应用程序中编程的任何远程处理功能。我们只是实例化了此程序集中定义的一个公共 .NET 类。

当我们接下来研究对 Demonstrate_CreationInstance_ByRemoting() 的函数调用时,我们将看到目标 EXE 程序集被启动,或者看到其正在运行的实例被使用。

void Demonstrate_CreationInstance_ByRemoting
(
  IDotNetClassFactoryPtr& spIDotNetClassFactory
)
{
  _bstr_t _bstrAssemblyName("ITestCSharpObjectInterfacesImpl01");
  _bstr_t _bstrTypeName("ITestCSharpObjectInterfacesImpl01.TestCSharpObject01");
  _bstr_t _bstrURL("tcp://:7000/TestCSharpObject01");
  _bstr_t _bstrAssemblyFullPath("... ITestCSharpObjectInterfacesImpl01.exe");
  _bstr_t _bstrArgumentString("TCP 7000");
  VARIANT varRet;
  ITestCSharpObjectInterfacesPtr spITestCSharpObjectInterfaces = NULL;
 
  VariantInit(&varRet);
  VariantClear(&varRet);
 
  spIDotNetClassFactory -> CreateInstance_ByRemoting
  (
    (BSTR)_bstrAssemblyName,
    (BSTR)_bstrTypeName,
    (BSTR)_bstrURL,
    VARIANT_TRUE,
    (BSTR)_bstrAssemblyFullPath,
    (BSTR)_bstrArgumentString,
    &varRet
  );
 
  if ((V_VT(&varRet) != VT_EMPTY) && (V_UNKNOWN(&varRet) != NULL))
  {
    V_UNKNOWN(&varRet) -> QueryInterface
    (
      __uuidof(ITestCSharpObjectInterfacesPtr),
      (void**)&spITestCSharpObjectInterfaces
    );
  }
 
  if (spITestCSharpObjectInterfaces)
  {
    spITestCSharpObjectInterfaces -> put_stringProperty
    (_bstr_t("... Created using IDotNetClassFactory.CreateInstance_ByRemoting()"));
    spITestCSharpObjectInterfaces -> DisplayMessage();
  }

  return;
}

Demonstrate_CreationInstance_ByRemoting() 调用像往常一样稍微复杂一些。以下是所涉步骤的总结

  1. 程序集 ITestCSharpObjectInterfacesImpl01.exe 必须复制到与客户端应用程序相同的路径。

    这是远程处理客户端的要求。程序集文件必须包含在与客户端应用程序相同的路径中。.NET 运行时将临时加载它(大概是为了检查远程处理服务器公开的类型)。为了确保其可用性,我特意保留了客户端应用程序的“Debug”文件夹,并在其中存储了 ITestCSharpObjectInterfacesImpl01.exe 的副本。如果您稍后编译客户端的“Release”版本,或者如果您对 ITestCSharpObjectInterfacesImpl01 项目进行任何更改,请记住将 ITestCSharpObjectInterfacesImpl01.exe 复制到 CPPClient01 的相应输出目录。

  2. _bstrAssemblyName 变量设置为“ITestCSharpObjectInterfacesImpl01”。

    这不应设置为 EXE 程序集的路径。相反,应指定不带扩展名的程序集文件名(其中包含我们希望通过远程处理访问的类类型的定义)。

  3. 是否启动远程处理服务器的决定将影响 _bstrURL_bstrArgumentString 参数。

    请注意,是否将第四个参数 (bExecute) 设置为 VARIANT_TRUEVARIANT_FALSE 将对 _bstrURL_bstrArgumentString 参数产生重大影响。

    如果我们启动远程处理服务器 ITestCSharpObjectInterfacesImpl01.exe 的新运行实例,则远程处理服务器的至少一个实例必须已经运行。此外,_bstrURL 必须设置为目标远程处理服务器使用的端口号。此外,_bstrAssemblyFullPath_bstrArgumentString 参数不相关,可以设置为空字符串。

    如果我们将启动远程处理服务器的新运行实例,则不仅 _bstrAssemblyFullPath 必须设置为 ITestCSharpObjectInterfacesImpl01.exe 的正确路径,而且 _bstrArgumentString 必须设置为指示所需的通道类型(“TCP 或 HTTP”)以及更重要的是一个唯一的端口号。_bstrURL 必须设置为反映所选通道类型和唯一端口号。

让我们以想要启动远程处理服务器 ITestCSharpObjectInterfacesImpl01.exe 的新运行实例为例;那么我们需要将 bExecute 设置为 VARIANT_TRUE,选择一个通道类型,并给出一个唯一的端口号。

我们将使用 TCP 通道类型(没有特别的原因),首先,我们将使用唯一的端口号 7000。这通过将 _bstrArgumentString 变量设置为字符串值 "TCP 7000" 来实现。

此配置与您在 Demonstrate_CreationInstance_ByRemoting() 函数的源代码中找到的完全一致。

运行时,远程处理服务器 ITestCSharpObjectInterfacesImpl01.exe 将被启动

第一个参数“TCP”(通道类型)及其第二个参数“7000”(端口号)将打印到控制台。特殊字符串“TestCSharpObject01 constructor.”由 TestCSharpObject01 类在其构造阶段打印。

将显示以下消息框

现在我们有了一个正在运行的 ITestCSharpObjectInterfacesImpl01.exe 实例,让我们尝试连接到这个正在运行的实例,而不是启动一个新的实例。我们需要将 bExecute 设置为 VARIANT_FALSE,并使用现有的 TCP 通道类型和现有的端口号 7000。再运行一次测试客户端应用程序。

当调用 CreateInstance_ByRemoting() 时,会打印另一行“TestCSharpObject01 constructor.

现在我们已经成功测试了重复使用端口 7000 的 ITestCSharpObjectInterfacesImpl01.exe 运行实例,现在让我们尝试运行一个新的远程处理服务器实例。

为此,我们必须将 bExecute 设置为 VARIANT_TRUE,使用现有的 TCP 通道类型,并通过将 _bstrArgumentString 变量设置为字符串值 "TCP 6000" 将端口号更改6000(或除 7000 之外的任何其他数字)。

_bstrURL 字符串也必须更改为类似:"tcp://:6000/TestCSharpObject01"

现在运行测试客户端时,将启动 ITestCSharpObjectInterfacesImpl01.exe 的新运行实例

请注意,将实例化此新实例的 TestCSharpObject01 类。我们怎么知道?“TestCSharpObject01 constructor.”行打印在新运行实例的控制台窗口中。

关于 IDotNetClassFactory 系统的一些最后的话

IDotNetClassFactory 系统提供的 CreateInstance_ByRemoting() 方法实际上是激活 .NET EXE 程序集作为 COM EXE 服务器的简单机制。它绝不完美。尽管远程对象生命周期管理由固有的 .NET 远程处理系统处理,但没有系统可以一旦服务器的所有对象都被垃圾回收就启动自动服务器终止。这仍然是特定客户端和特定远程处理服务器的责任。

另请注意,远程处理通道类型和端口号的复杂安排严格是测试客户端和测试 EXE 远程处理服务器之间的事情。IDotNetClassFactory 系统充当经纪人,不了解其间发生的协议。这种必要的设计点使 IDotNetClassFactory 系统保持简洁、简单和健壮。

完全在托管代码中实现的 COM EXE 服务器

本节是 CodeProject 会员 mav.northwind 和我本人的研究开发成果,最初受到 Aaron Queenan 的启发(请参阅下方 FAQ 部分中他于 2005 年 1 月 4 日发表的评论,标题为“您可以在 .NET 托管代码中完全构建 COM 本地服务器”)。

Aaron 主要建议通过满足 COM 规范规定的所有要求,将 .NET EXE 程序集完全包装成 COM 本地服务器。

这包括但不限于以下要求

  • 为每个我们要导出到 COM 的 .NET 类提供一个类工厂类(实现 COM IClassFactory 接口)。
  • 正确插入 EXE 程序集的注册表条目,包括 LocalServer32 键。
  • 在这些工厂类上适当地调用 CoRegisterClassObject()CoRevokeClassObject()

我必须承认,这乍一看似乎是一项艰巨的工作,直到 mav.northwind(一个好伙伴!)给我发了他以前做过的一些启动代码。经过几天的协作工作,我们得出了一个最终解决方案。我将在本节中介绍我们实验工作的丰硕成果。

重要提示

请注意,此处提供的信息绝非权威。mav.northwind 和我正在阐述一个已证明可行但仅限于我们对其进行测试的用例的想法。其有效性在很大程度上仍基于传闻证据。

此解决方案存在已知问题,包括对本地服务器公开的对象的单元模型控制,这似乎与非托管对象不同。随着我们使用它的增加,其他意想不到的问题也可能浮出水面。

我们呼吁所有读者和经验丰富的开发人员严格测试我们的工作并向我们反馈任何意见。谢谢!

源代码

我提供了一个纯托管代码运行的 .NET EXE 服务器的示例实现。其源代码包含在 <main folder>\CSharpExeCOMServers\ManagedCOMLocalServer 中。

在此目录中,我们有两个项目位于以下子文件夹中

  • .\implementations\ManagedCOMLocalServer_Impl01
  • .\clients\CPPClient01

请注意,这两个项目都引用了最初在 SimpleCOMObject 项目中定义的类型。ManagedCOMLocalServer_Impl01 将需要 Interop.SimpleCOMObject.dll 主互操作程序集,而 CPPClient01 将需要 SimpleCOMObject.tlb。因此,请确保 SimpleCOMObject.sln 已成功编译,并且 CreateAndRegisterPrimaryInteropAssembly.bat 已被调用。

现在让我们开始研究 ManagedCOMLocalServer_Impl01 解决方案。

ManagedCOMLocalServer_Impl01

ManagedCOMLocalServer_Impl01 解决方案是一个 C# 项目,它提供了 ISimpleCOMObject 接口的实现。除此之外,ManagedCOMLocalServer_Impl01 还是 COM 进程外或本地服务器的 C# 实现。这是通过尝试满足 COM 规范规定的此类服务器的所有要求来实现的。

以下小节将解释源代码的相关部分以及为实现此目标所采取的必要步骤。

DllImportAttribute 的多次使用

为了开发一个可以根据 COM 规则充当 COM 本地服务器的 C# 应用程序,我们必须使用经过验证的 COM/Win32 API 以及 COM 开发的核心代码结构。

其中包括 CoRegisterClassObject()CoResumeClassObjects()CoRevokeClassObject() 以及消息循环。

为了使用这些 API,我们需要 DllImportAttribute。此外,其中一些 API 需要 Win32 结构作为参数;因此,这些结构已在我们的解决方案中定义。

这些结构也用 ComVisibleAttribute 标记(其 bool Visibility 构造函数参数设置为 false)。这将确保当我们对输出的 ManagedCOMLocalServer_Impl01.exe 调用 regasm(带 /tlb 标志)时,这些结构将不会包含在输出类型库文件 ManagedCOMLocalServer_Impl01.tlb 中。类型库中存在这些结构可能会在 #import 到 VC++ 项目时导致重新定义问题。

类工厂

类工厂的概念在 COM 本地服务器中尤为重要。类工厂对象必须实现 IClassFactory 接口。每个 COM 类(协同类)都需要自己的类工厂对象用于实例化。

IClassFactory::CreateInstance() 方法是创建特定 CLSID 的 COM 对象的入口点。此接口的另一个方法 IClassFactory::LockServer() 用于在客户端继续使用其类工厂创建对象时保持本地服务器运行。保持本地服务器“锁定”可避免服务器应用程序不断启动和终止的可能不希望出现的情况。我们将在稍后的部分中研究本地服务器生命周期管理。

在我们的 ManagedCOMLocalServer_Impl01 示例代码中,我们定义了一个名为 ClassFactoryBase 的基类,用于封装 COM 类工厂所需的功能。稍后将在其自己的子节中详细阐述。

类工厂的单元模型

与从进程内(DLL)服务器导出的 COM 对象不同,从本地服务器导出的 COM 对象的单元模型不基于任何注册表设置。相反,它基于其类工厂对象的单元模型。而类工厂对象的单元模型反过来又取决于创建它的线程使用的单元模型。这是非托管 COM 本地服务器使用的常见协议。

对于托管 COM 本地服务器,情况似乎有所不同。我们的类工厂对象将在服务器应用程序的主线程中创建。因此,乍一看,我们似乎应该主动设置此主线程使用的单元模型。

有几个 .NET 结构可以帮助我们影响主线程的线程模型:STAThreadAttributeMTAThreadAttributeThread 类的 ApartmentState 属性。请参阅 MSDN 以获取这些技术的参考。除此之外,我们可能还想使用可靠的 CoInitializeEx() API。

然而,这些结构似乎对主线程的单元模型没有任何影响。它也对类工厂对象或我们稍后创建的最终对象的单元模型没有任何影响。

我观察到的是,主线程、类工厂和工厂创建的对象似乎都生活在 MTA 中。尽管我们的 ManagedCOMLocalServer_Impl01 代码包含在 Main() 函数中使用 STAThreadAttribute,但情况似乎仍然如此。调用 CoInitializeEx()(参数 COINIT_APARTMENTTHREADED)也没有奏效。

我目前的理论是 STAThreadAttributeMTAThreadAttributeCoInitializeEx() API 仅对托管应用程序中使用的 COM 对象有用。也就是说,由于 COM 对象本质上需要存在于一个单元中,CLR 为它们创建了这样的环境结构,以便它们可以在托管代码中正常生存和呼吸。

另一个重要的事实是,我们服务器应用程序的主线程从根本上讲是一个托管线程。类工厂对象和工厂实例化的对象毕竟仍然是托管对象。它们都在一个与客户端分离的应用程序进程中的托管代码中诞生和生存。因此,CLR 设置的这些对象的默认 COM 设置将生效。我的直觉是,在 COM 互操作的上下文中,主线程、类工厂和创建的对象都存在于 MTA 中。

我目前正在与另一位 CodeProject 会员 Kyoung-Sang Yu 合作研究如何可能控制托管线程、托管工厂对象及其生成的对象的单元模型。我们一定会随时向大家通报进展。

类工厂注册、暂停、恢复和撤销

当非托管客户端应用程序调用 CoCreateInstance()CoGetClassObject(),并将类上下文值设置为 CLSCTX_LOCAL_SERVER 时,COM 会在注册表中搜索适当的本地服务器代码,以便启动或引用。如果在其内部类工厂表中找到适当的类工厂对象,并且此工厂已注册为多次使用(通过 REGCLS_MULTIPLEUSE 标志),COM 将暂时锁定此工厂(通过 IClassFactory::LockServer())并使用它创建所需对象。

如果在表中未找到类工厂对象,或者已找到但已注册为单次使用(通过 REGCLS_SINGLEUSE 标志),COM 将使用特殊命令行参数 "-Embedding" 启动服务器应用程序。COM 将等待适当的类工厂对象注册。“-Embedding”命令行参数旨在指示服务器应用程序执行类工厂注册,而不是作为普通程序启动。此标志还应指示服务器在不显示任何应用程序窗口的情况下启动。

每个类工厂必须由 COM 本地服务器创建,然后注册到 COM 的类工厂表中。此注册通过 CoRegisterClassFactory() 实现。

请注意,一旦类工厂对象已注册到 COM 的类工厂表中,客户端应用程序就可以使用它来创建对象。这可能会导致协调问题,因为对象可能会在本地服务器完成初始化之前创建。在 STA 对象的情况下,由于对其方法的调用仅在包含 STA 线程的消息循环期间进行处理(通常在该线程的所有初始化完成后启动),因此此问题可能不那么严重。但是,MTA 对象没有这种便利。此外,本地服务器应用程序可能会启动其他线程,每个线程都可以注册其他类工厂。

为了克服潜在的协调问题,COM 允许我们使用 REGCLS_SUSPENDED 标志注册类工厂,这表示 COM 暂停注册过程并推迟对指定 CLSID 对象的任何激活请求,直到调用 CoResumeClassObjects()

当本地服务器被标记为终止时,服务器公开的所有已注册类工厂对象都必须被撤销。这通过使用 CoRevokeClassObject() API 来实现。请注意,一旦类工厂对象被撤销,它将从全局类对象表中删除。这意味着后续客户端调用以创建由类工厂支持的对象将导致 COM 启动本地服务器的另一个实例。

在我们的 ManagedCOMLocalServer_Impl01 示例代码中,我们的 ClassFactoryBase 类公开了执行类工厂注册、暂停、恢复和撤销工作的方法。

本地服务器生命周期管理

COM 本地服务器必须控制其自身的生命周期。这通常通过检测自我终止的正确条件来实现。这些正确条件通常涉及两个实体:全局对象计数全局服务器锁定计数

全局对象计数表示本地服务器(通过其注册的类工厂)创建且在任何给定时刻仍处于活动状态的对象总数。

全局服务器锁定计数是指服务器应用程序所有已注册类工厂对象的总锁定计数。非零值表示客户端应用程序仍持有至少一个类工厂,从而强制服务器保持运行。

典型的服务器应用程序还将在其主线程中包含一个 Windows 消息循环,该循环持续泵送以保持活动状态。这种情况将持续到收到 WM_QUIT 消息,此时循环将中断,应用程序清理将发生(例如,为所有已注册类工厂调用 CoRevokeClassObject()),然后正式终止。

管理服务器生命周期的常见安排涉及检查上述两个全局值,以查看它们是否都已降至零。此检查通常包含在一个函数中(我们称之为 AttemptToTerminateServer() 函数),并在对象的引用计数已 Release() 到零以及类工厂的 LockServer() 方法被调用且参数值为 FALSE 时调用。

当两个值都为零时,表示服务器导出的所有对象都已被销毁,并且服务器的任何类工厂对象上都没有强制锁定。在这种情况下,不再需要服务器应用程序保持活动状态。AttemptToTerminateServer() 函数将向主消息循环发布 WM_QUIT 消息,以开始应用程序终止过程。

ManagedCOMLocalServer_Impl01 的总体设计

我们的 ManagedCOMLocalServer_Impl01 本地服务器示例遵循上述类工厂注册、消息循环激活、类工厂撤销和最终终止的典型模式。它还使用全局对象计数和全局锁定计数概念来控制其生命周期。请注意,.NET 应用程序也可以包含消息循环,该消息循环无缝地融入 .NET 应用程序工作流。这没有问题。

.NET COM 本地服务器的问题在于,虽然跟踪全局锁定计数并不困难,但要准确确定 .NET 对象(伪装成 COM 对象)的引用计数何时降至零并不容易。我们可以尝试使用对象的析构函数作为其引用计数降至零的确定性依据,但我们知道,由于 .NET 垃圾回收器的性质,对对象析构函数的调用是不确定的。简而言之,我们不知道垃圾回收器何时会激活其进程并销毁未引用的对象。

为了解决这个问题,除了主应用程序线程的逻辑流程之外,我们引入了垃圾回收线程的概念。这个线程非常简单,它通过定期调用静态方法 GC.Collect() 来工作。

这个 GC.Collect() 方法强制立即进行垃圾回收,这对我们来说非常有用,因为它导致所有未引用的对象被销毁。当这种情况发生时,对象的析构函数会被触发。然后可以调用我们的 AttemptToTerminateServer() 函数。这对我们来说是好事,因为我们可以有一种可靠的方式来执行服务器关闭。

我们将在稍后的专门章节中更详细地研究垃圾回收线程。

IClassFactory 接口

我们提供了 IClassFactory 接口的定义,供 ManagedCOMLocalServer_Impl01 解决方案使用。此接口如下所示

[
  ComImport,
  ComVisible(false),
  InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
  Guid("00000001-0000-0000-C000-000000000046")
]
public interface IClassFactory
{
  void CreateInstance
  (
    IntPtr pUnkOuter,
    ref Guid riid,
    out IntPtr ppvObject
  );
  void LockServer(bool fLock);
}

IClassFactory 接口定义旨在提供 COM IClassFactory 接口的 .NET 定义。它是此解决方案中定义的类工厂类成功编译所必需的。以下是此定义的重要点

  • 通过应用 ComImportAttribute,我们向 C# 编译器指示此接口源自 COM。
  • ComVisibleAttribute(使用 false 值构造)指示 C# 编译器,以及稍后的 regasm.exetlbexp.exe 等工具,此接口不得包含在这些工具生成的任何类型库文件中。这当然不难想象。忽略此操作将导致重新定义问题。
  • 通过 InterfaceTypeAttribute(使用 ComInterfaceType.InterfaceIsIUnknown 构造),我们向 C# 编译器指示此接口不是基于 IDispatch 的。对于直接从 IUnknown 派生的 COM IClassFactory 接口确实如此。
  • GuidAttribute(用“00000001-0000-0000-C000-000000000046”构造)指示要应用于此接口的特定 GUID。请注意,这确实是 COM IClassFactory 接口的 GUID。

此接口的方法与其 COM 对应方法完全相同。

SimpleCOMObject 类

SimpleCOMObject C# 类实现了 ISimpleCOMObject 接口。此实现非常简单,与我们之前看到的其他实现非常相似。我们不会深入探讨。相反,我们将简要介绍此类的相关部分

[
  Guid("E1FE1223-45C2-4872-9B1E-634FB850E753"),
  ProgId("ManagedCOMLocalServer_Impl01.SimpleCOMObject"),
  ClassInterface(ClassInterfaceType.None)
]
public class SimpleCOMObject :
  ReferenceCountedObjectBase,
  ISimpleCOMObject 
{
  ...
  ...
  ...
}
  • 通过应用 GuidAttribute,我们已为该类指示了特定的 CLSID。
  • ProgIdAttribute 表示此类的 ProgId 为 "ManagedCOMLocalServer_Impl01.SimpleCOMObject"。请注意,此属性并非严格要求,因为如果未使用 ProgIdAttribute,它也是 regasm.exe 生成的默认 ProgId。
  • 通过 ClassInterfaceAttribute,我们已指示 regasm.exe 不为此类生成类接口。有关更多详细信息,请查阅 MSDN 文档中的类接口。实际上,通过这种方式设置此属性,我们将不会有任何名为 _SimpleCOMObject 的接口注册到注册表中。
  • 此类继承自基类 ReferenceCountedObjectBase。我们将在稍后的部分中详细讨论基类。现在,只需说基类提供了全局对象计数的自动和线程安全增量和减量。全局对象计数在 COM 本地服务器的自终止机制中至关重要。

ReferenceCountedObjectBase 类

这是一个非常有用的辅助基类,用于帮助自动化跟踪 COM 本地服务器中活动的对象。它如下所示

// This ComVisibleAttribute is set to false so that TLBEXP

// and REGASM will not expose it nor COM-register it.

[ComVisible(false)] 
public class ReferenceCountedObjectBase
{
  public ReferenceCountedObjectBase()
  {
    Console.WriteLine("ReferenceCountedObjectBase contructor.");
    // We increment the global count of objects.

    ManagedCOMLocalServer_Impl01.InterlockedIncrementObjectsCount();
  }

  ~ReferenceCountedObjectBase()
  {
    Console.WriteLine("ReferenceCountedObjectBase destructor.");
    // We decrement the global count of objects.

    ManagedCOMLocalServer_Impl01.InterlockedDecrementObjectsCount();
    // We then immediately test to see if we the conditions

    // are right to attempt to terminate this server application.

    ManagedCOMLocalServer_Impl01.AttemptToTerminateServer();
  }
}

通过从这个基类派生,我们确保当一个对象被实例化时,我们调用 ManagedCOMLocalServer_Impl01 类的静态方法 InterlockedIncrementObjectsCount()。我们还确保当一个对象被销毁时,ManagedCOMLocalServer_Impl01InterlockedDecrementObjectsCount()AttemptToTerminateServer() 被调用。

我们稍后将讨论 ManagedCOMLocalServer_Impl01 类。现在,只需注意以下几点

  • ManagedCOMLocalServer_Impl01 类是控制整个 COM EXE 服务器应用程序的核心类。我已对其进行编码,以便开发人员可以重用。它导入了许多 Win32 API 和结构。我们将只研究更重要的方法。此类的大多数属性和方法都是不言自明的。
  • ManagedCOMLocalServer_Impl01 还将确定是否是时候关闭本地服务器应用程序本身。

ReferenceCountedObjectBase 是一个很好的基类,可供要导出到 COM 的其他类使用。

SimpleCOMObjectClassFactory 类

每个 COM 类,无论是驻留在进程内 DLL 服务器还是进程外 EXE 服务器中,都必须提供一个类工厂。SimpleCOMObjectClassFactory 类被指定为 SimpleCOMObject 类的 COM 类工厂。此类的列表如下

class SimpleCOMObjectClassFactory : ClassFactoryBase
{
  public override void virtual_CreateInstance
  (
    IntPtr pUnkOuter,
    ref Guid riid,
    out IntPtr ppvObject
  )
  {
    Console.WriteLine("SimpleCOMObjectClassFactory.CreateInstance().");
    Console.WriteLine("Requesting Interface : " + riid.ToString());

    if (riid == Marshal.GenerateGuidForType(typeof(ISimpleCOMObject)) ||
        riid == ManagedCOMLocalServer_Impl01.IID_IDispatch ||
        riid == ManagedCOMLocalServer_Impl01.IID_IUnknown)
    {
      SimpleCOMObject SimpleCOMObject_New = new SimpleCOMObject();

      ppvObject =
        Marshal.GetComInterfaceForObject
        (SimpleCOMObject_New, typeof(ISimpleCOMObject));
    }
    else
    {
      throw new COMException("No interface", 
        unchecked((int) 0x80004002));
    }
  }
}

SimpleCOMObjectClassFactory 派生自 ClassFactoryBase 基类,该基类提供了许多辅助功能。SimpleCOMObjectClassFactory 只实现了 virtual_CreateInstance() 方法,这是基类的一个虚方法。

我们很快将讨论 ClassFactoryBase。现在,只需注意当 COM 需要 ManagedCOMLocalServer_Impl01.SimpleCOMObject 协同类的类工厂来实例化对象时,此基类会调用 SimpleCOMObjectClassFactory.virtual_CreateInstance()

您可能已经猜到,virtual_CreateInstance() 具有与 IClassFactory.CreateInstance() 方法相同的参数。这是因为 ClassFactoryBase 将要求派生类执行实际的类实例化过程。

参数 riid 用于确定 SimpleCOMObject 新实例所需的接口。如果这是标准接口 IUnknownIDispatch 之一,或者它是 ISimpleCOMObject,则会创建一个 SimpleCOMObject 新实例,并将其 IUnknown 接口传递给 ppvObject IntPtr 参数。

我们正在目睹的实际上是 COM IClassFactory::CreateInstance() 方法的 C# 实现。

ClassFactoryBase 类

此类是一个辅助基类,为派生类提供了非常有用的属性和方法。它旨在为 COM 使用的类工厂对象提供所需功能的基本实现。这些功能包括

  • 我们 .NET 版 IClassFactory 接口的标准实现。
  • 封装类工厂注册(RegisterClassObject() 方法)、恢复(ResumeClassObjects() 方法)和撤销(RevokeClassObject() 方法)操作。

它公开了几个属性,例如:ClassContextClassIdFlags。这些属性用于填充对 COM API CoRegisterClassObject() 的内部调用的参数。它还维护由对 CoRegisterClassObject() 的内部调用返回的 cookie。

请注意,一旦对 ClassFactoryBase 派生对象调用了 RegisterClassObject() 方法(可能需要随后调用 ResumeClassObjects()),类工厂就可以供 COM 使用。这种情况将一直持续到在此 ClassFactoryBase 派生对象上调用 RevokeClassObject()

IClassFactory 实现相当有趣

public void CreateInstance
(
  IntPtr pUnkOuter,
  ref Guid riid,
  out IntPtr ppvObject
)
{
  virtual_CreateInstance
  (
    pUnkOuter,
    ref riid,
    out ppvObject
  );
}

CreateInstance() 方法(如上所示)只是简单地调用虚拟 virtual_CreateInstance() 方法。此方法旨在由派生类重写。其自身的默认实现很简单

public virtual void virtual_CreateInstance
(
  IntPtr pUnkOuter,
  ref Guid riid,
  out IntPtr ppvObject
)
{
  IntPtr nullPtr = new IntPtr(0);
  ppvObject = nullPtr;
}

我们已经看到了 SimpleCOMObjectClassFactory 的有意义的实现。

public void LockServer(bool bLock)
{
  if (bLock)
  {
    ManagedCOMLocalServer_Impl01.InterlockedIncrementServerLockCount();
  }
  else
  {
    ManagedCOMLocalServer_Impl01.InterlockedDecrementServerLockCount();
  }

  // Always attempt to see if we need to shutdown this server application.

  ManagedCOMLocalServer_Impl01.AttemptToTerminateServer();
}

它的 LockServer() 方法(如上所示)将根据参数 bLocktrue 还是 false 来执行此 COM EXE 服务器锁定计数的线程安全递增或递减。

接下来,它将调用 ManagedCOMLocalServer_Impl01AttemptToTerminateServer() 方法。此方法非常有用,因为它将检查条件是否适合此 COM EXE 服务器进行自终止。我们将在下一节中详细研究此方法。

ManagedCOMLocalServer_Impl01 类

ManagedCOMLocalServer_Impl01 类是整个 COM EXE 服务器应用程序的控制器。我将其编码为可供开发人员重用。它导入了许多 Win32 API 和结构。我们将只研究更重要的方法。此类的大多数属性和方法都是不言自明的。

Main() 方法

此函数是服务器的起点。此方法相当长,此处不予列出。我将逐步介绍它

  • 它首先调用 ProcessArguments() 来处理命令行参数。重要的命令行参数包括:“-register”、“-unregister”和“-embedding”。
  • 当提供“-register”时,ManagedCOMLocalServer_Impl01.exe 会将“LocalServer32”键添加到 SimpleCOMObject 类的 COM 注册表键中。
  • 当提供“-unregister”时,将执行相反的操作(删除“LocalServer32”键)。使用任一参数都会导致应用程序立即退出。
  • 如果传递“-embedding”参数,则服务器应用程序真正启动。在这种情况下,我们首先初始化对象和服务器锁计数。
  • 我们还确定当前线程(即主线程)的线程 ID,并将此值保存在 m_uiMainThreadId 中。
  • 然后我们实例化一个 SimpleCOMObjectClassFactory 对象,将其 ClassContextClassIdFlags 属性设置为适当的值,并调用其 RegisterClassObject() 方法。
  • 请注意,我们已将 REGCLS.REGCLS_MULTIPLEUSE | REGCLS.REGCLS_SUSPENDED 的值作为 Flags 属性的值提供。
  • 使用 REGCLS.REGCLS_MULTIPLEUSE 标志将导致当前 EXE 服务器应用程序中包含的类工厂被多个客户端应用程序多次使用。因此,只要不调用 RevokeClassObject(),当前服务器将继续提供 SimpleCOMObject 类的实例。尝试使用 REGCLS.REGCLS_SINGLEUSE 标志查看其他结果。
  • 使用 REGCLS.REGCLS_SUSPENDED 标志将导致类工厂注册过程(由 CoRegisterClassObject() 调用)暂停,直到调用 CoResumeClassObjects()。这在某些情况下很有用。
  • 在内部,当调用 RegisterClassObject() 时,SimpleCOMObjectClassFactory 对象将通过其基类调用 CoRegisterClassObject() API 将自身注册为要作为类工厂对象暴露给 COM 的类。这是可能的,因为值“this”作为第二个参数传递给 CoRegisterClassObject()。
  • 然后,调用静态 ClassFactoryBase.ResumeClassObjects() 方法以正式使注册的类工厂可用于连接和实例化。
  • 这种情况将一直持续到 SimpleCOMObjectClassFactory 对象调用 RevokeClassObject() 方法。
  • 接下来,启动一个“垃圾回收”线程,其目的是定期调用静态 GC.Collect() 方法。这会强制立即进行垃圾回收,这对我们来说很重要,以确保及时销毁完全释放且不再引用的对象。这反过来又确保了 COM EXE 服务器的及时关闭。
  • 设置了一个 Windows 消息循环来处理 Windows 消息,特别是 WM_QUIT 消息,当条件适合关闭服务器时,该消息会发布到 ManagedCOMLocalServer_Impl01.AttemptToTerminateServer() 方法中的此消息循环。
  • 当消息循环确实收到 WM_QUIT 消息时,我们将立即调用 RevokeClassObject(),以便 SimpleCOMObjectClassFactory 对象不再可供 COM 使用。在这种情况下,当下次需要 SimpleCOMObjectClassFactory 对象时,将启动 ManagedCOMLocalServer_Impl01.exe 的新实例。
  • 然后我们将停止垃圾回收线程。
  • 服务器应用程序现在正在终止。

AttemptToTerminateServer() 方法

此方法如下所示

public static void AttemptToTerminateServer()
{
  lock(typeof(ManagedCOMLocalServer_Impl01))
  {
    Console.WriteLine("AttemptToTerminateServer()");

    // Get the most up-to-date values of these critical data.

    int iObjsInUse = ObjectsCount;
    int iServerLocks = ServerLockCount;

    // Print out these info for debug purposes.

    StringBuilder sb = new StringBuilder("");   
    sb.AppendFormat
    ("m_iObjsInUse : {0}. m_iServerLocks : {1}",
      iObjsInUse, iServerLocks);
    Console.WriteLine(sb.ToString());

    if ((iObjsInUse > 0) || (iServerLocks > 0))
    {
      Console.WriteLine
      ("There are still referenced objects or the server
        lock count is non-zero.");
    }
    else
    {
      UIntPtr wParam = new UIntPtr(0);
      IntPtr lParam = new IntPtr(0);
      Console.WriteLine("PostThreadMessage(WM_QUIT)");
      PostThreadMessage(MainThreadId, 0x0012, wParam, lParam);
    }
  }
}

此方法是线程安全的,确保任何时候只有一个线程访问它。然后访问并打印出全局对象计数和服务器锁定计数(用于诊断目的)。然后我们检查此应用程序服务的对象总数或服务器锁定总数是否仍然非零。如果是,我们认为此服务器应用程序需要保持活动状态。我们只需退出此方法。

如果这些数字同时降为零,我们认为不再需要此服务器应用程序。我们向主线程发布 WM_QUIT 消息,并且 Main() 中包含的消息循环将中断。

AttemptToTerminateServer() 方法在两个地方被调用

  1. ReferenceCountedObjectBase() 的析构函数。
  2. ClassFactoryBase.LockServer() 方法。

ReferenceCountedObjectBase() 的析构函数标志着对象的生命周期结束。此(已销毁的)对象将递减全局对象计数,现在是确定全局对象计数是否与服务器锁定计数一起降至零的好时机。

ClassFactoryBase.LockServer() 方法由客户端应用程序调用,当 COM 访问类工厂对象时也会调用。如果传入的参数值为 FALSE,我们需要调用 AttemptToTerminateServer() 再次检查服务器终止的条件是否成熟。

GarbageCollection 类

GarbageCollection 类提供了一个线程管理类的简单示例。其主要目的是定期调用静态 GC.Collect() 方法。调用 GC.Collect() 之间的时间间隔是可配置的,并通过 GarbageCollection 类构造函数参数 (iInterval) 进行设置

public GarbageCollection(int iInterval)
{
  m_bContinueThread = true;
  m_GCWatchStopped = false;
  m_iInterval = iInterval;
  m_EventThreadEnded = new ManualResetEvent(false);
}

GCWatch() 方法作为 Main() 方法的“垃圾回收”线程的线程入口点

public void GCWatch()
{
  Console.WriteLine("GarbageCollection.GCWatch() is now running ...");
  // Pause for a moment to provide a delay to make threads more apparent.

  while (ContinueThread())
  {
    GC.Collect();
    Thread.Sleep(m_iInterval);
  }

  Console.WriteLine("Goind to call m_EventThreadEnded.Set().");
  m_EventThreadEnded.Set();
}

它基本上包含一个 while 循环,该循环只会在 m_bContinueThread 设置为 false 时才会中断。在每次循环开始时,我们调用 GC.Collect()。此后,我们会在设定的时间间隔内阻塞线程。当循环最终通过 StopThread() 方法中断时,GCWatch() 将会发出 ManualResetEvent 对象 m_EventThreadEnded 的信号,然后退出线程。

m_EventThreadEnded 对象的信号将导致 WaitForThreadToStop() 方法(假设它在另一个线程上被调用)解除阻塞并退出。

COM 相关条目已添加到注册表

标准的 .NET 工具 regasm.exe 用于将所有相关的 COM 条目注册到注册表中。这包括以下条目

[HKEY_CLASSES_ROOT\ManagedCOMLocalServer_Impl01.SimpleCOMObject]
@="ManagedCOMLocalServer_Impl01.SimpleCOMObject"

[HKEY_CLASSES_ROOT\ManagedCOMLocalServer_Impl01.SimpleCOMObject\CLSID]
@="{E1FE1223-45C2-4872-9B1E-634FB850E753}"

[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}]
@="ManagedCOMLocalServer_Impl01.SimpleCOMObject"

[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}
\InprocServer32]
@="mscoree.dll"
"ThreadingModel"="Both"
"Class"="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
"Assembly"="ManagedCOMLocalServer_Impl01, Version=1.0.0.0, Culture=neutral,
  PublicKeyToken=94ff3289282b08f3"
"RuntimeVersion"="v1.1.4322"

[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}
\InprocServer32\1.0.0.0]
"Class"="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
"Assembly"="ManagedCOMLocalServer_Impl01, Version=1.0.0.0, Culture=neutral,
  PublicKeyToken=94ff3289282b08f3"
"RuntimeVersion"="v1.1.4322"

[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}\ProgId]
@="ManagedCOMLocalServer_Impl01.SimpleCOMObject"

[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}
\Implemented Categories\{62C8FE65-4EBB-45E7-B440-6E39B2CDBF29}]

这些注册表条目基本上是为了将输出的 ManagedCOMLocalServer_Impl01.exe 准备为 COM 进程内服务器加载。但是,我们还希望 ManagedCOMLocalServer_Impl01.exe 作为 COM 本地服务器激活,它独立运行,同时连接到在单独进程中运行的非托管客户端。

这需要在 "HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}" 键中存在一个额外的 "LocalServer32" 键。LocalServer32 键的值必须是 ManagedCOMLocalServer_Impl01.exe 的完整路径。我们通过让 ManagedCOMLocalServer_Impl01.exe 响应 "-register" 命令行参数来满足这一需求。

ManagedCOMLocalServer_Impl01.exe 使用此命令行参数运行时,它将在 ManagedCOMLocalServer_Impl01::ProcessArguments() 中使用以下代码段添加额外的 LocalServer32

try
{
  key = Registry.ClassesRoot.CreateSubKey
  ("CLSID\\" +
   Marshal.GenerateGuidForType(typeof(SimpleCOMObject)).ToString("B")
  );
  key2 = key.CreateSubKey("LocalServer32");
  key2.SetValue(null, Application.ExecutablePath);
}
catch (Exception ex)
{
  MessageBox.Show("Error while registering the server:\n"+ex.ToString());
}
finally
{
  if (key != null)
    key.Close();
  if (key2 != null)
    key2.Close();
}

这将导致观察到以下注册表情况

使用完整路径是可以的,但如果我们要将可执行文件注册到 GAC 中,则不太理想。我还没有找到确定 GAC 中已注册程序集的 GAC 路径的 API 或公式,因此我没有尝试将 ManagedCOMLocalServer_Impl01.exe 注册到 GAC。

我已包含一个批处理文件 RegisterAssemblyToRegistry.bat,它将自动化注册表更新过程。此批处理文件包含在解决方案文件夹中。其内容如下所示

echo off
echo Registering Assembly ManagedCOMLocalServer_Impl01.exe
  to the Registry...
regasm .\bin\Debug\ManagedCOMLocalServer_Impl01.exe /tlb
echo Registering Additional Entries into the Registry
  for ManagedCOMLocalServer_Impl01.exe
.\bin\Debug\ManagedCOMLocalServer_Impl01.exe -register

我们使用 regasm.exe 使用标准 COM 信息更新注册表。然后我们使用 "-register" 标志调用可执行文件,以便创建/更新 LocalServer32 键。

测试 ManagedCOMLocalServer_Impl01.exe

我们现在将对 ManagedCOMLocalServer_Impl01.exe 进行测试。为此,我准备了一个客户端非托管应用程序,位于 <主文件夹>\CSharpExeCOMServers\ManagedCOMLocalServer\clients\CPPClient01 中。

在此测试应用程序中,我们创建了两个 ISimpleCOMObjectPtr 智能指针对象以及一个 IClassFactoryPtr 智能指针对象。

ISimpleCOMObjectPtr spISimpleCOMObject1 = NULL;
ISimpleCOMObjectPtr spISimpleCOMObject2 = NULL;
IClassFactoryPtr spIClassFactory = NULL;

这两个 ISimpleCOMObjectPtr 智能指针对象在应用程序生命周期的不同点实例化。IClassFactoryPtr 对象用于在中间锁定 ISimpleCOMObject 对象的类工厂。

我已将客户端应用程序编码,以便可以清楚地看到 ManagedCOMLocalServer_Impl01.exe 中包含的各种对象的完整功能(包括垃圾收集器,而不仅仅是 COM 对象)。还放置了控制台输出,以显示操作序列的发生过程。

让我们分析 _tmain() 函数。在这里,我们使用一个特殊构造的全局模板函数 CreateInstanceByClassFactory(),创建一个其 CLSID 与 ProgID "ManagedCOMLocalServer_Impl01.SimpleCOMObject" 同义的 coclass 的类工厂实例。然后,我们将创建上述 coclass 的实例(通过刚刚获得的类工厂),并获取其 ISimpleCOMObject 接口。

CreateInstanceByClassFactory<ISimpleCOMObjectPtr>
(
  "ManagedCOMLocalServer_Impl01.SimpleCOMObject",
  spISimpleCOMObject1,
  spIClassFactory,
  CLSCTX_LOCAL_SERVER
);

让我们更深入地研究这个函数,并注意在 CreateInstanceByClassFactory() 执行时,ManagedCOMLocalServer_Impl01 本地服务器中发生了什么。

CreateInstanceByClassFactory() 首先使用 CLSIDFromProgID() API 将输入的 ProgID 字符串转换为其二进制 CLSID 等效项

hrRetTemp = CLSIDFromProgID
(
  (LPCOLESTR)bstProgID,
 (LPCLSID)&clsid
);

在获取到要实例化的 coclass 的 CLSID 后,CreateInstanceByClassFactory() 接着将其作为参数值传递给 CoGetClassObject() 调用,以获取此 coclass 的类工厂(其 ProgId 为 "ManagedCOMLocalServer_Impl01.SimpleCOMObject")

CoGetClassObject
(
  (REFCLSID)clsid, 
  (DWORD)dwClsContext,
  (COSERVERINFO*)NULL,
  (REFIID)IID_IClassFactory,
  (LPVOID *)&spIClassFactoryPtrReceiver
);

现在,执行此 API 后,COM 子系统将启动 ManagedCOMLocalServer_Impl01 本地服务器。服务器应用程序的控制台窗口将显示以下(或等效)输出

Sample screenshot

行 "Request to start as out-of-process COM server." 由 ProcessArguments() 函数显示。请注意,在此行之后,立即打印 "InterlockedIncrementServerLockCount()",这表明 ManagedCOMLocalServer_Impl01.InterlockedIncrementServerLockCount() 函数已被调用。此函数仅在 ClassFactoryBase.LockServer() 函数中被调用。接下来的三行输出来自 ManagedCOMLocalServer_Impl01.AttemptToTerminateServer() 函数。

我们可以从这些行输出中推断出,在请求获取 coclass 的类工厂对象时,类工厂对象(此时已被创建并注册到 COM 的内部类工厂表中)通过其 LockServer() 函数被锁定。这是由 COM 完成的。

接下来,我们让类工厂对象执行 coclass 实例创建

hrRetTemp = spIClassFactoryPtrReceiver -> CreateInstance
(
  NULL,
 __uuidof(SmartPtrClass),
 (LPVOID *)&spSmartPtrReceiver
);

请注意,本地服务器控制台窗口上出现了更多行

前两行:"SimpleCOMObjectClassFactory.CreateInstance()" 和 "Requesting Interface : ..." 是从 virtual_CreateInstance() 方法打印的。

接下来的三行:"ReferenceCountedObjectBase constructor"、"InterlockedIncrementObjectsCount()" 和 "SimpleCOMObject constructor" 是由于创建了 SimpleCOMObject 类(派生自 ReferenceCountedObjectBase)的新实例而打印的。

接下来,当我们调用刚刚填充的类工厂接口指针的 LockServer() 方法时,发生了一件有趣的事情

if (spIClassFactory)
{
 spIClassFactory -> LockServer(TRUE);
}

我们注意到 SimpleCOMObjectClassFactory.LockServer() 方法未被调用。我们可以通过 InterlockedIncrementServerLockCount() 方法未被调用,并且 AttemptToTerminateServer() 也未执行来确定这一点。对于真正的 COM 本地服务器客户端应用程序来说,似乎就是这种情况。

稍作跳跃,我们将注意到当我们调用以下代码时

if (spIClassFactory)
{
  spIClassFactory -> LockServer(FALSE);
 spIClassFactory = NULL;
}

SimpleCOMObjectClassFactory.LockServer() 方法(参数值为 FALSE)仍然没有被调用。但是,当我们将 "spIClassFactory" 设置为 NULL(有效地在类工厂对象上调用 Release())时,SimpleCOMObjectClassFactory.LockServer() 确实被调用了,参数值为 FALSE

这似乎表明 IClassFactory::LockServer() 方法实际上由 COM 子系统控制。当通过调用 CoGetClassObject() API 实例化类工厂时,LockServer(TRUE) 似乎被调用(由 COM)。相反,当类工厂被销毁时(由于其引用计数降至零),LockServer(FALSE) 似乎被调用。如前所述,在真正的 COM 本地服务器中也观察到了相同的行为,这让我们感到欣慰,因为它表明我们的托管本地服务器已按照规范工作。

第二个 spISimpleCOMObject2 对象通过我们的模板化 CreateInstance() 函数正常实例化。这个对象没有什么不寻常的。在整个测试应用程序中,我希望读者能理解 GarbageCollection() 线程的重要性,它确保析构函数定期被调用。为了看到它的实际作用,我建议读者暂时将 GarbageCollection 实例的构造函数参数设置为一个相对较大的值,例如每个垃圾收集周期 20000(表示 20 秒)。

while (ContinueThread())
{
  GC.Collect();
  Thread.Sleep(m_iInterval);
}

现在,在 CPPClient01 源代码中,将 spISimpleCOMObject1 设置为 NULL 的位置设置一个调试断点

if (spISimpleCOMObject1)
{
  spISimpleCOMObject1 -> put_LongProperty(1002);
  spISimpleCOMObject1 -> Method01
  (
    _bstr_t("C# EXE Local Server. The Long Property Value Is : ")
  );
  // Next Release() spISimpleCOMObject1.

  spISimpleCOMObject1 = NULL;
}

执行此 NULL 语句后,稍等片刻(不要执行下一个客户端源语句),并观察 ManagedCOMLocalServer_Impl01 本地服务器的控制台输出。一段时间后,您会注意到 SimpleCOMObject 的析构函数代码所引发的操作序列将被执行

结论

我当然希望您喜欢我们对 COM/.NET 互操作性世界的漫长探索。我已尽力彻底地深入探讨互操作性的机制,尽管这只是一方面。

我确实希望读者能从示例 COM 本地服务器 ManagedCOMLocalServer_Impl01 以及 IDotNetClassFactory 系统中受益。鉴于简单性的需要,我避免了过多的错误捕获和异常处理,以便专注于呈现正确的思想。

如果您在解释性文本中发现任何错误或在源代码中发现任何错误,或者您只是需要就本文的任何问题向我澄清,请随时与我联系。

我已感谢包括 Aaron Queenanmav.northwindKyoung-Sang Yu 在内的一些非常友好的 CodeProject 成员,感谢他们宝贵的反馈。

致谢和参考

  • .NET 和 COM - 完整的互操作性指南 作者 Adam Nathan。由 SAMS Publishing 出版。

有用资源

更新历史

2005 年 1 月 5 日

更新了源代码 zip 文件 BuildCOMServersInDotNet_src.ZIP。用于 SimpleCOMObject.sln 解决方案的原始批处理文件 CreateAndRegisterPrimaryInteropAssembly.bat 已更新了一行新内容

regasm Interop.SimpleCOMObject.dll

这将把 Interop.SimpleCOMObject.dll 主互操作程序集注册到注册表。

2005 年 1 月 11 日

更新了源代码 zip 文件 BuildCOMServersInDotNet_src.ZIP,其中包含一个名为 ManagedCOMLocalServer 的文件夹中的一组新源代码。此文件夹包含一个完全用 C# 编码的 COM EXE 本地服务器项目的 VS.NET 解决方案。

2005 年 1 月 16 日

更新了“完全用托管代码实现的 COM EXE 服务器”部分,增加了更多解释性文本。

2005 年 1 月 22 日

更新了“测试 ManagedCOMLocalServer_Impl01.exe”部分,增加了更多解释性文本。提供了对服务器代码实际运行的深入分析。还简要演示了 GarbageCollection 线程如何影响服务器生命周期管理。

2005 年 2 月 2 日

对“测试 ManagedCOMLocalServer_Impl01.exe”部分进行了少量更新。

© . All rights reserved.