前言
本文是《自制php框架》之自動加載篇,筆者參照tp5框架的自動加載相關源碼,寫了幾個p1~p4四個demo(放在我的github了),基本體現了從0到成型框架的自動加載的編寫過程。文章篇幅很長,如果你屬於以下情況,建議看下:
-
用過php框架,但不懂爲何:只要
use app\model\User
(沒有include或require
)就能直接用User類。 -
理解php是通過
spl_autoload_register
實現自動加載的,但心血來潮看了一下某個框架的源碼,不理解大型一點的框架的自動機制代碼爲何能寫那麼長,是爲了解決什麼問題。 -
想理解composer的自動加載如何實現。
正文
背景知識
- 非自動加載的使用類的流程是先引入類所在文件,然後訪問該類。
- php實現自動加載的核心是:當訪問在其他文件定義的類時,會調用我們指定的處理函數,在該函數內引入類所在文件。
- 這個自動加載處理函數是通過
spl_autoload_register
註冊(即指定)的,可以註冊多個- 爲什麼需要註冊多個?因爲使用第三方包時,一般需要註冊該包的自動加載處理函數,實現加載該包的類。
- 有多個就涉及順序了,存儲自動加載函數的數據結構是隊列,即先註冊的,優先使用,找不到再調後面的處理函數。當然,
spl_autoload_register(callback, $prepend)
,設置第二個參數爲true,即可排到隊首。 - 最初的自動加載處理方式是:直接在
__autoload(){}
寫處理代碼,但明顯不能應對上面說的有多個自動加載處理函數的情況。
- 命名空間:
- 爲什麼要有命名空間?能區分同名的類
- 與自動加載的關係?自動加載處理函數裏就是根據命名空間找到類所在文件的路徑的。
- 最終期望實現的效果是:在A文件,想訪問定義在B文件的類,只需
- 在B文件聲明命名空間
- 在A文件
use
,然後訪問。
P1:自動加載簡單嘗試
根據上面的背景知識,最直觀能想到的,就是P1這種實現。也是網上大多數博文都有寫到的。
先看看整體目錄結果,後面的p2~p4目錄結構相同。
p1
├── app ## 應用目錄,開發者主要在這層寫
│ ├── controller
│ │ └── User.php
│ └── model
│ └── User.php
├── fool ## 框架類庫目錄
│ └── Loader.php ## 自動加載類
├── index.php ## 入口文件
└── vendor ## 第三方包目錄
└── pack1
└── A.php ## 第三個包pack1下的A文件,裏面有A類
-
首先在入口文件
/index.php
定義根目錄路徑和調用自動加載類<?php define("DS", DIRECTORY_SEPARATOR); // linux下是/,windows是/或\ define("EXT", '.php'); define("ROOT_PATH", __DIR__ . DS); // 項目根目錄 // 註冊自動加載機制 require ROOT_PATH . 'fool/Loader.php'; \fool\Loader::register();
-
然後在
/fool/Loader.php
寫具體的加載處理:- 注意一點:
autoload($class)
的參數$class
是自動傳入的,其值就是想訪問但找不到的類的完整類名(即包含命名空間部分的)。打印一下就知道了~ - 核心是
findFile()
,命名空間格式是namespace app\controller;
,linux下文件目錄路徑是app/contoller
,要找到文件,將傳入命名空間的\
轉成DS
,再拼上.php
,就是了。
<?php namespace fool; class Loader { /* * 註冊自動加載處理函數 * @return void * */ public static function register() { spl_autoload_register("fool\\Loader::autoload", true, true); } /* * 自動加載處理函數 * @param string $class 類名 * @return bool * */ public static function autoload($class) { if ($file = self::findFile($class)) { return require $file; } return false; } /* * 查找文件 * @param string $class 類名 * @return bool|string * */ private static function findFile($class) { return ROOT_PATH . strtr($class, '\\', '/') . EXT; } }
- 注意一點:
-
測試:
- 在
/app/controller/User.php
聲明User類,當然是要聲明命名空間的。然後通過use方式,訪問定義在app/model/User.php
的User
類。
<?php namespace app\controller; use app\model\User as UserModel; class User { public function index() { echo "this is controller User function index <br />\n"; $model = new UserModel(); $model->getList(); } }
- 在
/app/model/User.php
:
<?php namespace app\model; class User { public function getList() { echo "this is model User function getList <br /> \n"; } }
- 在入口文件
/index.php
訪問
// new與目錄對應的命名空間,成功 use app\controller\User; $user = new User(); $user->index();
- 運行
php index.php
,或通過web方式訪問index.php。看到打印如下:表明訪問成功
this is controller User function index this is model User function getList
- 在
-
似乎,自動加載就這麼簡單的實現了,上面寫的最終實現效果也實現了。但,你也許有2個疑惑:
-
寫了一堆代碼,與最原始方式:用常量定義根目錄,訪問寫在其它文件的類前
require ROOT_PATH . 文件路徑
,再訪問。似乎區別不大啊,並沒簡化多少工作量,訪問前還是要use
,甚至多出一步,在類頭聲明命名空間,這樣做的意義在哪?在類頭聲明命名空間是爲了避免類重名,即使通過原始方式,也應該要在類頭聲明命名空間,訪問前也要use。
即原始方式實際也是:require -> use -> 訪問
而自動加載是:use -> 訪問
use 是必需的,否則,程序怎麼可能知道你要訪問的是哪個類。
-
嗯,自動加載的好處我體會到了(省去了require步驟),那麼,爲什麼那些框架的自動加載處理代碼有那麼長呢,爲了解決什麼問題?tp5.0自動加載源碼。
-
-
其中一個要解決的問題是,也就是P1方式的致命缺陷,如果文件路徑與聲明的命名空間不對應。當使用第三方包時~看例子吧。
把第三方包放在
/vendor
目錄下,/vendor/pack1
爲一個第三方包,在/vendor/pack1/A.php
寫,第三方包的命名空間不可能是vendow/pack1
的,別人用了存放第三方包的目錄名未必是vendor
,因此第三方包基本都是這種格式。<?php namespace pack1; class A { public function work() { echo "this is a extra package pack1, class A function work was called <br /> \n"; } }
-
訪問試試,在
/index.php
// new目錄與命名空間不對應的 (項目目錄/vendor/pack1/A.php, 命名空間pack1\A) 失敗 use pack1\A; $p1 = new A(); $p1->work();
報錯找不到
PHP Warning: require(/data/autoload/p1/pack1/A.php): failed to open stream: No such file or directory in /data/autoload/p1/fool/Loader.php on line 24
如何解決命名空間與所在文件路徑不對應情況?
P2:添加註冊命名空間機制
use pack1\A
要引入/vendor/pack1/A.php
,很容易想到,只要在自動加載處理函數函數裏,將pack1
替換成/vendor/pack1
即可,即需要加多兩個步驟:
- 註冊命名空間:即將
pack1
與/vendor/pack1
的映射關係存到變量裏。 - 找文件時:傳入命名空間 + 映射關係 -> 文件路徑
上面的代碼不變,改的只有/fool/Loader.php
。
-
註冊命名空間
- 添加私有的
$prefixDirsPsr4
變量,存命名空間與目錄的映射關係。 - 添加私有的
addPsr4()
方法,將映射關係存儲到$prefixDirsPsr4
變量。 - 添加public的
addNamespace()
方法, 目的有- 提供對外註冊命名空間接口
- 使註冊命名空間傳參方便些。
private static $prefixDirsPsr4 = []; public static function addNamespace($namespace, $path = '') { if (is_array($namespace)) { foreach ($namespace as $prefix => $paths) { self::addPsr4($prefix, $paths, true); } } else { self::addPsr4($namespace, $path, true); } } private static function addPsr4($prefix, $paths, $prepend = false) { if (!isset(self::$prefixDirsPsr4[$prefix])) { // 註冊新的命名空間 self::$prefixDirsPsr4[$prefix] = (array) $paths; } else { // 爲已有命名空間添加對應目錄 self::$prefixDirsPsr4[$prefix] = $prepend ? array_merge((array) $paths, self::$prefixDirsPsr4[$prefix]) : array_merge(self::$prefixDirsPsr4[$prefix], (array) $paths); } }
- 添加私有的
-
註冊框架必須的命名空間:
public static function register() { spl_autoload_register("fool\\Loader::autoload", true, true); // 添加命名空間 對應目錄 self::addNamespace([ 'app' => APP_PATH, 'fool' => FOOL_PATH, ]); }
-
修改找文件方式:思路是識別出傳入命名空間裏首次出現的
/
的索引,將前面替換成對應文件路徑,再拼接後面。private static function findFile($class) { // 先直接 命名空間 轉成 路徑 $logicalPathPsr4 = strtr($class, '\\', DS) . EXT; // 根據(命名空間 與 目錄)映射 替換前綴 $len = strpos($logicalPathPsr4, '/'); $cPrefix = substr($logicalPathPsr4, 0, $len); $follow = substr($logicalPathPsr4, $len+1); foreach (self::$prefixDirsPsr4 as $prefix => $dirs) { if ($prefix == $cPrefix) { foreach ($dirs as $dir) { if (is_file($file = $dir . $follow)) { return $file; } } } } return false; }
-
與P1同樣調用的代碼,在
/index.php
// new與目錄對應的命名空間,成功 use app\controller\User; $user = new User(); $user->index();
運行,成功
this is controller User function index this is model User function getList
-
註冊額外的命名空間,在
/index.php
。這就是爲什麼addNamespace()
方法了要設爲public。// 設置命名空間pack1 對應 目錄 vendow/pack1 \fool\Loader::addNamespace('pack1', ROOT_PATH . 'vendor/pack1'. DS);
-
new命名空間與文件所在路徑不對應的類,在
/index.php
// new目錄與命名空間不對應的 (項目目錄/vendor/pack1/A.php, 命名空間pack1\A) 也成功 use pack1\A; $p1 = new A(); $p1->work();
運行,成功
this is controller User function index this is model User function getList this is a extra package pack1, class A function work was called
P3:完善註冊命名空間機制
p3實現的效果與p2基本相同,p3基本就是tp5那套,而tp5那套很像composer那套。
-
用了官方的規範:對於映射關係中命名空間結尾是否要加
\
,目錄結尾是否要加/
,想必很亂,按tp5的來,這裏統一:- 命名空間結尾加
\
:如fool\
,app\
- 目錄結尾不加
/
:如/data/autoload/p3/fool
- 命名空間結尾加
-
其它代碼不變,修改
/fool/Loader.php
-
添加
$prefixLengthsPsr4
:變量的結構是,按註冊的命名空間的首字母劃分,存了其長度Array ( [a] => Array ( [app\] => 4 [aaaaa\] => 6 ) [f] => Array ( [fool\] => 5 ) )
-
addPsr4()
和findFile()
方法也改成對應那套private static $prefixLengthsPsr4 = []; private static function addPsr4($prefix, $paths, $prepend = false) { if (!isset(self::$prefixDirsPsr4[$prefix])) { // 註冊新的命名空間 self::$prefixDirsPsr4[$prefix] = (array) $paths; // 記錄前綴長度 $length = strlen($prefix); if ('\\' !== $prefix[$length - 1]) { // PSR-4規範,非類的命名空間應該以\結尾 echo 'A non-empty PSR-4 prefix must end with a namespace separator.'; } self::$prefixLengthsPsr4[$prefix[0]][$prefix] = $length; } else { // 爲已有命名空間添加對應目錄 self::$prefixDirsPsr4[$prefix] = $prepend ? array_merge((array) $paths, self::$prefixDirsPsr4[$prefix]) : array_merge(self::$prefixDirsPsr4[$prefix], (array) $paths); } } private static function findFile($class) { // 先直接 命名空間 轉成 路徑 $logicalPathPsr4 = strtr($class, '\\', DS) . EXT; // 根據(命名空間 與 目錄)映射 替換前綴 $first = $class[0]; if (isset(self::$prefixLengthsPsr4[$first])) { foreach (self::$prefixLengthsPsr4[$first] as $prefix => $length) { if (0 === strpos($class, $prefix)) { foreach (self::$prefixDirsPsr4[$prefix] as $dir) { if (is_file($file = $dir . DS . substr($logicalPathPsr4, $length))) { return $file; } } } } } return false; }
-
筆者也還沒能理解這樣寫好處在哪,猜想是註冊時,即新增數據時,添加索引,查找時,根據索引找效率會更高,(自動加載確實應該考慮效率)。但findFile時始終要遍歷去找,與P2相比,貌似並不能減少循環次數。
P4:完善自動加載機制
上面P2,P3基本沒問題了,只要給類定義好命名空間,不管類所在文件放在哪,只要將命名空間註冊,用前use下,就能用了。
但還能優化,從效率方面考慮。
如,當框架成型,每次請求運行,都必須要找自定義異常處理類,日誌類等,框架目錄結果基本是固定的,但現在的方式對這種命名空間與目錄確定的關係,還是要遍歷去找, 是否覺得這部分遍歷是多餘的?同理,當項目開發完,大部分文件只需小改,文件路徑不會大改,是否可以省去遍歷查找。
答案是肯定的:只需記錄完整命名空間 => 文件路徑。下面第一點就是相應處理。下面的第二點是可簡單理解爲設置默認目錄,如果都找不到,就去默認目錄裏找。第三點:我目前還沒用過,看tp5有就也加上去了。
下面是P4的所有優化:
1. 添加類名映射
-
在
/fool/Loader.php
-
添加
$classMap
變量:存儲完整命名空間 與 文件路徑 的映射關係,結構是:Array ( [TestClassMap] => /data/autoload/p4/classMap/TestClassMap.php )
-
添加public的註冊類名映射方法
addClassMap()
-
findFile()
時,優先通過$classMap
判斷。
private static $classMap = []; public static function addClassMap($class, $map = '') { if (is_array($class)) { self::$classMap = array_merge(self::$classMap, $class); } else { self::$classMap[$class] = $map; } } private static function findFile($class) { // 類庫映射 具體指定的,優先級最高 if (isset(self::$classMap[$class])) { return self::$classMap[$class]; } // 遍歷prefixDirsPsr4找... // 找不到記錄一下映射爲false, 並返回false return self::$classMap[$class] = false; }
-
-
在
/classMap/TestClassMap.php
,加<?php class TestClassMap { public function work() { echo "this is classMap class A function work <br /> \n"; } }
-
在
index.php
註冊類名映射,並運行之// 註冊類庫映射 Loader::addClassMap('TestClassMap', ROOT_PATH . 'classMap/TestClassMap.php'); $tcp = new TestClassMap(); $tcp->work();
// 輸出 this is classMap class A function work
2.添加回退目錄:即默認目錄
-
在
/fool/Loader.php
添加,$fallbackDirsPsr4
結構:Array ( [0] => /data/autoload/p4/extend )
private static $fallbackDirsPsr4 = []; public static function register() { // ... // 自動加載extend目錄 self::$fallbackDirsPsr4[] = rtrim(EXTEND, DS); } private static function findFile($class) { // 類庫映射 查找,優先級最高 // ... // 根據prefixDirsPsr4找 // ... // 指定目錄找不到 從PSR-4回退目錄(也可理解爲默認目錄)找 foreach (self::$fallbackDirsPsr4 as $dir) { if (is_file($file = $dir . DS . $logicalPathPsr4)) { return $file; } } // 找不到記錄一下映射爲false, 並返回false // ... }
-
添加
/extend/e1/A.php
,寫上<?php namespace e1; class A { public function work() { echo "this is extend e1 class A function work <br /> \n"; } }
-
在
/index.php
// new擴展目錄的類 use e1\A as eA; $e = new eA(); $e->work();
運行,輸出
this is extend e1 class A function work
3. 添加類別名
-
在
/fool/Loader.php
,添加$namespaceAlias
,結構:Array ( [model] => app\model )
private static $namespaceAlias = []; public static function addNamespaceAlias($namespace, $original = '') { if (is_array($namespace)) { self::$namespaceAlias = array_merge(self::$namespaceAlias, $namespace); } else { self::$namespaceAlias[$namespace] = $original; } } public static function autoload($class) { // 檢測命名空間別名,若匹配,則返回(並不加載)後面再進來findFile後加載 if (!empty(self::$namespaceAlias)) { $length = strpos($class, '\\'); $prefix = substr($class, 0, $length); if (isset(self::$namespaceAlias[$prefix])) { $original = self::$namespaceAlias[$prefix] . substr($class, $length); if (class_exists($original)) { return class_alias($original, $class, false); } } } if ($file = self::findFile($class)) { return require $file; } return false; }
-
在
/app/model/Goods.php
加<?php namespace app\model; class Goods { public function getList() { echo "this is model Goods function getList <br /> \n"; } }
-
在
/index.php
,加// 添加別名 Loader::addNamespaceAlias('model', 'app\model'); use model\Goods; // 使用了別名找類 $goods = new Goods(); $goods->getList();
運行之,輸出
this is model Goods function getList
P5
添加composer的自動加載處理
P6
添加PSR0規範的處理
總結
P4基本就能用了,添加composer處理和PSR-0處理以後有時間再寫了。簡單看了composer的Loader.php
,大概也是這回事。而PSR-0,感覺寫了相關處理也用不上。
額外寫一句:php的自動加載花了2天多來理解,寫demo並總結,以後看其它語言的自動加載,理解應該不難了。
通過Loader類的屬性來總結下吧,實在無力寫了。
重要的:
prefixDirsPsr4
classMap
次要的:
prefixLengthsPsr4
fallbackDirsPsr4
namespaceAlias