在过去的一周里,我一直在研究ZendFramework中自动加载的不同策略。一段时间以来,我一直怀疑我们的类加载策略可能是性能下降的一个来源,并且想研究一些不同的方法,并进行比较性能。
在这篇文章中,我将概述我尝试过的方法、我应用的基准测试策略以及对每种方法进行基准测试的结果。
自动加载策略
我将策略分为两类,PEAR/PSR-0策略和类图策略。
我还开始测试第三类。这包括需要PECL扩展的解决方案,特别是SplClassLoader和Automap扩展。但是,我最终放弃了这些解决方案。对于SplClassLoader
,我实际上开始测试它,但立即遇到了段错误。这个不幸的事件让我记得我正在寻找我们可以在ZendFramework中使用的用户空间自动加载器;SplClassLoader
和Automap
都可以由用户在任何时候放入,但由于针对您的平台编译和安装的要求,永远不会成为使用ZendFramework的明确要求。
梨/PSR-0
那些熟悉ZendFramework的人都知道我们遵循PEAR编码标准,并且,对于这个练习,他们的1-class-1-file命名约定。PEAR命名约定规定了文件系统和类之间的1:1关系。例如,类Foo_Bar_Baz
可以在文件Foo/Bar/Baz.php
中找到在您的include_path
上。
这是一个非常容易记住的约定,并且已在PHP世界中被广泛采用。PHPFrameworkInteroperabilityGroup迄今为止举行的唯一一次投票,http://groups.google.com/group/php-standards/web/psr-0-final-proposal,已经简单地批准了这个标准(有一些关于名称空间的额外措辞)。ZendFramework的自动加载器从1.10.0开始就符合PSR-0(并且在此之前符合PEAR)。
因此,第一种方法是简单地使用现状。
也就是说,我还查看了其他符合PSR-0的方法以寻求一些灵感。SplClassLoader
由Doctrine项目的JonWage提出,作为GitHub要点与ZF实现有一些不同:
- 它允许指定一个特定的目录,在该目录下查找特定的命名空间。
- 您可以为要加载的每个命名空间创建一个实例,而不是充当单例,然后调用它的
register()
方法注册到spl_autoload
注册表。
此外,我查看了Symfony2UniversalClassLoader
。虽然它以SplClassLoader
实现为基础,但它提供了我们已经添加到ZF2自动加载器的功能:能够注册名称空间和供应商前缀以自动加载。我将这些想法结合到自定义PSR-0实现中。
(注意:SplClassLoader
扩展是从GitHub
要点到C扩展的类的文字端口。)
类映射
我看到的下一类自动加载器解决方案的最佳特征是术语“类映射”。在这个策略中,您创建一个类名/文件名对的映射,并将其提供给您的自动加载器。
在我看来,该策略的主要优势包括:
- 能够根据需要偏离PSR-0标准(例如,应用程序资源)。
- 能够为每个组件放入一个类映射。对于像ZF这样的库,这为分发具有较少依赖性的单个组件提供了可能性,因为您不需要同时运送工件,例如您的自动加载器。
- 尽早失败。如果映射中不存在该类,自动加载器可以提前退出,允许您转到链中的另一个自动加载器—或者简单地让PHP提高它的
E_FATAL
类未找到报告。 - 随意拜访。您可以在开发期间使用动态自动加载器,但在部署或构建期间运行脚本以生成类映射自动加载器。这使您能够获得RAD循环的好处,同时还能获得性能优化的自动加载器的好处。
我将在稍后检查基准时详细说明最后一点。
尽管PSR-0风格的自动加载器很流行,但仍有许多类映射自动加载器在使用。ez/zeta组件已经使用了多年,部分原因是使用了不符合PSR-0的命名约定,还有部分原因是出于性能方面的考虑。ArneBlankerts还在GitHub上以他的Autoload库的形式向我介绍了一个这样的解决方案。
构建类映射时,您可以在开发时手动构建它们,也可以使用脚本。我喜欢基于脚本的方法,因为它确保我不会忘记将项目添加到地图中,而且因为它是我可以运行现有库然后放入的东西。
构建这样的脚本时,算法非常简单:
- 递归扫描文件系统树
- 对于找到的每个PHP文件,扫描接口、类和抽象类
- 对于每个匹配项,存储完全限定的类名和文件路径
我之前在博客中介绍了扫描文件系统树的解决方案;使用该博客中引用的ClassFileLocater
类并生成实际类映射的脚本示例可以在我的GitHub帐户中找到。
我采用了三种不同的方法来生成类图:
- 将文件路径存储为相对于
include_path
- 使用
__DIR__
作为路径前缀存储文件路径 - 使用
__DIR__
作为路径前缀存储文件路径,并将映射直接传递给使用spl_autoload
注册的闭包。
在前两种情况下,地图存储在脚本返回的数组中。然后,我将映射文件的位置传递给自动加载器,该自动加载器对该文件执行include
,存储映射(可选择与它已经包含的映射合并),然后使用该映射进行类查找。您可以在GitHub上找到这个自动加载器。
第三种情况是从ArneBlankert的Autoload库中借用的技巧。在几个方面偏离了他的设计。首先,Arne将地图定义为闭包的静态成员。从理论上讲,这应该确保每个请求只定义一次地图。然而,在我的测试中,我发现每次调用闭包时,地图实际上都是在内存中构建的,这导致性能严重下降。因此,myversion在本地范围内创建一个变量,然后通过use
语句将其传递给闭包:
namespace test; $_map = array( /* ... */ ); spl_autoload_register(function($class) use ($_map) { if (array_key_exists($class, $_map)) { require_once __DIR__ . DIRECTORY_SEPARATOR . $_map[$class]; } });
注意命名空间
声明;这允许您创建多个autoloadclass地图文件,而不会破坏以前加载的地图。
对标策略
基准测试自动加载有些困难;一旦你自动加载了一个类,你就不能再次自动加载它。此外,库中往往包含有限数量的类,从而提供有限的数据集作为基准。最后,如果不针对操作码缓存进行测试,则不应该进行良好的自动加载基准测试——如果类文件太多,您可以轻松击败缓存。
我的解决方案有两个:
- 创建一个脚本来生成有限的、大量的类文件
- 确定有多少类文件提供了一个合理的集合,这样操作码缓存就不会失效,但也使得可测量的差异可能被观察到。
生成类文件的脚本很容易创建。我所需要的只是一个递归函数,它会遍历字母表中的字母并生成类,直到达到特定深度;您可以在GitHub上检查脚本。
我从查看26^3
类文件开始,这非常好,忘记了一些初始统计数据。然而,一旦我开始针对操作码缓存进行基准测试,我发现我正在打破实际路径缓存以及我的操作码缓存的内存限制。然后我将采样大小减少到16^3
个文件。事实证明,此采样大小足够大,既可以在多次运行时生成可靠的结果,又可以让缓存正常工作。
在进行基准测试时,我运行了以下策略:
- 基线:无自动加载器(所有
class_exists()
调用均失败) - ZF1自动加载器(符合PSR-0,使用
include_path
) - PSR-0自动加载器(使用显式名称空间/路径对,无
include_path
ClassMapAutoloader
(使用include_path
)ClassMapAutoloader
(使用__DIR__
前缀路径)- Classmap使用
__DIR__
-前缀路径,闭包直接注册到spl_autoload
我的测试算法如下:
- 加载树中所有类的列表
- 执行任何必要的设置以准备给定的自动加载器
- 遍历列表中的所有类,并利用
class_exists
自动加载
计时仅在循环中执行。为每个策略运行10次基准测试,以确定平均值并校正异常值。此外,在使用和不使用字节码缓存的情况下都执行了基准测试,以查看两种环境中可能出现的差异。此类脚本的示例如下:
include 'benchenv.php'; require_once '/path/to/zf/library/Zend/Loader/ClassMapAutoloader.php'; $loader = new Zend\Loader\ClassMapAutoloader(); $loader->registerAutoloadMap(__DIR__ . '/test/map_abs_autoload.php'); $loader->register(); echo "Starting benchmark of ClassFile map autoloader using absolute paths...\n"; $start = microtime(true); foreach ($classes as $class) { if (!class_exists($class)) { echo "Aborting test; could not find class $class\n"; exit(2); } } $end = microtime(true); echo "total time: " . ($end - $start) . "s\n";
我为每个测试用例准备了一个这样的脚本。为了自动运行所有此类脚本,并对每个脚本进行10次迭代,我编写了以下脚本:
#!/usr/bin/zsh # benchmark_noaccel.sh # No opcode caching for TYPE in baseline.php classmap_abs.php classmap_inc.php spl_autoload.php psr0_autoload.php zf1_autoload.php;do echo "Starting $TYPE" for i in {1..10};do curl http://autoloadbench/$TYPE done done #!/usr/bin/zsh # benchmark_accel.sh # With opcode caching for TYPE in baseline.php classmap_abs.php classmap_inc.php spl_autoload.php psr0_autoload.php zf1_autoload.php;do echo "Starting $TYPE" # Clear Optimizer+ cache curl http://autoloadbench/optimizer_clear.php for i in {1..10};do curl http://autoloadbench/$TYPE done done
我重新运行了几次测试,以确保没有发现异常。虽然我收到的实际数字在迭代之间有所不同,但从统计学上讲,它们在运行之间仅相差几个百分点。
结果
无操作码缓存
|攻略|平均时间(秒)||:————————|——————:||基线|0.0067||ZF1自动装载机(包括路径)|1.2153||PSR-0(无包含路径)|1.0758||类映射(include_path)|0.9796||类映射(__DIR__
)|0.9800||SPL关闭|0.9520|
使用操作码缓存
|攻略|全部平均值|取消加速|最短|平均加速||:——–|:——————–:|:—–:|:——:|:—————-:||基线|0.0061|0.0053|0.0052|0.0062||ZF1自动装载机(inc_path)|0.4855|1.4444|0.3653|0.3789||PSR-0(无包含路径)|0.4021|1.5477|0.2437|0.2748||类映射(include_path)|0.3022|1.2755|0.1724|0.1941||类映射(__DIR__
)|0.2568|1.2253|0.1362|0.1492||SPL关闭|0.2630|1.2971|0.1341|0.1481|
分析
这三个类映射变体是明显的赢家,在没有加速的情况下,ZF1自动加载器的性能提高了大约25%,而在操作码缓存到位时,性能提高了60–85%。当没有操作码缓存时,这三种类映射方法大致相同,但当操作码缓存存在时,不使用include_path
的变体在统计上更快。
也许对我自己来说最有趣的发现是了解include_path
对性能的影响有多大,尤其是在使用操作码缓存时。在每种情况下,我都将包含我的测试类的目录列为include_path
中的第一项——这是最佳位置(我之前完成的基准测试和分析表明,匹配条目越深,性能就会迅速下降include_path
)。即使在非加速测试中,不使用include_path
的PSR-0实现也快了>10%,随着加速的增加,这一差异跃升至近40%。类映射实现也存在相同的差异。
虽然这些更改是非常微观的优化(请记住,上面的数字表示加载了16^3
个类—单个类加载大约为1/10,000秒),如果您正在加载在您的应用程序生命周期中有数百或数千个独特的类,证据清楚地显示了使用类映射和完全限定路径带来的一些显着性能优势。
结论
每种方法都有其优点。在开发过程中,您不希望每次添加新类时都必须运行脚本来生成类映射或手动更新类映射。就是说,如果您预计您的站点有大量流量,那么在部署期间运行脚本来为您构建类映射非常容易,从而让您从应用程序中获得一点额外的性能。
从这个实验中可以清楚地认识到,include_path
是解析当前PHP版本中文件的一种不良方式。虽然当您的初始路径匹配时降级并不大,但它仍然会影响性能。此外,它会使应用程序的设置更加困难,因为您必须记录include_path
的正确用法;将__DIR__
与PSR-0样式或类映射自动加载器一起使用更容易移植,并且需要对最终用户进行更少的教育。
基于类映射的自动加载器的一个有趣用例是帮助优化现有的ZF1应用程序。该脚本可以在您的“应用程序”目录上运行,以构建您的应用程序资源类的类映射。这将允许您绕过各种资源自动加载器和调度程序的自定义逻辑来查找控制器类。它还可以作为ZF1和ZF2之间迁移套件的一部分。
我将建议ZendFramework2.0同时使用PSR-0和类映射策略,而不依赖于include_path
,并提供工具和脚本来帮助部署。