Commit c8b0214b authored by dpi's avatar dpi
Browse files

Issue #3276804 by dpi: Discover and cache hook implementations

parent 60b2d27a
Loading
Loading
Loading
Loading
+19 −2
Original line number Diff line number Diff line
@@ -25,8 +25,8 @@ Other features
# Installation

 1. Install as normally.
 2. Patch core with in progress patch from
    https://www.drupal.org/project/drupal/issues/2616814
 2. No additional steps if running Drupal 9.4 or later. If using Drupal 9.3, use
    a patch core from https://www.drupal.org/project/drupal/issues/2616814

# Usage

@@ -108,6 +108,22 @@ at https://www.drupal.org/project/coder/issues/3250346 to appease code sniffer.

Working examples of all Hux features can be found in included tests.

# Optional configuration

## Optimised mode

Hux' [optimized mode][optimized-mode] provides an option geared towards being 
developer friendly or optimized for production use. By default this mode is off,
but it should be turned on in production for small gains in performance.

To control whether Hux optimized mode is on or off, add to your `services.yml`:

```yaml
parameters:
  hux:
    optimize: true
```

# License

This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
@@ -123,3 +139,4 @@ Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-130
 [project-hook_event_dispatcher]: https://www.drupal.org/project/hook_event_dispatcher
 [project-hooks]: https://www.drupal.org/project/hooks
 [project-entity_events]: https://www.drupal.org/project/entity_events
 [optimized-mode]: https://www.drupal.org/docs/contributed-modules/hux/hux-optimized-mode
+6 −1
Original line number Diff line number Diff line
parameters:
  # See README.md for guidance.
  hux:
    optimize: false

services:
  hux.module_handler:
    decorates: module_handler
@@ -5,8 +10,8 @@ services:
    public: true
    arguments:
      - '@hux.module_handler.inner'
      - '@cache.bootstrap'
    calls:
      - [ setContainer, [ '@service_container' ] ]
    tags:
      - { name: service_id_collector, tag: hooks }

src/Hooks/HuxHooks.php

0 → 100644
+76 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\hux\Hooks;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\hux\Attribute\Hook;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Hux for Hux for Hux for Hux for Hux for Hux for Hux for Hux for Hux for Hux.
 *
 * Eat your tail 🐍.
 */
final class HuxHooks implements ContainerInjectionInterface {

  use StringTranslationTrait;

  /**
   * Constructs Hooks for Hux.
   *
   * @param TranslationInterface $stringTranslation
   *   The string translation service.
   * @param string $environment
   *   The app environment.
   * @param array{optimize: bool} $huxParameters
   *   Container parameters for Hux.
   */
  public function __construct(
    TranslationInterface $stringTranslation,
    private string $environment,
    private array $huxParameters,
  ) {
    $this->setStringTranslation($stringTranslation);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('string_translation'),
      $container->getParameter('kernel.environment'),
      $container->getParameter('hux'),
    );
  }

  /**
   * Implements hook_requirements().
   *
   * @see \hook_requirements()
   */
  #[Hook('requirements')]
  public function requirements(string $phase): array {
    if ($phase !== 'runtime') {
      return [];
    }

    ['optimize' => $optimize] = $this->huxParameters;

    $tArgs = [':hux_optimized_mode' => 'https://www.drupal.org/docs/contributed-modules/hux/hux-optimized-mode'];
    $requirements['hux.optimize']['title'] = $this->t('Hux');
    $requirements['hux.optimize']['severity'] = $optimize ? \REQUIREMENT_OK : \REQUIREMENT_WARNING;
    $requirements['hux.optimize']['value'] = match (TRUE) {
      $optimize === TRUE => $this->t('Hux is running in <a href=":hux_optimized_mode">optimized mode</a>. This mode can be switched off in development environments.', $tArgs),
      'prod' === $this->environment => $this->t('It is recommended to run Hux in <a href=":hux_optimized_mode">optimized mode</a> when deployed to production environments. This warning can be ignored on development environments.', $tArgs),
      default => $this->t('It is recommended to run Hux in <a href=":hux_optimized_mode">optimized mode</a>. This warning can be ignored on development environments.', $tArgs),
    };

    return $requirements;
  }

}
+19 −13
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ use Drupal\hux\Attribute\Hook;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;

