COM基本概念

維基教科書,自由的教學讀本

介紹COM的文章很多,但是卻很少有從編程角度把它的體系、運行講得淺顯而透徹的。這篇文章,就是嘗試從C/C++編程角度,把COM是怎麼回事、怎麼運轉的給出入門理解。

COM的目的[編輯]

COM的目的是在Windows平台上二進制程序的跨(編程)語言的無縫的共享。DLL是為了C語言搞出來的,其它(腳本)語言調用起來不方便,而且還有DLL Hell問題。一個COM客戶程序,需要調用別人寫的一個COM模塊提供的服務,這就需要根據客戶程序提供的數字標識符(UUID),在Windows註冊表中找到這個COM模塊的存儲位置並啟動之(甚至可能是在網絡上別的計算機上用RPC遠程啟動之),然後就是客戶程序與提供服務的COM模塊之間直接打交道了。

什麼是 COM?為什麼要用 COM?「COM 就是實現了自我管理的 DLL!」。為什麼要用 COM?因為程序要用 DLL,而 DLL 又 有 DLL Hell 問題,於是微軟定製了一些標準來消滅魔鬼,以使 DLL 的優點更好地發揮出來。COM 有很多優點,這裡簡單說三個:

  • 註冊後,就不需要知道服務組件放在哪裡;
  • COM DLL 一般只有幾個標準的導出函數,這是好的,因為導出函數是可以自己取名的,重名問題很嚴重,靜態鏈接起來就可憐了……
  • COM DLL 可以很容易添加被腳本語言調用的能力,一般 DLL 是不能被腳本語言直接調用的。

COM由三大類組成[編輯]

COM模块内部的东西可分为三大类:TypeLibrary、coclass、interface。这三类都有各自的UUID,分别叫做TypeLibID_id、clsid、iid。

TypeLib包含了若干個coclass,一般是一個工廠類、一個業務類。

coclass實現了若干個interface。

interface聲明了若干成員函數可供客戶程序調用,而且這個interface一旦定好了就永遠不能改變。

TypeLib[編輯]

TypeLib是用於腳本語言查詢有哪些界面、界面中有哪些數據成員、哪些成員函數、以及成員函數的參數與返回類型。C\C++客戶程序可以直接根據COM的頭文件去調用其界面的函數,不需要用低效的dispatch界面。但是,腳本語言就必須先查詢類型描述(詳見下述),再通過dispatch界面去調用相應的函數。typelib是在用「中間描述語言」寫的idl文件中定義,然後用MIDL.exe把這個idl文件編譯為3個文件:C\C++客戶程序用的頭文件與.c文件(裡面是各個uuid的定義)、以及二進制類型庫.tlb。tlb文件將要登記入Windows註冊表,以便客戶程序可以從COM的clsid即可以查詢到其類型描述。

coclass[編輯]

coclass是實現了一個或多個interface的數據結構。在C++實現時,就是一個繼承了interface的類;在C語言實現時,是一個struct,裡面包含了一個指向函數表的指針以及若干個內部使用的變量。coclass的用途,就是(動態)構造一個這樣的coclass之後,就可以調用各種interface上聲明的函數為客戶提供服務。它是interface的物理實現。

interface[編輯]

interface是向客戶描述一個為了某個特定目的可提供服務的若干函數的聲明。例如算術運算界面提供了加減乘除等函數。用C++語言來寫,界面就是一個虛基類。用C語言來寫,界面是一個struct,其第一個數據成員是一個函數表的指針,其實就是模仿了C++的虛表機制。

用戶在自己的頭文件中如下定義一個interface:

#undef  INTERFACE
#define INTERFACE   IExample
DECLARE_INTERFACE_ (INTERFACE, IUnknown)
{
    STDMETHOD  (QueryInterface)        (THIS_ REFIID, void **) PURE;
    STDMETHOD_ (ULONG, AddRef)        (THIS) PURE;
    STDMETHOD_ (ULONG, Release)        (THIS) PURE;
    STDMETHOD  (SetString)            (THIS_ char *) PURE;
    STDMETHOD  (GetString)            (THIS_ char *, DWORD) PURE;
};

