COM 介绍 Part1

Introduction to COM - What It Is and How to Use It.

本文章翻译自如下链接:
http://www.codeproject.com/Articles/633/Introduction-to-COM-What-It-Is-and-How-to-Use-It

这篇文章的目的

我写下这篇文章是为了给那些刚刚接触 COM 的人提供引导,帮助他们理解 COM 的基本概念。这篇文章简要地介绍了 COM 的一些特性,COM 中的一些术语,以及如何使用已有的 COM 组件。这篇文章并没有介绍如何编写自己的 COM 对象。

介绍

COM(Component Object Model) 组建对象模型这段时间在 Windows 世界中随处可见。每天都有大堆的基于 COM 的文章冒出来,这些文章抛出来一大堆类似于 COM 对象,接口,服务器之类的名词出来,但这些文章都假定你已经了解了 COM 并且知道如何使用它。

本文针对于初学者,由浅入深地介绍 COM 底层机制,教你如何在程序中使用第三方 COM 对象(以 Windows Shell 为例)。理解了这篇文章的内容,你就能使用 Windows 内建的 COM 对象和第三方提供的 COM 对象。

本文假定你熟悉 C++, 我在示例代码中使用了一部分 MFC 和 ATL 的代码,即使你不了解这两种技术也没关系,我会在这些地方做出详细的解释。本文分为如下几个章节:

  • 什么是 COM 简单介绍 COM 标准,COM 的出现解决了什么问题。你使用 COM 并不需要了解这部分的内容。但我仍然推荐你读一下这一章,以便你能够理解为什么 COM 里的东西为什么要写成那样。

  • 基本概念 COM 术语及其对应的意义。

  • 使用 COM 对象 简要介绍如何创建、使用、销毁 COM 对象。

  • 基本接口-IUnknown 介绍基本接口 IUnknown, 及该接口中的函数。

  • 注意-字符串处理 介绍如何在 COM 代码中处理字符串。

  • 知识点整合-实例代码 用两个代码实例来说明本文提到的各个概念。

  • 处理 HRESULT 介绍 HRESULT 类型及如何测试错误码。

  • 引用 介绍一些值得看的书。

什么是 COM

简单来说, COM 是一种在不同的程序和编程语言间共享二进制代码的方法。这跟 C++ 不一样,它提倡的是源代码级别的共享。ATL 就是一个很好的例子,源代码共享虽然好,但是只能用于 C++。它还带来了命名空间冲突的可能性,更不用说不断拷贝重用代码而导致的工程膨胀。

Windows 使用 DLLs 进行二进制代码共享。Windows 应用程序通过重用 kernel32.dll,user32.dll 来运行。但这些 DLL 都是用 C 写的,只有符合 C 调用规则的语言能够使用它们。这就给其它语言重用 dll 造成了负担。

MFC 提供了 MFC extension DLLs 这种机制来共享二进制代码,但它限制更大——这种机制搞出来的 dll 只能在 MFC 程序之间共享。

COM 通过定义二进制标准解决了上述的问题。也就是说 COM 规定它的二进制模块(dll 和 exe)必须被编译成能够跟指定结构相匹配的格式。这种标准也详细规定了如何在内存中组织 COM 对象。并且它独立于任何编程语言的特性(如C++的命名空间)。一旦确立了上述的规定,二进制模块就能够被任何编程语言方便地访问。这种标准把二进制共享所需要做的额外工作放到了编译器上(而不是 dll 本身),只要编程语言的编译器产生的二进制代码与标准兼容,其他人就能方便地使用它。

COM 对象在内存中的结构“碰巧”和 C++ 虚函数所使用的结构类似。这也是为什么很多 COM 代码使用 C++ 进行编写的原因。但要记住,COM 组件用哪种语言写成并不重要,因为任何语言都可以使用它。

顺便说一句, COM 不止限于 Windows 平台。理论上它可以被移植到 Unix 或其它操作系统上。不过我还从来没在其它系统上看到过有关 COM 的讨论。

