DLL 應用 - 設計可抽換的模組
作者:蔡煥麟 日期:Jan-2-2001
摘要:介紹以 DLL 來切割應用程式的實作方式,其中包含介面程式設計的技巧以及運用 Design Patterns 來解決設計上的問題。
前言
DLL(Dynamic Link Library,動態聯結函式庫)就目前來講已經不是什麼了不得的技術,坊間書籍隨手撿一本視窗程式設計或 Delphi 的書籍都可以找到 DLL 的相關說明,少這一篇也不算少,之所以寫這篇文章,一方面是給自己的學習心得作個記錄,一方面也提供給有需要的人參考;而本文的主題--設計動態載入的模組--說穿了也只是提供一個把 Form 包在 DLL 裡面的實作方法,儘管如此,我還是希望你能在其中發現一些比較不一樣的東西。
由於現有關於 DLL 的文件資料已經很多,在此不多做重複,因此在閱讀本文時會需要一些 DLL 的基礎知識或者 DLL 的撰寫經驗,這樣閱讀起來會比較輕鬆。以下就重點式地列出一些基礎觀念:
- 靜態連結與動態連結的差異。
- 了解如何宣告 DLL 輸出函式(exported functions)以及如何在外部呼叫它們。
- 各種呼叫慣例(calling conventions)的差異。
- 何謂 DLL hell(1)以及它對應用程式的維護有何影響。
DLL 在使用上又有靜態與動態載入的區別,所謂「靜態載入的 DLL」意指在編譯時期已經確定要連結的 DLL 是哪一個,而且會在行程初始化的階段就被載入,Delphi 的 VCL packages 即屬此類。動態載入的 DLL 則是執行時期需要時才載入,在程式撰寫上比靜態載入的方式麻煩些,但較有彈性,且應用程式啟動的速度也較快。
本文所要討論的就是以動態載入的 DLL 來實作可抽換的應用程式模組。
以 DLL 切割應用程式
一般來說,使用 DLL 有下列優點:
- 節省記憶體。多個應用程式使用同一個 DLL 時,該 DLL 只會被載入一次,甚至可以用到時才載入 DLL,且用完立即釋放。
- 程式碼重複使用,可讓不同的程式語言使用。
- 應用程式模組化,可降低應用程式的複雜度,程式更新維護時較方便。
- 可支援設計多國語言的應用程式。你可以把每一種語言的字串資源分別存放在一個 DLL 裡面,程式執行時便可以動態切換程式所使用的語言。
但也會一些困難必須克服,當我們要將應用程式切割成數個 DLL 模組的時候,通常會碰到以下幾個問題:
- DLL 如何輸出(export) VCL 物件?
- 如何將一個 Form 包在 DLL 裡面以供外部使用?
- DLL 之間如何共享變數?
基本上,如果你撰寫成 package 的形式就沒有上述問題了,但你可能會遇到其他麻煩,例如:名稱衝突的問題,這包括了型態、單元名稱、函式名稱的衝突,在此之前我也曾試著以 package 的方式來撰寫可抽換的模組,但名稱衝突的問題令我覺得蠻困擾。我也曾在另一份文件中提及此事,以下這段文字是從該文轉貼上來的(2):
「在撰寫幾個 package 的測試程式之後,我還是沒有將 package 應用在實際的專案開發中,而仍然使用 DLL,其最主要的原因,正是 package 優於 DLL 之處--可以共享變數。這項功能的立意很好,但也帶來了另一些限制,主要是名稱衝突的問題,使得共用的 unit 一定要放在 package 裡面,否則當兩個 package 包含了相同的 unit,其中一個就無法載入,我們覺得這會造成麻煩。另外,由於其他的小組成員對於 package 的使用不熟,容易出 trouble(例如:project 要加入 .dcp 之類的),這也是考量之一。」
在 DLL 之間共享變數的問題可以透過記憶體映射檔(memory-mapped file)來解決,你可以在文後所附的參考資料中找到相關資訊,這裡就不贅述。而在 DLL 中輸出 VCL 物件(例如:string)時得注意以下幾點:
- 在 DLL 和其用戶端程式的 Uses 子句裡頭的第一個單元必須是 ShareMem。
- BORLNDMM.DLL 必須跟著你的應用程式一起發佈。
- 如果你修改了輸出物件的類別定義而使得原有物件的記憶體佈局改變,比如說加入一個 Integer 型態的私有成員,用戶端程式就必須重新編譯,如果使用舊的用戶端程式來呼叫新的 DLL 函式,應用程式就會發生錯誤甚至導致當機。
與其隨時注意這些規則,也許選擇可以完全避開這些問題的方法會比較好,我的意思是使用 Windows 的標準型別來傳遞資料,例如要傳遞字串,就用 PChar 來代替 string。對其他較為複雜的結構,可以使用介面來解決,這意味著兩件事情:
- 輸出的型態是個抽象類別(abstract class)或介面(interface)。
- 物件應由 DLL 來建立(用戶端程式不知道物件的記憶體佈局)。
符合了以上的規則,對於如何將 Form 物件包在 DLL 裡面的問題也就迎刃而解,稍後就會講到這部分如何實作。
介面(interface)在物件導向的領域裡是一個很重要的觀念,它描述了服務提供者和使用者之間的權責,或者說定義了兩個物件之間溝通的方式,通常這個溝通方式一經制定就不會修改(理想狀況下),因此介面亦可視為物件之間的合約。以 OOP 的角度來看,介面就是一組公開的方法(public methods),跟類別不同之處是它沒有 private 及 protected 等存取等級的區別,不可以包含資料成員,也沒有實作的程式碼,它就只是很單純的....呃...介面。在一個複雜的系統裡面,這種單純顯得特別珍貴,常常得經過一番深思熟慮之後才能萃取出較為抽象的成分,這不但有助於你在設計時以比較抽象的層次去思考,同時設計出來的介面也比較能夠再拿來重複使用。以用戶端的角度來看,介面把系統背後的複雜度隱藏了起來,用戶端就只需專注在它需要的部分,使用上會比較容易。
設計可抽換的模組
所謂可抽換的模組,就是指在程式執行時動態地載入與釋放的模組,對於規模較龐大,功能較複雜的應用程式來說,將應用程式切割成數個獨立運作的模組有以下優點:
- 應用程式部署的組態更加彈性(例如:有些模組僅包裝於某種版本中)。
- 減少應用程式每次更新版本的檔案大小。
- 有利於明確劃分小組成員的權責。
- 有效地降低單一程式的複雜度,程式較易於維護。
以下會一步步實作出一個具體而微的範例,你可以把這個範例視為一個基礎的框架(framework),稍加修改就可以運用於實際的專案開發上面。
描述需求
讓我們來簡單地分析一下應用程式的需求,假設原本的開發方式是將所有的程式單元編譯連結成一個可執行檔,現在要將應用程式的各個功能切割為獨立的模組,例如: +--- 客戶資料維護作業(Customer.DLL)
主程式(Main.exe)---+--- 產品資料維護作業(Employee.DLL)
+--- 訂單資料維護作業(Orders.DLL)
其中每個 DLL 都是在使用者執行該項功能的時候才動態載入,而且每個 DLL 裡面至少包含一個 Form,為了有別於一般的 DLL,以下就以 plugin 稱之。我們預期各 plugin DLL 所包含的 Form 會有一些共同的屬性和行為,因此把這些共同點放到一個基礎視窗類別裡面,讓其他 Form 繼承自這個基礎類別。它們的關係看起來像這樣:
每一個維護作業都需要開啟一個視窗,因此主程式的責任之一便是建立並顯示 DLL 裡面的視窗。我們希望每一個維護作業的視窗關閉後才能執行另一個維護作業,所以使用 ShowModal 的方式顯示視窗。做一些簡單的分析之後,可以得到主程式在執行每項作業時所需的共同步驟:
- 載入指定的 plugin DLL。
- 建立並顯示 plugin DLL 裡面的 Form 物件。
- 釋放 Form 物件。
- 釋放 plugin DLL。
其中載入與是釋放 plugin DLL 的工作由主程式負責,而前面有提過 DLL 中的物件必須由 DLL 自己來建立,因此建立、顯示以及釋放 Form 物件的工作都由 plugin DLL 來負責提供函式,主程式只要在適當時機去呼叫它們就行了。
主程式
在主程式中加入一個執行 plugin 的方法,此方法需要一個參數指定 DLL 的檔名以便將其載入執行,像這樣: procedure TMainForm.RunPlugin(const FileName: string);
var
ADllHandle: THandle;
APlugin: IPlugin;
AFormHandle: THandle;
begin
ADllHandle := SafeLoadLibrary(FileName);
if ADllHandle <> 0 then
begin
APlugin := DllCreatePlugin(ADllHandle, Application.Handle);
try
AFormHandle := APlugin.CreateForm(Handle);
APlugin.ShowModalForm;
APlugin := nil;
FreeLibrary(ADllHandle);
except
FreeLibrary(ADllHandle);
raise;
end;
end
else
ShowMessage(''''無法載入函式庫: '''' + FileName);
end;
從以上程式碼可以看出主程式載入 DLL 之後會呼叫 DllCreatePlugin 來建立 plugin 物件並且取得其介面參考,接著主程式就利用該介面參考來存取 plugin 物件提供的服務,包括建立視窗,顯示視窗等等。很明顯地,IPlugin 介面是主程式和 plugin DLL 之間溝通的橋樑,而且 IPlugin 介面至少要提供下列方法:
CreateForm - 建立 Form 物件 ShowModalForm - 顯示視窗 DestroyForm - 摧毀 Form 物件
眼尖的讀者可能會發現,上面的程式中並沒有呼叫 DestroyForm,而且也沒有呼叫類似 DllDestroyPlugin 的函式來摧毀 plugin 物件,這些物件什麼時候會被釋放掉?
它們是自動被釋放掉的。由於 Form 物件的建立是透過 plugin 物件來完成,所以我打算把摧毀 Form 物件的責任交給 plugin 物件,也就是當 plugin 物件摧毀時會自動將 Form 物件一併釋放掉;而為了簡化摧毀 plugin 物件的動作,我讓 plugin 物件具有自動參考計數的能力,這麼一來只要該物件沒有人使用它(物件的參考計數為 0)就會自動釋放掉了,做法很簡單,只要讓實作 IPlugin 的類別繼承自 TInterfaceObject 就行了,其他細節都由 VCL 幫我們完成了。
LoadLibrary 與 FreeLibrary 也有自己的參考計數,並且用它來決定是否載入及釋放 DLL。也就是說重複呼叫 LoadLibrary(''''A.DLL'''') 並不會將 A.DLL 載入兩次,第二次的呼叫只會遞增參考計數而已;同樣的,FreeLibrary 會遞減 DLL 的參考計數,直到計數為 0 才會真正將 DLL 釋放掉。
接著看 DllCreatePlugin 函式: type
TCreatePluginFunc = function (hApp: THandle): IPlugin; stdcall;
const
SDllCreatePluginFuncName = ''''CreatePlugin'''';
implementation
resourcestring
sErrorLoadingDLL = ''''無法載入模組!'''';
sErrorDllProc = ''''無法呼叫 DLL 函式: %s'''';
function DllCreatePlugin(hLib, hApp: THandle): IPlugin;
var
pProc: TFarProc;
CreatePluginFunc: TCreatePluginFunc;
begin
pProc := GetProcAddress(hLib, PChar(SDllCreatePluginFuncName));
if pProc = nil then
raise Exception.CreateFmt(sErrorDllProc, [SDllCreatePluginFuncName]);
CreatePluginFunc := TCreatePluginFunc(pProc);
Result := CreatePluginFunc(hApp);
end;
DllCreatePlugin 會嘗試從指定的 DLL 模組中呼叫函式 ''''CreatePlugin'''' 來建立 plugin 物件,並且傳回 plugin 物件的介面參考,參數 hLib 是 DLL 代碼,而 hApp 則直接傳遞給 DLL 的CreatePlugin 函式,這個參數的作用稍後會解釋。
至此主程式所需的程式碼大致上已經完成了,接下來看看 DLL 的 CreatePlugin 函式。
DLL 的輸出函式
我們的 plugin DLL 只有輸出一個函式供外界呼叫,就是前面提到的 CreatePlugin,其函式原型為:
function CreatePlugin(hApp: THandle): IPlugin; export; stdcall;
CreatePlugin 函式會建立 TPlugin 物件並且傳回 IPlugin 介面的參考。由於 plugin 物件僅需被建立一次,我們可以用一個全域變數實作出簡單的 Singleton(3): var
g_PluginIntf: IPlugin = nil;
implementation
function CreatePlugin(hApp: THandle): IPlugin;
begin
Application.Handle := hApp; // 讓 EXE 與 DLL 使用同一個 application handle.
if g_PluginIntf = nil then
g_PluginIntf := TPlugin.Create; // TPlugin 的物件參考計數 = 1
Result := g_PluginIntf; // TPlugin 的物件參考計數 = 2
end;
CreatePlugin 需要傳入一個參數 hApp,代表呼叫者程序的 Application 物件的 Handle,通常是傳入 Application.Handle,好讓主程式和 DLL 的 Application 物件能夠「同步」。之所以要這麼做是因為當你的 DLL 專案未使用 "Build with runtime package" 選項時,執行檔和載入的 DLL 會各自有一個 Application 物件,但是只有執行檔的 Application 物件有連結一個視窗,DLL 則沒有,因此 DLL 的 Application.Handle 屬性總是為 0。若少了這個同步的動作,那麼當 DLL 的 Form 開啟時,你會在桌面的工作列上看到多了一個視窗按鈕,看起來就像執行了另一個應用程式一樣,我們不希望看到這種情形。
當然啦,如果你的主程式和 DLL 都使用 "Build with runtime packages" 來建立(你應該這麼做),就不需要這個同步動作了(想想看為什麼?)。
程式碼裡面有兩行關於物件參考計數的註解,是想要表達介面程式設計的一個基本觀念:當一個介面參考在函式之間以 pass by value 的方式傳遞時會遞增物件的參考計數(pass by reference 則不會)。此觀念有助於你正確掌握物件的壽命。
最後別忘了還要把這輸出函式加到專案原始碼的 exports 子句裡頭: exports
CreatePlugin;
IPlugin 介面與 TPlugin 類別
IPlugin 介面定義如下: IPlugin = interface
[''''{D3F4445A-C704-42BC-8283-822541668919}''''] // 按 Ctrl+Shift+G 產生 GUID
function CreateForm(hMainForm: THandle): THandle;
procedure DestroyForm;
function ShowModalForm: Integer;
end;
其實以上函式也可以寫成一般的 DLL 函式,當作是 DLL 的介面,之所以另外定義這個介面,一方面是希望簡化 DLL 本身的介面,另一方面也可以集中管理程式碼,以後如果需要增加介面方法的話,只要加在 IPlugin 介面裡面就好了,不用把現有的 DLL 原始碼一個個找出來修改,這也有助於簡化 DLL 的撰寫以及日後的維護工作。
介面不包含實作,實作必須由類別來提供。
接著定義一個 TPlugin 類別來實作 IPlugin 介面: TPlugin = class(TInterfacedObject, IPlugin)
private
FForm: TForm;
public
destructor Destroy; override;
function CreateForm(hMainForm: THandle): THandle;
procedure DestroyForm;
function ShowModalForm: Integer;
end;
在 IPlu [1] [2] 下一页 [系统软件]用RegDllView揪出所有已注册的dll/ocx [系统软件]Windows系统的活动大陆基石 细看DLL文件 [系统软件]Windows的活动大陆:细看DLL文件 [常用软件]利用msdvm.dll实现微软虚拟桌面 [VB.NET程序]使用VB6.0设计ActiveX DLL事件 [VB.NET程序]Crystal Report(水晶报表)的报表封装成VB的DLL [VB.NET程序]用diskid.dll和disk32.dll获得硬盘序列号 [VB.NET程序]几个 WMI 的例子(初级) - 1 [VB.NET程序]几个 WMI 的例子(初级) - 2 [VB.NET程序]几个 WMI 的例子(初级) - 3
|