沉思錄本文發表於《程序員》5月號
是一個系列的第一篇,目前想到的其他一些主題是:
SQL注入問題
事件模型
AOP模型
UI Framework的實現
Template機制
PHP沉思錄
工作模型
PHP的工作模型非常特殊。從某種程度上說,PHP和ASP、ASP.Net、JSP/Servlet等流行的Web技術,有著本質上的區別。
以Java為例,Java在Web應用領域,有兩種技術:Java Servlet和JSP(Java Server Page)。Java Servlet是一種特殊類型的Java程序,它通過實現相關接口,處理Web服務器發送過來的請求,完成相應的工作。JSP在形式上是一種類似於PHP的腳本,但是事實上,它最後也被編譯成Servlet。也就是說,在Java解決方案中,JSP和Servlet是作為獨立的Java應用程序執行的,它們在初始化之後就駐留內存,通過特定的接口和Web服務器通信,完成相應工作。除非被顯式地重啟,否則它們不會終止。因此,可以在JSP和Servlet中使用各種緩存技術,例如數據庫連接池。
ASP.Net的機制與此類似。至於ASP,雖然也是一種解釋型語言,但是仍然提供了Application對象來存放應用程序級的全局變量,它依托於ASP解釋器在IIS中駐留的進程,在整個應用程序的生命期有效。
PHP卻完全不是這樣。作為一種純解釋型語言,PHP腳本在每次被解釋時進行初始化,在解釋完畢後終止運行。這種運行是互相獨立的,每一次請求都會創建一個單獨的進程或線程,來解釋相應的頁面文件。頁面創建的變量和其他對象,都只在當前的頁面內部可見,無法跨越頁面訪問。在終止運行後,頁面中申請的、沒有被代碼顯式釋放的外部資源,包括內存、數據庫連接、文件句柄、Socket連接等,都會被強行釋放。
也就是說,PHP無法在語言級別直接訪問跨越頁面的變量,也無法創建駐留內存的對象。見下例:
<?PHP
class StaticVarTester {
public static $StaticVar = 0;
}
function TestStaticVar() {
StaticVarTester :: $StaticVar += 1;
echo "StaticVarTester :: StaticVar = " . StaticVarTester :: $StaticVar;
}
TestStaticVar();
echo "<br/>";
TestStaticVar();
?>
在這個例子中,定義了一個名為StaticVarTester的類,它僅有一個公共的靜態成員$StaticVar,並被初始化為0。然後,在TestStaticVar()函數中,對StaticVarTester :: $StaticVar進行累加操作,並將它打印輸出。
熟悉Java或C++的開發者對這個例子應該並不陌生。$StaticVar作為StaticVarTester類的一個靜態成員,只在類被裝載時進行初始化,無論StaticVarTester類被實例化多少次,$StaticVar都只存在一個實例,而且不會被多次初始化。因此,當第一次調用TestStaticVar()函數時,$StaticVar進行了累加操作,值為1,並被保存。第二次調用TestStaticVar()函數,$StaticVar的值為2。
打印出來的結果和我們預料的一樣:
StaticVarTester :: StaticVar = 1
StaticVarTester :: StaticVar = 2
但是,當浏覽器刷新頁面,再次執行這段代碼時,不同的情況出現了。在Java或C++裡面,$StaticVar的值會被保存並一直累加下去,我們將會看到如下的結果:
StaticVarTester :: StaticVar = 3
StaticVarTester :: StaticVar = 4
…
但是在PHP中,由於上文敘及的機制,當前頁面每次都解釋時,都會執行一次程序初始化和終止的過程。也就是說,每次訪問時,StaticVarTester都會被重新裝載,而下列這行語句
public static $StaticVar = 0;
也會被重復執行。當頁面執行完成後,所有的內存空間都會被回收,$StaticVar這個變量(連同整個StaticVarTester類)也就不復存在。因此,無論刷新頁面多少次,$StaticVar變量都會回到起點:先被初始化為0,然後在TestStaticVar()函數調用中被累加。所以,我們看到的結果永遠是這個:
StaticVarTester :: StaticVar = 1
StaticVarTester :: StaticVar = 2
PHP這種獨特的工作模型的優勢在於,基本上解決了令人頭疼的資源洩漏問題。Web應用的特點是大量的、短時間的並發處理,對各種資源的申請和釋放工作非常頻繁,很容易導致洩漏。同時,大量的動態Html腳本的存在,使得追蹤和調試的工作都非常困難。PHP的運行機制,以一種非常簡單的方式避免了這個問題,同時也避免了將程序員帶入到繁瑣的緩沖池和同步等問題中去。在實踐中,基於PHP的應用往往比基於Java或.NET的應用更加穩定,不會出現由於某個頁面的BUG而導致整個站點崩潰的問題。(相比之下,Java或.Net應用可能因為緩沖池崩潰或其他的非法操作,而導致整個站點崩潰。)後果是,即使PHP程序員水平不高,也無法寫出使整個應用崩潰的代碼。PHP腳本可以一次調用極多的資源,從而導致頁面執行極為緩慢,但是執行完畢後所有的資源都會被釋放,應用仍然不會崩潰。甚至即使執行了一個死循環,也會在30秒(默認時間)後因為超時而中止。從理論上來說,基於PHP的應用是不太可能崩潰的,因為它的運行機制決定它不存在常規的崩潰這個問題。在實踐中,很多開發者也認為PHP是最穩定的Web應用。
但是,這種機制的缺點也非常明顯。最直接的後果是,PHP在語言級別無法實現跨頁面的緩沖機制。這種緩沖機制缺失造成的影響,可以分成兩個方面:
一是對象的緩沖。如我們所知,很多設計模式都依賴於對象的緩沖機制,對於需要頻繁應付大量並發的服務端軟件更是如此。因此,對象緩沖的缺失,理論上會極大地降低速度。但是,由於PHP本身的定位和工作機制等原因,它在實際工作中的速度非常快。就作者自己的經驗來看,在小型的Web應用中,PHP至少不比Java慢。在大型的應用中,為了搾干每一分硬件資源,即使PHP本身足夠快,一個優秀的對象緩沖機制仍然是必要的。在這種情況下,可以使用第三方的內存緩沖軟件,如Memcached。由於Memcached本身的優異特性(高性能,支持跨服務器的分布式存儲,和PHP的無縫集成等),在大型的PHP應用中,Memcached幾乎已經成為不可或缺的基礎設施了。比起使用PHP語言自己實現對象緩沖來,這種第三方解決方案似乎更好一些。
二是數據庫連接的緩沖。對MySQL,PHP提供了一種內置的數據庫緩沖機制,使用起來非常簡單,程序員需要做的只是用mysql_pconnect()代替MySQL_connect()來打開數據庫而已。PHP會自動回收被廢棄的數據庫連接,以供重復使用。具有諷刺意味的是,在實際應用中,這種持久性數據庫連接往往會導致數據庫連接的偽洩漏現象:在某個時間,並發的數據庫連接過多,超過了MySQL的最大連接數,從而導致新的進程無法連接數據庫。但是過一段時間,當並發數減少時,PHP會釋放掉一些連接,網站又會恢復正常。出現這種現象的原因是,當使用pconnect時,Apache的httpd進程會不釋放connect,而當Apache的httpd進程數超過了mysql的最大連接數時,就會出現無法連接的情況。因此,需要小心地調整Apache和Mysql的配置,以使apache的httpd進程數不會超出MySQL的最大連接數。在某些情況下,一些有經驗的PHP程序員寧可繼續使用mysql_connect(),而不是MySQL_pconnect()。
就作者所知,在PHP未來的roadmap中,對於工作模型這一部分,沒有根本性的變動。這是PHP的缺點,也是PHP的優勢,從本質上說,這就是PHP的獨特之處。因此,我們很難期待PHP在近期內會對這一問題做出重大的改變。但是,在對待這個問題帶來的一系列後果時,我們必須謹慎應對。
數據庫訪問接口
長期以來,PHP都缺乏一個象ADO或JDBC那樣的統一的數據庫訪問接口。PHP在訪問不同的數據庫時,使用不同的專門API。例如,使用mysql_connect函數連接MySQL,使用ora_logon函數連接Oracle。平心而論,這種方式並沒有象我們想象的那樣麻煩。在真實項目中,把系統從一種數據庫完全遷移到另一種數據庫的要求是比較少見的,特別是對於LAMP這樣的小型項目而言。而且,只要將訪問數據庫的代碼進行了良好的封裝,遷移的工作量也會較少。另外,使用專門API,在效率上多少會有一些優勢。
雖然如此,PHP的開發人員仍然在努力構建PHP的統一的數據庫訪問接口。從PHP 5.1開始,PHP的發行包內置了PDO(PHP Data Objects,PHP數據對象)。PDO具有如下特性:
統一的數據庫訪問接口。PDO為訪問不同的數據庫提供了統一的接口,並且能夠通過切換數據庫驅動程序,方便地支持各種流行的數據庫。
面向對象。PDO完全基於PHP 5的對象機制,因此區別於基於過程的專用API。
高性能。PDO的底層用C編寫,比起用純PHP開發的其他類似解決方案,有更高的性能。
一個典型的PDO應用如下例:
$pdo = new PDO("mysql:host=localhost;dbname=justtest", " mysql_user ", " MySQL_passWord");
$query = "SELECT id, username FROM userinfo ORDER BY ID";
foreach ($pdo->query($query) as $row) {
echo $row['id']." | ".$row['username']."<br/>";
}
但是,PDO還有一個更重要的問題沒有解決,那就是對數據集的抽象。
無論是ADO還是JDBC,除了提供統一的數據庫訪問接口以外,也提供了對數據集的抽象。也就是說,在通過ADO/JDBC取回數據集結果以後,這些數據集以統一的格式被存放在RecordSet/RowSet對象中,業務邏輯代碼只需要與數據集對象進行交互即可。對數據集進行抽象的直接後果,是徹底地分離了業務邏輯層和數據庫訪問層的代碼,並且也在某種程度上起到了OR Mapping的效果。
自從ADO.NET出現後,數據集的抽象又有了一次不小的進步。和ADO相比,ADO.Net中的DataTable/DataSet類的主要新特性如下:
非連接性。在傳統的ADO模型中,數據集需要占用一個數據庫連接,直到所有工作完成。一旦連接被關閉,數據集的內容也就失效了。ADO.NET中的數據集是非連接的,也就是說,當連接被關閉後,數據集中的內容仍然保存。這種非連接性帶來的直接後果是,數據庫連接可以被最大限度地利用,因為一旦工作完成就可以將連接返回到數據庫連接池中。(ADO也支持非連接的數據集,但是需要程序員自己實現,而ADO.Net的數據集在本質上就是非連接的。)
自描述性。ADO.Net中的數據集是完全自我描述的,而且具有完備的metadata,其內容不但可以從任何特定的數據庫生成,而且可以由代碼動態生成。DataSet可以跟蹤數據的變化,並完成相應的操作。
互操作性。由於非連接性和自描述性,ADO.Net中的數據集可以非常方便地在網絡之間進行傳輸。DataSet可以序列化/反序列化為XML或其他特定的格式。這樣,DataSet不但可以用於同一平台的分布式網絡環境,而且可以用於異構網絡環境。
Java從J2SE 5.0開始內置了CachedRowSet,其原理和ADO.Net的數據集類似。Borland開發的用於取代BDE的新一代數據庫引擎dbExpress,其改進也與此類似。
與之對比,PHP中對數據集的支持顯得非常原始。無論是傳統的API還是PDO,取回的數據僅僅表現為數組,並且沒有任何緩存機制。這意味著,在所有需要訪問數據集的地方,都必須頻繁地使用直接訪問數據庫的API。下面是一個使用MySQL API的例子:
$link = mysql_connect('localhost', 'mysql_user', 'MySQL_passWord');
if (!$link) {
dIE('Could not connect: ' . MySQL_error());
}
MySQL_select_db("justtest");
$result = MySQL_query("SELECT id, username FROM userinfo ORDER BY ID");
while ($row = MySQL_fetch_array($result)) {
echo $row['id']." | ".$row['username']."<br/>";
}
MySQL_close($link);
這樣做的後果是業務邏輯和訪問數據庫的代碼無法分離,在規模較大的系統中尤其嚴重。
就作者所知,PHP官方沒有提供支持抽象數據集的計劃。但是,自己實現這樣一個數據集並不是一件難事。作者參照ADO.Net的架構,使用純PHP代碼編寫了一個規模非常小的數據抽象類庫,姑且稱之為MyPDO。MyPDO大約有1300行代碼,在幾個真實項目中工作得很好。由於篇幅所限,本文不列出它的所有代碼,僅僅給出幾個最主要的類的描述:
DataAdapter接口:定義了所有與數據庫操作相關的方法。
ConceptDataAdapter類:實現了DataAdapter,封裝了訪問特定數據庫的代碼。如MySQLDataAdapter。
DataSet類:實現了自描述的抽象數據集,與具體數據庫無關。可以自我跟蹤數據的變化。
SqlCommandBuilder類:可以跟據DataSet類的內容,自動實現Insert、Update、Delete等操作。
下面是MyPDO的一個典型應用:
$Conn = new MySqlDataConnection(new ConnectionInfo("localhost", "mysql_username", "MySQL_passWord", "justtest", "gbk"));
$Da = $Conn->GetDataAdapter();
$Da->SetSqlString("SELECT id, username FROM userinfo ORDER BY ID");
$Ds = new DataSet();
$Da->Fill($Ds);
$Conn->Disconnect(); // 關閉數據庫連接,但$Ds仍然保存數據內容
echo $Ds; // 調用DataSet的__tostring()方法,格式化輸出內容
通過替換MySQLDataConnection,可以以最小的成本實現不同數據庫之間的切換。
由於上文中討論過的PHP的工作模型,通過MyPDO實現的緩存在性能上獲得的好處有限。但是,在采用Memcached的解決方案中,MyPDO還是能夠帶來很大的便利。因為只有基於非連接方式的數據集,才可能在Memcached這樣的內存池中被緩存。
另外,由於MyPDO中的DataSet是自描述的,內置了WriteToXml和ReadFromXml方式,它無需程序員編碼就可以保存為XML或從XML中還原,在網絡上甚至異構平台之間進行傳輸和識別。在電子商務領域中,這個特性是非常有用的。