PHP是一個為Web開發(fā)而設(shè)計的語言。在PHP5之后,PHP開始大力支持對象,因此你可以享受到設(shè)計模式帶來的好處,就像使用其他面向?qū)ο笳Z言(特別是Java)那樣。
本章將舉一個簡單的例子來說明設(shè)計模式的使用。請注意,選擇使用某種模式時,并不一定也要使用與該模式配合良好的其他所有模式,而且示例中介紹的部署這些模式的方法也不是唯一可行的方法。示例主要用于幫助理解模式的核心思想,你可以從中提取自己需要的內(nèi)容,并應(yīng)用于實際開發(fā)當(dāng)中。
本章介紹的內(nèi)容很多,是全書最長和最復(fù)雜的章節(jié),相信讀者很難一次性讀完。本章分為一個簡要介紹和兩個主要部分。你可以在讀完其中某部分時休息一下。
12.1節(jié)中介紹了一些獨(dú)立的模式。盡管這些內(nèi)容有時是相互關(guān)聯(lián)的,但可以直接跳到任何一個你想了解的模式進(jìn)行閱讀和學(xué)習(xí),在你有空的時候再閱讀其他相關(guān)模式的內(nèi)容。
本章包括以下內(nèi)容。
- 架構(gòu)概述:企業(yè)應(yīng)用程序分層。
- 注冊(Registry)模式:管理應(yīng)用程序數(shù)據(jù)。
- 表現(xiàn)層:管理和響應(yīng)用戶請求,并把數(shù)據(jù)呈現(xiàn)給用戶。
- 業(yè)務(wù)邏輯層:處理系統(tǒng)的真實任務(wù)------解決業(yè)務(wù)問題。
12.1 架構(gòu)概述
因為涉及的內(nèi)容比較廣泛,所以首先概述一下模式,然后介紹如何構(gòu)建分層的應(yīng)用程序。
12.1.1 模式
下面是本章將要討論的設(shè)計模式。你可以從頭看到尾,也可以根據(jù)需要和興趣選擇性地閱讀。注意命令模式?jīng)]有在此單獨(dú)介紹(在第11章介紹過),但會在前端控制器和應(yīng)用控制器模式中提及
- 注冊表:該模式用于使數(shù)據(jù)對進(jìn)程中所有的類都有效。通過謹(jǐn)慎的序列化操作,注冊表對象可以用于存儲跨會話甚至跨應(yīng)用實例的數(shù)據(jù)。
- 前端控制器:在規(guī)模較大的系統(tǒng)中,該模式可用于盡可能靈活地管理各種不同的命令和視圖。
- 應(yīng)用控制器:創(chuàng)建一個類來管理視圖邏輯和命令選擇。
- 模板視圖:創(chuàng)建模板來處理和顯示用戶界面,在顯示標(biāo)記中加入動態(tài)內(nèi)容。盡量少使用原始代碼。
- 頁面控制器:頁面控制器滿足和前端控制器相同的需求,但較為輕量級,靈活性也小一些。如果想快速得到結(jié)果而且系統(tǒng)也不太復(fù)雜的話,可以使用這種模式管理請求和處理頁面邏輯。
- 事務(wù)腳本:如果想要快速完成某個任務(wù),可以使用本模式。通過簡單的規(guī)劃,用“過程式”的代碼來實現(xiàn)程序邏輯。但本模式的可伸縮性不佳。
- 領(lǐng)域模型:和事務(wù)腳本相反,使用本模式可以為業(yè)務(wù)參與者和過程構(gòu)建基于對象的模型。
12.1.2 應(yīng)用程序和層
本章大部分模式是用來使程序中不同的“層”(tier,也稱為layer)獨(dú)立工作的。就像類的使命是執(zhí)行特定的任務(wù),企業(yè)應(yīng)用系統(tǒng)中的層也是如此,不過更為粗獷。圖12-1展示了一個系統(tǒng)中分工明確的各個層。
圖12-1所示的結(jié)構(gòu)并不是固定不變的,其中一些層可以合并,而且層之間的交互策略可能根據(jù)系統(tǒng)的復(fù)雜度而不同。無論如何,圖12-1展示的模型強(qiáng)調(diào)靈活性和重用,而許多企業(yè)應(yīng)用就是根據(jù)“靈活”和“重用”的原則進(jìn)行擴(kuò)展的。
- 視圖層包括系統(tǒng)用戶實際看到和交互的界面。它負(fù)責(zé)顯示用戶請求的結(jié)果及傳遞新的請求給系統(tǒng)。
- 命令和控制層處理用戶的請求。它委托業(yè)務(wù)邏輯層處理和滿足請求,然后選擇最合適的視圖,將結(jié)果顯示給用戶。實際上,這個層和視圖層常常合并為表現(xiàn)層。即使這樣,顯示的任務(wù)應(yīng)當(dāng)嚴(yán)格地與請求處理和業(yè)務(wù)邏輯調(diào)用分離開來。
- 業(yè)務(wù)邏輯層負(fù)責(zé)根據(jù)請求執(zhí)行業(yè)務(wù)操作。它執(zhí)行需要的計算并整理結(jié)果數(shù)據(jù)。
- 數(shù)據(jù)層負(fù)責(zé)保存和獲取系統(tǒng)中的持久信息。在某些系統(tǒng)中,命令和控制層使用數(shù)據(jù)層來獲取他所需要的業(yè)務(wù)對象。但在其他系統(tǒng)中,數(shù)據(jù)層通常盡可能地被隱藏起來。
那么為什么要用這種方式劃分系統(tǒng)呢?答案是解耦(decoupling)。通過分離業(yè)務(wù)邏輯層與視圖層,當(dāng)添加新的接口到系統(tǒng)時,系統(tǒng)內(nèi)部只需要做很小的改動。
假設(shè)有一個管理時間列表的系統(tǒng)。終端用戶需要一個漂亮的HTML接口,而維護(hù)系統(tǒng)的管理員可能需要一個命令行接口來構(gòu)建自動化系統(tǒng),同時,你可能需要開發(fā)支持手機(jī)和其他手持設(shè)備訪問的版本,甚至可能考慮使用REST式API或SOAP等協(xié)議。
如果你以前把系統(tǒng)的底層邏輯和HTML視圖層混合在一起(盡管這種寫法備受批評,但在PHP項目中依然很普遍),上面所提的這些需求將會讓你不得不重寫代碼。另一方面,如果已經(jīng)創(chuàng)建了分層的系統(tǒng),就可以直接使用新的顯示方案而不用重新考慮業(yè)務(wù)邏輯和數(shù)據(jù)層。
同樣,項目的持久性策略也可能改變。你應(yīng)該能夠在對系統(tǒng)的其他層影響最小的情況下更換存儲模型。
將系統(tǒng)分層的另一個原因是測試。Web應(yīng)用程序是很難測試的。任何一種自動測試在需要在一端解析HTML接口并在另一端使用在線數(shù)據(jù)庫時都會很為難。也就是說,測試工作必須運(yùn)行在完全部署的系統(tǒng)上,并且冒著破壞本應(yīng)受保護(hù)的真實系統(tǒng)的風(fēng)險。在分層系統(tǒng)中,任何需要與其他層直接打交道的類通常都擴(kuò)展自抽象父類或者實現(xiàn)了同一個接口。這個父類型可以支持多態(tài)。在測試環(huán)境中,一個完整的層可以被一組虛擬的對象(通常稱為stub或mock對象)所代替。例如,通過這種方法,我們可以使用虛擬的數(shù)據(jù)層來測試業(yè)務(wù)邏輯層。你可以在第18章讀到更多關(guān)于測試的內(nèi)容。
即使系統(tǒng)只有一個簡單的接口,并且你覺得測試時多余的時候,分層仍是非常有用的。通過創(chuàng)建獨(dú)立分工的層,可以構(gòu)建一個易于擴(kuò)展和調(diào)試的系統(tǒng)。將具有同樣功能的代碼放在同一個地方可以減少代碼重復(fù)(而不是將系統(tǒng)和數(shù)據(jù)庫調(diào)用或顯示方案綁定在一起),因此添加功能到系統(tǒng)中會相對簡單,因為這些改變是縱向而不是橫向的。
在分層系統(tǒng)中,一個新功能可能需要一個新的接口組件、額外的請求處理、更多的業(yè)務(wù)邏輯和對存儲機(jī)制的修改。這些修改是縱向的。在一個沒有分層的系統(tǒng)中,如果要增加新的功能,則可能需要記住5個甚至更多和數(shù)據(jù)庫相關(guān)的頁面。新的接口可能會在數(shù)十個地方被調(diào)用,因此需要為系統(tǒng)增加這部分的代碼。這就是橫向的修改。
當(dāng)然,實際上我們并不能完全避免這種橫向依賴,特別當(dāng)修改頁面的導(dǎo)航部分的時候。盡管如此,一個分層的系統(tǒng)有助于最小化橫向修改的需要。
本章所有例子都圍繞一個虛擬的事件列表系統(tǒng),系統(tǒng)的名稱叫Woo,它是What's On Outside(外頭發(fā)生了什么事)的縮寫。
系統(tǒng)由場所(venue,如劇院、俱樂部和電影院)、空間(space,如屏幕1和樓上)和事件(event,如電影The Long Good Friday、The Importance of Being Earnest)組成。
本系統(tǒng)的操作包括創(chuàng)建場所、添加空間到場所和列出系統(tǒng)中的所有場所。
記住,本章的目標(biāo)是闡述主要的企業(yè)設(shè)計模式而不是構(gòu)建一個實際系統(tǒng)。由于設(shè)計模式之間常常相互依賴,本章中的大部分示例也常常會互相重疊,以充分利用本章其他地方介紹的基礎(chǔ)知識。本章的代碼主要是用來解釋企業(yè)模式的概念,因此無法符合實際系統(tǒng)中的所有標(biāo)準(zhǔn),甚至為了簡潔起見,還忽略了錯誤檢查。讀者應(yīng)該把這些例子當(dāng)成學(xué)習(xí)設(shè)計模式的途徑,不要直接當(dāng)做框架或程序中的一部分。
12.2 企業(yè)架構(gòu)之外的基礎(chǔ)模式
12.2.1 注冊表
12.3 表現(xiàn)層
當(dāng)一個請求到達(dá)系統(tǒng)時,系統(tǒng)必須能夠理解請求中的需求是什么,然后調(diào)用適當(dāng)?shù)臉I(yè)務(wù)邏輯進(jìn)行處理,最后返回相應(yīng)結(jié)果。對于簡單的程序,整個過程可能完全放在視圖之中,只有重量級的邏輯和持久化操作相關(guān)的代碼才放在封裝好的類庫中。
注解:一個視圖是指視圖層中一個單獨(dú)的元素。它可能是一個PHP頁面(或視圖元素集合),負(fù)責(zé)顯示數(shù)據(jù)和讓用戶生成新請求。在像Smarty這樣的模板系統(tǒng)中,一個視圖即指一個模板。
隨著系統(tǒng)的增長,這種默認(rèn)方案不能滿足處理請求、調(diào)用業(yè)務(wù)邏輯和派發(fā)視圖的要求。
本節(jié)我們將研究表現(xiàn)層管理以上3個主要功能的策略。視圖層與命令和控制層的邊界通常很模糊,因此我們常把這兩個層統(tǒng)稱為“表現(xiàn)層”。
12.3.1 前端控制器
本模式和傳統(tǒng)PHP應(yīng)用程序的“多入口”方式相反。前端控制器模式用一個中心來處理所有到來的請求,最后調(diào)用視圖來講結(jié)果呈現(xiàn)給用戶。前端控制器模式是Java企業(yè)應(yīng)用的核心模式之一。本模式在《J2EE核心模式》中有詳細(xì)的講解,它同時也是最有影響力的企業(yè)模式之一。在PHP中,這個模式并沒有受到廣泛喜愛,部分原因是初始化前端控制器所需要的開銷會導(dǎo)致系統(tǒng)性能下降。
現(xiàn)在我寫的大部分系統(tǒng)都開始向前端控制器模式轉(zhuǎn)移。雖然我有時沒有使用完整的前端控制器模式,但是我發(fā)現(xiàn)在項目中使用前端控制器模式確實可以提供我需要的靈活性。
- 問題
當(dāng)請求可以發(fā)送到系統(tǒng)中多個地方時,我們很難避免代碼重復(fù)。你可能需要驗證用戶、把術(shù)語翻譯成多種語言或者只訪問公用數(shù)據(jù)。當(dāng)多個頁面都要執(zhí)行同一個操作時,我們可以從一個頁面復(fù)制該操作相關(guān)的代碼并粘貼到另一個頁面。但是這樣的話,當(dāng)需要修改系統(tǒng)中某個部分時,其他部分也要隨著改變,給代碼維護(hù)帶來困難。因此我們要盡量避免這種情況。當(dāng)然,首先要做的是把公共操作集中到類庫代碼中。但即使這樣,對庫函數(shù)和方法的調(diào)用代碼仍然會分布到系統(tǒng)中各個部分。
當(dāng)系統(tǒng)控制器和視圖混雜在一起時,管理視圖的切換和選擇是另一個難點(diǎn)。在一個復(fù)雜的系統(tǒng)中,隨著輸入和邏輯層中操作的成功執(zhí)行,一個視圖中的提交動作可能會產(chǎn)生任意數(shù)目的結(jié)果頁面。從一個視圖跳到另一個視圖時,可能會產(chǎn)生混亂,特別當(dāng)某個視圖被用在多個地方的時候。 - 實現(xiàn)
在核心部分,前端控制器模式定義了一個中心入口,每個請求都要從這個入口進(jìn)入系統(tǒng)。前端控制器處理請求并選擇要執(zhí)行的操作。操作通常都定義在特定的Command對象中。Command對象是根據(jù)命令模式組織的。
圖12-4展示了一個前端控制器的結(jié)構(gòu)。
實際開發(fā)時,你可能會部署一些助手類來協(xié)助控制器的處理過程,但現(xiàn)在我們還是先從控制器的核心部分開始研究。下面是一個簡單的Controller類:
namespace woo\controller;
//...
class Controller{
private $applicationHelper;
private function __construct(){}
static function run(){
$instance = new Controller();
$instance->init();
$instance->handleRequest();
}
function init(){
$applicationHelper = ApplicationHelper::instance();
$applicationHelper->init();
}
function handleRequest(){
$request = new \woo\controller\Request();
$cmd_r = new \woo\command\CommandResolver();
$cmd = $cmd_r->getCommand($request);
$cmd->execute($request);
}
}
這個Controller類非常簡單,而且沒有考慮錯誤處理。系統(tǒng)中的控制器負(fù)責(zé)分配任務(wù)給其他類。其他類完成了絕大部分實際工作。
run()只是一個便捷方法,用于調(diào)用init()和handleRequest()。run()是靜態(tài)方法,而且本類的構(gòu)造方法被聲明為private,因此客戶端代碼只能通過run()方法來實例化控制器類,并執(zhí)行相關(guān)操作。我們可以使用只包含兩行代碼的index.php文件來完成這個工作:
require("woo/controller/Controller.php");
\woo\controller\Controller::run();
init()和handleRequest()方法的不同體現(xiàn)了PHP的特性。在某些編程語言中,init()只在應(yīng)用第一次啟動時運(yùn)行,而handleRequest()在用戶的每個請求到來時運(yùn)行。盡管init()在每次請求中都會被調(diào)用,但是這個類還是注意到了啟動和請求處理間的區(qū)別。
init()方法中獲得ApplicationHelper(應(yīng)用程序助手)類的一個對象實例。這個類的作用是管理應(yīng)用程序的配置信息。控制器的init()方法調(diào)用ApplicaiontHelper中同名的init()方法,用于初始化應(yīng)用程序要使用的數(shù)據(jù)。
handleRequest()方法通過CommandResolver來獲取一個Command對象,然后調(diào)用Command對象的execute()方法來執(zhí)行實際操作。
- 應(yīng)用程序助手
ApplicationHelper類并不是前端控制器的核心,但前端控制器通常都需要通過應(yīng)用助手類來獲取基本的配置數(shù)據(jù),因此我們我們需要討論一下獲取配置數(shù)據(jù)的策略。下面是一個簡單的ApplicationHelper:
namespace woo\controller;
// ...
class ApplicationHelper{
private static $instance;
private $config = "/tmp/data/woo_options.xml";
private function __construct(){}
static function instance(){
if(!self::$instance){
self::$instance = new self();
}
return self::$instance;
}
function init(){
$dsn = \woo\base\ApplicationRegistry::getDSN();
if(!is_null($dsn)){
return;
}
$this->getOptions();
}
private function getOptions(){
$this->ensure(file_exists($this->config),"Could not find options file");
$options = SimpleXml_load_file($this->config);
print get_class($options);
$dsn = (string)$options->dsn;
$this->ensure($dsn, "No DSN found");
\woo\base\ApplicationRegistry::setDSN($dsn);
// 設(shè)置其他值
}
private function ensure($expr, $message){
if(!$expr){
throw new \woo\base\AppException($message);
}
}
}
這個類的作用是讀取配置文件中的數(shù)據(jù)并使客戶端代碼可以訪問這些數(shù)據(jù)。可以看到,這個類實現(xiàn)了單例模式。使用單例模式使它能夠為系統(tǒng)中所有的類服務(wù)。另外,你也可以把這個類的代碼當(dāng)成一個標(biāo)準(zhǔn)類并確保它被傳遞給其他感興趣的對象。本書在第9章及本章的前面部分已經(jīng)討論了使用單例模式需要注意的問題。
現(xiàn)在我們已經(jīng)實現(xiàn)了ApplicationRegistry(應(yīng)用注冊表),我們還應(yīng)重構(gòu)代碼,把ApplicationHelper改寫為注冊表,而不是兩個任務(wù)重疊的單例對象。重構(gòu)代碼的建議前一節(jié)中已經(jīng)提過(將ApplicationRegistry的核心功能從領(lǐng)域?qū)ο蟮拇嫒≈蟹蛛x出來),留給讀者當(dāng)做練習(xí)。
因此init()方法只負(fù)責(zé)加載配置數(shù)據(jù)。實際上,它檢查ApplicationRegistry,看數(shù)據(jù)是否已經(jīng)被緩存。如果Registry對象中的值已經(jīng)存在,init()就什么都不做。如果系統(tǒng)初始化要做大量工作,這樣的緩存機(jī)制是很有用的。在將應(yīng)用程序初始化和獨(dú)立請求相分離的編程語言中,可以使用復(fù)雜的初始化操作。但在PHP中,你不得不盡量使用緩存來減少初始化操作。
緩存可以有效地保證復(fù)雜而且耗費(fèi)時間的初始化過程只在第一次請求時發(fā)生,而之后所有的請求都能從中受益。
如果是第一次運(yùn)行(或者緩存文件已被刪除------這是一種簡單而有效的強(qiáng)制重新讀取配置信息的方法),getOptioins()方法將被調(diào)用。
在現(xiàn)實世界中,我們需要做比示例代碼更多的工作。示例中所做的工作只是獲取一個DSN。首先,getOptions()方法檢查配置文件是否存在(路徑存放在$config屬性中),然后從配置文件中加載XML數(shù)據(jù)并設(shè)置DSN。
注解:在這些例子中,ApplicationRegistry和ApplicationHelper都使用了硬編碼的文件路徑。在實際項目中,這些文件路徑應(yīng)該是可配置的而且可以從一個注冊表對象或一個配置對象中獲取。實際的路徑可以在安裝時用構(gòu)建工具(如PEAR或Phing,參見第15章和第19章)來設(shè)置。
注意類中使用了一個技巧來拋出異常,避免了在代碼中到處使用下面這樣的條件語句和throw語句:
if(!file_exists($this->config)){
throw new \woo\base\AppException("Could not find options file");
}
這個技巧就是ApplicationHelper類在ensure()方法中集合了檢測表達(dá)式和throw語句。只用一行代碼就能確定條件是否為真(如果不為真,則拋出異常):
$this->ensure(file_exists($this->config),"Could not find options file");
緩存對系統(tǒng)開發(fā)者和使用者都有好處。系統(tǒng)可以方便地維護(hù)一個易于使用的XML配置文件,同時使用緩存意味著系統(tǒng)能以很快的速度訪問配置文件中的數(shù)據(jù)。當(dāng)然,如果類的用戶還是程序員,或者并不經(jīng)常修改配置,你可以直接在助手類中(或者是用一個單獨(dú)的文件)包含PHP數(shù)據(jù)結(jié)構(gòu),不用把配置數(shù)據(jù)單獨(dú)放到XML文件中。雖然這種寫法有風(fēng)險,但是代碼執(zhí)行效率最高。
- 命令解析器
控制器需要通過某種策略來決定如何解釋一個HTTP請求,然后調(diào)用正確的代碼來滿足這個請求。你可以很容易地在Controller類中包含這個策略,但我更喜歡使用一個特定的類來完成這個任務(wù)。因為這樣的代碼易于重構(gòu)和實現(xiàn)多態(tài)。
前端控制器通常通過運(yùn)行一個Command對象(本書在第11章中介紹過命令模式)來調(diào)用應(yīng)用程序。Command對象通常根據(jù)請求中的參數(shù)或URL的結(jié)構(gòu)(例如,可以使用Apache配置來確定URL中的哪個字段用于選擇命令)來決定選擇哪個命令。在下面的例子中,我們將使用一個簡單的參數(shù)cmd。
有多種方法可以用來根據(jù)給定的參數(shù)選擇命令。你可以在一個配置文件或一個數(shù)據(jù)結(jié)構(gòu)(邏輯方案)中測試該參數(shù),或者直接查找文件系統(tǒng)(物理方案)中是否存在與參數(shù)相對應(yīng)的類文件。
邏輯方案更靈活一些,但是工作量也更大些(包括設(shè)置和維護(hù))。你可以在12.3.2節(jié)找到使用該方案的例子。
上一章介紹過一個使用物理方案的命令工廠的例子。下面對該例子做微小改動,使用反射(reflection)來增強(qiáng)安全性:
namespace woo\command;
//...
class CommandResolver{
private static $base_cmd;
private static $default_cmd;
function __construct(){
if(!self::$base_cmd){
self::$base_cmd = new \ReflectionClass("\woo\command\Command");
self::$default_cmd = new DefaultCommand();
}
}
function getCommand( \woo\controller\Request $request){
$cmd = $request->getProperty('cmd');
$sep = DIRECTORY_SEPARATOR;
if(!$cmd){
return self::$default_cmd;
}
$cmd = str_replace(array('.', $sep), "", $cmd);
$filepath = "woo{$sep}command{$sep}{$cmd}.php";
$classname = "woo\\command\\{$cmd}";
if(file_exists($filepath)){
@require_once("$filepath");
if(class_exists($classname)){
$cmd_class = new ReflectionClass($classname);
if($cmd_class->isSubClassOf(self::$base_cmd)){
return $cmd_class->newInstance();
}else{
$request->addFeedback("command '$cmd' is not a Command");
}
}
}
$request->addFeedback("command '$cmd' not found");
return clone self::$default_cmd;
}
}
這個簡單的類用于查找請求中包含的cmd參數(shù)。假設(shè)參數(shù)被找到,并與命令目錄中的類文件相匹配,而該文件也正好包含了cmd類,則該方法創(chuàng)建并返回相應(yīng)類的實例。
如果其中任意條件未滿足,getCommand()方法使用默認(rèn)的Command對象。
你或許想知道,為什么實例化Command類時不需要提供參數(shù):