1. 程式人生 > >使用Qt編寫模組化外掛式應用程式

使用Qt編寫模組化外掛式應用程式

動態連結庫技術使軟體工程師們獸血沸騰,它使得應用系統(程式)可以以二進位制模組的形式靈活地組建起來。比起原始碼級別的模組化,二進位制級別的模組劃分使得各模組更加獨立,各模組可以分別編譯和連結,模組的升級不會引起其它模組和主程式的重新編譯,這點對於大系統的構建來說更加實用。另一方面,對於商業目的明顯的企業,各模組可以獨立設定訪問許可權,開發成員只能訪問自己負責的模組,其它模組是不能也不給看到的,這樣減少了整個系統洩漏技術的風險。

一、動態連結庫技術概況

動態連結庫技術用得很多。事實上,整個Windows就是由一個個動態連結庫(DLL)構建起來的,不管是系統核心,或是系統呼叫的API封裝,還是通用工具(如控制面板、ActiveX外掛等),都是一個個動態連結庫檔案。動態連結庫並不是微軟獨有的技術,它是軟體工程發展到一定階段的必然產物。在類Unix系統中,這種二進位制可執行模組技術不叫動態連結庫,而被稱為共享物件或共享庫,字尾名一般為.so(即Share Object的簡寫)。為簡便,下文將統稱這種動態連結的技術為DLL或共享庫。

其實,DLL檔案跟普通的可執行檔案差別不大,都是可執行檔案嘛,裝載到程序空間後,都是一些機器指令(函式程式碼)、記憶體分配(變數)等。在Windows中,這些可執行檔案被稱作PE/COFF格式檔案,在Linux則稱為ELF檔案。從CPU的角度看來,程式中的各個要素,不管是函式還是變數,它們都是一個個地址,函式是入口地址,變數是訪問地址;而C++的所謂類或物件,最後也被編譯器肢解成了一個個變數和函式程式碼(這裡是形象的說法,嚴謹技術解說請搜尋C++物件模型)。DLL的裝載(指匯入程序空間,然後執行)方式比可執行檔案的裝載稍微複雜,因為它把模組連結過程推遲到了執行時。在動態連結庫的裝載過程中,首要任務就是解決地址重定向問題。我們知道,DLL裝載到程序空間的位置(基址)是不確定的(動態裝載嘛),即使DLL內部使用的函式呼叫和全域性變數引用,在裝載時都要重新計算其地址。Windows採用基址重定向(Rebasing)技術解決這一問題,而Linux採用地址無關程式碼(PIC,通過GOT和PLT表實現)技術。這兩種技術各有優缺點。

二、Qt中的動態連結庫程式設計

使用C++面向物件的類編寫DLL是要注意很多細節的,主要是二進位制(ABI)相容問題。COM是一個很成功的例子,只要符合COM的規範,我們就能編寫出很好的DLL來,然而COM是微軟私生的,要想跨平臺,我們還得另找它路。

Qt的跨平臺特性同樣令人(至少是我)獸血沸騰。如果你認為QT僅僅是一個跨平臺介面庫,那就小看它了。我要說的是,它不但是一個通用的跨平臺的面向物件的應用程式介面庫(包括GUI、資料庫、網路、多執行緒、XML、資料容器和演算法等,常用的編輯資源都有封裝,就是說,這些都可以跨平臺,而不僅僅是介面),更是一種C++語言的擴充套件,一種程式設計平臺和應用程式框架。訊號和槽的機制簡化了物件之間的通訊,比MFC的訊息對映直觀多了;介面的佈局管理機制使開發人員可以很輕鬆地編出優雅的窗體;介面語言翻譯機制也很方便實用;QObject容器管理可以看到Qt在記憶體管理方面的努力;擴充套件的foreach迴圈結構也向現代語言靠攏……

Qt的跨平臺特性很好,對於本文的主題——動態連結庫的支援也很好。QT對各種平臺的動態連結庫程式設計技術都有包裝,QT把這種技術統一命名為共享庫(Shared Libraries)。通過使用Qt包裝過的類和巨集,可以編寫跨平臺的共享庫和外掛——當然,這只是原始碼級別的跨平臺,你不要指望用MSVC編譯出來的DLL,能整合到ARM平臺的Linux程式上面——這是一個很美很美的理想哦。

