Skip to content

Commit

Permalink
AutoService - Automatically add services to the container based on fi…
Browse files Browse the repository at this point in the history
…le-scan
  • Loading branch information
totten committed Aug 24, 2022
1 parent 96e27a7 commit eb92dd7
Show file tree
Hide file tree
Showing 7 changed files with 778 additions and 0 deletions.
25 changes: 25 additions & 0 deletions Civi/Core/Compiler/AutoServiceScannerPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Civi\Core\Compiler;

use Civi\Core\ClassScanner;
use Civi\Core\Service\AutoServiceInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Scan the source-tree for implementations of `AutoServiceInterface`. Load them.
*
* Note: This will scan the core codebase as well as active extensions. For fully automatic
* support in an extension, the extension must enable the mixin `scan-classes@1`.
*/
class AutoServiceScannerPass implements CompilerPassInterface {

public function process(ContainerBuilder $container) {
$autoServices = ClassScanner::get(['interface' => AutoServiceInterface::class]);
foreach ($autoServices as $autoService) {
$autoService::buildContainer($container);
}
}

}
3 changes: 3 additions & 0 deletions Civi/Core/Container.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<?php
namespace Civi\Core;

use Civi\Core\Compiler\AutoServiceScannerPass;
use Civi\Core\Compiler\EventScannerPass;
use Civi\Core\Compiler\SpecProviderPass;
use Civi\Core\Event\EventScanner;
use Civi\Core\Lock\LockManager;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
Expand Down Expand Up @@ -96,6 +98,7 @@ public function loadContainer() {
public function createContainer() {
$civicrm_base_path = dirname(dirname(__DIR__));
$container = new ContainerBuilder();
$container->addCompilerPass(new AutoServiceScannerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 1000);
$container->addCompilerPass(new EventScannerPass());
$container->addCompilerPass(new SpecProviderPass());
$container->addCompilerPass(new RegisterListenersPass());
Expand Down
195 changes: 195 additions & 0 deletions Civi/Core/Service/AutoDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

namespace Civi\Core\Service;

use Civi\Api4\Utils\ReflectionUtils;
use Civi\Core\HookInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class AutoDefinition {

/**
* Identify any/all service-definitions for the given class.
*
* If the class defines any static factory methods, then there may be multiple definitions.
*
* @param string $className
* The name of the class to scan. Look for `@inject` and `@service` annotations.
* @return \Symfony\Component\DependencyInjection\Definition[]
* Ex: ['my.service' => new Definition('My\Class')]
*/
public static function scan(string $className): array {
$class = new \ReflectionClass($className);
$result = [];

$classDoc = ReflectionUtils::parseDocBlock($class->getDocComment());
if (!empty($classDoc['service'])) {
$serviceName = static::pickName($classDoc, $class->getName());
$def = static::createBaseline($class, $classDoc);
self::applyConstructor($def, $class);
$result[$serviceName] = $def;
}

foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_STATIC) as $method) {
/** @var \ReflectionMethod $method */
$methodDoc = ReflectionUtils::parseDocBlock($method->getDocComment());
if (!empty($methodDoc['service'])) {
$serviceName = static::pickName($methodDoc, $class->getName() . '::' . $method->getName());
$returnClass = isset($methodDoc['return'][0]) ? new \ReflectionClass($methodDoc['return'][0]) : $class;
$def = static::createBaseline($returnClass, $methodDoc);
$def->setFactory($class->getName() . '::' . $method->getName());
$def->setArguments(static::toReferences($methodDoc['inject'] ?? ''));
$result[$serviceName] = $def;
}
}

if (count($result) === 0) {
error_log("WARNING: Class {$class->getName()} was expected to have a service definition, but it did not. Perhaps it needs service name.");
}

return $result;
}

/**
* Create a basic definition for an unnamed service.
*
* @param string $className
* The name of the class to scan. Look for `@inject` and `@service` annotations.
* @return \Symfony\Component\DependencyInjection\Definition
*/
public static function create(string $className): Definition {
$class = new \ReflectionClass($className);
$classDoc = ReflectionUtils::parseDocBlock($class->getDocComment());
$def = static::createBaseline($class, $classDoc);
static::applyConstructor($def, $class);
return $def;
}

protected static function pickName(array $docBlock, string $internalDefault): string {
if (is_string($docBlock['service'])) {
return $docBlock['service'];
}
if (!empty($docBlock['internal']) && $internalDefault) {
return $internalDefault;
}
throw new \RuntimeException("Error: Failed to determine service name ($internalDefault). Please specify '@service NAME' or '@internal'.");
}

protected static function createBaseline(\ReflectionClass $class, ?array $docBlock = []): Definition {
$class = is_string($class) ? new \ReflectionClass($class) : $class;
$def = new Definition($class->getName());
$def->setPublic(TRUE);
self::applyTags($def, $class, $docBlock);
self::applyObjectProperties($def, $class);
self::applyObjectMethods($def, $class);
return $def;
}

protected static function toReferences(string $injectExpr): array {
return array_map(
function (string $part) {
return new Reference($part);
},
static::splitSymbols($injectExpr)
);
}

protected static function splitSymbols(string $expr): array {
if ($expr === '') {
return [];
}
$extraTags = explode(',', $expr);
return array_map('trim', $extraTags);
}

/**
* @param \Symfony\Component\DependencyInjection\Definition $def
* @param \ReflectionClass $class
* @param array $docBlock
*/
protected static function applyTags(Definition $def, \ReflectionClass $class, array $docBlock): void {
if (!empty($docBlock['internal'])) {
$def->addTag('internal');
}
if ($class->implementsInterface(HookInterface::class) || $class->implementsInterface(EventSubscriberInterface::class)) {
$def->addTag('event_subscriber');
}

if (!empty($classDoc['serviceTags'])) {
foreach (static::splitSymbols($classDoc['serviceTags']) as $extraTag) {
$def->addTag($extraTag);
}
}
}

/**
* @param \Symfony\Component\DependencyInjection\Definition $def
* @param \ReflectionClass $class
*/
protected static function applyConstructor(Definition $def, \ReflectionClass $class): void {
if ($construct = $class->getConstructor()) {
$constructAnno = ReflectionUtils::parseDocBlock($construct->getDocComment() ?? '');
if (!empty($constructAnno['inject'])) {
$def->setArguments(static::toReferences($constructAnno['inject']));
}
}
}

/**
* Scan for any methods with `@inject`. They should be invoked via `$def->addMethodCall()`.
*
* @param \Symfony\Component\DependencyInjection\Definition $def
* @param \ReflectionClass $class
*/
protected static function applyObjectMethods(Definition $def, \ReflectionClass $class): void {
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
/** @var \ReflectionMethod $method */
if ($method->isStatic()) {
continue;
}

$anno = ReflectionUtils::parseDocBlock($method->getDocComment());
if (!empty($anno['inject'])) {
$def->addMethodCall($method->getName(), static::toReferences($anno['inject']));
}
}
}

