Composer 與 Autoload

Composer 與 Autoload

相信大家都有在用 Composer,但許多初學者只知道 Composer 可以安裝套件,卻不知道為什麼需要用 Composer、以及 Composer 解決了什麼問題。在這篇文章中,我們將會討論

  1. Package Manager 是做什麼的?
  2. PHP 中的 Autoload 是什麼?

什麼是 Package Manager?

Package Manager (套件管理員),從名字上來看,他是用來安裝套件的。大家熟悉的 Package Manager 除了 Composer 外,還有 NPM (node.js)、Homebrew (macOS)、APT (Debian)、DNF (RHEL) 等常見的 Package Manager。

除了安裝你指定的套件外,這些 Package Manager 還有個重要的功能,就是會幫你處理 Dependency (相依性)。所謂的 Dependency,就是指這個套件所依賴的其他套件。舉例來說,如果我們在一個空目錄下執行 composer require guzzlehttp/guzzle,會發現不只安裝了 guzzle,還一併安裝了其他許多套件:

composer require guzzlehttp/guzzle 的畫面攝影

這些安裝的套件就是 guzzle 的「Dependency」,我們可以打開 guzzlehttp/guzzlecomposer.json 看看其中 require 的定義:

{
    "require": {
        "php": "^7.2.5 || ^8.0",
        "ext-json": "*",
        "guzzlehttp/promises": "^1.4",
        "guzzlehttp/psr7": "^1.7 || ^2.0",
        "psr/http-client": "^1.0"
    }
}

眼尖的朋友馬上就會發現, composer.json 中並沒有定義 ralouphie/getallheaders,但是剛才下 composer require 的時候卻也安裝了這個套件!

這時我們可以用到 composer 提供的一個方便的功能 —— composer why —— 來看看為什麼會安裝這個套件:

» composer why ralouphie/getallheaders
guzzlehttp/psr7  1.8.1  requires  ralouphie/getallheaders (^2.0.5 || ^3.0.0) 

讓我們來看看這個輸出。這裡寫的意思是, guzzlehttp/psr7 這個套件目前安裝的是 1.8.1 版,而這個套件的 Dependency (也就是定義在它的 composer.json 內的 require) 包含了 ralouphie/getallheaders ,而且規定版本必須是 ^2.0.5^3.0.0

可以發現,每個套件都有自己的 Dependency,而這些 Dependency 又有各自的 Dependency…… 這些 Package Manager 的重要職責就是幫我們處理好這些相依性的關係並進行安裝。
假設我們不用 Composer 的話,這些 Dependency 就都得要自己處理。而使用到不同的套件時,還可能會產生衝突 (Conflict),例如有兩個套件都有 require 到同一個 Dependency,但需要的版本卻不同。由於 Composer 還會分析所有相依性的關係,因此遇到版本衝突的時候就可以即時發現。

為什麼要 Autoload?

我們剛才已經下載了 Guzzle 了,接下來該怎麼使用呢?

剛學會 PHP 的朋友都知道,在 PHP 中若要引入另一個檔案,可以使用 requireinclude。我們剛才用 Composer 下載好了 Guzzle,如果要開始使用 GuzzleHttp\Client,會發現這個類別定義在 vendor/guzzlehttp/guzzle/src/Client.php 內。因此我們來將這個檔案引入:

<?php

require 'vendor/guzzlehttp/guzzle/src/Client.php';

$client = new \GuzzleHttp\Client();
$res = $client->request('GET', 'https://cornch.dev/');

var_dump($res->getBody());

但執行這個 PHP 程式之後就會遇到一個 Fatal Error:

Fatal error: Uncaught Error: Interface "GuzzleHttp\ClientInterface" not found in /private/tmp/an-empty-folder/vendor/guzzlehttp/guzzle/src/Client.php on line 17

Error: Interface "GuzzleHttp\ClientInterface" not found in /private/tmp/an-empty-folder/vendor/guzzlehttp/guzzle/src/Client.php on line 17

Call Stack:
    0.0081     396456   1. {main}() /private/tmp/an-empty-folder/index.php:0
    0.0110     458608   2. require('/private/tmp/an-empty-folder/vendor/guzzlehttp/guzzle/src/Client.php') /private/tmp/an-empty-folder/index.php:3

