编写免注册COM组件的正确姿势

注意:此文假定你对Windows编程和微软的COM(Component Object Model组件对象模型)技术有基本的了解。并且知道ATL(Active Template Library)这个简化COM组件编写工作的库。

这里记录折腾免注册COM组件碰到的坑作为备忘。

COM技术是微软发布的一套应用程序二进制接口的规范。用COM的规范导出DLL文件想要提供的API,可以保证发布出去的新版本DLL在应用程序二进制接口(ABI)上与旧版本保持一致。所以windows下要做一套提供可升级的API的动态库,COM是个很不错的选择。但是微软开的脑洞实在是太大了,它想这个二进制兼容的接口规范实现系统级的共享。这意味着一般情况下要成功创建一个COM对象,首先这个COM组件要在注册表里注册,我们才能用CoCreateInstance函数正确创建出来这个对象。

这很不绿色很不环保!!首先把放COM对象的dll删了,注册表里还有残留痕迹。其次,注册了之后这个dll的位置不能变,DLL的位置改了之后还得重新注册。这些COM组件的注册数据放在哪里,可以随便点开一个注册表中路径是HKEY_CLASSES_ROOT\CLSID\{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}的项,能看到每个COM组件库的CLSID和都是和DLL绝对路径绑定在一起的。这些特性对绿色程序来说是不可接受的。

好在Windows XP之后微软引入了免注册COM组件的概念,但是相关的文档却很难找,google了registration-free COM关键字只找到了微软让你在DLL中嵌入Manifest文件的办法,或者通篇都在谈.NET和VB怎么弄,完全没有用c艹和ATL怎样操作的指南。我试了,照那个文档里说的仅仅把普通的manifest文件编译到DLL中是不够的。用google各种找文章在一篇博客的指引下终于找到了官方手把手教你编写免注册COM组件的文章:
https://docs.microsoft.com/en-us/previous-versions/dotnet/articles/ms973913(v=msdn.10)#rfacomwalk_topic6

照文档做的时候踩了不少坑,这里把正确的方法记录下来。

首先在Visual Studio下新建个c++控制台应用程序,然后再新建个用ATL的DLL,用VS提供的ATL模板在ATL工程里创建两个COM对象。然后在idl文件中给两个对象对应的接口分别添加一个方法。假设这之后ATL DLL项目包含的idl文件内容如下:

// RegFreeComLib.idl : IDL source for RegFreeComLib
//

// This file will be processed by the MIDL tool to
// produce the type library (RegFreeComLib.tlb) and marshalling code.

import "oaidl.idl";
import "ocidl.idl";

[
    object,
    uuid(e237510b-fce2-48f1-ac00-e29b28ec0ddd),
    pointer_default(unique)
]
interface ITestObj1 : IUnknown
{
    [id(0), helpstring("returns \"Foo\"")] HRESULT Foo(BSTR* pVal);
};
[
    object,
    uuid(1f696df2-9532-4234-89af-1afacdc558bc),
    pointer_default(unique)
]
interface ITestObj2 : IUnknown
{
    [id(0), helpstring("return \"Bar\"")] HRESULT Bar(BSTR* pVal);
};
[
    uuid(fe8885f8-5ffb-4bdf-b738-0642d17d02a5),
    version(1.0),
]
library RegFreeComLibLib
{
    importlib("stdole2.tlb");
    [
        uuid(a75c6fc7-6656-424e-b007-b65553a45b30)
    ]
    coclass TestObj1
    {
        [default] interface ITestObj1;
    };
    [
        uuid(89c74fb1-dbb5-4b70-a48e-4706f6e3cd74)
    ]
    coclass TestObj2
    {
        [default] interface ITestObj2;
    };
};

import "shobjidl.idl";

ITestObj1::Foo会返回字符串值FooITestObj2::Bar会返回字符串值Bar

之后我们要调用CoCreateInstance创建这两个COM对象,在主程序中写下如下代码:

#include <iostream>

#include "RegFreeComLib_i.h"

#include <atlbase.h>
#include <atlcom.h>
#include <windows.h>

int main()
{
    CoInitialize(NULL);

    // 这里要包起来,否则CoUnInitialize执行之后CComPtr指向的COM对象所属的DLL早已被卸载了,程序会崩溃
    {
        HRESULT hr = S_OK;
        ATL::CComPtr<ITestObj1> pTestObj1;
        // 查找已注册的COM组件中是否有类TestObj1的clsid,接口的guid是ITestObj1的guid,
        // 如果有就能成功创建这个COM对象,对象的指针存放在pTestObj1中
        hr = CoCreateInstance(__uuidof(TestObj1), NULL, CLSCTX_ALL, __uuidof(ITestObj1), (void**)&pTestObj1);

        ATL::CComPtr<ITestObj2> pTestObj2;
        hr = CoCreateInstance(__uuidof(TestObj2), NULL, CLSCTX_ALL, __uuidof(ITestObj2), (void**)&pTestObj2);

        if (pTestObj1 && pTestObj2)
        {
            ATL::CComBSTR str1;
            pTestObj1->Foo(&str1);
            ATL::CComBSTR str2;
            pTestObj2->Bar(&str2);
            // COM对象使用的字符串类型都是wchar_t的
            std::wcout << str1.m_str << ' ' << str2.m_str << std::endl;
        }
    }

    CoUninitialize();
}