QT使用以下兩個巨集來實現符號(函式或全域性變數/物件)的匯出和匯入(跨平臺不能用def檔案了):

QT使用 QLibrary 類實現共享庫的動態載入,即在執行時決定載入那個DLL程式,外掛機制使用。

三、QT共享庫和外掛範例

本節通過例子,實現一個共享庫和一個外掛。在Windows平臺上開發,使用VS2005編譯,QT庫版本為4.6.2。

本例了將編寫以下三類專案:

  1. Bil 專案:共享庫專案,輸出Bil.dll和Bil.lib,基礎介面類庫,定義一個公共的介面IAnimal(抽象類),供客戶專案和外掛專案使用;
  2. Plugin 類專案:外掛類專案,現編寫BilDog和BilPanda兩外掛專案,實現IAnimal的功能,供客戶專案載入和測試。兩專案輸出BilDog.dll和BilPanda.dll;
  3. Test 專案:客戶應用程式專案,輸出Test.exe,介面中可以選擇要載入的Animal外掛,然後呼叫Animal的功能函式,完成測試;

1. 編寫共享庫——Bil 專案的實現

該專案定義一個抽象的 IAnimal 類作為匯出介面,供客戶專案和外掛專案使用。專案型別為共享庫,將生成Bil.lib和Bil.dll兩個檔案,Bil.lib供Plugin專案和Test 專案引用,而Bil.dll將給Test.exe執行時動態載入。

新建一個頭檔案Bil.h,輸入如下程式碼:

你現在可能不知道BIL_SHARE巨集有何用處。沒關係,請繼續看下面的IAnimal介面定義程式碼: 

現在知道BIL_SHARE巨集的妙用了吧。BIL_SHARE巨集會根據專案編譯選項BIL_LIB有沒有定義,自動宣告IAnimal是匯出類,還是匯入類。所以,使用BIL_SHARE巨集,我們只需要向IAnimal外掛的開發者提供同一份IAnimal定義檔案(IAnimal.h)即可。

當然,我們得先在Bil專案的編譯選項中定義BIL_LIB巨集,使得在Bil專案內,BIL_SHARE就是匯出符號的宣告。外掛專案就不要定義BIL_LIB了,因為在Animal外掛專案中,IAnimal是匯入符號。

編譯選項如何定義巨集?如果使用Visual Studio工程檔案,依次展開:專案屬性->配置屬性->C/C++->前處理器,在前處理器定義中新增巨集BIL_LIB即可;如果是QT工程檔案,請在QT工程檔案Bil.pro中加入如下定義:

在IAnimal介面中,我們定義了三個純虛擬函式Eat()、Run()和Sleep(),表示吃、跑和睡眠的動作,這是抽象的,因為不同的動物有不同的吃相和睡眠姿態,而世間的動物何止千千萬——無所謂,讓這些具體動物的不同表現交給IAnimal外掛的編寫者發揮吧——這就是介面的魅力,加上外掛的思想,整個應用程式就變成開放的,可擴充套件的了!

繼續編寫IAnimal類的實現檔案IAnimal.cpp:

 

雖然只實現了構造和解構函式,並且什麼工作也不做,但這是必要的,我們暫時不要使用內聯的構造和解構函式,否則在外掛專案實現IAnimal時可能會出現連結錯誤。

好了,我們開始編譯吧,生成整個Bil專案。最終我們得到兩個輸出檔案:Bil.lib 和 Bil.dll。

我們向Animal外掛開發者提供:

  • 兩個標頭檔案:Bil.h 和 IAnimal.h
  • 兩個庫檔案:Bil.lib 和 Bil.dll

下面的外掛類專案和客戶專案就是依賴這些檔案實現的,也許你更願意把Bil看作是一個通用的DLL類庫,就像QT或MFC一樣——事實上也是如此,Bil就是這樣一個動態的共享類庫。

2. 編寫Animal外掛——BilDog和BilPanda專案的實現

現在,讓我們來實現兩個小外掛。BilDog外掛很簡單,只是彙報下“我是Dog,我正在啃骨頭”;BilPanda也是如此——這裡僅僅是測試而已,實現的專案中,你可以盡情的發揮——沒錯,是在遵循IAnimal介面的前提下。

