[COMPOSER] PHP Autoloading, PSR4 and Composer

https://viblo.asia/p/php-autoloading-psr4-and-composer-V3m5Wy0QZO7

Giới thiệu

Rất nhiều PHP developer hiện nay và kể cả phần lớn các framework đều chọn cách viết code theo mô hình lập trình hướng đối tượng. Một trong những best practice, convention khi viết OOP đó là mỗi class sẽ được viết riêng vào 1 file. Nhưng có một vấn đề đối với practice này đó là chúng ta phải include/require cả list dài các file class trước khi sử dụng chúng.

Một ví dụ đơn giản, thay vì viết:

<?php
include __DIR__ . '/classes/MyClass.php';
include __DIR__ . '/classes/Foo.php';
include __DIR__ . '/classes/Bar.php';
// ...

$obj = new MyClass;
$foo = new Foo;
$bar = new Bar;
// ...

Thì chúng ta sẽ dùng autoloading:

<?php
spl_autoload_register(function ($classname) {
    // Hey, PHP! Nếu class mà chưa được khai báo trong file này thì tìm nó trong folder classes nhé!!!
    include __DIR__ . '/classes/' . $classname . '.php';
});

$myClass = new MyClass;
$foo = new Foo;
$bar = new Bar;

Các class sẽ tự động được load thay vì phải include/require từng file.

__autoload vs spl_autoload_register

Để thực hiện autoloading, chúng ta phải hướng dẫn cho PHP cách load các file class đó, vì có nhiều quy tắt đặt tên, cách tổ chức file, folder mà, mỗi nguời mỗi ý, không nói ra thì làm sao biết??

Từ thưở mới bắt đầu, PHP 5.0, người ta sử dụng magic function __autoload() để thực hiện ra công văn chỉ đạo cho PHP thực hiện autoload. Tuy nhiên, do có nhiều hạn chế nên bắt đầu từ PHP 5.1 thì PHP đưa ra thêm function spl_autoload_register(), khuyên dùng hàm này thay cho __autoload() và cho đến phiên bản PHP 7.2.0 thì function __autoload() đã bị DEPRECATED, sẽ bị xóa trong tương lai gần.

__autoload

Cụ thể __autoload() là một magic function, muốn sử dụng nó bạn định nghĩa nó, tức là bạn phải viết 1 function có tên là __autoload, nhận tham số là tên class muốn load.

void __autoload (string $class)

VD, convention của project là viết các file class vào thư mục src/classes/, chúng ta sẽ thực hiện autoload như sau:

  • File src/classes/Foo.php:

    <?php
    class Foo 
    {
        public function __construct() 
        {
            echo 'I am Foo!';
        }
    }
  • File src/classes/Bar.php:

    <?php
    class Bar 
    {
        public function __construct() 
        {
            echo 'I am Bar!';
        }
    }
  • File src/index.php:

    <?php
    function __autoload($classname) 
    {
        $filename = __DIR__ . '/classes/' . $classname . '.php';
        include $filename;
    }
    
    $foo = new Foo;
    $bar = new Bar;

Với cách này, bạn chỉ có thể có định nghĩa 1 function __autoload(), vì thế nếu bạn sử dụng một thư viện nào đó cũng sử dụng function này thì sẽ gây conflict và sẽ có lỗi do định nghĩa 2 hàm trùng tên.

spl_autoload_register

bool spl_autoload_register ([callable $autoload_function [, bool $throw = TRUE [, bool $prepend = FALSE ]]])

Khác với __autoload() thì spl_autoload_register() là một function thông thường, muốn sử dụng nó thì bạn gọi đến nó chứ không phải định nghĩa nó, và tất nhiên là chúng ta có thể gọi nó nhiều lần.

Như bạn thấy, function này sẽ nhận tham số là một callback function, callback này có nhiệm vụ giống như __autoload(). Nếu có nhiều hàm callback autoload, PHP sẽ tạo 1 queue và thực hiện lần lượt theo thứ tự hàm callback được định nghĩa trong lời gọi hàm spl_autoload_register() cho đến khi nó tìm được class, và nếu sau khi chạy qua tất cả autoload mà không tìm thấy class thì sẽ có exception class not found.

VD thay thế cho __autoload():

<?php
/*function __autoload($classname) 
{
    $filename = __DIR__ . '/classes/' . $classname . '.php';
    include $filename;
}*/

spl_autoload_register(function ($classname)  {
    $filename = __DIR__ . '/classes/' . $classname . '.php';
    include $filename;
});

$foo = new Foo;
$bar = new Bar;

VD nhiều autoload callback, load class trong 2 thư mục includesclasses:

src
├── classes
│   └── MyClass.php
├── includes
├── index.php
<?php
$autoloadIncludes = function ($classname) {
    $filename = __DIR__ . '/includes/' . $classname . '.php';
    echo 'Try to load ' . $filename . PHP_EOL;
    if (file_exists($filename)) {
        include $filename;
    }
};

$autoloadClasses = function ($classname) {
    $filename = __DIR__ . '/classes/' . $classname . '.php';
    echo 'Try to load ' . $filename . PHP_EOL;
    if (file_exists($filename)) {
        include $filename;
    }
};

spl_autoload_register($autoloadIncludes);
spl_autoload_register($autoloadClasses);

$myClass = new MyClass;

Output khi chạy:

Try to load /tmp/autoload/src/includes/MyClass.php
Try to load /tmp/autoload/src/classes/MyClass.php
I am MyClass

Nếu thay đổi thứ tự autoload callback:

<?php
// ...
spl_autoload_register($autoloadClasses);
spl_autoload_register($autoloadIncludes);

$myClass = new MyClass;