/**
* Scan for any properties with `@inject`. They should be configured via `$def->setProperty()`
* or via `injectPrivateProperty()`.
*
* @param \Symfony\Component\DependencyInjection\Definition $def
* @param \ReflectionClass $class
* @throws \Exception
*/
protected static function applyObjectProperties(Definition $def, \ReflectionClass $class): void {
foreach ($class->getProperties() as $property) {
/** @var \ReflectionProperty $property */
if ($property->isStatic()) {
continue;
}

$propDoc = ReflectionUtils::getCodeDocs($property);
if (!empty($propDoc['inject'])) {
if ($propDoc['inject'] === TRUE) {
$propDoc['inject'] = $property->getName();
}
if ($property->isPublic()) {
$def->setProperty($property->getName(), new Reference($propDoc['inject']));
}
elseif ($class->hasMethod('injectPrivateProperty')) {
$def->addMethodCall('injectPrivateProperty', [$property->getName(), new Reference($propDoc['inject'])]);
}
else {
throw new \Exception(sprintf('Property %s::$%s is marked private. To inject services into private properties, you must implement method "injectPrivateProperty($key, $value)".',
$class->getName(), $property->getName()
));
}
}
}
}

}
41 changes: 41 additions & 0 deletions Civi/Core/Service/AutoService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
namespace Civi\Core\Service;

/**
* AutoService is a base-class for defining a service (in the service-container).
* Classes which extend AutoService will have these characteristics:
*
* - The class is scanned automatically (if you enable `scan-classes@1`).
* - The class is auto-registered as a service in Civi's container.
* - The service is given a default name (derived from the class name).
* - The service may subscribe to events (via `HookInterface` or `EventSubscriberInterface`).
*
* Additionally, the class will be scanned for various annotations:
*
* - Class annotations:
* - `@service <service.name>`: Customize the service name.
* - `@serviceTags <tag1,tag2>`: Declare additional tags for the service.
* - Property annotations
* - `@inject [<service.name>]`: Inject another service automatically (by assigning this property).
* If the '<service.name>' is blank, then it loads an eponymous service.
* - Method annotations
* - (TODO) `@inject <service.name>`: Inject another service automatically (by calling the setter-method).
*
* Note: Like other services in the container, AutoService cannot meaningfully subscribe to
* early/boot-critical events such as `hook_entityTypes` or `hook_container`. However, you may
* get a similar effect by customizing the `buildContainer()` method.
*/
abstract class AutoService implements AutoServiceInterface {

use AutoServiceTrait;

}
31 changes: 31 additions & 0 deletions Civi/Core/Service/AutoServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
namespace Civi\Core\Service;

use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* The CiviCRM container will automatically load classes that implement
* AutoServiceInterface.
*
* Formally, this resembles `hook_container` and `CompilerPassInterface`. However, the
* build method must be `static` (running before CiviCRM has fully booted), and downstream
* implementations should generally register concrete services (rather performing meta-services
* like tag-evaluation).
*/
interface AutoServiceInterface {

/**
* Register any services with the container.
*/
public static function buildContainer(ContainerBuilder $container): void;

}
51 changes: 51 additions & 0 deletions Civi/Core/Service/AutoServiceTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
namespace Civi\Core\Service;

use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* By combining AutoServiceInterface and AutoServiceTrait, you can make any class
* behave like an AutoService (auto-registered in the CiviCRM container).
*/
trait AutoServiceTrait {

/**
* Register the service in the container.
*
* @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
* @internal
*/
final public static function buildContainer(ContainerBuilder $container): void {
// "final": AutoServices should avoid coupling to Symfony DI. However, if you really
// need to customize this, then omit AutoServiceTrait and write your own variant.

$file = (new \ReflectionClass(static::class))->getFileName();
$container->addResource(new \Symfony\Component\Config\Resource\FileResource($file));
foreach (AutoDefinition::scan(static::class) as $id => $definition) {
$container->setDefinition($id, $definition);
}
}

/**
* (Internal) Utility method used to `@inject` data into private properties.
*
* @param string $key
* @param mixed $value
* @internal
*/
final public function injectPrivateProperty(string $key, $value): void {
// "final": There is no need to override. If you want a custom assignment logic, then put `@inject` on your setter method.

$this->{$key} = $value;
}

}
Loading

0 comments on commit eb92dd7

Please sign in to comment.