深入理解COM(一)

前言

最近需要深入学习下COM,来解决工作上的问题。本文根据《COM原理与应用》这本书来写的。

COM简单介绍

COM是微软提出的组件标准,它定义了组件程序之间进行交互的标准,提供了组件程序运行所需的环境。

进程内组件:指的是dll

进程外组件:指的是exe

组件程序可能会包含多个组件对象,所以程序与程序进行通信时,通信双方为COM对象。

COM的历史发展

随着桌面程序之间的交互不断深入,在OLE技术发展过程中产生了COM。

大家都知道Windows操作系统,可以将一个应用程序里面写的文字复制到另一个程序中。

这其中需要程序与程序之间进行通信。

刚开始微软采用的是OLE规范,在OLE1中,进程间通信采用的是DDE技术,这种技术最大的缺点是效率低,稳定性不好,使用不方便。

OLE2中,放弃了DDE通信,采用COM方式进行通信。

既然提到了OLE,简单介绍下OLE是什么?

OLE原先是对象链接和嵌入,主要用于复合文档,但是自从OLE2之后,OLE变成了在桌面系统上进行程序通信的一个技术统称。

COM组件的组成

一个COM组件就是一个dll文件或者一个exe文件,一个组件可以包含多个COM对象,并且每个COM对象可以实现多个接口。

COM对象是通过接口来进行进程间通信的。

在进程通信中,COM规范采用的是客户/服务器模型。

一般而言,服务器为COM组件,调用方为客户。

COM对象用一个128位全局唯一标识符(GUID)来标识,称之为CLISID。

COM接口也是用一个128位的GUID来标识,称之为IID。

客户通过CLISID来创建COM对象,得到一个指向对象某个接口的指针之后(在此处可以得到该对象的所有接口指针),然后再调用该指针,就可以调用该接口提供的所有服务。

客户可以同时拥有两个相同CLISID的COM对象。

重要的IUnknown接口

所有的COM接口都要继承IUnknown接口,所有的COM对象都需要实现IUnknown接口。

IUnknown接口提供了非常重要的两个特性:生存期控制和接口查询。

客户端程序虽然只能通过接口与COM对象进行通信,但是也要控制COM对象的存在与否。

我们看一下IUnknown接口的定义:

1
2
3
4
5
6
7
class IUnknown
{
public:
virtual HRESULT _stdcall QueryInterface(const IID& iid,void ** ppv) = 0;
virtual ULONG _stdcall AddRef()=0;
virtual ULONG _stdcall Release()=0;
}

QueryInterface用于查询接口,AddRef用于增加引用计数,Release用于减少引用计数。

进程内组件

进程内组件和客户程序运行在同一个进程地址空间中,它是作为dll加载到客户程序的内存中。

dll本身是独立的,不依赖客户程序。

客户程序如果想要调用dll必须有标准的约定(二进制上的约定),dll程序包含一个引出函数表。

引出函数表中包含函数名称、函数序号以及函数地址。

在C++语言中,一般指定DLL的引出函数为_stdcall调用方式,如果使用了_cdecl,那么有些编程语言就不能使用dll。

因为C++会对dll的每个引出函数自动生成修饰名,但对于不同的编译器并不兼容,所以为了通用性,我们可以不要编译器使用修饰名,即在每个函数定义前面加上 extern “C”。

1
extern "C" int _stdcall MyFunction(int n);

光这个还不够,还需要描述DLL程序的模块信息。我们可以有两种方式去描述,一种是使用DEF文件,另一种是直接在函数说明时使用_declspec(dllexport)说明符:

1
2
3
4
5
6
7
8
9
//def 文件
LIBRARY "DictComp"
DESCRIPTION "描述语言"
EXPORTS
CreateObject @1


//直接在函数说明时使用说明符
extern "C" _declspec(dllexport) int _stdcall MyFunction(int n);

进程外组件

进程外组件是组件程序独占一个进程而不使用客户程序的进程空间。

这样就产生了两个问题

  1. 一个进程如何调用另一个进程中的函数
  2. 参数如何从一个进程被传递到另一个进程中

COM采用了本地过程调用(LPC)和远过程调用(RPC)的方法进行进程间通信。

LPC用于同一机器上的不同进程之间通信,RPC用于在不同机器上的进程间进行通信。