Output thế nào chắc các bạn cũng biết được.

Trường hợp class không tồn tại:

<?php
// ...
spl_autoload_register($autoloadClasses);
spl_autoload_register($autoloadIncludes);

$notExistClass = new NotExistClass;

=>

Try to load /tmp/autoload/src/classes/NotExistClass.php
Try to load /tmp/autoload/src/includes/NotExistClass.php
PHP Fatal error:  Uncaught Error: Class 'NotExistClass' not found in /tmp/autoload/src/spl_autoload.php:22
Stack trace:
#0 {main}
  thrown in /tmp/autoload/src/spl_autoload.php on line 22

PSR-4

Việc mỗi người, mỗi dự án có một cách thức thực hiện autoloading làm cho việc chia sẻ dùng lại code giữa các framework, thư viện trở nên phức tạp. Do đó, chuẩn PSR-4 Autoloader được tạo ra để thống nhất một quy tắc trong việc thực hiện autoloading. Các framework như Laravel, Symfony, Phalcon... đều dùng chuẩn này (tham khảo file composer.json). Tiêu chuẩn này mô tả các quy tắc tổ chức namespace trong class, cũng như cách tổ chức tên file, folder của class tương ứng. Trước chuẩn này còn có chuẩn PSR-0, tuy nhiên nó đã lỗi thời, các bạn có thể tự tham khảo và so sánh với chuẩn PSR-4.

Nội dung chính của chuẩn PSR-4 đó là: Quy tắc tổ chức các thư mục code sao cho mọi class (bao gồm class, interface, trait) đều có thể được tham chiếu đến bằng cách viết mã như sau:

\<NamespaceName>(\<SubNamespaceNames>)*\<ClassName>
  • NamespaceName: Tiền tố đầu tiên bắt buộc phải có - có thể hiểu là tên vendor.

  • SubNamespaceNames: Các namespace con (theo sau NamespaceName đầu tiên). Có thể một hoặc nhiều.

    Các namespace liền kề nhau ở đầu có thể kết hợp tạo thành namespace prefix và tương ứng với ít nhất một base directory. Nhưng bắt đầu từ SubNamespace sau namespace prefix thì nó phải tương ứng với một thư mục con bên trong base directory, tên thư mục phải trùng với tên SubNamespace.

  • ClassName: Bắt buộc phải có tên file trùng với tên lớp ClassName, nằm trong thư mục con tương ứng với namespace cuối cùng.

Ví dụ về cách tổ chức:

Tham chiếu đến class

Namespace prefix

Base directory

Class File Path

\Acme\Log\Writer\File_Writer

Acme\Log\Writer

./src/lib/

=>

./src/lib/File_Writer.php

\Symfony\Core\Request

Symfony\Core

./vendor/Symfony/Core/

=>

./vendor/Symfony/Core/Request.php

\App\Http\Web\HomeController

App

./src/

=>

./src/Http/Web/HomeController.php

\App\Utility\Class_Name

App

./src/

=>

./src/Utility/Class_Name.php

Sự khác biệt chủ yếu của PSR-0 so với PSR-4 đó là, PSR-0 không có khái niệm namespace prefix nên cấu trúc namespace sẽ tương ứng với cấu trúc thư mục chứa class. Ngoài ra PSR-0 còn sử dụng thêm dấu gạch dưới _ trong ClassName để thể hiện các thư mục bổ sung chứa class theo sau các tên namespace. Một Lưu ý nữa đó là, dấu _ chỉ có ý nghĩa đặc biệt trong tên class, còn trong tên của namespace thì không.

VD về cách tổ chức class theo PSR-0:

Tham chiếu đến class

Code directory

Class File Path

\Acme\Log\Writer\File_Writer

./src/lib/

=>

./src/lib/Acme/Log/Writer/File/Writer.php

\Symfony\Core\Request

./vendor/

=>

./vendor/Symfony/Core/Request.php

\App\Http\Web\HomeController

./src/

=>

./src/App/Http/Web/HomeController.php

\App\Utility\Class_Name

./src/

=>

./src/App/Utility/Class/Name.php

\App\Active_Record\Class_Name

./src/

=>

./src/App/Active_Record/Class/Name.php

Ngoài ra còn một yêu cầu đối với autoloader (autoload function, callback) đó là: Autoloader không được ném ra bất cứ exception nào, không được gây ra bất cứ lỗi hay warning nào, và không nên trả về giá trị.

Đó là cách thống nhất viết bố trí code PHP trên các thư mục và theo các namespace. Khi đã viết code tuân thủ theo hướng dẫn này thì các framework khác nhau đều sử dụng một cơ chế tự động nạp tương tự nhau nên việc chia sẻ, tích hợp là rất linh hoạt.

Ví dụ implement một autoloader (source):

<?php
/**
 *
 * Sau khi đăng ký autoloader, thì dòng sau đây sẽ như một chỉ dẫn cho function
 * load class \Foo\Bar\Baz\Qux từ đường dẫn /path/to/project/src/Baz/Qux.php:
 *
 *      new \Foo\Bar\Baz\Qux;
 *
 * @param string $class The fully-qualified class name.
 * @return void
 */
spl_autoload_register(function ($class) {

    // Namespace prefix
    $prefix = 'Foo\\Bar\\';

    // Base directory tương ứng cho namespace prefix, thư mục src
    $base_dir = __DIR__ . '/src/';

    // Tên Class đầy đủ có chứa Namespace prefix không?
    $len = strlen($prefix);
    if (strncmp($prefix, $class, $len) !== 0) {
        // Không => Không thuộc chuẩn PSR-4 => autoloader này sẽ bị bỏ qua
        return;
    }

    // Lấy phần còn của class name trừ namespace prefix
    $relative_class = substr($class, $len);

    /*
      Tìm đường dẫn đến file class:
      - Thay thế namespace prefix bằng tên base directory
      - Thay thế ký tự namespace separators \ bằng directory separator / (Linux)
      - Thêm ext .php
    */
    $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';

    // Nếu file tồn tại thì require it
    if (file_exists($file)) {
        require $file;
    }
});

