Drupal 11面向对象钩子和钩子服务类:带示例与测试的综合指南

Drupal 11:面向对象的钩子与钩子服务类

2025年8月3日 - 阅读时长39分钟

Drupal开发中,钩子用于允许模块和主题监听或触发Drupal系统中的各类事件。当这些事件发生时,Drupal会暂停并询问是否有模块想对当前触发的事件发表意见。

例如,在查看、创建、更新或删除内容时,通常会使用钩子。要是我们删除了一个内容页面,就会触发一个删除钩子,这能让模块对该被删除的内容项做出反应。利用这一点,我们可以对此做出响应,并对不再存在的内容项执行清理操作。我公司的自定义模块可能希望从数据库表中移除相关项,或者删除关联文件,因为它们不再需要了。

这只是钩子在Drupal中使用方式的一个示例,因为它们可用于各种不同的情况,而不仅仅是监听内容事件。另一个常见的Drupal钩子的例子是创建自定义模板时。许多模块会使用一个名为 hook_theme() 的钩子向主题系统注册一个或多个模板,以便它们可以用于为自定义内容生成主题。

钩子在Drupal中已经使用了很长时间(可能从版本3开始),并且一直是初学者较难理解的内容之一。模块文件中充满了具有特殊命名的函数,这些函数似乎会被Drupal神奇地调用,这种概念不太容易理解,需要一段时间才能熟悉。

Drupal 11.1.0的新特性是能够创建面向对象(OOP)的钩子,这与长期以来作为Drupal一部分的传统过程式钩子有所不同。这种面向对象的钩子方法现在就可以使用,具有向后兼容的特性,并最终将(在可能的情况下)取代过程式钩子。

在本文中,我公司将探讨如何创建一个面向对象的钩子,如何在Drupal模块开发中过渡到使用面向对象的钩子,以及如何创建自己的面向对象的钩子。

一、定义服务钩子

现在可以像定义任何其他服务一样,在服务类中定义钩子。然后使用属性将这些钩子注册到Drupal钩子系统中。

如果你想了解更多关于Drupal服务的信息,可以阅读我之前的文章《服务和依赖注入简介》,但目前我不会假设你了解所有相关内容。

虽然面向对象的钩子技术相对较新,但通常的做法是将钩子放在“Hook”命名空间中,并为想要的每种类型的钩子定义一个单独的服务类。一个服务用于与节点进行交互,一个服务用于与主题层进行交互,以此类推。

例如,如果创建一个名为 NodeHooks 的服务类,那么它将位于模块目录的 src/Hook 目录中。该类的命名空间将是 Drupal\services_hooks_example\Hook

对于模块钩子,没有必要创建一个 *.services.yml 文件,Drupal会自动识别它们,并默认将它们视为“自动装配”的服务。这意味着只需要将所需的服务接口添加到构造函数中,Drupal就会注入这些服务。

钩子使用一个名为 Hook 的PHP属性来定义,可以将其附加到方法或类上。让我们来看看这些属性的不同位置。

二、将钩子作为类属性

要将类中的一个方法定义为钩子,需要将Hook属性附加到类定义上。如果不指定方法名,那么当钩子被触发时,将调用一个名为 __invoke() 的方法。

在下面的示例中,我们告诉Drupal,当 hook_node_insert 钩子被触发时,希望运行 __invoke() 方法,我们在类声明上方定义了这个钩子。


namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

#[Hook('node_insert')]
class NodeHooks {

  /**
   * 为节点实体实现 hook_ENTITY_TYPE_insert()。
   */
  public function __invoke(NodeInterface $node) {
    // 对触发的钩子做出响应。
  }
}

可以使用 method 参数指定方法名,传入钩子触发时需要调用的方法。

在下面的示例中,我们告诉Drupal,当 hook_node_insert 钩子被触发时,希望运行 nodeInsert() 方法。


namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

#[Hook('node_insert', method: 'nodeInsert')]
class NodeHooks {

  /**
   * 为节点实体实现 hook_ENTITY_TYPE_insert()。
   */
  public function nodeInsert(NodeInterface $node) {
    // 对触发的钩子做出响应。
  }
}

可以通过在类声明中列出所有钩子的方式,以这种方式定义多个钩子。但如果服务类中有多个钩子,使用方法属性可能是个好主意,因为如果不明确某个方法是否被定义为钩子,可能会让开发者感到困惑。

实际上,最好忽略这种方法,专注于将钩子定义为方法属性,所以让我们来看看这种方式。

