4.5 框架是如何執行的
基本很多專案的開發都是基於PHP開源框架的,或者至少都是基於框架的,不管這個框架是內部的,還是自己個人編寫的,還是來自開源社群的。理解框架是如何執行是很有幫助的,注意這裡說的是理解,而不是瞭解。說白了,就是你不單要知道它是怎麼樣的,還要明白為什麼會這樣。
不同框架的設計思路是不一樣的,但最後核心都會落在如何把全部需要使用到的類、物件、資源更好地組織起來,在效能上達到最優,在易用性上達到最高。理解框架是如何執行的,不僅能幫助我們理清框架的設計思路,還能讓我們編寫更能符合框架制定的標準和規範的程式碼,甚至在恰當的時候提升我們專注框架或者自主設計微架構的能力。在與開發工程師交流過程中,我發現還是有很多同學對於這一塊幾乎沒什麼認識,這讓我想起木蘭詩裡的那句“同行十二年,不知木蘭是女郎。”
如果使用框架開發了多年專案,卻不知道框架內部是如何執行的話,我覺得同樣是有點可悲的。所以,我覺得有必要在這裡簡單分享一下。
4.5.1 多種呼叫方式PHP是一門動態解釋性指令碼語言,它真的很動態,很靈活,並且它是弱型別的。你看,對於一個字串變數$var = "abc",你可以把它賦值為整型,接著又可以把它設定為一個數組,還可以把它變為布林值,甚至還可以改為類物件例項。這都沒任何問題!
根據我的理解,框架所做的事情,概括起來就是:對於將要訪問的連結或功能服務,先按路由規則進行解析,提取待執行的類名稱和方法名稱。然後對待執行的操作進行呼叫,在執行前後還需要將過濾、預處理、回撥、事件偵聽等環節串聯起來。最後,把執行結果以合適的方式返回給客戶端,可以是頁面輸出,也可以是介面資料返回。當然,還要有異常處理的機制。
這裡,重點講如何呼叫待執行的操作。即給定一個類名和一個類的方法名,如何對其進行動態呼叫。
假設,我們已經有這樣一個BookController類,透過getHotList()方法可以獲取一些熱門的書籍。為專注於如何呼叫,而非實現,所以這裡簡單模擬了一些資料。同時為簡化,此類方法的結果將透過介面請求返回資料給客戶端,而不是返回輸出一個頁面。BookController類程式碼如下:
<?phpclass 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的應用程式時不時會彈窗提示你“程式崩潰,錯誤程式碼:0XXXXXX”一樣粗暴。
那有沒有更好的方式呢?繼續看一下節。
透過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.phpnamespace 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()鉤子函式。
<?phpnamespace 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透過合適的方式返回給客戶端。
<?phpnamespace 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{ /** * 執行函式或者閉包方法 支援引數呼叫 */ 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()函式進行回撥,並傳遞實際的引數列表。在這執行前後、處理過程中,再結合鉤子函式或者偵聽事件豐富更多擴充套件的操作。
4.5.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()等。你也可以自己編寫一個函式。例如將全部陣列的元素轉成大寫:
對類例項方法的回撥前面說的都是面向過程程式設計中的函式,下面來講講面向物件程式設計中的類。對於類的成員函式方法,如果需要進行回撥的話,表示方式是:array(類例項, 方法名)。關於這種用法,前面在講框架是如何執行的一節中已有很多案例,這裡不再贅述。
例如:
$book = new BookController();$actionName = 'getHotList';$params = array();$rs = call_user_func_array(array($book, $actionName), $params);
對類靜態方法的回撥
最後,還有一種是對類靜態方法的回撥。因為類的靜態方法不需要例項化就能呼叫,因此它的回撥型別用字串來表示,格式是:類名::方法名。例如,我們有一個Foo類,裡面有一個靜態方法doSth(),則可以這樣進行回撥:
class Foo { public static function doSth() { return '我在類的靜態方法內'; }} var_dump(call_user_func('Foo::doSth'));
此外,也可以使用陣列的形式來表示,第一個位置表示類名,第二個位置表示方法名。例如:
var_dump(call_user_func(array('Tool', 'doSth')));
也可以達到同樣的效果。
<?phpnamespace Symfony\Component\EventDispatcher;// ……class EventDispatcher implements EventDispatcherInterface{ /** * Triggers the listeners of an event. */ 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.phpnamespace App\EventSubscriber;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()返回的配置,再結合當前具體的實現類,就不難推匯出底層是如何組裝回調型別的了。
4.5.3 自動載入檔案的自動載入在任何一檔案語言中,都有其處理的特色。在PHP中,則有一套靈活的機制來動態載入所需要的PHP檔案。下面,從原始的手動載入,到簡單實現自動載入,再到社群推薦和統一的PSR-4命名規範,分別依次講解。
原始的手動載入直的很難理解,為什麼到了科技如此發達的21世紀,居然還會有PHP專案使用手動載入的方式來引入檔案。
在手動引入的專案中,可以說歷史原因是多種多樣,但令人費解的是他們可以一直這樣保持著並忍受手動引入的痛苦。要麼就是因為缺少引入的檔案出現“Class not found”的錯誤,要麼就是因為重複載入而提示“Cannot redeclare class”。
例如,在入口檔案index.php中,需要用到存放類Helper類的檔案Helper.php,以及存放函式foo()的檔案foo.php。如果你的專案中使用的也是手動載入的方式,那麼以下程式碼很可能就是你專案的縮影。
==> index.php <==<?phpif (!class_exists('Helper')) { require_once dirname(__FILE__) . '/Helper.php';}$helper = new Helper();if (!function_exists('foo')) { require_once dirname(__FILE__) . '/foo.php';}==> foo.php <==<?phpif (!function_exists('foo')) { function foo() { }}==> Helper.php <==<?phpif (!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規範。簡單來說,類的全稱格式如下:
<?phpnamespace 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(); }}
其中,<NamespaceName>為頂級名稱空間;<SubNamespaceNames>為子名稱空間,可以有多層;<ClassName>為類名。
Composer已經幫我們實現了統一的自動載入機制,剩下要做的事就是按照它的規範命名即可。但對於初次使用composer和初次接觸PSR-4的同學,以下事項需要特別注意,否則容易導致誤解、誤用、誤導。
1、在當前名稱空間使用其他名稱空間的類時,應先use再使用,或者使用完整的、最前面帶反斜槓的類名。2、在定義類時,當前名稱空間應置於第一行,且當存在多級名稱空間時,應填寫完整。3、名稱空間和類,應該與檔案路徑保持一致,並嚴格區分大小寫。例如,以PhalApi框架內編寫介面類Site為例:
更多關於PSR-4的規範說明,可以參考:
PSR-4: Autoloader,https://www.php-fig.org/psr/psr-4/