https://www.php-fig.org/psr/psr-4/examples/

Closure Example
<?php
/**
 * An example of a project-specific implementation.
 *
 * After registering this autoload function with SPL, the following line
 * would cause the function to attempt to load the \Foo\Bar\Baz\Qux class
 * from /path/to/project/src/Baz/Qux.php:
 *
 *      new \Foo\Bar\Baz\Qux;
 *
 * @param string $class The fully-qualified class name.
 * @return void
 */
spl_autoload_register(function ($class) {

    // project-specific namespace prefix
    $prefix = 'Foo\\Bar\\';

    // base directory for the namespace prefix
    $base_dir = __DIR__ . '/src/';

    // does the class use the namespace prefix?
    $len = strlen($prefix);
    if (strncmp($prefix, $class, $len) !== 0) {
        // no, move to the next registered autoloader
        return;
    }

    // get the relative class name
    $relative_class = substr($class, $len);

    // replace the namespace prefix with the base directory, replace namespace
    // separators with directory separators in the relative class name, append
    // with .php
    $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';

    // if the file exists, require it
    if (file_exists($file)) {
        require $file;
    }
});
Class Example
The following is an example class implementation to handle multiple namespaces:

<?php
namespace Example;

/**
 * An example of a general-purpose implementation that includes the optional
 * functionality of allowing multiple base directories for a single namespace
 * prefix.
 *
 * Given a foo-bar package of classes in the file system at the following
 * paths ...
 *
 *     /path/to/packages/foo-bar/
 *         src/
 *             Baz.php             # Foo\Bar\Baz
 *             Qux/
 *                 Quux.php        # Foo\Bar\Qux\Quux
 *         tests/
 *             BazTest.php         # Foo\Bar\BazTest
 *             Qux/
 *                 QuuxTest.php    # Foo\Bar\Qux\QuuxTest
 *
 * ... add the path to the class files for the \Foo\Bar\ namespace prefix
 * as follows:
 *
 *      <?php
 *      // instantiate the loader
 *      $loader = new \Example\Psr4AutoloaderClass;
 *
 *      // register the autoloader
 *      $loader->register();
 *
 *      // register the base directories for the namespace prefix
 *      $loader->addNamespace('Foo\Bar', '/path/to/packages/foo-bar/src');
 *      $loader->addNamespace('Foo\Bar', '/path/to/packages/foo-bar/tests');
 *
 * The following line would cause the autoloader to attempt to load the
 * \Foo\Bar\Qux\Quux class from /path/to/packages/foo-bar/src/Qux/Quux.php:
 *
 *      <?php
 *      new \Foo\Bar\Qux\Quux;
 *
 * The following line would cause the autoloader to attempt to load the
 * \Foo\Bar\Qux\QuuxTest class from /path/to/packages/foo-bar/tests/Qux/QuuxTest.php:
 *
 *      <?php
 *      new \Foo\Bar\Qux\QuuxTest;
 */
class Psr4AutoloaderClass
{
    /**
     * An associative array where the key is a namespace prefix and the value
     * is an array of base directories for classes in that namespace.
     *
     * @var array
     */
    protected $prefixes = array();

    /**
     * Register loader with SPL autoloader stack.
     *
     * @return void
     */
    public function register()
    {
        spl_autoload_register(array($this, 'loadClass'));
    }

    /**
     * Adds a base directory for a namespace prefix.
     *
     * @param string $prefix The namespace prefix.
     * @param string $base_dir A base directory for class files in the
     * namespace.
     * @param bool $prepend If true, prepend the base directory to the stack
     * instead of appending it; this causes it to be searched first rather
     * than last.
     * @return void
     */
    public function addNamespace($prefix, $base_dir, $prepend = false)
    {
        // normalize namespace prefix
        $prefix = trim($prefix, '\\') . '\\';

        // normalize the base directory with a trailing separator
        $base_dir = rtrim($base_dir, DIRECTORY_SEPARATOR) . '/';

        // initialize the namespace prefix array
        if (isset($this->prefixes[$prefix]) === false) {
            $this->prefixes[$prefix] = array();
        }

        // retain the base directory for the namespace prefix
        if ($prepend) {
            array_unshift($this->prefixes[$prefix], $base_dir);
        } else {
            array_push($this->prefixes[$prefix], $base_dir);
        }
    }

    /**
     * Loads the class file for a given class name.
     *
     * @param string $class The fully-qualified class name.
     * @return mixed The mapped file name on success, or boolean false on
     * failure.
     */
    public function loadClass($class)
    {
        // the current namespace prefix
        $prefix = $class;

        // work backwards through the namespace names of the fully-qualified
        // class name to find a mapped file name
        while (false !== $pos = strrpos($prefix, '\\')) {

            // retain the trailing namespace separator in the prefix
            $prefix = substr($class, 0, $pos + 1);

            // the rest is the relative class name
            $relative_class = substr($class, $pos + 1);

            // try to load a mapped file for the prefix and relative class
            $mapped_file = $this->loadMappedFile($prefix, $relative_class);
            if ($mapped_file) {
                return $mapped_file;
            }

            // remove the trailing namespace separator for the next iteration
            // of strrpos()
            $prefix = rtrim($prefix, '\\');
        }

        // never found a mapped file
        return false;
    }

