C++ 中的 COM






4.38/5 (37投票s)
使用纯 C++ 创建 COM 组件,不使用 ATL
引言
这是一篇关于如何使用纯 C++(不使用 ATL)编写 COM 组件的文章。在我开始这个项目时,我遇到了一个问题,就是在互联网上很少有关于如何编写自己的 COM 代码的有用文章,所以我决定分享这段代码,以防有人觉得它有用。
该项目包含三个组件。第三个组件包含了前两个。
背景
由于我花了很多时间寻找漂亮的 COM 对象代码示例,所以我想我不得不尝试自己写一个(可能不太好,但给我个机会,好吗? )。如果你不知道从哪里开始,可以阅读 MSDN 上的这篇文章:http://msdn.microsoft.com/en-us/library/ms683835(v=VS.85).aspx。(如果链接失效,它叫做“COM 客户端和服务器”)。我也推荐你阅读 Rodgerson 的书《Inside COM》。
使用代码
如果你不是 COM 新手,可以跳过接下来的几个段落,因为我将为刚开始编写 COM 代码的人们解释清楚。
让我们看看COM 对象和普通 C++ 类之间的区别
类:使用一个接口,该接口提供大量 C++ 方法;依赖于编程语言;没有内置的版本控制。
COM:通常实现不止一个接口;提供与编程语言的独立性(COM 在不同语言中使用和实现);内置版本控制;提供与硬盘上位置的独立性。
使用像 C 和 Java 这样的面向对象语言开发的主要优点之一是能够有效地封装内部函数和数据。这是由于这些语言的面向对象特性。在对象的“外部”只有一个定义良好的接口,它允许外部客户端有效地使用对象的 [功能]。COM 技术通过定义实现和提供 COM 对象接口的标准方法来提供这些功能。
这里有两个DLL项目。Com2x 项目包含两个 COM 对象的实现(我称它们为 BVAA 和 BVAB)。
首先是描述接口。接口就像一个虚拟类。你可以在一个.h文件或一个.idl文件中使用MIDL来描述它。在这个例子中,我在一个简单的.h文件中完成了它。请注意,接口必须继承IUknown
。
COM 技术提供了一组需要实现的抽象类。构建 COM 组件时,你需要实现的第一个接口是所有 COM 组件都会使用的接口:IUnknown
。组件不仅应该实现IUnknown
接口,还应该为它的每个接口提供实现。起初这可能看起来很复杂,但这是这项技术的基础。大多数 COM 组件提供多个接口。请记住:COM 接口只是一个 C 接口的指针。
IUnknown
接口执行两个功能。第一个是提供一种标准的方法,让用户(客户端)查询组件的特定接口。这个功能提供了QueryInterface
方法。第二个功能是提供一种从外部控制组件生命周期的方法。为此,IUnknown
接口提供了两个方法:AddRef
和Release
,用于控制组件实例的生命周期。这是在ObjBase.h中定义的IUnknown
。
class IUnknown
{
public:
virtual HRESULT QueryInterface(REFID riid, void** ppv)=0;
virtual ULONG AddRef () = 0;
virtual ULONG Release() = 0;
};
由于IUnknown
是一个 COM 接口的声明,它是一个抽象类。任何派生类都必须实现前面描述的三个方法,从而将它们添加到虚表中。在继续之前,让我们谈谈QueryInterface
函数的返回类型,它是HRESULT
类型。
COM 接口和 API 函数的大部分方法都返回HRESULT
类型的值(AddRef
和Release
是例外)。在 Win32 中,HRESULT
数据类型定义为DWORD
(32 位整数),该类型返回的值包含有关函数调用结果的信息。最高有效位指示函数是成功还是错误关闭,接下来的 15 位标识错误类型并提供一种分组相似代码完成的方式,而较低的 16 位提供有关事件的具体信息。HRESULT
数据结构与 Win32 API 函数使用的状态标志值相同。
COM 开发系统提供了一些宏来帮助你了解调用方法的结果。SUCCEEDED
宏在函数调用成功时返回TRUE
,而FAILED
宏在调用ERROR
函数时返回相同的值。这些宏不是 COM 和 ActiveX 特有的,在整个 Win32 环境中使用,并且定义在WINERROR.H文件中。Win32 中的返回值在正常完成时以S_为前缀(例如,我们使用S_OK
),错误时以E_为前缀(E_FAILED
, E_OUTOFMEMORY
等)。
第一步是使用如下的抽象类定义组件接口
//IBVAA.h
#pragma once
#include <ObjBase.h>
// {5219B44A-0874-449E-8611-B7080DBFA6AB}
static const GUID IID_IBVAA_summer =
{0x5219b44a, 0x874, 0x449e, { 0x86, 0x11, 0xb7, 0x8, 0xd, 0xbf, 0xa6, 0xab} };
interface IBVAA_summer:IUnknown // summer
{
virtual HRESULT __stdcall Add(
const double x, // [in]????????? x
const double y, // [in]????????? y
double& z // [out] ????????? z = x+y
) = 0;
virtual HRESULT __stdcall Sub(
const double x, // [in]
const double y, // [in] ?????????? y
double& z // [out] ????????? z = x-y
) = 0;
};
// {8A2A00DD-8B8D-4898-B08E-000A6E40A2B5}
static const GUID IID_IBVAA_multiplier =
{ 0x8a2a00dd, 0x8b8d, 0x4898, { 0xb0, 0x8e, 0x0, 0xa, 0x6e, 0x40, 0xa2, 0xb5 } };
interface IBVAA_multiplier:IUnknown // multiplier
{
virtual HRESULT __stdcall Mul(
const double x, // [in]x
const double y, // [in]y
double& z // [out] z = x*y
) = 0;
virtual HRESULT __stdcall Div(
const double x, // [in] x
const double y, // [in] y
double& z // [out] z = x/y
) = 0;
};
第一个类IBVAA_summer
允许将两个数字相加和相减,并将结果传递到[out] z
变量中。第二个类IBVAA_multiplier
用于乘法和除法。
COM 最强大的功能之一是每个组件都可以提供,而且通常为单个对象提供多个接口。再想一想。在开发 C++ 类时,只创建一个接口。当你创建一个基于类的组件时,至少创建一个接口。通常,在构建 COM 组件时,你需要为 C++ 类的一个实例提供多个接口。
这些是我们第一个组件(BVAA)将实现的接口。现在,让我们为第二个对象 BVAB 编写一个接口。这个接口将实现幂运算和对数运算。
//IBVAB.h
#pragma once
#include <ObjBase.h>
// {59F4A881-5464-4409-9052-8C0D05828EFA}
static const GUID IID_IBVAB_power = { 0x59f4a881, 0x5464, 0x4409, { 0x90, 0x52, 0x8c, 0xd, 0x5, 0x82, 0x8e, 0xfa} };
interface IBVAB_power:IUnknown // power
{
virtual HRESULT __stdcall Pow(
const double x, // [in]x
const double y, // [in]y
double& z // [out] z = x^y
) = 0;
virtual HRESULT __stdcall Log(
const double x, // [in] x
const double y, // [in] y
double& z // [out] z=log_y(x)
) = 0;
};
BVAA 对象实现了两个接口:IBVAA_summer
, IBVAA_multiplier
(IBVAA.h)。BVAB 实现IBVAB_power
(IBVAB.h)。
//BVAA.h
class BVAA : public IBVAA_summer, public IBVAA_multiplier
//BVAB.h
class BVAB : public IBVAB_power
我们必须将包含我们接口的.h文件包含到描述组件的.h文件中。
组件的实现包括每个继承接口的实现。 这种方法的唯一问题是基接口IUnknown
可能存在冲突(因为类IBVAA
和IBVAB
都继承自IUnknown
),但在这种情况下不会发生任何事情,因为继承的类应该共同使用IUnknown
接口的实现。关键点在于 COM 组件必须提供多个接口,或者换句话说,几个指向虚表的指针。如果你通过多重继承来实现它,那么指针类型必须正确转换为指向虚表的指针。 因此,上面类的QueryInterface
方法将如下所示:
HRESULT STDMETHODCALLTYPE BVAA::QueryInterface(REFIID riid, void **ppv) {
HRESULT rc = S_OK;
*ppv = NULL;
// Multiple inheritance requires an explicit cast
if (riid == IID_IUnknown) *ppv = (IBVAA_summer*)this;
else if (riid == IID_IBVAA_summer) *ppv = (IBVAA_summer*)this;
else if (riid == IID_IBVAA_multiplier) *ppv = (IBVAA_multiplier*)this;
else rc = E_NOINTERFACE;
//Return a pointer to the new interface and thus call AddRef() for the new index
if (rc == S_OK) this->AddRef();
return rc;
}
指向IUnknown
接口的指针可以作为指向IBVAA
或IBVAB
的引用返回,因为这两个类都包含QueryInterface
方法。
每个 COM 对象都实现了IUknown
的方法。 IUknown
是定义在objbase.h中的结构,它有三个方法:QueryInterface
, AddRef
, Release
。当我们编写自己的对象时,我们必须实现这三个方法。 现在让我们仔细看看这些方法。
QueryInterface
方法引用接口标识符(Interface Identifier - IID),它代表一个 128 位唯一 ID(即 GUID,稍后将讨论),并返回一个指向 COM 对象提供的特定接口(例如,IUnknown
, IMath
)的指针。指针通过第二个参数返回,该参数是指向void
类型指针的指针。
为了管理组件的生命周期,IUnknown
接口提供了两个方法:AddRef()
和Release()
。通常,COM 组件有多个接口,每个接口可能与多个外部客户端相关联。请注意,在此示例中,组件实际上是一个 C++ 类,而我们目前正在讨论一个类的特定实例的生命周期管理。用户将通过某种机制创建实例,我们将对此进行讨论,并通过其 COM 接口利用该实例。原始副本将通过 C++ 的new
来创建,然后我们将尝试确定何时可以删除该实例。
由于 COM 组件的一个实例可以有多个与许多客户端关联的接口,我们的对象应该有一些引用来计算它的可能性(计数器)。每当客户端请求一个接口时,计数器的值就会增加,当客户端终止接口时——它就会减少。最终,当计数器达到零时,COM 组件将被销毁。为此,提供了IUnknown::AddRef()
和IUnknown::Release()
方法。
因此,在这个例子中,我们必须跟踪内部调用计数器的值。我们将这个计数器称为m_lRef
。当一个组件在请求时可用时,计数器值将增加,并在客户端接口结束时,通过调用IUnknown::Release()
来减少该值。当m_lRef
为 0 时,组件实例可以被删除。
#pragma once
#include "IBVAA.h"
class BVAA : public IBVAA_summer, public IBVAA_multiplier
{
protected:
// Reference count
long m_lRef;
public:
BVAA(void);
~BVAA(void);
//IUnknown
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid,void **ppv);
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
//IBVAA_summer
virtual HRESULT STDMETHODCALLTYPE Add(const double x, const double y, double& z);
virtual HRESULT STDMETHODCALLTYPE Sub(const double x, const double y, double& z);
//IBVAA_multiplier
virtual HRESULT STDMETHODCALLTYPE Mul(const double x, const double y, double& z);
virtual HRESULT STDMETHODCALLTYPE Div(const double x, const double y, double& z);
};
下一个组件是 BVAB
#pragma once
#include "IBVAB.h"
class BVAB : public IBVAB_power
{
protected:
// Reference count
long m_lRef;
public:
BVAB(void);
~BVAB(void);
//IUnknown
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid,void **ppv);
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
//IBVAB_power
virtual HRESULT STDMETHODCALLTYPE Pow(const double x, const double y, double& z);
virtual HRESULT STDMETHODCALLTYPE Log(const double x, const double y, double& z);
};
在实现这些方法时,我们将在AddRef(): InterlockedIncrement( &m_lRef );
中增加引用计数,并在Release(): InterlockedDecrement( &m_lRef )
中减少它。在Release()
方法中,如果我们看到没有对对象的引用,我们应该删除它:
delete this;
为了实现从类IUnknown
继承的AddRef()
和Release()
函数,我们引入了一个成员变量m_lRef
,它负责计算对象当前拥有的命中次数或待处理接口指针。尽管AddRef()
和Release()
函数可以改变 COM 接口调用计数器的值,但接口本身并不是对象的实例。一个对象一次可以有任意数量的用户,并且它们各自的接口应该跟踪活动接口的内部计数器值。如果这个值达到零,对象就会删除自身。
正确及时地使用AddRef()
和Release()
方法非常重要。这对函数类似于 C++ 中用于内存管理的new
和delete
运算符。一旦用户获得了一个新的接口指针,或者将其值赋给一个变量,就需要调用AddRef()
。在这种情况下,你必须非常小心,因为 COM 接口的某些功能会返回指向其他接口的指针,在这些情况下,它们会自己调用返回指针的AddRef()
方法。最明显的例子是QueryInterface()
方法,其中AddRef()
在每次请求接口时被调用,因此不需要再次调用AddRef()
。
类工厂
存储组件的文件还必须包含必要的工具,以确保在客户端请求时,以标准、独立于语言的方式创建该组件的实例。有一个标准的 COM 接口IClassFactory
,它应该确保按需从外部创建组件实例。下面是IClassFactory
的接口定义。像所有 COM 接口一样,它必须实现IUnknown
接口。
class IClassFactory : public IUnknown
{
public:
virtual HRESULT CreateInstance(LPUNKNOWN pUnk, REFIID riid, void** ppv)=0;
virtual HRESULT LockServer (BOOL fLock) = 0;
};
类工厂本身就是一个 COM 组件。它唯一的任务是简化其他 COM 组件的创建。每个组件,无论它是可执行文件还是 DLL 文件,都应该为每个可以按外部请求创建的组件提供类工厂实现。IClassFactory
的主要接口提供了两个方法:CreateInstance
(创建组件实例)和LockServer
(提供锁定服务器程序在内存中运行)。通过为其他程序锁定服务器,客户端可以确保能够快速访问它。这通常是为了提高性能或在服务器最脆弱的时候(例如,在注册自身或其组件时)将其保留在内存中。
让我们看看 BVAA 组件的类工厂接口。由于 BVAA 和 BVAB 组件在同一个项目中,我为它们编写了一个模板类工厂。请注意,每个组件都有自己的类工厂。在此模板的情况下,将从此模板创建 BVAA 和 BVAB 类工厂。
BVAC 组件位于另一个项目中,因此它有自己的 ClassFactory,但是如果你愿意,你可以将它们全部放在一起并使用一个模板。
extern ULONG g_ServerLocks; // server locks
template <typename T>
class ClassFactory :
public IClassFactory //standard interface IClassFactory, provides creating components for outter requests
{
protected:
// Reference count
long m_lRef;
public:
ClassFactory(void);
~ClassFactory(void);
//IUnknown
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid,void **ppv);
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
/*Basic interface IClassFactory contains 2 methods:*/
virtual HRESULT STDMETHODCALLTYPE CreateInstance(LPUNKNOWN pUnk, const IID& id, void** ppv); //creates component instance
virtual HRESULT STDMETHODCALLTYPE LockServer (BOOL fLock);
};
template <typename T>
HRESULT STDMETHODCALLTYPE ClassFactory<T>::CreateInstance(LPUNKNOWN pUnk,//basic IUnknown
const IID& id,// id of required interface
void** ppv)//required interface
{
HRESULT rc = E_UNEXPECTED;
//check wether we are aggregating
if (pUnk != NULL) rc = CLASS_E_NOAGGREGATION;
else {
T* p;
//creating new instance of component (T will be either BVAA or BVAB)
if ((p = new T()) == NULL) {
//return out of memory if can not create it
rc = E_OUTOFMEMORY;
}
else {
//Get a pointer from the created object to the requested interface
rc = p->QueryInterface(id,ppv);
p->Release();
}
}
return rc;
}
每个组件都需要类工厂,组件存储应该提供 COM 方式访问该工厂的工具。根据存储的版本,使用两种主要的访问技术之一。DLL 文件必须提供两个通用的函数:DllGetClassObject
和DllCanUnloadNow
,可执行文件必须使用 COM API 库中的CoRegisterClassObject
函数注册其类工厂。
在这种情况下,我们正在实现一个 BVAA 本地服务器组件。DllGetClassObject
函数包含以下代码:
STDAPI DllGetClassObject(const CLSID& clsid, const IID& iid, void**ppv) {
HRESULT rc = E_UNEXPECTED;
//we have to ensure that CLSID matches our component ID
if (clsid == CLSID_BVAA) {
//create new ClassFactory instance
ClassFactory<BVAA> *cf = new ClassFactory<BVAA>();
//get it's interface
rc = cf->QueryInterface(iid, ppv);
cf->Release();
}
else
if (clsid == CLSID_BVAB) {
ClassFactory<BVAB> *cf = new ClassFactory<BVAB>();
rc = cf->QueryInterface(iid, ppv);
cf->Release();
}
else
rc = CLASS_E_CLASSNOTAVAILABLE;
return rc;
};
在客户端使用任何 COM API 功能之前,必须使用CoInitialize
初始化 COM 服务。完成此操作后,客户端必须调用CoGetClassObject
函数以获取所需的类工厂组件接口(组件的指定标识符 CLSID,稍后将讨论)。在收到类工厂客户机后,立即调用CreateInstance()
创建 BVAA 类的实际实例,然后释放类工厂接口。
CreateInstance()
方法返回指向指定接口的指针。在我们的示例中,我们请求IBVAA_summer
接口,并在收到后立即使用QueryInterface
方法获取指向IBVAA_multiplier
接口的指针(请参阅客户端列表)。通过调用CreateInstance
也可以轻松获得IBVAA_multiplier
接口,但我希望向你展示一个使用QueryInterface()
和Release()
方法的示例。
IBVAA_summer
接口的指针用于执行一些基本计算。当我们从接口中获取所需内容后,我们会调用Release()
并删除组件实例。有人可能会问:“我们怎么知道 COM 组件(DLL)的存储位置?”回答:“来自注册表。”在 COM 中,Windows注册表通常用于存储有关组件的信息。
注册表
有关托管和创建组件实例所需的 COM 客户端应用程序和服务的信息存储在 Windows 注册表中。通过查找注册表,应用程序可以确定已安装组件的数量和类型等。
注册表中的信息是分层组织的,并有几个预定义的顶级部分。在本章中,对我们最重要的部分是 HKEY_CLASSES_ROOT,它存储有关组件的信息。HKEY_CLASSES_ROOT 的一个重要子部分是 CLSID(类标识符),它描述了系统中安装的每个组件。例如,我们创建的 BVAA 组件在其工作过程中需要注册表的几个元素。它们是:
HKEY_CLASSES_ROOT\BVAA.Component.1 = BVA COM
HKEY_CLASSES_ROOT\BVAA.Component.1\CurVer = BVAA.Component.1
HKEY_CLASSES_ROOT\BVAA.Component.1\CLSID = {6942E971-6F95-44BC-B3A9-EFD270EB39C9}
HKEY_CLASSES_ROOT\CLSID\{6942E971-6F95-44BC-B3A9-EFD270EB39C9} = BVAA
HKEY_CLASSES_ROOT\CLSID\{6942E971-6F95-44BC-B3A9-EFD270EB39C9}\ProgID = BVAA.Component.1
HKEY_CLASSES_ROOT\CLSID\{6942E971-6F95-44BC-B3A9-EFD270EB39C9}\VersionIndependentProgID = Math.Component
HKEY_CLASSES_ROOT\CLSID\{6942E971-6F95-44BC-B3A9-EFD270EB39C9}\InprocServer32 = c:\PATH_TO_YOUR_PROJECT\debug\comcpp.dll
HKEY_CLASSES_ROOT\CLSID\{6942E971-6F95-44BC-B3A9-EFD270EB39C9}\NotInsertable
我们在Registry.h文件中定义了这些参数。为了注册我们的 DLL,调用了Registry.cpp中的函数。
前三行创建了 BVAA 组件的程序化标识符(ProgID)。组件的 CLSID 是其唯一标识符,但非常难以阅读和记忆。COM 引入 ProgID 的概念是为了方便开发人员与组件交互。第三行提供了我们的控件的 ProgID 与相应的 CLSID 之间的直接链接。
在所考虑的代码片段的最后几行包含了将 COM 组件放置在存储中所需的所有信息。
在 ProgID 和组件版本信息之间存在一个链接。然而,最重要的是InProcServer32
部分,它描述了组件存储的确切位置。这里详细描述了注册表的基本元素。
ProgID | 指定 COM 类的 ProgID 字符串。它可以包含 39 个字符,包括小数点。 |
InProcServer32 | 包含 32 位 DLL 文件的路径和名称。路径是可选的,但如果未指定,则可以在将组件放置在 Windows 目录中时使用该组件(设置 PATH 环境变量)。 |
LocalServer32 | 包含 32 位 EXE 文件的路径和名称。 |
CurVer | 组件类的最新版本的 ProgID。 |
就 COM 而言,一个好的注册表查看器是 OLEVIEW 工具,它由 Visual C++ 环境和 SDK 提供。
打开 regedit 或 OLE VIEW(随 Visual Studio 提供,你可以通过在 PC 上的 Microsoft 搜索中键入 OLE 来找到它),并确保注册成功。打开“所有对象”选项卡,找到你的组件名称(BVA),比较 GUID 与你的 GUID(我使用了 VS2010 附带的 GUID 生成器)
第三个对象包含前两个组件
#pragma once
#include "ibvac.h"
#include "IBVAA.h"
#include "IBVAB.h"
// {347CC716-94FA-412C-8B04-AAF0116CC8F0}
static const GUID CLSID_BVAC =
{ 0x347cc716, 0x94fa, 0x412c, { 0x8b, 0x4, 0xaa, 0xf0, 0x11, 0x6c, 0xc8, 0xf0 } };
class BVAC :
public IBVAC_moder, IBVAC_summer, IBVAC_multiplier, IBVAC_power
{
protected:
volatile long m_lRef;
public:
BVAC(void);
~BVAC(void);
//IUnknown
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid,void **ppv);
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
//IBVAC_moder
virtual HRESULT STDMETHODCALLTYPE Mod(const int x, const int y, int& z);
virtual HRESULT STDMETHODCALLTYPE Nod(const int x, const int y, int& z);
//IBVAC_summer
virtual HRESULT STDMETHODCALLTYPE Add(const double x, const double y, double& z);
virtual HRESULT STDMETHODCALLTYPE Sub(const double x, const double y, double& z);
//IBVAC_multiplier
virtual HRESULT STDMETHODCALLTYPE Mul(const double x, const double y, double& z);
virtual HRESULT STDMETHODCALLTYPE Div(const double x, const double y, double& z);
//IBVAC_power
virtual HRESULT STDMETHODCALLTYPE Pow(const double x, const double y, double& z);
virtual HRESULT STDMETHODCALLTYPE Log(const double x, const double y, double& z);
HRESULT Init();
private:
IBVAA_summer *summer;
IBVAA_multiplier *multiplier;
IBVAB_power *power;
};
BVAC 的ClassFactory
使用Init()
函数来初始化 BVAA 和 BVAB 组件
HRESULT STDMETHODCALLTYPE BVAC_Factory::CreateInstance(LPUNKNOWN pUnk, const IID& id, void** ppv) {
HRESULT rc = E_UNEXPECTED;
if (pUnk != NULL) rc = CLASS_E_NOAGGREGATION;
else if (id == IID_IBVAC_moder || id == IID_IBVAC_summer ||
id == IID_IBVAC_power || id == IID_IBVAC_multiplier ||
id == IID_IUnknown)
{
BVAC* pA;
if ((pA = new BVAC()) == NULL)
rc = E_OUTOFMEMORY;
else
rc = pA->Init();
if (FAILED(rc)) {
// initialization failed, delete component
pA->Release();
return rc;
}
rc = pA->QueryInterface(id,ppv);
pA->Release();
return rc;
}
return rc;
}
编译你的项目时,请使用regsvr32注册 DLL。不要忘记切换到你的 DLL 所在的目录!
然后启动你的客户端,查看 COM 的工作成果!
就是这样
我使用了 MSDN、“Inside COM”by Rodgerson、“Active-X Web Development”by Tom Armstrong。如果你觉得这篇文章有用,请留下评论!我还有关于组件聚合和EXE本地服务器的项目,所以如果有人需要,我会写关于它们的文章。