找不到 GuzzleHttp\ClientInterface !搜尋原始碼之後發現這個檔案在 vendor/guzzlehttp/guzzle/src/ClientInterface.php,因此我們也一併將其引入:

<?php

require 'vendor/guzzlehttp/guzzle/src/ClientInterface.php';
require 'vendor/guzzlehttp/guzzle/src/Client.php';

$client = new \GuzzleHttp\Client();
$res = $client->request('GET', 'https://cornch.dev/');

var_dump($res->getBody());

再次執行程式,錯誤訊息變得不太一樣了:

Fatal error: Uncaught Error: Interface "Psr\Http\Client\ClientInterface" not found in /private/tmp/an-empty-folder/vendor/guzzlehttp/guzzle/src/Client.php on line 17

Error: Interface "Psr\Http\Client\ClientInterface" not found in /private/tmp/an-empty-folder/vendor/guzzlehttp/guzzle/src/Client.php on line 17

Call Stack:
    0.0029     396536   1. {main}() /private/tmp/an-empty-folder/index.php:0
    0.0043     463808   2. require('/private/tmp/an-empty-folder/vendor/guzzlehttp/guzzle/src/Client.php') /private/tmp/an-empty-folder/index.php:4

……

在重複了上面這幾個步驟無數次之後,我們的 PHP 檔案變成了下面這樣:

<?php

require 'vendor/guzzlehttp/promises/src/Is.php';
require 'vendor/guzzlehttp/promises/src/PromiseInterface.php';
require 'vendor/guzzlehttp/promises/src/Promise.php';
require 'vendor/guzzlehttp/promises/src/TaskQueueInterface.php';
require 'vendor/guzzlehttp/promises/src/TaskQueue.php';
require 'vendor/guzzlehttp/promises/src/Utils.php';
require 'vendor/guzzlehttp/promises/src/FulfilledPromise.php';
require 'vendor/guzzlehttp/psr7/src/MessageTrait.php';
require 'vendor/psr/http-message/src/MessageInterface.php';
require 'vendor/psr/http-message/src/ResponseInterface.php';
require 'vendor/guzzlehttp/psr7/src/Response.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/EasyHandle.php';
require 'vendor/guzzlehttp/guzzle/src/PrepareBodyMiddleware.php';
require 'vendor/guzzlehttp/promises/src/Create.php';
require 'vendor/psr/http-message/src/StreamInterface.php';
require 'vendor/guzzlehttp/psr7/src/Stream.php';
require 'vendor/psr/http-message/src/RequestInterface.php';
require 'vendor/guzzlehttp/psr7/src/Request.php';
require 'vendor/psr/http-message/src/UriInterface.php';
require 'vendor/guzzlehttp/psr7/src/Uri.php';
require 'vendor/guzzlehttp/psr7/src/Utils.php';
require 'vendor/guzzlehttp/guzzle/src/RequestOptions.php';
require 'vendor/guzzlehttp/guzzle/src/RedirectMiddleware.php';
require 'vendor/guzzlehttp/guzzle/src/Middleware.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/StreamHandler.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/CurlHandler.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/CurlFactoryInterface.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php';
require 'vendor/guzzlehttp/guzzle/src/Handler/Proxy.php';
require 'vendor/guzzlehttp/guzzle/src/Utils.php';
require 'vendor/guzzlehttp/guzzle/src/HandlerStack.php';
require 'vendor/guzzlehttp/guzzle/src/ClientTrait.php';
require 'vendor/psr/http-client/src/ClientInterface.php';
require 'vendor/guzzlehttp/guzzle/src/ClientInterface.php';
require 'vendor/guzzlehttp/guzzle/src/Client.php';

$client = new \GuzzleHttp\Client();
$res = $client->request('GET', 'https://cornch.dev/');

var_dump($res->getBody());

能正常執行並取得輸出:

/private/tmp/an-empty-folder/index.php:44:
class GuzzleHttp\Psr7\Stream#23 (7) {
  private $stream =>
  resource(44) of type (stream)
  private $size =>
  NULL
  private $seekable =>
  bool(true)
  private $readable =>
  bool(true)
  private $writable =>
  bool(true)
  private $uri =>
  string(10) "php://temp"
  private $customMetadata =>
  array(0) {
  }
}