基本概念

我们从底层开始往上逐个介绍 COM 中的基本概念

  • interface 接口,在 COM 里表示一组函数的集合。这些函数叫做 method. interface 以 I 为开头,例如 IShellLink. 在 C++ 里, interface 通常被写作一个只有纯虚函数的抽象基类。类似于 C++, COM 的接口也可以从其它接口那里继承而来,COM 接口继承的工作方式和 C++ 单继承类似,它不允许多继承。

  • coclass(Component Object Class组件对象类) 被包含于 DLL 或 EXE 中。它里面包含有一个或多个 interface 的代码。coclass 也被称作是这些 interface实现。这里再提醒各位注意一下, COM 中的"类" 并不等同于 C++ 中的"类",尽管实际工作中通常都会用 C++ 类来编写 COM 类代码。

  • COM Object(COM 对象) 在内存中,一个 COM 对象就是一个 coclass 实例。COM 对象里可能有一个或多个 interface

  • COM server (COM 服务) 是一个包含了一个或多个 coclass 的二进制模块(DLL 或者 EXE)。

  • Registration 是一个创建注册表项的过程,它告诉 Windows 如何定位 COM serverUnregistration 反之,它从 Windows 中移除注册表项。

  • GUID (Global Unique Identifier) 是一个 128-bit 数字。GUID 是 COM 提供的一种无关于编程语言的标识方式。每个 interfacecoclass 都对应于一个 GUID。因为 GUID 具有全球惟一性,所以它可以避免名称冲突(只要用 COM API 创建,名称必然不会冲突)。有时候你会看到另一个属于 UUID (Universally Unique Identifier), 它们俩的功能是一样的。

  • class ID 或者 CLSID 表示一个 coclassGUID。而 interface ID 或者 IID 表示一个 interfaceGUID

    GUID 在 COM 里应用如此广泛有如下两个原因:

    1. GUID 只是一串数字,任何编程语言都可以处理它。

    2. 不管是任何人任何机器,一旦 GUID 创建完成后,都是唯一的。因此,COM 开发者可以创建自己的 GUID 而无需担心与其他人的 GUID 发生冲突。这样就避免了集中发布 GUID 的麻烦。

  • HRESULT 是 COM 中用于返回错误码所使用的一个整型值。尽管以"H"开头,但它并不是任何对象的“句柄”,接下来的章节中会有 HRESULT 更详细的描述。

  • COM library 是你在做 COM 相关操作时,所在的操作系统的一部分。通常 COM library 就被叫做 “COM”,但为了避免产生疑惑,这里没有采用这种称呼。

使用 COM 对象

每种编程语言都有它处理对象的方式。比如,在 C++ 里,你可以在栈上创建对象,或者用 new 在堆上动态分配对象。因为 COM 必须是语言无关的,所以 COM library 提供了它自己的对象管理方法。以下列出了 COM 和 C++ 对象管理的区别:

创建对象

  • C++ ,使用 new 操作符或者直接在栈上创建;
  • COM ,调用 COM library 中的 API;

销毁对象

  • C++ ,使用 delete 操作符或者超出作用域自动销毁栈上的对象;
  • COM ,所有对象保存自己的引用计数,当使用者用完 COM 对象的时候,它必须告诉 COM 对象递减引用计数。当 COM 对象引用计数递减为 0 的时候, COM 对象从内存中销毁。

使用对象 创建完 COM 对象之后,你需要使用这个对象。一个 COM 对象中可能会有一个或多个 method,当你要使用某个 method 的时候,你得告诉 COM library 你需要的 interface 是哪个。如果 COM 对象已经成功创建,COM library 会返回需要的 interface 的指针。你可以用这个指针来调用这个 method,就像调用 C++ 对象的一个接口一样。

创建 COM 对象

当你需要创建一个 COM 对象并获取对象中的某个接口指针的时候,你可以调用 COM API CoCreateInstance(). 该函数的原型如下:

