基本很多项目的开发都是基于PHP开源框架的,或者至少都是基于框架的,不管这个框架是内部的,还是自己个人编写的,还是来自开源社区的。理解框架是如何运行是很有帮助的,注意这里说的是理解,而不是了解。说白了,就是你不单要知道它是怎么样的,还要明白为什么会这样。
不同框架的设计思路是不一样的,但最后核心都会落在如何把全部需要使用到的类、对象、资源更好地组织起来,在性能上达到最优,在易用性上达到最高。理解框架是如何运行的,不仅能帮助我们理清框架的设计思路,还能让我们编写更能符合框架制定的标准和规范的代码,甚至在恰当的时候提升我们提升框架或者自主设计微架构的能力。在与开发工程师交流过程中,我发现还是有很多同学对于这一块几乎没什么认识,这让我想起木兰诗里的那句“同行十二年,不知木兰是女郎。”
如果使用框架开发了多年,却不知道框架内部是如何运行的话,我觉得同样是有点可悲的。所以,我觉得有必要在这里简单分享一下。
1 多种调用方式
PHP是一门动态解释性脚本语言,它真的很动态,很灵活,并且它是弱类型的。你看,对于一个字符串变量$var = “abc”,你可以把它赋值为整型,接着又把它设置为一个数组,还可以把它变为布尔值,甚至还可以改为类对象实例。这都没任何问题!
根据我的理解,框架所做的事情,概括起来就是:对于将要访问的链接,先按路由规则进行解析,提取待执行的类名称和方法名称。然后对待执行的操作进行调用,在执行前后还需要将过滤、预处理、回调、事件侦听等环节串联起来。最后,把执行结果以合适的方式返回给客户端,可以是页面输出,也可以是接口数据返回。当然,还要有异常处理的机制。
这里,重点讲如何调用待执行的操作。即给定一个类名和一个类的方法名,如何对其进行动态调用。
假设,我们已经有这样一个BookController类,通过getHotList()方法可以获取一些热门的书籍。为专注于如何调用,而非实现,所以这里简单模拟了一些数据。同时为简化,此类方法的结果将通过接口请求返回数据给客户端,而不是返回输出一个页面。BookController类代码如下:
<?php class BookController { public function getHotList() { return array( array('name' => '重构:改善既有代码的设计'), array('name' => '逆流而上:PHP企业级系统开发'), ); } }
下面来看下多种调用方式的实现与差异。
通过硬编码方式调用
首先,是硬编码的方式。硬编码就是把要实例化的类名,将要执行的方法名,都是固定写死的。这种方式最为常见,也最简单。
$book = new BookController(); $rs = $book->getHotList(); print_r($rs);
很多已经流行的开源框架,都是多年前提出来,并且是在当时的时代背景下设计、迭代出来的。那时,网站建设还很流行,PC端的流量就像一块处女地,到处攻城掠地。而如今,天平的砝码开始倾斜到移动端。基于前后端分离的思想,更多的开发工作从原来的网站页面开发转变成对接口服务的开发。由于以前老的开源框架专注于网站页面的开发,所以对接口微服务开发这一领域支持度不够友好。渐而行之,我们就能慢慢发现,身边的项目充斥着很多下面这样的代码,以应对AJAX请求的接口能在服务端对应的被请求和响应。
$action = $_GET['action']; if ($action == 'getHotList') { $book = new BookController(); $rs = $book->getHotList(); } else if ($action == 'getDetail') { $book = new BookController(); $rs = $book->getDetail(); } else if ($action == 'updateDetail') { $book = new BookController(); $rs = $book->updateDetail(); } else if ($action == 'xxx') { // …… } $apiRs = array('code' => 200, 'msg' => '', 'data' => $rs); echo json_encode($apiRs);
这里用的就是硬编码的方式来调用。可以看到,会存在很多重复性的代码,有一定有代码异味。最重要的是,每次新增一个接口或者页面,都要同步修改这里的入口控制代码。虽然某种程序上符合开放-封闭原则,但是增加了维护成本。
通过动态变量方式调用
另外一种方式,可能会比硬编码的方式好一点,那就是通过动态变量的方式来调用。把待执行的类名和方法名,先存在变量中,然后再根据类名动态类实例对象,再根据方法名动态执行。这对于一直习惯于静态编程语言的同学来说,可能会觉得有点不可思议。但它这就样发生了。
$className = 'BookController'; $actionName = 'getHotList'; $book = new $className(); $rs = $book->$actionName(); print_r($rs);
这种方式能节省很多重复的代码,并且可以支持动态执行新增扩展的接口或者页面,减少额外维护的成本。但还不是最好的做法,并且你也基本找不到主流的开源框架会采用这种方式来执行。为什么呢?
因为,首先,这种做法看起来很粗鲁,登不场面(我个人的看法)。其次,更重要的是,如果需要传递参数该怎么办,尤其当参数的个数、位置、签名各有不同时?最后,缺少对基本错误的判断检测和预处理。例如,如果方法是不存在的或者不可调用的话,框架执行到这里就会出现500错误,而开发人员完全不知道是怎么回事。更别说在线上生产环境上,用户不小心访问了某个不存在的链接,结果系统给用户一个空白的页面,这就像Windows的应用程序时不时会弹窗提示你“程序崩溃,错误代码:XXXXXX”一样粗暴。
那有没有更好的方式呢?继续看一下节。
通过call_user_func_array()调用
调用一个回调函数,或以使用call_user_func()或者call_user_func_array()来进行调用。两者的区别在于两者对于参数列表的传递方式,前者是通过参数列表方式来传递多个不定参数,后者是通过一个数组来传递。
我们先来看一下简单的实现版本,再来逐步迭代优化。在第一版中,我们先快速使用call_user_func_array()实现动态执行。
// call_user_func_array()调用 - 第一版 $className = 'BookController'; $actionName = 'getHotList'; $book = new $className(); $params = array(); $rs = call_user_func_array(array($book, $actionName), $params); print_r($rs);
call_user_func_array()函数的第一个参数是待调用的回调函数,即callback类型,对应的值是array($book, $actionName)。关于回调类型,在下一节会继续详细讲解,这里暂时不展开。$params是要被传入回调函数的数组,即待调用函数的实际参数。这里暂时也没有额外的参数,但后面会对此强化。
回到上面的问题,我们怎么提前判断一个回调函数是否可以被正常执行呢?答案是使用is_callable()函数,使用它可以增加我们系统的健壮性和容错性。只需要这样即可:
// call_user_func_array()调用 - 第二版 $book = new $className(); $params = array(); $callback = array($book, $actionName); if (is_callable($callback)) { $rs = call_user_func_array($callback, $params); } print_r($rs);
但是,在有些开源框架里,例如Somfony的控制器的操作方法是可以有参数的,例如下面的LuckyController::number($max)中,就有一个参数$max,它们又是如何做到实际参数传递的呢?
// src/Controller/LuckyController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class LuckyController { /** * @Route("/lucky/number/{max}", name="app_lucky_number") */ public function number($max) { $number = mt_rand(0, $max); return new Response( '<html><body>Lucky number: '.$number.'</body></html>' ); } }
具体实现起来也不难,我们已经知道通过call_user_func_array()的第二个数组参数,可以动态传递多个不定实际参数给待执行的回调函数。剩下的难点,就是如果找到回调函数需要哪些形参,以及如何在请求的参数中找到对应的实际参数。先来看,怎么知道控制器的操作需要哪些形式参数。
我们也来为获取热门书籍列表的接口增加一个参数$max,也用来表示需要获取的最大条目数量。以此为例,再来探讨如何具体实现。增加$max参数,并且重新调整实现的代码如下:
class BookController { public function getHotList($max = 2) { $all = array( array('name' => '重构:改善既有代码的设计'), array('name' => '逆流而上:PHP企业级系统开发'), ); return array_slice($all, 0, $max); } }
如果想获取形式参数列表,包括有几个参数、参数名字是什么、有没默认值(有的话是什么),这时需要用到反射Reflection里面的ReflectionMethod和ReflectionParameter。继续我们第三版迭代,在为了尝试获取形式参数列表的名称以及默认值而添加插入的代码如下:
// call_user_func_array()调用 - 第三版(上) // 获取形式参数和参数实际 $reflection = new ReflectionMethod($className, $actionName); foreach ($reflection->getParameters() as $arg) { $argName = $arg->name; $argDefaultValue = $arg->getDefaultValue(); var_dump($argName, $argDefaultValue); }
作为临时调试的代码,可以看到结果中有输出前面的$max参数,以及它对应的默认值2。
string(3) "max" int(2)
但第三版到这里只完成了一半,因为我们还要找到实际中对应的参数值。这一点就好办了,有了具体的参数名字以及它的默认值,稍微制定一下规则就可以轻松找到客户端传递过来的具体参数值了。例如,就以形参名字作为客户端的参数名,如果客户端没传,就使用默认值,如果没有默认值则赋为NULL。即最终参数的值的优先级依次是:
- 1、优化使用客户端传递的参数值
- 2、如果没传,则使用形参的默认值
- 3、如果没有默认值,就赋为NULL
根据这些规则,再来完善第三版,最终代码是:
// call_user_func_array()调用 - 第三版(下) // 获取形式参数和参数实际 $reflection = new ReflectionMethod($className, $actionName); foreach ($reflection->getParameters() as $arg) { $argName = $arg->name; $argDefaultValue = $arg->isOptional() ? $arg->getDefaultValue() : NULL; // var_dump($argName, $argDefaultValue); $params[$argName] = isset($_REQUEST[$argName]) ? $_REQUEST[$argName] : $argDefaultValue; }
至此,经过多次迭代,我们对于通过call_user_func_array()调用回调函数的方案设计,就可以暂告一段落了。
作为最后的总结和回顾,我们来观察下几个开源框架对于这一块的做法,并简单分析一下。
- Yii框架 2.0
在Yii 2.0中,Action::runWithParams($params)里,可以看到对控制器Controller的Action操作执行前的相关处理。这里使用了method_exists()函数来判断方法是否存在,通过bindActionParams()操作来绑定实际参数并产生参数列表$args。最后在执行前触发beforeRun()钩子函数,通过call_user_func_array()函数来执行回调函数[$this, ‘run’],实际参数就是刚刚产生的$args,执行完毕后再触发afterRun()钩子函数。
<?php namespace yii\base; // …… class Action extends Component { /** * Runs this action with the specified parameters. * This method is mainly invoked by the controller. */ public function runWithParams($params) { if (!method_exists($this, 'run')) { throw new InvalidConfigException(get_class($this) . ' must define a "run()" method.'); } $args = $this->controller->bindActionParams($this, $params); Yii::debug('Running action: ' . get_class($this) . '::run()', __METHOD__); if (Yii::$app->requestedParams === null) { Yii::$app->requestedParams = $args; } if ($this->beforeRun()) { $result = call_user_func_array([$this, 'run'], $args); $this->afterRun(); return $result; } return null; } // ……
- Symfony框架 4.0
在Symfony 4.0中,提炼后的HttpKernel::handleRaw()代码如下。通过getController()获取待调用的控制器,通过getArguments()获取实际参数列表,最后通过call_user_func_array()来进行调用。最后将结果$response通过合适的方式返回给客户端。
<?php namespace Symfony\Component\HttpKernel; // …… class HttpKernel implements HttpKernelInterface, TerminableInterface { /** * Handles a request to convert it to a response. */ private function handleRaw(Request $request, int $type = self::MASTER_REQUEST) { $this->requestStack->push($request); // …… $controller = $event->getController(); $arguments = $event->getArguments(); // call controller $response = \call_user_func_array($controller, $arguments); // …… return $this->filterResponse($response, $request, $type); } // ……
- ThinkPHP框架 5.1
在 ThinkPHP 5.1,Container:: invokeFunction($function, $vars = [])内使用了ReflectionFunction反射来获取回调函数的参数信息,然后通过bindParams()与实际参数进行绑定并产生$args,最后通过call_user_func_array()进行调用执行。如果方法不存在,则会通过ReflectionException异常抛出。
<?php namespace think; // …… class Container implements \ArrayAccess { /** * 执行函数或者闭包方法 支持参数调用 * @access public * @param mixed $function 函数或者闭包 * @param array $vars 参数 * @return mixed */ public function invokeFunction($function, $vars = []) { try { $reflect = new ReflectionFunction($function); $args = $this->bindParams($reflect, $vars); return call_user_func_array($function, $args); } catch (ReflectionException $e) { throw new Exception('function not exists: ' . $function . '()'); } } // ……
可以发现,不同开源框架在处理动态执行这一块是大同小异的,都是使用反射来获取形式参数,然后绑定到实际参数。准备好待调用的控制器或回调函数后,通过call_user_func_array()函数进行回调,并传递实际的参数列表。在这执行前后、处理过程中,再结合钩子函数或者侦听事件进行更多扩展的操作。
2 Callback / Callable 回调类型
在前面刚刚结束的这一节中,有讨论到回调类型。在使用call_user_func_array()进行回调时,它的第一个参数是回调类型,类型关键字是Callback / Callable。回调类型可以用于动态执行,还可以作为注册的事件先存储起来,在适当的时机再触发。
对匿名函数的回调
回调类型是一个很趣的类型,下面我们一起来逐一学习下。
首先,是匿名函数,类似这样:
<?php $func = function() { return '我在匿名函数内'; }; // 这样调用 var_dump($func()); // 或这样调用 var_dump(call_user_func($func));
匿名函数与数组系统的函数结合使用较多,例如:array_walk,和前面提到的usort、array_map()、array_filter()。在提供了DI容器的开源框架中,也会使用匿名函数来延迟加载,从而提升性能。例如在PhalApi 2.x中的di.php文件内,对于缓存的注册就使用了匿名函数。因为并不是全部的接口请求都需要使用到缓存,所以可以延迟加载,直到有需要时才去初始化。
// 缓存 - Memcache/Memcached // $di->cache = function () { // return new \PhalApi\Cache\MemcacheCache(\PhalApi\DI()->config->get('sys.mc')); // };
这时,匿名函数可直接作为回调类型。
对普通函数的回调
接下来,就是带名称的函数。PHP官方本身就有很多这样的函数,例如:strtoupper()、md5()、intval()等。你也可以自己编写一个函数。例如将全部数组的元素转成大写:
$arr = array('dogstar', 'aevit', 'yoyo'); $arrUpper = array_map('strtoupper', $arr); // print_r($arrUpper);
在这里,只需要用函数的名称,就可以表示成回调类型了。
对类实例方法的回调
前面说的都是面向过程编程中的函数,下面来讲讲面向对象编程中的类。对于类的成员函数方法,如果需要进行回调的话,表示方式是:array(类实例, 方法名)。关于这种用法,前面在讲框架是如何运行的一节中已有很多案例,这里不再赘述。
对类静态方法的回调
最后,还有一种是对类静态方法的回调。因为类的静态方法不需要实例化就能调用,因此它的回调类型用字符串来表示,格式是:类名::方法名。例如,我们有一个Foo类,里面有一个静态方法doSth(),则可以这样进行回调:
class Foo { public static function doSth() { return '我在类的静态方法内'; } } var_dump(call_user_func('Foo::doSth'));
此外,也可以使用数组的形式来表示,第一个位置表示类名,第二个位置表示方法名。例如:
var_dump(call_user_func(array('Tool', 'doSth')));
也可以达到同样的效果。
在讨论完以上四类回调类型的表示方式后,再来看下在Symfony框架中,事件分别的相关代码版本,就能更好地理解了。
<?php namespace Symfony\Component\EventDispatcher; // …… class EventDispatcher implements EventDispatcherInterface { /** * Triggers the listeners of an event. * * @param callable[] $listeners The event listeners */ protected function doDispatch($listeners, $eventName, Event $event) { foreach ($listeners as $listener) { if ($event->isPropagationStopped()) { break; } \call_user_func($listener, $event, $eventName, $this); } } // ……
上面是Symfony底层处理事件分发的核心代码,很简洁。其实就是循环每一个侦听事件注册的回调函数进行调用,然后把相应的上下文信息传递过去。
紧接着,再来联系一下客户端的使用,看看客户端是如何注册侦听事件以及实现事件回调处理的话,就更加清晰明朗了。下面是从Symfony官方摘录的代码版本,讲的是如何创建一个事件订阅者。
// src/EventSubscriber/ExceptionSubscriber.php namespace App\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; use Symfony\Component\HttpKernel\KernelEvents; class ExceptionSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents() { // return the subscribed events, their methods and priorities return array( KernelEvents::EXCEPTION => array( array('processException', 10), array('logException', 0), array('notifyException', -10), ) ); } public function processException(GetResponseForExceptionEvent $event) { /** 略 **/ } public function logException(GetResponseForExceptionEvent $event) { /** 略 **/ } public function notifyException(GetResponseForExceptionEvent $event) { /** 略 **/ } }
这些都有回调类型的身影,虽然它并不是那么明显,但通过getSubscribedEvents()返回的配置,再结合当前具体的实现类,就不难推导出底层是如何组装回调类型的了。
3 自动加载
文件的自动加载在任何一文件语言中,都有其处理的特色。在PHP中,则有一套灵活的机制来动态加载所需要的PHP文件。下面,从原始的手动加载,到简单实现自动加载,再到社区推荐和统一的PSR-4命名规范,分别依次讲解。
原始的手动加载
直的很难理解,为什么到了科技如此发达的21世纪,居然还会有PHP项目使用手动加载的方式来引入文件。
在手动引入的项目中,可以说历史原因是多种多样,但令人费解的是他们可以一直这样保持着手动引入的痛苦。要么就是缺少引入的文件出现“Class not found”的错误,要么就是出现重复加载提示“Cannot redeclare class”。
例如,在入口文件index.php中,需要用到存放类Helper类的文件Helper.php,以及存放函数foo()的文件foo.php。如果你的项目中使用的也是手动加载的方式,那么以下代码很可能就是你项目的缩影。
==> index.php <== <?php if (!class_exists('Helper')) { require_once dirname(__FILE__) . '/Helper.php'; } $helper = new Helper(); if (!function_exists('foo')) { require_once dirname(__FILE__) . '/foo.php'; } ==> foo.php <== <?php if (!function_exists('foo')) { function foo() { } } ==> Helper.php <== <?php if (!class_exists('Helper')) { class Helper { } }
在客户端调用时,需要先判断要实例化的类是否已经存在,没有话就手动引入。同样,客户端在调用函数之前,要判断函数是否存在,没有的话也手动引入。每次都这样,显得很重复累赘。以防“Class not found”错误。不仅如此,在声明类和声明函数时,也要添加多一层判断,避免出现“Cannot redeclare class”错误。
沿用手动加载的方式,原因可能有两个,一点是出于性能的考虑,但我觉得并不成立。第二点是项目没有使用框架,或者分层设计得不明显,代码放置得错落无序,没有统一的规则能根据类名找到代码文件的位置。
我觉得手动加载的方式,简直是在浪费程序的生命,因为每次都要忍受最原始方式的折磨。就好比如,现在要点个火,花一块钱买个打火机,然后一按就有火了,即方便携带又可以长时间保存火种,经济又实惠。但如果换成,每次生火都要你拿两个火石在碰撞,或者使用放大镜通过凸点聚焦方式来燃烧,不是很麻烦,很浪费时间,很不值得吗?
那,有没更好的解决方案?有,当然有!正如你看到的,没有哪个开源框架是还需要你手动引入文件的。接下来,我们来重复造个轮子,以便深刻理解PHP是如何实现自动加载的。
简单实现自动加载
当调用一个不存在的对象方法时,会触发魔法方法。那当使用一个不存在的类时,会触发什么方法,或者会发生什么事情呢?
PHP提供了两种方式,可用来注册自己的自动加载方式,分别是:
__autoload()
尝试加载未定义的类spl_autoload_register()
注册自定义加载类的方式,注册给定的函数作为__autoload
的实现
__autoload()
只能定义一次,它的参数只有一个,就是未定义的类名称。推荐的方式是使用spl_autoload_register()
,因为它更灵活。它需要的第一个参数是回调类型,即欲注册的自动装载函数。
注册很简单,基本的代码骨架是:
spl_autoload_register(array(new MyAutoLoader(), 'load')); class MyAutoLoader { public function load($classname) { // …… } }
剩下的事情,就是如何根据类名找到对应PHP文件的艺术了。
说它是艺术,是因为项目代码的目录结构,以及命名规则,以及放置的位置,都是可以由我们自己来制定的。只要类名以及文件路径之间,存在唯一映射关系,再来实现自动加载就不难了。例如常用的PEAR命名规范,就是其中一种。又或者使用后缀来区分不同的目录位置,比如DemoController表示在控制器Controller目录内,DemoModel表示在模型Model目录内,DemoHelper表示在辅助类Helper目录内。这些规则都是可参考既有的方式,也可以自己根据情况来设计。这里不过多展开。
但这里要重点说明,在实现自定义自动加载时,要特别注意以下几个事项:
- 避免重复加载
在最终引入PHP文件时,可以使用require_once的方式来引入,避免重复加载。 - 严格区分大小写
需要严格区分大小写,包括类名和文件路径。因为经常发生的事情是,明明在本地的Windows系统开发和调试是正常的,但一发布到线上环境就会出500错误。是因为线上的Linux操作系统是严格区分大小写的,从而会导致PHP文件找不到。与操作系统环境有不可移植性的除了大小写敏感外,还有就是文件路径的分割符号。Windows系统是反斜杠,而Linux系统是斜杠。这一点也要留意区分。 - 注意命名空间
还要注意如果类名是带有命名空间的话,要怎么处理。关键点在于命名空间之间的连接符。 - 与其他加载机制的共存
最后,如果自定义的加载机制无法找到对应的类文件,也不要轻易终止或抛出异常。应该把机会留给其他自动加载机制,除非确认这是一个闭包生态圈。
其他还有一些零散的知识点,例如可以用file_exists()来判断文件是否存在,引入后还可以使用class_exists()来判断类是否真的存在。
综合这些知识点,基本的加载骨架,和注意事项,我们就可以实现自己的自动加载机制了。但有没有更省心的做法,就是我连自动加载都不用实现,就会实现类文件的自动加载?有!下面会继续介绍。
遵循PSR-4命名规范
在PHP开源社区里,Composer的方式逐渐成为了主流。很多开源框架都纷纷升级转为这种组件化的方式。Compose主要使用的是PSR-4规范。简单来说,类的全称格式如下:
\<NamespaceName>(\<SubNamespaceNames>)*\<ClassName>
其中,<NamespaceName>
为顶级命名空间;<SubNamespaceNames>
为子命名空间,可以有多层;<ClassName>
为类名。
Composer已经帮我们实现了统一的自动加载机制,剩下要做的事就是按照它的规范命名即可。但对于初次使用composer和初次接触PSR-4的同学,以下事项需要特别注意,否则容易导致误解、误用、误导。
- 1、在当前命名空间使用其他命名空间的类时,应先use再使用,或者使用完整的、最前面带反斜杠的类名。
- 2、在定义类时,当前命名空间应置于第一行,且当存在多级命名空间时,应填写完整。
- 3、命名空间和类,应该与文件路径保持一致,并区别大小写。
例如,以PhalApi框架内编写接口类Site为例:
<?php namespace App\Api; use PhalApi\Api; class Site extends Api { public function test() { // 错误!会提示 App\Api\DI()函数不存在! DI()->logger->debug('测试函数调用'); // 正确!调用PhalApi官方函数要用绝对命名空间路径 \PhalApi\DI()->logger->debug('测试函数调用'); } public function testMyFun() { // 错误!会提示 App\Api\my_fun()函数不存在! //(假设在./src/app/functions.php有此函数) my_fun(); // 正确!调用前要加上用绝对命名空间路径 \App\my_fun(); } }
更多关于PSR-4的规范说明,可以参考:
PSR-4: Autoloader,https://www.php-fig.org/psr/psr-4/