建立BilDog專案,把Bil專案輸出的Bil.h、IAnimal.h和Bil.lib加入到工程。

建立Dog類的標頭檔案Dog.h:

 

建立Dog類的實現檔案Dog.cpp:

呼叫QT的QMessageBox::information()函式彈出一個資訊提示框。

還有一個非常重要的工作,我們得提供一個能夠建立(釋放)Animal具體物件(這裡是Dog)的介面,並且把這些函式匯出,讓主程式(Test.exe)能夠解析這個介面函式,動態建立Animal物件,並訪問其功能。

新建BilDog.h檔案,輸入下面的程式碼:

這兩個函式的工作很簡單,直接建立和釋放物件即可。
下面是BilDog.cpp的程式碼:

至此,一個Animal外掛總算完成了。編譯,生成BilDog專案,輸出BilDog.dll外掛檔案,以供主程式Test.exe動態呼叫。

BilPanda專案和BilDog專案類似,在這裡就不把程式碼貼出來了。以後開發Animal外掛(即使是第三方)的過程都是如此。

我們不打算輸出該專案的.lib檔案和那些標頭檔案,因為我們打算讓主程式在執行時刻根據需要裝載dll外掛和呼叫外掛的功能,而不是讓主程式專案在編譯時就指定具體的外掛。

3. 編寫客戶程式——Test專案的實現

Test專案是一個測試程式專案,但它的角色是主程式,是能使用Animal外掛的客戶程式。

同樣,這個專案用到了Bil共享庫,所以得先把Bil專案的幾個輸出檔案匯入到Test專案。

我們假設Test主程式是一個對話方塊,上面有一個編輯框和一個“載入並呼叫”按鈕,終端使用者在編輯框中輸入Animal外掛的檔名(比如BilDog,字尾名可省略,Qt會根據平臺判斷該查詢.dll還是.so),點選“載入並呼叫”進行共享庫的載入,並呼叫動態建立的IAnimal物件的Eat()函式(當然你可以呼叫Run()函式或Sleep(),這裡僅僅是一個示例)。

下面的函式將被“載入並呼叫”按鈕的觸發事件呼叫:

生成Test專案,輸出Test.exe。我們把Test.exe、Bil.dll、BilDog.dll、BilPanda.dll放在同一目錄,雙擊執行Test.exe,趕快試下效果吧!注意BilDog.dll或BilPanda.dll依賴於基礎介面庫Bil.dll,如果系統找不到Bil.dll,將不能載入BilDog.dll或BilPanda.dll,所以請把它們放在同一目錄。

四、一些遺憾

DLL的願望是美好的,只要介面一致,使用者可以任意更換模組。但如果不注意細節,很容易陷入它的泥潭中,這就是傳說中的DLL Hell(DLL地獄)!

引起DLL地獄問題的主要原因有以下幾點:

1. 版本控制不好(主要是介面的版本)

    DLL是共享的,如果某程式更新了一個共享的DLL,其它同樣依賴於該DLL的程式就可能不能正常工作了!

2. 二制相容問題(ABI)

    即使同一平臺,不同編譯器(甚至同一編譯器的不同版本)編出來的共享庫和程式也可能不能協同工作。

    二制相容問題對於C++來說尤其嚴重。C++的標準是原始碼級別的,標準中並沒有對如何實現C++作出統一的規定,所以不同的編譯器,對標準C++採用不同的實現方式。這些差異主要有:物件在記憶體中的分配(C++)、構造和解構函式的實現(C++)、過載和模板的實現(C++)、虛擬函式表結構(C++)、多重繼承和虛基類的實現(C++)、函式呼叫約定(C)、符號修飾(C/C++)等。此外,不同的執行時庫(CRT、STL等標準庫)也會引起ABI相容問題。可以說,如果你在編寫基於類的共享庫,如果介面(指匯出類)稍有改變,新的DLL與原程式就可能不協同工作了。

不過這些都不是大問題,畢竟我們不是編寫像Qt一樣的通用庫。我們引入DLL劃分應用程式的模組,目的是減小系統開發和後期升級維護的難度,同時方便專案的管理。如果使用者想自己編寫外掛模組,就得使用我們指定的編譯平臺和類介面。所以我們仍能從DLL技術中得到很大的實惠。