@@ -47,7 +48,7 @@ final class HuxCompilerPass implements CompilerPassInterface {
      $definition = new Definition($className);
      $definition
        ->addTag('hooks')
        ->setPrivate(TRUE);
        ->setPublic(TRUE);

      if ((new \ReflectionClass($className))->isSubclassOf(ContainerInjectionInterface::class)) {
        $definition
@@ -60,19 +61,24 @@ final class HuxCompilerPass implements CompilerPassInterface {

    $huxModuleHandler = $container->findDefinition('hux.module_handler');

    foreach ($container->findTaggedServiceIds('hooks') as $id => $tags) {
      $serviceDefinition = $container->getDefinition($id);
    $serviceIds = array_keys($container->findTaggedServiceIds('hooks'));
    $implementations = array_combine(
      $serviceIds,
      array_map(function (string $serviceId) use ($container): array {
        $serviceDefinition = $container->getDefinition($serviceId);
        /** @var class-string|null $className */
        $className = $serviceDefinition->getClass();
        preg_match_all('/^Drupal\\\\(?<moduleName>[a-z_0-9]{1,32})\\\\.*$/m', $className, $matches, PREG_SET_ORDER);
        $moduleName = $matches[0]['moduleName'] ?? throw new \Exception(sprintf('Could not determine module name from class %s', $className));

      $huxModuleHandler->addMethodCall('addHookImplementation', [
        $id,
        $moduleName,
        return [$moduleName, $className];
      }, $serviceIds),
    );
    $huxModuleHandler->addMethodCall('discovery', [
      new Reference('service_container'),
      $implementations,
      new Parameter('hux'),
    ]);
  }
  }

  /**
   * Get Hux classes for the provided namespaces.

src/HuxDiscovery.php

0 → 100644
+153 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\hux;

use Drupal\hux\Attribute\Alter;
use Drupal\hux\Attribute\Hook;
use Drupal\hux\Attribute\ReplaceOriginalHook;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Hux discovery.
 *
 * Discovers hooks, hook replacements, and alters in tagged services. This class
 * makes it easier to implement hook caching, potentially eliminating the need
 * to initialize some hook classes which are not utilized. Switching off caching
 * allows developers to quickly add new hooks to hook classes without the need
 * to clear the entire cache.
 *
 * @internal
 *   For internal use only, behavior and serialized data structure may change at
 *   any time.
 */
final class HuxDiscovery {

  /**
   * @var array<class-string, array<mixed>>
   */
  protected array $discovery = [];

  private ?array $implementations = NULL;

  /**
   * Constructs a new HuxDiscovery.
   *
   * @param array<string, array{string, string}> $implementations
   *   An array of module names and class names keyed by service ID.
   */
  public function __construct(array $implementations) {
    $this->implementations = $implementations;
  }

  /**
   * Discovers hook implementations in hook classes.
   *
   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
   *   The service container.
   *
   * @throws \Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException
   *   If a service implementation was added but was removed unexpectedly.
   */
  public function discovery(ContainerInterface $container): void {
    if (!isset($this->implementations)) {
      throw new \Exception('Hook implementations were cleared after serialization. Re-construct the discovery class.');
    }

    $this->discovery = [];

    foreach ($this->implementations as $serviceId => [$moduleName, $className]) {
      $reflectionClass = new \ReflectionClass($className ?? $container->get($serviceId));
      $methods = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC);

      foreach ($methods as $reflectionMethod) {
        $methodName = $reflectionMethod->getName();

        $attributesHooks = $reflectionMethod->getAttributes(Hook::class);
        if ($attribute = $attributesHooks[0] ?? NULL) {
          $instance = $attribute->newInstance();
          assert($instance instanceof Hook);
          $this->discovery[Hook::class][$instance->hook][] = [
            $serviceId,
            $instance->moduleName ?? $moduleName,
            $methodName,
            $instance->priority,
          ];
        }

        $attributesHookReplacements = $reflectionMethod->getAttributes(ReplaceOriginalHook::class);
        if ($attribute = $attributesHookReplacements[0] ?? NULL) {
          $instance = $attribute->newInstance();
          assert($instance instanceof ReplaceOriginalHook);
          $this->discovery[ReplaceOriginalHook::class][$instance->hook][] = [
            $serviceId,
            $instance->moduleName,
            $methodName,
            $instance->originalInvoker,
          ];
        }

        $attributesAlters = $reflectionMethod->getAttributes(Alter::class);
        if ($attribute = $attributesAlters[0] ?? NULL) {
          $instance = $attribute->newInstance();
          assert($instance instanceof Alter);
          $this->discovery[Alter::class][$instance->alter][] = [
            $serviceId,
            $methodName,
          ];
        }
      }
    }
  }

  /**
   * Get all Hux implementations of a hook.
   *
   * @param string $hook
   *   A hook.
   *
   * @return \Generator<array{string, string, string, int}>
   *   A generator yielding an array of service ID, module name, method name,
   *   and priority.
   */
  public function getHooks(string $hook) {
    yield from $this->discovery[Hook::class][$hook] ?? [];
  }

  /**
   * Get all Hux implementations of replacement hooks.
   *
   * @param string $hook
   *   A hook.
   *
   * @return \Generator<array{string, string, string, bool}>
   *   A generator yielding an array of service ID, module name, method name,
   *   and flag for whether the original implementation should be passed as a
   *   callable as first parameter.
   */
  public function getHookReplacements(string $hook) {
    yield from $this->discovery[ReplaceOriginalHook::class][$hook] ?? [];
  }

  /**
   * Get all Hux implementations of alters hooks.
   *
   * @param string $alter
   *   An alter.
   *
   * @return \Generator<array{string, string, string}>
   *   A generator yielding an array of service ID,  method name.
   */
  public function getAlters(string $alter) {
    yield from $this->discovery[Alter::class][$alter] ?? [];
  }

  /**
   * Optimises the object by removing $this->implementations.
   */
  public function __sleep(): array {
    return ['discovery'];
  }

}
Loading