一、COM介绍
com组件是Component Object Model(组件对象模型)的缩写。为了减少重复造轮子,人们在开发过程中提出了“接口”这一概念,将部分通用的功能进行抽象,由具体的实例实现具体的操作。com的核心思想也是类似的,不过与C++的接口不同的是,它的重用是在源码级别的,会受到由于不同语言、不同环境各种影响;而com组件是各种各样的二进制可执行文件,它与平台、语言无关,通过com库提供的接口,即可对com组件进行调用。下面我们就来学习如何使用本地com服务器的com组件,并在第二阶段尝试着自己注册一个二进制Dll文件并调用它。
二、com的一些基本元素、概念
1.coclass(组件对象类):包含一个或多个接口的代码,被包含在二进制文件。
2.COM服务器:包含了一个或多个coclass的二进制文件。
3.GUID:全球唯一标识符,是128位的二进制。这是独立于com开发的标示方法,这也意味着任何编程语言都可对它进行处理,为每一个接口和coclass分配一个GUID,可以避免名字冲突。
4.HRESULT:com接口被调用时的返回值。
5.所有的COM接口都直接或间接地继承
IUnKnown
,这个接口提供了内存管理及接口查询(此处后文再重写QueryInterface()函数时会有介绍)的功能。
6.com组件的内存管理采用引用计数的方式,即对当前调用的接口及对象都保存一个count值,当值为0时,可以用IUnKnown的Release()函数将其从内容中释放,每当有一个新指针获取接口和对象时,该值++。
三、com对象的基本使用
1.使用前的初始化
和某些高级语言在定义变量时需要初始化一样,在使用com库前,我们也需要对其进行初始化,这里要用到其自带的
CoInitialize()
函数(objbase.h库中),它有一个保留参数,使用时传入NULL即可。
2.调用com库的接口
使用
CoCreateInstance()
函数,原型如下:
HRESULT CoCreateInstance(
[in] REFCLSID rclsid,
[in] LPUNKNOWN pUnkOuter,
[in] DWORD dwClsContext,
[in] REFIID riid,
[out] LPVOID *ppv
);
第一个参数为实例对象所调用coclass的GUID;第二个参数如果为NULL,则指示对象未作为聚合的一部分创建。 如果为非NULL,则指向聚合对象的
IUnknown
接口的指针 (控制 IUnknown) ;第三个参数用于管理创建的新对象与将在其中运行的上下文。具体可查阅
CLSCTX
;第四个参数是调用该coclass的接口的GUID;第五个是实例化对象的指针(作为返回值)。
3.创建好实例后,调用接口内的函数。
其实在具体实现上,和调用类的函数没有什么两样,后面会用示例展示。
4.实例指针的释放
和C++使用new开辟空间后需使用delete进行空间释放一样;函数运行完成后,调用的com组件若不释放,仍会占用内存。此时可以调用
Release()
函数对其进行释放。(注:所有com接口都继承IUNKNOWN)。
5.COM库的关闭
程序结束时,需使用与初始化对应(类似于C++中类的析构函数)的函数CoUninitialize(),函数作用是关闭当前线程上的 COM 库,卸载线程加载的所有 DLL,释放线程维护的任何其他资源,并强制关闭线程上的所有 RPC 连接。
四、使用com组件示例
下面是使用一些com组件实现用对话框展示当前打开文件的路径的案例,借鉴自
文章
。
总体的步骤如下:打开文件对话框,判断是否打开文件,若打开则记录文件路径,用对话框将获取的路径展示。用到的组件主要有:
IFileOpenDialog
、
IShellItem
、
MessageBow
,依次组件的作用为:打开文件对话框并打开文件、获取文件路径、将路径信息展示至对话框中,具体作用可可通过上述链接进行查看。
下面代码的意思是实例化一个调用IFileOpenDialog接口的指针,其中CLSID_FileOpenDialog结合IID_IFileOpenDialog是设定好的。
IFileOpenDialog *pFileOpen;
hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_ALL,
IID_IFileOpenDialog, reinterpret_cast<void**>(&pFileOpen));
如何判断操作有没有成功呢?这里用hr接收这个返回值,并用宏
SUCCEED()
对其进行判断。若操作成功,则可用实例指针进行下一步操作,操作模式为:
hr = //使用接口
if (SUCCEEDED(hr)){
//之前的操作成功,下一步操作
}
else{
//某些错误发生,对错误进行处理
}
2.在这个实例中,若调用成功,则需要展示出文件选择的对话框,展示出对话框后,需判断是否打开文件,一个个的嵌套条件语句。代码如下:
if (SUCCEEDED(hr))
{
// 打开文件选择对话框
hr = pFileOpen->Show(NULL);
if (SUCCEEDED(hr))
{
//下面选择文件
IShellItem *pItem;
//获取行为结果,即是否选择文件
hr = pFileOpen->GetResult(&pItem);
if (SUCCEEDED(hr))
{
//储存文件路径的字符串
PWSTR pszFilePath;
//调用IShellItem接口的GetDisplayName获取路径名
hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath);
//用MessageBox,将获取的结果展示到对话框
if (SUCCEEDED(hr))
{
//设置对话框的格式
MessageBox(NULL, pszFilePath, L"File Path", MB_OK);
//注意,前面我们用pszFilePath指向了函数返回的存放打开文件路径的指针,在使用完后需要手动释放
CoTaskMemFree(pszFilePath);
}
//释放pItem实例
pItem->Release();
}
}
//释放pFileOpen实例
pFileOpen->Release();
}
此外:这里本人使用的IDE是VScode,gcc版本为gcc version 8.1.0 (x86_64-win32-seh-rev0, Built by MinGW-W64 project),我直接编译是不成功的,提示“’IID_IFileOpenDialog’ was not declared in this scope”,原因在
MinGW会将NTDDT_VERSION的值设置为0x05020000
是MinGW会将NTDDT_VERSION的值设置为0x05020000
MinGW会将NTDDT_VERSION的值设置为0x05020000
,需将其修改,参考
文章
;若出现提示“undefined reference to _imp__CoInitialize
undefined reference to _imp__CoInitialize
”,则为未链接ole32库,需在编译时手动链接,参考
文章
,在下文注释里有。
整体代码如下:
#define NTDDI_VERSION 0x0A000006 //NTDDI_WIN10_RS5
#define _WIN32_WINNT 0x0A00 // _WIN32_WINNT_WIN10, the _WIN32_WINNT macro must also be defined when defining NTDDI_VERSION
#include <windows.h>
#include <shobjidl.h>
#include <iostream>
#include <stdlib.h>
/*
g++ -static-libgcc -static-libstdc++ test.cpp -o test.exe -lole32 -luuid
*/
int main(){
//初始化com库
HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED |
COINIT_DISABLE_OLE1DDE);
if (SUCCEEDED(hr))
{
// 创建调用IOpenDialog接口的实例,pFileOpen,是返回的实例指针
IFileOpenDialog *pFileOpen;
hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_ALL,
IID_IFileOpenDialog, reinterpret_cast<void**>(&pFileOpen));
if (SUCCEEDED(hr))
{
// 打开文件选择对话框
hr = pFileOpen->Show(NULL);
if (SUCCEEDED(hr))
{
//下面选择文件
IShellItem *pItem;
//获取行为结果,即是否选择文件
hr = pFileOpen->GetResult(&pItem);
if (SUCCEEDED(hr))
{
//储存文件路径的字符串
PWSTR pszFilePath;
//调用IShellItem接口的GetDisplayName获取路径名
hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath);
//用MessageBox,将获取的结果展示到对话框
if (SUCCEEDED(hr))
{
//设置对话框的格式
MessageBox(NULL, pszFilePath, L"File Path", MB_OK);
//注意,前面我们用pszFilePath指向了函数返回的存放打开文件路径的指针,在使用完后需要手动释放
CoTaskMemFree(pszFilePath);
}
//释放pItem实例
pItem->Release();
}
else {
}
}
//释放pFileOpen实例
pFileOpen->Release();
}
//关闭当前线程的com库,并释放加载的DLL文件
CoUninitialize();
}
return 0;
}
在初步了解com组件后,我们进一步深入学习,下面我们学习如何自己编写一个com组件,并将其注册至注册表中,通过本地的com服务器对其进行调用,在控制台中打印”生成组件的CLSID和helloworld”。
五、用C++编写自己的com组件,注册并使用
在初步了解com组件后,我们进一步深入学习,下面我们学习如何自己编写一个com组件,并将其注册至注册表中,通过本地的com服务器对其进行调用,在控制台中打印”生成组件的CLSID和helloworld”。
1.工程文件Mydemo结构:
总体分为两个部分:1.服务端工程CompTest:生成一个dll文件,手动注册CompTestClass组件 2.客户端工程CtrlTest:生成一个exe文件,使用本地的com服务器对注册的组件的接口进行调用、输出注册组件的CLSID以及“hello wolrd”。
第2个步骤的调用细节在上文有介绍,这里不再细说,主要讲讲第1个步骤。
2.文件执行流程解释:
在实操之前,可以通过此
文章
了解一下引用计数、类厂、和注册。
3.手动注册CompTest组件步骤:
(1)使用VS的guidgen.exe生成GUID(位于VS/Common7/Tools文件夹下)
(2)重写注册函数
这里我使用的是“regsvr32.exe”对生成的dll进行注册,而实际上这个注册过程仅调用了生成的dll中的DllRegisterSever引出函数,故对其重写。
这个是注册时的核心部分,下面我们只需DllRegisterSever中调用此函数即可,分离实现使代码易懂。
int myReg(LPCWSTR lpPath) //use to register this component to registry, including CLSID, lpPath, ProgID
{
HKEY thk, tclsidk;
//打开键HKEY_CLASSES_ROOT\CLSID,创建新键为CompTestClass的CLSID
//此处我生成的CLSID为"{15BAAFA6-C0AA-430F-9C79-3F8860CF77F4}"
//在该键下创建键InprocServer32,并将本组件(dll)所在路径lpPath写为该键的默认值
if (ERROR_SUCCESS == RegOpenKey(HKEY_CLASSES_ROOT, L"CLSID", &thk))
{
if (ERROR_SUCCESS == RegCreateKey(thk, L"{15BAAFA6-C0AA-430F-9C79-3F8860CF77F4}", &tclsidk))
{
HKEY tinps32k;
if (ERROR_SUCCESS == RegCreateKey(tclsidk, L"InprocServer32", &tinps32k))
{
if (ERROR_SUCCESS == RegSetValue(tinps32k, NULL, REG_SZ, lpPath, wcslen(lpPath) * 2))
{
}
RegCloseKey(tinps32k);
}
RegCloseKey(tclsidk);
}
RegCloseKey(thk);
}
//在键HKEY_CLASSES_ROOT下创建新键为COMCTL.CompTest,
//在该键下创建子键,并将CompTestClass的CLSID写为该键的默认值
//通过此操作,我们通过FrogID找到目标的CLSID,然后再对其进行调用
if (ERROR_SUCCESS == RegCreateKey(HKEY_CLASSES_ROOT, L"COMCTL.CompTest", &thk))
{
if (ERROR_SUCCESS == RegCreateKey(thk, L"CLSID", &tclsidk))
{
if (ERROR_SUCCESS == RegSetValue(tclsidk,
NULL,
REG_SZ,
L"{15BAAFA6-C0AA-430F-9C79-3F8860CF77F4}",
wcslen(L"{15BAAFA6-C0AA-430F-9C79-3F8860CF77F4}") * 2))
{
cout << "register success" << endl;
}
RegCloseKey(tclsidk);
}
RegCloseKey(thk);
}
return 0;
}
下面是DllRegisterSever函数,负责调用自己重写的注册函数。
extern "C" HRESULT _stdcall DllRegisterServer()
{
//获取当前模块的文件路径,用于存入注册表中。
WCHAR szModule[1024];
DWORD dwResult = GetModuleFileName(g_hModule, szModule, 1024);
if (0 == dwResult)
{
return -1;
}
MessageBox(NULL, szModule, L"path", MB_OK);
//调用重写的reg函数进行注册
myReg(szModule);
return 0;
}
同理,对于此Dll文件,有注册,那么肯定也需要有一个函数,可将文件从注册表中移除,这里主要设计到DllUnregisterServer()函数和
DllCanUnloadNow()
函数,过程与当前步骤类似,不作赘述,代码于附件处查询,需要注意的是,此处的内容涉及到com组件引用计数的知识,可以先了解后再重写函数。
注:生成的GUID有可能无效
本人第一次生成的GUID,发现调用CLSIDFromProgID()函数时一直运行不成功,后面调试发现返回错误码为”CO_E_CLASSSTRING“,即ProgID 的已注册 CLSID 无效。此时,需要重新生成一个GUID并重新注册一次。
(3)重写与Dll调用相关的函数及类
com组件注册之后,是如何被调用并将组件加载到内存呢?
前文有提到,当我们初次调用某一组件的接口时,会使用CoCreateInstance()函数,其实它本质上是封装了如下功能
CoGetClassObject(rclsid, dwClsContext, NULL, IID_IClassFactory, &pCF);
hresult = pCF->CreateInstance(pUnkOuter, riid, ppvObj);
pCF->Release();
这里又出现了一个新函数
CoGetClassObject()
,它在这的作用是通过注册表找到对于的DLL文件并将其加载至进程,而后调用该DLL的DllGetClassObject函数返回组件所属类厂对象的IClassFactory指针,最后IClassFactory通过调用QueryInterface(),返回确定组件的接口指针。
简而言之,就是要重写DLL文件的DLLGetClassObject方法使得其能被此组件对象类能被成功调用。
此处关于可能出现的错误码”0x80040154″
上文提到,我第一次生成的GUID码无效,于是我又重新生成GUId码注册了一遍,但是在组件类上忘记及时更新此CLSID了。由于我这里使用条件语句,判定只有类中CLSID码与从注册表中获取的CLSID码比对成功,类厂才能调用此组件,否则会返回错误码”CLASS_E_CLASSNOTAVAILABLE“,即十六进制下的”0x80040154″,意为未在注册表中找到此CLSID对应的注册类。
extern "C" HRESULT _stdcall DllGetClassObject(__in REFCLSID rclsid, __in REFIID riid, LPVOID FAR * ppv)
{
if (CLSID_CompTestClass == rclsid)//注册用的CLSID被保存,用来检验客户端传入的CLSID是否正确
{
CompTestFactory* pFactory = new CompTestFactory();
if (NULL == pFactory)
{
//创建后指针认为空指针,可能出现了越界错误。
return E_OUTOFMEMORY;
}
//创建成功,返回客户端传入的目的接口
HRESULT result = pFactory->QueryInterface(riid, ppv);
return result;
}
else
{
//若不正确,此处会返回错误码:0x80040111,并随后在客户端输出此错误码
return CLASS_E_CLASSNOTAVAILABLE;
}
}
那么除了重写DllGetClassObject之外,根据上面描述的CoCreateInstance()函数的执行过程,我们还需要自己实现一个Factory类,用它来创建com对象
,本质上就是重写一下它所继承的
IClassFactory
接口内的函数。
代码直接从附件处查询,此处就不赘述了。
下面分别给出factory.h和factory.cpp的代码。
(4)类厂代码
factory.h
#pragma once
#include <Unknwnbase.h>
class CompTestFactory :
public IClassFactory
{
public:
CompTestFactory();
~CompTestFactory();
virtual HRESULT _stdcall QueryInterface(const IID& riid, void** ppvObject);
virtual ULONG _stdcall AddRef();
virtual ULONG _stdcall Release();
virtual HRESULT _stdcall CreateInstance(IUnknown* pUnknown, const IID& riid, void** ppvObject);
virtual HRESULT _stdcall LockServer(BOOL fLock);
protected:
ULONG m_Ref;
};
factory.cpp
#include "factory.h"
#include "CompTestClass.h"
CompTestFactory::CompTestFactory()
{
m_Ref = 0;
}
CompTestFactory::~CompTestFactory()
{
}
HRESULT _stdcall CompTestFactory::QueryInterface(const IID& riid, void** ppvObject)
{
if (IID_IUnknown == riid)
{
*ppvObject = (IUnknown*)this;
((IUnknown*)(*ppvObject))->AddRef();
}
else if (IID_IClassFactory == riid)
{
*ppvObject = (IClassFactory*)this;
((IClassFactory*)(*ppvObject))->AddRef();
}
else
{
*ppvObject = NULL;
return E_NOINTERFACE;
}
return S_OK;
}
ULONG _stdcall CompTestFactory::AddRef()
{
m_Ref++;
return m_Ref;
}
ULONG _stdcall CompTestFactory::Release()
{
m_Ref--;
if (0 == m_Ref)
{
delete this;
return 0;
}
return m_Ref;
}
HRESULT _stdcall CompTestFactory::CreateInstance(IUnknown* pUnkOuter, const IID& riid, void** ppvObject)
{
if (NULL != pUnkOuter)
{
return CLASS_E_NOAGGREGATION;
}
HRESULT hr = E_OUTOFMEMORY;
CompTestClass::Init();
CompTestClass* pObj = new CompTestClass();
if (NULL == pObj)
{
return hr;
}
hr = pObj->QueryInterface(riid, ppvObject);
if (S_OK != hr)
{
delete pObj;
}
return hr;
}
HRESULT _stdcall CompTestFactory::LockServer(BOOL fLock)
{
return NOERROR;
}
基本就是实现继承接口的方法。
上文提到的IUnKnown有查询接口的作用,此处解释。
这里我觉得比较值得有意思的点是
QueryInterface()
函数,这是IUnKnown的方法,前面我们提到所有的COM接口都直接或间接地继承IUnKnown。因此,在实例化一个类时,我们一般都先调用该类的IUnKnown接口(因为其一定存在),那么问题来了,如何拿到我想要使用的哪个接口呢?很简单,提供要使用接口的IID,使用QueryInterface()函数让其返回该接口的指针就好了。那么它到底是如何实现的呢?更简单,使用条件语句将IID和接口地址一一对应即可。如下面的代码:
HRESULT _stdcall CompTestFactory::QueryInterface(const IID& riid, void** ppvObject)
{
if (IID_IUnknown == riid)
{
*ppvObject = (IUnknown*)this;
((IUnknown*)(*ppvObject))->AddRef();
}
else if (IID_IClassFactory == riid)
{
*ppvObject = (IClassFactory*)this;
((IClassFactory*)(*ppvObject))->AddRef();
}
else
{
*ppvObject = NULL;
return E_NOINTERFACE;
}
return S_OK;
}
此函数还能验证你提供的IID是否存在于此类中,也即没有找到匹配的IID就置传入的接收指针为空,并返回错误码。这篇
文章
对QueryInterface()函数也作了很通俗的解释,本人也从中获益匪浅。
此DLL文件是用def文件进行导出的,把会使用的到几个函数名写上即可。更多细节可参考
此文
。
(5)def文件
LIBRARY "CompTest"
EXPORTS
DllCanUnloadNow PRIVATE
DllGetClassObject PRIVATE
DllUnregisterServer PRIVATE
DllRegisterServer PRIVATE
(5)HelloWorld接口头文件如下:
(注:IID是使用Guidgen生成的,详情看上文CLSID的注册)
#pragma once
#include <Unknwn.h>
// {54D9F00E-9866-49E8-9B13-5A39D45D180A}
static const GUID IID_ICompTest =
{ 0x54d9f00e, 0x9866, 0x49e8, {0x9b, 0x13, 0x5a, 0x39, 0xd4, 0x5d, 0x18, 0xa} };
class ICompTest : public IUnknown
{
public:
virtual char* _stdcall HelloWorld() = 0;
};
HelloWorld函数具体的重写代码请在附件处查看。
补充一些细节:
本人使用的编译器是VS2022,与正常编译exe文件不同,要正确生成Dll还需修改一些设置。
保存使用Ctrl+B运行生成即可。
那么最后就是编写一个接口,包含sayhello这个方法,同时令上面写好的组件类继承该接口并实现即可。
六、学习体会及附件
本人也是刚刚接触com组件,还有很多东西可能理解不算透彻,仅以此文用于学习的记录于整理,若有错误请多多包涵。此外,这个demo仅是单线程,待进一步的学习后,可以尝试多进程下com组件的编写。这个demo比单线程的demo会多一个“锁”。假设这么一个场景,线程1调用了某接口,线程2也调用了此接口,当线程1调用完毕后,此接口引用计数–,为0时我们手动释放,这个过程如果不连贯,则会导致,在手动释放之前,线程2先拿到此时接口的地址,而当它调用接口时实际上接口已经被释放了却不知情,导致出现越界。