代碼的連結在《用C#編寫一個進程外的COM組件》,小技巧:如果你要同時看範例程式碼和講解的話,可以用瀏覽器分別開啟範例程式碼和這篇文章,然後使用Windows提供的縱向平鋪視窗功能就可同時看兩篇文章了。
TestComVisibleClass.cs裡面定義了我們要發布給COM客戶程式的.NET對象,由於我們的.NET進程外組件需要調用幾個COM庫的API,因此在ComHelperClass裡面定義這些API在.NET裡面的聲明方式,正確地聲明P/Invoke函數的原型非常困難,要求程式員對Win32,COM和Managed 程式碼都很熟悉才可以做,所以我寫了另外一篇文章《使用Signature Tool自動產生P/Invoke調用Windows API的C#函式宣告》簡化P/Invoke函式宣告的步驟。
ComHelperClass類裡面CoRegisterClassObject函數的原型比較有意思,注意rclsid參數前面的[MarshalAs(UnmanagedType.LPStruct)]屬性,這個屬性告訴.NET,在從.NET一端傳遞rclsid參數值到Unmanaged 程式碼一端時,不要使用預設的列集(Marshal)規則,在P/Invoke裡面,NET預設將結構體對象完整複製到非託管的記憶體裡,使用UnmanagedType.LPStruct告訴NET將Guid對象的指標傳遞給非託管函數,就省去了在調用的時候添加ref關鍵字的麻煩,UnmanagedType.LPStruct會有另外一篇文章來解釋它,它有些特別。.NET預設將類執行個體對象列整合VARIANT拷貝到非託管的記憶體裡,因此第二個參數我用[MarshalAs(UnmanagedType.IUnknown)]通知.NET需要將這個對象執行個體列整合IUnknow *指標。
我們的.NET進程外組件有可能被一些C++編寫的用戶端調用到,對於C++程式來說,使用前綁定(即通過介面指標調用介面成員函數)的方式會更加方便一些,否則使用延遲綁定技術(通過IDispatch介面調用介面成員函數)的方式C++代碼會比較複雜一些。因此將TestComVisibleClass的一些方法和屬性提取成一個介面,並且分別給ITestComVisible介面和TestComVisibleClass類分配了一個GUID,在COM世界裡,前者就是我們熟知的IID,後者則是CLSID。
同時,為了方便VB程式使用.NET進程外組件,我還特意給ITestComVisible介面的屬性和函數指定了DispID,因為在OLE規範裡,DispID為0,-1等幾個特殊值的函數有特殊的意義,這一點我將會在後面的文章裡講到。
由於我們不能用mscoree.dll自己提供的類啟用策略來在COM中啟用我們的.NET對象,mscoree.dll預設提供的啟用策略也會在後面的文章裡講到,我們只好顯示地提供類廠,並且將我們的類廠在COM運行庫裡面註冊一下。類廠(ClassFactory)的相關介面同樣需要定義一個C#形式的原型,一個比較取巧的辦法就是使用tlbimp產生一個dll,或者就是看看System.Runtime.InteropServices.ComTypes命名空間裡面是否已經有定義好了的類型?IClassFactory最重要的一個函數就是CreateInstance,我們的實現就是看看用戶端需要什麼樣子的介面,如果是IUnknown或者是我們發布的ITestComVisible(102行到107行),否則就在109行的位置上拋出一個異常(碰到錯誤就拋異常的習慣在COM世界裡不是一個友好的方式,My Code為了簡單就採取了拋異常的方式,更好的做法是返回錯誤碼,由COM用戶端決定如何處理這個錯誤。)
在程式啟動的時候,我們將自己實現的類廠註冊在COM運行庫裡面(Program.cs的48行到53行),記得保留CoRegisterClassObject返回給我們的註冊ID(第53行)。Program.cs裡面的35行到44行可選,它們的目的是做一些安全檢查,確保只有一些有許可權的使用者才能調用你的C# Dcom組件,如果你對安全性不關心的話,可以刪除它們。在程式退出的時候(Program.cs的26行到29行)掃除一些尾巴,釋放一些資源。
那為什麼我們還要一個註冊表檔案呢?這是因為在前綁定調用方式裡,我們需要將指標在進程間傳遞,比如在用戶端C++代碼的第21行,實際上CoCreateInstanceEx需要啟動我們的.NET進程(也即是進程外COM伺服器),調用我們在Program.cs的第53行註冊過了的IClassFactory介面來建立.NET對象執行個體,然後將執行個體的ITestComVisible介面指標從.NET進程傳回C++用戶端進程裡來。可能有人會說,可以直接將指標的地址到C++用戶端進程去嘛?這是不行的,因為Windows作業系統將進程與其它進程獨立開來,簡單說,一個進程裡面的虛擬記憶體地址在另外一個進程裡面可能指向一個垃圾,原理請參看作業系統書籍裡面關於虛擬記憶體的描述。
為了在進程間傳遞ITestComVisible介面指標,COM庫需要知道如何列集ITestComVisible指標,一般情況下,程式員需要提供另外一個DLL,這個DLL包含了列集ITestComVisible指標的代碼。為什麼要提供代碼來列集指標的原因是,不同的介面包含不同的函數,例如在用戶端C++代碼的第25行和第26行,COM庫需要列集遠程函數調用(RPC),也就是需要一個方法將TestMethod和Release的調用區分開來。一般這個DLL,可以通過用msidl.exe分析IDL檔案來產生列集合函式的原始碼編譯產生。然而,這種方法比較麻煩,因此微軟提供了OLEAUT32.dll,裡面有一個通用的介面列集合函式(但是這個函數不能列集所有的介面,可以列集的介面需要遵循一些規則,這個後面有時間再講),可以通過分析TLB檔案來列集指標,因為TLB檔案相當於.NET Assembly裡面的中繼資料,oleaut32.dll可以知道你的com組件裡面有哪些介面,各個介面的聲明又是怎樣的,函數的參數類型是什麼等等。但是oleaut32.dll需要查詢註冊表才能知道介面存在的Tlb檔案的位置:
1. 因此註冊表代碼裡面的第3行到第16行在註冊表裡面儲存了類型庫的存放路徑,注意,在COM世界裡,tlb檔案也是用GUID來唯一標識的,你可以用oleview.exe開啟regasm.exe或者tlbexp.exe產生的tlb檔案,找到[custom(9903F14C-12CE-4c99-9986-2EE3D7D588A8)…]那一段文字來找到Tlb檔案的GUID。
2. 註冊表代碼裡面的第18行到第28行,在註冊表裡面儲存了列集ITestComVisible介面的資訊,例如ProxyStubClsid指的是採用oleaut32.dll提供的通用介面列集合函式,並且儲存了該函數所使用tlb檔案的資訊(第26行到28行)。如果註冊表裡面沒有列集介面的資訊,CoCreateInstanceEx函數會返回E_NOINTERFACE(不支援此介面)錯誤—一個讓人稀裡糊塗的錯誤碼。
3. 最後註冊表代碼裡面的第30行到第41行向全世界聲明(不好意思,不是中國人民從此站起來了的那種聲明):我們的.NET對象不需要通過mscoree來在COM端啟用,自己可以完成啟用操作,因此我們刪掉了由regasm.exe插入的InprocServer32索引值,而是添加了LocalServer32索引值。
附,上面註冊表代碼裡面的第3行到第28行可以用RegisterTypeLib函數來完成,而RegisterTypeLib所需要的ptlib參數可以通過LoadTypeLib函數來拿到。