或許有人已經發現,這些檔案名稱跟類別的 namespace 好像有對應關係,例如 GuzzleHttp\Client 這個類別對應的檔案就是 vendor/guzzlehttp/guzzle/src/Client.php。既然我們每次做的事情就只是把錯誤訊息上提示找不到的類別加上對應的 require 語句,那這段過程是否能自動化呢?

答案是,可以!

PHP 中提供了一個 spl_autoload_register() 函式,我們可以通過這個函式來設定當 PHP 遇到不認識的函式時要做什麼事。這個過程就稱為 Autoload。

我們馬上來依照 PHP 手冊實作一下:

<?php

spl_autoload_register(function ($class) {
    // 先把 namespace 替換成對應的資料夾
    $filename = str_replace(
        [
            'GuzzleHttp\\Promise\\',
            'GuzzleHttp\\Psr7\\',
            'GuzzleHttp\\',
            'Psr\\Http\\Message\\',
            'Psr\\Http\\Client\\',
        ],
        [
            'vendor/guzzlehttp/promises/src/',
            'vendor/guzzlehttp/psr7/src/',
            'vendor/guzzlehttp/guzzle/src/',
            'vendor/psr/http-message/src/',
            'vendor/psr/http-client/src/',
        ],
        $class
    );

    // 將反斜線轉成斜線,並加上副檔名
    $filename = str_replace('\\', '/', $filename) . '.php';

    // require!
    require $filename;
});

$client = new \GuzzleHttp\Client();
$res = $client->request('GET', 'https://cornch.dev/');

var_dump($res->getBody());

試著執行一下程式,一切正常!

其實,Composer 已經幫我們實作好這個 Autoload 了,放在 vendor/autoload.php 內。因此其實我們只需要 require 這個檔案即可。
我們可以將程式修改為下面這樣:

<?php

require 'vendor/autoload.php';
  
$client = new \GuzzleHttp\Client();
$res = $client->request('GET', 'https://cornch.dev/');

var_dump($res->getBody());

Composer 的 Autoload

我們在剛才的操作過程中已經體會到,如果沒有了 autoload,要嘛就必須將所有類別寫在同一個檔案內,要嘛我們每次要用到其他類別時,就得自己手動 require 他。
雖然 Autoload 這麼方便,但是,請記得, autoload 的邏輯是可以自行實作的。每個人實作出來的邏輯可能不太一樣,有的人想放在 src/ 資料夾內,其他人則可能想放在 lib/ 內。

讓我們先回來看看 Guzzle 的 composer.json,裡面有一段 "autoload"

{
    "autoload": {
        "psr-4": {
            "GuzzleHttp\\": "src/"
        },
        "files": [
            "src/functions_include.php"
        ]
    }
}

其中的 psr-4 看起來跟我們剛才自己實作的 Autoload 有點像,而 PSR-4 是這種檔名與 Namespace、Class 名稱對應關係的一套標準,有興趣的人可以閱讀一下 PSR-4 的定義

有了這段定義,Composer 就能根據其中設定的 Namespace ➡️ 資料夾的關係來處理 Autoload 邏輯。
打開 vendor/composer/autoload_psr4.php 就能看到這段程式碼:

<?php

// autoload_psr4.php @generated by Composer

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

return array(
    'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-message/src'),
    'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
    'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'),
    'GuzzleHttp\\Promise\\' => array($vendorDir . '/guzzlehttp/promises/src'),
    'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'),
);

這段資料就是從各個套件中的 composer.json 中的設定彙整而來的。

除了 PSR-4 是最常用的標準外,Composer 支援的其他標準還有 PSR-0 (已由 PSR-4 取代)、Classmap 以及 File。有關這些標準是怎麼對應到實際檔名的說明,請參考 Composer 官網的說明文件

總結

我們今天學到了

  1. Package Manager 在幫我們安裝套件的同時,還會處理他們的 Dependency。
  2. 通過 spl_autoload_register(),可以告訴 PHP 當找不到類別時要怎麼處理。
  3. Composer 幫我們實作好了 Autoload。

補充

有些人可能會誤以為 PHP 中的「use」statement 就是用來引入檔案的,但其實並不是。我們在這篇文章中已經說明了,Autoload 是在 PHP 發現找不到類別的時候才會執行的,而 use statement 實際上只是類似宣告別名的功能而已, 在實際要用到該類別時,才會載入該檔案。 因此,在 PHP 中有多餘的 use 實際上不但不會對效能造成影響,甚至還能 use 不存在的類別呢!