php自動加載機制從0到優化

前言

本文是《自制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文件的類,只需
  1. 在B文件聲明命名空間
  2. 在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類
  1. 首先在入口文件/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();
    
  2. 然後在/fool/Loader.php寫具體的加載處理:

    1. 注意一點:autoload($class)的參數$class是自動傳入的,其值就是想訪問但找不到的類的完整類名(即包含命名空間部分的)。打印一下就知道了~
    2. 核心是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;
        }   
    }
    
  3. 測試:

    1. /app/controller/User.php聲明User類,當然是要聲明命名空間的。然後通過use方式,訪問定義在app/model/User.phpUser類。
    <?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();
        }   
    }
    
    1. /app/model/User.php
    <?php
    
    namespace app\model;
    
    class User 
    {
        public function getList() 
        {   
            echo "this is model User function getList <br /> \n";
        }   
    }
    
    1. 在入口文件/index.php訪問
    // new與目錄對應的命名空間,成功
    use app\controller\User;
    
    $user = new User();
    $user->index();
    
    1. 運行php index.php,或通過web方式訪問index.php。看到打印如下:表明訪問成功
    this is controller User function index 
    this is model User function getList 
    
  4. 似乎,自動加載就這麼簡單的實現了,上面寫的最終實現效果也實現了。但,你也許有2個疑惑:

    1. 寫了一堆代碼,與最原始方式:用常量定義根目錄,訪問寫在其它文件的類前require ROOT_PATH . 文件路徑,再訪問。似乎區別不大啊,並沒簡化多少工作量,訪問前還是要use,甚至多出一步,在類頭聲明命名空間,這樣做的意義在哪?

      在類頭聲明命名空間是爲了避免類重名,即使通過原始方式,也應該要在類頭聲明命名空間,訪問前也要use。

      即原始方式實際也是:require -> use -> 訪問

      而自動加載是:use -> 訪問

      use 是必需的,否則,程序怎麼可能知道你要訪問的是哪個類。

    2. 嗯,自動加載的好處我體會到了(省去了require步驟),那麼,爲什麼那些框架的自動加載處理代碼有那麼長呢,爲了解決什麼問題?tp5.0自動加載源碼

  5. 其中一個要解決的問題是,也就是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";
        }   
    }
    
  6. 訪問試試,在/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即可,即需要加多兩個步驟:

  1. 註冊命名空間:即將pack1/vendor/pack1的映射關係存到變量裏。
  2. 找文件時:傳入命名空間 + 映射關係 -> 文件路徑

上面的代碼不變,改的只有/fool/Loader.php

  1. 註冊命名空間

    1. 添加私有的$prefixDirsPsr4變量,存命名空間與目錄的映射關係。
    2. 添加私有的addPsr4()方法,將映射關係存儲到$prefixDirsPsr4變量。
    3. 添加public的addNamespace()方法, 目的有
      1. 提供對外註冊命名空間接口
      2. 使註冊命名空間傳參方便些。
       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);
            }
        }
    
  2. 註冊框架必須的命名空間:

        public static function register()
        {
            spl_autoload_register("fool\\Loader::autoload", true, true);
        
            // 添加命名空間 對應目錄
            self::addNamespace([
                'app' => APP_PATH,
                'fool' => FOOL_PATH,
            ]);
        }   
    
  3. 修改找文件方式:思路是識別出傳入命名空間裏首次出現的/的索引,將前面替換成對應文件路徑,再拼接後面。

        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;
        }   
    
  4. 與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 
    
  5. 註冊額外的命名空間,在/index.php。這就是爲什麼addNamespace()方法了要設爲public。

    // 設置命名空間pack1 對應 目錄 vendow/pack1
    \fool\Loader::addNamespace('pack1', ROOT_PATH . 'vendor/pack1'. DS);
    
  6. 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那套。

  1. 用了官方的規範:對於映射關係中命名空間結尾是否要加\,目錄結尾是否要加/,想必很亂,按tp5的來,這裏統一:

    1. 命名空間結尾加\:如fool\app\
    2. 目錄結尾不加/:如/data/autoload/p3/fool
  2. 其它代碼不變,修改/fool/Loader.php

    1. 添加$prefixLengthsPsr4:變量的結構是,按註冊的命名空間的首字母劃分,存了其長度

      Array
      (
          [a] => Array
              (
                  [app\] => 4
                  [aaaaa\] => 6
              )
          [f] => Array
              (
                  [fool\] => 5
              )
      )
      
    2. 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. 添加類名映射

  1. /fool/Loader.php

    1. 添加$classMap變量:存儲完整命名空間 與 文件路徑 的映射關係,結構是:

      Array
      (
          [TestClassMap] => /data/autoload/p4/classMap/TestClassMap.php
      )
      
    2. 添加public的註冊類名映射方法addClassMap()

    3. 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;
        }
    
  2. /classMap/TestClassMap.php,加

    <?php
    
    class TestClassMap
    {
        public function work()
        {   
            echo "this is classMap class A function work <br /> \n";
        }   
    }
    
  3. index.php註冊類名映射,並運行之

    // 註冊類庫映射
    Loader::addClassMap('TestClassMap', ROOT_PATH . 'classMap/TestClassMap.php');
    
    $tcp = new TestClassMap();
    $tcp->work();
    
    // 輸出
    this is classMap class A function work 
    

2.添加回退目錄:即默認目錄

  1. /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
            // ...
        }
    
  2. 添加/extend/e1/A.php,寫上

    <?php
    
    namespace e1; 
    
    class A 
    {
        public function work() 
        {   
            echo "this is extend e1 class A function work <br /> \n";
        }   
    }
    
  3. /index.php

    // new擴展目錄的類
    use e1\A as eA; 
    $e = new eA();
    $e->work();
    

    運行,輸出

    this is extend e1 class A function work
    

3. 添加類別名

  1. /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;
        }
    
  2. /app/model/Goods.php

    <?php
    
    namespace app\model;
    
    class Goods
    {
        public function getList() 
        {   
            echo "this is model Goods function getList <br /> \n";
        }   
    }
    
  3. /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類的屬性來總結下吧,實在無力寫了。

重要的:

  1. prefixDirsPsr4
  2. classMap

次要的:

  1. prefixLengthsPsr4
  2. fallbackDirsPsr4
  3. namespaceAlias

github源碼~

相關鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章