前言
本文是《自制php框架》之自动加载篇,笔者参照tp5框架的自动加载相关源码,写了几个p1~p4四个demo(放在我的github了),基本体现了从0到成型框架的自动加载的编写过程。文章篇幅很长,如果你属于以下情况,建议看下:
-
用过php框架,但不懂为何:只要
use app\model\User
(没有include或require
)就能直接用User类。 -
理解php是通过
spl_autoload_register
实现自动加载的,但心血来潮看了一下某个框架的源码,不理解大型一点的框架的自动机制代码为何能写那么长,是为了解决什么问题。 -
想理解composer的自动加载如何实现。
正文
背景知识
- 非自动加载的使用类的流程是先引入类所在文件,然后访问该类。
- php实现自动加载的核心是:当访问在其他文件定义的类时,会调用我们指定的处理函数,在该函数内引入类所在文件。
- 这个自动加载处理函数是通过
spl_autoload_register
注册(即指定)的,可以注册多个- 为什么需要注册多个?因为使用第三方包时,一般需要注册该包的自动加载处理函数,实现加载该包的类。
- 有多个就涉及顺序了,存储自动加载函数的数据结构是队列,即先注册的,优先使用,找不到再调后面的处理函数。当然,
spl_autoload_register(callback, $prepend)
,设置第二个参数为true,即可排到队首。 - 最初的自动加载处理方式是:直接在
__autoload(){}
写处理代码,但明显不能应对上面说的有多个自动加载处理函数的情况。
- 命名空间:
- 为什么要有命名空间?能区分同名的类
- 与自动加载的关系?自动加载处理函数里就是根据命名空间找到类所在文件的路径的。
- 最终期望实现的效果是:在A文件,想访问定义在B文件的类,只需
- 在B文件声明命名空间
- 在A文件
use
,然后访问。
P1:自动加载简单尝试
根据上面的背景知识,最直观能想到的,就是P1这种实现。也是网上大多数博文都有写到的。
先看看整体目录结果,后面的p2~p4目录结构相同。
p1
├── app ## 应用目录,开发者主要在这层写
│ ├── controller
│ │ └── User.php
│ └── model
│ └── User.php
├── fool ## 框架类库目录
│ └── Loader.php ## 自动加载类
├── index.php ## 入口文件
└── vendor ## 第三方包目录
└── pack1
└── A.php ## 第三个包pack1下的A文件,里面有A类
-
首先在入口文件
/index.php
定义根目录路径和调用自动加载类<?php define("DS", DIRECTORY_SEPARATOR); // linux下是/,windows是/或\ define("EXT", '.php'); define("ROOT_PATH", __DIR__ . DS); // 项目根目录 // 注册自动加载机制 require ROOT_PATH . 'fool/Loader.php'; \fool\Loader::register();
-
然后在
/fool/Loader.php
写具体的加载处理:- 注意一点:
autoload($class)
的参数$class
是自动传入的,其值就是想访问但找不到的类的完整类名(即包含命名空间部分的)。打印一下就知道了~ - 核心是
findFile()
,命名空间格式是namespace app\controller;
,linux下文件目录路径是app/contoller
,要找到文件,将传入命名空间的\
转成DS
,再拼上.php
,就是了。
<?php namespace fool; class Loader { /* * 注册自动加载处理函数 * @return void * */ public static function register() { spl_autoload_register("fool\\Loader::autoload", true, true); } /* * 自动加载处理函数 * @param string $class 类名 * @return bool * */ public static function autoload($class) { if ($file = self::findFile($class)) { return require $file; } return false; } /* * 查找文件 * @param string $class 类名 * @return bool|string * */ private static function findFile($class) { return ROOT_PATH . strtr($class, '\\', '/') . EXT; } }
- 注意一点:
-
测试:
- 在
/app/controller/User.php
声明User类,当然是要声明命名空间的。然后通过use方式,访问定义在app/model/User.php
的User
类。
<?php namespace app\controller; use app\model\User as UserModel; class User { public function index() { echo "this is controller User function index <br />\n"; $model = new UserModel(); $model->getList(); } }
- 在
/app/model/User.php
:
<?php namespace app\model; class User { public function getList() { echo "this is model User function getList <br /> \n"; } }
- 在入口文件
/index.php
访问
// new与目录对应的命名空间,成功 use app\controller\User; $user = new User(); $user->index();
- 运行
php index.php
,或通过web方式访问index.php。看到打印如下:表明访问成功
this is controller User function index this is model User function getList
- 在
-
似乎,自动加载就这么简单的实现了,上面写的最终实现效果也实现了。但,你也许有2个疑惑:
-
写了一堆代码,与最原始方式:用常量定义根目录,访问写在其它文件的类前
require ROOT_PATH . 文件路径
,再访问。似乎区别不大啊,并没简化多少工作量,访问前还是要use
,甚至多出一步,在类头声明命名空间,这样做的意义在哪?在类头声明命名空间是为了避免类重名,即使通过原始方式,也应该要在类头声明命名空间,访问前也要use。
即原始方式实际也是:require -> use -> 访问
而自动加载是:use -> 访问
use 是必需的,否则,程序怎么可能知道你要访问的是哪个类。
-
嗯,自动加载的好处我体会到了(省去了require步骤),那么,为什么那些框架的自动加载处理代码有那么长呢,为了解决什么问题?tp5.0自动加载源码。
-
-
其中一个要解决的问题是,也就是P1方式的致命缺陷,如果文件路径与声明的命名空间不对应。当使用第三方包时~看例子吧。
把第三方包放在
/vendor
目录下,/vendor/pack1
为一个第三方包,在/vendor/pack1/A.php
写,第三方包的命名空间不可能是vendow/pack1
的,别人用了存放第三方包的目录名未必是vendor
,因此第三方包基本都是这种格式。<?php namespace pack1; class A { public function work() { echo "this is a extra package pack1, class A function work was called <br /> \n"; } }
-
访问试试,在
/index.php
// new目录与命名空间不对应的 (项目目录/vendor/pack1/A.php, 命名空间pack1\A) 失败 use pack1\A; $p1 = new A(); $p1->work();
报错找不到
PHP Warning: require(/data/autoload/p1/pack1/A.php): failed to open stream: No such file or directory in /data/autoload/p1/fool/Loader.php on line 24
如何解决命名空间与所在文件路径不对应情况?
P2:添加注册命名空间机制
use pack1\A
要引入/vendor/pack1/A.php
,很容易想到,只要在自动加载处理函数函数里,将pack1
替换成/vendor/pack1
即可,即需要加多两个步骤:
- 注册命名空间:即将
pack1
与/vendor/pack1
的映射关系存到变量里。 - 找文件时:传入命名空间 + 映射关系 -> 文件路径
上面的代码不变,改的只有/fool/Loader.php
。
-
注册命名空间
- 添加私有的
$prefixDirsPsr4
变量,存命名空间与目录的映射关系。 - 添加私有的
addPsr4()
方法,将映射关系存储到$prefixDirsPsr4
变量。 - 添加public的
addNamespace()
方法, 目的有- 提供对外注册命名空间接口
- 使注册命名空间传参方便些。
private static $prefixDirsPsr4 = []; public static function addNamespace($namespace, $path = '') { if (is_array($namespace)) { foreach ($namespace as $prefix => $paths) { self::addPsr4($prefix, $paths, true); } } else { self::addPsr4($namespace, $path, true); } } private static function addPsr4($prefix, $paths, $prepend = false) { if (!isset(self::$prefixDirsPsr4[$prefix])) { // 注册新的命名空间 self::$prefixDirsPsr4[$prefix] = (array) $paths; } else { // 为已有命名空间添加对应目录 self::$prefixDirsPsr4[$prefix] = $prepend ? array_merge((array) $paths, self::$prefixDirsPsr4[$prefix]) : array_merge(self::$prefixDirsPsr4[$prefix], (array) $paths); } }
- 添加私有的
-
注册框架必须的命名空间:
public static function register() { spl_autoload_register("fool\\Loader::autoload", true, true); // 添加命名空间 对应目录 self::addNamespace([ 'app' => APP_PATH, 'fool' => FOOL_PATH, ]); }
-
修改找文件方式:思路是识别出传入命名空间里首次出现的
/
的索引,将前面替换成对应文件路径,再拼接后面。private static function findFile($class) { // 先直接 命名空间 转成 路径 $logicalPathPsr4 = strtr($class, '\\', DS) . EXT; // 根据(命名空间 与 目录)映射 替换前缀 $len = strpos($logicalPathPsr4, '/'); $cPrefix = substr($logicalPathPsr4, 0, $len); $follow = substr($logicalPathPsr4, $len+1); foreach (self::$prefixDirsPsr4 as $prefix => $dirs) { if ($prefix == $cPrefix) { foreach ($dirs as $dir) { if (is_file($file = $dir . $follow)) { return $file; } } } } return false; }
-
与P1同样调用的代码,在
/index.php
// new与目录对应的命名空间,成功 use app\controller\User; $user = new User(); $user->index();
运行,成功
this is controller User function index this is model User function getList
-
注册额外的命名空间,在
/index.php
。这就是为什么addNamespace()
方法了要设为public。// 设置命名空间pack1 对应 目录 vendow/pack1 \fool\Loader::addNamespace('pack1', ROOT_PATH . 'vendor/pack1'. DS);
-
new命名空间与文件所在路径不对应的类,在
/index.php
// new目录与命名空间不对应的 (项目目录/vendor/pack1/A.php, 命名空间pack1\A) 也成功 use pack1\A; $p1 = new A(); $p1->work();
运行,成功
this is controller User function index this is model User function getList this is a extra package pack1, class A function work was called
P3:完善注册命名空间机制
p3实现的效果与p2基本相同,p3基本就是tp5那套,而tp5那套很像composer那套。
-
用了官方的规范:对于映射关系中命名空间结尾是否要加
\
,目录结尾是否要加/
,想必很乱,按tp5的来,这里统一:- 命名空间结尾加
\
:如fool\
,app\
- 目录结尾不加
/
:如/data/autoload/p3/fool
- 命名空间结尾加
-
其它代码不变,修改
/fool/Loader.php
-
添加
$prefixLengthsPsr4
:变量的结构是,按注册的命名空间的首字母划分,存了其长度Array ( [a] => Array ( [app\] => 4 [aaaaa\] => 6 ) [f] => Array ( [fool\] => 5 ) )
-
addPsr4()
和findFile()
方法也改成对应那套private static $prefixLengthsPsr4 = []; private static function addPsr4($prefix, $paths, $prepend = false) { if (!isset(self::$prefixDirsPsr4[$prefix])) { // 注册新的命名空间 self::$prefixDirsPsr4[$prefix] = (array) $paths; // 记录前缀长度 $length = strlen($prefix); if ('\\' !== $prefix[$length - 1]) { // PSR-4规范,非类的命名空间应该以\结尾 echo 'A non-empty PSR-4 prefix must end with a namespace separator.'; } self::$prefixLengthsPsr4[$prefix[0]][$prefix] = $length; } else { // 为已有命名空间添加对应目录 self::$prefixDirsPsr4[$prefix] = $prepend ? array_merge((array) $paths, self::$prefixDirsPsr4[$prefix]) : array_merge(self::$prefixDirsPsr4[$prefix], (array) $paths); } } private static function findFile($class) { // 先直接 命名空间 转成 路径 $logicalPathPsr4 = strtr($class, '\\', DS) . EXT; // 根据(命名空间 与 目录)映射 替换前缀 $first = $class[0]; if (isset(self::$prefixLengthsPsr4[$first])) { foreach (self::$prefixLengthsPsr4[$first] as $prefix => $length) { if (0 === strpos($class, $prefix)) { foreach (self::$prefixDirsPsr4[$prefix] as $dir) { if (is_file($file = $dir . DS . substr($logicalPathPsr4, $length))) { return $file; } } } } } return false; }
-
笔者也还没能理解这样写好处在哪,猜想是注册时,即新增数据时,添加索引,查找时,根据索引找效率会更高,(自动加载确实应该考虑效率)。但findFile时始终要遍历去找,与P2相比,貌似并不能减少循环次数。
P4:完善自动加载机制
上面P2,P3基本没问题了,只要给类定义好命名空间,不管类所在文件放在哪,只要将命名空间注册,用前use下,就能用了。
但还能优化,从效率方面考虑。
如,当框架成型,每次请求运行,都必须要找自定义异常处理类,日志类等,框架目录结果基本是固定的,但现在的方式对这种命名空间与目录确定的关系,还是要遍历去找, 是否觉得这部分遍历是多余的?同理,当项目开发完,大部分文件只需小改,文件路径不会大改,是否可以省去遍历查找。
答案是肯定的:只需记录完整命名空间 => 文件路径。下面第一点就是相应处理。下面的第二点是可简单理解为设置默认目录,如果都找不到,就去默认目录里找。第三点:我目前还没用过,看tp5有就也加上去了。
下面是P4的所有优化:
1. 添加类名映射
-
在
/fool/Loader.php
-
添加
$classMap
变量:存储完整命名空间 与 文件路径 的映射关系,结构是:Array ( [TestClassMap] => /data/autoload/p4/classMap/TestClassMap.php )
-
添加public的注册类名映射方法
addClassMap()
-
findFile()
时,优先通过$classMap
判断。
private static $classMap = []; public static function addClassMap($class, $map = '') { if (is_array($class)) { self::$classMap = array_merge(self::$classMap, $class); } else { self::$classMap[$class] = $map; } } private static function findFile($class) { // 类库映射 具体指定的,优先级最高 if (isset(self::$classMap[$class])) { return self::$classMap[$class]; } // 遍历prefixDirsPsr4找... // 找不到记录一下映射为false, 并返回false return self::$classMap[$class] = false; }
-
-
在
/classMap/TestClassMap.php
,加<?php class TestClassMap { public function work() { echo "this is classMap class A function work <br /> \n"; } }
-
在
index.php
注册类名映射,并运行之// 注册类库映射 Loader::addClassMap('TestClassMap', ROOT_PATH . 'classMap/TestClassMap.php'); $tcp = new TestClassMap(); $tcp->work();
// 输出 this is classMap class A function work
2.添加回退目录:即默认目录
-
在
/fool/Loader.php
添加,$fallbackDirsPsr4
结构:Array ( [0] => /data/autoload/p4/extend )
private static $fallbackDirsPsr4 = []; public static function register() { // ... // 自动加载extend目录 self::$fallbackDirsPsr4[] = rtrim(EXTEND, DS); } private static function findFile($class) { // 类库映射 查找,优先级最高 // ... // 根据prefixDirsPsr4找 // ... // 指定目录找不到 从PSR-4回退目录(也可理解为默认目录)找 foreach (self::$fallbackDirsPsr4 as $dir) { if (is_file($file = $dir . DS . $logicalPathPsr4)) { return $file; } } // 找不到记录一下映射为false, 并返回false // ... }
-
添加
/extend/e1/A.php
,写上<?php namespace e1; class A { public function work() { echo "this is extend e1 class A function work <br /> \n"; } }
-
在
/index.php
// new扩展目录的类 use e1\A as eA; $e = new eA(); $e->work();
运行,输出
this is extend e1 class A function work
3. 添加类别名
-
在
/fool/Loader.php
,添加$namespaceAlias
,结构:Array ( [model] => app\model )
private static $namespaceAlias = []; public static function addNamespaceAlias($namespace, $original = '') { if (is_array($namespace)) { self::$namespaceAlias = array_merge(self::$namespaceAlias, $namespace); } else { self::$namespaceAlias[$namespace] = $original; } } public static function autoload($class) { // 检测命名空间别名,若匹配,则返回(并不加载)后面再进来findFile后加载 if (!empty(self::$namespaceAlias)) { $length = strpos($class, '\\'); $prefix = substr($class, 0, $length); if (isset(self::$namespaceAlias[$prefix])) { $original = self::$namespaceAlias[$prefix] . substr($class, $length); if (class_exists($original)) { return class_alias($original, $class, false); } } } if ($file = self::findFile($class)) { return require $file; } return false; }
-
在
/app/model/Goods.php
加<?php namespace app\model; class Goods { public function getList() { echo "this is model Goods function getList <br /> \n"; } }
-
在
/index.php
,加// 添加别名 Loader::addNamespaceAlias('model', 'app\model'); use model\Goods; // 使用了别名找类 $goods = new Goods(); $goods->getList();
运行之,输出
this is model Goods function getList
P5
添加composer的自动加载处理
P6
添加PSR0规范的处理
总结
P4基本就能用了,添加composer处理和PSR-0处理以后有时间再写了。简单看了composer的Loader.php
,大概也是这回事。而PSR-0,感觉写了相关处理也用不上。
额外写一句:php的自动加载花了2天多来理解,写demo并总结,以后看其它语言的自动加载,理解应该不难了。
通过Loader类的属性来总结下吧,实在无力写了。
重要的:
prefixDirsPsr4
classMap
次要的:
prefixLengthsPsr4
fallbackDirsPsr4
namespaceAlias