    /**
     * Load the mapped file for a namespace prefix and relative class.
     *
     * @param string $prefix The namespace prefix.
     * @param string $relative_class The relative class name.
     * @return mixed Boolean false if no mapped file can be loaded, or the
     * name of the mapped file that was loaded.
     */
    protected function loadMappedFile($prefix, $relative_class)
    {
        // are there any base directories for this namespace prefix?
        if (isset($this->prefixes[$prefix]) === false) {
            return false;
        }

        // look through base directories for this namespace prefix
        foreach ($this->prefixes[$prefix] as $base_dir) {

            // replace the namespace prefix with the base directory,
            // replace namespace separators with directory separators
            // in the relative class name, append with .php
            $file = $base_dir
                  . str_replace('\\', '/', $relative_class)
                  . '.php';

            // if the mapped file exists, require it
            if ($this->requireFile($file)) {
                // yes, we're done
                return $file;
            }
        }

        // never found it
        return false;
    }

    /**
     * If a file exists, require it from the file system.
     *
     * @param string $file The file to require.
     * @return bool True if the file exists, false if not.
     */
    protected function requireFile($file)
    {
        if (file_exists($file)) {
            require $file;
            return true;
        }
        return false;
    }
}
Unit Tests
The following example is one way of unit testing the above class loader:

<?php
namespace Example\Tests;

class MockPsr4AutoloaderClass extends Psr4AutoloaderClass
{
    protected $files = array();

    public function setFiles(array $files)
    {
        $this->files = $files;
    }

    protected function requireFile($file)
    {
        return in_array($file, $this->files);
    }
}

class Psr4AutoloaderClassTest extends \PHPUnit_Framework_TestCase
{
    protected $loader;

    protected function setUp()
    {
        $this->loader = new MockPsr4AutoloaderClass;

        $this->loader->setFiles(array(
            '/vendor/foo.bar/src/ClassName.php',
            '/vendor/foo.bar/src/DoomClassName.php',
            '/vendor/foo.bar/tests/ClassNameTest.php',
            '/vendor/foo.bardoom/src/ClassName.php',
            '/vendor/foo.bar.baz.dib/src/ClassName.php',
            '/vendor/foo.bar.baz.dib.zim.gir/src/ClassName.php',
        ));

        $this->loader->addNamespace(
            'Foo\Bar',
            '/vendor/foo.bar/src'
        );

        $this->loader->addNamespace(
            'Foo\Bar',
            '/vendor/foo.bar/tests'
        );

        $this->loader->addNamespace(
            'Foo\BarDoom',
            '/vendor/foo.bardoom/src'
        );

        $this->loader->addNamespace(
            'Foo\Bar\Baz\Dib',
            '/vendor/foo.bar.baz.dib/src'
        );

        $this->loader->addNamespace(
            'Foo\Bar\Baz\Dib\Zim\Gir',
            '/vendor/foo.bar.baz.dib.zim.gir/src'
        );
    }

    public function testExistingFile()
    {
        $actual = $this->loader->loadClass('Foo\Bar\ClassName');
        $expect = '/vendor/foo.bar/src/ClassName.php';
        $this->assertSame($expect, $actual);

        $actual = $this->loader->loadClass('Foo\Bar\ClassNameTest');
        $expect = '/vendor/foo.bar/tests/ClassNameTest.php';
        $this->assertSame($expect, $actual);
    }

    public function testMissingFile()
    {
        $actual = $this->loader->loadClass('No_Vendor\No_Package\NoClass');
        $this->assertFalse($actual);
    }

    public function testDeepFile()
    {
        $actual = $this->loader->loadClass('Foo\Bar\Baz\Dib\Zim\Gir\ClassName');
        $expect = '/vendor/foo.bar.baz.dib.zim.gir/src/ClassName.php';
        $this->assertSame($expect, $actual);
    }

    public function testConfusion()
    {
        $actual = $this->loader->loadClass('Foo\Bar\DoomClassName');
        $expect = '/vendor/foo.bar/src/DoomClassName.php';
        $this->assertSame($expect, $actual);

        $actual = $this->loader->loadClass('Foo\BarDoom\ClassName');
        $expect = '/vendor/foo.bardoom/src/ClassName.php';
        $this->assertSame($expect, $actual);
    }
}

https://www.php-fig.org/psr/psr-4/meta/

The purpose is to specify the rules for an interoperable PHP autoloader that maps namespaces to file system paths, and that can co-exist with any other SPL registered autoloader. This would be an addition to, not a replacement for, PSR-0.

The PSR-0 class naming and autoloading standard rose out of the broad acceptance of the Horde/PEAR convention under the constraints of PHP 5.2 and previous. With that convention, the tendency was to put all PHP source classes in a single main directory, using underscores in the class name to indicate pseudo-namespaces, like so:

/path/to/src/
    VendorFoo/
        Bar/
            Baz.php     # VendorFoo_Bar_Baz
    VendorDib/
        Zim/
            Gir.php     # Vendor_Dib_Zim_Gir

With the release of PHP 5.3 and the availability of namespaces proper, PSR-0 was introduced to allow both the old Horde/PEAR underscore mode and the use of the new namespace notation. Underscores were still allowed in the class name to ease the transition from the older namespace naming to the newer naming, and thereby to encourage wider adoption.

/path/to/src/
    VendorFoo/
        Bar/
            Baz.php     # VendorFoo_Bar_Baz
    VendorDib/
        Zim/
            Gir.php     # VendorDib_Zim_Gir
    Irk_Operation/
        Impending_Doom/
            V1.php
            V2.php      # Irk_Operation\Impending_Doom\V2

This structure is informed very much by the fact that the PEAR installer moved source files from PEAR packages into a single central directory.

