COM编程攻略(七 COM跨进程组件开发实战)

特别说明 —— 该系列文章皆转自知乎作者 Froser 的COM编程攻略:https://www.zhihu.com/column/c_1234485736897552384


文章目录

  • 前言
  • 一、第一步,我们通过VS的新建模板。
  • 二、第二步,编译
  • 三、第三步,新建客户端工程


前言

上一篇讲了COM模块的基本构成。在这一篇,我们将起一个头,一步步编写一个跨进程COM组件。简单起见,我们实现一个简单的处理MessageBox的进程ATLProject2.exe(这个名字随便起的),它是服务器。它暴露了一个IMessage接口给客户端调用。客户端叫做ConsoleApplication1.exe,它会跨进程与ATLProject2.exe通信,拿到其IMessage接口,然后调用里面的方法。

这个实战里面包括了很多我们之前还没有说到的概念,例如Apartment, Marshal, Stub, Proxy等。这些以后会详细说明,这里只是出现了的情况稍微解释一下。


一、第一步,我们通过VS的新建模板。

新建一个ATL exe工程:ATLProject2。VS生成了如下工程:
COM编程攻略(七 COM跨进程组件开发实战)_第1张图片
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键:
COM编程攻略(七 COM跨进程组件开发实战)_第2张图片
CoCreateInstance如果创建的是进程外服务,那么会在LocalServer32键中寻找服务路径并启动

接下来,取出LocalServer32中的默认值,如果服务没有启用,那么它就简单地调用CreateProcess启动服务。服务启动后,它便可以通过ProxyStub,顺利地从CLSID_Message中拿到一个新的IMessage接口了。调用IMessage::Show之后,服务端也可以顺利响应。

最后我们需要调用CoUninitialize释放STA,到此我们的工程就已经写完了。运行ConsoleApplication1.exe,我们可以看到,一个MessageBox弹了出来:
COM编程攻略(七 COM跨进程组件开发实战)_第3张图片
从客户端到服务端,发生了什么?

我们先看服务端启动时,发生了什么事情。

服务端最核心的功能实现在了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看来也不行。


你可能感兴趣的:(COM,windows,c++)