COM 对象
关于 COM 接口的介绍请参考我的另一篇博文 COM 接口。
本文主要关注 COM 的实现。
接口实现
COM 接口只是描述了它所代表的功能,实现这些功能的是 COM 对象。COM 规范并没有规定对象应该如何实现,只要接口指针能够访问到对象对接口的具体实现即可。
这里我们使用 C++ 语言来实现 COM 对象。当然,使用 C++ 语言也有不同的方法来实现 COM 对象,只要通过接口指针能够访问到对象的的方法和属性(私有数据)即可。为简单起见,我们使用从接口类派生实现类的办法来实现 COM 对象。
继续 COM 接口中的例子:我们的计算器支持接口 ICalculator:
interface ICalculator : IUnknown // interface 关键字表明接口定义的开始
{
HRESULT add( [in] long num1, [in] long num2, [out, retval] long result );
HRESULT sub( [in] long subtrahend, [in] long minuend, [out, retval] long result );
};
假设还支持另外一个接口:
interface IOther : IUnknown
{
HRESULT foo([in] long param1, [in] long param2);
};
那么 CCalculator 类的定义为:
class CCalculator : public ICalculator, public IOther
{
public:
CCalculator();
~CCalculator();
// IUnknown 成员函数(在这里要实现,所以再次申明)
virtual HRESULT __stdcall QueryInterface(const IID& iid, void **ppv);
virtual ULONG __stdcall AddRef();
virtual ULONG __stdcall Release();
// ICalculator 成员函数
virtual HRESULT __stdcall add(long num1, long num2, long result);
virtual HRESULT __stdcall sub(long subtrahend, long minuend, long result);
// IOther 成员函数
virtual HRESULT __stdcall foo(long param1, long param2);
private:
int m_nRef; //用作引用计数
};
CCalculator 内存结构如下图所示:
IUnknown 接口函数实现如下:
CCalculator::CCalculator()
{
m_nRef = 0; //引用计数赋初值
}
ULONG CCalculator::AddRef()
{
m_nRef++; //增加引用计数
return (ULONG)m_nRef;
}
ULONG CCalculator::Release()
{
m_nRef--; //减少引用计数
if (m_nRef == 0 ) {
delete this; //减到0时,删除自身
return 0;
}
return (ULONG) m_nRef;
}
HRESULT CCalculator::QueryInterface(const IID& iid, void **ppv)
{
if ( iid == IID_IUnknown ) {
*ppv = static_cast <ICalculator *> (this);
((ICalculator*)(*ppv))->AddRef();
}
else if ( iid == IID_Calculator ) {
*ppv = static_cast <ICalculator *> (this);
((ICalculator*)(*ppv))->AddRef();
}
else if ( iid == IID_OTHER ) {
*ppv = static_cast <IOther *> (this);
((IOther*)(*ppv))->AddRef();
}
else {
*ppv = NULL;
return E_NOINTERFACE;
}
return S_OK;
}
注意这里使用 ( (ICalculator*) (*ppv) )->AddRef(),而不是直接 AddRef(),因为调用 AddRef 的指针可能不一样,当然,这里它们都是指向同一个对象,都是使用同一个 AddRef()。但是也有例外,在使用其他方式实现接口时(比如嵌套类方式),不同的指针调用 AddRef 实现可能不一致。为了统一起见,一律使用对应的新指针去调用 AddRef,这样也符合直观的逻辑。
另外我们没有使用
if ( iid == IID_IUnknown ) {
*ppv = static_cast <IUnknown *> (this) ;
……
}
是因为这种转换存在二义性,无法通过编译。当客户请求 IUnknown 接口时,我们直接返回 ICalculator 接口,因为 ICalculator 接口虚表的前三个函数正是 IUnknown 的前三个函数。而且这样处理也满足接口查询的原则(当然转换成 IOther 接口也是可以的)。
COM 注册
COM 对象往往以一个 DLL 为载体,当然,有时也以一个 EXE 程序为载体。客户程序和 COM 对象可能位于不同的进程,甚至不同的机器上。我们需要一种中介机制在双方建起桥梁。 当 COM 对象的数目、接口的数目、COM 客户的数目很大时,其必要性就尤其突出。
注册表是 Windows 操作系统的中心数据仓库。当 COM 组件程序安装到机器上以后,必须把它的信息注册到注册表中,客户才能从注册表中找到组件程序并对其进行操作。注册表可以看作是组件与客户的中介,是 COM 实现位置透明性的关键。
COM 对象的信息存储在注册表的 HK_CLASSES_ROOT 键的 CLSID 子键下。在每个 COM 对象的 CLSID 子键下,存储了一些相关的信息,比如组件程序的路径、版本号、类型库、ProgID、COM 对象的唯一标识符 CLSID、COM 接口的唯一标识符 IID 等。ProgID(Program identifier)是字符串化的组件名字。COM 提供了两个 API 用于 CLSID 和 ProgID 的相互转换:CLSIDFromProgID 和 ProgIDFromCLSID。
组件有两种方式将信息注册到注册表中
- 手动注册
这种类型的组件本身不编写任何代码来支持注册操作,相反,由程序员手工编写注册文件,然后导入到注册表中。比如:使用文本编辑器将要注册的内容写入文件并以 .reg 后缀保存,双击之组件信息即由注册表编辑器导入注册表。以下是一个注册文件的例子:
REGEDIT
HKEY_CLASSES_ROOT\CLSID\{89A48671-20B3-11d0-8B80-EA9EFFE6330C} = MyCaclulator
HKEY_CLASSES_ROOT\CLSID\{89A48671-20B3-11d0-8B80-EA9EFFE6330C}\InprocServer32 = C:\MyCalc.dll
- 自注册 (先只讨论进程内组件的情形)
Windows 系统提供了一个注册进程内组件的工具 RegSvr32.exe,只要进程内组件提供了入口函数,RegSvr32.exe 就会调用入口函数完成注册或注销工作。
注册:RegSvr32.exe C:\MyCalc.dll
注销:RegSvr32.exe \u C:\MyCalc.dll
组件负责提供的入口函数名字分别为 DllRegisterServer 和 DllUnRegisterServer,分别完成注册和注销任务。DllRegisterServer 和 DllUnRegisterServer 要由组件程序实现,其中要使用 Windows 提供的操作注册表的 API 如 RegCreateKey 和 RegSetValue 等函数。
// DllRegisterServer - Adds entries to the system registry.
STDAPI DllRegisterServer(void)
{
// registers object, typelib and all interfaces in typelib
HRESULT hr = _AtlModule.DllRegisterServer();
return hr;
}
// DllUnregisterServer - Removes entries from the system registry.
STDAPI DllUnregisterServer(void)
{
HRESULT hr = _AtlModule.DllUnregisterServer();
return hr;
}
类厂
类厂的由来
最初使用源代码共享时,客户包含 C++ 类的定义,然后使用诸如以下的方式:
FastString *pFS = new FastString;
使用动态链接库作为 C++ 对象的载体时,仍然使用:
FastString *pFS = new FastString;
使用接口类把接口从实现中分离出来:
FastStringItf *pFsIf = new FastStringItf;
使用抽象基类把接口与对象分开以后,COM 组件只需引出一个诸如以下的函数:
extern "C" { __declspec(dllexport) IFastString *CreateFastString(const char* psz); }
而客户只需调用此导出函数,就可以得到接口指针:
IFastString *pIFs = CreateFastString("abcdefg");
但是 COM 规范考虑地问题更多。比如我们希望在客户创建对象的时候需要提供口令或其他安全信息。我们使用另外一个类来创建对象并返回接口。比如说,用以下的方式:
class CCalculatorFactory
{
public:
HRESULT CreateCalculator(ICalculator **ppv) {
… … // 在这里可以进行身份认证、安全认证等附加操作。
*ppv = new CCalculator;
return NO_ERROR;
}
}
组件 DLL 中再把 CCalculatorFactory 类暴露出去,客户则可以这样使用:
CCalculatorFactory* pCF = new CCalculatorFactory;
ICalculator* pIC = pCF->CreateCalculator(&pIC);
pIC-> … … // 使用计算器接口的功能。
通过以上方式,可以进一步控制 COM 对象的安全性。
但是,很显然,CCalculatorFactory 类不能很安全地从 DLL 中引出。然而这一次,我们已经有了经验,我们可以完全仿照 COM 对象的实现方式,对 CCalculatorFactory 的定义和实现也分离开。这就是类厂对象以及类厂接口的由来。
类厂的定义与实现
类厂,准确地说应该叫 “对象厂”(因为是用来创建对象的)。有的文献称为 “类对象”(class object)。COM 库通过类厂来创建 COM 对象。对应每个 COM 类,有一个类厂专门用于该 COM 类的对象的创建工作。而且,类厂本身也是一个 COM 对象(当然,类厂不再需要别的类厂来创建了) 。
类厂支持一个特殊的接口 IClassFactory(如前所述,它也派生自 IUnknown):
interface IClassFactory : public IUnknown
{
public:
// pUnkOuter 用于对象被聚合的情形,一般把它设为 NULL。
// riid 指 COM 对象创建完后,客户应该得到的初始接口 IID,比如
// IID_ICalculator,IID_ISpellCheck 等。ppv 用来保存接口指针。
virtual HRESULT _stdcall CreateInstance(IUnknown *pUnkOuter, REFIID riid, void **ppv) = 0;
// LockServer 用来控制 DLL 组件的卸载。
virtual HRESULT _stdcall LockServer(BOOL fLock) = 0;
};
类厂的定义则为:
class CCalculatorFactory : public IClassFactory
{
public:
CCalculatorFactory ();
~CCalculatorFactory ();
//IUnknown 成员
HRESULT __stdcall QueryInterface(const IID& iid, void **ppv);
ULONG __stdcall AddRef();
ULONG __stdcall Release();
//IClassFactory 成员
HRESULT __stdcall CreateInstance(IUnknown *, const IID& iid, void **ppv);
HRESULT __stdcall LockServer(BOOL); // 组件生存周期控制,见后
private:
ULONG m_Ref; // 类厂接口的引用计数
};
CreateInstance 的实现如下:
HRESULT CCalculatorFactory::CreateInstance( IUnknown *pUnknownOuter, const IID& iid, void **ppv)
{
*ppv = NULL;
// 确保 pUnknownOuter 在这里是空指针
if (NULL != pUnknownOuter)
return CLASS_E_NOAGGREGATION;
CCalculator* pObj = new CCalculator(); // 创建 COM 对象
if (NULL == pObj)
return E_OUTOFMEMORY;
HRESULT hr = pObj->QueryInterface(iid, ppv); //返回 COM 对象的初始接口
if (FAILED(hr))
delete pObj;
return hr;
}
类厂的创建
现在的问题是类厂如何创建。COM 并不使用如下的方法:
IClassFactory* pIF = CreateClassFactory(…);
相反,COM 规定使用 DllGetClassObject 函数来完成这一任务:
extern "C" HRESULT __stdcall DllGetClassObject( const CLSID& clsid, const IID& iid, void **ppv )
{
if ( clsid == CLSID_Calculator ) {
CCalculatorFactory *pFactory = new CCalculatorFactory;
if (pFactory == NULL)
return E_OUTOFMEMORY;
return pFactory->QueryInterface(iid, ppv);
}
else
return CLASS_E_CLASSNOTAVAILABLE;
}
首先确认 clsid 是我们要创建的计算器对象的 ID,然后创建类厂对象,调用类厂对象的 QueryInterface 成员函数返回类厂接口指针。整个过程与类厂创建 COM 对象并返回 COM 接口的过程完全一致。
iid 一般为 IID_IClassFactory。ppv 用来保存类厂接口指针。然而客户仍然不直接调用 DllGetClassObject 导出函数函数来获得类厂接口指针。COM 规定,客户使用如下的 COM 库函数:
extern "C" __stdcall HRESULT CoGetClassObject(
REFCLSID rclsid, // 将要创建的 COM 对象的 ID
DWORD dwClsContext, // 指定组件的类别,进程内或进程外
LPVOID pvReserved, // 用于 DCOM,指定远程对象的服务器信息,此时为 NULL
REFIID riid, // 类厂接口的 ID,一般为 IID_IClassFactory
void** ppv // 用来保存类厂的接口指针。
);
我们考虑进程内组件的情形(即 COM 对象存在于 DLL 中)。
CoGetClassObject 从注册表中查找组件 clsid 对应程序的路径(COM 组件注册时最主要的任务之一就是注册组件路径),然后加载组件到内存,再调用组件程序的导出函数 DllGetClassObject 以创建类厂接口对象并返回指针(MSDN 文档中指出,在这个过程中并没有调用 CoLoadLibrary,但是是否调用了 Win32API LoadLibrary 和 GetProcAddress 则无从得知,也许 Microsoft 使用了未公开的其他 API,但是从原理上,我们可以清晰地了解整个过程)。
在调用 DllGetClassObject 时,CoGetClassObject 直接把 clsid,riid 和 ppv 三个参数传进去。
一旦客户得到了类厂接口指针,就可以使用该指针调用其 CreateInstance 成员函数来创建 COM 对象,并得到该 COM 对象的接口指针。实际上 COM 还提供了另一个函数把以上两步操作封装起来:
extern "C" __stdcall HRESULT CoCreateInstance (
REFCLSID rclsid, // 将要创建的 COM 对象的 ID
LPUNKNOWN pUnkOuter, // 用于被聚合的情形
DWORD dwClsContext, // 指定组件的类别,进程内或进程外
REFIID riid, // COM 接口的 ID 比如 IID_ICalculator
void** ppv // 用来保存 COM 接口指针
);
它的实现方式可以是这样的:
HRESULT CoCreateInstance(REFCLSID rclsid, LPUNKNOWN pUnkOuter, DWORD dwClsContext, REFIID riid, LPVOID FAR* ppv)
{
IClassFactory *pCF = NULL;
HRESULT hr = CoGetClassObject(clsid, dwClsContext, NULL, IID_IClassFactory, (void**)&pCF);
if (FAILED(hr))
return hr;
hr = pCF->CreateInstance(pUnkOuter, iid, (void**)&ppv);
pCF->Release();
return hr;
}
使用这种方式把类厂屏蔽起来了,使得客户看不到类厂接口指针,方便客户的使用。当然用户也可以使用 CoGetClassObject 先得到类厂指针,然后使用类厂指针去创建 COM 对象。
客户使用类厂创建 COM 对象的过程如下图:
类厂对组件生存周期的控制
概念澄清:COM 对象的引用计数是对 COM 对象的生存周期的控制。组件是指 DLL 或 EXE,系 COM 对象的载体。客户有可能在一个载体内创建同一种 COM 对象类的多个对象。每个对象及其接口指针通过引用计数机制来对该对象进行生存周期的控制。而组件的生存周期,是指组件何时可以从内存中卸载的时期。当然,组件的生存周期要比单个 COM 对象的生存周期要长。
以下的讨论,我们假设组件中只有一种 COM 对象。而对于有多种 COM 对象的情形,完全可以类似地处理。
一般情况下,客户只是在创建 COM 对象的时候要用到类厂接口指针,创建完后就把类厂对象丢弃了。为了效率等原因,客户可能需要控制组件程序的生存周期。因为如果组件程序被释放后,客户可能在将来还要重新加载,而且此时由于类厂对象也随着组件程序一起被销毁,客户再使用此接口指针会出错。因此,如果客户能控制其生存周期,客户可以在将来继续使用类厂接口指针,以便创建新的 COM 对象,这种情况下可能会提高程序的工作效率。
类厂接口的 LockServer 函数正是为了这个目的而设置的。
LockServer 函数的实现如下:
ULONG g_LockNumber = 0; // 在组件程序中需要定义一个全局变量
HRESULT CCalculatorFactory::LockServer(BOOL bLock)
{
if (bLock)
g_LockNumber++;
else
g_LockNumber--;
return S_OK;
}
为了准确地判断组件程序能否卸载,我们还需要引入一个全局变量以记录 COM 对象的个数。
ULONG g_CalculatorNumber = 0;
在 CCalculator 的构造函数和析构函数中分别进行增 1 和减 1 操作。
这样当锁计数器和组件对象个数计数器都为 0 时组件程序就可以安全卸载了。
extern "C" HRESULT __stdcall DllCanUnloadNow(void)
{
if ((g_CalculatorNumber == 0) && (g_LockNumber == 0))
return S_OK;
else
return S_FALSE;
}
而这个引出函数是当客户执行 CoFreeUnusedLibraries 时,由 COM 库调用的函数。
通过 COM 对象引用计数器、组件对象个数计数器、锁计数器的引入,客户可以真正安全、方便且灵活地控制 COM 逻辑对象以及其运行载体。
COM 线程模型
套间(Apartment)的由来
最开始的 COM 库,支持的使用组件的唯一模式是 single-thread-per-process 模式。这样就避免了多线程的同步,而且组件执行的线程肯定是创建它的线程。
然而组件对象真正的执行环境很复杂。COM 组件的执行环境有两种:单线程环境 Single-Thread,多线程环境 Multi-Thread。单线程要考虑执行线程是否是创建组件的线程;多线程还要考虑并发、同步、死锁、竞争等问题。无论哪种环境,都要编写大量的代码以使 COM 组件对象正确的运行。
为了使程序员减轻痛苦,COM 库决心提供一套机制来帮助程序员。如果我们都遵从这套机制,只要付出较少的劳动,就可以让组件对象和 COM 库一起完成工作。COM 库这套机制的核心技术就是 “套间技术”。
单线程套间 STA
Single-threaded Apartments,一个套间只关联一个线程,COM 库保证对象只能由这个线程访问(通过对象的接口指针调用其方法),其他线程不得直接访问这个对象(可以间接访问,但最终还是由这个线程访问)。
COM 库实现了所有调用的同步,因为只有关联线程能访问 COM 对象。如果有 N 个调用同时并发,N-1 个调用处于阻塞状态。对象的状态(也就是对象的成员变量的值)肯定是正确变化的,不会出现线程访问冲突而导致对象状态错误。
注意:这只是要求、希望、协议,实际是否做到是由 COM 决定的。这个模型很像 Windows 提供的窗口消息运行机制,因此这个线程模型非常适合于拥有界面的组件,像 ActiveX 控件、OLE 文档服务器等,都应该使用 STA 套间。
STA 的实现
调用 CoInitializeEx( NULL, COINIT_APARTMENTTHREADED ) 可以创建一个 STA,然后套间把当前的线程和自己关联在一起,线程被标记为套间线程,只有这个线程能直接调用 COM 对象。
在创建套间的时候,COM 创建了一个隐藏的窗口。关联线程对组件的调用直接通过接口指针调用方法;其他线程对套间里的对象的调用,都转变成对那个隐藏窗口发送消息,然后由这个隐藏窗口的消息处理函数来实际调用组件对象的方法。
由于窗口消息的处理是异步的,所以所有的调用都是依次进行的,不必考虑同步的问题。但是对于全局变量和静态变量,组件编写者还是要小心。
多线程套间 MTA
Multithreaded Apartments,一个套间可以对应多个线程,COM 对象可以被多个线程并发访问。所以这个对象的作者必须在自己的代码中实现线程保护、同步工作,保证可以正确改变自己的状态。
这对于作为业务逻辑组件或干后台服务的组件非常适合。因为作为一个分布式的服务器,同一时间可能有几千条服务请求到达,如果排队进行调用,那么将是不能想象的。
注意:这也只是一个要求、希望、协议而已。
MTA 的实现
调用 CoInitializeEx( NULL, COINIT_MULTITHREADED ),第一次如此调用的时候,会创建一个 MTA,然后套间把当前线程和自己关联在一起,线程被标记为自由线程(Free Thread)。以后第二个线程再调用(在同一进程中)的时候,这个 MTA 会把第二个线程也关联在一起,并标记为自由线程。一个 MTA 可以关联多个线程。
所有的关联线程都可以调用套间中的组件。这就涉及到同步问题,需要组件编写者解决(使用 CriticalSection,Semaphore,Mutex 等等)。
第三方组件线程套间
组件将在哪种类型的套间中执行,是编写者决定的。对于进程外组件,要调用 CoInitializeEx 并指定参数,以显式确定套间类型。对于进程内的服务器来说,因为客户端已经调用 CoInitializeEx 产生套间了,为了允许进程内的服务器可以控制它们的套间类型,COM 允许每个组件有自己不同的线程模型,并记录在注册表中:
HKEY_CLASSES_ROOT/CLSID/…/InprocServer32 键值 ThreadingModel
可取值如下:
- Main Thread Apartment (未指定)
- Single Thread Apartment (Apartment)
- Free Thread Apartment (Free)
- Any Apartment (Both)
- Neutral Apartment (N/A)
对象在哪个套间创建
下表中第一列为套间种类,第一行为对象线程模型属性。那么,结果就是在这样的套间中创建的组件会在什么地方。在必要的时候,会创建一个代理,就是表中的宿主。
未指定 | Apartment | Free | Both | |
---|---|---|---|---|
单线程(主线程) | 当前套间 | 当前套间 | MTA | 当前套间 |
单线程(非主) | 主 STA | 当前套间 | MTA | 当前套间 |
单线程(主线程) | 主 STA | 宿主 STA | MTA | MTA |
一个进程可以有 0 个、1 个或多个 STA,还可以有 0 个或 1 个 MTA。
在既有自由线程又有套间线程的进程里,所有自由线程在一个套间里,而其他套间都是单线程套间。
COM 的线程模型为客户端和服务端提供了这样一种机制:让不同的线程协同工作。不同进程内,不同线程之间的对象调用都是被支持的。从调用者的角度来看,所有对进程外对象的调用都是一致的,而不管它在怎样的线程模型。以被调用者的角度来看,不管调用者的线程模型如何,所获得的调用都是一致的。
– EOF –