在C++環境下,被解釋為:

struct __declspec{novtable} IExample : public IUnknown{
   HRESULT QueryInterface(const IID &, void **) =0;
   ULONG   AddRef(void) =0;
   ULONG   Release(void) =0;
   HRESULT SetString(char*) =0;
   HRESULT GetString(char*, DWORD) =0;
}

在C環境下,被解釋為:

typedef struct IExample { struct IExampleVtbl far * lpVtbl; } IExample;
typedef struct IExampleVtbl IExampleVtbl;
struct IExampleVtbl{
   HRESULT (__stdcall * QueryInterface) (IExample far * This, const IID &, void **) ;
   ULONG   (__stdcall * AddRef) (IExample far * This) ;
   ULONG   (__stdcall * Release) (IExample far * This) ;
   HRESULT (__stdcall * SetString) (IExample far * This, char*) ;
   HRESULT (__stdcall * GetString) (IExample far * This, char*, DWORD) ;
}

可見,C環境下,還需要用戶手工填入這樣的「虛表」的實例。

dispid[編輯]

interface內聲明的各個函數,是用正整數的id來編號、使用。

調用COM的流程[編輯]

客戶程序角度,首先為自己初始化COM環境(函數::CoInitialize(NULL), 等價於CoInitializeEx(NULL, COINIT_APARTMENTTHREADED),根據第1個參數(從 0開始),調用了 TLS 函數來給線程設置一個記號,這個記號叫套間模式。實質上是聲明客戶程序使用什麼線程模型)。如果客戶程序都是單線程的,則不存在同步問題,那麼這個函數沒必要存在;如果 COM 是為單線程設計的,而客戶是多線程,那麼明顯存在同步問題,這時候只 能讓客戶程序或者 COM Runtime 來解決同步問題。讓客戶程序來解決是不合理的,違背 COM 的宗旨;那麼就讓 COM Runtime 來 解決吧。於是 COM Runtime 要先後詢問過服務組件和客戶程序,確定他們是不是一致。這就需要服務組件和客戶程序雙方誠實交代自己的套間模式,服務組件是通過註冊時在註冊表寫的信息告訴 COM Runtime 的,而客戶程序就是 用 CoInitializeEx 來給自己線程設置套間模式的,COM Runtime 在必要時去讀取調用線程的套間模式值。

然後,獲取類庫中的工廠類:調用::CoGetClassObject,提供類ID(clsid)與工廠類的標準界面IID(即IID_IClassFactory);由COM模塊所在DLL的一個標準輸出函數DllGetClassObject(REFCLSID rclsid ,REFIID riid,void **ppv)創建一個工廠類並返回標準的工廠類界面IID_IClassFactory。然後通過該工廠類標準界面調用工廠類的成員函數CreateInstance產生業務類的實例的地址,並release不再需要的工廠類。再通過業務類實例來查獲各種支持的界面,調用界面上定義的函數(必然是虛函數,因為界面是虛基類)。最後,程序結束前調用::CoUninitialize函數。

可見,這裡面的要點是COM所在DLL的DllGetClassObject怎麼寫。你也可以把它寫為直接創建業務類,壓根不用工廠類。

也可以使用coclass的clsid與要使用的interface的iid作為參數調用::CoCreateInstance,獲得要使用的COM業務類的實例的地址。::CoCreateInstance的內部實現是CoGetClassObject(rclsid, dwClsContext, NULL, IID_IClassFactory, &pCF); hresult = pCF->CreateInstance(pUnkOuter, riid, ppvObj); pCF->Release(); 實質上還是同一回事。

COM的server[編輯]

從編寫COM模塊的角度,不外乎就是編寫一個提供各種服務功能的DLL或EXE。