With Composer, package sources are no longer copied to a single global location. They are used from their installed location and are not moved around. This means that with Composer there is no “single main directory” for PHP sources as with PEAR. Instead, there are multiple directories; each package is in a separate directory for each project.

To meet the requirements of PSR-0, this leads to Composer packages looking like this:

vendor/
    vendor_name/
        package_name/
            src/
                Vendor_Name/
                    Package_Name/
                        ClassName.php       # Vendor_Name\Package_Name\ClassName
            tests/
                Vendor_Name/
                    Package_Name/
                        ClassNameTest.php   # Vendor_Name\Package_Name\ClassNameTest

The “src” and “tests” directories have to include vendor and package directory names. This is an artifact of PSR-0 compliance.

Many find this structure to be deeper and more repetitive than necessary. This proposal suggests that an additional or superseding PSR would be useful so that we can have packages that look more like the following:

vendor/
    vendor_name/
        package_name/
            src/
                ClassName.php       # Vendor_Name\Package_Name\ClassName
            tests/
                ClassNameTest.php   # Vendor_Name\Package_Name\ClassNameTest

This would require an implementation of what was initially called “package-oriented autoloading” (as vs the traditional “direct class-to-file autoloading”).

It’s difficult to implement package-oriented autoloading via an extension or amendment to PSR-0, because PSR-0 does not allow for an intercessory path between any portions of the class name. This means the implementation of a package-oriented autoloader would be more complicated than PSR-0. However, it would allow for cleaner packages.

Initially, the following rules were suggested:

  1. Implementors MUST use at least two namespace levels: a vendor name, and package name within that vendor. (This top-level two-name combination is hereinafter referred to as the vendor-package name or the vendor-package namespace.)

  2. Implementors MUST allow a path infix between the vendor-package namespace and the remainder of the fully qualified class name.

  3. The vendor-package namespace MAY map to any directory. The remaining portion of the fully-qualified class name MUST map the namespace names to identically-named directories, and MUST map the class name to an identically-named file ending in .php.

Note that this means the end of underscore-as-directory-separator in the class name. One might think underscores should be honored as they are under PSR-0, but seeing as their presence in that document is in reference to transitioning away from PHP 5.2 and previous pseudo-namespacing, it is acceptable to remove them here as well.

  • Retain the PSR-0 rule that implementors MUST use at least two namespace levels: a vendor name, and package name within that vendor.

  • Allow a path infix between the vendor-package namespace and the remainder of the fully qualified class name.

  • Allow the vendor-package namespace MAY map to any directory, perhaps multiple directories.

  • End the honoring of underscores in class names as directory separators

  • Provide a general transformation algorithm for non-class resources

This approach retains key characteristics of PSR-0 while eliminating the deeper directory structures it requires. In addition, it specifies certain additional rules that make implementations explicitly more interoperable.

Although not related to directory mapping, the final draft also specifies how autoloaders should handle errors. Specifically, it forbids throwing exceptions or raising errors. The reason is two-fold.

  1. Autoloaders in PHP are explicitly designed to be stackable so that if one autoloader cannot load a class another has a chance to do so. Having an autoloader trigger a breaking error condition violates that compatibility.

  2. class_exists() and interface_exists() allow “not found, even after trying to autoload” as a legitimate, normal use case. An autoloader that throws exceptions renders class_exists() unusable, which is entirely unacceptable from an interoperability standpoint. Autoloaders that wish to provide additional debugging information in a class-not-found case should do so via logging instead, either to a PSR-3 compatible logger or otherwise.

Pros:

  • Shallower directory structures

  • More flexible file locations

  • Stops underscore in class name from being honored as directory separator

  • Makes implementations more explicitly interoperable

Cons:

  • It is no longer possible, as under PSR-0, to merely examine a class name to determine where it is in the file system (the “class-to-file” convention inherited from Horde/PEAR).

Staying with PSR-0 only, although reasonable, does leave us with relatively deeper directory structures.

Pros:

  • No need to change anyone’s habits or implementations

Cons:

  • Leaves us with deeper directory structures

  • Leaves us with underscores in the class name being honored as directory separators

Beau Simensen and others suggested that the transformation algorithm might be split out from the autoloading proposal so that the transformation rules could be referenced by other proposals. After doing the work to separate them, followed by a poll and some discussion, the combined version (i.e., transformation rules embedded in the autoloader proposal) was revealed as the preference.

Pros:

  • Transformation rules could be referenced separately by other proposals

Cons:

  • Not in line with the wishes of poll respondents and some collaborators

After the second vote was pulled by a Sponsor after hearing from multiple +1 voters that they supported the idea but did not agree with (or understand) the wording of the proposal, there was a period during which the voted-on proposal was expanded with greater narrative and somewhat more imperative language. This approach was decried by a vocal minority of participants. After some time, Beau Simensen started an experimental revision with an eye to PSR-0; the Editor and Sponsors favored this more terse approach and shepherded the version now under consideration, written by Paul M. Jones and contributed to by many.

PHP versions before 5.3.3 do not strip the leading namespace separator, so the responsibility to look out for this falls on the implementation. Failing to strip the leading namespace separator could lead to unexpected behavior.

  • Paul M. Jones, Solar/Aura

  • Phil Sturgeon, PyroCMS (Coordinator)

  • Larry Garfield, Drupal

  • Andreas Hennings

  • Bernhard Schussek

  • Beau Simensen

  • Donald Gilbert

  • Mike van Riel

  • Paul Dragoonis

  • Too many others to name and count

https://www.php-fig.org/psr/psr-4/

