Composer 的 Autoload 原始碼實現——註冊與執行

leoyang發表於2017-05-11

  在開始之前,歡迎關注我自己的部落格:www.learnku.com/blog/leoyang
本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/lar...
  上一篇文章我們講到了Composer自動載入功能的啟動與初始化,經過啟動與初始化,自動載入核心類物件已經獲得了頂級名稱空間與相應目錄的對映,換句話說,如果有名稱空間'App\Console\Kernel,我們已經知道了App\對應的目錄,接下來我們就要解決下面的就是\Console\Kernel這一段。


  我們先回顧一下自動載入引導類:

     public static function getLoader()
     {
        /***************************經典單例模式********************/
        if (null !== self::$loader) {
            return self::$loader;
        }

        /***********************獲得自動載入核心類物件********************/
        spl_autoload_register(array('ComposerAutoloaderInit
        832ea71bfb9a4128da8660baedaac82e', 'loadClassLoader'), true, true);

        self::$loader = $loader = new \Composer\Autoload\ClassLoader();

        spl_autoload_unregister(array('ComposerAutoloaderInit
        832ea71bfb9a4128da8660baedaac82e', 'loadClassLoader'));

        /***********************初始化自動載入核心類物件********************/
        $useStaticLoader = PHP_VERSION_ID >= 50600 && 
        !defined('HHVM_VERSION');

        if ($useStaticLoader) {
            require_once __DIR__ . '/autoload_static.php';

            call_user_func(\Composer\Autoload\ComposerStaticInit
            832ea71bfb9a4128da8660baedaac82e::getInitializer($loader));

        } else {
            $map = require __DIR__ . '/autoload_namespaces.php';
            foreach ($map as $namespace => $path) {
                $loader->set($namespace, $path);
            }

            $map = require __DIR__ . '/autoload_psr4.php';
            foreach ($map as $namespace => $path) {
                $loader->setPsr4($namespace, $path);
            }

            $classMap = require __DIR__ . '/autoload_classmap.php';
            if ($classMap) {
                $loader->addClassMap($classMap);
            }
        }

        /***********************註冊自動載入核心類物件********************/
        $loader->register(true);

        /***********************自動載入全域性函式********************/
        if ($useStaticLoader) {
            $includeFiles = Composer\Autoload\ComposerStaticInit
            832ea71bfb9a4128da8660baedaac82e::$files;
        } else {
            $includeFiles = require __DIR__ . '/autoload_files.php';
        }

        foreach ($includeFiles as $fileIdentifier => $file) {
            composerRequire
            832ea71bfb9a4128da8660baedaac82e($fileIdentifier, $file);
        }

        return $loader;
    } 

現在我們開始引導類的第四部分:註冊自動載入核心類物件。我們來看看核心類的register()函式:

    public function register($prepend = false)
    {
        spl_autoload_register(array($this, 'loadClass'), true, $prepend);
    }

簡單到爆炸啊!一行程式碼實現自動載入有木有!其實奧祕都在自動載入核心類ClassLoader的loadClass()函式上,這個函式負責按照PSR標準將頂層名稱空間以下的內容轉為對應的目錄,也就是上面所說的將'App\Console\Kernel中'Console\Kernel這一段轉為目錄,至於怎麼轉的我們在下面“Composer自動載入原始碼分析——執行”講。核心類ClassLoader將loadClass()函式註冊到PHP SPL中的spl_autoload_register()裡面去,這個函式的來龍去脈我們之前文章講過。這樣,每當PHP遇到一個不認識的名稱空間的時候,PHP會自動呼叫註冊到spl_autoload_register裡面的函式堆疊,執行其中的每個函式,直到找到名稱空間對應的檔案。

  Composer不止可以自動載入名稱空間,還可以載入全域性函式。怎麼實現的呢?很簡單,把全域性函式寫到特定的檔案裡面去,在程式執行前挨個require就行了。這個就是composer自動載入的第五步,載入全域性函式。

      if ($useStaticLoader) {
            $includeFiles = Composer\Autoload\ComposerStaticInit832ea71bfb9a4128da8660baedaac82e::$files;
        } else {
            $includeFiles = require __DIR__ . '/autoload_files.php';
        }
        foreach ($includeFiles as $fileIdentifier => $file) {
            composerRequire832ea71bfb9a4128da8660baedaac82e($fileIdentifier, $file);
        }

跟核心類的初始化一樣,全域性函式自動載入也分為兩種:靜態初始化和普通初始化,靜態載入只支援PHP5.6以上並且不支援HHVM。

靜態初始化:

ComposerStaticInit832ea71bfb9a4128da8660baedaac82e::$files:

       public static $files = array (
        '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
        '667aeda72477189d0494fecd327c3641' => __DIR__ . '/..' . '/symfony/var-dumper/Resources/functions/dump.php',
        ...
    );

看到這裡我們可能又要有疑問了,為什麼不直接放檔案路徑名,還要一個hash幹什麼呢?這個我們一會兒講,我們這裡先了解一下這個陣列的結構。

普通初始化

autoload_files:

    $vendorDir = dirname(dirname(__FILE__));
    $baseDir = dirname($vendorDir);

    return array(
    '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
    '667aeda72477189d0494fecd327c3641' => $vendorDir . '/symfony/var-dumper/Resources/functions/dump.php',
       ....
    );

其實跟靜態初始化區別不大。

載入全域性函式

    class ComposerAutoloaderInit832ea71bfb9a4128da8660baedaac82e{
      public static function getLoader(){
          ...
          foreach ($includeFiles as $fileIdentifier => $file) {
            composerRequire832ea71bfb9a4128da8660baedaac82e($fileIdentifier, $file);
          }
          ...
      }
    }

    function composerRequire832ea71bfb9a4128da8660baedaac82e($fileIdentifier, $file)
     {
        if (empty(\$GLOBALS['__composer_autoload_files'][\$fileIdentifier])) {
            require $file;

            $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
        }
    }

  這一段很有講究,第一個問題:為什麼自動載入引導類的getLoader()函式不直接require \$includeFiles裡面的每個檔名,而要用類外面的函式composerRequire832ea71bfb9a4128da8660baedaac82e0?(順便說下這個函式名hash仍然為了避免和使用者定義函式衝突)因為怕有人在全域性函式所在的檔案寫\$this或者self
  假如\$includeFiles有個app/helper.php檔案,這個helper.php檔案的函式外有一行程式碼:\$this->foo(),如果引導類在getLoader()函式直接require(\$file),那麼引導類就會執行這句程式碼,呼叫自己的foo()函式,這顯然是錯的。事實上helper.php就不應該出現\$this或self這樣的程式碼,這樣寫一般都是使用者寫錯了的,一旦這樣的事情發生,第一種情況:引導類恰好有foo()函式,那麼就會莫名其妙執行了引導類的foo();第二種情況:引導類沒有foo()函式,但是卻甩出來引導類沒有foo()方法這樣的錯誤提示,使用者不知道自己哪裡錯了。把require語句放到引導類的外面,遇到$this或者self,程式就會告訴使用者根本沒有類,$this或self無效,錯誤資訊更加明朗。
  第二個問題,為什麼要用hash作為\$fileIdentifier,上面的程式碼明顯可以看出來這個變數是用來控制全域性函式只被require一次的,那為什麼不用require_once呢?事實上require_once比require效率低很多,使用全域性變數\$GLOBALS這樣控制載入會更快。還有一個原因我猜測應該是require_once對相對路徑的支援並不理想,所以composer儘量少用require_once。

  我們終於來到了核心的核心——composer自動載入的真相,名稱空間如何通過composer轉為對應目錄檔案的奧祕就在這一章。
  前面說過,ClassLoader的register()函式將loadClass()函式註冊到PHP的SPL函式堆疊中,每當PHP遇到不認識的名稱空間時就會呼叫函式堆疊的每個函式,直到載入名稱空間成功。所以loadClass()函式就是自動載入的關鍵
了。
loadClass():

    public function loadClass($class)
    {
        if ($file = $this->findFile($class)) {
            includeFile($file);

            return true;
        }
    }

    public function findFile($class)
    {
        // work around for PHP 5.3.0 - 5.3.2 https://bugs.php.net/50731
        if ('\\' == $class[0]) {
            $class = substr($class, 1);
        }

        // class map lookup
        if (isset($this->classMap[$class])) {
            return $this->classMap[$class];
        }
        if ($this->classMapAuthoritative) {
            return false;
        }

        $file = $this->findFileWithExtension($class, '.php');

        // Search for Hack files if we are running on HHVM
        if ($file === null && defined('HHVM_VERSION')) {
            $file = $this->findFileWithExtension($class, '.hh');
        }

        if ($file === null) {
            // Remember that this class does not exist.
            return $this->classMap[$class] = false;
        }

        return $file;
    }

  我們看到loadClass(),主要呼叫findFile()函式。findFile()在解析名稱空間的時候主要分為兩部分:classMap和findFileWithExtension()函式。classMap很簡單,直接看名稱空間是否在對映陣列中即可。麻煩的是findFileWithExtension()函式,這個函式包含了PSR0和PSR4標準的實現。還有個值得我們注意的是查詢路徑成功後includeFile()仍然類外面的函式,並不是ClassLoader的成員函式,原理跟上面一樣,防止有使用者寫$this或self。還有就是如果名稱空間是以\開頭的,要去掉\然後再匹配。
findFileWithExtension:

         private function findFileWithExtension($class, $ext)
    {
        // PSR-4 lookup
        $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;

        $first = $class[0];
        if (isset($this->prefixLengthsPsr4[$first])) {
            foreach ($this->prefixLengthsPsr4[$first] as $prefix => $length) {
                if (0 === strpos($class, $prefix)) {
                    foreach ($this->prefixDirsPsr4[$prefix] as $dir) {
                        if (file_exists($file = $dir . DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $length))) {
                            return $file;
                        }
                    }
                }
            }
        }

        // PSR-4 fallback dirs
        foreach ($this->fallbackDirsPsr4 as $dir) {
            if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
                return $file;
            }
        }

        // PSR-0 lookup
        if (false !== $pos = strrpos($class, '\\')) {
            // namespaced class name
            $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
                . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
        } else {
            // PEAR-like class name
            $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
        }

        if (isset($this->prefixesPsr0[$first])) {
            foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
                if (0 === strpos($class, $prefix)) {
                    foreach ($dirs as $dir) {
                        if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
                            return $file;
                        }
                    }
                }
            }
        }

        // PSR-0 fallback dirs
        foreach ($this->fallbackDirsPsr0 as $dir) {
            if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
                return $file;
            }
        }

        // PSR-0 include paths.
        if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
            return $file;
        }
    }

  下面我們通過舉例來說下上面程式碼的流程:
  如果我們在程式碼中寫下'phpDocumentor\Reflection\example',PHP會通過SPL呼叫loadClass->findFile->findFileWithExtension。首先預設用php作為檔案字尾名呼叫findFileWithExtension函式裡,利用PSR4標準嘗試解析目錄檔案,如果檔案不存在則繼續用PSR0標準解析,如果解析出來的目錄檔案仍然不存在,但是環境是HHVM虛擬機器,繼續用字尾名為hh再次呼叫findFileWithExtension函式,如果不存在,說明此名稱空間無法載入,放到classMap中設為false,以便以後更快地載入。
  對於phpDocumentor\Reflection\example,當嘗試利用PSR4標準對映目錄時,步驟如下:

PSR4標準載入

  • 將\轉為檔案分隔符/,加上字尾php或hh,得到\$logicalPathPsr4即phpDocumentor//Reflection//example.php(hh);
  • 利用名稱空間第一個字母p作為字首索引搜尋prefixLengthsPsr4陣列,查到下面這個陣列:
        p' => 
            array (
                'phpDocumentor\\Reflection\\' => 25,
                'phpDocumentor\\Fake\\' => 19,
          )
  • 遍歷這個陣列,得到兩個頂層名稱空間phpDocumentor\Reflection\和phpDocumentor\Fake\
  • 用這兩個頂層名稱空間與phpDocumentor\Reflection\example_e相比較,可以得到phpDocumentor\Reflection\這個頂層名稱空間
  • 在prefixLengthsPsr4對映陣列中得到phpDocumentor\Reflection\長度為25。
  • 在prefixDirsPsr4對映陣列中得到phpDocumentor\Reflection\的目錄對映為:
    'phpDocumentor\\Reflection\\' => 
        array (
            0 => __DIR__ . '/..' . '/phpdocumentor/reflection-common/src',
            1 => __DIR__ . '/..' . '/phpdocumentor/type-resolver/src',
            2 => __DIR__ . '/..' . '/phpdocumentor/reflection-docblock/src',
        ),
  • 遍歷這個對映陣列,得到三個目錄對映;
  • 檢視 “目錄+檔案分隔符//+substr(\$logicalPathPsr4, \$length)”檔案是否存在,存在即返回。這裡就是'_DIR_/../phpdocumentor/reflection-common/src + /+ substr(phpDocumentor/Reflection/example_e.php(hh),25)'
  • 如果失敗,則利用fallbackDirsPsr4陣列裡面的目錄繼續判斷是否存在檔案,具體方法是“目錄+檔案分隔符//+\$logicalPathPsr4”