如果是DLL,在DLLMain之外則必須寫四個export函數:

  • 向Windows註冊表登記COM對象使用的DllRegisterServer、DllUnregisterServer;用regsvr32命令註冊(或者取消註冊)當前這個DLL到Windows Registry時的回調函數。
  • COM客戶加載COM對象時調用的DllGetClassObject。客戶程序要求查詢並建立一個coclass的實例時調用的函數。
  • DllCanUnloadNow。前兩個是詳見下文。是Windows詢問某個COM Server application是否可以卸掉的回調函數。

一個COM模塊,通常包括一個工廠類,一個或多個業務類。這些類你可以把它們視作C++類,但是不論是類名還是成員函數,都不用名字而必須用UUID去查詢使用,這保證了不同的語言都可以調用COM對象。無論是工廠類還是業務類,都是從interface IUnknown派生的。這個單詞interface,並不是C++關鍵字,而是#define interface struct,這種struct的成員函數都是純虛函數。

一個coclass實現了多個接口,用QueryInterface來返回客戶所需要的那個接口的指針,這其實是C++的派生類對象地址轉為一個(多繼承的)基類對象地址。用完了這個接口後必須調用Release通知COM對象。

COM Server application內部一般有兩個引用計數變量:

  • OutstandingObjects:生成並存在着的COM類的數量,包括IClassFactory。
  • LockCount:通過IClassFactory的方法LockServer,來鎖定COM Server application不被卸載,以免下次獲取類廠對象時需要重新加載至內存。

IUnknown[編輯]

IUnknown是最基礎的虛基類,聲明了三個純虛函數:AddRef、Release、QueryInterface。COM體系結構中所有類都是以IUnknown為基類。

代理 IUnknown 接口做什麼的?  本質就是:if ( 被聚合 ) { 調用內部組件非代理 IUnknown 接口 } else { 調用外部組件 IUnknown 接口 };

類廠 IClassFactory[編輯]

類廠IClassFactory比較簡單,在IUnknown之上,再聲明兩個純虛函數:CreateInstance、LockServe。最後,COM模塊由IClassFactory再派生一個實作的類廠,把上述五個純虛函數都定義實現了。其中:

  • QueryInterface判斷iid是IID_IUnknown或者IID_IClassFactory,就把自身(this)當作類廠返回。
  • CreateInstance成員函數首先創建(通常是new)一個業務類,然後調用業務類的QueryInterface得到符合界面iid的一個實例地址,客戶程序就可以用這個實例地址去訪問各種服務了。如果一個COM類庫中有多個業務類,那麼類廠應該根據CreateInstance函數的iid參數來決定創建(new)哪個業務類。
  • LockServer方法:
    • 對於進程內的COM服務器,保持IClassFactory接口的指針就足以使類廠對象不被釋放。接口中的Lock方法用於在使用者釋放了所有的單獨(outstanding )的接口(包括IClassFactory )、在下一次創建實例請求前的這個時間段內,控制進程內COM程序庫的加載/卸載/重加載。
    • 對於進程外的COM服務器,創建、獲取、釋放類廠接口指針,不影響服務器的上鎖。這說明保持一個類廠接口指針,到用該類廠接口指針實際創建一個實例(CreateInstance),有可能服務器已經處於非加載狀態。所以,類廠marshalling時,獲取到類廠接口指針就自動調用IClassFactory::LockServer以阻止服務器卸載。

類廠只是COM的DLL實現中的一個工具助手,不登記到Registry中。也不寫入.idl文件,因此在.tlb這種二進制類型庫中也不描述類廠。

API函數CoGetClassObject返回IClassFactory接口的指針。

IDispatch[編輯]