三、将钩子作为方法属性

要将一个方法创建为钩子,只需要在服务类中创建一个方法,并在其前面加上 Hook 属性,以告诉Drupal我们希望将这个方法作为钩子调用。

在下面的示例中,我们告诉Drupal,当 hook_node_insert 钩子被触发时,希望运行 nodeInsert() 方法,我们将其放在方法声明上方。


namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

class NodeHooks {

  /**
   * 为节点实体实现 hook_ENTITY_TYPE_insert()。
   */
  #[Hook('node_insert')]
  public function nodeInsert(NodeInterface $node) {
    // 对触发的钩子做出响应。
  }
}

甚至可以堆叠PHP属性,让同一个方法用于不同的情况。

例如,在对实体进行插入和更新操作时,通常会运行相同的代码,所以可以将上面的代码修改为以下内容。


  /**
   * 为节点实体实现 hook_ENTITY_TYPE_insert() 和
   * hook_ENTITY_TYPE_update()。
   */
  #[Hook('node_insert')]
  #[Hook('node_update')]
  public function nodeUpdateOrInsert(NodeInterface $node) {
    // 对触发的钩子做出响应。
  }

现在,当Drupal网站中的节点被更新或创建时,这个方法将被触发,并且我们可以为每个操作运行相同的代码。

有关可用钩子的更多信息,请参阅Drupal.org上关于钩子的文档页面,其中列出了大部分可用的钩子。

四、测试

面向对象钩子的加入意味着我们可以使用内置的PHPUnit测试框架非常轻松地测试钩子。

在Drupal中,通常是隐式地测试钩子,这意味着我们设置触发钩子的条件,然后查看钩子是否被触发。通过向面向对象钩子的转变,还可以通过将一个已知对象传递给钩子方法并查看结果,来 显式地 测试钩子。

附带说明一下。是的,我意识到从技术上讲,我们之前也可以显式地测试钩子,但将模块文件中的独立函数注入到PHPUnit测试用例中是一件痛苦的事情。在测试开始之前,通常需要费力地加载模块文件并确保函数存在。服务类让这一切变得容易得多。

编写测试的方式在很大程度上取决于实现的是哪种类型的钩子。在上面的示例中,我们使用的是 hook_node_insert,所以让我们为这个钩子创建一个测试。

如果稍微修改一下 hook_node_insert 方法,使其在节点保存时打印一条消息,那么就可以很容易地测试这个操作。我们将使用信使服务来修改 nodeInsert() 方法,以打印一条关于节点保存的消息。请注意,我们在这里也将信使服务注入到了类中,所以在测试中需要考虑这一点。我不会在这里贴出所有代码,因为所有代码都可以在GitHub上找到,而且那些代码大多是样板代码/设置代码。


  #[Hook('node_insert')]
  public function nodeInsert(NodeInterface $node) {
    $this->messenger->addStatus('Services Hooks Example: Node ' . $node->getTitle() . ' created.');
  }

现在我们面临一个选择,下一步该怎么做。可以编写一个 单元测试 或一个 内核测试,以检查钩子是否正常工作。选择哪种测试将取决于要实现的功能,但我将在这里给出两种测试的示例。

附带说明一下,为钩子编写功能测试没有什么意义,因为在这种情况下,不可能将钩子与Drupal的其他部分隔离开来。功能测试应该比模块钩子架构的实现细节更宏观。

虽然我在这里选择了一个比较常见的钩子(除了表单修改钩子之外),但我也选择了一个比较难测试的钩子。我们的目标是通过创建一个节点对象并将该对象直接传递给方法,来显式地测试这个钩子。然而,问题在于,如果我们创建 将节点保存到数据库中,那么在我们有机会显式调用钩子之前,Drupal会隐式地调用这个钩子。我们需要做的是创建一个不与数据库交互的节点对象,下面的代码示例将体现这一点。

五、创建单元测试

nodeInsert() 方法进行单元测试需要创建几个模拟对象。节点对象可以很容易地进行模拟,因为我们只需要确保 getTitle 方法存在并返回正确的输出。如果你希望节点有更多的功能,可以实现一些模拟方法,但对于测试需求来说,一个空白节点就足够了。

信使服务也可以进行模拟,但在这种情况下,我们需要确保在 nodeInsert() 方法的执行过程中,addStatus() 方法只被调用一次,并且该方法接收到的输入是正确的。测试完成后,PHPUnit会检查这一点,如果 addStatus() 方法被调用多次,或者输入参数不正确,测试将失败。之后,只需要创建 NodeHooks 服务并调用钩子方法即可。

