exteon / chaining-class-resolver
为PHP模块插件提供类链编织的类加载器框架
Requires
- exteon/class-helper: ^1.2.0
- exteon/mapping-class-loader: ^4.0
- nikic/php-parser: ^4.2.4
Requires (Dev)
- phpunit/phpunit: ^8.5
README
与mapping-class-loader一起,这个库提供了一个框架插件开发和加载的平台。通过使用“链式加载”的概念,它提供了一个位于混入和多重继承之间的实现平台。
摘要
一个提供插件功能的平台常见的问题是这样的:我们有多个针对相同代码库(相同的合约)开发的插件,但是它们互相之间并不知情(它们必须像黑盒一样对待其他插件)。
由于插件的本质,它们与它们扩展或定制的基代码紧密耦合。同时,加载的插件的效果需要是可叠加的。多个与代码库耦合的插件必须组合在一起才能一起工作。
我们发现一些常见的解决方案存在不足之处
- 基于装饰器的方法提供的耦合性不足,插件结构是一个“洋葱层”,其中外层的输出无法馈送到内层的输入
- 观察者模式插件系统(钩子、事件驱动、通用调用混入等)实现、维护和调试成本很高
大多数高效的插件模式依赖于混入,类似于多重继承。虽然PHP的Traits是一个了不起的成就,但它不足以实现插件所需的混入模式,因为它们缺乏静态身份(静态属性被复制到实现它们的类中)。
我们的解决方案提供了一种以链式(分层)动态结构加载插件的方法(类似于装饰器),但使用源编织来修改类层次结构,使得结果继承模型完全耦合。
以下插图将提供对链式过程的更直观解释。让我们从如何从代码库开始,插件开发者通过扩展现有类来添加特殊化,目的是使它们的特殊化替换初始实现
所以在上面的图像中,Plugin1和Plugin2都扩展了基代码,而Plugin3基于Plugin2。
问题是,我们如何重新组合这些结构,以便在应用程序中只使用一个A类和一个B类,就可以使用所有3个插件?
这时,chaining-class-resolver就出现了
通过chaining-class-loader,代码以模块的形式出现,所有基代码和插件都是将被线性化的模块。这是在类加载时间通过更改继承来完成的。在第二个图中,你会注意到\Plugin2\A现在继承自\Plugin1\A,而不是像原始源(第一个图)中那样从\Code\Base\A继承。
所有这些线性化的类随后被投影到一个目标命名空间中,以便应用程序可以使用,例如\Target\A,而对生成它的链相对无知。
这种类编织是在运行时完成的,因此这个过程对应用程序开发者来说是透明的,只需要配置模块。因此,插件可以从任何来源(如composer)引入,而且不需要对应用程序进行任何特定于应用的定制,chaining-class-resolver将完成所有魔术。
用法
您可以在上面的图中找到示例在示例目录中实现。
加载器设置部分在setup.inc.php中。
要运行示例,请运行 app.php
设置链式类加载的步骤如下
创建模块
我们将模块组织在文件夹中,具有 PSR-4 类结构
- base 命名空间为
Code\Base - plugins/plugin1 命名空间为
Plugin1 - plugins/plugin2 命名空间为
Plugin2 - plugins/plugin3 命名空间为
Plugin3
每个模块定义或扩展类 A 和/或 B,如上图所示
你可以使用任何目录结构,只要你有多个模块目录,每个目录都包含一个 PSR-4 命名空间。
链式解析器
我们获取一个解析器实例
use Exteon\Loader\ChainingClassResolver\ChainingClassResolver; use Exteon\Loader\ChainingClassResolver\Module; use Exteon\Loader\ChainingClassResolver\ClassFileResolver\PSR4ClassFileResolver; $chainingClassResolver = new ChainingClassResolver( [ new Module( 'Code base', [new PSR4ClassFileResolver(__DIR__ . '/base', 'Code\\Base')] ), new Module( 'Plugin 1', [new PSR4ClassFileResolver(__DIR__ . '/plugins/plugin1', 'Plugin1')] ), new Module( 'Plugin 2', [new PSR4ClassFileResolver(__DIR__ . '/plugins/plugin2', 'Plugin2')] ), new Module( 'Plugin 3', [new PSR4ClassFileResolver(__DIR__ . '/plugins/plugin3', 'Plugin3')] ) ], 'Target' );
构造函数参数 $targetNs 定义了类链将被编织进的目标命名空间,在我们的例子中,类将在 \Target 下链式化。
模块将按照它们被发送给构造函数的顺序链式化;这意味着,在上面的例子中,Plugin 1 将扩展/覆盖 Code base 中的类,Plugin 3 将覆盖所有类等。
设置加载器
我们将使用 exteon/mapping-class-loader 来加载链式文件
use Exteon\Loader\MappingClassLoader\MappingClassLoader; use Exteon\Loader\MappingClassLoader\StreamWrapLoader; $loader = new MappingClassLoader( [], [ $chainingClassResolver ], [], new StreamWrapLoader([ 'enableMapping' => true ]) ); $loader->register();
有关使用 MappingClassLoader 的更多详细信息,请参阅 exteon/mapping-class-loader 文档。
使用链式类
我们现在可以使用链式类。所有类都定义或覆盖了 whoami() 方法,并添加了结果。
use Target\A; use Target\B; $a = new A(); $b = new B(); var_dump($a->whoami()); var_dump($b->whoami());
上面的代码将产生以下结果
array(3) {
[0] =>
string(11) "Code\Base\A"
[1] =>
string(9) "Plugin1\A"
[2] =>
string(9) "Plugin2\A"
}
array(2) {
[0] =>
string(9) "Plugin2\B"
[1] =>
string(9) "Plugin3\B"
}
所以现在类就像这张图中所示的那样链式化。
类提示文件
如果你在智能 IDE 中打开 app.php,则 Target 命名空间中的类将无法解析,并且你将无法为它们提供自动完成。这是因为 Target\A 和 Target\B 还未在代码的任何地方定义。
为了解决这个问题,我们需要创建包含 Target 类存根的类提示文件。我们可以通过在 example 目录中运行 create-hints.php 来实现这一点。
使用相同的 setup.inc.php,这个工具会运行
$loader->dumpHintClasses(__DIR__.'/dev/hints');
这个工具会在你指定的目录中生成类提示文件到 dev/hints。这些是具有 PSR-4 结构的 PHP 类文件,它们将在你指定的目录中生成。现在只需将提示目录添加到你的 IDE 的源目录中,现在,你的 Target\A 类将被定义为以下内容
namespace Target { /** * @extends \Code\Base\A * @extends \Plugin1\A */ class A extends \Plugin2\A {} }
注意
每次你向项目中添加类或更改类层次结构、重新配置模块等时,都应该运行 dumpHintClasses 工具来重新生成提示文件。
注意
提示类只包含一个扩展,即 Plugin2\A,它在您的源文件中扩展了 Code\Base\A。因此,如果您现在在 Plugin1\A 上添加一个 someMethod() 方法,在静态分析 Target\A 时,该方法将无法识别,除非您的IDE可以读取多个 @extends 注解。
在未来的版本中,我们可能会通过始终使用特性来扩展类来解决这个问题。(参见特性链)
然而,插件的主要功能是修改(即覆盖)现有类的方法。因此,我们建议您在考虑扩展现有类的契约时,考虑使用单独的特性来实现,或者使用对象组合将添加的功能保持到不同的类中。
其他功能
调试
尽管 chaining-class-resolver 通过修改(交织)源代码来实现其功能,但可以通过使用 exteon/mapping-class-loader 的映射功能轻松地进行步骤调试。在调试代码时,您将像往常一样在原始类文件上执行步骤。您需要做的唯一事情,就像上面的代码示例一样,是将 'enableMapping' => true 设置到 StreamingWrapLoader 的配置中。
有关映射功能的更多信息,请参阅exteon/mapping-class-loader文档。
缓存
类交织过程可能是一个很大的开销;exteon/mapping-class-loader 为交织的类文件提供了缓存。要启用缓存,您需要将 enableCaching 和 cacheDir 配置选项设置到 MappingClassLoader 构造函数中。有关缓存的更多详细信息,请参阅exteon/mapping-class-loader文档。
对于开发,每次更改源类时,都需要清除和重新生成缓存;有一个基于 inotify 的更改监视器的开发正在进行中,但直到那时,如果您在开发环境中使用缓存,您应该创建自己的工具。
特性链
在PHP中,实现相同方法的特性不能添加到同一个类中(《参见这里》)。虽然这是复制粘贴特性实现的产物,但它也是特性可重用性和表现力的一个缺点。由于 parent:: 和 static:: 都支持特性,而PHP不支持重载,因此多个特性可以覆盖相同的基方法。
chaining-class-resolver 通过线性化(链式)类 uses 子句中的特性列表来支持这一点,创建中间类,每个类使用一个特性。因此,特性的优先级是它们在 use 子句中列出的顺序。
例如,这是使用 chaining-class-resolver 的可能性(请注意,根据PSR-4标准,不同的类需要在不同的文件中实现)
class A { public function whoami(){ return ['A']; } }
trait T1 { public function whoami(){ return array_merge(parent::whoami(),['T1']); } }
trait T2 { public function whoami(){ return array_merge(parent::whoami(),['T2']); } }
class B extends A { use T1,T2; public function whoami(){ return array_merge(parent::whoami(),['B']); } }
var_dump((new B())->whoami());
这将列出 ['A','T1','T2','B'] 作为类继承。
请参阅多重继承注意事项,以了解更多关于影响的讨论。
多重继承注意事项
如果链式类或多个特性定义了相同名称的新方法,就会出现臭名昭著的菱形问题。chaining-class-resolver 对此没有特殊处理,但 PHP 也没有(没有语法可以明确表示重写)。因此,只要按照 PHP 规则,第二个方法的签名与第一个方法兼容,就可以假设它是重写的。
将来我们可能会实现更严格的机制来检测和解决菱形问题。
链式反射
您可以使用 ChainedClassMeta 在运行时查找有关链式类的信息。
ChainedClassMeta::get('Some\Class')->isChained()
返回是否有名为指定类的链。
ChainedClassMeta::get('Some\Class')->getChainParent()
返回 Some\Class 的链父类作为一个 ChainedClassMeta 对象,如果没有则返回 null。使用 ->getClassName() 获取这个父类的类名。
ChainedClassMeta::get('Some\Class')->getChainedClass()
返回 Some\Class 的链目标类(即目标命名空间中的链的末端)。
ChainedClassMeta::get('Some\Class')->getModuleName()
返回定义 Some\Class 的模块的名称。
ChainedClassMeta::get('Some\Class')->getChainTraits()
返回在 Some\Class 的链中由所有链式类添加的特性。(但不包括继承的特性)。
ChainedClassMeta::get('Some\Class')->getChainInterfaces()
返回在 Some\Class 的链中由所有链式类添加的接口。(但不包括继承的接口)。