This PSR describes a specification for autoloading classes from file paths. It is fully interoperable, and can be used in addition to any other autoloading specification, including PSR-0. This PSR also describes where to place files that will be autoloaded according to the specification.

  1. The term “class” refers to classes, interfaces, traits, and other similar structures.

  2. A fully qualified class name has the following form:

     \<NamespaceName>(\<SubNamespaceNames>)*\<ClassName>
    1. The fully qualified class name MUST have a top-level namespace name, also known as a “vendor namespace”.

    2. The fully qualified class name MAY have one or more sub-namespace names.

    3. The fully qualified class name MUST have a terminating class name.

    4. Underscores have no special meaning in any portion of the fully qualified class name.

    5. Alphabetic characters in the fully qualified class name MAY be any combination of lower case and upper case.

    6. All class names MUST be referenced in a case-sensitive fashion.

  3. When loading a file that corresponds to a fully qualified class name …

    1. A contiguous series of one or more leading namespace and sub-namespace names, not including the leading namespace separator, in the fully qualified class name (a “namespace prefix”) corresponds to at least one “base directory”.

    2. The contiguous sub-namespace names after the “namespace prefix” correspond to a subdirectory within a “base directory”, in which the namespace separators represent directory separators. The subdirectory name MUST match the case of the sub-namespace names.

    3. The terminating class name corresponds to a file name ending in .php. The file name MUST match the case of the terminating class name.

  4. Autoloader implementations MUST NOT throw exceptions, MUST NOT raise errors of any level, and SHOULD NOT return a value.

The table below shows the corresponding file path for a given fully qualified class name, namespace prefix, and base directory.

FULLY QUALIFIED CLASS NAME

NAMESPACE PREFIX

BASE DIRECTORY

RESULTING FILE PATH

\Acme\Log\Writer\File_Writer

Acme\Log\Writer

./acme-log-writer/lib/

./acme-log-writer/lib/File_Writer.php

\Aura\Web\Response\Status

Aura\Web

/path/to/aura-web/src/

/path/to/aura-web/src/Response/Status.php

\Symfony\Core\Request

Symfony\Core

./vendor/Symfony/Core/

./vendor/Symfony/Core/Request.php

\Zend\Acl

Zend

/usr/includes/Zend/

/usr/includes/Zend/Acl.php

For example implementations of autoloaders conforming to the specification, please see the examples file. Example implementations MUST NOT be regarded as part of the specification and MAY change at any time.

Autoloading với Composer

Để thực hiện autoloading với Composer bạn cần khai báo trong file composer.json. Composer hỗ trợ các kiểu autoload PSR-4, PSR-0, classmap và files, các bạn có thể tham khảo tài liệu gốc.

Ở đây, mình sẽ ví dụ autoload PSR-4 với Composer.

Ví dụ bạn có cấu trúc thư mục như sau:

.
├── composer.json
├── index.php
└── src
    ├── Controllers
    │   └── HomeController.php
    ├── Models
    │   └── User.php
    └── Views
        └── home.php

File src/Models/User.php:

<?php
namespace Viblo\Models;

class User
{
    public function list()
    {
        // TODO: get actual data
        return [1, 2, 3];
    }
}

Tương tự file src/Controllers/HomeController.php sẽ có namespace Viblo\Controllers.

<?php
namespace Viblo\Controllers;

use Viblo\Models\User;

class HomeController 
{
    public function actionIndex()
    {
        return (new User)->list();
    }
}

Thư mục Views gồm những file markup PHP, Html nên sẽ không thực hiện autoload.

Tiếp theo, chúng ta có file composer.json:

{
    "autoload": {
        "psr-4": {
            "Viblo\\": "src/"
        }
    }
}

Sau đó chạy lệnh:

composer dump-autoload

Câu lệnh này sẽ tạo ra file autoloader vendor/autoload.php, autoloader này sẽ load các rule được implement trong vendor/composer/autoload_*.php.

vendor/composer
├── autoload_classmap.php
├── autoload_files.php
├── autoload_namespaces.php
├── autoload_psr4.php
├── autoload_real.php
├── autoload_static.php
├── ca-bundle
├── ClassLoader.php
├── installed.json
├── installers
└── LICENSE

Sử dụng index.php:

<?php
require __DIR__ . '/vendor/autoload.php';

use Viblo\Controllers\HomeController;

$homeCtrl = new HomeController;

print_r($homeCtrl->actionIndex());

// ...

Autoload optimizing

Với các quy tắc PSR-0, PSR-4, autoloader cần phải check sự tồn tại của class file trong filesystem trước khi load class. Việc này có thể làm chậm tốc độc của application một chút, nhưng bù lại nó làm cho việc develop dễ dàng hơn khi bạn thêm một class và có thể sử dụng nó ngay lập tức mà không phải rebuild autoloader.

Vấn đề ở đây là, trong môi trường production, bạn thường muốn app của mình chạy nhanh nhất có thể, vì môi trường này ít khi thay đổi và bạn có thể dễ dàng rebuild lại autoloader khi deploy.

Vì lý do này, Composer cung cấp vài phương pháp để optimize autoloader. Mình sẽ giới thiệu 1 phương pháp thường được sử dụng nhất.

Optimization Level 1: Class map generation

Làm sao để bật chế độ này?

Có một vài tùy chọn để bạn bật chế độ optimization này:

  • Thiết lập trong file composer.json

    "config": {
        "optimize-autoloader": true
    }
  • Chạy composer install hoặc composer update với option -o hay --optimize-autoloader

  • Chạy composer dump-autoload với option -o / --optimize

Nó hoạt động như thế nào?

Composer sẽ convert các rule PSR-4/PSR-0 sang classmap rule. Các bạn có thể xem file vendor/composer/autoload_classmap.php để kiểm tra. File này sẽ return một array, có key là tên class đầy đủ và value là đường dẫn đến class. Composer đã check sự tồn tại của các class này trước và generate ra classmap, do đó, khi tham chiếu đến class thì autoloader chỉ việc require file vào không cần phải check filesystem nữa.