HRESULT CoCreateInstance(
    REFCLSID rclsid,
    LPUNKNOWN pUnkOuter,
    DWORD dwClsContext,
    REFIID riid,
    LPVOID* ppv );

参数意义如下:

rclsid :

coclass 的 CLSID. 例如,你可以给这个参数传 CLSID_ShellLink 来创建一个 COM 对象,它可以用来创建快捷键。

pUnkOuter :

这个参数只用在 COM 对象的聚合,利用它可以向已有的 coclass 添加新方法。我们这里只需要传入 NULL 表示不需要聚合。

dwClsContext

指定你需要哪种类型的 COM server。 本文中只是用到了一种最简单的 server, 进程内 DLL, 所以传入 CLSCTX_INPROC_SERVER。注意这里不要使用 CLSCTX_ALL(这是 ATL 的默认值),因为它在没有安装 DCOM 的 Windows 95 上会发生错误。

riid :

你希望返回的 interface 的 IID,例如,你可以给这个参数传 IID_IShellLink 来获取 IShellLink 接口。

ppv :

接口指针的地址,COM library 会把请求的接口通过这个参数来返回。

当你调用 CoCreateInstance() 函数时,它会在注册表里查询 CLSID, 找到 COM server 所在的位置,把 server 加载到内存,然后创建一个你所请求的 coclass 的实例。

如下代码给出了一个简单的例子。它会实例化一个 CLSID_ShellLink 对象并请求一个指向该 COM 对象的 IShellLink 接口:

HRESULT hr;
IShellLink* pISL;

hr = CoCreateInstance( CLSID_ShellLink,
                        NULL,
                        CLSCTX_INPROC_SERVER,
                        IID_IShellLink,
                        (void**) &pISL );
if ( SUCCEEDED(hr) )
{
    // 创建 COM 对象成功,使用 pISL 调用接口
}
else
{
    // 无法创建 COM 对象,错误码存在 hr 中
}

销毁 COM 对象

正如之前所说,你不需要手动将 COM 对象从内存中销毁,你只需要告诉它你不再需要它了,所有的 COM 类都继承自 IUnKnown 接口,这个接口提供了一个函数叫 Release()。调用这个函数就可以告诉 COM 对象你不再需要它。一旦你调用了 Release(), 你就不能继续使用对应的接口,因为 COM 对象可能已经从内存中销毁了。

如果你的程序使用了许多不同的 COM 对象,非常重要的一点是,你必须在不需要使用接口时调用 Release()。如果你没有释放这些接口,COM 对象及其对应的 dll 会一直存在于内存中,这会增加不必要的开销。如果你的程序要运行很长时间,你可以在空闲期调用 CoFreeUnusedLibraries() API。这个 API 会卸载任何没有明显调用的 COM server。这样做能在一定程度上减少内存占用。

以下示例代码展示了使用 Release() 的方法:

if ( SUCCEEDED (hr) )
{
    // 使用 pISL 接口进行一些操作

    // 使用完毕,告诉 COM 对象不需要这个接口了
    pISL->Release();
}

IUnKnown 接口将在下一节详细介绍。

基本接口-IUnknown

每个 COM 接口都继承自 IUnknown 接口。 IUnknown 这个名字有点儿误导人,它的意思并不是说一个“未知的”接口。之所以这样命名是因为,如果你有一个 IUnknown 指针指向一个 COM 对象,你并不知道底层对象是什么,因为每个 COM 对象都实现了 IUnknown 接口。

IUnknown 接口有三个函数:

  1. AddRef() 这个接口告知 COM 对象递增引用计数。
  2. Release() 这个接口告知 COM 对象递减引用计数。
  3. QueryInterface() 从 COM 对象中请求接口指针,当一个 coclass 实现了多个接口的时候,你需要用这个函数来获取指定的接口。

QueryInterface() 的函数原型如下:

HRESULT IUnknown::QueryInterface {
    REFIID iid,
    void** ppv );

参数意义如下:

iid

你所请求的接口的 IID

ppv

接口指针地址,QueryInterface() 调用成功的话会将接口通过这个参数传递出来。

在之前的例子里,我们已经通过 CoCreateInstance() 获取了 IShellLink 接口的指针 pISL, 利用 pISL 和 QueryInterface() 还可以获取 COM 对象里的其它接口指针:

HRESULT hr;
IPersistFile* pIPF;

hr = pISL->QueryInterface( IID_IPersistFile, (void**)pIPF);

你可以用 SUCCEEDED 宏来检测接口指针是否获取成功,当 pIPF 使用完毕的时候,也要像 pISL 那样调用 Release() 来递减引用计数。

注意-字符串处理

这一节先绕绕道儿,讨论一下如何在 COM 代码里处理字符串。如果你熟知 Unicode 和 ANSI 字符串的工作原理,并且了解如何在这两种编码间进行转换,你可以跳过这个章节。

无论何时,COM 函数返回的字符串都以 Unicode 进行编码。Unicode 是一个字符编码集,类似于 ANSI, 只是 Unicode 的所有字符都是两个字节。如果你想更好地操作字符串,你可以将它转换成 TCHAR 类型。

TCHAR 和 _t 开头的函数(如 _tcscpy) 被设计成可以用同一份代码来处理 Unicode 和 ANSI 字符串的形式。在大多数情况下,你会使用 ANSI 字符串及 ANSI API,因此为了简便,在往后的文章里,我会使用 char 而非 TCHAR。但你仍然应该熟练掌握 TCHAR 类型。

当你从 COM 函数里获取到一个字符串后,你可以用以下函数将其转换为 char 类型:

  • 调用 WideCharToMultiByte();
  • 调用 CRT 函数 wcstombs();
  • 在 MFC 中可以使用 CString 构造函数来进行转换;
  • 使用 ATL 字符串转换宏;

以下是这些方法的详细介绍:

WideCharToMultiByte()

该函数的原型如下:

int WideCharToMultiByte(
    UINT CodePage,
    DWORD dwFlags,
    LPCWSTR lpWideCharStr,
    int cchWideChar,
    LPSTR lpMultiByteStr,
    int cbMultiByte,
    LPCSTR lpDefaultChar,
    LPBOOL lpUserDefaultChar );

CodePage

Unicode 字符进行转换时的目标代码页。你可以传入 CP_ACP 来将 Unicode 转换为当前系统所使用的 ANSI 代码页。代码页是 256 个字符集,其中 0~127 和 ANSI 的一样,128~255 不一样,它可以包含图形字符或读音符号。每个语言都有它自己的代码页,因此使用正确的代码页很重要,这样才能正确地显示字符。

dwFlags

