談到PHP應用的性能優化,很多人最先想到的肯定是緩存。因為一般的程序不會用PHP進行太多的計算,算法優化的余地很小,所以性能瓶頸不會出現在CPU上,而更可能出現在IO上。而IO操作最需要緩存的莫過於耗時的數據庫查詢了。 最常見的緩存使用情形就是類似這樣的一次查詢:
function query_author_articles($author) { global $pdo,$memcache; $sql='SELECT * FROM `articles` WHERE author=:author ORDER BY `date` DESC'; $cacheKey="author_{$author}_articles"; $results=$memcache->get($cacheKey); if (false===$results) { //緩存沒有命中則執行查詢並將結果保存到memcache中 $sth=$pdo->prepare($sql); $sth->bindParam(':author', $author, PDO::PARAM_STR); $sth->execute(); $results=$sth->fetchAll(PDO::FETCH_ASSOC); $memcache->set($cacheKey,$results); } return $results; }
除了SQL查詢,還有哪些IO情形需要緩存呢? —— 當然少不了外部網絡接口請求了!比如請求Google搜索結果,盡管Google服務器處理查詢很快,但這個請求完成仍需要很長時間,不但因為建立HTTPS連接比建立HTTP連接更耗時、以及這個URL會產生302跳轉,更是因為(此處已被屏蔽,請自行腦補)。示例代碼如下:
function search_google($keywords) { global $memcache; $url='https://www.google.com/search?q='.urlencode($keywords); $results=$memcache->get($url); if (false===$results) { //緩存沒有命中則發起請求 $results=file_get_contents($url); $memcache->set($url,$results); } return $results; }
先停下來觀察下前面兩個典型的緩存案例代碼。你會發現什麼樣的問題?
如何逐步解決這些問題呢?代碼重復,就意味著缺少抽象!先將這些步驟抽象一下,緩存的使用一般都是這樣一個流程:
if 緩存命中 then 返回緩存中的內容 else 執行操作 將操作結果放入緩存並返回結果 end
而其中“執行操作”這一步才是程序的主邏輯,而其它的都是緩存代碼的邏輯。如果要避免代碼重復,那就只有將程序主邏輯代碼與緩存操作代碼分離開來。那麼我們就不得不將原來的一個函數拆分成這樣兩個函數:
function cache_operation(); //緩存操作代碼,可重用 function program(); //程序主體代碼 # 以search_google函數為例,抽取掉緩存檢查代碼後 # 它就露出了原形 —— 我們會驚訝地發現原來主邏輯只有一行代碼 function search_google($keywords) { return file_get_contents('https://www.google.com/search?q='.urlencode($keywords)); }
至於如何實現先放一邊。至此,我們已經清楚解決代碼重復問題的方向。接下來第二個問題:如何實現緩存Key的統一管理?很明顯,當然是在可重用的緩存操作函數中自動生成緩存Key了。
那將問題抽象化之後,我們能發現什麼?之前我們還在發散思維,努力思索著有多少種不同情形的緩存,現在,我們發現所有的緩存情形都可以歸類成一種情形:對執行具體操作函數的返回結果進行緩存。同時得到一個統一的解決方案:將需要對結果進行緩存的操作放到一個函數中,只需要對該函數調用的返回結果進行緩存就行了。而這種函數調用緩存技術有一個專門的術語:Memoization。唉~說半天就是為了講明白為什麼需要Memoization!
設計良好的函數通常盡量遵循這些簡單的原則:輸入相同的參數總是返回相同的值(純函數),它的返回值只受它的參數影響,不依賴於其它全局狀態;沒有副作用。比如一個簡單的加法函數:function add($a,$b) { return $a+$b; }
。這樣的函數可以安全使用Memoization,因為它輸入相同的參數總是返回相同的結果,所以函數名加上參數值即是一個自動生成的緩存Key。但是現實中很多和外部有數據交互的函數(IO)並不具備這樣的特性,如上面的query_author_articles
函數,它的返回結果由數據庫中的內容決定,如果該Author有新的Article提交到數據庫中,那麼即使相同的參數$author,返回結果也不一樣了。對於這樣的函數,我們需要用一種變通的方案。我們可以近似地認為,包含IO的函數在一定緩存周期內其行為一致性(即相同參數返回相同結果)是有保證的。如,我們對數據庫查詢允許有10分鐘的緩存,那麼我們就可以認為,相同的查詢,在10分鐘內,它的返回結果會是一樣的。這樣包含IO的函數也可以使用具有緩存過期時間的Memoization,而這類情形在真實環境中也更為常見。
根據前面的分析,Memoization函數(即前面列出的要實現的cache_operation
函數)的接口及工作方式應該是這樣的:
/** memoize_call 將$fn函數調用的結果進行緩存 @param {String} $fn 執行操作的函數名 @param {Array} $args 調用$fn的參數 @return $fn執行返回的結果,也可能是從緩存中取到 */ function memoize_call($fn,$args) { global $cache; # 函數名和序列化的參數值構成Cache Key $cacheKey=$fn.':'.serialize($args); # 根據Cache Key,查詢緩存 $results=$cache->get($cacheKey); if (false===$results) { # 緩存沒有命中則調用函數 $results=call_user_func_array($fn,$args); $cache->set($cacheKey,$results); } return $results; } # 下面給出一個示例的Cache實現,以便使這段代碼可以成功運行 class DemoCache { private $store; public function __construct() {$this->store=array();} public function set($key,$value) {$this->store[$key]=$value;} public function get($key) { return array_key_exists($key,$this->store)?$this->store[$key]:false; } } $cache=new DemoCache(); # 比如對於add函數,調用方式為: memoize_call('add',array(3,6));
注意這裡需要使用序列化方法serialize函數將參數轉換成String,序列化方法會嚴格保留值的類型信息,可以保證不同的參數序列化得到的String Key不會沖突。除了serialize
函數,也可以考慮使用json_encode
等方法。
到這一步,我們已經實現了緩存Key的統一管理。但這個實現卻給我們帶來了麻煩,原本簡簡單單的add($a,$b)
現在竟要寫成memoize_call('add',array($a,$b))
!這簡直就是反人類!
…………那該怎麼解決這個問題呢?
…………也許你可以這樣寫:
function _search_google() {/*BA LA BA LA*/} function search_google() {return memoize_call('_search_google',func_get_args());} //緩存化的函數調用方式總算不再非主流了 echo search_google('Hacker'); //直接調用就行了
至少正常一點了。但還是很麻煩啊,本來只要寫一個函數的,現在要寫兩個函數了!這時,匿名函數閃亮登場!(沒錯,只有在PHP5.3之後才可以使用Closure,但這關頭誰還敢提更反人類的create_function?)使用Closure重寫一下會變成啥樣呢?
function search_google() { return memoize_call(function () {/*BA LA BA LA*/},func_get_args()); }
還不是一樣嘛!還是一堆重復的代碼!程序主邏輯又被套了一層厚大衣!
別忙下結論,再看看這個:
function memoized($fn) { return function () use($fn) { return memoize_call($fn,func_get_args()); }; } function add($a,$b) { return $a+$b; } # 生成新函數,不影響原來的函數 $add=memoized('add'); # 後面全部使用$add函數 $add(1E4,3E4);
是不是感覺清爽多了?……還不行?
是啊,仍然會有兩個函數!但是這個真沒辦法,PHP就是個破語言!你沒辦法創建一個同名的新函數覆寫掉以前的舊函數。如果是JavaScript完全可以這樣寫嘛:add=memoized(add)
,如果是Python還可以直接用Decorators多方便啊!
沒辦法,這就是PHP!
……不過,我們確實還有相對更好的辦法的。仍然從削減冗余代碼入手!看這一行:$add=memoized('add');
,如果我們可以通過規約要求Memoized函數名的生成具有固定的規律,那麼生成新的緩存函數這個步驟就可以通過程序自動處理。比如,我們可以在規范中要求,所有需要Memoize的函數命名都使用_memoizable
後綴,然後自動生成去掉後綴的新的變量函數:
# add函數聲明時加一個後綴表示它是可緩存的 # 對應自動創建的變量函數名就是$add function add_memoizable($a,$b) {return $a+$b;} # 自動發現那些具有指定後綴的函數 # 並創建對應沒有後綴的變量函數 function auto_create_memoized_function() { $suffix='_memoizable'; $suffixLen=strlen($suffix); $fns=get_defined_functions(); foreach ($fns['user'] as $f) { //function name ends with suffix if (substr_compare($f,$suffix,-$suffixLen)===0) { $newFn=substr($f,0,-$suffixLen); $GLOBALS[$newFn]=memoized($f); } } } # 只需在所有可緩存函數聲明之後添加上這個 auto_create_memoized_function(); # 就自動生成對應的沒有後綴的變量函數了 $add(3.1415,2.141);
還不滿意?好好的都變成了變量函數,使用起來仍然不便。其實,雖然全局函數我們拿它沒轍,但我們還可以在對象的方法調用上做很多Hack!PHP的魔術方法提供了很多機會!
Class的靜態方法__callStatic可用於攔截對未定義靜態方法的調用,該特性也是在PHP5.3開始支持。將前面的命名後綴方案應用到對象靜態方法上,事情就變得非常簡單了。將需要應用緩存的函數定義為Class靜態方法,在命名時添加上後綴,調用時則不使用後綴,通過__callStatic方法重載,自動調用緩存方法,一切就OK了。
define('MEMOIZABLE_SUFFIX','_memoizable'); class Memoizable { public static function __callStatic($name,$args) { $realName=$name.MEMOIZABLE_SUFFIX; if (method_exists(__CLASS__,$realName)) { return memoize_call(__CLASS__."::$realName",$args); } throw new Exception("Undefined method ".__CLASS__."::$name();"); } public static function search_memoizable($k) {return "Searching:$k";} } # 調用時則不添加後綴 echo Memoizable::search('Lisp');
同樣對象實例方法也可使用這個Hack。在對象上調用一個不可訪問方法時,__call會被調用。對照前面__callStatic依樣畫葫蘆,只要稍作改動就可得到__call方法:
class Memoizable { public function __call($name,$args) { $realName=$name.MEMOIZABLE_SUFFIX; if (method_exists($this,$realName)) { return memoize_call(array($this,$realName),$args); } throw new Exception("Undefined method ".get_class($this)."->$name();"); } public function add_memoizable($a,$b) {return $a+$b;} } # 調用實例方法時不帶後綴 $m=new Memoizable; $m->add(3E5,7E3);
運行一下,會得到一個錯誤。因為memoize_call
方法第一個參數只接受String類型的函數名,而PHP的call_user_func_array方法需要一個Array參數來表示一個對象方法調用,這裡就傳了個數組:memoize_call(array($this,$realName),$args);
。如果$fn
參數傳入一個數組,生成緩存Key則成了問題。對於Class靜態方法,可以使用Class::staticMethod
格式的字符串表示,與普通函數名並無差別。對於實例方法,最簡單的方式是將memoize_call
修改成對$fn
參數也序列化成字符串以生成緩存Key:
function memoize_call(callable $fn,$args) { global $cache; # 函數名和參數值都進行序列化 $cacheKey=serialize($fn).':'.serialize($args); $results=$cache->get($cacheKey); if (false===$results) { $results=call_user_func_array($fn,$args); $cache->set($cacheKey,$results); } return $results; }
PHP 5.4開始可以使用callable參數類型提示,見:Callable Type Hint。Callable類型的具體格式可見 is_callable 函數的示例。
但這樣會帶來一些不必要的開銷。對於復雜的對象,它的被緩存方法可能只訪問了它的一個屬性,而直接序列化對象會將它全部屬性值都序列化進Key,這樣不但Key體積會變得很大,而且一旦其它不相關的屬性值發生了變化,緩存也就失效了:
class Bar { public $tags=array('PHP','Python','Haskell'); public $current='PHP'; #...這裡省略實現Memoizable功能的__call方法 public function getCurrent_memoizable() {return $this->current;} } $b=new Bar; $b->getCurrent(); $b->tags[0]='OCaml'; # 由於不相干的tags屬性內容也被序列化放入Key # tags被修改後,該方法的緩存就失效了 $b->getCurrent(); # 會被再次執行 # 但它的緩存不應該失效
對此問題的第一反應可能是……將代碼改成:只序列化該方法中使用到的屬性值。隨之而來的障礙是,我們根本沒有辦法在運行時分析出方法M到底訪問了$this
的哪幾個屬性。作為一種嘗試性方案,我們可以手動在代碼中聲明方法M訪問了對象哪幾個屬性,可以在類中聲明一個靜態屬性存放相關信息:
class Foo { public $current='PHP'; public $hack='HACK IT'; # 存放方法與其訪問屬性列表的映射 public static $methodUsedMembers=array( 'getCurrent_memoizable'=>'current,hack' # getCurrent訪問的兩個屬性 ); public function getCurrent_memoizable() { return $this->current.$this->hack; } } # 這樣memoize_call就可以通過$methodUsedMembers # 得到方法M對應要序列化的屬性列表 # 對應memoize_call中生成緩存Key的邏輯則是 if (is_array($fn) && is_object($fn[0])) { list($o,$m)=$fn; $class=get_class($o); # $members=$class::$methodUsedMembers[$m]; # PHP5.3才支持此語法 # 如果是PHP5.3之前的版本,使用下面的方法 $classVars=get_class_vars($class); $members=$classVars['methodUsedMembers'][$m]; $objVars=get_object_vars($o); $objVars=array_intersect_key($objVars,array_flip(explode(',',$members))); # 注意要加上以類名和方法名構成的Prefix # 因為get_object_vars轉成數組丟了Class信息 $cacheKey=$class.'::'.$m.'::'; $cacheKey.=serialize($objVars).':'.serialize($args); }
手動聲明仍然很麻煩,仍然是在Repeat Yourself。如果本著Hack到底(分明是折騰到底)的精神,為了能自動獲取方法訪問過哪些屬性,我們還可以依葫蘆畫瓢,參照前面Memoizable方法調用攔截,再搞出這樣一個自動化方案:屬性定義時也都添加上_memoizable
後綴,訪問時則不帶後綴,通過__get方法,我們就可以在方法執行完後,得到這一次該方法訪問過的屬性列表了(但Memoize不是需要在函數調用之前就要確定緩存Key麼? 這樣才能查看緩存是否命中以決定是否要執行該方法啊? 這個簡單,對方法M訪問了對象哪些屬性也進行緩存,就不用每次都執行了):
class Foo { public $propertyHack_memoizable='Hack'; public $accessHistory=array();//記錄屬性訪問歷史 public function __get($name) { $realName=$name.MEMOIZABLE_SUFFIX; if (property_exists($this,$realName)) { $this->accessHistory[]=$realName; return $this->$realName; } # otherwise throw Exception } public function hack() {return $this->propertyHack;} } $f=new Foo; #方法調用前清空歷史 $f->accessHistory=array(); echo $f->hack(); var_dump($f->accessHistory); # => 得到hack方法訪問過的屬性列表
不過,我們不能真的這麼干!這樣會把事情搞得越來越復雜。太邪門了!我們不能在錯誤的道路上越走越遠!
適可而止吧!對於此問題,我覺得折衷方案是避免對實例方法進行緩存。因為實例方法通常都不是純函數,它依賴於$this
的狀態,因此它也不適用於Memoization。 正常情況下對靜態方法緩存也已經夠用了,如果實例方法需要緩存,可以考慮重構代碼提取出一個可緩存的類靜態方法出來。
如果要將這裡的__callStatic
及__call
代碼重用,可將其作為一個BaseClass,讓需要Memoize功能的子類去繼承:
class ArticleModel extends Memoizable { public static function getByAuthor_memoizable() {/*...*/} }
試一下,便會發現這樣是行不通的。在__callStatic
中,我們直接使用了Magic Constants:__CLASS__
,來得到當前類名。但這個變量的值是它在代碼中所在的類的名稱,而不是運行時調用此方法的類的名稱。即這裡的__CLASS__
的值永遠是Memoizable
。這問題並不很難解決,只要升級到PHP5.3,將__CLASS__
替換成get_called_class()
就行了。然而還有另外一個問題,PHP的Class是不支持多繼承的,如果一個類已經繼承了另外一個類,就不好再使用繼承的方式實現Memoize代碼重用了。這問題仍然不難解決,只要升級到PHP5.4,使用Traits就可以實現Mixin了。並且,使用Traits之後,就可以直接使用__CLASS__常量而不需要改成調用get_called_class()
函數了,真是一舉兩得:
trait Memoizable { public static function __callStatic() { echo __CLASS__; # => 輸出use trait的那個CLASS名稱 } public function __call() {/*...*/} } class ArticleModel { use Memoizable; public static function getByAuthor_memoizable() {/*...*/} }
只是你需要升級到PHP5.4。也許有一天一個新的PHP版本會支持Python那樣的Decorators,不過那時估計我已不再關注PHP,更不會回來更新這篇文章的內容了。
前面講到,在現實世界中,通常都是對IO操作進行緩存,而包含IO操作的函數都不是純函數。純函數的緩存可以永不過期,而IO操作都需要一個緩存過期時間。現在問題不是過期時間到底設置成多長,這個問題應該交給每個不同的函數去設定,因為不同的操作其緩存時長是不一樣的。現在的問題是,我們已經將緩存函數抽取了出來,讓函數代碼自身無需關心具體的緩存操作。可現在又要自己設置緩存過期時長,需要向這個memoize_call
函數傳遞一個$expires
參數,以在$cache->set
時再傳給MemCache實例。初級解決方案:繼續使用前面提出的類靜態屬性配置方案。類中所有方法的緩存過期時長,也可以用一個Class::methodMemoizeExpires
數組來配置映射。不過,我們不能一直這樣停留在初級階段百年不變!設想中最好的方案當然是將緩存過期時長和方法代碼放一起,分開來寫肯定不利於維護。可如何實現呢?前面已經將PHP的魔術方法差不多都用遍了,現在必須換個招術了。一直被人遺忘在角落裡的靜態變量和反射機制,終於也能登上舞台表演魔術了!
緩存過期時間,聲明成函數的一個靜態變量:
function search_google_memoizable($keywords) { static $memoizeExpires=600;//單位:秒 }
通過ReflectionFunction的getStaticVariables方法,即可獲取到函數設置的$memoizeExpires
值:
$rf=new ReflectionFunction('search_google_memoizable'); $staticVars=$rf->getStaticVariables(); $expires=$staticVars['memoizeExpires'];
舉一反三,類靜態方法及實例方法,都可以通過ReflectionClass、ReflectionMethod這些途徑獲取到靜態變量的值。
前面討論了那麼多,大部分的篇幅都是在討論如何讓緩存化函數的調用方式和原來保持一致。 筋疲力竭之後又突然想起來,雖然PHP代碼中無法覆蓋一個已經定義的函數,但PHP C Extension則可以做到!正好,PECL上已經有一個C實現的Memoize模塊,不過目前仍然是Beta版。可以通過下面的命令安裝:
sudo pecl install memoize-beta
該模塊工作方式正如前面PHP代碼所想要實現卻又實現不了的那樣。它提供一個memoize
函數,將一個用戶定義的函數修改成一個緩存化函數。主要步驟和前面的PHP實現方案並無二致,本質上是通過memoize("fn")
創建一個新的函數(類似前面PHP實現的memoized
),新的函數執行memoize_call
在緩存不命中時再調用原來的函數,只不過C擴展可以修改函數表,將舊的函數重命名成fn$memoizd
,將新創建的函數命名成fn
並覆蓋用戶定義的函數:
function hack($x) {sleep(3);return "Hack $x\n";} memoize('hack'); echo hack('PHP'); # returns in 3s echo hack('PHP'); # returns in 0.0001s $fns=get_defined_functions(); echo implode(' ',$fns['user']); # => hack$memoizd # 函數hack現在變成internal了 var_dump(in_array('hack',$fns['internal'])); # => bool(true)
由於新函數是memcpy
其內置函數memoize_call
,所以變成了internal,分析下memoize
函數部分C代碼可知:
PHP_FUNCTION(memoize) { zval *callable; /*...*/ zend_function *fe, *dfe, func, *new_dfe; /*默認為全局函數表,EG宏獲取當前的executor_globals*/ HashTable *function_table = EG(function_table); /*...*/ /*檢查第一個參數是否is_callable*/ if (Z_TYPE_P(callable) == IS_ARRAY) { /*callable是數組則可能為類靜態方法或對象實例方法*/ zval **fname_zv, **obj_zv; /*省略:obj_zv=callable[0],fname_zv=callable[1]*/ if (obj_zv && fname_zv && (Z_TYPE_PP(obj_zv)==IS_OBJECT || Z_TYPE_PP(obj_zv)==IS_STRING) && Z_TYPE_PP(fname_zv)==IS_STRING) { /* looks like a valid callback */ zend_class_entry *ce, **pce; if (Z_TYPE_PP(obj_zv)==IS_OBJECT) {/*obj_zv是對象*/ /*獲取對象的class entry,見zend_get_class_entry*/ ce = Z_OBJCE_PP(obj_zv); } else if (Z_TYPE_PP(obj_zv)==IS_STRING) {/*obj_zv為string則是類名*/ if (zend_lookup_class(Z_STRVAL_PP(obj_zv), Z_STRLEN_PP(obj_zv),&pce TSRMLS_CC)==FAILURE){/*...*/} ce = *pce; } /*當callable為array時,則使用該Class的函數表*/ function_table = &ce->function_table; /*PHP中函數名不區分大小寫,所以這裡全轉成小寫*/ fname = zend_str_tolower_dup(Z_STRVAL_PP(fname_zv),Z_STRLEN_PP(fname_zv)); fname_len = Z_STRLEN_PP(fname_zv); /*檢查方法是否存在*/ if (zend_hash_exists(function_table,fname,fname_len+1)==FAILURE) {/*RET FALSE*/} } else {/*RET FALSE*/} } else if (Z_TYPE_P(callable) == IS_STRING) {/*普通全局函數,省略*/ } else {/*RET FALSE*/} /* find source function */ if (zend_hash_find(function_table,fname,fname_len+1,(void**)&fe)==FAILURE){/*..*/} if (MEMOIZE_IS_HANDLER(fe)) {/*已經被memoize緩存化過了,RET FALSE*/} if (MEMOIZE_RETURNS_REFERENCE(fe)) {/*不接受返回引用的函數,RET FALSE*/} func = *fe; function_add_ref(&func); /* find dest function,dfe=memoize_call */ /* copy dest entry with source name */ new_dfe = emalloc(sizeof(zend_function)); /*從memoize_call函數復制出一個新函數,memoize_call本身是internal的*/ /*其實可以通過new_def->type=ZEND_USER_FUNCTION將其設置成用戶函數*/ memcpy(new_dfe, dfe, sizeof(zend_function)); /*將復制出的memoize_call函數的scope設置成原函數的scope*/ new_dfe->common.scope = fe->common.scope; /*將新函數名稱設置成和原函數相同*/ new_dfe->common.function_name = fe->common.function_name; /*修改function_table,將原函數名映射到新函數new_dfe*/ if (zend_hash_update(function_table,fname, fname_len+1,new_dfe,sizeof(zend_function),NULL)==FAILURE){/*..*/} if (func.type == ZEND_INTERNAL_FUNCTION) {/*省略對internal函數的特殊處理*/} if (ttl) {/*省略ttl設置*/} /*原函數重命名成 fname$memoizd並添加到函數表*/ new_fname_len = spprintf(&new_fname, 0, "%s%s", fname, MEMOIZE_FUNC_SUFFIX); if (zend_hash_add(function_table,new_fname, new_fname_len+1,&func,sizeof(zend_function),NULL)==FAILURE){/*RET FALSE*/} }
其memoize_call
函數是不可以直接調用的,它只專門用來被復制以生成新函數的,其執行時通過自己的函數名找到對應要執行的原函數,並且同樣使用serialize
方法序列化參數,並取序列化結果字符串的MD5值作為緩存Key。
附部分Zend API函數參考:zend_get_class_entry、EG:Executor Globals、zend_function,以上均可通過站點http://lxr.php.net/搜索到。 另可參考:深入理解PHP內核——PHP函數內部實現。
其它參見Github上的源碼和文檔:https://github.com/arraypad/php-memoize
PECL Memoize Package:http://pecl.php.net/package/memoize
完整的Memoization的PHP實現參見:https://github.com/jex-im/anthology/tree/master/php/Memoize
該實現覆蓋了很多其它的邊緣問題。比如通過Reflection API,實現了將方法參數默認值也序列化到緩存Key的功能。不過該實現只支持PHP5.4以後的版本。
原文地址:http://jex.im/programming/memoization-in-php.html