Với PHP 5.6+, Composer còn thực hiện cache classmap trong opcache, làm cho việc load class có thể chạy nhanh nhất có thể.

Nhược điểm

Không có nhược điểm thực sự với phương pháp này, và bạn nên luôn luôn dùng nó trong môi trường production.

Một vấn đề nhỏ với ở đây là, nếu class không tồn tại trong classmap, nó sẽ có fallback về PSR-4 rule, tức là tiếp tục follow theo PSR-4 rule và có thể phát sinh filesystem check.

Để giải quyết vấn đề này Composer cung cấp thêm 2 phương pháp optimize level 2 đó là Level 2/A: Authoritative class maps (loại bỏ fallback về PSR-4 rule) và Level 2/B: APCu cache (sử dụng apcu cache). Hai option level 2 này không thể được sử dụng cùng 1 lúc và cũng ít được sử dụng.

Ngoài ra còn 1 vấn đề nữa mình phát hiện trong quá trình tham gia một project, đó là khi sử dụng optimize thì rule PSR-4 sẽ không được đảm bảo về ràng buộc giữa class namespaces và thư mục chứa class. Tức là, vd 1 class là App\User nhưng lại được đặt trong thư mục khác, chẳng hạn app/Http/Models/User.php thay vì app/User.php.

Đây cũng không phải là vấn đề lớn và vẫn đang được thảo luận, các bạn có thể theo dõi tại issue này.

Kết luận

Có thể nói Autoloading là một feature của "modern" PHP. Hy vọng qua bài viết này các bạn có thể áp dụng nhiều hơn vào các project của mình.

https://www.php-fig.org/psr/psr-4/examples/

The following examples illustrate PSR-4 compliant code:

Closure Example

<?php
/**
 * An example of a project-specific implementation.
 *
 * After registering this autoload function with SPL, the following line
 * would cause the function to attempt to load the \Foo\Bar\Baz\Qux class
 * from /path/to/project/src/Baz/Qux.php:
 *
 *      new \Foo\Bar\Baz\Qux;
 *
 * @param string $class The fully-qualified class name.
 * @return void
 */
spl_autoload_register(function ($class) {

    // project-specific namespace prefix
    $prefix = 'Foo\\Bar\\';

    // base directory for the namespace prefix
    $base_dir = __DIR__ . '/src/';

    // does the class use the namespace prefix?
    $len = strlen($prefix);
    if (strncmp($prefix, $class, $len) !== 0) {
        // no, move to the next registered autoloader
        return;
    }

    // get the relative class name
    $relative_class = substr($class, $len);

    // replace the namespace prefix with the base directory, replace namespace
    // separators with directory separators in the relative class name, append
    // with .php
    $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';

    // if the file exists, require it
    if (file_exists($file)) {
        require $file;
    }
});

Class Example

The following is an example class implementation to handle multiple namespaces:

<?php
namespace Example;

/**
 * An example of a general-purpose implementation that includes the optional
 * functionality of allowing multiple base directories for a single namespace
 * prefix.
 *
 * Given a foo-bar package of classes in the file system at the following
 * paths ...
 *
 *     /path/to/packages/foo-bar/
 *         src/
 *             Baz.php             # Foo\Bar\Baz
 *             Qux/
 *                 Quux.php        # Foo\Bar\Qux\Quux
 *         tests/
 *             BazTest.php         # Foo\Bar\BazTest
 *             Qux/
 *                 QuuxTest.php    # Foo\Bar\Qux\QuuxTest
 *
 * ... add the path to the class files for the \Foo\Bar\ namespace prefix
 * as follows:
 *
 *      <?php
 *      // instantiate the loader
 *      $loader = new \Example\Psr4AutoloaderClass;
 *
 *      // register the autoloader
 *      $loader->register();
 *
 *      // register the base directories for the namespace prefix
 *      $loader->addNamespace('Foo\Bar', '/path/to/packages/foo-bar/src');
 *      $loader->addNamespace('Foo\Bar', '/path/to/packages/foo-bar/tests');
 *
 * The following line would cause the autoloader to attempt to load the
 * \Foo\Bar\Qux\Quux class from /path/to/packages/foo-bar/src/Qux/Quux.php:
 *
 *      <?php
 *      new \Foo\Bar\Qux\Quux;
 *
 * The following line would cause the autoloader to attempt to load the
 * \Foo\Bar\Qux\QuuxTest class from /path/to/packages/foo-bar/tests/Qux/QuuxTest.php:
 *
 *      <?php
 *      new \Foo\Bar\Qux\QuuxTest;
 */
class Psr4AutoloaderClass
{
    /**
     * An associative array where the key is a namespace prefix and the value
     * is an array of base directories for classes in that namespace.
     *
     * @var array
     */
    protected $prefixes = array();

    /**
     * Register loader with SPL autoloader stack.
     *
     * @return void
     */
    public function register()
    {
        spl_autoload_register(array($this, 'loadClass'));
    }

    /**
     * Adds a base directory for a namespace prefix.
     *
     * @param string $prefix The namespace prefix.
     * @param string $base_dir A base directory for class files in the
     * namespace.
     * @param bool $prepend If true, prepend the base directory to the stack
     * instead of appending it; this causes it to be searched first rather
     * than last.
     * @return void
     */
    public function addNamespace($prefix, $base_dir, $prepend = false)
    {
        // normalize namespace prefix
        $prefix = trim($prefix, '\\') . '\\';

        // normalize the base directory with a trailing separator
        $base_dir = rtrim($base_dir, DIRECTORY_SEPARATOR) . '/';

        // initialize the namespace prefix array
        if (isset($this->prefixes[$prefix]) === false) {
            $this->prefixes[$prefix] = array();
        }

        // retain the base directory for the namespace prefix
        if ($prepend) {
            array_unshift($this->prefixes[$prefix], $base_dir);
        } else {
            array_push($this->prefixes[$prefix], $base_dir);
        }
    }