编译,运行。记得把ATL DLL项目的生成后注册组件的选项关掉(Linker -> General -> Register Output设成false),否则运行regsvr32注册失败算编译失败。
因为编译好的ATL DLL还没有在注册表中登记,所以创建COM组件的操作一定会失败。

要把我们编写的dll变成免注册的COM组件,需要在应用程序附带的清单文件上做手脚,把我们编写的COM对象在DLL包含的Manifest文件中声明出来。在dll工程下新建一个Manifest文件,参照IDL文件写入以下内容:

<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<assembly xmlns='urn:schemas-microsoft-com:asm.v1' manifestVersion='1.0'>
  <!--如果Manifest文件要嵌入到DLL中,那么name的值必须是文件名-->
  <!--版本号必须是mmmmm.nnnnn.ooooo.ppppp的格式-->
  <assemblyIdentity type="win32" name="RegFreeComLib" version="1.0.0.0" />
  <!--必须与DLL文件名(包含扩展名)保持一致-->
  <file name = "RegFreeComLib.dll">
    <!--CLSID_TestObj1-->
    <comClass clsid="{a75c6fc7-6656-424e-b007-b65553a45b30}" threadingModel="Apartment" />
    <!--CLSID_TestObj2-->
    <comClass clsid="{89c74fb1-dbb5-4b70-a48e-4706f6e3cd74}" threadingModel="Apartment" />

    <typelib tlbid="{fe8885f8-5ffb-4bdf-b738-0642d17d02a5}" version="1.0" helpdir=""/>
  </file>

  <!--原教程里的这个没有看懂是什么意思,但是删掉之后免注册的机制也能正常工作-->
  <!--<comInterfaceExternalProxyStub
      name="ISideBySideClass"
      iid="{[IID_ISideBySideClass]}"
      proxyStubClsid32="{00020424-0000-0000-C000-000000000046}"
      baseInterface="{00000000-0000-0000-C000-000000000046}"
      tlbid = "{[LIBID_SideBySide]}" />-->
</assembly>

我们的主程序要引用这些免注册COM组件,也要为主程序指定一个Manifest文件:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1"
  manifestVersion="1.0">
  <dependency>
    <!--文件名和版本号要和刚才的COM dll包含的Manifest文件中的assemblyIdentity声明保持一致-->
    <dependentAssembly>
      <assemblyIdentity type="win32" name="RegFreeComLib" version="1.0.0.0" />
    </dependentAssembly>
  </dependency>
</assembly>

然后要把编写好的清单文件嵌入到exe和dll中:

按F5运行之后竟然提示了这个,WTF???

事件查看器可以帮助我们排错。开始菜单 -> 运行 输入eventvwr,打开事件查看器。在Windows日志 -> 应用程序栏中能看到刚才程序启动出错的信息。这里看到错误提示是免注册COM dll附带的清单文件中不允许出现requestedPrivileges这个节。

用Resource Hacker打开生成的DLL,看到生成出的Manifest文件确实是带了不该带的东西。

解决办法是在DLL的项目属性中关掉生成Manifest文件时指定UAC操作的选项,打开 项目属性 -> 链接器 -> 清单文件,把 启用UAC 这个选项设成 否。在此要吐槽VS把一些选项藏得太深了,不好找,有的甚至要翻vcxproj才能找到设置。

再编译运行,就能看到程序正确输出了Foo Bar,证明程序成功调用了免注册的COM组件。

附注

  • QQ的windows客户端大量应用了COM技术。用PE查看器打开QQ安装目录下的DLL文件,能看到大多数DLL都只有四个导出函数:DLLRegisterServer,DLLUnRegisterServer,DLLGetClassObject,DLLCanUnloadNow。但是奇怪的是资源区段里面的Manifest并没有像文中这样导出接口。我这里不负责任地猜测QQ客户端应该是在内部模拟了windows加载COM组件的流程,大致应该和这里描述的方法差不多。
  • ABI不兼容会造成什么后果,可以见这两篇文章。虽然我不同意第二篇文章说微软COM是反面教材的看法。
  • 因为ABI的差异,很多用C艹实现的项目要做一些额外的工作。比如说Qt的安装包里要提供通过用多种编译器编译好的Qt动态库(vc2015/vc2017/mingw64),不同编译器编译出来的Qt DLL不能混用;windows下给Node.js写原生动态库,编译好的库要链接上指定版本Node.js对应的.lib文件,在JS里面require了版本不同的原生库,就会提示was compiled against a different Node.js version不让你加载这个模块。