可以看一下普通程序如何通过LPC来调用另一个进程的:

COM进程外组件:

可以看到多出来代理dll和存根dll,它们是用来完成LPC调用,还会对参数和返回值进行翻译和传递。

所有的跨进程的操作被COM库封装了,实际使用过程和进程内组件差不多。

COM组件注册信息

com库会通过系统注册表所提供的信息进行组件的创建工作。

COM只用到了HKEY_CLASSES_ROOTHKEY_CLASSES_ROOT下最主要的是CLISID子键,CLISID下面有很多组件项。

每个组件项底下有组件信息:

1
2
3
4
5
6
7
8
InprocServer32  		---------------进程内组件dll的全路径
LocalServer32 ---------------进程外组件的全路径
Version ---------------组件的版本
ProgID ---------------程序标识符
TypeLib ---------------当前系统中类型库信息
ProxyStubClsid ---------------代理dll
ProxyStubClsid32 ---------------代理dll
Implemented Categories ---------------COM类别,子键代表实现的接口

有关Implemented Categories列出的类型,可以在HKEY_CLASSES_ROOT键下有个子键Component Categories中找到。

微软也提供了OleView工具来列出机器上的所有类型以及组件对象列表。

进程内组件注册时会调用DllRegisterServer方法进行注册,卸载时会调用DllUnregisterServer进行卸载。

而进程外组件需要支持/RegServer和UnregServer来完成注册或卸载。

类厂

创建COM对象之前,我们先了解下类厂的概念。

类厂:创建类的工厂。

每个类厂只针对特定的COM类对象,类厂都会继承并实现IClassFactory:

1
2
3
4
5
6
7
class IClassFactory:public IUnkown
{
//用来创建COM对象
virtual HRESULT _stdcall CreateInstance(IUnknown* pUnknownOuter,const IID& iid,void ** ppv) =0;
//用来控制组件的生命周期
virtual HRESULT _stdcall LockServer(BOOL bLock);
}

类厂也是COM对象,类厂是由DllGetClassObject来创建的。

1
2
//原型
HRESULT DllGetClassObject(const CLSID& clsid,const IID& iid,(void**) ppv);

在COM库中,有三个API可用于创建对象:

CoGetClassObject
CoCreateInstance
CoCreateInstanceEx

1
2
3
4
5
HRESULT CoGetClassObject(const CLISID& clsid,
DWORD dwClsContext, //指定组件类型,进程内组件、进程外组件或者进程内控制对象
COSERVERINFO* pServerInfo, //用于DCOM时,不能为NULL
const IID& iid,
(void**) ppv);

如果是进程内组件,CoGetClassObject会调用dll中的DllGetClassObject函数,iid通常为接口IClassFactory的标识符IID_IClassFactory,然后返回类厂对象接口指针。

如果是进程外组件,那么就比较复杂。首先CoGetClassObject函数启动组件进程,等待组件进程把它支持的COM类对象的类厂注册到COM中,然后CoGetClassObject函数把COM中相应的类厂信息返回。

进程外组件被COM库启动时,它必须把所支持的COM类的类厂对象通过CoRegisterClassObject函数注册到COM中,以便COM库创建COM对象使用。

当进程退出时,必须调用CoRevokeClassObject函数以便通知COM所注册的类厂对象不再有效。

CoRegisterClassObject函数与CoRevokeClassObject函数必须配对。

这样一描述是挺麻烦的。。。

1
2
3
4
5
HRESULT CoCreateInstance(const CLSID& clsid,
IUnknown* pUnknownOuter,
DWORD dwClsContext,
const IID& iid,
(void**) ppv);

CoCreateInstance是一个包装的辅助函数,在它内部调用了CoGetClassObject函数。参数pUnknownOuter与类厂接口的CreateInstance中对应的参数一致。

使用这个函数,客户程序可以不用和类厂打交道,直接获取到了COM对象的接口指针。

CoCreateInstance不能创建远程机器的对象,因为服务器参数内部默认为NULL。

如果想要创建远程对象,可以使用CoCreateInstanceEx:

1
2
3
4
5
6
HRESULT CoCreateInstanceEx(const CLISID& clsid,
IUnknown* pUnknownOuter,
DWORD dwClsContext,
CONSERVERINFO* pServerInfo,
DWORD dwCount,
MULTI_QI* rgMultiQU)