    /**
     * Loads the class file for a given class name.
     *
     * @param string $class The fully-qualified class name.
     * @return mixed The mapped file name on success, or boolean false on
     * failure.
     */
    public function loadClass($class)
    {
        // the current namespace prefix
        $prefix = $class;

        // work backwards through the namespace names of the fully-qualified
        // class name to find a mapped file name
        while (false !== $pos = strrpos($prefix, '\\')) {

            // retain the trailing namespace separator in the prefix
            $prefix = substr($class, 0, $pos + 1);

            // the rest is the relative class name
            $relative_class = substr($class, $pos + 1);

            // try to load a mapped file for the prefix and relative class
            $mapped_file = $this->loadMappedFile($prefix, $relative_class);
            if ($mapped_file) {
                return $mapped_file;
            }

            // remove the trailing namespace separator for the next iteration
            // of strrpos()
            $prefix = rtrim($prefix, '\\');
        }

        // never found a mapped file
        return false;
    }

    /**
     * Load the mapped file for a namespace prefix and relative class.
     *
     * @param string $prefix The namespace prefix.
     * @param string $relative_class The relative class name.
     * @return mixed Boolean false if no mapped file can be loaded, or the
     * name of the mapped file that was loaded.
     */
    protected function loadMappedFile($prefix, $relative_class)
    {
        // are there any base directories for this namespace prefix?
        if (isset($this->prefixes[$prefix]) === false) {
            return false;
        }

        // look through base directories for this namespace prefix
        foreach ($this->prefixes[$prefix] as $base_dir) {

            // replace the namespace prefix with the base directory,
            // replace namespace separators with directory separators
            // in the relative class name, append with .php
            $file = $base_dir
                  . str_replace('\\', '/', $relative_class)
                  . '.php';

            // if the mapped file exists, require it
            if ($this->requireFile($file)) {
                // yes, we're done
                return $file;
            }
        }

        // never found it
        return false;
    }

    /**
     * If a file exists, require it from the file system.
     *
     * @param string $file The file to require.
     * @return bool True if the file exists, false if not.
     */
    protected function requireFile($file)
    {
        if (file_exists($file)) {
            require $file;
            return true;
        }
        return false;
    }
}

Unit Tests

The following example is one way of unit testing the above class loader:

<?php
namespace Example\Tests;

class MockPsr4AutoloaderClass extends Psr4AutoloaderClass
{
    protected $files = array();

    public function setFiles(array $files)
    {
        $this->files = $files;
    }

    protected function requireFile($file)
    {
        return in_array($file, $this->files);
    }
}

class Psr4AutoloaderClassTest extends \PHPUnit_Framework_TestCase
{
    protected $loader;

    protected function setUp()
    {
        $this->loader = new MockPsr4AutoloaderClass;

        $this->loader->setFiles(array(
            '/vendor/foo.bar/src/ClassName.php',
            '/vendor/foo.bar/src/DoomClassName.php',
            '/vendor/foo.bar/tests/ClassNameTest.php',
            '/vendor/foo.bardoom/src/ClassName.php',
            '/vendor/foo.bar.baz.dib/src/ClassName.php',
            '/vendor/foo.bar.baz.dib.zim.gir/src/ClassName.php',
        ));

        $this->loader->addNamespace(
            'Foo\Bar',
            '/vendor/foo.bar/src'
        );

        $this->loader->addNamespace(
            'Foo\Bar',
            '/vendor/foo.bar/tests'
        );

        $this->loader->addNamespace(
            'Foo\BarDoom',
            '/vendor/foo.bardoom/src'
        );

        $this->loader->addNamespace(
            'Foo\Bar\Baz\Dib',
            '/vendor/foo.bar.baz.dib/src'
        );

        $this->loader->addNamespace(
            'Foo\Bar\Baz\Dib\Zim\Gir',
            '/vendor/foo.bar.baz.dib.zim.gir/src'
        );
    }

    public function testExistingFile()
    {
        $actual = $this->loader->loadClass('Foo\Bar\ClassName');
        $expect = '/vendor/foo.bar/src/ClassName.php';
        $this->assertSame($expect, $actual);

        $actual = $this->loader->loadClass('Foo\Bar\ClassNameTest');
        $expect = '/vendor/foo.bar/tests/ClassNameTest.php';
        $this->assertSame($expect, $actual);
    }

    public function testMissingFile()
    {
        $actual = $this->loader->loadClass('No_Vendor\No_Package\NoClass');
        $this->assertFalse($actual);
    }

    public function testDeepFile()
    {
        $actual = $this->loader->loadClass('Foo\Bar\Baz\Dib\Zim\Gir\ClassName');
        $expect = '/vendor/foo.bar.baz.dib.zim.gir/src/ClassName.php';
        $this->assertSame($expect, $actual);
    }

    public function testConfusion()
    {
        $actual = $this->loader->loadClass('Foo\Bar\DoomClassName');
        $expect = '/vendor/foo.bar/src/DoomClassName.php';
        $this->assertSame($expect, $actual);

        $actual = $this->loader->loadClass('Foo\BarDoom\ClassName');
        $expect = '/vendor/foo.bardoom/src/ClassName.php';
        $this->assertSame($expect, $actual);
    }
}

Last updated