From 00c11c44b9da77f3b45916bd7187abe59d4f8ee6 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Mon, 15 Jun 2026 17:10:44 +0300 Subject: [PATCH 1/3] Introduce message class resolver --- config/di.php | 8 ++++ config/params.php | 1 + docs/guide/en/configuration-with-config.md | 16 +++++++ .../ArrayMessageClassResolver.php | 35 ++++++++++++++ .../MessageClassResolverInterface.php | 24 ++++++++++ src/Message/Serializer/MessageSerializer.php | 47 +++++++++---------- .../ArrayMessageClassResolverTest.php | 46 ++++++++++++++++++ .../Serializer/MessageSerializerTest.php | 36 ++++---------- 8 files changed, 160 insertions(+), 53 deletions(-) create mode 100644 src/Message/ClassResolver/ArrayMessageClassResolver.php create mode 100644 src/Message/ClassResolver/MessageClassResolverInterface.php create mode 100644 tests/Unit/Message/ClassResolver/ArrayMessageClassResolverTest.php diff --git a/config/di.php b/config/di.php index 5747ab56..9fa15cca 100644 --- a/config/di.php +++ b/config/di.php @@ -6,6 +6,8 @@ use Yiisoft\Queue\Cli\LoopInterface; use Yiisoft\Queue\Cli\SignalLoop; use Yiisoft\Queue\Cli\SimpleLoop; +use Yiisoft\Queue\Message\ClassResolver\ArrayMessageClassResolver; +use Yiisoft\Queue\Message\ClassResolver\MessageClassResolverInterface; use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; use Yiisoft\Queue\Message\Serializer\MessageEncoderInterface; use Yiisoft\Queue\Message\Serializer\MessageSerializer; @@ -49,4 +51,10 @@ ], MessageEncoderInterface::class => JsonMessageEncoder::class, MessageSerializerInterface::class => MessageSerializer::class, + MessageClassResolverInterface::class => [ + 'class' => ArrayMessageClassResolver::class, + '__construct()' => [ + 'map' => $params['yiisoft/queue']['message-class-map'], + ], + ], ]; diff --git a/config/params.php b/config/params.php index 776b488a..fad7f0e0 100644 --- a/config/params.php +++ b/config/params.php @@ -24,6 +24,7 @@ 'middlewares-push' => [], 'middlewares-consume' => [], 'middlewares-fail' => [], + 'message-class-map' => [], ], 'yiisoft/yii-debug' => [ 'collectors' => [ diff --git a/docs/guide/en/configuration-with-config.md b/docs/guide/en/configuration-with-config.md index 49ffb64a..301b99b5 100644 --- a/docs/guide/en/configuration-with-config.md +++ b/docs/guide/en/configuration-with-config.md @@ -13,6 +13,22 @@ Advanced applications eventually need the following tweaks: - **Queue names** — configure queue/back-end per logical queue name via [`yiisoft/queue.queues` config](queue-names.md) when you need to parallelize message handling or send some of them to a different application. - **Named handlers or callable definitions** — map a short message type to a callable in [`yiisoft/queue.handlers` config](message-handler-advanced.md) when another application is the message producer and you cannot use FQCN as message type. +- **Message class map** — map message types to specific message classes so that `unserialize()` reconstructs the + original typed object instead of falling back to `GenericMessage`. Configure via `yiisoft/queue.message-class-map`: + + ```php + return [ + 'yiisoft/queue' => [ + 'message-class-map' => [ + 'send-email' => SendEmailMessage::class, + 'download-file' => DownloadFileMessage::class, + ], + ], + ]; + ``` + + When a type is not present in the map, `GenericMessage` is used as a fallback. + - **Middleware pipelines** — adjust push/consume/failure behavior: collect metrics, modify messages, and so on. See [Middleware pipelines](middleware-pipelines.md) for details. If you don't have a broker yet (for development, testing, or as a stepping stone before introducing diff --git a/src/Message/ClassResolver/ArrayMessageClassResolver.php b/src/Message/ClassResolver/ArrayMessageClassResolver.php new file mode 100644 index 00000000..8d9b524b --- /dev/null +++ b/src/Message/ClassResolver/ArrayMessageClassResolver.php @@ -0,0 +1,35 @@ + OrderCreatedMessage::class, + * 'send_email' => SendEmailMessage::class, + * ] + * ``` + * + * @psalm-param array> $map + */ + public function __construct( + private readonly array $map = [], + ) {} + + public function resolve(string $type): ?string + { + return $this->map[$type] ?? null; + } +} diff --git a/src/Message/ClassResolver/MessageClassResolverInterface.php b/src/Message/ClassResolver/MessageClassResolverInterface.php new file mode 100644 index 00000000..96e821e5 --- /dev/null +++ b/src/Message/ClassResolver/MessageClassResolverInterface.php @@ -0,0 +1,24 @@ +|null + */ + public function resolve(string $type): ?string; +} diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index 81cab490..b32091ec 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -4,7 +4,8 @@ namespace Yiisoft\Queue\Message\Serializer; -use Yiisoft\Queue\Message\Envelope; +use Yiisoft\Queue\Message\ClassResolver\ArrayMessageClassResolver; +use Yiisoft\Queue\Message\ClassResolver\MessageClassResolverInterface; use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Message\MessageInterface; @@ -12,35 +13,39 @@ use function is_string; /** - * Serializes and unserializes queue messages, preserving the original message class in metadata. + * Serializes and unserializes queue messages, resolving the message class via a {@see MessageClassResolverInterface}. * * When serializing, assembles an array with `type`, `data`, and `meta` keys and passes it as a single array to * {@see MessageEncoderInterface}, which encodes it to a string. When unserializing, decodes the string back to an - * array and reconstructs the original message class from the `meta` key, falling back to {@see GenericMessage} - * if the class is missing or invalid. + * array and resolves the message class from the type via the resolver, falling back to {@see GenericMessage} + * if the type is not registered. */ final class MessageSerializer implements MessageSerializerInterface { - private const META_MESSAGE_CLASS = 'message-class'; - + private readonly MessageClassResolverInterface $resolver; + + /** + * @param MessageEncoderInterface $encoder Encoder used to encode and decode message data. + * @param MessageClassResolverInterface|array $classResolver Resolver for message classes, or a map of type to + * class. + * + * @psalm-param MessageClassResolverInterface|array> $classResolver + */ public function __construct( private readonly MessageEncoderInterface $encoder, - ) {} + MessageClassResolverInterface|array $classResolver = [], + ) { + $this->resolver = is_array($classResolver) + ? new ArrayMessageClassResolver($classResolver) + : $classResolver; + } public function serialize(MessageInterface $message): string { - $metadata = $message->getMetadata(); - - if (!isset($metadata[self::META_MESSAGE_CLASS])) { - $metadata[self::META_MESSAGE_CLASS] = $message instanceof Envelope - ? $message->getMessage()::class - : $message::class; - } - return $this->encoder->encode([ 'type' => $message->getType(), 'data' => $message->getData(), - 'meta' => $metadata, + 'meta' => $message->getMetadata(), ]); } @@ -62,15 +67,7 @@ public function unserialize(string $value): MessageInterface throw new MessageSerializerException('Metadata must be an array. Got ' . get_debug_type($metadata) . '.'); } - $class = $metadata[self::META_MESSAGE_CLASS] ?? GenericMessage::class; - - // Don't check subclasses when it's a default class: that's faster - if ($class !== GenericMessage::class - && (!is_string($class) || !is_subclass_of($class, MessageInterface::class)) - ) { - $class = GenericMessage::class; - } - /** @var class-string $class */ + $class = $this->resolver->resolve($type) ?? GenericMessage::class; return $class::fromData($type, $data['data'] ?? null)->withMetadata($metadata); } diff --git a/tests/Unit/Message/ClassResolver/ArrayMessageClassResolverTest.php b/tests/Unit/Message/ClassResolver/ArrayMessageClassResolverTest.php new file mode 100644 index 00000000..da5b80fa --- /dev/null +++ b/tests/Unit/Message/ClassResolver/ArrayMessageClassResolverTest.php @@ -0,0 +1,46 @@ + TestMessage::class]); + + $this->assertSame(TestMessage::class, $resolver->resolve('test')); + } + + public function testResolveUnregisteredTypeReturnsNull(): void + { + $resolver = new ArrayMessageClassResolver(['test' => TestMessage::class]); + + $this->assertNull($resolver->resolve('unknown')); + } + + public function testResolveWithEmptyMapReturnsNull(): void + { + $resolver = new ArrayMessageClassResolver(); + + $this->assertNull($resolver->resolve('test')); + } + + public function testResolveMultipleTypes(): void + { + $resolver = new ArrayMessageClassResolver([ + 'generic' => GenericMessage::class, + 'test' => TestMessage::class, + ]); + + $this->assertSame(GenericMessage::class, $resolver->resolve('generic')); + $this->assertSame(TestMessage::class, $resolver->resolve('test')); + $this->assertNull($resolver->resolve('not-registered')); + } +} diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php index 53c40a08..0223cea2 100644 --- a/tests/Unit/Message/Serializer/MessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -61,22 +61,7 @@ public function testUnsupportedMetadata(mixed $metadata): void $this->createSerializer()->unserialize($value); } - public function testDefaultMessageClassFallbackWrongClass(): void - { - $payload = [ - 'type' => 'handler', - 'data' => 'test', - 'meta' => [ - 'message-class' => 'NonExistentClass', - ], - ]; - - $message = $this->createSerializer()->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - - $this->assertInstanceOf(GenericMessage::class, $message); - } - - public function testDefaultMessageClassFallbackClassNotSet(): void + public function testFallbackToGenericMessageForUnknownType(): void { $payload = [ 'type' => 'handler', @@ -84,7 +69,7 @@ public function testDefaultMessageClassFallbackClassNotSet(): void 'meta' => [], ]; - $message = $this->createSerializer()->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); + $message = $this->createSerializer()->unserialize(json_encode($payload)); $this->assertInstanceOf(GenericMessage::class, $message); } @@ -116,7 +101,7 @@ public function testSerialize(): void $json = $this->createSerializer()->serialize($message); $this->assertEquals( - '{"type":"handler","data":"test","meta":{"message-class":"Yiisoft\\\\Queue\\\\Message\\\\GenericMessage"}}', + '{"type":"handler","data":"test","meta":[]}', $json, ); } @@ -129,11 +114,7 @@ public function testSerializeEnvelopeStack(): void $json = $serializer->serialize($message); $this->assertEquals( - sprintf( - '{"type":"handler","data":"test","meta":{"%s":"test-id","message-class":"%s"}}', - IdEnvelope::META_ID, - str_replace('\\', '\\\\', GenericMessage::class), - ), + sprintf('{"type":"handler","data":"test","meta":{"%s":"test-id"}}', IdEnvelope::META_ID), $json, ); @@ -142,14 +123,13 @@ public function testSerializeEnvelopeStack(): void $this->assertInstanceOf(GenericMessage::class, $restored); $this->assertEquals([ IdEnvelope::META_ID => 'test-id', - 'message-class' => GenericMessage::class, ], $restored->getMetadata()); } public function testRestoreOriginalMessageClass(): void { $message = new TestMessage(); - $serializer = $this->createSerializer(); + $serializer = $this->createSerializer(['test' => TestMessage::class]); $restored = $serializer->unserialize($serializer->serialize($message)); @@ -159,15 +139,15 @@ public function testRestoreOriginalMessageClass(): void public function testRestoreOriginalMessageClassWithEnvelope(): void { $message = new IdEnvelope(new TestMessage(), 1); - $serializer = $this->createSerializer(); + $serializer = $this->createSerializer(['test' => TestMessage::class]); $restored = $serializer->unserialize($serializer->serialize($message)); $this->assertInstanceOf(TestMessage::class, $restored); } - private function createSerializer(): MessageSerializer + private function createSerializer(array $classResolver = []): MessageSerializer { - return new MessageSerializer(new JsonMessageEncoder()); + return new MessageSerializer(new JsonMessageEncoder(), $classResolver); } } From 0459e67d59f75c383ff8befa64ce3c6fb09513a8 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 16 Jun 2026 08:33:25 +0300 Subject: [PATCH 2/3] Rename `messsage-class-map` to `messages` --- config/di.php | 2 +- config/params.php | 21 ++++++++++++++++++++- docs/guide/en/configuration-with-config.md | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/config/di.php b/config/di.php index 9fa15cca..86224d7c 100644 --- a/config/di.php +++ b/config/di.php @@ -54,7 +54,7 @@ MessageClassResolverInterface::class => [ 'class' => ArrayMessageClassResolver::class, '__construct()' => [ - 'map' => $params['yiisoft/queue']['message-class-map'], + 'map' => $params['yiisoft/queue']['messages'], ], ], ]; diff --git a/config/params.php b/config/params.php index fad7f0e0..1c73db0c 100644 --- a/config/params.php +++ b/config/params.php @@ -8,6 +8,8 @@ use Yiisoft\Queue\Debug\QueueCollector; use Yiisoft\Queue\Debug\QueueProviderInterfaceProxy; use Yiisoft\Queue\Debug\QueueWorkerInterfaceProxy; +use Yiisoft\Queue\Message\MessageHandlerInterface; +use Yiisoft\Queue\Message\Serializer\MessageSerializer; use Yiisoft\Queue\Provider\QueueProviderInterface; use Yiisoft\Queue\Worker\WorkerInterface; @@ -20,11 +22,28 @@ ], ], 'yiisoft/queue' => [ + /** + * Map of message type to message class. Used by {@see MessageSerializer} to reconstruct the original typed + * message object on unserialize. Example: + * [ + * 'send-email' => SendEmailMessage::class, + * 'generate-report' => GenerateReportMessage::class, + * ] + */ + 'messages' => [], + /** + * Map of message type to handler. The worker uses this to find the handler for a received message. + * A handler may be a class name implementing {@see MessageHandlerInterface}, a callable, or any definition + * supported by yiisoft/injector. Example: + * [ + * 'send-email' => SendEmailHandler::class, + * 'generate-report' => [GenerateReportHandler::class, 'handle'], + * ] + */ 'handlers' => [], 'middlewares-push' => [], 'middlewares-consume' => [], 'middlewares-fail' => [], - 'message-class-map' => [], ], 'yiisoft/yii-debug' => [ 'collectors' => [ diff --git a/docs/guide/en/configuration-with-config.md b/docs/guide/en/configuration-with-config.md index 301b99b5..d528ea6f 100644 --- a/docs/guide/en/configuration-with-config.md +++ b/docs/guide/en/configuration-with-config.md @@ -14,12 +14,12 @@ Advanced applications eventually need the following tweaks: - **Queue names** — configure queue/back-end per logical queue name via [`yiisoft/queue.queues` config](queue-names.md) when you need to parallelize message handling or send some of them to a different application. - **Named handlers or callable definitions** — map a short message type to a callable in [`yiisoft/queue.handlers` config](message-handler-advanced.md) when another application is the message producer and you cannot use FQCN as message type. - **Message class map** — map message types to specific message classes so that `unserialize()` reconstructs the - original typed object instead of falling back to `GenericMessage`. Configure via `yiisoft/queue.message-class-map`: + original typed object instead of falling back to `GenericMessage`. Configure via `yiisoft/queue.messages`: ```php return [ 'yiisoft/queue' => [ - 'message-class-map' => [ + 'messages' => [ 'send-email' => SendEmailMessage::class, 'download-file' => DownloadFileMessage::class, ], From 266bc9f33e69d57e689b3a80b5cee3f4c930acbf Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 16 Jun 2026 08:43:29 +0300 Subject: [PATCH 3/3] fix docs --- docs/guide/en/configuration-with-config.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/guide/en/configuration-with-config.md b/docs/guide/en/configuration-with-config.md index d528ea6f..603dcbfb 100644 --- a/docs/guide/en/configuration-with-config.md +++ b/docs/guide/en/configuration-with-config.md @@ -13,8 +13,9 @@ Advanced applications eventually need the following tweaks: - **Queue names** — configure queue/back-end per logical queue name via [`yiisoft/queue.queues` config](queue-names.md) when you need to parallelize message handling or send some of them to a different application. - **Named handlers or callable definitions** — map a short message type to a callable in [`yiisoft/queue.handlers` config](message-handler-advanced.md) when another application is the message producer and you cannot use FQCN as message type. -- **Message class map** — map message types to specific message classes so that `unserialize()` reconstructs the - original typed object instead of falling back to `GenericMessage`. Configure via `yiisoft/queue.messages`: +- **Message class map** — map message types to specific message classes so that + `MessageSerializerInterface::unserialize()` reconstructs the original typed object instead of falling back + to `GenericMessage`. Configure via `yiisoft/queue.messages`: ```php return [