PHP自动加载

本文将介绍PHP自动加载这块基础中的基础,鄙人造化不深,技术浅薄,所写均为个人学习过程与体会的记录。以后通过理论与实践结合,得到更扎实更深入更有含量的收获,鄙人也会及时梳理出来。

回到标题,我们写代码的时候,无论用什么语言,都会涉及到要加载很多原生的或第三方的各种库/包/文件等等,毕竟,我们总需要用到很多别人造好的轮子,这些轮子没有必要自己重复劳动搞一遍(除非是算法笔试题),PHP也不例外。

接下来,咱赶紧进入正文。

 

手动加载

在讨论自动加载之前,先看看PHP最原始的加载方式:通过include、require把其它文件导入到当前代码中。

 

include

使用include导入文件,也分两种具体情况。如果include的目标文件仅给出文件名,则会首先根据PHP基础配置文件php.ini中的include_path配置项所指定的路径,在该路径下寻找目标文件;如果没有找到,则退而在使用include的当前代码文件所在的路径下,寻找目标文件;如果还是找不到,则会抛出警告,include操作返回false。如果在上述任何地方已经找到目标文件,则不再继续寻找,把找到的目标文件导入当前代码,include操作返回1。

来看下面的例子。首先,检查php.ini中include_path配置项的值:

然后,进入该路径,创建test.php文件,内容如下:

<?php

function test(): void
{
    echo "test from include_path of php.ini\n";
}

然后,在随意某个不同的路径下,创建又一个test.php。同在该路径下,也创建habon.php、run.php,内容分别如下:

<?php

// test.php

function test(): void
{
    echo "test from relative path\n";
}
<?php

// habon.php

function habon(): void
{
    echo "habon from relative path\n";
}
<?php

// run.php

$r1 = include 'test.php';
$r2 = include 'habon.php';
$r3 = include 'godofjiong.php';

echo var_export($r1, true) . "\n";
test();
echo var_export($r2, true) . "\n";
habon();
echo var_export($r3, true) . "\n";

运行run.php看看:

完全符合预期!好,我们接着看include的另一种情况。如果include的目标文件是含有路径的文件名,甭管是相对路径还是绝对路径,php.ini中include_path将被忽略,直接在该路径下寻找目标文件。找到与否的结果与上文相同。

来看下面的例子。首先,在php.ini的include_path对应的路径下,上文的test.php维持不变,创建godofjiong.php,内容如下:

<?php

function godofjiong(): void
{
    echo "godofjiong from include_path of php.ini\n";
}

然后,回到上文run.php所在路径,该路径下所有上文创建的文件维持不变,修改run.php如下:

<?php

// run.php

$r1 = include './test.php';
$r2 = include './godofjiong.php';

echo var_export($r1, true) . "\n";
test();
echo var_export($r2, true) . "\n";

运行run.php:

完全符合预期!再修改下run.php,再运行:

<?php

// run.php

$r1 = include './test.php';
$r2 = include '/data/web/sms_admin/test.php';

echo var_export($r1, true) . "\n";
test();
echo var_export($r2, true) . "\n";

由此看出,include的目标文件,相对路径与绝对路径都起效了。对include而言,重复导入相同的文件,会报错。

 

include_once

要解决可能重复导入的问题,用include_once即可。include_once与include几乎一样,唯一区别:目标文件先前已被导入过时,include_once会直接返回true,忽略其它动作。

只修改run.php如下,再运行:

<?php

// run.php

$r1 = include './test.php'; // 此处是否换成include_once都可以。
$r2 = include_once '/data/web/sms_admin/test.php';

echo var_export($r1, true) . "\n";
test();
echo var_export($r2, true) . "\n";

完全符合预期!

 

require

require与include几乎一样,唯一区别:当目标文件未能找到时,include抛出告警,但当前代码继续运行,而require则会报错,当前代码终止运行。

只修改run.php如下,再运行:

<?php

// run.php

$r1 = require './test.php';
$r2 = require './godofjiong.php';

echo var_export($r1, true) . "\n";
test();
echo var_export($r2, true) . "\n";

完全符合预期!

 

require_once

同理,require_once与require几乎一样,唯一区别:目标文件先前已被导入过时,require_once会直接返回true,忽略其它动作。

只修改run.php如下,再运行:

<?php

// run.php

$r1 = require_once './habon.php'; // 此处是否换成require_once都可以。
$r2 = require_once '/data/web/sms_admin/habon.php';

echo var_export($r1, true) . "\n";
habon();
echo var_export($r2, true) . "\n";

完全符合预期!

 

自动加载

上文梳理了PHP原生的手动加载方式。显然,在实际开发中,你的代码需要导入的文件往往比较多,如果每个文件都通过include或require导入,代码将显得非常繁琐。因此,我们便需要PHP的自动加载了。

 

