snicco/better-wp-hooks

v2.0.0-beta.9 2024-09-07 14:27 UTC

README

codecov Psalm Type-Coverage Psalm level PhpMetrics - Static Analysis PHP-Versions

BetterWPHooks 是一个小型库,它允许你在复杂的 WordPress 项目中编写 现代可测试面向对象 的代码。

目录

  1. 动机
  2. 安装
  3. 用法
    1. 事件监听器
    2. 事件分发
    3. 事件接口
    4. 事件订阅者
    5. 移除监听器
    6. 将事件映射到核心/第三方钩子
      1. 确保你的事件首先触发
      2. 确保你的事件最后触发
    7. 将你的事件(部分)暴露给 WordPress
    8. 比 apply_filters 更好的替代方案
    9. 停止事件流/传播
  4. 测试
  5. 贡献
  6. 问题和 PR
  7. 安全

动机

BetterWPHooksSnicco 项目 的核心组件之一,开发它是由于 WordPress 钩子 系统 存在以下问题

  1. 当使用 add_actionadd_filter 时,你没有任何 类型安全。任何内容都可以返回。
  2. 事件(钩子)理想情况下应该是 不可变的,这意味着它不能被更改。使用 apply_filters,一旦运行了第一个回调,原始参数就会立即丢失。
  3. 没有 适当的地点定义钩子和回调。许多开发者默认将钩子放入 类构造函数 中,这从许多角度来看都是一种糟糕的解决方案。
  4. 依赖注入 不受支持。 你不能延迟实例化类回调。这导致自定义函数大量污染全局命名空间,或者在每次请求中实例化代码库中的所有类。这并不高效。
  5. 无法定义哪些钩子是公开使用的,哪些是代码库内部的。
  6. 删除注册为闭包或对象方法的钩子 极其困难
  7. 没有使用额外的测试框架(如 WP_MockBrain Monkey)测试钩子是极其困难的。(模拟很糟糕

虽然在小项目中添加一些快速操作是完全可行的,但对于 企业级项目 或复杂的分布式插件,WordPress 钩子 成为了维护和可测试性的负担。

安装

composer require snicco/better-wp-hooks

用法

创建事件分发器

use Snicco\Component\BetterWPHooks\WPEventDispatcher;

$dispatcher = WPEventDispatcher::fromDefaults();

默认情况下,你的事件监听器(WordPress 称之为钩子回调)被认为是可实例化的类($instance = new MyClass())。

可选的(但强烈推荐),你可以使用任何 PSR-11 容器 解决你的监听器。

use Snicco\Component\BetterWPHooks\WPEventDispatcher;
use Snicco\Component\EventDispatcher\BaseEventDispatcher;
use Snicco\Component\EventDispatcher\ListenerFactory\PsrListenerFactory;

$your_psr_container = /* */

$base_dispatcher = new BaseEventDispatcher(new PsrListenerFactory($your_psr_container));

$dispatcher = new WPEventDispatcher($base_dispatcher);

事件监听器

以下是附加监听器到任何事件的有效方式

use Snicco\Component\BetterWPHooks\WPEventDispatcher;

$dispatcher = WPEventDispatcher::fromDefaults();

// Assumes OrderListener has an __invoke method
$dispatcher->listen(OrderCreated::class, OrderListener::class);

// String names work for events
$dispatcher->listen('order_created', OrderListener::class);

// Any public method works
$dispatcher->listen(OrderCreated::class, [OrderListener::class, 'someMethod']);

// A simple closure listener
$dispatcher->listen(OrderCreated::class, function(OrderCreated $event) {
    // 
});

// This is the same as above
$dispatcher->listen(function(OrderCreated $event) {
    // 
});

事件分发

任何事件都是通过在您的 WPEventDispatcher 实例上调用 dispatch 方法来派发的。

dispatch 方法接受任何 object。默认情况下,将使用事件的类名来确定应该创建和调用的监听器。

由于 BetterWPHooks 符合 PSR-14 标准,对 dispatch 的每次调用都将返回传递的相同对象实例。

use Snicco\Component\BetterWPHooks\WPEventDispatcher;

$dispatcher = WPEventDispatcher::fromDefaults();
$dispatcher->listen(OrderCreated::class, function (OrderCreated $event) {
    // Do stuff with order
    $order = $event->order;
});

$order = /* */

$event = new OrderCreated($order);

// This will lazily create and call all listeners
// that are attached to OrderCreated::class event
$result = $dispatcher->dispatch($event);

var_dump($event === $result); // true

如果您出于某种原因不想创建专用的事件类,可以即时创建通用事件:GenericEvent 的第一个构造函数参数是事件名称,第二个参数是一个数组,这些参数将被传递给所有监听器。

use Snicco\Component\BetterWPHooks\WPEventDispatcher;
use Snicco\Component\EventDispatcher\GenericEvent;

$dispatcher = WPEventDispatcher::fromDefaults();

$dispatcher->listen('order_created', function (Order $order) {
    // Do stuff with order
});

$order = /* */

$dispatcher->dispatch(new GenericEvent('order_created', [$order]));

Event 接口

BetterWPHooks 提供了一个接口,您可以使用它来完全自定义事件的行为。

interface Event
{
    public function name(): string;
    
   /**
    * @return mixed  
    */
    public function payload();
}

假设 OrderCreated 事件实现了此 interface

class OrderCreated implements Event {
            
    private Order $order;
    
    public function __construct(Order $order) {
        $this->order = $order;
    }        
            
    public function name() :string {
        return 'order.created'
    }
    
    public function payload() : {
        return [$this, time()];
    }
}

您的代码现在看起来像这样

use Snicco\Component\BetterWPHooks\WPEventDispatcher;
use Snicco\Component\EventDispatcher\GenericEvent;

$dispatcher = WPEventDispatcher::fromDefaults();

$dispatcher->listen('order.created', function (Order $order, int $timestamp) {
    // Do stuff with order
});

$order = /* */

$dispatcher->dispatch(new OrderCreated($order));

事件订阅者

除了使用 listen 方法定义所有监听器之外,您还可以实现 EventSubscriber 接口,并在 WPEventDispatcher 上使用 subscribe 方法。

use Snicco\Component\BetterWPHooks\WPEventDispatcher;
use Snicco\Component\EventDispatcher\EventSubscriber;
use Snicco\Component\EventDispatcher\GenericEvent;

class OrderSubscriber implements EventSubscriber {
    
   public static function subscribedEvents() : array{
        
        return [
           OrderCreated::class => 'sendShippingNotification',
           OrderCanceled::class => 'sendCancelNotification'
        ];
   }
   
   public function sendShippingNotification(OrderCreated $event) :void {
        // 
   }
   
   public function sendCancelNotification(OrderCreated $event) :void {
        // 
   }
   
}

$dispatcher = WPEventDispatcher::fromDefaults();

$dispatcher->subscribe(OrderSubscriber::class);

$order = /* */

$dispatcher->dispatch(new OrderCreated($order));
$dispatcher->dispatch(new OrderCanceled($order));

移除事件监听器

在大多数情况下,您的应用程序/插件的引导阶段之后,事件派发器应该是不可变的。但是,如果您想移除事件/监听器,可以这样做

use Snicco\Component\BetterWPHooks\WPEventDispatcher;

$dispatcher = WPEventDispatcher::fromDefaults();

// This will remove ALL listeners for the order created event.
$dispatcher->remove(OrderCreated::class);

// This will remove only one listener
$dispatcher->remove(OrderCreated::class, [OrderListener::class, 'someMethod']);

如果您想防止移除特定的监听器,可以实现 Unremovable 接口。如果正在移除不可移除的监听器,将抛出 CantRemoveListener 异常。

use Snicco\Component\BetterWPHooks\WPEventDispatcher;
use Snicco\Component\EventDispatcher\Unremovable;

class OrderListener implements Unremovable {

    public function someMethod(OrderCreated $event){
        //
    }

}

$dispatcher = WPEventDispatcher::fromDefaults();

// This will throw an exception
$dispatcher->remove(OrderCreated::class, [OrderListener::class, 'someMethod']);

映射核心和第三方操作。

BetterWPHooks 带有一个非常有用的 EventMapper 类。这个 EventMapper 允许您将 WordPress 核心或其他第三方操作/过滤器转换为适当的事件对象。

它作为您代码和外部钩子之间的薄层。

映射的事件必须实现 MappedHookMappedFilter

如果您正在将事件映射到操作,请实现 MappedHook,如果您正在映射到期望返回值的过滤器,请实现 MappedFilter

使用 EventMapper,您可以保留 BetterWPHooks 的所有好处,例如惰性加载监听器,同时仍然能够以与以前相同的方式与第三方代码交互。

MappedHook 接口上的 shouldDispatch 方法为您提供对事件流程的极大控制。如果 shouldDispatcher 返回 (bool) false,则所有附加的监听器都不会被调用。

这允许您构建高度定制和性能出色的第三方代码集成。

映射到操作的示例

(只有当执行订单的用户登录时,此事件才会被派发)

use Snicco\Component\BetterWPHooks\EventMapping\EventMapper;
use Snicco\Component\BetterWPHooks\EventMapping\MappedHook;
use Snicco\Component\BetterWPHooks\WPEventDispatcher;

class LoggedInUserCreatedOrder implements MappedHook {
    
    public int $order_id;
    public int $current_user_id;
    
    public function __construct(int $order_id, int $current_user_id) {
    
       $this->order_id = $order_id;
       $this->current_user_id = $current_user_id;
       
    }
    
    public function shouldDispatch() : bool{
        return $this->current_user_id > 0;
    }
    
}

$wp_dispatcher = WPEventDispatcher::fromDefaults();

$wp_dispatcher->listen(function (LoggedInUserCreatedOrder $event) {
    $id = $event->order_id;
    $user_id = $event->current_user_id;
});

$event_mapper = new EventMapper($wp_dispatcher);
$event_mapper->map('woocommerce_order_created', LoggedInUserCreatedOrder::class, 10);

do_action('woocommerce_order_created', 1000, 1);

映射到过滤器的示例

(由于我们返回 true,此事件始终会被派发)

use Snicco\Component\BetterWPHooks\EventMapping\EventMapper;
use Snicco\Component\BetterWPHooks\EventMapping\MappedFilter;
use Snicco\Component\BetterWPHooks\WPEventDispatcher;

class DeterminingOrderPrice implements MappedFilter {
    
    public int $new_total;
    public int $initial_order_total;
    
    public function __construct(int $initial_order_total) {
        $this->new_total = $intial_order_total;
        $this->initial_order_total = $intial_order_total;
    }
    
    public function filterableAttribute(){
        return $this->new_total;
    }
    
    public function shouldDispatch() : bool{
        return true;
    }
    
}

$wp_dispatcher = WPEventDispatcher::fromDefaults();

$wp_dispatcher->listen(function (DeterminingOrderPrice $event) {
   if($some_condition) {
        $event->new_total+= 5000;
   }
});

$wp_dispatcher->listen(function (DeterminingOrderPrice $event) {
   if($some_condition) {
        $event->new_total+= 4000;
   }
});

$event_mapper = new EventMapper($wp_dispatcher);
$event_mapper->map('woocommerce_order_total', DeterminingOrderPrice::class, 10);

// Somewhere in woocommerce
$order_total = apply_filters('woocommerce_order_total', 1000);

var_dump($order_total); // (int) 10000

确保您的映射事件首先触发

使用 EventMapper 上的 mapFirst 方法,您的监听器将始终在注册到 WordPress 的任何其他钩子回调之前运行。

use Snicco\Component\BetterWPHooks\EventMapping\EventMapper;
use Snicco\Component\BetterWPHooks\WPEventDispatcher;

$wp_dispatcher = WPEventDispatcher::fromDefaults();

$wp_dispatcher->listen(OrderCreated::class, OrderListener::class);

$event_mapper = new EventMapper($wp_dispatcher);
$event_mapper->mapFirst('woocommerce_order_created', OrderCreated::class);

function some_other_random_callback() {

}
add_action('woocommerce_order_created', 'some_other_random_callback', PHP_INT_MIN);

// OrderListener will still be called first. 
do_action('woocommerce_order_created', 1000, 1);

确保您的映射事件最后触发

使用 EventMapper 上的 mapLast 方法,您的监听器将始终在注册到 WordPress 的任何其他钩子回调之后运行。这对于您想要控制最终结果的过滤器特别有用。

use Snicco\Component\BetterWPHooks\EventMapping\EventMapper;
use Snicco\Component\BetterWPHooks\WPEventDispatcher;

$wp_dispatcher = WPEventDispatcher::fromDefaults();

$wp_dispatcher->listen(OrderCreated::class, OrderListener::class);

$event_mapper = new EventMapper($wp_dispatcher);
$event_mapper->mapLast('woocommerce_order_created', OrderCreated::class);

function some_other_random_callback() {
    return 5000;
}
add_filter('woocommerce_order_created', 'some_other_random_callback', PHP_INT_MAX);

// OrderListener will still be called last. 
$order_total = apply_filters('woocommerce_order_total', 1000);

将(一些)事件公开到 WordPress 钩子系统

WordPress 钩子系统在全局范围内可用。这是一个问题。对于作为开发者的您以及想要与您应用程序/插件创建的定制事件交互的用户来说。

没有方法可以强制执行哪些事件是安全的,哪些事件可能会因为您重构了代码而明天消失。

ExposeToWP 接口有助于解决这个问题。

默认情况下,每次您派发事件时,都会首先调用您的内部监听器。

如果发送的事件对象实现了 ExposeToWP 接口,则事件对象将被传递到 WordPress 插件系统,以便第三方开发者可以在您定义的范围内与您的代码交互。

如果发送的事件对象没有实现 ExposeToWP,则它将 不可用WordPress 插件。

示例

use Snicco\Component\BetterWPHooks\EventMapping\ExposeToWP;

class PrivateEvent {
    
}

class PublicEvent implements ExposeToWP {

}

add_action(PrivateEvent::class, function (PrivateEvent $event) {
    // This will never be called
});

add_action(PublicEvent::class, function (PublicEvent $event) {
     // This will be called.
});

$dispatcher->dispatch(new PrivateEvent());

$dispatcher->dispatch(new PublicEvent());

apply_filters 更好的替代品

PSR-14 元文档 定义了事件系统四个常见的目标

  • 单向通知。(“我做了某事,如果你感兴趣。”)
  • 对象增强。(“这里有某物,请在我做某事之前修改它。”)
  • 收集。(“把所有你的东西给我,我可以对这份清单做些什么。”)
  • 替代链。(“这里有某物;你们中第一个能处理它的人来处理,然后停止。”)

在您的代码中使用 apply_filters 大多数时候意味着您想要增强行为或允许其他开发者自定义您的代码的行为。(对象增强

apply_filters 对于此目的并不理想,因为它的返回类型是 mixed。没有任何阻止第三方开发者错误地返回 (int) 0,而您期望的是 (bool) false

事件对象允许您强制执行 类型安全,这样您就不必手动检查每个过滤器的最终结果。

这是我们推荐并在我们代码中使用的方法

use Snicco\Component\BetterWPHooks\EventMapping\ExposeToWP;

class PerformingUserDeletion implements ExposeToWP {

    public bool $is_allowed = true;
    private int $user_being_deleted;
    private int $current_user_id;
    
    public function __construct(int $user_being_deleted, int $current_user_id) {
         $this->user_being_deleted = $user_being_deleted;
         $this->current_user_id = $current_user_id;
    }
    
    public function userBeingDeleted(): int{
        return $this->user_being_deleted;
    }
    
    public function currentUserId(): int{
        return $this->current_user_id;
    }
    
}

// Some third-party-code:
add_filter(PerformingUserDeletion::class, function(PerformingUserDeletion $event) {
    
    // The user with id 10 must never be deleted.
    if(10 === $event->userBeingDeleted()) {
        $event->is_allowed = false;
    }
    
});

// Your code.
$action = $dispatcher->dispatch(new PerformingUserDeletion(10, 1));

// There is no way that this is not a boolean.
if(!$action->is_allowed) {
    throw new Exception('You have no permission to delete this user.');
}

// Delete user.

停止事件流/传播

在某些情况下,监听器阻止其他监听器被调用可能是合理的。换句话说,监听器需要能够告诉调度器停止将事件传播给未来的监听器(即不再通知任何监听器)。

为了使此功能正常工作,您的事件对象必须实现 PSR-14 StoppableEventInterface

示例

use Psr\EventDispatcher\StoppableEventInterface;

class DeterminingOrderPrice implements StoppableEventInterface {
    
    public int $initial_price;
    public int $order_total;
    
    public function __construct( int $initial_price ) {
        $this->order_total = $initial_price;
        $this->initial_price = $initial_price;
    }
    
    public function isPropagationStopped() : bool{
        return $this->order_total >= 2000    
    }
    
    
}

$dispatcher->listen(function (DeterminingOrderPrice $event) {
    $event->order_total+=200
})

$dispatcher->listen(function (DeterminingOrderPrice $event) {
    $event->order_total+=800
})

$dispatcher->listen(function (DeterminingOrderPrice $event) {
   throw new Exception('This will never be called.');
})

$dispatcher->dispatch(new DeterminingOrderPrice(1000));

测试

BetterWPHooks 随附针对 phpunit 的专用测试工具。

首先,安装

composer require snicco/event-dispatcher-testing --dev

此包应使用 composer 作为 dev dependency 安装。它 不适用于生产使用

现在,在您的测试中,您应该将配置好的 WPEventDispatcherTestableEventDispatcher 包装起来。您如何做到这一点取决于您如何构建代码库。

TestableEventDispachter 包装了 WPEventDispatcher 并可以在测试中对发送的事件进行断言。

此外,您可以伪造事件,以便它们不会传递给真实的 WPEventDispatcher

dispatchlistensubscriberemove 方法将被代理到 WPEventDispatcher。以下是一些断言方法。

use Snicco\Component\EventDispatcher\Testing\TestableEventDispatcher;

$testable_dispatcher = new TestableEventDispatcher(WPEventDispatcher::fromDefaults());

$testable_dispatcher->assertNotingDispatched();

$testable_dispatcher->assertNotDispatched(OrderCreated::class);

$testable_dispatcher->assertDispatched(OrderCreated::class);

$testable_dispatcher->assertDispatchedTimes(OrderCreated::class, 2);

// With conditions.

$testable_dispatcher->assertDispatched(function (OrderCreated $event) {
    return $event->order->total >= 1000;
});

$testable_dispatcher->assertNotDispatched(function (OrderCreated $event) {
    return $event->order->total >= 1000;
});

某些事件可以伪造如下

use Snicco\Component\EventDispatcher\Testing\TestableEventDispatcher;

$testable_dispatcher = new TestableEventDispatcher(WPEventDispatcher::fromDefaults());

// No event will be passed to the real dispatcher, assertions still work.
$testable_dispatcher->fakeAll();

// Fake one (or more) events. They will be not passed to the real dispatcher
// while all other events will.
$testable_dispatcher->fake(OrderCreated::class);
$testable_dispatcher->fake([OrderCreated::class, OrderDeleted::class]);

$testable_dispatcher->fakeExcept(OrderCreated::class);

$testable_dispatcher->resetDispatchedEvents();

贡献

此存储库是 Snicco 项目 开发存储库的只读分支。

您可以这样贡献.

报告问题和发送拉取请求

请在 Snicco 单一存储库 中报告问题。

安全

如果您在 BetterWPHooks 中发现安全漏洞,请遵循我们的 披露程序