pServerInfo用于指定服务器信息,dwCount和rgMultiQI指定了一个结构数组,可用于保存多个对象接口指针,其目的在于一次获取多个接口指针,减少网络交互。

COM库

如果应用程序需要使用COM库,那么必须先调用COM库的初始化函数:

1
2
3
4
5
HRESULT CoInitialize(IMalloc* pMalloc);//pMalloc为内存分配限制,默认为NULL,使用缺省的内存分配器
//返回S_OK,则表示初始化成功
//返回S_FALSE,则表示虽然初始化成功,但是本次调用不是本进程中首次调用。
//返回E_UNEXPECTED,则表示在初始化过程中发生了错误,应用程序不能使用COM库
DWORD CoBuildVersion();//获取COM库版本
1
void CoUninitialize(void);//释放COM库

因为COM是基于二进制标准建立起来的,COM库的内存分配与管理需要使用COM库中的内存分配与释放函数。

1
2
3
void* CoTaskMemAlloc(ULONG cb);//分配内存
void CoTaskMemFree(void* pv);//释放内存
void CoTaskMemRealloc(void* pv,ULONG cb);//相当于realloc

COM重用

COM重用有两种方式,包含和聚合。

看图说话:

我在工作中常用包含,A对象包含B对象,如果用C++表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class B
{
public:
void printB();
};

class A
{
public:
void printA();
void printB(){ m_b.printB();}
B m_b;

};

int main()
{
A a;
a.printA();
a.printB();
}

聚合比较复杂了,首先要了解什么是聚合?

聚合就是把一堆东西放在一块。

我们可以画一个图来表示COM聚合:

假如客户程序调用A对象,A对象聚合B对象,那么实现聚合需要依赖A对象的QueryInterface成员函数。

首先我们要了解QueryInterface会根据传入参数不同,返回不同的接口指针。

客户程序现在可以拥有两个接口指针,客户程序根据不同的接口指针,看到的世界不一样。

COM 规范

  1. 对于同一个对象的不同接口指针,查询得到的IUnknown接口必须完全相同
  2. 接口的对称性。对一个接口查询其自身总应该成功
  3. 接口自反性。如果从一个接口指针查询到另一个接口指针,则从第二个接口指针再回到第一个接口指针必定成功
  4. 接口传递性。如果从第一个接口指针查询到第二个接口指针,从第二个接口指针可以查询到第三个接口指针,则从第三个接口指针一定可以查询到第一个接口指针。
  5. 接口查询时间无关性。如果在某一时刻可以查询到某个接口指针,那么任何时刻都可以查询到。

根据图所示,客户程序是把整个COM组件当做对象A来调用。从外边看,对象A实现了两个接口。

但是根据COM规范,它违反了第1、3规范,第4规范更不用提了。

如何解决这个问题呢?

当客户程序通过IB接口指针查询IUnknown接口时,把IA的IUnknown接口传出去。这样做就不会违背COM第1规范。

如何才能做到这点呢?可以通过CoCreateInstance方法创建COM接口IA时候做到:

1
2
3
4
5
HRESULT CoCreateInstance(const CLSID& clsid,
IUnknown* pUnknownOuter,//将IB的IUnknown接口指针传进去,代表A聚合B
DWORD dwClsContext,
const IID& iid,
(void**) ppv);

IA接口需要实现两种IUnknown接口,一个是正常的IUnknown(非委托IUnknown),另一个是聚合的IUnknown(委托IUnknown)。

接下来我们就用代码实现下,因为C++不支持同时实现两个IUnknown,所以委托IUnknown和非委托IUnknown不能都使用IUnknown类,但是我们可以定义一个新的类。

1
2
3
4
5
6
7
class INondelegationUnknown
{
public:
virtual HRESULT _stdcall NondelegationQueryInterface(const IID& iid,void** ppv) = 0;
virtual ULONG _stdcall NondelegationAddRef()=0;
virtual ULONG _stdcall NondelegationRelease()=0;
};

