PHP アプリケーションのパフォーマンスの最適化に関して、多くの人が最初に考えるのは間違いなくキャッシュです。一般的なプログラムでは PHP を使用してあまり多くの計算を実行しないため、アルゴリズムを最適化する余地がほとんどないため、パフォーマンスのボトルネックは CPU ではなく、IO で発生する可能性が高くなります。キャッシュを最も必要とする IO 操作は、時間のかかるデータベース クエリです。 最も一般的なキャッシュの使用例は、次のようなクエリです:
リーリーSQL クエリに加えて、他にキャッシュが必要な IO 状況は何ですか? ——もちろん、外部ネットワーク インターフェイスのリクエストは不可欠です。たとえば、Google 検索結果をリクエストする場合、Google サーバーはクエリを非常に迅速に処理しますが、HTTP 接続よりも HTTPS 接続を確立する方が時間がかかるだけでなく、リクエストを完了するまでに時間がかかります。 URL は 302 ジャンプを生成しますが、これには (このページはブロックされているため、ご自身で判断してください) という理由もあります。サンプルコードは次のとおりです:
リーリーまず、前の 2 つの典型的なキャッシュ ケース コードを停止して観察してください。どのような問題が見つかるでしょうか?
これらの問題を段階的に解決するにはどうすればよいですか?コードの重複は抽象化の欠如を意味します。まずこれらの手順を抽象化しましょう。キャッシュの使用は通常、次のプロセスに従います。 リーリー
「操作の実行」ステップはプログラムのメインロジックであり、他のステップはキャッシュコードのロジックです。コードの重複を避けたい場合は、プログラムのメインロジックコードとキャッシュオペレーションコードを分離するしかありません。次に、元の関数を次のように 2 つの関数に分割する必要があります:リーリー
実装方法については、今は脇に置いておきましょう。現時点では、コードの重複の問題を解決するための明確な方向性が見えています。次の質問: キャッシュ キーの一元管理をどのように実現するか?当然ですが、再利用可能なキャッシュ操作機能ではキャッシュKeyが自動生成されます。問題を抽象化すると何が見つかるでしょうか?以前は、私たちはまだ多岐に渡って考え、キャッシュのさまざまな状況がいくつあるかを考えようとしていましたが、現在では、すべてのキャッシュの状況が 1 つの状況 (特定の操作関数の実行の戻り結果をキャッシュする) に分類できることがわかりました。同時に、結果をキャッシュする必要がある操作を関数に入れ、関数呼び出しの戻り結果のみをキャッシュする必要があるという統一されたソリューションが得られます。この関数呼び出しキャッシュ テクノロジには、メモ化という特別な用語があります。ああ、メモ化が必要な理由を説明するだけで長い時間がかかってしまいました。
メモ化の適用範囲
関数の場合、その戻り結果はデータベース内のコンテンツによって決まります。著者がデータベースに送信した新しい記事がある場合、パラメーター $author が同じであっても、戻り結果は次のようになります。違う。このような関数については、回避策を使用する必要があります。特定のキャッシュ サイクル内の IO を含む関数の動作の一貫性 (つまり、同じパラメータが同じ結果を返す) は保証されているとおおよそ考えることができます。たとえば、データベース クエリに 10 分間のキャッシュを許可した場合、同じクエリは 10 分以内に同じ結果を返すと考えることができます。このように、IO を含む関数でもキャッシュ有効期限を設定したメモ化を使用できます。この種の状況は実際の環境ではより一般的です。 function add($a,$b) { return $a+$b; }
。这样的函数可以安全使用Memoization,因为它输入相同的参数总是返回相同的结果,所以函数名加上参数值即是一个自动生成的缓存Key。但是现实中很多和外部有数据交互的函数(IO)并不具备这样的特性,如上面的query_author_articles
関数) のインターフェイスと動作モードは次のようになります: cache_operation
リーリー
やその他のメソッドに加えて。 serialize
函数,也可以考虑使用json_encode
!これはまさに反人道的行為です! 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