以下是完整的单元测试类代码。


namespace Drupal\Tests\services_hooks_example\Unit;

use Drupal\Core\Messenger\MessengerInterface;
use Drupal\node\Entity\Node;
use Drupal\services_hooks_example\Hook\NodeHooks;
use Drupal\Tests\UnitTestCase;

/**
 * NodeHooks服务的单元测试。
 */
class NodeHooksTest extends UnitTestCase {

  /**
   * 测试当nodeInsert钩子被调用时是否创建了状态消息。
   */
  public function testNodeServiceHookInsert() {
    // 我们创建一个节点的模拟对象,因为我们不想在准备好之前触发插入钩子
    $node = $this->createMock(Node::class);
    $node->expects($this->any())
      ->method('getTitle')
      ->willReturn('qwerty');

    // 创建信使服务的模拟对象,并确保addStatus方法被调用一次
    $messenger = $this->createMock(MessengerInterface::class);
    $messenger->expects($this->once())
      ->method('addStatus')
      ->with('Services Hooks Example: Node qwerty created.');

    $nodeHooksService = new NodeHooks($messenger);
    $nodeHooksService->nodeInsert($node);
  }

}

如果在这种情况下对节点进行了有趣的操作,可能想检查模拟节点中的方法是否被调用。对于我们的测试,只想确保 addStatus() 方法被调用,并且消息作为参数传递给了它。

我们在这里模拟了很多对象,但它们的使用是合理的,因为这些方法将在钩子中被使用。如果你发现自己模拟了很多对象,不妨停下来思考一下想用单元测试测试什么。模拟大量对象可能不是一个好现象,但不一定是坏事。

六、创建内核测试

内核测试的实现稍微长一些,这是因为有一些设置代码。此外,由于我们可以访问完整的信使服务,我们可以使用该服务来检查我们的消息是否以正确的方式设置(而不是从模拟方法调用中推断)。

由于我们想通过 \Drupal::service('services_hooks_example.node_hooks'); 引用钩子服务类本身,我们还需要在 *.service.yml 文件中为这个类添加一个引用。我们 这样做是因为我们想按名称加载服务,因为Drupal不需要服务文件来查找和注册面向对象的钩子。


services:
  # 为测试目的添加命名服务钩子。
  # Drupal找到钩子并不需要这个服务,但我们需要它
  # 因为我们想在内核测试中使用它作为命名服务
  # 以显式引用钩子方法。
  services_hooks_example.node_hooks:
    class: \Drupal\services_hooks_example\Hook\NodeHooks
    autowire: true

如果你不想在模块的核心 *.services.yml 文件中添加这个引用,可以创建一个单独的测试模块来注册这个命名服务。

为了避免在保存节点时调用 hook_insert_node,我们以正常方式创建一个节点对象,但不调用将节点保存到数据库的方法。相反,我们只确保在调用钩子方法之前,将我们期望传递给钩子方法的任何值添加到对象中。在这种情况下,我们在新节点对象中显式设置节点ID为1。

调用钩子后,我们可以获取信使服务并检查在方法执行期间创建的消息。

以下是完整的内核测试类代码。


namespace Drupal\Tests\services_hook_example\Kernel;

use Drupal\Core\Messenger\MessengerInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;

/**
 * NodeHooks服务的内核测试。
 */
class NodeHooksTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'services_hooks_example',
    'node',
    'user',
  ];

  /**
   * {@inheritdoc}
   */
  public function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('user');
    $this->installEntitySchema('node');
  }

  /**
   * 测试当nodeInsert钩子被调用时是否创建了状态消息。
   */
  public function testNodeHookServiceInsert() {
    // 创建一个测试节点,但不保存它
    $node = Node::create([
      'title' => 'qwerty',
      'type' => 'page',
    ]);
    // 设置nid值,这是保存操作会做的事情
    $node->set('nid', 1);

    // 从服务类中获取并调用我们的node_insert钩子
    /** @var \Drupal\services_hooks_example\Hook\NodeHooks $nodeHookService */
    $nodeHookService = \Drupal::service('services_hooks_example.node_hooks');
    $nodeHookService->nodeInsert($node);

    // 测试信使服务是否正确填充
    $messenger = \Drupal::service('messenger');
    $this->assertCount(1, $messenger->messagesByType(MessengerInterface::TYPE_STATUS));
    $this->assertEquals('Services Hooks Example: Node qwerty created.', $messenger->messagesByType(MessengerInterface::TYPE_STATUS)[0]);
  }

}

