在我開發基於動態代理的輕量容器過程中,動態裝入外部的客戶自定義接口/類/組件功能是一個必要的組成部分。對於應該選擇用DLL還是BPL來作為自定義組件的實現方式一直不能確定。在反復的試驗過程中,發現了一些其中的技術細節,特別是在用字符串類型作為參數或返回值的情況下。
凡是用DELPHI開發過DLL的,都知道DELPHI的DLL向導生成的代碼中,在DLL Project Source一開頭就有一段長長的“關於DLL內存管理的重要說明”,其內容大致是說:如果在DLL的exports函數中使用string類型作為參數或返回值的,必須在DLL的Uses段和你的應用程序的Uses段的最前面加入ShareMem,它會使二者共同使用BORLNDMM.DLL進行內存管理,才能保證string類型的內存分配/釋放正確。
這是因為string類型在DELPHI內部,由編譯器為其提供了動態內存分配/釋放機制和引用計數機制,這才能使得string類型可以像一般簡單類型一樣地使用,而不用像在C/C++中那樣麻煩地考慮內存管理和防止內存洩漏。但這同時也帶了像DLL中的這樣的問題:如果不使用ShareMem的話,就有可能發生在一處分配的內存被錯誤地在另一處釋放,最終導致討厭的Access Violation。
對於簡單的函數調用,用DLL+ShareMem就可以實現了,但是如果涉及到接口和類的時候就不行了。
考慮下面這個簡單的接口及實現。
//---------------------------------------------------------
// 定義在接口單元
{$M+}
IDemoIntf = interface
['{5F3C4D61-B885-41B6-B43B-C4725DF5D901}']
Function GetHello( nID : Integer ) : String; StdCall;
End;
{$M-}
//---------------------------------------------------------
// 定義在實現單元
type
TDemoImpl = class(TInterfacedObject, IDemoIntf)
protected
{ Protected declarations }
Function GetHello( nID : Integer ) : String; StdCall;
end;
Procedure CompRegister( aIntfReg : TMRegisterIntfEvent ); Cdecl;
implementation
Procedure CompRegister( aIntfReg : TMRegisterIntfEvent );
Begin
aIntfReg( IDemoIntf, TypeInfo( IDemoIntf ), TDemoImpl );
End;
{ TDemoImpl }
function TDemoImpl.GetHello(nID: Integer): String;
begin
Result := 'Hello ' + IntToStr( nID );
end;
首先來看DLL版的實現。創建一個DLL Project,然後把上述兩個單元加入,並將CompRegister函數Exports出來。關於這個CompRegister函數要作一下簡單說明:
這個CompRegister就是一個注冊入口,容器將該DLL調用入立即執行這個注冊函數,而用戶組件包必須實現這個注冊函數,並在其中向容器注冊用戶實現的接口/類等。TMRegisterIntfEvent是一個方法類型,容器調用注冊函數時通過參數將它的注冊方法引用傳給這個注冊函數供它調用。
下面是對應的用DUnit實現的單元測試程序。
procedure TTestCaseDLLPackageLoader.Setup;
Var
funcInit : TMFuncCompRegister;
begin
hPkg := LoadLibrary( 'demopkg.dll' );
funcInit := TMFuncCompRegister( GetProcAddress( hPkg, 'CompRegister' ) );
funcInit( GMIntfReg.RegisterIntf );
end;
procedure TTestCaseDLLPackageLoader.TearDown;
begin
GMIntfReg.UnregisterIntf( IDemoIntf );
FreeLibrary( hPkg );
end;
procedure TTestCaseDLLPackageLoader.TestLoader;
Var
f : IDemoIntf;
begin
f := GMIntfReg.GetIntfInstance( IDemoIntf ) As IDemoIntf;
Check( f.GetHello( 10 ) = 'Hello 10' );
end;
這個測試很簡單:首先在初始化(Setup方法[1])載入DLL,然後調用CompRegister注冊接口和實現類。在測試函數TestLoader中,通過接口注冊管理器(GMIntfReg,由容器提供)的GetIntfInstance方法取得接口實例。這個方法的內部實現原理就是通過接口的GUID找到對應的類類型,然後創建一個實例,再通過Supports函數轉成接口引用。調用接口方法GetHello並用DUnit的Check函數進行檢查。最後在清理過程(TearDown方法[1])中刪除接口注冊信息並釋放DLL。
但結果卻很不幸,測試沒有通過。問題就在於,f.GetHello( )返回了一個string,而這個string所用的內存是在DLL中分配的,但因為DELPHI的編譯在TestLoader返回前,會自動清理這樣的返回值string引用。經過調試可以發現問題就在這裡,首先可以肯定,Check函數的檢查是通過的,但是打開CPU調試窗口跟蹤就可以發現在編譯生成的清理代碼執行時就發生異常了。
為什麼明明使用了ShareMem但還是會出錯呢?因為這裡調用的接口方法f.GetHello( )並非一個DLL的exports函數,所以它的參數或返回值裡用到了string並不會被ShareMem所管理,出錯也就是當然的事了。
再來看看BPL方式的實現。同樣是創建一個BPL Project,然後把前面那個接口單元和實現單元加入,不過這裡就不需要像DLL那樣exports了,但需要加入一個Hack的東西:
Procedure HackRegister;
Asm
push edx
push eax
call CompRegister
pop ecx
pop ecx
End;
下面是BPL版的測試代碼。
procedure TTestCaseBPLPackageLoader.Setup;
Var
funcInit : TProcedure;
begin
hPkg := LoadPackage( 'demobpl.bpl' );
funcInit := TProcedure( GetProcAddress( hPkg, '@Unit1@HackRegister$qqrv' ) );
CompRegisterHack( funcInit, GMIntfReg.RegisterIntf );
end;
procedure TTestCaseBPLPackageLoader.TearDown;
begin
GMIntfReg.UnregisterIntf( IDemoIntf );
UnloadPackage( hPkg );
end;
procedure TTestCaseBPLPackageLoader.TestLoader;
Var
f : IDemoIntf;
begin
f := GMIntfReg.GetIntfInstance( IDemoIntf ) As IDemoIntf;
Check( f.GetHello( 10 ) = 'Hello 10' );
end;
基本上與DLL版一樣,只是調用注冊入口是通過CompRegisterHack間接實現,在其中將參數存入edx:eax後調用HackRegister函數指針。除此之外,與DLL版本完全一樣。
理論上這樣應該是沒有問題的,但為了保險起見,我用了調試方式運行這個測試,跟蹤了CPU窗口的代碼,直到測試函數返回時,似乎都沒有問題。但是繼續運行下去卻在DUnit的測試框架中發生了意料之外的異常。這讓我好幾天百思不得其解,因為DELPHI本身也是這樣使用BPL的,使用String的地方也很常見,從來沒聽說會有問題。為了找到問題所在,我甚至試過把實現類改為從TComponent派生,但異常仍然存在。
最後我注意到了在這裡,雖然在GetIntfInstance中創建的是TDemoImpl的實例,但返回後調用的GetHello方法是接口類型IDemoIntf的成員。理論上調用接口方法本質上是通過OOP的虛函數機制映射到類實例上的,應該沒什麼區別,但是這裡涉及到了跨Module調用(在EXE與BPL之間調用代碼)的問題,會不會在這裡經過映射後破壞了ShareMem要求的條件?
為了證實這個猜測,我在測試程序中包含了類定義代碼,並且將接口方法調用改為類方法調用,一試果然成功。為了深入了解問題的本質,我再次查看了類方法調用和接口方法調用兩種情況下的CPU窗口。果然接口方法調用後經過一次虛函數的跳轉,而跳轉後的目標地址居然與類方法調用的地址不同!但兩種情況下不同代碼地址處的代碼卻又是完全一樣的!
這就讓我一籌莫展了。我甚至為此實現了一個基於接口的String類來代替String類型,雖然用這個可以實現通過參數傳遞String,但是返回值還是不行。而且這種方法實在是難看而且難用。
今天偶然想到我的測試程序是按靜態包方式編譯的,即不帶BPL方式運行的,而DELPHI本身是編譯為帶BPL方式運行的。所以我抱著試試看的心態,把測試程序改為帶VCL.bpl和RTL.bpl的方式。
果然測試通過了。