这个标记决定此函数将如何处理 “复合的” Unicode 字符串。这种复合字符串后面会跟一个读音符号,比如 è。如果指定代码页里有这个符号,那就没啥问题,但如果没有的话, Windows 必须把这个符号转换为其它的形式进行展示。
给 dwFlags 传入 WC_COMPOSITECHECK, 那么 API 将对 “复合字符串” 进行检测;
给 dwFlags 传入 WC_SEPCHARS, 那么 API 会把字符分成 “字符+读音符号” 的形式,比如 è --> e`
给 dwFlags 传入 WC_DISCARDNS, 那么 API 会丢弃读音符号;
给 dwFlags 传入 WC_DEFAULTCHAR, 那么 API 会将读音符号替换为一个默认符号,这个默认符号可以在 lpDefaultChar 中指定。
dwFlags 的默认值是 WC_SEPCHARS.

lpWideCharStr

要转换的 Unicode 字符串。

cchWideChar

lpWideCharStr 字符串的长度,如果传入 -1, 将自动检查 00 结尾来确认长度。

lpMultiByteStr

char 类型的字符串缓冲区,用于接收转换后的 ANSI 字符串;

cbMultiByte

lpMultiByteStr 缓冲区的长度,byte 为单位。

lpDefaultChar

可选参数,当 dwFlags 传入 WC_COMPOSITECHECK | WC_DEFAULTCHAR 时,如果 API 检查到某个字符在目标代码页中不存在,那么将用 这个缺省字符来替换那个字符。这个参数传入 NULL 的话,API 会使用系统默认字符来替换(一般是一个问号)。

lpUserDefaultChar

可选参数,是一个指向 BOOL 值的指针,如果 lpDefaultChar 被插入到了目标字符串中,那么会将这个 BOOL 值设定为 TRUE, 以此进行标记。

这个函数很复杂,下面是一个例子:

char szANSIString [MAX_PATH];
WideCharToMultiByte( CP_ACP,              // 使用系统当前代码页
                    WC_COMPOSITECHECK,    // 检查复合字符
                    wszSomeString,       // 要转换的 Unicode 字符串
                    -1,                  // 自动检查 Unicode 字符串长度
                    szANSIString,          // ANSI 字符串缓冲区
                    sizeof(szANSIString), // 缓冲区长度
                    NULL,                  // 使用系统默认字符替换复合字符
                    NULL );             // 不检查是否替换

wcstombs()

CRT 函数 wcstombs() 简单了很多,不过它最终还是在调用 WideCharToMultiByte().

size_t wcstombs(
    char* mbstr,
    const wchar_t* wcstr,
    size_t count);

mbstr

转换后的 ANSI 字符串存储在这个缓冲区中;

wcstr

要转换的 Unicode 字符串;

count

mbstr 的字符串长度, byte 为单位。

wcstombs() 使用了 WC_COMPOSITECHECK | WC_SEPCHARS 标记,你可以用如下方式调用 wcstombs():

wcstombs(szANSIString, wszSomeString, sizeof(szANSIString));

CString

MFC 中的 CString 可以在构造函数 或者 赋值操作符中接受 Unicode 字符串,可以利用这一点来进行转换:

CString str1(wszSomeString);
CString str2;
str2 = wszSomeString;

ATL 字符串转换宏

ATL 提供了一组宏用于转换字符串:

  • W2A() (Wide To ANSI) 用于将 Unicode 转换为 ANSI;
  • OLE2A() (OLE or COM String To ANSI) 跟上面的宏作用一样,只是描述上更为精确一些,"OLE" 明确指出了是 COM 字符串。
  • W2T() (Wide To TCHAR) 将 Unicode 转换为 TCHAR;
  • W2CT() (Wide To const TCHAR)
  • OLE2CA() (OLE String To const char String)

下面是例子:

char szANSIString[MAX_PATH];
USES_CONVERSION;    // 声明 OLE2A 宏所需要的本地变量
lstrcpy(szANSIString, OLE2A(wszSomeString);

之所以要调用 lstrcpy 将 OLE2A() 返回的结果拷贝到 szANSIString, 是因为 OLE2A() 返回的结果暂存在栈中,拷贝出来才能长久使用。

知识点整合-实例代码

下面用两个例子来说明本文所提到的各个概念。

使用单接口的 COM 对象

下面的例子使用 shell 中的 Active Desktop coclass 来获取当前壁纸的路径。

#include 
#include 
#include 
#include 

int main()
{
    setlocale(LC_ALL, "chs");

    HRESULT hr;
    IActiveDesktop* pIAD;
    WCHAR wszWallpaper[MAX_PATH];

    // 1. 初始化 COM library
    CoInitialize(NULL);

    // 2. 创建 COM 对象实例
    hr = CoCreateInstance(CLSID_ActiveDesktop,
        NULL, CLSCTX_INPROC_SERVER, IID_IActiveDesktop, (void**)&pIAD);

    if ( SUCCEEDED(hr) )
    {
        // 3. 调用 GetWallpaper 函数
        hr = pIAD->GetWallpaper(wszWallpaper, MAX_PATH, 0);
        if ( SUCCEEDED(hr) )
        {
            wprintf(L"wallpaper path = %s\n", wszWallpaper);
        }
        else
        {
            wprintf(L"GetWallpaper Error! \n");
        }

        // 4. 释放接口
        pIAD->Release();
    }
    else
    {
        wprintf(L"CoCreateInstance Error! \n");
    }

    // 5. 反初始化 COM library
    CoUninitialize();

    return 0;
}

上面代码中的 setlocale() 是为了让 wprintf() 能够在控制台中正确输出中文。

使用多接口的 COM 对象

下面的例子展示了使用 QueryInterface() 获取接口的方法。它先创建 ShellLink COM 对象,拿到它的 IShellLink 接口,然后调用 QueryInterface() 获取 IPersistFile 接口。

这个例子的功能是创建壁纸文件的快捷方式。

#include 
#include 
#include 

int main()
{
    WCHAR wszWallpaper[MAX_PATH] = L"C:\\Users\\Dongyu\\Pictures\\桌面黑.png";

    // 1. 初始化 COM library
    CoInitialize(NULL);

    HRESULT hr;
    IShellLink* pISL;
    IPersistFile* pIPF;

    // 2. 创建 ShellLink COM 对象实例
    hr = CoCreateInstance(CLSID_ShellLink, 
        NULL, CLSCTX_INPROC_SERVER, IID_IShellLink, (void**)&pISL);

    if ( SUCCEEDED(hr) )
    {
        // 3. 设定目标文件的路径
        hr = pISL->SetPath(wszWallpaper);

        if ( SUCCEEDED(hr) )
        {
            // 4. 获取第二个 COM 接口
            hr = pISL->QueryInterface(IID_IPersistFile, (void**)&pIPF);

            if ( SUCCEEDED(hr) )
            {
                // 5. 调用 Save 方法保存壁纸的快捷方式
                hr = pIPF->Save(L"E:\\wallpaper.lnk", FALSE);

                // 6. 用完了,释放
                pIPF->Release();
            }
        }
        // 7. 用完了,释放
        pISL->Release();
    }

    // 8. 反初始化 COM library
    CoUninitialize();

    return 0;
}

处理 HRESULT

之前的例子中,用 SUCCEEDED, FAILED 宏简单处理了 HRESULT. 下面我要介绍一些关于 HRESULT 更多的细节。

HRESULT 返回值是一个 32 位有符号整形。非负数表示成功,负数表示失败。

HRESULT 有三个域,[结果位],[功能码],[状态码]。结果位表示结果是成功还是失败;功能码表示错误来自于哪个组件,如 COM, 任务调度程序都有对应的功能码。功能码是一个 16 位的值,没有其它内在含义,这个数字和意义之间是没有关联的,类似于 GetLastError() 的返回值。

如果你去查看 winerror.h, 你会看到一堆 HRESULT 的定义。它们命名的规则是: [功能][结果][描述],如果 HRESULT 不属于任何特定组件,那么 [功能] 这一项不写:

  • REGDB_E_READREGDB: 功能=REGDB(registry database), 结果=Error, 描述=READREGDB(表示无法读取数据库);
  • S_OK: 功能=通用, 结果=Success, 描述=OK(表示没啥问题);

除了直接查看 winerror.h,你可以用 Error lookup tool 来了解 HRESULT 的具体意义。

你可以在调试时的监视窗口中使用 @err.hr 来查看 HRESULT 所代表的具体意义。

引用

Essential COM by Don Box《COM 本质论》
MFC Internals by George Shepherd and Scot Wingo
Beginning ATL 3 COM Programming by Richard Grimes

以上是文章中推荐的几本书,《COM 本质论》值得一读,但有点难懂。
《COM 技术内幕》 也值得一读。

你可能感兴趣的:(COM 介绍 Part1)