请记住,尽量不要编写针对Drupal钩子系统本身的测试,这不是你在模块测试中想要测试的内容。相反,你应该处理钩子内部的代码。

七、旧版钩子

目前,建议你在通常的位置(即 *.module 文件中)添加一个兼容的过程式钩子。这意味着即使你的模块不会安装在Drupal 11.1.0站点上,你也可以开始编写面向对象的钩子。

你可以通过在过程式钩子中简单引用新的面向对象钩子来节省时间,这比编写两次钩子实现要好。在钩子函数顶部添加 #[LegacyHook] 属性,告诉Drupal这是一个旧版钩子,因此如果存在等效的面向对象钩子,它将不会运行。


use Drupal\node\NodeInterface;

#[LegacyHook]
function services_hooks_example_node_insert(NodeInterface $node) {
  \Drupal::service('services_hooks_example.node_hooks')->nodeInsert($node);
}

有了这个设置,你的钩子将像以前一样正常工作,并且还能在系统中对这些钩子进行单元测试。当需要关闭或移除旧钩子时,你所要做的就是移除这些旧版钩子,而不是重写大量代码。

如果你没有旧版钩子,或者不想支持它们,那么可以通过在 *.services.yml 文件中设置这个参数来提高性能。只需将其添加到服务文件的根目录中。


parameters:
  services_hooks_example.skip_procedural_hook_scan: true

这样做可以防止Drupal扫描旧版钩子,这意味着不仅旧版钩子不会运行,而且它们甚至根本不会在Drupal的钩子系统中注册。

你应该清楚自己在这里的操作。对于需要在Drupal <11.0.0上运行的模块,不要移除旧版支持。

八、所有钩子都会变成面向对象的吗?

Drupal核心开发团队正在努力从Drupal中移除所有旧的过程式钩子函数。在撰写本文时,看起来所有现有的钩子都将被等效的面向对象钩子所取代。

安装和更新钩子 可能 仍将保留为过程式钩子,因为它们依赖于最小化地引导Drupal,因此在该引导级别可能无法加载所有基于服务的钩子。在安装运行时,命名空间不可用,因此它们不会通过模块处理器。

目前也在进行将它们转换为新格式的工作,但这需要重写Drupal的加载方式才能运行这些钩子。

基于模块的模板钩子,如 template_preprocess_HOOKhook_preprocesshook_preprocess_HOOK 正在被面向对象的钩子所取代。

完全移除过程式钩子尚未被弃用,这可能要到Drupal 12才会发生,在这种情况下,直到Drupal 13才会移除它们。

九、定义自定义的面向对象钩子

为了完整性,我认为快速了解如何创建自己的面向对象钩子会很有帮助。

module_handler 服务负责管理系统中的所有钩子调用,因此添加你自己的钩子非常简单。目前,module_handler 服务并不处理所有钩子,只处理面向对象的钩子。

作为一个示例,让我们创建一个名为 hook_example_get_items 的钩子,该钩子可用于收集一个项目列表,然后我们将在页面上打印出来。这是一个有点简单的示例,但它展示了钩子注册系统的工作原理。

首先,我们需要定义一个将调用该钩子的服务,因此我们在模块的 *.services.yml 文件中创建这个服务。我们在这里使用 autowire 选项,以便可以将 module_handler 服务的依赖项添加到类中的控制器中。


services:

    services_hooks_example.custom_hook:
    class: \Drupal\services_hooks_example\CustomHook
    autowire: true

该类只需要使用 module_handler 服务,通过 invokeAll() 方法来调用一个钩子。该方法的结果是实现此钩子的所有钩子的返回值,这些值会合并到一个数组中。

我们的钩子实现服务实际上非常小,特别是因为我们只需要获取一个项目列表。


namespace Drupal\services_hooks_example;

use Drupal\Core\Extension\ModuleHandlerInterface;

/**
 * 定义自定义钩子的服务。
 */
class CustomHook {

  /**
   * 创建一个新的CustomHook对象。
   *
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
   *   模块处理器服务。
   */
  public function __construct(protected ModuleHandlerInterface $moduleHandler) {
  }

  /**
   * 调用hook_example_get_items钩子并返回找到的项目列表。
   *
   * @return array
   *   找到的项目列表。
   */
  public function getItems() {
    return $this->moduleHandler->invokeAll('example_get_items');
  }
}