从领域事件到Webhooks
I’m sorry, but I can’t access external links. Could you please paste the text you’d like translated here? Once you provide the content, I’ll translate it into Simplified Chinese while preserving the formatting and markdown as you requested.
域事件
域事件实现以下接口:
<?php
interface DomainEvent
{
public function aggregateRootId(): string;
public function displayReference(): string;
public function occurredAt(): \DateTimeImmutable;
public static function eventType(): DomainEventType;
}
示例事件
<?php
#[TriggerWebhook]
class OrderConfirmed implements DomainEvent
{
public function __construct(
private string $orderId,
private string $orderNumber,
private \DateTimeImmutable $confirmedAt,
) {}
public function aggregateRootId(): string
{
return $this->orderId;
}
public function displayReference(): string
{
return $this->orderNumber;
}
public function occurredAt(): \DateTimeImmutable
{
return $this->confirmedAt;
}
public static function eventType(): DomainEventType
{
return DomainEventType::OrderConfirmed;
}
}
#[TriggerWebhook] 属性标记该事件需要通过 webhook 进行投递。
事件类型枚举
DomainEventType 枚举将事件类型映射到其具体类。
(我们还会将事件持久化到事件存储中;eventClass() 方法在反序列化存储的事件时使用——此部分此处省略。)
<?php
enum DomainEventType: string
{
case OrderConfirmed = 'order.confirmed';
// … more event types
public function eventClass(): string
{
return match ($this) {
self::OrderConfirmed => OrderConfirmed::class,
// … more mappings
};
}
}
Entity
实体记录了发生的事情。仓库保存它并分发事件(通过 Symfony Messenger)。
<?php
class Order
{
/** @var DomainEvent[] */
private array $events = [];
public function confirm(\DateTimeImmutable $confirmedAt): void
{
$this->status = OrderStatus::Confirmed;
$this->events[] = new OrderConfirmed(
orderId: $this->id,
orderNumber: $this->orderNumber,
confirmedAt: $confirmedAt,
);
}
public function releaseEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}
}
<?php
class OrderRepository
{
public function __construct(
private EntityManagerInterface $em,
private MessageBusInterface $eventBus,
) {}
public function save(Order $order): void
{
$events = $order->releaseEvents();
$this->em->persist($order);
$this->em->flush();
foreach ($events as $event) {
$this->eventBus->dispatch($event);
}
}
}
Source: …
Webhooks
消息处理器会处理所有领域事件,但仅对 $webhookTopics 数组中的事件发送 webhook。
该数组通过使用 Symfony 的 资源标签(在 PR #59704 中加入)收集所有带有 #[TriggerWebhook] 属性的类来填充。
<?php
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Uid\Factory\UuidFactory;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
#[AsMessageHandler]
class WebhookSender
{
/**
* @param DomainEventType[] $webhookTopics
*/
public function __construct(
private MessageBusInterface $bus,
private WebhookSubscriptions $subscriptions,
private NormalizerInterface $normalizer,
private UuidFactory $uuidFactory,
#[Autowire(param: 'webhook.topics')]
private array $webhookTopics,
) {}
public function __invoke(DomainEvent $event): void
{
if (!\in_array($event::eventType(), $this->webhookTopics, true)) {
return;
}
$payload = $this->createPayload($event);
$remoteEvent = new RemoteEvent(
name: $event::eventType()->value,
id: $this->uuidFactory->create()->toString(),
payload: $payload,
);
foreach ($this->subscriptions->findByTopic($event::eventType()) as $subscription) {
$this->bus->dispatch(
new SendWebhookMessage(
new Subscriber($subscription->url, $subscription->secret),
$remoteEvent,
),
);
}
}
private function createPayload(DomainEvent $event): array
{
// Option 1: Send the event directly
// return $this->normalizer->normalize($event);
// Option 2: Send a dataless notification with a resource URL (used here)
return [
'resourceId' => $event->aggregateRootId(),
'displayReference' => $event->displayReference(),
'occurredAt' => $event->occurredAt()->getTimestamp(),
'topic' => $event::eventType()->value,
// … additional fields as needed
];
}
}
处理器流程
- 检查事件的类型是否在配置的
$webhookTopics中。 - 对事件进行标准化(或构建自定义负载)。
- 将负载包装在
RemoteEvent中。 - 为每个匹配该主题的订阅分发
SendWebhookMessage。
示例负载生成(可选)
// 实际使用中,我们通过 ApiPlatform\Metadata\IriConverterInterface 来生成资源 URL,
// 你也可以采用类似的策略。
[
'url' => "https://example.com/api/orders/{$event->aggregateRootId()}",
];
Subscription Interface & Model
<?php
interface WebhookSubscriptions
{
/**
* @return iterable<WebhookSubscription>
*/
public function findByTopic(DomainEventType $topic): iterable;
}
<?php
class WebhookSubscription
{
public function __construct(
public string $url,
public string $secret,
) {}
}
Symfony\Component\Webhook\Subscriber 类是 Symfony 对 webhook 消费者的表示。它保存 webhook URL 和 secret。当 Symfony 的 webhook 传输发送 HTTP POST 请求时,它会自动添加以下头部:
- Webhook-Signature – 使用 secret 的 HMAC‑SHA256 签名(格式:
sha256=...) - Webhook-Id –
RemoteEvent的 id - Webhook-Event –
RemoteEvent的名称
要自定义签名算法或头部名称,请装饰:
提示: 将 SendWebhookMessage 路由到异步传输以实现非阻塞投递。配置重试策略(延迟 + 乘数)以处理临时失败。如果事件顺序重要,请使用 FIFO 队列。
使用 API Platform(OpenAPI)记录 Webhook
如果你使用 API Platform,可以在 OpenAPI 中记录你的 webhook。下面的示例使用前面展示的 dataless‑notification 模式。
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Attributes\Webhook;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\Parameter;
use ApiPlatform\OpenApi\Model\PathItem;
use ApiPlatform\OpenApi\Model\Response;
#[ApiResource(
operations: [
new Post(
openapi: new Webhook(
name: 'Webhook',
pathItem: new PathItem(
post: new Operation(
operationId: 'resource_webhook',
tags: ['Webhooks'],
responses: [
'2XX' => new Response(
description: 'Return 2xx to acknowledge receipt'
),
'default' => new Response(
description: 'Non-2xx triggers retry: 5m, 25m, 2h5m'
),
],
summary: 'Webhook notification for order events',
description: 'Sent when subscribed order events occur. Event type in Webhook-Event header.',
parameters: [
new Parameter(
name: 'Webhook-Signature',
in: 'header',
description: 'HMAC‑SHA256 signature of the payload',
required: true,
),
new Parameter(
name: 'Webhook-Id',
in: 'header',
description: 'Unique identifier of the RemoteEvent',
required: true,
),
new Parameter(
name: 'Webhook-Event',
in: 'header',
description: 'Name of the event (topic)',
required: true,
),
],
),
),
),
),
],
)]
class Order
{
// …
}
其他 OpenAPI 示例
<?php
#[OA\Get(
path: '/webhooks',
operationId: 'listWebhooks',
tags: ['Webhooks'],
summary: 'List all webhooks',
description: 'Retrieve a paginated list of webhook subscriptions.',
parameters: [
new Parameter(
name: 'page',
in: 'query',
description: 'Page number for pagination',
required: false,
schema: ['type' => 'integer', 'default' => 1]
),
new Parameter(
name: 'perPage',
in: 'query',
description: 'Number of items per page',
required: false,
schema: ['type' => 'integer', 'default' => 20]
),
],
responses: [
new OA\Response(
response: 200,
description: 'Successful response',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/WebhookPayload')
),
new OA\Property(
property: 'meta',
type: 'object',
properties: [
new OA\Property(property: 'total', type: 'integer'),
new OA\Property(property: 'page', type: 'integer'),
new OA\Propert
y(property: 'perPage', type: 'integer')
]
)
]
)
)
]
)]
class WebhookPayload
{
public function __construct(
public string $resourceId,
public string $displayReference,
public \DateTimeImmutable $occurredAt,
public string $url,
public string $topic,
) {}
}
最初发表于我的博客