PSR0標準載入

如果PSR4標準載入失敗,則要進行PSR0標準載入:

  • 找到phpDocumentor\Reflection\examplee最後“\”的位置,將其後面檔名中’‘’‘字元轉為檔案分隔符“/”,得到$logicalPathPsr0即phpDocumentor/Reflection/example/e.php(hh)
    利用名稱空間第一個字母p作為字首索引搜尋prefixLengthsPsr4陣列,查到下面這個陣列:
    'P' => 
        array (
            'Prophecy\\' => 
            array (
                0 => __DIR__ . '/..' . '/phpspec/prophecy/src',
            ),
            'phpDocumentor' => 
            array (
                0 => __DIR__ . '/..' . '/erusev/parsedown',
            ),
        ),
  • 遍歷這個陣列,得到兩個頂層名稱空間phpDocumentor和Prophecy
  • 用這兩個頂層名稱空間與phpDocumentor\Reflection\example_e相比較,可以得到phpDocumentor這個頂層名稱空間
  • 在對映陣列中得到phpDocumentor目錄對映為'_DIR_ . '/..' . '/erusev/parsedown'
  • 檢視 “目錄+檔案分隔符//+\$logicalPathPsr0”檔案是否存在,存在即返回。這裡就是
    “_DIR_ . '/..' . '/erusev/parsedown + //+ phpDocumentor//Reflection//example/e.php(hh)”
  • 如果失敗,則利用fallbackDirsPsr0陣列裡面的目錄繼續判斷是否存在檔案,具體方法是“目錄+檔案分隔符//+\$logicalPathPsr0”
  • 如果仍然找不到,則利用stream_resolve_include_path(),在當前include目錄尋找該檔案,如果找到返回絕對路徑。

  經過三篇文章,終於寫完了PHP Composer自動載入的原理與實現,結下來我們開始講解laravel框架下的門面Facade,這個門面功能和自動載入有著一些聯絡.

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章