__autoload

首先看下PHP一开头是如何支持自动加载的。PHP提供了魔术函数__autoload,该函数的参数是1个字符串形式的类名,没有返回值。当代码中遇到某个类名,需要寻找其定义时,__autoload函数会自动触发调用,把类名作为参数传进去。这意味着,如果我们覆写__autoload函数的逻辑,在里头指定自定义的导入规则,那么,以后代码中无论遇到哪个类名,不就都能自动走这个规则把对应的文件导入进来吗?

那就让鄙人试一下。首先,随便找个路径,创建两个PHP文件,分别定义两个类:

<?php

// Habon.php

class Habon
{
    public static function f(): void
    {
        echo "Habon called\n";
    }
}
<?php

// GodOfJiong.php

class GodOfJiong
{
    public function __construct()
    {
        echo "GodOfJiong created\n";
    }
}

然后,在同一路径下,创建run.php,__autoload函数体以类名参数拼出同路径下的同名PHP文件,通过include_once导入:

<?php

// run.php

function __autoload(string $class): void
{
    include_once './' . $class . '.php';
}

Habon::f();
$o = new GodOfJiong();

运行一下看看结果:

可以看到,run.php遇到Habon、GodOfJiong两个类时,确确实实自动触发调用了__autoload魔术函数,根据我们指定的规则导入了对应的类定义文件。那么,假如__autoload根据同样规则,没能找到对应类定义文件,会如何呢?且看如下:

<?php

// run.php

function __autoload(string $class): void
{
    include_once './' . $class . '.php';
}

Habon::f();
$o = new GodOfJiong();
TestClass::testFun();

run.php只添加了最后那行,调用新的类TestClass,这个类我并没有在与run.php同一路径下创建类定义文件。当运行run.php遇到TestClass时,依旧触发了__autoload,但导致include_once没能找到对应文件。由之前的介绍可知,include_once抛出警告,run.php的运行不会终止,在__autoload调用完毕后,回到TestClass::testFun();这条语句终于报错终止运行了,毕竟,最终是没能找到类定义,无法对这个类进行使用。

通过上述示例,我们在run.php中实现了:无论是哪个类名,都能统一走__autoload魔术函数,自动导入同一路径下的类定义文件,代码挺简洁。不过,__autoload只带上类一起玩耍吗?函数呢?常量呢?我们试一下:

<?php

// test.php

const TEST_CONST = 1;

function testFun(): void
{
    echo "testFun called\n";
}
<?php

// run.php

function __autoload(string $class): void
{
    include_once './test.php';
}

echo TEST_CONST . "\n";
testFun();

在与run.php同一路径下,创建test.php,内容如上,run.php也修改如上。运行的结果实锤了,__autoload确实不会让函数与常量加入自动加载的大家庭一起玩耍。其实,从__autoload魔术函数的定义就能看出,其接收的是字符串形式的类名,而非函数名或常量名。

那么,如果在run.php里头,__autoload与include同时存在呢?看看效果:

<?php

// run.php

function __autoload(string $class): void
{
    echo "from __autoload\n";
    include_once './' . $class . '.php';
}

include_once 'Habon.php';
Habon::f();

<?php

// run.php

function __autoload(string $class): void
{
    echo "from __autoload\n";
    include_once './' . $class . '.php';
}

Habon::f();
include_once 'Habon.php';

可见,只要在类被使用之前,就已通过手动加载方式导入了类定义文件,则__autoload将被忽略。

 

spl_autoload_register

细心的小伙伴可能已经发现:鄙人上面的例子,执行php指令为何都用的是7.1的版本?这里就要引出关于__autoload的缺点了。上文尽管已经初步展示了__autoload的便利之处,但毕竟如果就只有它1个魔术函数,显然是不能满足实际需要的。在稍微复杂一点的项目中,为了更好地维护代码,类定义文件可能会按照业务逻辑被划分到不同的路径下,不同路径的层次结构也可能不一样。如果我们需要用到多个类,它们的类定义文件分布于不同的路径下,这种情况必然是需要指定多个导入规则的。问题是,现在只有1个__autoload魔术函数给到我们使用,该函数又仅接收1个字符串形式的类名作为参数,那我们就得在其函数体,把所有类定义文件可能的路径,都在这枚举出来,形成对应的导入规则。如此一来,__autoload函数体就会变得很臃肿,十分不灵活。PHP开发大神们是绝对不允许这种情况发生的!

因此,更灵活更能体现设计模式思想的spl_autoload_register就诞生了,__autoload也自然而然从7.2版本之后的PHP中弃用:

<?php

// run.php