因为对象B支持聚合,所以它要实现INondelegationUnknown

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class B:public IB,public INondelegationUnknown
{
protected:
ULONG m_Ref;
public:
B(IUnknown* pUnknownOuter);
~B();
public:
//委托IUnknown接口函数
virtual HRESULT _stdcall QueryInterface(const IID& iid,void** ppv);
virtual ULONG _stdcall AddRef();
virtual ULONG _stdcall Release();
//非委托IUnknown接口函数
virtual HRESULT _stdcall NondelegationQueryInterface(const IID& iid,void** ppv);
virtual ULONG _stdcall NondelegationAddRef();
virtual ULONG _stdcall NondelegationRelease();
virtual HRESULT _stdcall printB();
private:
IUnknown* m_pUnknownOuter;
};
//非委托实现
ULONG B::NondelegationAddRef()
{
m_Ref++;
reurn (ULONG)m_Ref;
}
ULONG B::NondelegationRelease()
{
m_Ref--;
if(m_Ref == 0)
{
//g_CompANumber--;
delete this;
return 0;
}
reurn (ULONG)m_Ref;
}
HRESULT B::NondelegationQueryInterface(const IID& iid,void** ppv)
{
if(iid == IID_IUnknown)
{
*ppv = (INodelegationgUnknown*)this;
((IUnknown*)(*ppv))->AddRef();
}else if(iid == IID_IB)
{
*ppv = (IB*)this;
((IB*)(*ppv))->AddRef();
}else{
*ppv = NULL;
return E_NOINTERFACE;
}
return S_OK;
}
//委托实现
ULONG B::AddRef()
{
if(m_pUnknownOuter != NULL)
{
return m_pUnknownOuter->AddRef();
}
return NondelegationAddRef();
}
ULONG B::Release()
{
if(m_pUnknownOuter != NULL)
{
return m_pUnknownOuter->Release();
}
return NondelegationRelease();
}
HRESULT B::QueryInterface(const IID& iid,void** ppv)
{
if(m_pUnknownOuter != NULL)
{
return m_pUnknownOuter->QueryInterface(iid,ppv);
}else{
return NondelegationQueryInterface();
}
}

搞清楚了COM接口支持聚合要实现的具体逻辑,下面考虑下支持聚合后COM对象如何创建?

在创建COM A对象的时候同时创建COM B对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HRESULT A::Init()
{
IUnknown* pUnknownOuter = (IUnknown*) this;
//创建B对象,可以看到创建的时候把自身的指针传进去了。
//m_pUnknownInner为指向B对象的非委托IUnknown
HRESULT result = ::CoCreateInstance(CLSID_B,pUnknownOuter,CLSCTX_INPROC_SERVER,
IID_IUnknown,(void**) &m_pUnknownInner);
if(FAILED(result))
{
return E_FAIL;
}
return S_OK;

}
//析构的时候,要释放聚合对象B接口指针
A::~A()
{
if(m_pUnknownInner != NULL)
{
m_pUnknownInner->Release();
}
}

B对象被A创建的时候,需要注意两点:

第一点,B工厂创建B对象的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HRESULT BFactory::CreateInstance(IUnknown* pUnknownOuter,
const IID& iid,void ** ppv)
{
//如果pUnknownOuter参数不是NULL,则表明创建的B对象要被聚合
//聚合必须保证iid为IID_IUnknown
if(pUnknownOuter != NULL && iid != IID_IUnknown)
{
return CLASS_E_NOAGGREGATION;
}
* ppv = NULL;
HRESULT hr = E_OUTOFMEMORY;
B* b = new B(pUnknownOuter);
if(b == NULL)
{
return hr;
}
//此处调用B对象的非委托IUnknown接口
//只有非委托的IUnknown接口,才可以查询到IB接口
hr = b->NondelegationQueryInterface(iid,ppv);
return hr;
}

第二点,B对象构造函数

1
2
3
4
5
B::B(IUnknown* pUnknownOuter)
{
//把对象A的接口指针IUnknown给存起来了
m_pUnknownOuter = pUnknownOuter;
}

这些是聚合的细节,有些复杂。。。可以简单的从客户程序角度看,聚合就是把多个COM对象整成一个对象、多个接口,想要用哪个就调用哪个接口。

整体流程

到目前为止,大体流程已经介绍完毕,让我们整理下:

简单来看就三个步骤,下一篇让我们接着深入理解COM。

评论