PSR-4规范:自动加载

摘要: FIG-PHP工作组推出的PSR-4规范能够满足面向package的自动加载,它规范了如何从文件路径自动加载类,同时规范了自动加载文件的位置。

1.  PSR-4规范:自动加载

 

虽然在[PSR-4-Meta]中指出PSR-4是对PSR-0规范的补充而不是替换,但是在[PSR-0]中已经写到PSR-0于2014.10.21被废弃,并在[PSR-4-Meta]中详细写明了PSR-0的不足,已经不能满足面向package的自动加载。

PSR-4规范能够满足面向package的自动加载,它规范了如何从文件路径自动加载类,同时规范了自动加载文件的位置。

1.1 概述

这份PSR规范描述了从文件路径自动加载类。可以与PSR-0规范互操作,可以一起使用。这份PSR也描述了自动加载的文件应当放在哪里。

1.2 规范

1.2.1 术语"class"是指classes, interfaces, traits, 以及其他类似的结构.

1.2.2 一个完全合乎规格的类名(A fully qualified class name)格式如下:

\<NamespaceName>(\<SubNamespaceNames>)*\<ClassName>

(1) 完全合规的类名必须(MUST)有一个顶级命名空间名称,也就是通常所说的"vendor命名空间".

(2) 完全合规的类名可以(MAY)有一个或多个二级命名空间名称(sub-namespace names).

(3) 完全合规的类名必须(MUST)以类名来结尾。

(4) 在完全合规的类名的任意一个部分,下划线都没有特殊的含义。

(5) 在完全合规的类名中,可以(MAY)是任意大小写字母混合。

(6) 所有的类名必须(MUST)按大小写敏感方式来引用。

1.2.3 当加载完全合规的类名对应的文件时...

(1) 在完全合规的类名中, 不包含前面的命名空间分隔符,由一个顶级命名空间与一个或多个二级命名空间名称组成的命名空间前缀,对应于至少一个“base目录”.

(2) 在命名空间前缀后面的二级命名空间名称对应于“base目录”中的一个子目录, 这里命名空间分隔符表示目录分隔符。子目录名称必须(MUST)匹配到二级命名空间名称。

(3) 后面的类名对应于以.php为后缀的文件名,这个文件名必须(MUST)匹配到后面的类名。

(4) 自动加载实现一定不能(MUST NOT)抛出异常,一定不能(MUST NOT)引发任何级别的错误, 并且不应当(SHOULD NOT)返回值。

1.3. 举例

下面的表展示了对一个完全合规的类名, 命名空间前缀以及base目录对应的文件路径.

完全合规类名 命名空间前缀 base目录 最终的文件路径
\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

备注:以第一行为例来说明,完全合规的类名是“\Acme\Log\Writer\File_Writer”, 去掉前面的命名空间分隔符'\', 则命名空间前缀为"Acme\Log\Writer", 类名为"File_Writer"。这个命名空间前缀对应的base目录为"./acme-log-writer/lib/", 因此最终加载的文件名为:base目录+类名+".php", 即"./acme-log-writer/lib/File_Writer.php"

 

遵循本规范的自动加载器的实现举例, 可参见下面的代码样例。这些实现样例一定不能(MUST NOT)被视为本规范的内容,它们可能(MAY)随时发生改变。

2. 代码样例

以下代码展示了遵循PSR-4的类定义,

闭包(Closure)举例:

<?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目录
    $base_dir = __DIR__ . '/src/';

    // does the class use the namespace prefix?
    // 检查$class中是否包含命名空间前缀
    $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
    // 用base目录替代命名空间前缀, 
    // 在相对类名中用目录分隔符'/'来替换命名空间分隔符'\', 
    // 并在后面追加.php组成$file的绝对路径
    $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';

    // if the file exists, require it
    // 如果文件存在,则通过require关键字包含文件
    if (file_exists($file)) {
        require $file;
    }
});

 

 

下面这个类处理多个命名空间:

<?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.
 * 下面例子中在一个命名空间前缀下有多个base目录。
 * 
 * Given a foo-bar package of classes in the file system at the following
 * paths ...
 * 在下面路径中foo-bar包中存在以下类:
 * 
 *     /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:
 * ...对\Foo\Bar\命名空间前缀,添加类文件的路径
 * 
 *      <?php
 *      // instantiate the loader
 *      // 初始化loader 
 *      $loader = new \Example\Psr4AutoloaderClass;
 *      
 *      // register the autoloader
 *      // 注册autoloader
 *      $loader->register();
 *      
 *      // register the base directories for the namespace prefix
 *      // 注册命名空间前缀的多个base目录
 *      $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:
 * 下面代码将用/path/to/packages/foo-bar/src/Qux/Quux.php文件来加载\Foo\Bar\Qux\Quux类。
 * 
 *      <?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:
 * 下面代码将用/path/to/packages/foo-bar/tests/Qux/QuuxTest.php文件来加载
 * \Foo\Bar\Qux\QuuxTest类。
 * 
 *      <?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.
     * 定义一个数组:key为命名空间前缀,value为一个数组,每一项表示命名空间中类对应的base目录.
     *
     * @var array
     */
    protected $prefixes = array();

    /**
     * Register loader with SPL autoloader stack.
     * 利用SPL自动加载器来注册loader
     * 
     * @return void
     */
    public function register()
    {
        spl_autoload_register(array($this, 'loadClass'));
    }

    /**
     * Adds a base directory for a namespace prefix.
     * 为一个命名空间前缀添加对应的base目录
     *
     * @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目录
        $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
        // 绑定命名空间前缀对应的base目录
        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()
            // 删除命名空间前缀尾部的分隔符,以便用于下一次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?
        // 命名空间前缀中有base目录吗?
        if (isset($this->prefixes[$prefix]) === false) {
            return false;
        }

        // look through base directories for this namespace prefix
        // 遍历命名空间前缀的base目录
        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
            // 用base目录替代命名空间前缀, 
            // 在相对类名中用目录分隔符'/'来替换命名空间分隔符'\', 
            // 并在后面追加.php组成$file的绝对路径
            $file = $base_dir
                  . str_replace('\\', '/', $relative_class)
                  . '.php';

            // if the mapped file exists, require it
            // 若映射文件存在,则require该文件
            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;
    }
}

 

 

3. 单元测试

下面是对应的单元测试代码:

<?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);
    }
}

 

 

 

4. PSR-4应用

PHP的包管理系统Composer已经支持PSR-4,同时也允许在composer.json中定义不同的prefix使用不同的自动加载机制。

Composer使用PSR-0风格

1
2
3
4
5
6
7
8
9
10
11
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\ClassName

Composer使用PSR-4风格

1
2
3
4
5
6
7
vendor/
vendor_name/
package_name/
src/
ClassName.php       # Vendor_Name\Package_Name\ClassName
tests/
ClassNameTest.php   # Vendor_Name\Package_Name\ClassNameTest

对比以上两种结构,明显可以看出PSR-4带来更简洁的文件结构。

THE END