function __autoload(string $class): void
{
    include_once './' . $class . '.php';
}

Habon::f();

spl_autoload_register的用法参考:https://www.php.net/manual/zh/function.spl-autoload-register.php,链接已经描述得灰常详细,鄙人在这里只列出最简单的用法:

<?php

// Habon.php

class Habon
{
    public static function f(): void
    {
        echo "Habon called\n";
    }
}
<?php

// GodOfJiong.php

class GodOfJiong
{
    public function __construct()
    {
        echo "GodOfJiong created\n";
    }
}
<?php

// run.php

// 自定义的加载函数与已弃用的__autoload结构一致,接收1个字符串形式的类名作为参数,没有返回。
$loadClass = function(string $class): void
{
    $file = './test1/' . $class . '.php';
    if(file_exists($file) && is_file($file)){
        include_once $file;
    }
};

spl_autoload_register($loadClass);

// 除了可把函数变量注册给spl_autoload_register,也可直接注册匿名函数。
spl_autoload_register(function($class): void {
    $file = './test2/' . $class . '.php';
    if(file_exists($file) && is_file($file)){
        include_once $file;
    }
});

Habon::f();
$o = new GodOfJiong();

在run.php中,spl_autoload_register的先注册了test1路径下的类定义文件自动加载函数,然后再注册test2路径下的。在遇到Habon、GodOfJiong类时,先自动触发调用第1个注册的加载函数,Habon的类定义找到了,就不再往下进行了。而GodOfJiong的类定义仍未找到,继续调用第2个注册的加载函数,终于找到类定义。

至于遍历了spl_autoload_register注册的自动加载函数列表中所有函数后,仍未找到类定义文件,以及spl_autoload_register与include或require并存,还有spl_autoload_register有没有带上函数或常量一起玩耍……其效果和上文介绍__autoload中的对应场景一致,这里就不再一一赘述。至于如果spl_autoload_register与__autoload并存的情况,由于__autoload本来就是要弃用的,因此,两者并存时,__autoload将被忽略。

好了,以上就是PHP自动加载的基础。但光了解这些,貌似远远不够!至少,上文提到个问题并未得到解决:如果项目下的各个类定义文件分布在不同路径,还是得枚举多个自动加载函数,分别指定各路径下类定义文件的导入规则,然后都通过spl_autoload_register注册到自动加载函数队列中。这离我们理想的尽量少去手写加载逻辑还是有较大距离。要解决这个问题,我们应该把自动加载与命名空间结合起来,才能发挥出自动加载的真正威力和强大效果!

相信大家肯定已经联想到,利用PHP的各种框架(如Laravel、Yii、CI、TP、Hyperf等等)进行开发时,对某个类而言,直接通过use对完全命名空间限定前缀的类名取个别名,就可以如意使用这个类了。没有说要我们写什么自动加载函数,然后通过spl_autoload_register注册这些有的没的。这是怎么做到的?其实,这一切正是框架本身通过把自动加载与命名空间相结合,帮我们实现了的逻辑。

 

自动加载与命名空间的结合

有关命名空间的介绍,可参考鄙人的《PHP命名空间》。当然咯,上述实现过程还有赖于对PSR4加载规范的遵循,关于PSR4加载规范,有兴趣可参考《PHP PSR 标准规范》。咱们在这可以先简单粗暴有个大致印象:遵循PSR4规范的话,类的完全限定命名空间前缀与类定义文件所在路径是能一一对应上的,我们在使用框架开发的过程中,其实也已经肉眼发现了这点,通过类的完整命名空间便可直接对应找到其类定义文件,这就是遵循PSR4规范所带来的好处。

下面,鄙人就通过一个很简单的示例,模拟下自动加载与命名空间结合的效果。首先,我们随意创建若干将被使用的类,目录结构及代码如下:

<?php

// TestClass1

namespace test1;

class TestClass1
{
    public function __construct()
    {
        echo "TestClass1 construct\n";
    }
}
<?php

// TestClass2

namespace test1\subtest2;

class TestClass2
{
    public function __construct()
    {
        echo "TestClass2 construct\n";
    }
}
<?php

// TestClass3

namespace test1\subtest3\more;

class TestClass3
{
    public function __construct()
    {
        echo "TestClass3 construct\n";
    }
}
<?php

// TestClass4

namespace test2;

class TestClass4
{
    public function __construct()
    {
        echo "TestClass4 construct\n";
    }
}
<?php

// TestClass5

namespace test2\subtest5;

class TestClass5
{
    public function __construct()
    {
        echo "TestClass5 construct\n";
    }
}

可以注意到,这5个类各自声明的命名空间,与各自的文件路径是一一对应的。然后,我们单独创建个加载器类,它是整个示例的核心部分。鄙人已把思考过程都写在注释中了,直接看以下代码即可:

<?php

// AutoLoader.php

class AutoLoader
{
    // 首先,我们要定义好顶级命名空间与实际根路径的映射关系。
    // 该映射是个关联数组,每个元素的key为顶级命名空间的名称,value为对应的实际根路径。
    // value中的__DIR__用于获取当前脚本所在的绝对路径,DIRECTORY_SEPARATOR为当前操作系统使用的路径分隔符。
    public static array $rootPathMap = [
        'test1' => __DIR__ . DIRECTORY_SEPARATOR . 'test1',
        'test2' => __DIR__ . DIRECTORY_SEPARATOR . 'test2'
    ];

    // 然后,实现个方法,根据带有完全命名空间限定前缀的类名,转换成类定义文件的实际路径。
    public static function formFilePath(string $class): string
    {
        // 获取顶级命名空间的名称。
        // 例如,$class的值为"test1\subtest2\TestClass2",则$rootNameSpace为"test1"。
        $rootNameSpace = substr($class, 0, strpos($class, "\\"));

        /* 获取顶级命名空间对应的实际根路径,没有则返回空字符串。*/
        if(empty(self::$rootPathMap[$rootNameSpace])){
            return '';
        }

        $rootPath = self::$rootPathMap[$rootNameSpace];
        /**/

        // 获取带有完全命名空间限定前缀的类名中,除去顶级命名空间剩余的部分,拼上".php"后缀。
        // 例如,$class的值为"test1\subtest2\TestClass2",则$tmp为"\subtest2\TestClass2.php"。
        $tmp = substr($class, strlen($rootNameSpace)) . '.php';

        // 生成类定义文件的实际路径。
        return str_replace("\\", DIRECTORY_SEPARATOR, $rootPath . $tmp);
    }

    // 最后,实现自动加载方法。
    public static function loadClass(): callable
    {
        return function(string $class): void {
            $filePath = self::formFilePath($class);
            if((!empty($filePath)) && file_exists($filePath) && is_file($filePath)){
                include_once $filePath;
            }
        };
    }
}

至此,我们已经创建了加载器类AutoLoader,并提供了自动加载方法loadClass。接着,创建autoload.php,在里头通过spl_autoload_register,把刚实现的自动加载方法注册进去:

<?php

// autoload.php

include_once 'AutoLoader.php';

spl_autoload_register(AutoLoader::loadClass());

最终,我们就可以在自己的代码里头,仅导入autoload.php,随后,通过use为任意有完全命名空间限定前缀的类名取别名,就可以直接使用各个类了。如下的代码,是否已经比较接近使用框架开发的情形了:

<?php

// test.php

include_once 'autoload.php';

use test1\TestClass1;
use test1\subtest2\TestClass2;
use test1\subtest3\more\TestClass3;
use test2\TestClass4;
use test2\subtest5\TestClass5;

// 尽管这里直接通过别名使用类,但PHP启动加载流程时,会解析为带有完全命名空间限定前缀的类名。
// 这样一来,传递给我们定义的自动加载方法的$class参数,也将是带有完全命名空间限定前缀的类名。
$tc1 = new TestClass1();
$tc2 = new TestClass2();
$tc3 = new TestClass3();
$tc4 = new TestClass4();
$tc5 = new TestClass5();

运行下看看效果:

大功告成!我们进一步看看,修改下test.php并运行:

<?php

// test.php

include_once 'autoload.php';

use test1\TestClass1;
use test1\subtest2\TestClass2;
use test1\subtest3\more\TestClass3;
use test2\TestClass4;
use test2\subtest5\TestClass5;

$tc1 = new TestClass1();
$tc2 = new TestClass2();
$tc3 = new TestClass3();
$tc4 = new TestClass4();
$tc5 = new TestClass5();

$o = new \test2\subtest5\TestClass5(); // 回顾命名空间的相关知识,这里还是会解析为:test2\subtest5\TestClass5,其作为参数$class的值传给我们定义的自动加载方法。
$e = new \Exception('error'); // 如果能直接找到类定义,则自动加载流程会被忽略。
echo $e->getMessage() . "\n";

上述的道理,在注释中已经给出,不再赘述。实际上,上述示例可以视作是框架为我们实现的自动加载的最基本原理,当然咯,框架实现自动加载的完整逻辑,比上述要考虑得更多而复杂得多!涉及到异常情况、性能、设计模式等等因素,鄙人目前的水平还远未能参透这一部分!但鄙人相信,最基本的思想还是万变不离其宗的。基于以上的介绍,我们可以进一步研读框架的源码,感觉会稍微畅顺一丢丢,也能获得不少更深入更有含量的收获。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值