特别说明 —— 该系列文章皆转自知乎作者 Froser 的COM编程攻略:https://www.zhihu.com/column/c_1234485736897552384
上一篇讲了COM模块的基本构成。在这一篇,我们将起一个头,一步步编写一个跨进程COM组件。简单起见,我们实现一个简单的处理MessageBox的进程ATLProject2.exe(这个名字随便起的),它是服务器。它暴露了一个IMessage接口给客户端调用。客户端叫做ConsoleApplication1.exe,它会跨进程与ATLProject2.exe通信,拿到其IMessage接口,然后调用里面的方法。
这个实战里面包括了很多我们之前还没有说到的概念,例如Apartment, Marshal, Stub, Proxy等。这些以后会详细说明,这里只是出现了的情况稍微解释一下。
新建一个ATL exe工程:ATLProject2。VS生成了如下工程:
ATLProject2主工程主要实现我们所定义的接口。
ATLProject2.cpp中定义了exe的程序入口、AppID等。它会调用Atl模块的WinMain函数,来实现消息循环。
using namespace ATL;
class CATLProject2Module : public ATL::CAtlExeModuleT< CATLProject2Module >
{
public :
DECLARE_LIBID(LIBID_ATLProject2Lib)
DECLARE_REGISTRY_APPID_RESOURCEID(IDR_ATLPROJECT2, "{4a02363c-cbc1-403a-905e-8ae116ccd454}")
};
CATLProject2Module _AtlModule;
//
extern "C" int WINAPI _tWinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/,
LPTSTR /*lpCmdLine*/, int nShowCmd)
{
return _AtlModule.WinMain(nShowCmd);
}
ATLProject2.idl中定义了我们需要的接口:
import "oaidl.idl";
import "ocidl.idl";
[object, uuid(59371D05-3025-4E52-AF88-977B219F0E97)]
interface IMessage : IUnknown
{
HRESULT Show([in]VARIANT szText);
};
[
uuid(4a02363c-cbc1-403a-905e-8ae116ccd454),
version(1.0),
]
library ATLProject2Lib
{
importlib("stdole2.tlb");
[uuid(3A68BEBC-3A60-46A5-8CA1-508C1406B73D)]
coclass Message
{
interface IMessage;
};
};
MIDL我们还没有介绍过。简单来说,它会生成对应的C++的接口。
上述idl描述了一个IMessage接口,继承于IUnknown。同时还指定了一个类对象Message,用于创建IMessage实例。
ATLProject2.rgs是注册表脚本。当ATLProject2.exe编译好后,编译器会调用ATLProject2.exe /RegServer,接着ATL模块的WinMain函数会开始解析rgs文件,并把它的内容写进注册表。我们的rgs内容如下:
HKCR
{
NoRemove CLSID
{
{3A68BEBC-3A60-46A5-8CA1-508C1406B73D} = s 'Message'
{
ProgID = s 'Message'
LocalServer32 = s '%MODULE%'
}
}
}
它的作用是在HKCR\CLSID下注册Message类,这样CoCreateInstance才可以找到它。
MessageBox.h, MessageBox.cpp主要是实现了IMessage接口,以及Message类对象:
// .h
#pragma once
#include "ATLProject2_i.h"
#include
using namespace ATL;
class MessageImpl
: public IMessage
, public CComObjectRoot
{
BEGIN_COM_MAP(MessageImpl)
COM_INTERFACE_ENTRY(IMessage)
END_COM_MAP()
STDMETHODIMP Show(VARIANT szText) override;
};
class Message
: public CComObjectRoot
, public CComCoClass<MessageImpl, &CLSID_Message>
{
public:
DECLARE_REGISTRY_RESOURCEID(IDR_ATLPROJECT2);
};
OBJECT_ENTRY_AUTO(CLSID_Message, Message);
// 可以将Message这些继承和DECLARE_REGISTRY_RESOURCEID放在MessageImpl中,这也是M$推荐的做法
// .cpp
#include "pch.h"
#include "MessageBox.h"
STDMETHODIMP MessageImpl::Show(VARIANT szText)
{
MessageBoxW(NULL, szText.bstrVal, L"From Remote", MB_OK);
return S_OK;
}
我们简单地就是调用MessageBoxW,表示IMessage::Show被调用到了,并且传入了需要显示的内容。OBJECT_ENTRY_AUTO、CComCoClass的用法,不记得的同学可以看上一篇文章。
Message作为一个类对象,继承了CComCoClass,通过DECLARE_REGISTRY_RESOURCEID宏可以注册自己的AppID。
ATLProject2_i.h由MIDL解析idl文件生成,把idl翻译成了C++可以识别的代码。里面声明了IMessage的IID,Message的CLSID等。
ATLProject2PS工程表示的是一个ProxyStub(代理桩),它生成一个dll。由于ATLProject2.exe是作为服务exe来运行,它需要接收客户端发来的消息。这个消息先会被ATLProject2PS.dll获取,然后反序列化(Unmarshal),接下来转调给ATLProject2.exe。ATLProject2.exe执行后,也将通过ProxyStub将结果返回给客户端。
ATLProject2_p.c和dlldata.c是MIDL自动生成的ProxyStub代码。里面包含了DllGetClassObject、DllCanUnloadNow、DllRegisterServer、DllUnregisterServer等4个函数(后者使用了REGISTER_PROXY_DLL则可以生成。VS生成的工程会自动带上此宏)。
编译好ATLProject2.exe, ATLProject2PS.dll后,我们需要运行regsvr32.dll ATLProject2PS.dll,将它注册到注册表。这样,ATLProject2.exe运行的时候,则会通过注册表找到此ProxyStub。
现在你可以运行ATLProject2.exe了,不过你会发现,它没有界面,就像服务一样,常驻在了后台。
我们接下来编写一个客户端工程来尝试连接一下服务。新建一个ConsoleApplication1的控制台工程,代码如下:
#include
#include “…/ATLProject2/ATLProject2_i.h”
#include
using namespace ATL;
int main()
{
HRESULT hr = CoInitialize(NULL);
if (SUCCEEDED(hr))
{
{
CComPtr cpMessage;
hr = CoCreateInstance(CLSID_Message, NULL, CLSCTX_LOCAL_SERVER, IID_IMessage, (void**)&cpMessage);
CComVariant var(L"HELLO");
hr = cpMessage->Show(var);
}
CoUninitialize();
}
return hr;
}
首先,通过CoInitialize初始化一个STA,接下来调用CoCreateInstance。我们注意第三个参数是CLSCTX_LOCAL_SERVER,它表示我们要启动一个进程外的服务,而不是之前一章说的进程内的服务。于是COM会在HKCR\CLSID里面找到CLSID_Message,并寻找它的LocalServer32键:
CoCreateInstance如果创建的是进程外服务,那么会在LocalServer32键中寻找服务路径并启动
接下来,取出LocalServer32中的默认值,如果服务没有启用,那么它就简单地调用CreateProcess启动服务。服务启动后,它便可以通过ProxyStub,顺利地从CLSID_Message中拿到一个新的IMessage接口了。调用IMessage::Show之后,服务端也可以顺利响应。
最后我们需要调用CoUninitialize释放STA,到此我们的工程就已经写完了。运行ConsoleApplication1.exe,我们可以看到,一个MessageBox弹了出来:
从客户端到服务端,发生了什么?
我们先看服务端启动时,发生了什么事情。
服务端最核心的功能实现在了CAtlExeModuleT::Run()中。
**首先,**它依次将我们所有的OBJECT_ENTRY_AUTO的类对象作为参数调用了CoRegisterClassObject。CoRegisterClassObject的作用是将类对象注册到服务控制管理(SCM)中。因为我们的服务是一个exe,所以客户端是不会去调用DllGetClassObject来获取的,需要服务exe手动注册,这样CoGetClassObject就可以直接获取类对象。截取代码如下,调用CoRegisterClassObject实现在了pT->RegisterClassObjects()。
HRESULT PreMessageLoop(_In_ int /*nShowCmd*/) throw()
{
HRESULT hr = S_OK;
T* pT = static_cast<T*>(this);
....
hr = pT->RegisterClassObjects(CLSCTX_LOCAL_SERVER,
REGCLS_MULTIPLEUSE | REGCLS_SUSPENDED);
if (FAILED(hr))
return hr;
if (hr == S_OK)
{
...
CHandle h(pT->StartMonitor());
if (h.m_h == NULL)
{
hr = E_FAIL;
}
else
{
hr = CoResumeClassObjects();
ATLASSERT(SUCCEEDED(hr));
if (FAILED(hr))
{
::SetEvent(m_hEventShutdown); // tell monitor to shutdown
::WaitForSingleObject(h, m_dwTimeOut * 2);
}
}
...
}
...
}
**接下来,**它会创建一个监控线程。这个监控线程用于给服务端发送WM_QUIT。代码实现在了pT->StartMonitor()。
HANDLE StartMonitor() throw()
{
m_hEventShutdown = ::CreateEvent(NULL, false, false, NULL);
if (m_hEventShutdown == NULL)
{
return NULL;
}
DWORD dwThreadID;
HANDLE hThread = ::CreateThread(NULL, 0, MonitorProc, this, 0, &dwThreadID);
if(hThread==NULL)
{
::CloseHandle(m_hEventShutdown);
m_hEventShutdown = NULL;
}
return hThread;
}
StartMonitor的实现原理很简单,就是创建一个线程,处理函数MonitorProc阻塞在了发送WM_QUIT之前。当程序需要结束的时候,设置m_hEventShutdown信号,让MonitorProc继续,最终得以发送WM_QUIT:
void MonitorShutdown() throw()
{
::WaitForSingleObject(m_hEventShutdown, INFINITE);
::CloseHandle(m_hEventShutdown);
m_hEventShutdown = NULL;
::PostThreadMessage(m_dwMainThreadID, WM_QUIT, 0, 0);
}
static DWORD WINAPI MonitorProc(_In_ void* pv) throw()
{
CAtlExeModuleT<T>* p = static_cast<CAtlExeModuleT<T>*>(pv);
p->MonitorShutdown();
return 0;
}
值得一提的是,ATL通过REGCLS_SUSPENDED参数注册了一个挂起的类对象,在一切准别就绪后,调用CoResumeClassObjects(),让客户端能够正式请求到这个类对象。
**第三步,**如果上面都成功了,那么通过RunMessageLoop进入OLE消息循环,来接收来自客户端的消息:
void RunMessageLoop() throw()
{
MSG msg;
while (GetMessage(&msg, 0, 0, 0) > 0)
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
最后,如果程序结束了,那么这个消息循环将会退出,因为GetMessage结果将会小于0 (参考https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage)。程序调入PostMessageLoop,将CoRegisterClassObject中注册的类对象进行反注册:
HRESULT PostMessageLoop() throw()
{
HRESULT hr = S_OK;
#ifndef _ATL_NO_COM_SUPPORT
T* pT = static_cast<T*>(this);
hr = pT->RevokeClassObjects();
if (m_bDelayShutdown)
Sleep(m_dwPause); //wait for any threads to finish
#endif // _ATL_NO_COM_SUPPORT
return hr;
}
pT->RevokeClassObjects() 最终调入了CoRevokeClassObject,它与CoRegisterClassObject是成对存在的,它最终将类对象从SCM中移除,这样客户端就再也请求不到这个类对象了。
客户端发生的事情:
客户端发生的事情比较直观,首先是通过CoCreateInstance,启动服务,并且从SCM中取出类对象Message。接下来将Message QI为IClassFactory,并调用其CreateInstance方法,创建一个IMessage对象。
COM隐藏了底层的传输细节。由于这个是跨进程通讯,所以我们的请求先会被序列化(Marshal),通过Proxy和Stub,走了底层的RPC通道(ORPC),发给了服务端Stub,服务端Stub将其反序列化(Unmarshal),还原成接口,转调到服务端exe,进行真正的调用。
看到这里,相信不熟悉COM的人,可能还是一头雾水吧。
COM底层到底发生了什么,也许只有等到开源的那一天才知道(也可能那一天永远都不会到),我一直都认为开源才是学习的最好途径,不然就只能天天翻MSDN的文档,看着它非常空洞的说明。
不管怎么说,VS还是提供了一个较为友好的模板,不过COM这一整套下来门槛也不低,代码更是不怎么好看。偏偏整个Windows都是基于COM来的,如果想做好一个Windows开发,不了解COM看来也不行。