原生和托管互操作变得简单






4.43/5 (3投票s)
本文通过使用 COM 互操作基础知识,展示了一个简单的计算器组件示例。
引言
作为我工作场所最新项目的一个早期步骤,我必须从一个原生 C++ 应用程序运行一个 Web 服务客户端。然而,由于这个应用程序已经开发并上市多年,并且完全是用原生 C++ 编写的,因此没有办法迁移整个应用程序。所以我必须将应用程序中的对象和代码暴露给 Web 服务客户端,并且能够从 C++ 代码中使用服务客户端。当时我需要一个简单小巧的代码示例来测试和研究如何做到这一切的基本结构,就像我现在提出的这个小计算器一样。
本文的目的是让大家能够初步接触托管代码和原生代码环境之间的 COM 互操作,而无需深入探讨其周围的细节和架构。我希望这对那些想在不太了解的情况下开始编写代码的人来说是一个起点,也适用于那些时间紧迫,需要首次完成这种互操作性的开发者。关于这些技术的详细知识以及深入研究的必要性,应该来自于实践经验和学习的本能。我认为,在对一个主题有初步了解并进行实践之前,不应该去购买和阅读一本关于该主题的书。我就是这么想的。
Using the Code
在这个简单的例子中,我在每个 COM 组件中只使用了一个接口和一个与之关联的类。这适用于原生 COM 组件和 C# 组件。因此,简单分析一下,我们将在 C++ 和 C# 组件中看到类似这样的结构:
class SimpleCalc : ISimpleCalc
{
double Add( double a, double b );
double Subtract( double a, double b );
double Multiply( double a, double b );
double Divide( double a, double b );
}
请记住,只有 C++ 组件为其类提供了实际的计算执行实现,而 C# 组件则作为托管代码包装器。可以这样设想:
要让整个系统运行并进行测试,需要为图像中的每个部分执行一些步骤。请注意,SimpleCalc.dll 和 SimpleCalcClient.exe 都是使用 Visual Studio 6 创建的项目。SimpleCalc.dll
- 构建 C++ COM 组件;
- 使用 Windows 自带的“regsvr32”工具在命令行中注册它
regsvr32 SimpleCalc.dll;
SimpleCalcInterop.dll
- 使用 .NET Framework 工具在命令行中生成一个密钥文件来签名程序集 SampleKeys.snk
sn –k SampleKeys.snk;
- 构建 C# COM 组件;
- 在命令行中使用一些 .NET Framework 工具分两步注册组件
-
regasm SimpleCalcInterop.dll
-
gacutil /i SimpleCalcInterop.dll
-
SimpleCalcClient.exe
- 构建 C++ COM 客户端;
有一些关于最佳实践的注意事项,以及为了通过这些简单的步骤让事情正常工作而已经完成的一些遗漏的步骤。
- 为了让 C# 组件能够使用 C++ COM 组件,必须有一个互操作程序集。通常,如果您直接将 COM 组件导入 C# 项目,此程序集会自动生成,但为 .NET 项目中的每个原生 COM 引用生成一个强命名程序集是一个好习惯。这可以通过使用 .NET Framework 的“tlbimp”命令行工具完成
tlbimp SimpleCalc.dll /keyfile:SampleKeys.snk /out:Interop.SimpleCalc.dll
然后将生成的 DLL 作为引用添加到正在开发的 .NET 项目中。
- 此外,为了能够生成类型库供 C++ COM 客户端使用 C# COM 组件,您需要使用另一个 .NET Framework 命令行工具。
tlbexp SimpleCalcInterop.dll /out:SimpleCalcInterop.tlb
正如您在代码中看到的,从那里开始,只需将其复制到项目目录即可使用。
深入代码
这就是事情变得不仅仅是初步了解并让它运行起来的地方。
有很多关于这一切如何工作以及其基本原理的优秀文章,更不用说 COM 互操作性以及使用 COM 组件的所有细微之处了,尤其是在 CodeProject 上。其中一些文章是本文末尾的参考资料。目前,我的想法是创建一个初步的心智模型,说明在时间紧迫时如何尽快让这一切运行起来。实际上,关于 COM 互操作性的内容之丰富,足以写成一本书。
再次从 C++ COM 组件开始,事情不能再简单了。您首先需要运行 Visual Studio 6 并创建一个新的“ATL COM Appwizard”,它将生成一个 DLL,并允许合并代理/存根代码。
然后添加一个“Simple ATL Object”并为其指定一个简短的名称。就是这样,现在您拥有一个双重接口(通常与 IDispatch
接口关联)的 COM 组件,可以编译并实例化在 COM 客户端中。这里发生了一些您看不见的事情,除非您去寻找。在 IDL 文件中,您会找到一个接口声明,以及一个在库声明中的类声明。库本质上是一个命名空间,即使您向接口添加方法和成员,类声明也会保持不变,但类和接口有一个共同的特定属性,即“uuid”。这本质上是操作系统中的一个唯一标识符字符串,它在 Microsoft 依赖的一种方法中生成,以确保两个软件供应商在同一客户端计算机上安装两个不同的组件并具有相同的唯一标识符,这确实是一个惊人的巧合(十亿分之一的巧合),甚至允许开发人员生成一个 ID 已被该计算机占用的组件。这个 ID 本质上是一组字符串值,如下面提供的源代码所示:
C79D6B3B-79DC-4EA4-83DF-0AA8EF02023D
这些 ID 旨在是唯一的,并在组件注册到系统后存储在系统注册表中。您甚至可以使用“regedit”工具在需要手动清理要卸载的组件、从注册表中删除或完全删除时查找它们。
下一步是向生成的接口添加方法和/或成员,它看起来非常像常规的 C++,除了某些额外的数据类型。您还会注意到无法设置方法的返回值,因此您必须使用其中一个参数的指针。(重要:此处避免使用引用参数)添加方法时,IDL 文件(一个中间接口和类描述文件)中会添加一行,该文件实现自己的语言,并在剩余的源代码编译之前进行编译,以生成一些源代码本身。以下一行是一个很好的例子:
[id(1), helpstring("method Add")] HRESULT Add([in]double a, [in]double b,
[out, retval]double *result);
请注意其组成部分:“id”、“helpstring”以及标准的 C++ 方法声明,带有一些额外的关键字“in”/“out”/“retval”。“id”表明所有接口方法和成员都已编号,并且只有成员允许对 get 和 put 方法使用相同的 id。“helpstring”只是为了帮助您跟踪方法应该代表什么。而关键字“in”/“out”则精确地表示参数的类型,是输入还是输出参数,尽管也要查看“retval”,它只能用于最后一个参数,并且该参数也必须是“out”参数,它将转换为将该方法返回设置为最后一个参数。当您在 Visual Studio 2005 或 Visual C# 2005 Express 中使用该代码并添加 DLL 作为引用尝试使用它时,这是最明显的。最终,该行将转换为方法原型,写法如下:
double Add( double a, double b );
此方法的代码本身也遵守 IDL 文件中的行,因此您最终会得到类似这样的方法:
STDMETHODIMP CSimpleCalculator::Add(double a, double b, double *result)
{
*result=a+b;
return S_OK;
}
下一步是实现您想在组件中做的所有源代码。然后构建 DLL,就该生成互操作程序集以与 .NET 托管代码一起使用了。
如果您查看源代码中提供的示例,相关项目位于“SimpleCalc”文件夹中,然后需要查看的文件是 SimpleCalc.idl、SimpleCalculator.h 和 SimpleCalculator.cpp。其余的要么是项目定义的一部分,要么由构建步骤或 Visual Studio 向导生成(尽管它们同样重要)。
最后,使用 regsvr32
命令注册生成的 DLL,如下所示:
regsvr32 SimpleCalc.dll
这时就轮到“tlbimp”和“sn”了。为了使用它们,您需要运行 Visual Studio 2005/Express 命令行控制台,并加载 .NET Framework 环境,或者在您的 Visual Studio 2005/Express SDK 文件夹中找到它们。“sn”用于使用前面提到的命令生成一个包含密钥对的文件:
sn –k SampleKeys.snk
您可以在开发大型应用程序时,使用此密钥文件来重复签名自己的组件,并且将所有程序集都命名为强命名,这对于 .NET Framework 在不同项目之间使用程序集作为引用时非常重要。
现在是时候开始查看包装器本身,即 C# COM 组件了。过程很简单,打开 Visual Studio 2005/Express 并启动一个新项目,一个类库,然后打开其中的类文件。
有两种实例化 COM 组件对象的方法:一种是早期绑定,另一种是后期绑定。它们之间的主要区别就像在代码复杂性和对互操作程序集的依赖之间进行选择。使用早期绑定,您需要添加互操作程序集作为引用才能构建应用程序或 DLL,尽管它将具有非常简单的代码,请查看“SimpleCalcInterop”项目中的 Add 方法:
public double Add(double a, double b)
{
double result=0;
try
{
if (mCalc!=null)
result=mCalc.Add(a, b);
}
catch (COMException ex)
{
System.Diagnostics.Debug.WriteLine("COM Exception : " + ex.Message);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("Unknown Exception : " +
ex.Message);
}
return result;
}
反过来,mCalc
实例由以下代码片段实例化和销毁:
public SimpleCalc()
{
try
{
mCalc = new Interop.SimpleCalc.SimpleCalculator();
GC.KeepAlive(mCalc);
}
catch(COMException ex)
{
System.Diagnostics.Debug.WriteLine("COM Exception : " + ex.Message);
}
catch(Exception ex)
{
System.Diagnostics.Debug.WriteLine("Unknown Exception : " +
ex.Message);
}
}
~SimpleCalc()
{
if(mCalc!=null)
Marshal.ReleaseComObject(mCalc);
mCalc = null;
}
正如您所见,执行每个操作所需代码,实例化、调用方法和销毁实例,如果一切顺利,通常只有几行代码。
实例化
ISimpleCalculator mCalc = new SimpleCalculator();
GC.KeepAlive(mCalc); //this is a good practice, optional, not required
调用 Add
方法
if (mCalc!=null)
return mCalc.Add(a, b);
销毁实例
if(mCalc!=null)
Marshal.ReleaseComObject(mCalc);
mCalc = null;
使用后期绑定则无需添加引用,假定组件已在系统注册表中正确注册,但代码确实会增长,并且更加复杂。这里的关键字变成了“reflection”(反射)和“meta-information”(元数据)。由于应用程序或 DLL 不包含或导入任何接口或对 COM 组件本身的引用,它所做的只是使用注册表中的信息以及 COM 组件 DLL 本身公开的信息。因此,要执行后期绑定的 Add
操作,您将得到以下代码:
public double Add(double a, double b)
{
double result = 0;
result = doCalc(a, b, "Add");
return result;
}
public double doCalc(double a, double b, String oper)
{
double result = 0;
try
{
object simpleCalcInterop = null;
Type simpleCalcType;
simpleCalcType = Type.GetTypeFromProgID(
"SimpleCalc.SimpleCalculator");
simpleCalcInterop = Activator.CreateInstance(simpleCalcType);
object[] simpleCalcParams=new object[2];
simpleCalcParams[0]=a;
simpleCalcParams[1]=b;
result = (double)simpleCalcType.InvokeMember(
oper,
BindingFlags.Default | BindingFlags.InvokeMethod,
null,
simpleCalcInterop,
simpleCalcParams
);
}
catch (COMException ex)
{
System.Diagnostics.Debug.WriteLine("COM Exception : " + ex.Message);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("Unknow Exception : " +
ex.Message);
}
return result;
}
与任何代码类似,也有实例化和方法调用,尽管在这种情况下,没有专门为 COM 互操作性编写的代码,例如直接调用垃圾回收器(GC
)或系统 Marshal
对象。在这种情况下,我让垃圾回收器完成它的工作,尽管也有很多代码与对象以及甚至基于接口和 DLL 中公开信息的元信息的反射的抽象概念相关。
实例化
object simpleCalcInterop = null;
Type simpleCalcType;
simpleCalcType = Type.GetTypeFromProgID("SimpleCalc.SimpleCalculator");
simpleCalcInterop = Activator.CreateInstance(simpleCalcType);
调用 Add
方法
object[] simpleCalcParams=new object[2];
simpleCalcParams[0]=a;
simpleCalcParams[1]=b;
result = (double)simpleCalcType.InvokeMember(
oper,
BindingFlags.Default | BindingFlags.InvokeMethod,
null,
simpleCalcInterop,
simpleCalcParams
);
主要的可见区别在于做事的方式,例如使用 object
和 Type
类型,它们使用静态方法根据名称实例化所需的对象。然后,object
类型再次用于数组中,通过其名称发送参数给被调用的成员,并提供所有必需的详细信息,例如调用它的实例。
这在源代码中提供的 SimpleCalcInteropLateBinding
项目中是可见的。
在填写包装器或 C# COM 组件中每个所需方法的代码后,下一步是确保它具有所需的 COM 行为,因此需要设置属性和声明接口,最终结果正如您在提供的“SimpleCalcInterop”项目中看到的:
[ComVisible(true),
InterfaceType(ComInterfaceType.InterfaceIsDual),
Guid("03C69B60-2F4F-4022-855A-F9702E40BF3A")]
public interface ISimpleCalc
{
double Add(double a, double b);
double Subtract(double a, double b);
double Multiply(double a, double b);
double Divide(double a, double b);
}
[ComVisible(true),
ClassInterface(ClassInterfaceType.None),
Guid("A8861345-3750-49df-BFBC-E16FE2DFC87B")]
public class SimpleCalc : ISimpleCalc
{
…
}
完成所有这些之后,.NET Framework 需要以略微不同的方式来注册 COM 组件。除了在注册表中创建一个条目外,它还需要将副本注册到全局程序集缓存(GAC)中。要将程序集注册到注册表中,很简单:
regasm SimpleCalcInterop.dll
要将其添加到 GAC,请运行:
gacutil /i SimpleCalcInterop.dll
使用 Visual Studio 2005/Express 命令行提示符。
此时,终于可以开始查看 C# 中创建的 COM 组件的客户端应用程序了。这里同样的技术适用,但代码会有所不同,因为它们也是完全不同的编程语言,因此提供的客户端代码同时使用了早期绑定和后期绑定技术。所以您为最后一步要做的就是运行 Visual Studio 6 并使用 MFC AppWizard 创建一个应用程序,并使其成为一个对话框应用程序以保持简单。
为了使用早期绑定,您需要再次在 Visual Studio 2005/Express 命令行提示符中使用“tlbexp”工具,命令如下:
tlbexp SimpleCalcInterop.dll /out: SimpleCalcInterop.tlb
然后将 TLB 文件复制到客户端应用程序项目文件夹。
以下是一个在 C++ 中执行 Add 方法的早期绑定示例:
#import "SimpleCalcInterop.tlb" named_guids
using namespace SimpleCalcInterop;
void CSimpleCalcClientDlg::OnBtAddEb()
{
ISimpleCalc *calculator=NULL;
HRESULT hr = CoCreateInstance(
CLSID_SimpleCalc,
NULL,
CLSCTX_INPROC_SERVER,
IID_ISimpleCalc,
(void**)&calculator
);
if(!SUCCEEDED(hr))
{
AfxMessageBox("An error occurred while trying to create the" +
"instance for the .NET COM object.");
return;
}
double oper1, oper2;
CString str;
m_Oper1.GetWindowText(str);
oper1=(double)atof(str.GetBuffer(str.GetLength()-1));
m_Oper2.GetWindowText(str);
oper2=(double)atof(str.GetBuffer(str.GetLength()-1));
double result=calculator->Add(oper1, oper2);
str.Format("%lf", result);
m_Result.SetWindowText(str);
calculator->Release();
}
这个例子从头到尾完成了所有操作:实例化、调用方法和销毁实例,甚至还导入了类型库。named_guids
是一个关键字,它确保唯一标识符获得限定名称,例如 CLSID_SimpleCalc
,而无需声明一个唯一标识符实例并使用唯一 ID 字符串。我最后想展示的 COM 互操作代码是 C++ 中的后期绑定,用于执行 Add 方法:
void CSimpleCalcClientDlg::OnBtAddLb()
{
HRESULT hr = 0;
CLSID cls;
OLECHAR progid[255];
IDispatch* calculator=NULL;
wcscpy( progid, L"SimpleCalcInterop.SimpleCalc");
hr = CLSIDFromProgID (progid, &cls);
hr = CoCreateInstance (
cls,
NULL,
CLSCTX_INPROC_SERVER,
__uuidof(IDispatch),
(void**)&calculator
);
if (!SUCCEEDED(hr))
{
AfxMessageBox("An error occurred while trying to create the" +
"instance for the .NET COM object.");
return;
}
double oper1, oper2;
CString str;
m_Oper1.GetWindowText(str);
oper1=(double)atof(str.GetBuffer(str.GetLength()-1));
m_Oper2.GetWindowText(str);
oper2=(double)atof(str.GetBuffer(str.GetLength()-1));
VARIANT VarResult = {0};
VARIANTARG vargs[2];
VARIANT arg1,arg2;
DISPID dispid;
DISPPARAMS dispparamsArgs = {NULL, NULL, 0, 0};
arg1.vt= VT_R8;
arg1.dblVal = oper1;
arg2.vt= VT_R8;
arg2.dblVal = oper2;
//for some reason these are sent in the reverse order
vargs[0]=arg2;
vargs[1]=arg1;
dispparamsArgs.rgvarg=vargs;
dispparamsArgs.cArgs = 2; //how many args
LPOLESTR szMember = OLESTR("Add");
hr = calculator->GetIDsOfNames(IID_NULL, &szMember, 1,
LOCALE_USER_DEFAULT, &dispid);
hr = calculator->Invoke(
dispid,
IID_NULL,
LOCALE_USER_DEFAULT,
DISPATCH_METHOD,
&dispparamsArgs,
&VarResult,
NULL,
NULL
);
double result=/*(double)*/VarResult.dblVal;
str.Format("%lf", result);
m_Result.SetWindowText(str);
calculator->Release();
}
这个最后的片段有一些假设,根据前面展示的内容,使用了 IDispatch
作为双重接口声明(在 C# 中的接口属性中可见)。除此之外,您可以看到与 C# 中使用 VARIANT
类型而不是 object
具有一些相似之处,最后使用了 OLE
相关的类型和常量来处理其余的细节和方法名称。请注意,还有一些特殊类型来弥补原生 C++ 中缺乏反射。
最后,您需要记住,在原生 C++ 中,为了让事物正常工作,需要初始化和反初始化 COM 上下文。这应该对整个应用程序中的每个 CoInitialize
和 CoUninitialize
调用执行一次。
在初始化时
CSimpleCalcClientDlg::CSimpleCalcClientDlg(CWnd* pParent /*=NULL*/)
: CDialog(CSimpleCalcClientDlg::IDD, pParent)
{
CoInitialize(NULL);
//{{AFX_DATA_INIT(CSimpleCalcClientDlg)
// NOTE: the ClassWizard will add member initialization here
//}}AFX_DATA_INIT
// Note that LoadIcon does not require a subsequent DestroyIcon in Win32
m_hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME);
}
在最终化时
CSimpleCalcClientDlg::~CSimpleCalcClientDlg()
{
CoUninitialize();
}
结论
如您所见,与我引用的文章相比,本文只是一个很小的样本。即便如此,它也实现了我的目标,即提供一个简短的介绍,您不必阅读一本像小书一样长的内容,并且可以直接获取代码进行测试和学习。尽管示例只是一个简单的计算器,但自动生成的源代码很多,尤其是在使用 C++ 项目时。
您还会注意到,我并没有详细解释很多术语,这是故意的,因为确实有很多术语,我需要避免让文章篇幅像一本书那么长,尽管您可以轻松地在 MSDN 互操作教程或 CodeProject 的参考资料中查找和学习更多内容(这些资料内容也非常深入,就像一本书一样)。
我希望本文能够实现我的目标,即为初学者提供一个初步的认识,让他们了解在进行 COM 互操作时需要注意的少量代码和架构选择。
关注点
在构建 DLL 时,请始终记住您为其设定的版本号,因为版本更改意味着需要更改使用该 COM 组件的所有客户端组件和应用程序。
在 COM 互操作上下文中,字符串是内存泄漏的主要来源之一。
在编写托管 C++ 的托管 COM 组件时尤其重要,请记住对并行程序集的需要,它们通常最终会放在一个文件夹中,例如 %windows%\WinSxS。
尽管您已经注册了一个托管 COM 组件并将其放入 GAC,但您可能需要将 DLL 本身的一个副本与客户端应用程序一起放置(这样应用程序才能知道在哪里找到它)。
将您的托管 COM 组件的最终版本制作为主互操作程序集(PIA)。
参考文献
纯 C 中的 COM (系列文章)[^]
使用后期绑定和早期绑定编写 COM 客户端[^]
理解 .NET 应用程序的经典 COM 互操作性[^]
在 .NET 中构建 COM 服务器[^]
COM 互操作性第二部分:C# 服务器教程[^]
历史
2007 年 11 月 10 日 – 原始文章