為了讓VB這種腳本解釋執行的編程語言也可以調用其成員函數,就不能靠C++的虛表風格的COM對象指針來訪問服務,而必須提供IDispatch接口(叫做DispInterface)。所以,業務類一般是以IUnknown為最基類,派生IDispatch,進一步再派生各個業務interface,最後派生把各個純虛函數定義實現的業務類。業務類與多個業務interface是多繼承的關係。IDispatch中聲明了四個純虛函數:GetTypeInfoCount、GetTypeInfo、GetIDsOfNames、Invoke。業務類重點實現GetIDsOfNames成員函數、Invoke成員函數。業務類中實現的各種界面上聲明的函數,不必是虛函數,而是根據ID號訪問的內部函數。GetTypeInfo成員函數,由於有了.tlb文件,所以可以直接調用::LoadRegTypeLib等API函數很方便的實現。

所以,Dual Interface就是支持IDispatch與VTBL兩種方法訪問的COM的界面

COM類庫中的多個類[編輯]

如果COM類庫中定義了多個類,那麼COM API的::CoCreateInstance如何知道創建哪個coclass的實例?答案是:在COM模塊的DllGetClassObject函數中創建哪個coclass,就以它為準。一般是創建類廠的實例。(當然,有時把類廠定義為DLL的一個靜態變量。)

MIDL:微軟中間描述語言[編輯]

在.idl文件中描述了COM的TypeLib、coclass、interface、界面內的各個函數及其參數,還有相關聯的uuid(對於界面的函數則是id)。描述這些信息的.idl文件用midl.exe編譯出一個頭文件、一個C源文件以及.tlb二進制類型庫文件。頭文件中,每個coclass、interface的聲明的struct/class關鍵字後面添加了__declspec( uuid("ComObjectGUID") )。 例如: class DECLSPEC_UUID("3BCFE27E-C88D-453C-8C94-F5F7B97E7841") MATHCOM; C源文件以"_i.c"為文件名後綴,定義了interface、coclass、library的GUID的常量。對於C\C++客戶端,有了頭文件就可以直接找到COM的界面中的函數。為了對腳本語言提供服務,需要把.tlb文件登記入Windows註冊表。這樣從CLSID或者IID,都可以查出是對應了哪個TypeLib,從而查獲詳盡的類型描述。

COM模塊在註冊表中登記[編輯]

COM模塊不論放在硬盤什麼位置,COM客戶都能通過::CoCreateInstance等函數直接啟動COM對象的實例。這是靠了regsrv32.exe的註冊功能,此程序調用COM模塊的DLL的DllRegisterServer函數完成註冊。一個COM模塊,作為一個類庫(因為它包含了多個業務類以及一個工廠類),用各個業務類的CLSID標識。因此在註冊後,Windows Registry的HKEY_CLASSES_ROOT的CLSID下(64位Windows系統是在HKEY_CLASSES_ROOT的Wow6432Node的CLSID下),以各個業務類的CLSID為KeyName,定義了缺省值(值為類庫名)、子鍵「InprocServer32」(缺省值為DLL的全路徑文件名)、子鍵「ProgID」(缺省值為一個字符串,如軟件名.類庫名.版本號,不保證唯一);另外在Windows註冊表的HKEY_CLASSES_ROOT下(64位Windows系統是在HKEY_CLASSES_ROOT的Wow6432Node下),註冊了鍵「ProgID」(同前述,不保證唯一)以及子鍵CLSID。所以Windows系統知道了類庫UUID,查到它的存儲位置就不是個問題了。

VBscript客戶程序習慣用「ProgID」來查詢、調用COM模塊。 HKEY_CLASSES_ROOT是HKEY_CURRET_USER\SOFTWARE\Classes與HKEY_LOCAL_MACHINE \SOFTWARE\Classes的合併表示。COM模塊登記到Windows註冊表的情況可以概述如下:

註冊表中的TypeLib[編輯]

