exteon/chaining-class-resolver

Weaving class loader framework for PHP modular plugins providing class chaining

2.1.1 2022-04-17 14:38 UTC

This package is auto-updated.

Last update: 2024-05-16 01:33:25 UTC


README

Together with mapping-class-loader, this library provides a framework plugin development and loading. By using a "chain-loading" concept, it provides an implementation platform sitting somewhere between mixins and multiple inheritance.

Abstract

A common problem concerning a platform that offers plugins functionality is this: we have a number of plugins that are developed against the same code-base (the same contracts), however they are unaware of each other (they must act like black-boxes for other plugins).

By their nature, plugins are tight-coupled to the base code they extend or customize. At the same time, loaded plugins' effect needs to be additive. Multiple plugins coupled to the codebase must be aggregated to work together.

Some of the solutions commonly implemented we found coming short:

  • decorators-based approach doesn't provide enough coupling, plugins structure is an "onion layer" where output of an outer layer cannot be fed to the input of an inner layer
  • observer pattern plugin systems (hooks, event-driven, generic invoker mixins ect) are very costly to implement, maintain and debug

Most efficient plugin patterns rely on mixins, which are akin' to multiple inheritance. PHP's Traits, while a great feat, are not sufficient to implement the mixin patterns needed for plugins, as they lack a static identity (static properties are duplicated to their implementing classes).

Our solution provides a way of loading plugins in a chaining (layered) dynamic structure (much like decorators), but uses source weaving to modify class hierarchy so that the resulting inheritanve model is fully-coupled.

The below illustrations will provide a more visual explanation of the chaining process. Let's start with how, starting from a codebase, plugin developers would add specialisation by extending the existing classes, with the purpose that their specialisations will replace the initial implementation:

So in the image above, both Plugin1 and Plugin2 extend the base code, while Plugin3 is based on Plugin2.

The question is, how do we rejoin these structure so that we can use all 3 plugins in an app, with only one A class and one B class?

In comes chaining-class-resolver:

With chaining-class-loader, code comes in modules, and all base code and plugins are modules that will be linearized. That is done at class load time, by changing the inheritance. In the second diagram, you will have noticed how \Plugin2\A now inherits from \Plugin1\A, not from \Code\Base\A as was in the original source (first diagram).

All such linearized classes are then projected into a Target namespace so that an application can use i.e. \Target\A with relative obliviousness to the chain that generated it.

This class weaving is done at runtime, so the process is transparent to the application developer, that just needs to configure the modules. Thus, the plugins can be brought in from any source (such as, composer) and they won't need to be customized in any way specific to the application, chaining-class-resolver will do all the magic.

Usage

You can find the example in the diagrams above implemented in the example directory.

The loader setup part is in setup.inc.php

To run the example, run app.php

The steps to set up chaining class loading are:

Creating the modules

We organize the modules in folders with a PSR-4 class structures:

Each module defines or extends classes A and/or B like in the above diagram

You are free to use any directory structure, as long as you have a number of module directories each containing a PSR-4 namespace.

The chaining resolver

We get a resolver instance:

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'
);

The $targetNs constructor parameter defines the target namespace that the class chain will be weaved into, in our case the classes will be chained under \Target.

Modules will be chained in the order they are sent to the constructor; this means, in the above example, Plugin 1 will extend/override classes in Code base, Plugin 3 will override all, ect.

Setting up the loader

We will use exteon/mapping-class-loader to load the chained files:

use Exteon\Loader\MappingClassLoader\MappingClassLoader;
use Exteon\Loader\MappingClassLoader\StreamWrapLoader;

$loader = new MappingClassLoader(
    [],
    [
        $chainingClassResolver
    ],
    [],
    new StreamWrapLoader([
        'enableMapping' => true
    ])
);
$loader->register();

For more details on using the MappingClassLoader, you can take a look at the exteon/mapping-class-loader documentation .

Using the chained classes

We can now use the chained classes. All classes define or override the whoami() method, adding the result.

    use Target\A;
    use Target\B;

    $a = new A();
    $b = new B();

    var_dump($a->whoami());
    var_dump($b->whoami());

The above code will produce the following result:

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"
}

So now the classes as chained as represented in this diagram.

Class hint files

If you open app.php in your smart IDE, the classes in the Target namespace will be unresolved and you will not have any autocompletion for them. That is because Target\A and Target\B are not yet defined anywhere in the code.

In order to fix this, we need to create class hint files that will contain the stubs for the Target classes. We can do this by running create-hints.php in the example directory.

Using the same setup.inc.php, this tools runs

$loader->dumpHintClasses(__DIR__.'/dev/hints');

What this does is generate class hint files in dev/hints. These are PHP class files with PSR-4 structure that are generated in the directory you specify. Now just add the hints directory to your source directories in your IDE and now, your Target\A class will be defined as such:

namespace Target {
    /**
     * @extends \Code\Base\A
     * @extends \Plugin1\A
     */
    class A extends \Plugin2\A {}
}

Note

The dumpHintClasses tool should be ran whenever you add classes or change the classes hierarchy in your project, reconfigure your modules ect. to regenerate the hint files.

Note

The hint classes contain only one extend, i.e. Plugin2\A, which in your source files extends Code\Base\A. Therefore, if you now add a someMethod() method on Plugin1\A, this method will not be known on static analysis on Target\A, there will be no autocomplete for it, unless your IDE can read the multiple @extends annotations.

In a future version, we might address this by always using traits for class extending. (See Traits chaining)

However, the primary functionality of a plugin is to modify (i.e. override) existing class methods. Therefore, we advise that when you think about extending an existing class's contract, you consider doing this using separate traits that you use or you use object composition to keep added functionality into separate classes.

Other features

Debugging

Even though the chaining-class-resolver modifies (weaves) the source code to achieve its functionality, step debugging can be easily performed using exteon/mapping-class-loader's mapping functionality. When debugging code, you will be stepping on the original class files as usual. The only thing you need to do, as in the code examples above, is to set 'enableMapping' => true to StreamingWrapLoader's config.

To read more about the mapping functionality, please refer to the exteon/mapping-class-loader documentation .

Caching

The class weaving process can be a significant overhead; exteon/mapping-class-loader provides caching for the weaved class files. To enable caching, you need to set the enableCaching and cacheDir config options to the MappingClassLoader constructor. For more details about caching, see the exteon/mapping-class-loader documentation .

For development, whenever you change the source classes, the cache needs to be cleared and reprimed; there is a work in progress for a change watcher over inotify, but until that is published, you should create your own tool, if you use caching in a development setup.

Traits chaining

In PHP, traits that implement the same method cannot be added on the same class (see here). While this is an artefact of the copy-paste trait implementation, it's also a setback in traits reusability and expresiveness. Since parent:: and static:: are supported for traits and PHP doesn't support overloading, it makes sense that multiple traits can override the same base method.

chaining-class-resolver supports this by linearizing (chaining) the list of traits in a class uses clause, creating intermediate classes, each using one trait. Therefore, traits precedence is the order in which they are listed in the use clause.

For instance, this is possible with chaining-class-resolver (note the different classes need to be implemented in different files as per PSR-4 standard):

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());

This will list ['A','T1','T2','B'] as class inheritance.

Please also further see Multiple Inheritance Considerations for a more dogmatic discussion of the implications.

Multiple Inheritance Considerations

If chained classes, or multiple traits, all define a new method with the same name, the infamous diamond problem arises. chaining-class-resolver has no special handling for it, but then PHP also doesn't (there is no syntax to explicitly signify an override). Therefore, having multiple traits defining the same new method is allowed, as long as, per PHP rules, the second method's signature is compatible with the first, making it an assumed override.

In the future we might implement a more stringent mechanism to detect and address the diamond problem.

Chaining reflection

You can find information about the chained classes at runtime using ChainedClassMeta:

ChainedClassMeta::get('Some\Class')->isChained()

Returns whether the named class is attached to a chain.

ChainedClassMeta::get('Some\Class')->getChainParent()

Returns Some\Class's chain parent class as a ChainedClassMeta object or null if there is none. use ->getClassName() to get this parent's class name.

ChainedClassMeta::get('Some\Class')->getChainedClass()

Returns the Some\Class's chain target class (i.e. the end of the chain in the target namespace).

ChainedClassMeta::get('Some\Class')->getModuleName()

Returns the name of the module that defines Some\Class.

ChainedClassMeta::get('Some\Class')->getChainTraits()

Returns the traits that were added in Some\Class's chain by all chained classes. (But not the inherited ones).

ChainedClassMeta::get('Some\Class')->getChainInterfaces()

Returns the interfaces that were added in Some\Class's chain by all chained classes. (But not the inherited ones).