一般都是用::RegisterTypeLib()來登記。該函數默認把該類型庫登記入五處位置:

  • HKEY_CLASSES_ROOT\TypeLib\{uuid_TypeLib}
  • HKEY_CLASSES_ROOT\Wow6432Node\TypeLib\{uuid_TypeLib}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\TypeLib\{uuid_TypeLib}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\TypeLib\{uuid_TypeLib}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes\TypeLib\{uuid_TypeLib}

在每一處,下轄子鍵:

  • \MajorVer.MinorVer\ReleaseVer\win32\默認值為.tlb文件的絕對路徑文件名

註冊表中的coclass[編輯]

不屬於.tlb包含範圍。因此coclass都是COM模塊實現自行寫代碼來登記。由於Windows Registry API的那些寫入函數在64位Windows系統上自動把32位程序的調用寫入相應的Wow6432Node目錄中,所以32位的COM模塊的clsid都登記入了相應的帶有Wow6432Node的目錄中:

  • HKEY_CLASSES_ROOT\CLSID\{clsid}
  • HKEY_CLASSES_ROOT\CLSID\Wow6432Node\{clsid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\\CLSID\{clsid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\CLSID\{clsid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes\CLSID\{clsid}

其默認值為軟件名字符串。另有兩個子鍵:InprocServer32,其默認值為dll的絕對路徑文件名;ProgID,其默認值為 軟件名.模塊名.主版本號

註冊表中的interface[編輯]

屬於.tlb包含的範圍。同TypeLib的情形。

  • HKEY_CLASSES_ROOT\Interface\{iid}
  • HKEY_CLASSES_ROOT\Wow6432Node\Interface\{iid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Interface\{iid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\Interface\{iid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes\Interface\{iid}

在每一處,默認值為界面的名字;另下轄子鍵:ProxyStubClsid32、TypeLib。其中的TypeLib子鍵的默認值為界面所在TypeLib的uuid,還有一個「Version」值。

註冊表中的ProgID[編輯]

類似於9.2的clsid的情形,也是由COM模塊的開發者自己寫代碼登記入註冊表。腳本語言會通過ProgID來查詢、調用COM的coclass:

  • HKEY_CLASSES_ROOT\「ProgID」字符串\

其默認值為coclass的名字。另有子鍵\CLSID,其默認值為{clsid}

補充:由於64位Windows上的32位程序使用註冊表,Wow6432對於程序來說是透明的。所以上述Wow6432Node註冊表子鍵的情形在編程時可以忽略不予考慮。

  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes is symbolic linked to HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node;
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\Typelib is symbolic linked to HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Typelib

COM對象實例就是虛表指針[編輯]

COM實際上是用C++實現的,因此一個COM對象實例的指針,指向了一個結構,這個結構實現了多個interface。這個結構的第一個數據成員是一個指針,指向了多個interface的各個被實現的函數的指針組成的表。從客戶程序的角度,可以說一個COM對象實例就是一個虛表指針。(注意,COM對象的實例不能說是一個虛表,而是虛表的指針。)換句話,創建對象後客戶程序得到什麼? 函數指針的列表的地址。

什麼是 Marshal?[編輯]

很少人去翻譯這個詞,都直接用英語。可以意譯為「安整」,全稱「發現不按順序排隊,則整頓為序列化排列」!

COM 分進程內和進程間,暫時不考慮 COM+ 的「計算機間」。進程內和進程間的「安整」通 過 Local RPC 完成,其中進程間的「安整」是必須的,因為服務和客戶都是獨立的進程,必須有一個渠道讓它們可以共用數據;進程內「安整」是在不同套間之間進行方法調用和傳遞對象接口時發生,也就是上面說到的 CoInitializeEx 後,COM Runtime 可能介入解決同步問題,它就是通過「安整」來解決的。

包容和聚合[編輯]

都是為了復用,用面向對象的術語來說就是「繼承」,不過他們「繼承」的是接口。

包容時,內部組件不需要做任何額外的支持;聚合時,需要內部組件支持被聚合。不過這個支持在 ATL 里只需要我們打個鈎。

包容是設計了一個新的接口,新接口的實現是調用內部組件的方法;而聚合是外部組件得到內部組件的接口指針,直接把這個指針傳給客戶,客戶訪問的時候就不 需要再經過外部組件了,但這樣一來,外部組件就無法對內部組件添加任何新功能。同時因為把內部組件的接口指針直接傳給客戶暴露了內部組件,使得客戶可以用 這個接口指針去查詢內部組件其他接口,這是違法的,所以內部組件還要把查詢轉回外部組件,所以代理 IUnknown 接口應運而生。 總結:這些基本知識表明,使用或者編寫COM並不難。還是很容易理解的。

COM的並發控制[編輯]

進程加載DLL後,會在進程內為DLL的全局變量、靜態變量分配內存空間,使用進程內的線程棧為DLL函數的局部變量分配內存。因此,多個進程訪問同一種COM對象不存在並發控制問題。

一個進程的多個線程,可以分別申請創建並訪問同一種COM對象。因此創建該COM對象的設施(如DllGetClassObject、IClassFactory)需要採取(無鎖的、原子的)並發控制。然後動態創建COM的一個對象返回給請求的線程。注意,IClassFactory常常實現為COM DLL的一個靜態全局對象。

客戶程序獲取到一個COM對象,可能會交給多個線程訪問它。這就需要在客戶線程與COM對象之間的並發控制。這就引入了套間「Apartment」的概念。有兩種套件類型:

  • 按照單線程執行方式寫COM對象的代碼,完全不考慮並發執行問題。這樣的每個COM對象只能由一個線程執行,該線程通過Windows消息隊列實現多線程訪問該COM對象的串行化從而並發安全。這種策略稱作單線程套間(Single-Threaded Apartment,STA)。
  • COM對象的代碼自身實現了並發控制(通過Windows互斥原語,如互斥鎖、臨界區、事件、信號量等)。因此實際上多線程可以直接調用該COM對象的方法,這是並發安全的。這種策略稱作多線程套間(Multi-Threaded Apartment,MTA)。

套間本質上只是一個邏輯概念而非物理實體,沒有句柄類型可以引用它,更沒有可調用的API操縱它。套間是一個邏輯容器,收納遵循相同線程訪問規則的COM對象與COM線程(創建了COM對象的線程或者調用了COM對象的方法的線程)。

一個COM對象只能存在於一個套間。COM對象一經創建就確定所屬套間,並且直到銷毀它一直存在於這個套間。

一個COM線程從創建到結束都屬於同一個套間。COM線程只有兩種套間模式:STA或MTA。線程必須通過調用CoInitializeEx()函數並且設定參數為COINIT_APARTMENTTHREADED或COINIT_MULTITHREADED,來指明該線程的套間模式。調用了CoInitializeEx()函數的線程即已進入套間,直到線程調用CoUninitialize()函數或者自身終止,才會離開套間。COM為每個STA的線程自動創建了一個隱藏窗口,其Windows class是"OleMainThreadWndClass" 。線程與屬於同一套間的對象可以直接執行方法調用而不需COM的輔助。線程跨套間邊界去調用COM對象,傳遞的指針需要marshalling。如果通過標準的COM的API來調用,可以自動完成安整。例如,把一個COM接口指針作為參數傳遞給另外一個套間的COM對象的proxy的情形。但如果軟件編程者跨套間傳遞接口指針而沒有使用標準COM機制,就需要手工完成安整(通過CoMarshalInterThreadInterfaceInStream函數)與反安整(通過CoGetInterfaceAndReleaseStream函數獲取COM接口的proxy)。例如,把COM接口指針作為線程啟動時的參數傳遞的情形。本質上,其他線程通過unmarshalling就知道了向那個窗口發送消息以實現跨套間的調用。如果跨套間調用STA的COM對象,該對象所在STA的線程必須顯式提供線程消息循環處理機制。