├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── Configuration.php ├── DependencyInjection.php ├── LICENSE ├── README.md ├── README_rus.md ├── README_v1.md ├── components ├── AbstractConnectionFactory.php ├── BaseRabbitMQ.php ├── Consumer.php ├── ConsumerInterface.php ├── Logger.php ├── Producer.php └── Routing.php ├── composer.json ├── controllers └── RabbitMQController.php ├── events ├── RabbitMQConsumerEvent.php └── RabbitMQPublisherEvent.php ├── exceptions ├── InvalidConfigException.php └── RuntimeException.php ├── helpers └── CreateUnitHelper.php ├── migrations └── M201026132305RabbitPublishError.php ├── models └── RabbitPublishError.php ├── phpunit.xml.dist └── tests ├── ConfigurationTest.php ├── DependencyInjectionTest.php ├── TestCase.php ├── bootstrap.php ├── components ├── AbstractConnectionFactoryTest.php ├── ConsumerTest.php ├── ProducerTest.php └── RoutingTest.php └── controllers └── RabbitMQControllerTest.php /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | coverage_clover: build/logs/clover.xml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # windows thumbnail cache 13 | Thumbs.db 14 | 15 | composer.lock 16 | vendor 17 | build -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.0 5 | - 7.1 6 | - 7.2 7 | - 7.3 8 | - 7.4 9 | 10 | install: 11 | - composer clear-cache 12 | - composer global require fxp/composer-asset-plugin --ignore-platform-reqs 13 | - composer install --no-interaction --prefer-dist 14 | 15 | script: vendor/bin/phpunit 16 | 17 | after_success: 18 | - travis_retry php vendor/bin/coveralls -v -------------------------------------------------------------------------------- /Configuration.php: -------------------------------------------------------------------------------- 1 | true, 33 | 'connections' => [ 34 | [ 35 | 'name' => self::DEFAULT_CONNECTION_NAME, 36 | 'type' => AMQPLazyConnection::class, 37 | 'url' => null, 38 | 'host' => null, 39 | 'port' => 5672, 40 | 'user' => 'guest', 41 | 'password' => 'guest', 42 | 'vhost' => '/', 43 | 'connection_timeout' => 3, 44 | 'read_write_timeout' => 3, 45 | 'ssl_context' => null, 46 | 'keepalive' => false, 47 | 'heartbeat' => 0, 48 | 'channel_rpc_timeout' => 0.0, 49 | ], 50 | ], 51 | 'exchanges' => [ 52 | [ 53 | 'name' => null, 54 | 'type' => null, 55 | 'passive' => false, 56 | 'durable' => true, 57 | 'auto_delete' => false, 58 | 'internal' => false, 59 | 'nowait' => false, 60 | 'arguments' => null, 61 | 'ticket' => null, 62 | 'declare' => true, 63 | ], 64 | ], 65 | 'queues' => [ 66 | [ 67 | 'name' => '', 68 | 'passive' => false, 69 | 'durable' => true, 70 | 'exclusive' => false, 71 | 'auto_delete' => false, 72 | 'nowait' => false, 73 | 'arguments' => null, 74 | 'ticket' => null, 75 | 'declare' => true, 76 | ], 77 | ], 78 | 'bindings' => [ 79 | [ 80 | 'exchange' => null, 81 | 'queue' => null, 82 | 'to_exchange' => null, 83 | 'routing_keys' => [], 84 | ], 85 | ], 86 | 'producers' => [ 87 | [ 88 | 'name' => null, 89 | 'connection' => self::DEFAULT_CONNECTION_NAME, 90 | 'safe' => true, 91 | 'content_type' => 'text/plain', 92 | 'delivery_mode' => 2, 93 | 'serializer' => 'serialize', 94 | ], 95 | ], 96 | 'consumers' => [ 97 | [ 98 | 'name' => null, 99 | 'connection' => self::DEFAULT_CONNECTION_NAME, 100 | 'callbacks' => [], 101 | 'qos' => [ 102 | 'prefetch_size' => 0, 103 | 'prefetch_count' => 0, 104 | 'global' => false, 105 | ], 106 | 'idle_timeout' => 0, 107 | 'idle_timeout_exit_code' => null, 108 | 'proceed_on_exception' => false, 109 | 'deserializer' => 'unserialize', 110 | 'systemd' => [ 111 | 'memory_limit' => 0, 112 | 'workers' => 1 113 | ], 114 | ], 115 | ], 116 | 'logger' => [ 117 | 'log' => false, 118 | 'category' => 'application', 119 | 'print_console' => true, 120 | 'system_memory' => false, 121 | ], 122 | ]; 123 | 124 | public $auto_declare = null; 125 | public $connections = []; 126 | public $producers = []; 127 | public $consumers = []; 128 | public $queues = []; 129 | public $exchanges = []; 130 | public $bindings = []; 131 | public $logger = []; 132 | 133 | protected $isLoaded = false; 134 | 135 | /** 136 | * Get passed configuration 137 | * @return Configuration 138 | * @throws InvalidConfigException 139 | */ 140 | public function getConfig() : Configuration 141 | { 142 | if(!$this->isLoaded) { 143 | $this->normalizeConnections(); 144 | $this->validate(); 145 | $this->completeWithDefaults(); 146 | $this->isLoaded = true; 147 | } 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * Get connection service 154 | * @param string $connectionName 155 | * @return object|AbstractConnection 156 | * @throws NotInstantiableException 157 | * @throws \yii\base\InvalidConfigException 158 | */ 159 | public function getConnection(string $connectionName = '') : AbstractConnection 160 | { 161 | if ('' === $connectionName) { 162 | $connectionName = self::DEFAULT_CONNECTION_NAME; 163 | } 164 | 165 | return Yii::$container->get(sprintf(self::CONNECTION_SERVICE_NAME, $connectionName)); 166 | } 167 | 168 | /** 169 | * Get producer service 170 | * @param string $producerName 171 | * @return Producer|object 172 | * @throws \yii\base\InvalidConfigException 173 | * @throws NotInstantiableException 174 | */ 175 | public function getProducer(string $producerName) 176 | { 177 | return Yii::$container->get(sprintf(self::PRODUCER_SERVICE_NAME, $producerName)); 178 | } 179 | 180 | /** 181 | * Get consumer service 182 | * @param string $consumerName 183 | * @return Consumer|object 184 | * @throws NotInstantiableException 185 | * @throws \yii\base\InvalidConfigException 186 | */ 187 | public function getConsumer(string $consumerName) 188 | { 189 | return Yii::$container->get(sprintf(self::CONSUMER_SERVICE_NAME, $consumerName)); 190 | } 191 | 192 | /** 193 | * Get routing service 194 | * @param AbstractConnection $connection 195 | * @return Routing|object|string 196 | * @throws NotInstantiableException 197 | * @throws \yii\base\InvalidConfigException 198 | */ 199 | public function getRouting(AbstractConnection $connection) 200 | { 201 | return Yii::$container->get(Configuration::ROUTING_SERVICE_NAME, ['conn' => $connection]); 202 | } 203 | 204 | /** 205 | * Config validation 206 | * @throws InvalidConfigException 207 | */ 208 | protected function validate() 209 | { 210 | $this->validateTopLevel(); 211 | $this->validateMultidimensional(); 212 | $this->validateRequired(); 213 | $this->validateDuplicateNames(['connections', 'exchanges', 'queues', 'producers', 'consumers']); 214 | } 215 | 216 | /** 217 | * Validate multidimensional entries names 218 | * @throws InvalidConfigException 219 | */ 220 | protected function validateMultidimensional() 221 | { 222 | $multidimensional = [ 223 | 'connection' => $this->connections, 224 | 'exchange' => $this->exchanges, 225 | 'queue' => $this->queues, 226 | 'binding' => $this->bindings, 227 | 'producer' => $this->producers, 228 | 'consumer' => $this->consumers, 229 | ]; 230 | 231 | foreach ($multidimensional as $configName => $configItem) { 232 | if (!is_array($configItem)) { 233 | throw new InvalidConfigException("Every {$configName} entry should be of type array."); 234 | } 235 | foreach ($configItem as $key => $value) { 236 | if (!is_int($key)) { 237 | throw new InvalidConfigException("Invalid key: `{$key}`. There should be a list of {$configName}s in the array."); 238 | } 239 | } 240 | } 241 | } 242 | 243 | /** 244 | * Validate top level options 245 | * @throws InvalidConfigException 246 | */ 247 | protected function validateTopLevel() 248 | { 249 | if (($this->auto_declare !== null) && !is_bool($this->auto_declare)) { 250 | throw new InvalidConfigException("Option `auto_declare` should be of type boolean."); 251 | } 252 | 253 | if (!is_array($this->logger)) { 254 | throw new InvalidConfigException("Option `logger` should be of type array."); 255 | } 256 | 257 | $this->validateArrayFields($this->logger, self::DEFAULTS['logger']); 258 | } 259 | 260 | /** 261 | * Validate required options 262 | * @throws InvalidConfigException 263 | */ 264 | protected function validateRequired() 265 | { 266 | foreach ($this->connections as $connection) { 267 | $this->validateArrayFields($connection, self::DEFAULTS['connections'][0]); 268 | if (!isset($connection['url']) && !isset($connection['host'])) { 269 | throw new InvalidConfigException('Either `url` or `host` options required for configuring connection.'); 270 | } 271 | if (isset($connection['url']) && (isset($connection['host']) || isset($connection['port']))) { 272 | throw new InvalidConfigException('Connection options `url` and `host:port` should not be both specified, configuration is ambigious.'); 273 | } 274 | if (!isset($connection['name'])) { 275 | throw new InvalidConfigException('Connection name is required when multiple connections is specified.'); 276 | } 277 | if (isset($connection['type']) && !is_subclass_of($connection['type'], AbstractConnection::class)) { 278 | throw new InvalidConfigException('Connection type should be a subclass of PhpAmqpLib\Connection\AbstractConnection.'); 279 | } 280 | if (!empty($connection['ssl_context']) && empty($connection['type'])) { 281 | throw new InvalidConfigException('If you are using a ssl connection, the connection type must be AMQPSSLConnection::class'); 282 | } 283 | if (!empty($connection['ssl_context']) && $connection['type'] !== AMQPSSLConnection::class) { 284 | throw new InvalidConfigException('If you are using a ssl connection, the connection type must be AMQPSSLConnection::class'); 285 | } 286 | } 287 | 288 | foreach ($this->exchanges as $exchange) { 289 | $this->validateArrayFields($exchange, self::DEFAULTS['exchanges'][0]); 290 | if (!isset($exchange['name'])) { 291 | throw new InvalidConfigException('Exchange name should be specified.'); 292 | } 293 | if (!isset($exchange['type'])) { 294 | throw new InvalidConfigException('Exchange type should be specified.'); 295 | } 296 | $allowed = ['direct', 'topic', 'fanout', 'headers']; 297 | if (!in_array($exchange['type'], $allowed, true)) { 298 | $allowed = implode(', ', $allowed); 299 | throw new InvalidConfigException("Unknown exchange type `{$exchange['type']}`. Allowed values are: {$allowed}"); 300 | } 301 | } 302 | foreach ($this->queues as $queue) { 303 | $this->validateArrayFields($queue, self::DEFAULTS['queues'][0]); 304 | } 305 | foreach ($this->bindings as $binding) { 306 | $this->validateArrayFields($binding, self::DEFAULTS['bindings'][0]); 307 | if (!isset($binding['exchange'])) { 308 | throw new InvalidConfigException('Exchange name is required for binding.'); 309 | } 310 | if (!$this->isNameExist($this->exchanges, $binding['exchange'])) { 311 | throw new InvalidConfigException("`{$binding['exchange']}` defined in binding doesn't configured in exchanges."); 312 | } 313 | if (isset($binding['routing_keys']) && !is_array($binding['routing_keys'])) { 314 | throw new InvalidConfigException('Option `routing_keys` should be an array.'); 315 | } 316 | if ((!isset($binding['queue']) && !isset($binding['to_exchange'])) || isset($binding['queue'], $binding['to_exchange'])) { 317 | throw new InvalidConfigException('Either `queue` or `to_exchange` options should be specified to create binding.'); 318 | } 319 | if (isset($binding['queue']) && !$this->isNameExist($this->queues, $binding['queue'])) { 320 | throw new InvalidConfigException("`{$binding['queue']}` defined in binding doesn't configured in queues."); 321 | } 322 | } 323 | foreach ($this->producers as $producer) { 324 | $this->validateArrayFields($producer, self::DEFAULTS['producers'][0]); 325 | if (!isset($producer['name'])) { 326 | throw new InvalidConfigException('Producer name is required.'); 327 | } 328 | if (isset($producer['connection']) && !$this->isNameExist($this->connections, $producer['connection'])) { 329 | throw new InvalidConfigException("Connection `{$producer['connection']}` defined in producer doesn't configured in connections."); 330 | } 331 | if (isset($producer['safe']) && !is_bool($producer['safe'])) { 332 | throw new InvalidConfigException('Producer option safe should be of type boolean.'); 333 | } 334 | if (!isset($producer['connection']) && !$this->isNameExist($this->connections, self::DEFAULT_CONNECTION_NAME)) { 335 | throw new InvalidConfigException("Connection for producer `{$producer['name']}` is required."); 336 | } 337 | if (isset($producer['serializer']) && !is_callable($producer['serializer'])) { 338 | throw new InvalidConfigException('Producer `serializer` option should be a callable.'); 339 | } 340 | } 341 | foreach ($this->consumers as $consumer) { 342 | $this->validateArrayFields($consumer, self::DEFAULTS['consumers'][0]); 343 | if (!isset($consumer['name'])) { 344 | throw new InvalidConfigException('Consumer name is required.'); 345 | } 346 | if (isset($consumer['connection']) && !$this->isNameExist($this->connections, $consumer['connection'])) { 347 | throw new InvalidConfigException("Connection `{$consumer['connection']}` defined in consumer doesn't configured in connections."); 348 | } 349 | if (!isset($consumer['connection']) && !$this->isNameExist($this->connections, self::DEFAULT_CONNECTION_NAME)) { 350 | throw new InvalidConfigException("Connection for consumer `{$consumer['name']}` is required."); 351 | } 352 | if (!isset($consumer['callbacks']) || empty($consumer['callbacks'])) { 353 | throw new InvalidConfigException("No callbacks specified for consumer `{$consumer['name']}`."); 354 | } 355 | if (isset($consumer['qos']) && !is_array($consumer['qos'])) { 356 | throw new InvalidConfigException('Consumer option `qos` should be of type array.'); 357 | } 358 | if (isset($consumer['proceed_on_exception']) && !is_bool($consumer['proceed_on_exception'])) { 359 | throw new InvalidConfigException('Consumer option `proceed_on_exception` should be of type boolean.'); 360 | } 361 | foreach ($consumer['callbacks'] as $queue => $callback) { 362 | if (!$this->isNameExist($this->queues, $queue)) { 363 | throw new InvalidConfigException("Queue `{$queue}` from {$consumer['name']} is not defined in queues."); 364 | } 365 | if (!is_string($callback)) { 366 | throw new InvalidConfigException('Consumer `callback` parameter value should be a class name or service name in DI container.'); 367 | } 368 | } 369 | if (isset($consumer['deserializer']) && !is_callable($consumer['deserializer'])) { 370 | throw new InvalidConfigException('Consumer `deserializer` option should be a callable.'); 371 | } 372 | } 373 | } 374 | 375 | /** 376 | * Validate config entry value 377 | * @param array $passed 378 | * @param array $required 379 | * @throws InvalidConfigException 380 | */ 381 | protected function validateArrayFields(array $passed, array $required) 382 | { 383 | $undeclaredFields = array_diff_key($passed, $required); 384 | if (!empty($undeclaredFields)) { 385 | $asString = json_encode($undeclaredFields); 386 | throw new InvalidConfigException("Unknown options: {$asString}"); 387 | } 388 | } 389 | 390 | /** 391 | * Check entrees for duplicate names 392 | * @param array $keys 393 | * @throws InvalidConfigException 394 | */ 395 | protected function validateDuplicateNames(array $keys) 396 | { 397 | foreach ($keys as $key) { 398 | $names = []; 399 | foreach ($this->$key as $item) { 400 | if (!isset($item['name'])) { 401 | $item['name'] = ''; 402 | } 403 | if (isset($names[$item['name']])) { 404 | throw new InvalidConfigException("Duplicate name `{$item['name']}` in {$key}"); 405 | } 406 | $names[$item['name']] = true; 407 | } 408 | } 409 | } 410 | 411 | /** 412 | * Allow certain flexibility on connection configuration 413 | * @throws InvalidConfigException 414 | */ 415 | protected function normalizeConnections() 416 | { 417 | if (empty($this->connections)) { 418 | throw new InvalidConfigException('Option `connections` should have at least one entry.'); 419 | } 420 | if (ArrayHelper::isAssociative($this->connections)) { 421 | $this->connections[0] = $this->connections; 422 | } 423 | if (count($this->connections) === 1) { 424 | if (!isset($this->connections[0]['name'])) { 425 | $this->connections[0]['name'] = self::DEFAULT_CONNECTION_NAME; 426 | } 427 | } 428 | } 429 | 430 | /** 431 | * Merge passed config with extension defaults 432 | */ 433 | protected function completeWithDefaults() 434 | { 435 | $defaults = self::DEFAULTS; 436 | if (null === $this->auto_declare) { 437 | $this->auto_declare = $defaults['auto_declare']; 438 | } 439 | if (empty($this->logger)) { 440 | $this->logger = $defaults['logger']; 441 | } else { 442 | foreach ($defaults['logger'] as $key => $option) { 443 | if (!isset($this->logger[$key])) { 444 | $this->logger[$key] = $option; 445 | } 446 | } 447 | } 448 | $multi = ['connections', 'bindings', 'exchanges', 'queues', 'producers', 'consumers']; 449 | foreach ($multi as $key) { 450 | foreach ($this->$key as &$item) { 451 | $item = array_replace_recursive($defaults[$key][0], $item); 452 | } 453 | } 454 | } 455 | 456 | /** 457 | * Check if an entry with specific name exists in array 458 | * @param array $multidimentional 459 | * @param string $name 460 | * @return bool 461 | */ 462 | private function isNameExist(array $multidimentional, string $name) 463 | { 464 | if($name == '') { 465 | foreach ($multidimentional as $item) { 466 | if (!isset($item['name'])) { 467 | return true; 468 | } 469 | } 470 | return false; 471 | } 472 | $key = array_search($name, array_column($multidimentional, 'name'), true); 473 | if (is_int($key)) { 474 | return true; 475 | } 476 | 477 | return false; 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /DependencyInjection.php: -------------------------------------------------------------------------------- 1 | rabbitmq->getConfig(); 30 | $this->registerLogger($config); 31 | $this->registerConnections($config); 32 | $this->registerRouting($config); 33 | $this->registerProducers($config); 34 | $this->registerConsumers($config); 35 | $this->addControllers($app); 36 | } 37 | 38 | /** 39 | * Register logger service 40 | * @param $config 41 | */ 42 | private function registerLogger($config) 43 | { 44 | \Yii::$container->setSingleton(Configuration::LOGGER_SERVICE_NAME, ['class' => Logger::class, 'options' => $config->logger]); 45 | } 46 | 47 | /** 48 | * Register connections in service container 49 | * @param Configuration $config 50 | */ 51 | protected function registerConnections(Configuration $config) 52 | { 53 | foreach ($config->connections as $options) { 54 | $serviceAlias = sprintf(Configuration::CONNECTION_SERVICE_NAME, $options['name']); 55 | \Yii::$container->setSingleton($serviceAlias, function () use ($options) { 56 | $factory = new AbstractConnectionFactory($options['type'], $options); 57 | return $factory->createConnection(); 58 | }); 59 | } 60 | } 61 | 62 | /** 63 | * Register routing in service container 64 | * @param Configuration $config 65 | */ 66 | protected function registerRouting(Configuration $config) 67 | { 68 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, function ($container, $params) use ($config) { 69 | $routing = new Routing($params['conn']); 70 | \Yii::$container->invoke([$routing, 'setQueues'], [$config->queues]); 71 | \Yii::$container->invoke([$routing, 'setExchanges'], [$config->exchanges]); 72 | \Yii::$container->invoke([$routing, 'setBindings'], [$config->bindings]); 73 | 74 | return $routing; 75 | }); 76 | } 77 | 78 | /** 79 | * Register producers in service container 80 | * @param Configuration $config 81 | */ 82 | protected function registerProducers(Configuration $config) 83 | { 84 | $autoDeclare = $config->auto_declare; 85 | foreach ($config->producers as $options) { 86 | $serviceAlias = sprintf(Configuration::PRODUCER_SERVICE_NAME, $options['name']); 87 | \Yii::$container->setSingleton($serviceAlias, function () use ($options, $autoDeclare) { 88 | /** 89 | * @var $connection AbstractConnection 90 | */ 91 | $connection = \Yii::$container->get(sprintf(Configuration::CONNECTION_SERVICE_NAME, $options['connection'])); 92 | /** 93 | * @var $routing Routing 94 | */ 95 | $routing = \Yii::$container->get(Configuration::ROUTING_SERVICE_NAME, ['conn' => $connection]); 96 | /** 97 | * @var $logger Logger 98 | */ 99 | $logger = \Yii::$container->get(Configuration::LOGGER_SERVICE_NAME); 100 | $producer = new Producer($connection, $routing, $logger, $autoDeclare); 101 | \Yii::$container->invoke([$producer, 'setName'], [$options['name']]); 102 | \Yii::$container->invoke([$producer, 'setContentType'], [$options['content_type']]); 103 | \Yii::$container->invoke([$producer, 'setDeliveryMode'], [$options['delivery_mode']]); 104 | \Yii::$container->invoke([$producer, 'setSafe'], [$options['safe']]); 105 | \Yii::$container->invoke([$producer, 'setSerializer'], [$options['serializer']]); 106 | 107 | return $producer; 108 | }); 109 | } 110 | } 111 | 112 | /** 113 | * Register consumers(one instance per one or multiple queues) in service container 114 | * @param Configuration $config 115 | */ 116 | protected function registerConsumers(Configuration $config) 117 | { 118 | $autoDeclare = $config->auto_declare; 119 | foreach ($config->consumers as $options) { 120 | $serviceAlias = sprintf(Configuration::CONSUMER_SERVICE_NAME, $options['name']); 121 | \Yii::$container->setSingleton($serviceAlias, function () use ($options, $autoDeclare) { 122 | /** 123 | * @var $connection AbstractConnection 124 | */ 125 | $connection = \Yii::$container->get(sprintf(Configuration::CONNECTION_SERVICE_NAME, $options['connection'])); 126 | /** 127 | * @var $routing Routing 128 | */ 129 | $routing = \Yii::$container->get(Configuration::ROUTING_SERVICE_NAME, ['conn' => $connection]); 130 | /** 131 | * @var $logger Logger 132 | */ 133 | $logger = \Yii::$container->get(Configuration::LOGGER_SERVICE_NAME); 134 | $consumer = new Consumer($connection, $routing, $logger, $autoDeclare); 135 | $queues = []; 136 | foreach ($options['callbacks'] as $queueName => $callback) { 137 | $callbackClass = $this->getCallbackClass($callback); 138 | $queues[$queueName] = [$callbackClass, 'execute']; 139 | } 140 | \Yii::$container->invoke([$consumer, 'setName'], [$options['name']]); 141 | \Yii::$container->invoke([$consumer, 'setQueues'], [$queues]); 142 | \Yii::$container->invoke([$consumer, 'setQos'], [$options['qos']]); 143 | \Yii::$container->invoke([$consumer, 'setIdleTimeout'], [$options['idle_timeout']]); 144 | \Yii::$container->invoke([$consumer, 'setIdleTimeoutExitCode'], [$options['idle_timeout_exit_code']]); 145 | \Yii::$container->invoke([$consumer, 'setProceedOnException'], [$options['proceed_on_exception']]); 146 | \Yii::$container->invoke([$consumer, 'setDeserializer'], [$options['deserializer']]); 147 | 148 | return $consumer; 149 | }); 150 | } 151 | } 152 | 153 | /** 154 | * Callback can be passed as class name or alias in service container 155 | * @param string $callbackName 156 | * @return ConsumerInterface 157 | * @throws InvalidConfigException 158 | */ 159 | private function getCallbackClass(string $callbackName) : ConsumerInterface 160 | { 161 | if (!class_exists($callbackName)) { 162 | $callbackClass = \Yii::$container->get($callbackName); 163 | } else { 164 | $callbackClass = new $callbackName(); 165 | } 166 | if (!($callbackClass instanceof ConsumerInterface)) { 167 | throw new InvalidConfigException("{$callbackName} should implement ConsumerInterface."); 168 | } 169 | 170 | return $callbackClass; 171 | } 172 | 173 | /** 174 | * Auto-configure console controller classes 175 | * @param Application $app 176 | */ 177 | private function addControllers(Application $app) 178 | { 179 | if($app instanceof \yii\console\Application) { 180 | $app->controllerMap[Configuration::EXTENSION_CONTROLLER_ALIAS] = RabbitMQController::class; 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mikhail Bakulin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | RabbitMQ Extension for Yii2 2 | ================== 3 | Wrapper based on php-amqplib library to incorporate messaging in your Yii2 application via RabbitMQ. Inspired by RabbitMqBundle for Symfony framework. 4 | 5 | This documentation is relevant for the version 2.\*, which require PHP version >=7.0. For legacy PHP applications >=5.4 please use [previous version of this extension](https://github.com/mikemadisonweb/yii2-rabbitmq/blob/master/README_v1.md). 6 | 7 | [![Latest Stable Version](https://poser.pugx.org/mikemadisonweb/yii2-rabbitmq/v/stable)](https://packagist.org/packages/mikemadisonweb/yii2-rabbitmq) 8 | [![License](https://poser.pugx.org/mikemadisonweb/yii2-rabbitmq/license)](https://packagist.org/packages/mikemadisonweb/yii2-rabbitmq) 9 | [![Build Status](https://travis-ci.org/mikemadisonweb/yii2-rabbitmq.svg?branch=master)](https://travis-ci.org/mikemadisonweb/yii2-rabbitmq) 10 | [![Coverage Status](https://coveralls.io/repos/github/mikemadisonweb/yii2-rabbitmq/badge.svg?branch=master)](https://coveralls.io/github/mikemadisonweb/yii2-rabbitmq?branch=master) 11 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmikemadisonweb%2Fyii2-rabbitmq.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmikemadisonweb%2Fyii2-rabbitmq?ref=badge_shield) 12 | 13 | Installation 14 | ------------ 15 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 16 | 17 | Either run 18 | ``` 19 | php composer.phar require mikemadisonweb/yii2-rabbitmq 20 | ``` 21 | or add 22 | ```json 23 | "mikemadisonweb/yii2-rabbitmq": "^2.2.0" 24 | ``` 25 | to the require section of your `composer.json` file. 26 | 27 | Configuration 28 | ------------- 29 | This extension facilitates the creation of RabbitMQ [producers and consumers](https://www.rabbitmq.com/tutorials/tutorial-three-php.html) to meet your specific needs. This is an example basic config: 30 | ```php 31 | [ 35 | // ... 36 | 'rabbitmq' => [ 37 | 'class' => \mikemadisonweb\rabbitmq\Configuration::class, 38 | 'connections' => [ 39 | [ 40 | // You can pass these parameters as a single `url` option: https://www.rabbitmq.com/uri-spec.html 41 | 'host' => 'YOUR_HOSTNAME', 42 | 'port' => '5672', 43 | 'user' => 'YOUR_USERNAME', 44 | 'password' => 'YOUR_PASSWORD', 45 | 'vhost' => '/', 46 | ] 47 | // When multiple connections is used you need to specify a `name` option for each one and define them in producer and consumer configuration blocks 48 | ], 49 | 'exchanges' => [ 50 | [ 51 | 'name' => 'YOUR_EXCHANGE_NAME', 52 | 'type' => 'direct' 53 | // Refer to Defaults section for all possible options 54 | ], 55 | ], 56 | 'queues' => [ 57 | [ 58 | 'name' => 'YOUR_QUEUE_NAME', 59 | // Queue can be configured here the way you want it: 60 | //'durable' => true, 61 | //'auto_delete' => false, 62 | ], 63 | [ 64 | 'name' => 'YOUR_ANOTHER_QUEUE_NAME', 65 | ], 66 | ], 67 | 'bindings' => [ 68 | [ 69 | 'queue' => 'YOUR_QUEUE_NAME', 70 | 'exchange' => 'YOUR_EXCHANGE_NAME', 71 | 'routing_keys' => ['YOUR_ROUTING_KEY'], 72 | ], 73 | ], 74 | 'producers' => [ 75 | [ 76 | 'name' => 'YOUR_PRODUCER_NAME', 77 | ], 78 | ], 79 | 'consumers' => [ 80 | [ 81 | 'name' => 'YOUR_CONSUMER_NAME', 82 | // Every consumer should define one or more callbacks for corresponding queues 83 | 'callbacks' => [ 84 | // queue name => callback class name 85 | 'YOUR_QUEUE_NAME' => \path\to\YourConsumer::class, 86 | ], 87 | ], 88 | ], 89 | ], 90 | ], 91 | ]; 92 | ``` 93 | To use this extension you should be familiar with the basic concepts of RabbitMQ. If you are not confident in your knowledge I suggest reading [this article](https://mikemadisonweb.github.io/2017/05/04/tldr-series-rabbitmq/). 94 | 95 | The 'callback' parameter can be a class name or a service name from [dependency injection container](http://www.yiiframework.com/doc-2.0/yii-di-container.html). Starting from Yii version 2.0.11 you can configure your container like this: 96 | ```php 97 | [ 103 | 'definitions' => [], 104 | 'singletons' => [ 105 | 'rabbitmq.import-data.consumer' => [ 106 | [ 107 | 'class' => \path\to\YourConsumer::class, 108 | ], 109 | [ 110 | // If dependency is needed 111 | 'some-dependency' => Instance::of('dependency-service-name'), 112 | ], 113 | ], 114 | ], 115 | ], 116 | ]; 117 | ``` 118 | If you need several consumers you can list respective entries in the configuration, but that would require a separate worker(daemon process) for each of that consumers. While it can be absolutely fine in some cases if you are dealing with small queues which consuming messages really fast you may want to group them into one worker. So just list your callbacks in consumer config and one worker will perform your business logic on multiple queues. 119 | 120 | Be sure that all queues and exchanges are defined in corresponding bindings, it's up to you to set up correct message routing. 121 | #### Lifecycle events 122 | There are also some lifecycle events implemented: before_consume, after_consume, before_publish, after_publish. You can use them for any additional work you need to do before or after message been consumed/published. For example, make sure that Yii knows the database connection has been closed by a timeout as a consumer is a long-running process: 123 | ```php 124 | [ 129 | // ... 130 | 'rabbitmq' => [ 131 | // ... 132 | 'on before_consume' => function ($event) { 133 | if (\Yii::$app->has('db') && \Yii::$app->db->isActive) { 134 | try { 135 | \Yii::$app->db->createCommand('SELECT 1')->query(); 136 | } catch (\yii\db\Exception $exception) { 137 | \Yii::$app->db->close(); 138 | } 139 | } 140 | }, 141 | ], 142 | // ... 143 | ], 144 | ]; 145 | ``` 146 | #### Logger 147 | Last but not least is logger configuration which is also optional: 148 | ```php 149 | [ 154 | // ... 155 | 'rabbitmq' => [ 156 | // ... 157 | 'logger' => [ 158 | 'log' => true, 159 | 'category' => 'application', 160 | 'print_console' => false, 161 | 'system_memory' => false, 162 | ], 163 | ], 164 | // ... 165 | ], 166 | ]; 167 | ``` 168 | Logger disabled by default. When enabled it will log messages into main application log or to your own log target if you specify corresponding category name. Option 'print_console' gives you additional information while debugging a consumer in you console. 169 | 170 | #### Example 171 | Simple setup of Yii2 basic template with the RabbitMQ extension is available [here](https://bitbucket.org/MikeMadison/yii2-rabbitmq-test). Feel free to experiment with it and debug your existing configuration in an isolated manner. 172 | 173 | Console commands 174 | ------------- 175 | Extension provides several console commands: 176 | - **rabbitmq/consume** - Run a consumer 177 | - **rabbitmq/declare-all** - Create RabbitMQ exchanges, queues and bindings based on configuration 178 | - **rabbitmq/declare-exchange** - Create the exchange listed in configuration 179 | - **rabbitmq/declare-queue** - Create the queue listed in configuration 180 | - **rabbitmq/delete-all** - Delete all RabbitMQ exchanges and queues that is defined in configuration 181 | - **rabbitmq/delete-exchange** - Delete the exchange 182 | - **rabbitmq/delete-queue** - Delete the queue 183 | - **rabbitmq/publish** - Publish a message from STDIN to the queue 184 | - **rabbitmq/purge-queue** - Delete all messages from the queue 185 | 186 | To start a consumer: 187 | ``` 188 | yii rabbitmq/consume YOUR_CONSUMER_NAME 189 | ``` 190 | In this case, you can use process control system, like Supervisor, to restart consumer process and this way keep your worker run continuously. 191 | #### Message limit 192 | As PHP daemon especially based upon a framework may be prone to memory leaks, it may be reasonable to limit the number of messages to consume and stop: 193 | ``` 194 | --memoryLimit, -l: (defaults to 0) 195 | --messagesLimit, -m: (defaults to 0) 196 | ``` 197 | #### Auto-declare 198 | By default extension configured in auto-declare mode, which means that on every message published exchanges, queues and bindings will be checked and created if missing. If performance means much to your application you should disable that feature in configuration and use console commands to declare and delete routing schema by yourself. 199 | 200 | Usage 201 | ------------- 202 | As the consumer worker will read messages from the queue, execute a callback method and pass a message to it. 203 | #### Consume 204 | In order a class to become a callback it should implement ConsumerInterface: 205 | ```php 206 | body; 222 | // Apply your business logic here 223 | 224 | return ConsumerInterface::MSG_ACK; 225 | } 226 | } 227 | ``` 228 | You can publish any data type(object, int, array etc), despite the fact that RabbitMQ will transfer payload as a string here in consumer $msg->body your data will be of the same type it was sent. 229 | #### Return codes 230 | As for the return codes there is a bunch of them in order for you to control following processing of the message by the broker: 231 | - **ConsumerInterface::MSG_ACK** - Acknowledge message (mark as processed) and drop it from the queue 232 | - **ConsumerInterface::MSG_REJECT** - Reject and drop message from the queue 233 | - **ConsumerInterface::MSG_REJECT_REQUEUE** - Reject and requeue message in RabbitMQ 234 | #### Publish 235 | Here is an example how you can publish a message: 236 | ```php 237 | $producer = \Yii::$app->rabbitmq->getProducer('YOUR_PRODUCER_NAME'); 238 | $msg = serialize(['dataset_id' => 657, 'linked_datasets' => []]); 239 | $producer->publish($msg, 'YOUR_EXCHANGE_NAME', 'YOUR_ROUTING_KEY'); 240 | ``` 241 | Routing key as third parameter is optional, which can be the case for fanout exchanges. 242 | 243 | By default connection to broker only get established upon publishing a message, it would not try to connect on each HTTP request if there is no need to. 244 | 245 | Options 246 | ------------- 247 | All configuration options: 248 | ```php 249 | $rabbitmq_defaults = [ 250 | 'auto_declare' => true, 251 | 'connections' => [ 252 | [ 253 | 'name' => self::DEFAULT_CONNECTION_NAME, 254 | 'type' => AMQPLazyConnection::class, 255 | 'url' => null, 256 | 'host' => null, 257 | 'port' => 5672, 258 | 'user' => 'guest', 259 | 'password' => 'guest', 260 | 'vhost' => '/', 261 | 'connection_timeout' => 3, 262 | 'read_write_timeout' => 3, 263 | 'ssl_context' => null, 264 | 'keepalive' => false, 265 | 'heartbeat' => 0, 266 | 'channel_rpc_timeout' => 0.0 267 | ], 268 | ], 269 | 'exchanges' => [ 270 | [ 271 | 'name' => null, 272 | 'type' => null, 273 | 'passive' => false, 274 | 'durable' => true, 275 | 'auto_delete' => false, 276 | 'internal' => false, 277 | 'nowait' => false, 278 | 'arguments' => null, 279 | 'ticket' => null, 280 | ], 281 | ], 282 | 'queues' => [ 283 | [ 284 | 'name' => '', 285 | 'passive' => false, 286 | 'durable' => true, 287 | 'exclusive' => false, 288 | 'auto_delete' => false, 289 | 'nowait' => false, 290 | 'arguments' => null, 291 | 'ticket' => null, 292 | ], 293 | ], 294 | 'bindings' => [ 295 | [ 296 | 'exchange' => null, 297 | 'queue' => null, 298 | 'to_exchange' => null, 299 | 'routing_keys' => [], 300 | ], 301 | ], 302 | 'producers' => [ 303 | [ 304 | 'name' => null, 305 | 'connection' => self::DEFAULT_CONNECTION_NAME, 306 | 'safe' => true, 307 | 'content_type' => 'text/plain', 308 | 'delivery_mode' => 2, 309 | 'serializer' => 'serialize', 310 | ], 311 | ], 312 | 'consumers' => [ 313 | [ 314 | 'name' => null, 315 | 'connection' => self::DEFAULT_CONNECTION_NAME, 316 | 'callbacks' => [], 317 | 'qos' => [ 318 | 'prefetch_size' => 0, 319 | 'prefetch_count' => 0, 320 | 'global' => false, 321 | ], 322 | 'idle_timeout' => 0, 323 | 'idle_timeout_exit_code' => null, 324 | 'proceed_on_exception' => false, 325 | 'deserializer' => 'unserialize', 326 | ], 327 | ], 328 | 'logger' => [ 329 | 'log' => false, 330 | 'category' => 'application', 331 | 'print_console' => true, 332 | 'system_memory' => false, 333 | ], 334 | ]; 335 | ``` 336 | ##### Exchange 337 | For example, to declare an exchange you should provide name and type for it. 338 | 339 | parameter | required | type | default | comments 340 | --- | --- | --- | --- | --- 341 | name | yes | string | | The exchange name consists of a non-empty sequence of these characters: letters, digits, hyphen, underscore, period, or colon. 342 | type | yes | string | | Type of the exchange, possible values are `direct`, `fanout`, `topic` and `headers`. 343 | passive | no | boolean | false | If set to true, the server will reply with Declare-Ok if the exchange already exists with the same name, and raise an error if not. The client can use this to check whether an exchange exists without modifying the server state. When set, all other method fields except name and no-wait are ignored. A declare with both passive and no-wait has no effect. 344 | durable | no | boolean | false | Durable exchanges remain active when a server restarts. Non-durable exchanges (transient exchanges) are purged if/when a server restarts. 345 | auto_delete | no | boolean | true | If set to true, the exchange would be deleted when no queues are bound to it anymore. 346 | internal | no | boolean | false | Internal exchange may not be used directly by publishers, but only when bound to other exchanges. 347 | nowait | no | boolean | false | Client may send next request immediately after sending the first one, no waiting for the reply is required 348 | arguments | no | array | null | A set of arguments for the declaration. 349 | ticket | no | integer | null | Access ticket 350 | 351 | Good use-case of the `arguments` parameter usage can be a creation of a [dead-letter-exchange](https://github.com/php-amqplib/php-amqplib/blob/master/demo/queue_arguments.php#L17). 352 | ##### Queue 353 | As for the queue declaration, all parameters are optional. Even if you do not provide a name for your queue server will generate a unique name for you: 354 | 355 | parameter | required | type | default | comments 356 | --- | --- | --- | --- | --- 357 | name | no | string | '' | The queue name can be empty, or a sequence of these characters: letters, digits, hyphen, underscore, period, or colon. 358 | passive | no | boolean | false | If set to true, the server will reply with Declare-Ok if the queue already exists with the same name, and raise an error if not. 359 | durable | no | boolean | false | Durable queues remain active when a server restarts. Non-durable queues (transient queues) are purged if/when a server restarts. 360 | auto_delete | no | boolean | true | If set to true, the queue is deleted when all consumers have finished using it. 361 | exclusive | no | boolean | false | Exclusive queues may only be accessed by the current connection, and are deleted when that connection closes. Passive declaration of an exclusive queue by other connections are not allowed. 362 | nowait | no | boolean | false | Client may send next request immediately after sending the first one, no waiting for the reply is required 363 | arguments | false | array | null | A set of arguments for the declaration. 364 | ticket | no | integer | null | Access ticket 365 | 366 | A complete explanation about options, their defaults, and valuable details can be found in [AMQP 0-9-1 Reference Guide](http://www.rabbitmq.com/amqp-0-9-1-reference.html). 367 | 368 | Beware that not all these options are allowed to be changed 'on-the-fly', in other words after queue or exchange had already been created. Otherwise, you will receive an error. 369 | 370 | Breaking Changes 371 | ------------- 372 | Since version 1.\* this extension was completely rewritten internally and can be considered brand new. However, the following key differences can be distinguished: 373 | - PHP version 7.0 and above required 374 | - Configuration format changed 375 | - All extension components get automatically loaded using [Yii2 Bootstraping](http://www.yiiframework.com/doc-2.0/guide-structure-extensions.html#bootstrapping-classes) 376 | - Different connection types supported 377 | - All extension components are registered in DIC as singletons 378 | - Routing component added to control schema in broker 379 | - Queue and exchange default options changed 380 | - Console commands are joined into one controller class which is added automatically and doesn't need to be configured 381 | - New console commands added to manipulate with routing schema 382 | - All data types are supported for message payload 383 | - Consumer handles control signals in a predictable manner 384 | 385 | 386 | ## License 387 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmikemadisonweb%2Fyii2-rabbitmq.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmikemadisonweb%2Fyii2-rabbitmq?ref=badge_large) 388 | -------------------------------------------------------------------------------- /README_rus.md: -------------------------------------------------------------------------------- 1 | RabbitMQ Extension for Yii2 2 | ================== 3 | 4 | **Advanced usage** 5 | 6 | Для предотвращения потери сообщений при обмене с RabbitMq рекомендуется использовать расширенные настройки для настройки продюсеров и слушателей (воркеров). 7 | 8 | **Пример конфига:** 9 | 10 | ``` 11 | Configuration::class, 20 | 'connections' => [ 21 | [ 22 | 'type' => $_ENV['RABBITMQ_SSL'] ? AMQPSSLConnection::class : AMQPLazyConnection::class, 23 | 'host' => $_ENV['RABBITMQ_HOST'], 24 | 'port' => $_ENV['RABBITMQ_PORT'], 25 | 'user' => $_ENV['RABBITMQ_USER'], 26 | 'password' => $_ENV['RABBITMQ_PASSWD'], 27 | 'vhost' => $_ENV['RABBITMQ_VHOST'], 28 | 'ssl_context' => $_ENV['RABBITMQ_SSL'] ? [ 29 | 'capath' => null, 30 | 'cafile' => null, 31 | 'verify_peer' => false, 32 | ] : null 33 | ], 34 | ], 35 | 'exchanges' => [ 36 | [ 37 | 'name' => 'test_exchange', 38 | 'type' => 'direct' 39 | ], 40 | ], 41 | 'queues' => [ 42 | [ 43 | 'name' => 'test_queue', 44 | ], 45 | ], 46 | 'producers' => [ 47 | [ 48 | 'name' => 'test_producer', 49 | ], 50 | ], 51 | 'bindings' => [ 52 | [ 53 | 'queue' => 'test_queue', 54 | 'exchange' => 'test_exchange', 55 | ], 56 | ], 57 | 'consumers' => [ 58 | [ 59 | 'name' => 'test_consumer', 60 | 'callbacks' => [ 61 | 'test_queue' => TestConsumer::class 62 | ], 63 | 'systemd' => [ 64 | 'memory_limit' => 8, // mb 65 | 'workers' => 3 66 | ], 67 | ], 68 | ], 69 | ]; 70 | ``` 71 | 72 | -------------------- 73 | 74 | **Настройка продюсеров** сводится к тому, что неотправленные сообщения сохраняются в таблице `rabbit_publish_error`, класс `\mikemadisonweb\rabbitmq\models\RabbitPublishError`, и отправляются, например, по крону. 75 | 76 | * в файле конфига консольного приложения в секции controllerMap прописываем namespace для миграций компонента 77 | 78 | ``` 79 | ... 80 | 'controllerMap' => [ 81 | 'migrate' => [ 82 | 'class' => 'yii\console\controllers\MigrateController', 83 | 'migrationNamespaces' => [ 84 | 'mikemadisonweb\rabbitmq\migrations' 85 | ], 86 | ], 87 | ], 88 | ... 89 | ``` 90 | 91 | Выполняем `php yii migrate` 92 | 93 | * при вызове продюсера отлавливаем исключения, и пишем сообщения в БД, пример: 94 | 95 | ``` 96 | public function actionPublish() 97 | { 98 | $producer = \Yii::$app->rabbitmq->getProducer('test_producer'); 99 | $data = [ 100 | 'counter' => 1, 101 | 'msg' => 'I\'am test publish' 102 | ]; 103 | while (true) { 104 | sleep(1); 105 | try { 106 | $producer->publish(json_encode($data), 'test_exchange'); 107 | $data['counter']++; 108 | } catch (\Exception $e) { 109 | $model_error = new RabbitPublishError(); 110 | $model_error->exchangeName = 'test_exchange'; 111 | $model_error->producerName = 'test_producer'; 112 | $model_error->msgBody = json_encode($data); 113 | $model_error->errorMsg = $e->getMessage(); 114 | $model_error->saveItem(); 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | * пример повторной отправки сохраненных сообщений 121 | 122 | ``` 123 | public function actionRePublish() 124 | { 125 | $republish = new RabbitPublishError(); 126 | $republish->rePublish(); 127 | } 128 | ``` 129 | Если повторное сообщение отправлено успешно, то запись удаляется, иначе поле counter увеличивается на 1. 130 | 131 | -------------- 132 | 133 | **Для расширенной настройки воркеров** необходимо запустить их в виде демонов с помощью systemd. 134 | Благодаря systemd мы можем решить две главные проблемы: 135 | 136 | 1. Перезапуск воркеров при разрыве соединения 137 | 138 | 2. Перезапуск воркеров при достижении memory limit 139 | 140 | Также с помощью systemd мы можем запускать несколько экземпляров воркеров для одной очереди 141 | 142 | * В конфиге rabbitmq, в секции `consumers` прописываем дополнительные настройки для systemd: для очереди `test_queue` запустить три воркера `test_consumer`, лимит памяти для каждого - 8 мб. 143 | 144 | ``` 145 | 'consumers' => [ 146 | [ 147 | 'name' => 'test_consumer', 148 | 'callbacks' => [ 149 | 'test_queue' => TestConsumer::class 150 | ], 151 | 'systemd' => [ 152 | 'memory_limit' => 8, // mb 153 | 'workers' => 3 154 | ], 155 | ], 156 | ], 157 | ``` 158 | 159 | * Для автоматической генерации юнитов systemd рекомендуется использовать хелпер `\mikemadisonweb\rabbitmq\helpers\CreateUnitHelper` 160 | 161 | При объявлении хелпера необходимо определить следующие поля: 162 | 163 | ``` 164 | /** @var string папка в которой будут созданы юниты, должна быть доступна на запись */ 165 | public $units_dir; 166 | 167 | /** @var string имя пользователя от имени которого будут запускаться юниты */ 168 | public $user; 169 | 170 | /** @var string имя группы для запуска юнитов */ 171 | public $group; 172 | 173 | /** @var string директория с исполняемым файлом yii */ 174 | public $work_dir; 175 | ``` 176 | Также в хелпере есть поле `example`, в нем хранится шаблон для генерации юнита. Рекомендуется его изучить и, при необходимости, переобъявить. Особое внимание секции `[Unit]` 177 | 178 | ``` 179 | public $example = '[Unit] 180 | Description=%description% 181 | After=syslog.target 182 | After=network.target 183 | After=postgresql.service 184 | Requires=postgresql.service 185 | 186 | [Service] 187 | Type=simple 188 | WorkingDirectory=%work_dir% 189 | 190 | User=%user% 191 | Group=%group% 192 | 193 | ExecStart=php %yii_path% rabbitmq/consume %name_consumer% %memory_limit% 194 | ExecReload=php %yii_path% rabbitmq/restart-consume %name_consumer% %memory_limit% 195 | TimeoutSec=3 196 | Restart=always 197 | 198 | [Install] 199 | WantedBy=multi-user.target'; 200 | ``` 201 | Пример работы с хелпером, контроллер 202 | 203 | ``` 204 | Yii::getAlias('@runtime/units'), 219 | 'work_dir' => Yii::getAlias('@app'), 220 | 'user' => 'vagrant', 221 | 'group' => 'vagrant', 222 | ] 223 | ); 224 | 225 | $helper->create(); 226 | } 227 | } 228 | ``` 229 | 230 | Не забываем запустить генерацию юнитов: `php yii create-units` 231 | 232 | * После генерации юнитов в папке c юнитами будет сгенерирован также баш скрипт exec.sh. При запуске, на вход могут быть переданы следующие команды: `copy | start | restart | status | delete`. Данный скрипт работает по маске со всеми сгенерированными юнитами. 233 | 234 | После первоначальной генерации юнитов, достаточно запустить команду `sh exec.sh copy` 235 | 236 | **Итак, для расширенной работы с воркерами** необходимо выполнить три шага 237 | 238 | 1. Объявить параметры для systemd в конфиге RabbitMq 239 | 240 | 2. Сгенерировать юниты для systemd 241 | 242 | 3. Запустить воркеры как демоны под управлением systemd 243 | 244 | **Enjoy!** 245 | 246 | -------------------------------------------------------------------------------- /README_v1.md: -------------------------------------------------------------------------------- 1 | RabbitMQ Extension for Yii2 2 | ================== 3 | Wrapper based on php-amqplib to incorporate messaging in your Yii2 application via RabbitMQ. Inspired by RabbitMqBundle for Symfony framework which is awesome. 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/mikemadisonweb/yii2-rabbitmq/v/stable)](https://packagist.org/packages/mikemadisonweb/yii2-rabbitmq) 6 | [![License](https://poser.pugx.org/mikemadisonweb/yii2-rabbitmq/license)](https://packagist.org/packages/mikemadisonweb/yii2-rabbitmq) 7 | 8 | Installation 9 | ------------ 10 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 11 | 12 | Either run 13 | ``` 14 | php composer.phar require mikemadisonweb/yii2-rabbitmq 15 | ``` 16 | or add 17 | ```json 18 | "mikemadisonweb/yii2-rabbitmq": "^1.7.0" 19 | ``` 20 | to the require section of your `composer.json` file. 21 | 22 | Configuration 23 | ------------- 24 | This extension facilitates creation of RabbitMQ [producers and consumers](https://www.rabbitmq.com/tutorials/tutorial-three-php.html) to meet your specific needs. This is an example basic config: 25 | ```php 26 | [ 30 | // ... 31 | 'rabbitmq' => [ 32 | 'class' => 'mikemadisonweb\rabbitmq\Configuration', 33 | 'connections' => [ 34 | 'default' => [ 35 | 'host' => '127.0.0.1', 36 | 'port' => '5672', 37 | 'user' => 'your_username', 38 | 'password' => 'your_password', 39 | 'vhost' => '/', 40 | 'heartbeat' => 0, 41 | ], 42 | ], 43 | 'producers' => [ 44 | 'import_data' => [ 45 | 'connection' => 'default', 46 | 'exchange_options' => [ 47 | 'name' => 'import_data', 48 | 'type' => 'direct', 49 | ], 50 | 'queue_options' => [ 51 | 'declare' => false, // Use this if you don't want to create a queue on producing messages 52 | ], 53 | ], 54 | ], 55 | 'consumers' => [ 56 | 'import_data' => [ 57 | 'connection' => 'default', 58 | 'exchange_options' => [ 59 | 'name' => 'import_data', 60 | 'type' => 'direct', 61 | ], 62 | 'queue_options' => [ 63 | 'name' => 'import_data', // Queue name which will be binded to the exchange adove 64 | 'routing_keys' => ['import_data'], // Name of the exchange to bind to 65 | 'durable' => true, 66 | 'auto_delete' => false, 67 | ], 68 | // Or just '\path\to\ImportDataConsumer' in PHP 5.4 69 | 'callback' => \path\to\ImportDataConsumer::class, 70 | ], 71 | ], 72 | ], 73 | // ... 74 | ], 75 | // should be in console.php 76 | 'controllerMap' => [ 77 | 'rabbitmq-consumer' => \mikemadisonweb\rabbitmq\controllers\ConsumerController::class, 78 | 'rabbitmq-producer' => \mikemadisonweb\rabbitmq\controllers\ProducerController::class, 79 | ], 80 | // ... 81 | ]; 82 | ``` 83 | To use this extension you should be familiar with the basic concepts of RabbitMQ. If you are not confident in your knowledge I suggest reading [this article](https://mikemadisonweb.github.io/2017/05/04/tldr-series-rabbitmq/). 84 | 85 | The 'callback' parameter can be a class name or a service name from [dependency injection container](http://www.yiiframework.com/doc-2.0/yii-di-container.html). Starting from Yii version 2.0.11 you can configure your container like this: 86 | ```php 87 | [ 93 | 'definitions' => [], 94 | 'singletons' => [ 95 | 'rabbitmq.import-data.consumer' => [ 96 | [ 97 | 'class' => \path\to\ImportDataConsumer::class, 98 | ], 99 | [ 100 | 'some-dependency' => Instance::of('dependency-service-name'), 101 | ], 102 | ], 103 | ], 104 | ], 105 | ]; 106 | ``` 107 | 108 | #### Multiple consumers 109 | If you need several consumers you can list respective entries in the configuration, but that would require a separate worker(daemon process) for each of that consumers. While it can be absolutely fine in some cases if you are dealing with small queues which consuming messages really fast you may want to group them into one worker. 110 | 111 | This is how you can set a consumer with multiple queues: 112 | ```php 113 | [ 118 | // ... 119 | 'rabbitmq' => [ 120 | // ... 121 | 'multipleConsumers' => [ 122 | 'import_data' => [ 123 | 'connection' => 'default', 124 | 'exchange_options' => [ 125 | 'name' => 'exchange_name', 126 | 'type' => 'direct', 127 | ], 128 | 'queues' => [ 129 | 'import_data' => [ 130 | 'name' => 'import_data', 131 | 'callback' => \path\to\ImportDataConsumer::class, 132 | 'routing_keys' => ['import_data'], // Queue will be binded using routing key 133 | // Other optional settings can be listed here (like in queue_options) 134 | 'durable' => true, 135 | ], 136 | 'update_index' => [ 137 | 'name' => 'update_index', 138 | 'callback' => \path\to\UpdateIndexConsumer::class, 139 | 'routing_keys' => ['update_index'], 140 | // Refer to the Options section for more 141 | 'exclusive' => true, // Optional 142 | ], 143 | ], 144 | ], 145 | ], 146 | ], 147 | // ... 148 | ], 149 | ]; 150 | ``` 151 | Be aware that all queues are under the same exchange, it's up to you to set the correct routing for callbacks. 152 | #### Lifecycle events 153 | There are also couple of lifecycle events implemented: before_consume, after_consume, before_publish, after_publish. You can use them for any additional work you need to do before or after message been consumed/published. For example, reopen database connection for it not to be closed by timeout as a consumer is a long-running process: 154 | ```php 155 | [ 160 | // ... 161 | 'rabbitmq' => [ 162 | // ... 163 | 'on before_consume' => function ($event) { 164 | if (isset(\Yii::$app->db)) { 165 | $db = \Yii::$app->db; 166 | if ($db->getIsActive()) { 167 | $db->close(); 168 | } 169 | $db->open(); 170 | } 171 | }, 172 | ], 173 | // ... 174 | ], 175 | ]; 176 | ``` 177 | #### Logger 178 | Last but not least is logger configuration which is also optional: 179 | ```php 180 | [ 185 | // ... 186 | 'rabbitmq' => [ 187 | // ... 188 | 'logger' => [ 189 | 'enable' => true, 190 | 'category' => 'application', 191 | 'print_console' => false, 192 | 'system_memory' => false, 193 | ], 194 | ], 195 | // ... 196 | ], 197 | ]; 198 | ``` 199 | Logger enabled by default, but it log messages into main application log. You can change that by setting your own log target and specify corresponding category name, like 'amqp' is set above. Option 'print_console' disabled by default, it give you additional information while debugging a consumer in you console. 200 | 201 | Console commands 202 | ------------- 203 | Extension provides several console commands: 204 | - **rabbitmq-consumer/single** - Run consumer(one instance per queue) 205 | - **rabbitmq-consumer/multiple** - Run consumer(one instance per multiple queues) 206 | - **rabbitmq-consumer/setup-fabric** - Setup RabbitMQ exchanges and queues based on configuration 207 | - **rabbitmq-producer/publish** - Pubish messages from STDIN to queue 208 | 209 | The most important here is single and multiple consumer commands as it start consumer processes based on consumer and multipleConsumer config respectively. 210 | 211 | As PHP daemon especially based upon a framework may be prone to memory leaks, it may be reasonable to limit the number of messages to consume and stop: 212 | ``` 213 | yii rabbitmq-consumer/single import_data -m=10 214 | ``` 215 | In this case, you can use process control system, like Supervisor, to restart consumer process and this way keep your worker run continuously. 216 | 217 | Usage 218 | ------------- 219 | As the consumer worker will read messages from the queue, it executes a callback and passes a message to it. Callback class should implement ConsumerInterface: 220 | ```php 221 | body); 237 | 238 | if ($this->isValid($data)) { 239 | // Apply your business logic here 240 | 241 | return ConsumerInterface::MSG_ACK; 242 | } 243 | } 244 | } 245 | ``` 246 | You can format your message as you wish(JSON, XML, etc) the only restriction is that it should be a string. Here is an example how you can publish a message: 247 | ```php 248 | \Yii::$app->rabbitmq->load(); 249 | $producer = \Yii::$container->get(sprintf('rabbit_mq.producer.%s', 'import_data')); 250 | $msg = serialize(['dataset_id' => $dataset->id, 'linked_datasets' => []]); 251 | $producer->publish($msg, 'import_data'); 252 | ``` 253 | This template for a service name 'rabbit_mq.producer.%s' is also available as a constant mikemadisonweb\rabbitmq\components\BaseRabbitMQ::PRODUCER_SERVICE_NAME. It's needed because producer classes are lazy loaded, that means they are only got created on demand. Likewise the Connection class also got created on demand, that means a connection to RabbitMQ would not be established on each request. 254 | 255 | Options 256 | ------------- 257 | All default options are taken from php-amqplib library. Complete explanation about options, their defaults and valuable details can be found in [AMQP 0-9-1 Reference Guide](http://www.rabbitmq.com/amqp-0-9-1-reference.html). 258 | 259 | ##### Exchange 260 | For example, to declare an exchange you should provide name and type for it. 261 | 262 | parameter | required | type | default | comments 263 | --- | --- | --- | --- | --- 264 | name | yes | string | | The exchange name consists of a non-empty sequence of these characters: letters, digits, hyphen, underscore, period, or colon. 265 | type | yes | string | | Type of the exchange, possible values are `direct`, `fanout`, `topic` and `headers`. 266 | declare | no | boolean | true | Whether to declare a exchange on sending or consuming messages. 267 | passive | no | boolean | false | If set to true, the server will reply with Declare-Ok if the exchange already exists with the same name, and raise an error if not. The client can use this to check whether an exchange exists without modifying the server state. When set, all other method fields except name and no-wait are ignored. A declare with both passive and no-wait has no effect. 268 | durable | no | boolean | false | Durable exchanges remain active when a server restarts. Non-durable exchanges (transient exchanges) are purged if/when a server restarts. 269 | auto_delete | no | boolean | true | If set to true, the exchange would be deleted when no queues are binded to it anymore. 270 | internal | no | boolean | false | Internal exchange may not be used directly by publishers, but only when bound to other exchanges. 271 | nowait | no | boolean | false | Client may send next request immediately after sending the first one, no waiting for reply is required 272 | arguments | no | array | null | A set of arguments for the declaration. 273 | ticket | no | integer | null | Access ticket 274 | 275 | Good use-case of the `arguments` parameter usage can be a creation of a [dead-letter-exchange](https://github.com/php-amqplib/php-amqplib/blob/master/demo/queue_arguments.php#L17). 276 | ##### Queue 277 | As for the queue declaration, all parameters are optional. Even if you does not provide a name for your queue server will generate unique name for you: 278 | 279 | parameter | required | type | default | comments 280 | --- | --- | --- | --- | --- 281 | name | no | string | '' | The queue name can be empty, or a sequence of these characters: letters, digits, hyphen, underscore, period, or colon. 282 | declare | no | boolean | true | Whether to declare a queue on sending or consuming messages. 283 | passive | no | boolean | false | If set to true, the server will reply with Declare-Ok if the queue already exists with the same name, and raise an error if not. 284 | durable | no | boolean | false | Durable queues remain active when a server restarts. Non-durable queues (transient queues) are purged if/when a server restarts. 285 | auto_delete | no | boolean | true | If set to true, the queue is deleted when all consumers have finished using it. 286 | exclusive | no | boolean | false | Exclusive queues may only be accessed by the current connection, and are deleted when that connection closes. Passive declaration of an exclusive queue by other connections are not allowed. 287 | nowait | no | boolean | false | Client may send next request immediately after sending the first one, no waiting for reply is required 288 | arguments | false | array | null | A set of arguments for the declaration. 289 | ticket | no | integer | null | Access ticket 290 | 291 | Beware that not all these options are allowed to be changed 'on-the-fly', in other words after queue or exchange had already been created. Otherwise, you will receive an error. 292 | -------------------------------------------------------------------------------- /components/AbstractConnectionFactory.php: -------------------------------------------------------------------------------- 1 | _class = $class; 24 | $this->_parameters = $this->parseUrl($parameters); 25 | } 26 | 27 | /** 28 | * @return mixed 29 | */ 30 | public function createConnection() : AbstractConnection 31 | { 32 | if ($this->_parameters['ssl_context'] !== null) { 33 | return new $this->_class( 34 | $this->_parameters['host'], 35 | $this->_parameters['port'], 36 | $this->_parameters['user'], 37 | $this->_parameters['password'], 38 | $this->_parameters['vhost'], 39 | $this->_parameters['ssl_context'], 40 | [ 41 | 'connection_timeout' => $this->_parameters['connection_timeout'], 42 | 'read_write_timeout' => $this->_parameters['read_write_timeout'], 43 | 'keepalive' => $this->_parameters['keepalive'], 44 | 'heartbeat' => $this->_parameters['heartbeat'], 45 | 'channel_rpc_timeout' => $this->_parameters['channel_rpc_timeout'], 46 | ] 47 | ); 48 | } 49 | return new $this->_class( 50 | $this->_parameters['host'], 51 | $this->_parameters['port'], 52 | $this->_parameters['user'], 53 | $this->_parameters['password'], 54 | $this->_parameters['vhost'], 55 | false, // insist 56 | 'AMQPLAIN', // login_method 57 | null, // login_response 58 | 'en_EN', // locale 59 | $this->_parameters['connection_timeout'], 60 | $this->_parameters['read_write_timeout'], 61 | $this->_parameters['ssl_context'], 62 | $this->_parameters['keepalive'], 63 | $this->_parameters['heartbeat'], 64 | $this->_parameters['channel_rpc_timeout'] 65 | ); 66 | } 67 | 68 | /** 69 | * Parse connection defined by url, e.g. 'amqp://guest:password@localhost:5672/vhost?lazy=1&connection_timeout=6' 70 | * @param $parameters 71 | * @return array 72 | */ 73 | private function parseUrl($parameters) 74 | { 75 | if (!$parameters['url']) { 76 | return $parameters; 77 | } 78 | $url = parse_url($parameters['url']); 79 | if ($url === false || !isset($url['scheme']) || $url['scheme'] !== 'amqp') { 80 | throw new \InvalidArgumentException('Malformed parameter "url".'); 81 | } 82 | if (isset($url['host'])) { 83 | $parameters['host'] = urldecode($url['host']); 84 | } 85 | if (isset($url['port'])) { 86 | $parameters['port'] = (int)$url['port']; 87 | } 88 | if (isset($url['user'])) { 89 | $parameters['user'] = urldecode($url['user']); 90 | } 91 | if (isset($url['pass'])) { 92 | $parameters['password'] = urldecode($url['pass']); 93 | } 94 | if (isset($url['path'])) { 95 | $parameters['vhost'] = urldecode(ltrim($url['path'], '/')); 96 | } 97 | if (isset($url['query'])) { 98 | $query = []; 99 | parse_str($url['query'], $query); 100 | $parameters = array_merge($parameters, $query); 101 | } 102 | unset($parameters['url']); 103 | 104 | return $parameters; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /components/BaseRabbitMQ.php: -------------------------------------------------------------------------------- 1 | conn = $conn; 33 | $this->routing = $routing; 34 | $this->logger = $logger; 35 | $this->autoDeclare = $autoDeclare; 36 | if ($conn->connectOnConstruct()) { 37 | $this->getChannel(); 38 | } 39 | } 40 | 41 | public function __destruct() 42 | { 43 | $this->close(); 44 | } 45 | 46 | public function close() 47 | { 48 | if ($this->ch) { 49 | try { 50 | $this->ch->close(); 51 | } catch (\Exception $e) { 52 | // ignore on shutdown 53 | } 54 | } 55 | if ($this->conn && $this->conn->isConnected()) { 56 | try { 57 | $this->conn->close(); 58 | } catch (\Exception $e) { 59 | // ignore on shutdown 60 | } 61 | } 62 | } 63 | 64 | public function renew() 65 | { 66 | if (!$this->conn->isConnected()) { 67 | return; 68 | } 69 | $this->conn->reconnect(); 70 | } 71 | 72 | /** 73 | * @return AMQPChannel 74 | */ 75 | public function getChannel() 76 | { 77 | if (empty($this->ch) || null === $this->ch->getChannelId()) { 78 | $this->ch = $this->conn->channel(); 79 | } 80 | 81 | return $this->ch; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /components/Consumer.php: -------------------------------------------------------------------------------- 1 | memoryLimit = $memoryLimit; 53 | } 54 | 55 | /** 56 | * Get the memory limit 57 | * 58 | * @return int 59 | */ 60 | public function getMemoryLimit(): int 61 | { 62 | return $this->memoryLimit; 63 | } 64 | 65 | /** 66 | * @param array $queues 67 | */ 68 | public function setQueues(array $queues) 69 | { 70 | $this->queues = $queues; 71 | } 72 | 73 | /** 74 | * @return array 75 | */ 76 | public function getQueues(): array 77 | { 78 | return $this->queues; 79 | } 80 | 81 | /** 82 | * @param $idleTimeout 83 | */ 84 | public function setIdleTimeout($idleTimeout) 85 | { 86 | $this->idleTimeout = $idleTimeout; 87 | } 88 | 89 | public function getIdleTimeout() 90 | { 91 | return $this->idleTimeout; 92 | } 93 | 94 | /** 95 | * Set exit code to be returned when there is a timeout exception 96 | * 97 | * @param int|null $idleTimeoutExitCode 98 | */ 99 | public function setIdleTimeoutExitCode($idleTimeoutExitCode) 100 | { 101 | $this->idleTimeoutExitCode = $idleTimeoutExitCode; 102 | } 103 | 104 | /** 105 | * Get exit code to be returned when there is a timeout exception 106 | * 107 | * @return int|null 108 | */ 109 | public function getIdleTimeoutExitCode() 110 | { 111 | return $this->idleTimeoutExitCode; 112 | } 113 | 114 | /** 115 | * @return mixed 116 | */ 117 | public function getDeserializer(): callable 118 | { 119 | return $this->deserializer; 120 | } 121 | 122 | /** 123 | * @param mixed $deserializer 124 | */ 125 | public function setDeserializer(callable $deserializer) 126 | { 127 | $this->deserializer = $deserializer; 128 | } 129 | 130 | /** 131 | * @return mixed 132 | */ 133 | public function getQos(): array 134 | { 135 | return $this->qos; 136 | } 137 | 138 | /** 139 | * @param mixed $qos 140 | */ 141 | public function setQos(array $qos) 142 | { 143 | $this->qos = $qos; 144 | } 145 | 146 | /** 147 | * @param string $name 148 | */ 149 | public function setName(string $name) 150 | { 151 | $this->name = $name; 152 | } 153 | 154 | /** 155 | * @return string 156 | */ 157 | public function getName(): string 158 | { 159 | return $this->name; 160 | } 161 | 162 | /** 163 | * Resets the consumed property. 164 | * Use when you want to call start() or consume() multiple times. 165 | */ 166 | public function getConsumed(): int 167 | { 168 | return $this->consumed; 169 | } 170 | 171 | /** 172 | * Resets the consumed property. 173 | * Use when you want to call start() or consume() multiple times. 174 | */ 175 | public function resetConsumed() 176 | { 177 | $this->consumed = 0; 178 | } 179 | 180 | /** 181 | * @return mixed 182 | */ 183 | public function getProceedOnException(): bool 184 | { 185 | return $this->proceedOnException; 186 | } 187 | 188 | /** 189 | * @param mixed $proceedOnException 190 | */ 191 | public function setProceedOnException(bool $proceedOnException) 192 | { 193 | $this->proceedOnException = $proceedOnException; 194 | } 195 | 196 | /** 197 | * Consume designated number of messages (0 means infinite) 198 | * 199 | * @param int $msgAmount 200 | * 201 | * @return int 202 | * @throws BadFunctionCallException 203 | * @throws RuntimeException 204 | * @throws AMQPTimeoutException 205 | * @throws ErrorException 206 | */ 207 | public function consume($msgAmount = 0): int 208 | { 209 | $this->target = $msgAmount; 210 | $this->setup(); 211 | // At the end of the callback execution 212 | while (count($this->getChannel()->callbacks)) 213 | { 214 | if ($this->maybeStopConsumer()) 215 | { 216 | break; 217 | } 218 | try 219 | { 220 | $this->getChannel()->wait(null, false, $this->getIdleTimeout()); 221 | } 222 | catch (AMQPTimeoutException $e) 223 | { 224 | if (null !== $this->getIdleTimeoutExitCode()) 225 | { 226 | return $this->getIdleTimeoutExitCode(); 227 | } 228 | 229 | throw $e; 230 | } 231 | if (!AMQP_WITHOUT_SIGNALS && extension_loaded('pcntl')) 232 | { 233 | pcntl_signal_dispatch(); 234 | } 235 | } 236 | 237 | return Controller::EXIT_CODE_NORMAL; 238 | } 239 | 240 | /** 241 | * Stop consuming messages 242 | */ 243 | public function stopConsuming() 244 | { 245 | foreach ($this->queues as $name => $options) 246 | { 247 | $this->getChannel()->basic_cancel($this->getConsumerTag($name), false, true); 248 | } 249 | } 250 | 251 | /** 252 | * Force stop the consumer 253 | */ 254 | public function stopDaemon() 255 | { 256 | $this->forceStop = true; 257 | $this->stopConsuming(); 258 | $this->logger->printInfo("\nConsumer stopped by user.\n"); 259 | } 260 | 261 | /** 262 | * Force restart the consumer 263 | */ 264 | public function restartDaemon() 265 | { 266 | $this->stopConsuming(); 267 | $this->renew(); 268 | $this->setup(); 269 | $this->logger->printInfo("\nConsumer has been restarted.\n"); 270 | } 271 | 272 | /** 273 | * Sets the qos settings for the current channel 274 | * This method needs a connection to broker 275 | */ 276 | protected function setQosOptions() 277 | { 278 | if (empty($this->qos)) 279 | { 280 | return; 281 | } 282 | $prefetchSize = $this->qos['prefetch_size'] ?? null; 283 | $prefetchCount = $this->qos['prefetch_count'] ?? null; 284 | $global = $this->qos['global'] ?? null; 285 | $this->getChannel()->basic_qos($prefetchSize, $prefetchCount, $global); 286 | } 287 | 288 | /** 289 | * Start consuming messages 290 | * 291 | * @throws RuntimeException 292 | */ 293 | protected function startConsuming() 294 | { 295 | $this->id = $this->generateUniqueId(); 296 | foreach ($this->queues as $queue => $callback) 297 | { 298 | $that = $this; 299 | $this->getChannel()->basic_consume( 300 | $queue, 301 | $this->getConsumerTag($queue), 302 | null, 303 | null, 304 | null, 305 | null, 306 | function (AMQPMessage $msg) use ($that, $queue, $callback) 307 | { 308 | // Execute user-defined callback 309 | $that->onReceive($msg, $queue, $callback); 310 | } 311 | ); 312 | } 313 | } 314 | 315 | /** 316 | * Decide whether it's time to stop consuming 317 | * 318 | * @throws BadFunctionCallException 319 | */ 320 | protected function maybeStopConsumer(): bool 321 | { 322 | if (extension_loaded('pcntl') && (defined('AMQP_WITHOUT_SIGNALS') ? !AMQP_WITHOUT_SIGNALS : true)) 323 | { 324 | if (!function_exists('pcntl_signal_dispatch')) 325 | { 326 | throw new BadFunctionCallException( 327 | "Function 'pcntl_signal_dispatch' is referenced in the php.ini 'disable_functions' and can't be called." 328 | ); 329 | } 330 | pcntl_signal_dispatch(); 331 | } 332 | if ($this->forceStop || ($this->consumed === $this->target && $this->target > 0)) 333 | { 334 | $this->stopConsuming(); 335 | 336 | return true; 337 | } 338 | 339 | if (0 !== $this->getMemoryLimit() && $this->isRamAlmostOverloaded()) 340 | { 341 | $this->stopConsuming(); 342 | 343 | return true; 344 | } 345 | 346 | return false; 347 | } 348 | 349 | /** 350 | * Callback that will be fired upon receiving new message 351 | * 352 | * @param AMQPMessage $msg 353 | * @param $queueName 354 | * @param $callback 355 | * 356 | * @return bool 357 | * @throws Throwable 358 | */ 359 | protected function onReceive(AMQPMessage $msg, string $queueName, callable $callback): bool 360 | { 361 | $timeStart = microtime(true); 362 | \Yii::$app->rabbitmq->trigger( 363 | RabbitMQConsumerEvent::BEFORE_CONSUME, 364 | new RabbitMQConsumerEvent( 365 | [ 366 | 'message' => $msg, 367 | 'consumer' => $this, 368 | ] 369 | ) 370 | ); 371 | 372 | try 373 | { 374 | // deserialize message back to initial data type 375 | if ($msg->has('application_headers') && 376 | isset($msg->get('application_headers')->getNativeData()['rabbitmq.serialized'])) 377 | { 378 | $msg->setBody(call_user_func($this->deserializer, $msg->getBody())); 379 | } 380 | // process message and return the result code back to broker 381 | $processFlag = $callback($msg); 382 | $this->sendResult($msg, $processFlag); 383 | \Yii::$app->rabbitmq->trigger( 384 | RabbitMQConsumerEvent::AFTER_CONSUME, 385 | new RabbitMQConsumerEvent( 386 | [ 387 | 'message' => $msg, 388 | 'consumer' => $this, 389 | ] 390 | ) 391 | ); 392 | 393 | $this->logger->printResult($queueName, $processFlag, $timeStart); 394 | $this->logger->log( 395 | 'Queue message processed.', 396 | $msg, 397 | [ 398 | 'queue' => $queueName, 399 | 'processFlag' => $processFlag, 400 | 'timeStart' => $timeStart, 401 | 'memory' => true, 402 | ] 403 | ); 404 | } 405 | catch (Throwable $e) 406 | { 407 | $this->logger->logError($e, $msg); 408 | if (!$this->proceedOnException) 409 | { 410 | throw $e; 411 | } 412 | } 413 | $this->consumed++; 414 | 415 | return true; 416 | } 417 | 418 | /** 419 | * Mark message status based on return code from callback 420 | * 421 | * @param AMQPMessage $msg 422 | * @param $processFlag 423 | */ 424 | protected function sendResult(AMQPMessage $msg, $processFlag) 425 | { 426 | // true in testing environment 427 | if (!isset($msg->delivery_info['channel'])) 428 | { 429 | return; 430 | } 431 | 432 | // respond to the broker with appropriate reply code 433 | if ($processFlag === ConsumerInterface::MSG_REQUEUE || false === $processFlag) 434 | { 435 | // Reject and requeue message to RabbitMQ 436 | $msg->delivery_info['channel']->basic_reject($msg->delivery_info['delivery_tag'], true); 437 | } 438 | elseif ($processFlag === ConsumerInterface::MSG_REJECT) 439 | { 440 | // Reject and drop 441 | $msg->delivery_info['channel']->basic_reject($msg->delivery_info['delivery_tag'], false); 442 | } 443 | else 444 | { 445 | // Remove message from queue only if callback return not false 446 | $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); 447 | } 448 | } 449 | 450 | /** 451 | * Checks if memory in use is greater or equal than memory allowed for this process 452 | * 453 | * @return boolean 454 | */ 455 | protected function isRamAlmostOverloaded(): bool 456 | { 457 | return memory_get_usage(true) >= ($this->getMemoryLimit() * 1024 * 1024); 458 | } 459 | 460 | /** 461 | * @param string $queueName 462 | * 463 | * @return string 464 | */ 465 | protected function getConsumerTag(string $queueName): string 466 | { 467 | return sprintf('%s-%s-%s', $queueName, $this->name, $this->id); 468 | } 469 | 470 | /** 471 | * @return string 472 | */ 473 | protected function generateUniqueId(): string 474 | { 475 | return uniqid('rabbitmq_', true); 476 | } 477 | 478 | protected function setup() 479 | { 480 | $this->resetConsumed(); 481 | if ($this->autoDeclare) 482 | { 483 | $this->routing->declareAll(); 484 | } 485 | $this->setQosOptions(); 486 | $this->startConsuming(); 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /components/ConsumerInterface.php: -------------------------------------------------------------------------------- 1 | options; 18 | } 19 | 20 | /** 21 | * Print success message to console 22 | * 23 | * @param $queueName 24 | * @param $timeStart 25 | * @param $processFlag 26 | */ 27 | public function printResult(string $queueName, $processFlag, $timeStart) 28 | { 29 | if (!$this->options['print_console']) { 30 | return; 31 | } 32 | if ($processFlag === ConsumerInterface::MSG_REQUEUE || false === $processFlag) { 33 | $messageFormat = '%s - Message from queue `%s` was not processed and sent back to queue! Execution time: %s %s'; 34 | $color = Console::FG_RED; 35 | } elseif ($processFlag === ConsumerInterface::MSG_REJECT) { 36 | $messageFormat = '%s - Message from queue `%s` was not processed and dropped from queue! Execution time: %s %s'; 37 | $color = Console::FG_RED; 38 | } else { 39 | $messageFormat = '%s - Message from queue `%s` consumed successfully! Execution time: %s %s'; 40 | $color = Console::FG_GREEN; 41 | } 42 | $curDate = date('Y-m-d H:i:s'); 43 | $execTime = $this->getExecutionTime($timeStart); 44 | $memory = $this->getMemory(); 45 | $consoleMessage = sprintf($messageFormat, $curDate, $queueName, $execTime, $memory); 46 | $this->printInfo($consoleMessage, $color); 47 | } 48 | 49 | /** 50 | * @param \Exception $e 51 | */ 52 | public function printError(\Exception $e) 53 | { 54 | if (!$this->options['print_console']) { 55 | return; 56 | } 57 | $color = Console::FG_RED; 58 | $consoleMessage = sprintf('Error: %s File: %s Line: %s', $e->getMessage(), $e->getFile(), $e->getLine()); 59 | $this->printInfo($consoleMessage, $color); 60 | } 61 | 62 | /** 63 | * Log message using standard Yii logger 64 | * @param string $title 65 | * @param AMQPMessage $msg 66 | * @param array $options 67 | */ 68 | public function log(string $title, AMQPMessage $msg, array $options) 69 | { 70 | if (!$this->options['log']) { 71 | return; 72 | } 73 | $extra['execution_time'] = isset($options['timeStart']) ? $this->getExecutionTime($options['timeStart']) : null; 74 | $extra['return_code'] = $options['processFlag'] ?? null; 75 | $extra['memory'] = isset($options['memory']) ? $this->getMemory() : null; 76 | $extra['routing_key'] = $options['routingKey'] ?? null; 77 | $extra['queue'] = $options['queue'] ?? null; 78 | $extra['exchange'] = $options['exchange'] ?? null; 79 | \Yii::info([ 80 | 'info' => $title, 81 | 'amqp' => [ 82 | 'body' => $msg->getBody(), 83 | 'headers' => $msg->has('application_headers') ? $msg->get('application_headers')->getNativeData() : null, 84 | 'extra' => $extra, 85 | ], 86 | ], $this->options['category']); 87 | } 88 | 89 | /** 90 | * Log error message using standard Yii logger 91 | * @param \Throwable $e 92 | * @param AMQPMessage $msg 93 | */ 94 | public function logError(\Throwable $e, AMQPMessage $msg) 95 | { 96 | if (!$this->options['log']) { 97 | return; 98 | } 99 | \Yii::error([ 100 | 'msg' => $e->getMessage(), 101 | 'amqp' => [ 102 | 'message' => $msg->getBody(), 103 | 'stacktrace' => $e->getTraceAsString(), 104 | ], 105 | ], $this->options['category']); 106 | } 107 | 108 | /** 109 | * Print message to STDOUT 110 | * @param $message 111 | * @param $color 112 | * @return bool|int 113 | */ 114 | public function printInfo($message, $color = Console::FG_YELLOW) 115 | { 116 | if (Console::streamSupportsAnsiColors(\STDOUT)) { 117 | $message = Console::ansiFormat($message, [$color]); 118 | } 119 | 120 | return Console::output($message); 121 | } 122 | 123 | /** 124 | * @param $timeStart 125 | * @param int $round 126 | * @return string 127 | */ 128 | protected function getExecutionTime($timeStart, int $round = 3) : string 129 | { 130 | return (string)round(microtime(true) - $timeStart, $round) . 's'; 131 | } 132 | 133 | /** 134 | * Get either script memory usage or free system memory info 135 | * @return string 136 | */ 137 | protected function getMemory() : string 138 | { 139 | if ($this->options['system_memory']) { 140 | return $this->getSystemFreeMemory(); 141 | } 142 | 143 | return 'Memory usage: ' . $this->getMemoryDiff(); 144 | } 145 | 146 | /** 147 | * Get memory usage in human readable format 148 | * @return string 149 | */ 150 | protected function getMemoryDiff() : string 151 | { 152 | $memory = memory_get_usage(true); 153 | if(0 === $memory) { 154 | 155 | return '0b'; 156 | } 157 | $unit = ['b','kb','mb','gb','tb','pb']; 158 | 159 | return @round($memory/ (1024 ** ($i = floor(log($memory, 1024)))),2).' '.$unit[$i]; 160 | } 161 | 162 | /** 163 | * Free system memory 164 | * 165 | * @return string 166 | */ 167 | protected function getSystemFreeMemory() : string 168 | { 169 | $data = explode("\n", trim(file_get_contents('/proc/meminfo'))); 170 | 171 | return sprintf( 172 | '%s, %s', 173 | preg_replace('/\s+/', ' ', $data[0]), 174 | preg_replace('/\s+/', ' ', $data[1]) 175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /components/Producer.php: -------------------------------------------------------------------------------- 1 | contentType = $contentType; 33 | } 34 | 35 | /** 36 | * @param $deliveryMode 37 | */ 38 | public function setDeliveryMode($deliveryMode) 39 | { 40 | $this->deliveryMode = $deliveryMode; 41 | } 42 | 43 | /** 44 | * @param callable $serializer 45 | */ 46 | public function setSerializer(callable $serializer) 47 | { 48 | $this->serializer = $serializer; 49 | } 50 | 51 | /** 52 | * @return callable 53 | */ 54 | public function getSerializer(): callable 55 | { 56 | return $this->serializer; 57 | } 58 | 59 | /** 60 | * @return array 61 | */ 62 | public function getBasicProperties(): array 63 | { 64 | return [ 65 | 'content_type' => $this->contentType, 66 | 'delivery_mode' => $this->deliveryMode, 67 | ]; 68 | } 69 | 70 | /** 71 | * @return mixed 72 | */ 73 | public function getSafe(): bool 74 | { 75 | return $this->safe; 76 | } 77 | 78 | /** 79 | * @param mixed $safe 80 | */ 81 | public function setSafe(bool $safe) 82 | { 83 | $this->safe = $safe; 84 | } 85 | 86 | /** 87 | * @return string 88 | */ 89 | public function getName(): string 90 | { 91 | return $this->name; 92 | } 93 | 94 | /** 95 | * @param string $name 96 | */ 97 | public function setName(string $name) 98 | { 99 | $this->name = $name; 100 | } 101 | 102 | /** 103 | * Publishes the message and merges additional properties with basic properties 104 | * 105 | * @param mixed $msgBody 106 | * @param string $exchangeName 107 | * @param string $routingKey 108 | * @param array $additionalProperties 109 | * @param array $headers 110 | * 111 | * @throws RuntimeException 112 | */ 113 | public function publish( 114 | $msgBody, 115 | string $exchangeName, 116 | string $routingKey = '', 117 | array $additionalProperties = [], 118 | array $headers = null 119 | ) { 120 | if ($this->autoDeclare) 121 | { 122 | $this->routing->declareAll(); 123 | } 124 | if ($this->safe && !$this->routing->isExchangeExists($exchangeName)) 125 | { 126 | throw new RuntimeException( 127 | "Exchange `{$exchangeName}` does not declared in broker (You see this message because safe mode is ON)." 128 | ); 129 | } 130 | $serialized = false; 131 | if (!is_string($msgBody)) 132 | { 133 | $msgBody = call_user_func($this->serializer, $msgBody); 134 | $serialized = true; 135 | } 136 | $msg = new AMQPMessage($msgBody, array_merge($this->getBasicProperties(), $additionalProperties)); 137 | 138 | if (!empty($headers) || $serialized) 139 | { 140 | if ($serialized) 141 | { 142 | $headers['rabbitmq.serialized'] = 1; 143 | } 144 | $headersTable = new AMQPTable($headers); 145 | $msg->set('application_headers', $headersTable); 146 | } 147 | 148 | \Yii::$app->rabbitmq->trigger( 149 | RabbitMQPublisherEvent::BEFORE_PUBLISH, 150 | new RabbitMQPublisherEvent( 151 | [ 152 | 'message' => $msg, 153 | 'producer' => $this, 154 | ] 155 | ) 156 | ); 157 | 158 | $this->getChannel()->basic_publish($msg, $exchangeName, $routingKey); 159 | 160 | \Yii::$app->rabbitmq->trigger( 161 | RabbitMQPublisherEvent::AFTER_PUBLISH, 162 | new RabbitMQPublisherEvent( 163 | [ 164 | 'message' => $msg, 165 | 'producer' => $this, 166 | ] 167 | ) 168 | ); 169 | 170 | $this->logger->log( 171 | 'AMQP message published', 172 | $msg, 173 | [ 174 | 'exchange' => $exchangeName, 175 | 'routing_key' => $routingKey, 176 | ] 177 | ); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /components/Routing.php: -------------------------------------------------------------------------------- 1 | conn = $conn; 37 | } 38 | 39 | /** 40 | * @param array $queues 41 | */ 42 | public function setQueues(array $queues) 43 | { 44 | $this->queues = $this->arrangeByName($queues); 45 | } 46 | 47 | /** 48 | * @param array $exchanges 49 | */ 50 | public function setExchanges(array $exchanges) 51 | { 52 | $this->exchanges = $this->arrangeByName($exchanges); 53 | } 54 | 55 | /** 56 | * @param array $bindings 57 | */ 58 | public function setBindings(array $bindings) 59 | { 60 | $this->bindings = $bindings; 61 | } 62 | 63 | /** 64 | * Declare all routing entries defined by configuration 65 | * @return bool 66 | * @throws RuntimeException 67 | */ 68 | public function declareAll() : bool 69 | { 70 | if (!$this->isDeclared) { 71 | foreach (array_keys($this->exchanges) as $name) { 72 | $this->declareExchange($name); 73 | } 74 | foreach (array_keys($this->queues) as $name) { 75 | $this->declareQueue($name); 76 | } 77 | $this->declareBindings(); 78 | $this->isDeclared = true; 79 | 80 | return true; 81 | } 82 | 83 | return false; 84 | } 85 | 86 | /** 87 | * @param $queueName 88 | * @throws RuntimeException 89 | */ 90 | public function declareQueue(string $queueName) 91 | { 92 | if(!isset($this->queues[$queueName])) { 93 | throw new RuntimeException("Queue `{$queueName}` is not configured."); 94 | } 95 | 96 | $queue = $this->queues[$queueName]; 97 | if (!isset($this->queuesDeclared[$queueName])) { 98 | if (ArrayHelper::isAssociative($queue)) { 99 | $this->getChannel()->queue_declare( 100 | $queue['name'], 101 | $queue['passive'], 102 | $queue['durable'], 103 | $queue['exclusive'], 104 | $queue['auto_delete'], 105 | $queue['nowait'], 106 | $queue['arguments'], 107 | $queue['ticket'] 108 | ); 109 | } else { 110 | foreach ($queue as $q) { 111 | $this->getChannel()->queue_declare( 112 | $q['name'], 113 | $q['passive'], 114 | $q['durable'], 115 | $q['exclusive'], 116 | $q['auto_delete'], 117 | $q['nowait'], 118 | $q['arguments'], 119 | $q['ticket'] 120 | ); 121 | } 122 | } 123 | $this->queuesDeclared[$queueName] = true; 124 | } 125 | } 126 | 127 | /** 128 | * Create bindings 129 | */ 130 | public function declareBindings() 131 | { 132 | foreach ($this->bindings as $binding) { 133 | if (isset($binding['queue'])) { 134 | $this->bindExchangeToQueue($binding); 135 | } else { 136 | $this->bindExchangeToExchange($binding); 137 | } 138 | } 139 | } 140 | 141 | /** 142 | * Create exchange-to-queue binding 143 | * @param array $binding 144 | */ 145 | public function bindExchangeToQueue(array $binding) 146 | { 147 | if (isset($binding['routing_keys']) && count($binding['routing_keys']) > 0) { 148 | foreach ($binding['routing_keys'] as $routingKey) { 149 | // queue binding is not permitted on the default exchange 150 | if ('' !== $binding['exchange']) { 151 | $this->getChannel()->queue_bind($binding['queue'], $binding['exchange'], $routingKey); 152 | } 153 | } 154 | } else { 155 | // queue binding is not permitted on the default exchange 156 | if ('' !== $binding['exchange']) { 157 | $this->getChannel()->queue_bind($binding['queue'], $binding['exchange']); 158 | } 159 | } 160 | } 161 | 162 | /** 163 | * Create exchange-to-exchange binding 164 | * @param array $binding 165 | */ 166 | public function bindExchangeToExchange(array $binding) 167 | { 168 | if (isset($binding['routing_keys']) && count($binding['routing_keys']) > 0) { 169 | foreach ($binding['routing_keys'] as $routingKey) { 170 | // queue binding is not permitted on the default exchange 171 | if ('' !== $binding['exchange']) { 172 | $this->getChannel()->exchange_bind($binding['to_exchange'], $binding['exchange'], $routingKey); 173 | } 174 | } 175 | } else { 176 | // queue binding is not permitted on the default exchange 177 | if ('' !== $binding['exchange']) { 178 | $this->getChannel()->exchange_bind($binding['to_exchange'], $binding['exchange']); 179 | } 180 | } 181 | } 182 | 183 | /** 184 | * @param $exchangeName 185 | * @throws RuntimeException 186 | */ 187 | public function declareExchange(string $exchangeName) 188 | { 189 | if(!isset($this->exchanges[$exchangeName])) { 190 | throw new RuntimeException("Exchange `{$exchangeName}` is not configured."); 191 | } 192 | $exchange = $this->exchanges[$exchangeName]; 193 | if (!isset($this->exchangesDeclared[$exchangeName])) { 194 | $this->getChannel()->exchange_declare( 195 | $exchange['name'], 196 | $exchange['type'], 197 | $exchange['passive'], 198 | $exchange['durable'], 199 | $exchange['auto_delete'], 200 | $exchange['internal'], 201 | $exchange['nowait'], 202 | $exchange['arguments'], 203 | $exchange['ticket'] 204 | ); 205 | $this->exchangesDeclared[$exchangeName] = true; 206 | } 207 | } 208 | 209 | /** 210 | * Purge the queue 211 | * @param string $queueName 212 | * @throws RuntimeException 213 | */ 214 | public function purgeQueue(string $queueName) 215 | { 216 | if (!isset($this->queues[$queueName])) { 217 | throw new RuntimeException("Queue {$queueName} is not configured. Purge is aborted."); 218 | } 219 | $this->getChannel()->queue_purge($queueName, true); 220 | } 221 | 222 | /** 223 | * Delete all configured queues and exchanges 224 | * @throws RuntimeException 225 | */ 226 | public function deleteAll() 227 | { 228 | foreach (array_keys($this->queues) as $name) { 229 | $this->deleteQueue($name); 230 | } 231 | foreach (array_keys($this->exchanges) as $name) { 232 | $this->deleteExchange($name); 233 | } 234 | } 235 | 236 | /** 237 | * Delete the queue 238 | * @param string $queueName 239 | * @throws RuntimeException 240 | */ 241 | public function deleteQueue(string $queueName) 242 | { 243 | if (!isset($this->queues[$queueName])) { 244 | throw new RuntimeException("Queue {$queueName} is not configured. Delete is aborted."); 245 | } 246 | $this->getChannel()->queue_delete($queueName); 247 | } 248 | 249 | /** 250 | * Delete the queue 251 | * @param string $exchangeName 252 | * @throws RuntimeException 253 | */ 254 | public function deleteExchange(string $exchangeName) 255 | { 256 | if (!isset($this->exchanges[$exchangeName])) { 257 | throw new RuntimeException("Exchange {$exchangeName} is not configured. Delete is aborted."); 258 | } 259 | $this->getChannel()->exchange_delete($exchangeName); 260 | } 261 | 262 | /** 263 | * Checks whether exchange is already declared in broker 264 | * @param string $exchangeName 265 | * @return bool 266 | */ 267 | public function isExchangeExists(string $exchangeName) : bool 268 | { 269 | try { 270 | $this->getChannel()->exchange_declare($exchangeName, null, true); 271 | } catch (AMQPProtocolChannelException $e) { 272 | return false; 273 | } 274 | 275 | return true; 276 | } 277 | 278 | /** 279 | * Checks whether queue is already declared in broker 280 | * @param string $queueName 281 | * @return bool 282 | */ 283 | public function isQueueExists(string $queueName) : bool 284 | { 285 | try { 286 | $this->getChannel()->queue_declare($queueName, true); 287 | } catch (AMQPProtocolChannelException $e) { 288 | return false; 289 | } 290 | 291 | return true; 292 | } 293 | 294 | /** 295 | * @param array $unnamedArr 296 | * @return array 297 | */ 298 | private function arrangeByName(array $unnamedArr) : array 299 | { 300 | $namedArr = []; 301 | foreach ($unnamedArr as $elem) { 302 | if('' === $elem['name']) { 303 | $namedArr[$elem['name']][] = $elem; 304 | } else { 305 | $namedArr[$elem['name']] = $elem; 306 | } 307 | } 308 | 309 | return $namedArr; 310 | } 311 | 312 | /** 313 | * @return AMQPChannel 314 | */ 315 | private function getChannel() 316 | { 317 | if (empty($this->ch) || null === $this->ch->getChannelId()) { 318 | $this->ch = $this->conn->channel(); 319 | } 320 | 321 | return $this->ch; 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mikemadisonweb/yii2-rabbitmq", 3 | "description": "Wrapper based on php-amqplib to incorporate messaging in your Yii2 application via RabbitMQ. Inspired by RabbitMqBundle for Symfony 2, really awesome package.", 4 | "type": "yii2-extension", 5 | "keywords": ["yii2","extension","rabbitmq","amqp","message queue","distributed systems"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Mikhail Bakulin", 10 | "email": "mikemadweb@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.1||^8.0", 15 | "yiisoft/yii2": "^2.0.43", 16 | "php-amqplib/php-amqplib": "^3.1" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^9.5.10", 20 | "php-coveralls/php-coveralls": "^2.5" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "mikemadisonweb\\rabbitmq\\": "" 25 | } 26 | }, 27 | "extra": { 28 | "bootstrap": "mikemadisonweb\\rabbitmq\\DependencyInjection" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /controllers/RabbitMQController.php: -------------------------------------------------------------------------------- 1 | rabbitmq = Yii::$app->rabbitmq; 40 | } 41 | 42 | protected $options = [ 43 | 'm' => 'messagesLimit', 44 | 'l' => 'memoryLimit', 45 | 'd' => 'debug', 46 | 'w' => 'withoutSignals', 47 | ]; 48 | 49 | /** 50 | * @param string $actionID 51 | * 52 | * @return array 53 | */ 54 | public function options($actionID): array 55 | { 56 | return array_merge(parent::options($actionID), array_values($this->options)); 57 | } 58 | 59 | /** 60 | * @return array 61 | */ 62 | public function optionAliases(): array 63 | { 64 | return array_merge(parent::optionAliases(), $this->options); 65 | } 66 | 67 | /** 68 | * @param Action $event 69 | * 70 | * @return bool 71 | */ 72 | public function beforeAction($event): bool 73 | { 74 | if (defined('AMQP_WITHOUT_SIGNALS') === false) 75 | { 76 | define('AMQP_WITHOUT_SIGNALS', $this->withoutSignals); 77 | } 78 | if (defined('AMQP_DEBUG') === false) 79 | { 80 | if ($this->debug === 'false') 81 | { 82 | $this->debug = false; 83 | } 84 | define('AMQP_DEBUG', (bool)$this->debug); 85 | } 86 | 87 | return parent::beforeAction($event); 88 | } 89 | 90 | /** 91 | * Run a consumer 92 | * 93 | * @param string $name Consumer name 94 | * 95 | * @return int 96 | * @throws \Throwable 97 | */ 98 | public function actionConsume(string $name): int 99 | { 100 | $consumer = $this->rabbitmq->getConsumer($name); 101 | $this->validateConsumerOptions($consumer); 102 | if ((null !== $this->memoryLimit) && ctype_digit((string)$this->memoryLimit) && ($this->memoryLimit > 0)) 103 | { 104 | $consumer->setMemoryLimit($this->memoryLimit); 105 | } 106 | $consumer->consume($this->messagesLimit); 107 | 108 | return ExitCode::OK; 109 | } 110 | 111 | /** 112 | * Restart consumer by name 113 | * 114 | * @param string $name 115 | * @return int 116 | * @throws \Throwable 117 | * @throws \yii\base\InvalidConfigException 118 | * @throws \yii\di\NotInstantiableException 119 | */ 120 | public function actionRestartConsume(string $name): int 121 | { 122 | $consumer = $this->rabbitmq->getConsumer($name); 123 | $consumer->restartDaemon(); 124 | return $this->actionConsume($name); 125 | } 126 | 127 | /** 128 | * Publish a message from STDIN to the queue 129 | * 130 | * @param string $producerName 131 | * @param string $exchangeName 132 | * @param string $routingKey 133 | * 134 | * @return int 135 | * @throws \yii\base\InvalidConfigException 136 | * @throws \yii\di\NotInstantiableException 137 | */ 138 | public function actionPublish(string $producerName, string $exchangeName, string $routingKey = ''): int 139 | { 140 | $producer = $this->rabbitmq->getProducer($producerName); 141 | $data = ''; 142 | if (posix_isatty(STDIN)) 143 | { 144 | $this->stderr(Console::ansiFormat("Please pipe in some data in order to send it.\n", [Console::FG_RED])); 145 | 146 | return ExitCode::UNSPECIFIED_ERROR; 147 | } 148 | while (!feof(STDIN)) 149 | { 150 | $data .= fread(STDIN, 8192); 151 | } 152 | $producer->publish($data, $exchangeName, $routingKey); 153 | $this->stdout("Message was successfully published.\n", Console::FG_GREEN); 154 | 155 | return ExitCode::OK; 156 | } 157 | 158 | /** 159 | * Create RabbitMQ exchanges, queues and bindings based on configuration 160 | * 161 | * @param string $connectionName 162 | * 163 | * @return int 164 | * @throws \RuntimeException 165 | */ 166 | public function actionDeclareAll(string $connectionName = Configuration::DEFAULT_CONNECTION_NAME): int 167 | { 168 | $routing = $this->getRouting($connectionName); 169 | $result = $routing->declareAll(); 170 | if ($result) 171 | { 172 | $this->stdout( 173 | Console::ansiFormat("All configured entries was successfully declared.\n", [Console::FG_GREEN]) 174 | ); 175 | 176 | return ExitCode::OK; 177 | } 178 | $this->stderr(Console::ansiFormat("No queues, exchanges or bindings configured.\n", [Console::FG_RED])); 179 | 180 | return ExitCode::UNSPECIFIED_ERROR; 181 | } 182 | 183 | /** 184 | * Create the exchange listed in configuration 185 | * 186 | * @param $exchangeName 187 | * @param string $connectionName 188 | * 189 | * @return int 190 | * @throws \RuntimeException 191 | */ 192 | public function actionDeclareExchange( 193 | string $exchangeName, 194 | string $connectionName = Configuration::DEFAULT_CONNECTION_NAME 195 | ): int { 196 | $routing = $this->getRouting($connectionName); 197 | if ($routing->isExchangeExists($exchangeName)) 198 | { 199 | $this->stderr(Console::ansiFormat("Exchange `{$exchangeName}` is already exists.\n", [Console::FG_RED])); 200 | 201 | return ExitCode::UNSPECIFIED_ERROR; 202 | } 203 | $routing->declareExchange($exchangeName); 204 | $this->stdout(Console::ansiFormat("Exchange `{$exchangeName}` was declared.\n", [Console::FG_GREEN])); 205 | 206 | return ExitCode::OK; 207 | } 208 | 209 | /** 210 | * Create the queue listed in configuration 211 | * 212 | * @param $queueName 213 | * @param string $connectionName 214 | * 215 | * @return int 216 | * @throws \RuntimeException 217 | */ 218 | public function actionDeclareQueue( 219 | string $queueName, 220 | string $connectionName = Configuration::DEFAULT_CONNECTION_NAME 221 | ): int { 222 | $routing = $this->getRouting($connectionName); 223 | if ($routing->isQueueExists($queueName)) 224 | { 225 | $this->stderr(Console::ansiFormat("Queue `{$queueName}` is already exists.\n", [Console::FG_RED])); 226 | 227 | return ExitCode::UNSPECIFIED_ERROR; 228 | } 229 | $routing->declareQueue($queueName); 230 | $this->stdout(Console::ansiFormat("Queue `{$queueName}` was declared.\n", [Console::FG_GREEN])); 231 | 232 | return ExitCode::OK; 233 | } 234 | 235 | /** 236 | * Delete all RabbitMQ exchanges and queues that is defined in configuration 237 | * 238 | * @param string $connection 239 | * 240 | * @return int 241 | * @throws \RuntimeException 242 | */ 243 | public function actionDeleteAll(string $connection = Configuration::DEFAULT_CONNECTION_NAME): int 244 | { 245 | if ($this->interactive) 246 | { 247 | $input = Console::prompt('Are you sure you want to delete all queues and exchanges?', ['default' => 'yes']); 248 | if ($input !== 'yes' && $input !== 'y') 249 | { 250 | $this->stderr(Console::ansiFormat("Aborted.\n", [Console::FG_RED])); 251 | 252 | return ExitCode::UNSPECIFIED_ERROR; 253 | } 254 | } 255 | $routing = $this->getRouting($connection); 256 | $routing->deleteAll(); 257 | $this->stdout(Console::ansiFormat("All configured entries was deleted.\n", [Console::FG_GREEN])); 258 | 259 | return ExitCode::OK; 260 | } 261 | 262 | /** 263 | * Delete an exchange 264 | * 265 | * @param $exchangeName 266 | * @param string $connectionName 267 | * 268 | * @return int 269 | * @throws \RuntimeException 270 | */ 271 | public function actionDeleteExchange( 272 | string $exchangeName, 273 | string $connectionName = Configuration::DEFAULT_CONNECTION_NAME 274 | ): int { 275 | if ($this->interactive) 276 | { 277 | $input = Console::prompt('Are you sure you want to delete that exchange?', ['default' => 'yes']); 278 | if ($input !== 'yes') 279 | { 280 | $this->stderr(Console::ansiFormat("Aborted.\n", [Console::FG_RED])); 281 | 282 | return ExitCode::UNSPECIFIED_ERROR; 283 | } 284 | } 285 | $routing = $this->getRouting($connectionName); 286 | $routing->deleteExchange($exchangeName); 287 | $this->stdout(Console::ansiFormat("Exchange `{$exchangeName}` was deleted.\n", [Console::FG_GREEN])); 288 | 289 | return ExitCode::OK; 290 | } 291 | 292 | /** 293 | * Delete a queue 294 | * 295 | * @param $queueName 296 | * @param string $connectionName 297 | * 298 | * @return int 299 | * @throws \RuntimeException 300 | */ 301 | public function actionDeleteQueue( 302 | string $queueName, 303 | string $connectionName = Configuration::DEFAULT_CONNECTION_NAME 304 | ): int { 305 | if ($this->interactive) 306 | { 307 | $input = Console::prompt('Are you sure you want to delete that queue?', ['default' => 'yes']); 308 | if ($input !== 'yes') 309 | { 310 | $this->stderr(Console::ansiFormat("Aborted.\n", [Console::FG_RED])); 311 | 312 | return ExitCode::UNSPECIFIED_ERROR; 313 | } 314 | } 315 | 316 | $routing = $this->getRouting($connectionName); 317 | $routing->deleteQueue($queueName); 318 | $this->stdout(Console::ansiFormat("Queue `{$queueName}` was deleted.\n", [Console::FG_GREEN])); 319 | 320 | return ExitCode::OK; 321 | } 322 | 323 | /** 324 | * Delete all messages from the queue 325 | * 326 | * @param $queueName 327 | * @param string $connectionName 328 | * 329 | * @return int 330 | * @throws \RuntimeException 331 | */ 332 | public function actionPurgeQueue( 333 | string $queueName, 334 | string $connectionName = Configuration::DEFAULT_CONNECTION_NAME 335 | ): int { 336 | if ($this->interactive) 337 | { 338 | $input = Console::prompt( 339 | 'Are you sure you want to delete all messages inside that queue?', 340 | ['default' => 'yes'] 341 | ); 342 | if ($input !== 'yes') 343 | { 344 | $this->stderr(Console::ansiFormat("Aborted.\n", [Console::FG_RED])); 345 | 346 | return ExitCode::UNSPECIFIED_ERROR; 347 | } 348 | } 349 | 350 | $routing = $this->getRouting($connectionName); 351 | $routing->purgeQueue($queueName); 352 | $this->stdout(Console::ansiFormat("Queue `{$queueName}` was purged.\n", [Console::FG_GREEN])); 353 | 354 | return ExitCode::OK; 355 | } 356 | 357 | 358 | /** 359 | * @param string $connectionName 360 | * @return Routing|object|string 361 | * @throws \yii\base\InvalidConfigException 362 | * @throws \yii\di\NotInstantiableException 363 | */ 364 | private function getRouting(string $connectionName) 365 | { 366 | $conn = $this->rabbitmq->getConnection($connectionName); 367 | return $this->rabbitmq->getRouting($conn); 368 | } 369 | 370 | /** 371 | * Validate options passed by user 372 | * 373 | * @param Consumer $consumer 374 | */ 375 | private function validateConsumerOptions(Consumer $consumer) 376 | { 377 | if (!AMQP_WITHOUT_SIGNALS && extension_loaded('pcntl')) 378 | { 379 | if (!function_exists('pcntl_signal')) 380 | { 381 | throw new BadFunctionCallException( 382 | "Function 'pcntl_signal' is referenced in the php.ini 'disable_functions' and can't be called." 383 | ); 384 | } 385 | 386 | pcntl_signal(SIGTERM, [$consumer, 'stopDaemon']); 387 | pcntl_signal(SIGINT, [$consumer, 'stopDaemon']); 388 | pcntl_signal(SIGHUP, [$consumer, 'restartDaemon']); 389 | } 390 | 391 | $this->messagesLimit = (int)$this->messagesLimit; 392 | $this->memoryLimit = (int)$this->memoryLimit; 393 | if (!is_numeric($this->messagesLimit) || 0 > $this->messagesLimit) 394 | { 395 | throw new InvalidArgumentException('The -m option should be null or greater than 0'); 396 | } 397 | if (!is_numeric($this->memoryLimit) || 0 > $this->memoryLimit) 398 | { 399 | throw new InvalidArgumentException('The -l option should be null or greater than 0'); 400 | } 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /events/RabbitMQConsumerEvent.php: -------------------------------------------------------------------------------- 1 | rabbitmq = Yii::$app->rabbitmq; 74 | if ($this->units_dir && !is_dir($this->units_dir)) { 75 | mkdir($this->units_dir); 76 | } 77 | } 78 | 79 | public function create() 80 | { 81 | $consumers = $this->rabbitmq->consumers; 82 | foreach ($consumers as $consumer) { 83 | $description = 'Consumer ' . $consumer['name']; 84 | $memory_limit = $consumer['systemd']['memory_limit'] == 0 ? '' : '-l ' . $consumer['systemd']['memory_limit']; 85 | $workers = $consumer['systemd']['workers']; 86 | $unit = str_replace( 87 | [ 88 | '%description%', 89 | '%work_dir%', 90 | '%user%', 91 | '%group%', 92 | '%yii_path%', 93 | '%name_consumer%', 94 | '%memory_limit%' 95 | ], 96 | [ 97 | $description, 98 | $this->work_dir, 99 | $this->user, 100 | $this->group, 101 | $this->work_dir . '/yii', 102 | $consumer['name'], 103 | $memory_limit 104 | ], 105 | $this->example 106 | ); 107 | $result[] = [ 108 | 'unit' => $unit, 109 | 'workers' => $workers, 110 | 'consumer' => $consumer['name'] 111 | ]; 112 | } 113 | if (!empty($result)) { 114 | foreach ($result as $item) { 115 | for ($i = 1; $i <= $item['workers']; $i++) { 116 | $file = $this->units_dir . '/consumer_' . $item['consumer'] . '_' . $i . '.service'; 117 | file_put_contents($file, $item['unit']); 118 | } 119 | } 120 | $bash_file = $this->units_dir . '/exec.sh'; 121 | file_put_contents($bash_file, $this->bash); 122 | chmod($bash_file, 0700); 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /migrations/M201026132305RabbitPublishError.php: -------------------------------------------------------------------------------- 1 | createTable('rabbit_publish_error', [ 15 | 'id' => $this->primaryKey(), 16 | 'message' => $this->text()->notNull(), 17 | 'created_at' => $this->integer(), 18 | 'updated_at' => $this->integer(), 19 | 'options' => $this->json()->notNull(), 20 | 'error' => $this->text(), 21 | 'counter' => $this->integer() 22 | ]); 23 | } 24 | 25 | public function safeDown() 26 | { 27 | $this->dropTable('rabbit_publish_error'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /models/RabbitPublishError.php: -------------------------------------------------------------------------------- 1 | producerName) { 66 | throw new InvalidArgumentException('Field producerName is required!'); 67 | } 68 | if (!$this->msgBody) { 69 | throw new InvalidArgumentException('Field msgBody is required!'); 70 | } 71 | if (!$this->exchangeName) { 72 | throw new InvalidArgumentException('Field exchangeName is required!'); 73 | } 74 | if (!$this->errorMsg) { 75 | throw new InvalidArgumentException('Field errorMsg is required!'); 76 | } 77 | $this->message = $this->msgBody; 78 | $options = [ 79 | 'exchangeName' => $this->exchangeName, 80 | 'producerName' => $this->producerName, 81 | 'routingKey' => $this->routingKey, 82 | 'additionalProperties' => $this->additionalProperties, 83 | 'headers' => $this->headers 84 | ]; 85 | $this->options = json_encode($options); 86 | $this->error = $this->errorMsg; 87 | $this->counter = 1; 88 | if (!$this->save()) { 89 | print_r($this->errors); 90 | throw new DomainException(); 91 | } 92 | } 93 | 94 | public function rePublish() 95 | { 96 | $cache = Yii::$app->cache; 97 | $key = 'rabbit_publish_error'; 98 | if ($cache->exists($key)) { 99 | return; 100 | } 101 | $cache->set($key, true); 102 | 103 | foreach (self::find()->each() as $model) { 104 | /** @var self $model */ 105 | try { 106 | $options = json_decode($model->options, true); 107 | /** @var Producer $producer */ 108 | $producer = Yii::$app->rabbitmq->getProducer($options['producerName']); 109 | $producer->publish( 110 | $model->message, 111 | $options['exchangeName'], 112 | $options['routingKey'], 113 | $options['additionalProperties'], 114 | $options['headers'] 115 | ); 116 | $model->delete(); 117 | } catch (Exception $e) { 118 | $model->error = $e->getMessage(); 119 | $model->updateCounters(['counter' => 1]); 120 | $model->save(); 121 | } 122 | } 123 | $cache->delete($key); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./tests 18 | 19 | 20 | 21 | 22 | . 23 | 24 | ./vendor 25 | ./tests 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | expectException(UnknownPropertyException::class); 16 | $this->mockApplication(); 17 | \Yii::$app->rabbitmq->getConfig(); 18 | } 19 | 20 | public function testConfigType() 21 | { 22 | $this->mockApplication([ 23 | 'components' => [ 24 | 'rabbitmq' => [ 25 | 'class' => Configuration::class, 26 | 'connections' => [ 27 | [ 28 | 'host' => 'localhost', 29 | ], 30 | ], 31 | ], 32 | ], 33 | ]); 34 | $this->assertInstanceOf(Configuration::class, \Yii::$app->rabbitmq->getConfig()); 35 | } 36 | 37 | /** 38 | * @dataProvider invalidConfig 39 | */ 40 | public function testInvalidConfig($reason, $config, $exception) 41 | { 42 | $this->expectException($exception); 43 | $rabbitmq = array_merge(['class' => Configuration::class], $config); 44 | $components = [ 45 | 'components' => [ 46 | 'rabbitmq' => $rabbitmq, 47 | ], 48 | ]; 49 | $this->mockApplication($components); 50 | \Yii::$app->rabbitmq->getConfig(); 51 | } 52 | 53 | public function invalidConfig() 54 | { 55 | $required = ['connections' => [['host' => 'localhost']]]; 56 | 57 | return [ 58 | ['At least one connection required', ['connections' => []], InvalidConfigException::class], 59 | ['Unknown option', array_merge($required, ['unknown' => []]), UnknownPropertyException::class], 60 | ['Name should be specified on multiple connections', ['connections' => [['host' => 'rabbitmq'],['host' => 'rabbitmq']]], InvalidConfigException::class], 61 | ['Wrong auto-declare option', array_merge($required, ['auto_declare' => 43]), InvalidConfigException::class], 62 | ['Logger is not an array', array_merge($required, ['logger' => 43]), InvalidConfigException::class], 63 | ['Unknown option in logger section', array_merge($required, ['logger' => ['unknown' => 43]]), InvalidConfigException::class], 64 | ['Connections array should be multidimensional and numeric', ['connections' => ['some-key' => 'some-value']], InvalidConfigException::class], 65 | ['Bindings array should be multidimensional and numeric', ['bindings' => ['some-key' => 'some-value']], InvalidConfigException::class], 66 | ['Exchanges array should be multidimensional and numeric', ['exchanges' => ['some-key' => 'some-value']], InvalidConfigException::class], 67 | ['Queues array should be multidimensional and numeric', ['queues' => ['some-key' => 'some-value']], InvalidConfigException::class], 68 | ['Producers array should be multidimensional and numeric', ['producers' => ['some-key' => 'some-value']], InvalidConfigException::class], 69 | ['Consumers array should be multidimensional and numeric', ['consumers' => ['some-key' => 'some-value']], InvalidConfigException::class], 70 | ['Url or host is required', ['connections' => [[]]], InvalidConfigException::class], 71 | ['Not both url and host allowed', ['connections' => [['url' => 'some-value1', 'host' => 'some-value2']]], InvalidConfigException::class], 72 | ['No additional fields are allowed in connections', ['connections' => [['wrong' => 'wrong', 'host' => 'localhost']]], InvalidConfigException::class], 73 | ['Connection type should be of correct subclass', ['connections' => [['type' => 'SomeTypeUnknown', 'host' => 'localhost']]], InvalidConfigException::class], 74 | ['Exchange name is required', array_merge($required, ['exchanges' => [['type' => 'direct']]]), InvalidConfigException::class], 75 | ['Exchange type is required', array_merge($required, ['exchanges' => [['name' => 'direct']]]), InvalidConfigException::class], 76 | ['Exchange type should be one of allowed', array_merge($required, ['exchanges' => [['type' => 'wrong']]]), InvalidConfigException::class], 77 | ['Exchange wrong field', array_merge($required, ['exchanges' => [['type' => 'direct', 'name' => 'direct', 'wrong' => 'wrong']]]), InvalidConfigException::class], 78 | ['Queue wrong field', array_merge($required, ['queues' => [['wrong' => 'wrong']]]), InvalidConfigException::class], 79 | ['Exchange name is required for binding', array_merge($required, ['bindings' => [[]]]), InvalidConfigException::class], 80 | ['Routing key is required for binding', array_merge($required, ['bindings' => [['exchange' => 'smth']]]), InvalidConfigException::class], 81 | ['Either `queue` or `to_exchange` options should be specified to create binding', array_merge($required, ['bindings' => [['exchange' => 'smth', 'routing_keys' => ['smth'],]]]), InvalidConfigException::class], 82 | ['Exchanges and queues should be configured in corresponding sections', array_merge($required, ['bindings' => [['exchange' => 'smth', 'routing_keys' => ['smth'], 'queue' => 'smth',]]]), InvalidConfigException::class], 83 | ['Binding wrong field', array_merge($required, ['bindings' => [['wrong' => 'wrong']]]), InvalidConfigException::class], 84 | ['Producer wrong field', array_merge($required, ['producers' => [['wrong' => 'wrong']]]), InvalidConfigException::class], 85 | ['Producer name is required', array_merge($required, ['producers' => [[]]]), InvalidConfigException::class], 86 | ['Connection defined in producer should exist', array_merge($required, ['producers' => [['name' => 'smth', 'connection' => 'unknown']]]), InvalidConfigException::class], 87 | ['Safe option should be a boolean', array_merge($required, ['producers' => [['name' => 'smth', 'safe' => 'non_bool']]]), InvalidConfigException::class], 88 | ['Serializer should be callable', array_merge($required, ['producers' => [['name' => 'smth', 'serializer' => 'non_callable']]]), InvalidConfigException::class], 89 | ['Consumer wrong field', array_merge($required, ['consumers' => [['wrong' => 'wrong']]]), InvalidConfigException::class], 90 | ['Consumer name is required', array_merge($required, ['consumers' => [[]]]), InvalidConfigException::class], 91 | ['Connection defined in consumer should exist', array_merge($required, ['consumers' => [['name' => 'smth', 'connection' => 'unknown']]]), InvalidConfigException::class], 92 | ['No callbacks specified in consumer', array_merge($required, ['consumers' => [['name' => 'smth', 'callbacks' => []]]]), InvalidConfigException::class], 93 | ['Option qos is not an array', array_merge($required, ['consumers' => [['name' => 'smth', 'qos' => 'not_an_array']]]), InvalidConfigException::class], 94 | ['Option proceed_on_exception is not a boolean', array_merge($required, ['consumers' => [['name' => 'smth', 'proceed_on_exception' => 'not_a_boolean']]]), InvalidConfigException::class], 95 | ['Queue defined in consumer should exist', array_merge($required, ['consumers' => [['name' => 'smth', 'callbacks' => ['unknown' => 'some-callback']]]]), InvalidConfigException::class], 96 | ['Consumer callback parameter should be string', array_merge($required, ['queues' => [['name' => 'smth']], 'consumers' => [['name' => 'smth', 'callbacks' => ['smth' => 34]]]]), InvalidConfigException::class], 97 | ['Deserializer should be callable', array_merge($required, ['consumers' => [['name' => 'smth', 'deserializer' => 'non_callable']]]), InvalidConfigException::class], 98 | ['Connection in consumer not exist', ['connections' => [['host' => 'rabbitmq']], 'consumers' => [['name' => 'smth', 'connection' => 'default2']]], InvalidConfigException::class], 99 | ['Named connection not specified in producer', ['connections' => [['host' => 'rabbitmq', 'name' => 'default2']], 'producers' => [['name' => 'smth']]], InvalidConfigException::class], 100 | ['Duplicate names in producer', array_merge($required, ['producers' => [['name' => 'smth'], ['name' => 'smth']]]), InvalidConfigException::class], 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/DependencyInjectionTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(ConsumerInterface::class) 20 | ->setMockClassName($callbackName) 21 | ->getMock(); 22 | $this->loadExtension([ 23 | 'components' => [ 24 | 'rabbitmq' => [ 25 | 'class' => Configuration::class, 26 | 'connections' => [ 27 | [ 28 | 'name' => $name, 29 | 'url' => 'amqp://user:pass@host:5432/vhost?query', 30 | ], 31 | ], 32 | 'exchanges' => [ 33 | [ 34 | 'name' => $name, 35 | 'type' => 'direct' 36 | ], 37 | ], 38 | 'queues' => [ 39 | [ 40 | 'name' => $name, 41 | 'durable' => true, 42 | ], 43 | ], 44 | 'bindings' => [ 45 | [ 46 | 'queue' => $name, 47 | 'exchange' => $name, 48 | 'routing_keys' => [$name], 49 | ], 50 | ], 51 | 'producers' => [ 52 | [ 53 | 'name' => $name, 54 | 'connection' => $name, 55 | ], 56 | ], 57 | 'consumers' => [ 58 | [ 59 | 'name' => $name, 60 | 'connection' => $name, 61 | 'callbacks' => [ 62 | $name => $callbackName, 63 | ], 64 | ], 65 | ], 66 | ], 67 | ], 68 | ]); 69 | $container = \Yii::$container; 70 | $connection = $container->get(sprintf(Configuration::CONNECTION_SERVICE_NAME, $name)); 71 | $this->assertInstanceOf(AbstractConnection::class, $connection); 72 | $this->assertInstanceOf(Routing::class, $container->get(Configuration::ROUTING_SERVICE_NAME, ['conn' => $connection])); 73 | $this->assertInstanceOf(Producer::class, $container->get(sprintf(Configuration::PRODUCER_SERVICE_NAME, $name))); 74 | $this->assertInstanceOf(Consumer::class, $container->get(sprintf(Configuration::CONSUMER_SERVICE_NAME, $name))); 75 | $this->assertInstanceOf(Logger::class, $container->get(Configuration::LOGGER_SERVICE_NAME)); 76 | $this->assertSame(\Yii::$app->controllerMap[Configuration::EXTENSION_CONTROLLER_ALIAS], RabbitMQController::class); 77 | } 78 | 79 | public function testBootstrapEmpty() 80 | { 81 | $this->loadExtension([ 82 | 'components' => [ 83 | 'rabbitmq' => [ 84 | 'class' => Configuration::class, 85 | 'connections' => [ 86 | [ 87 | 'host' => 'somehost', 88 | ], 89 | ], 90 | ], 91 | ], 92 | ]); 93 | $container = \Yii::$container; 94 | $conn = $container->get(sprintf(Configuration::CONNECTION_SERVICE_NAME, Configuration::DEFAULT_CONNECTION_NAME)); 95 | $this->assertInstanceOf(AbstractConnection::class, $conn); 96 | $router = $container->get(Configuration::ROUTING_SERVICE_NAME, ['conn' => $conn]); 97 | // Declare nothing as nothing was configured 98 | $this->assertTrue($router->declareAll($conn)); 99 | } 100 | 101 | public function testBootstrapWrongUrl() 102 | { 103 | $this->loadExtension([ 104 | 'components' => [ 105 | 'rabbitmq' => [ 106 | 'class' => Configuration::class, 107 | 'connections' => [ 108 | [ 109 | 'url' => 'https://www.rabbitmq.com/uri-spec.html', 110 | ], 111 | ], 112 | ], 113 | ], 114 | ]); 115 | $this->expectException(\InvalidArgumentException::class); 116 | \Yii::$app->rabbitmq->getConnection(); 117 | } 118 | 119 | public function testBootstrapProducer() 120 | { 121 | $producerName = 'smth'; 122 | $contentType = 'non-existing'; 123 | $deliveryMode = 432; 124 | $serializer = 'json_encode'; 125 | $safe = false; 126 | $this->loadExtension([ 127 | 'components' => [ 128 | 'rabbitmq' => [ 129 | 'class' => Configuration::class, 130 | 'connections' => [ 131 | [ 132 | 'host' => 'unreal', 133 | ], 134 | ], 135 | 'producers' => [ 136 | [ 137 | 'name' => $producerName, 138 | 'content_type' => $contentType, 139 | 'delivery_mode' => $deliveryMode, 140 | 'safe' => $safe, 141 | 'serializer' => $serializer, 142 | ] 143 | ], 144 | ], 145 | ], 146 | ]); 147 | // Test producer setter injection 148 | $producer = \Yii::$container->get(sprintf(Configuration::PRODUCER_SERVICE_NAME, $producerName)); 149 | $props = $producer->getBasicProperties(); 150 | $this->assertSame($producerName, $producer->getName()); 151 | $this->assertSame($safe, $producer->getSafe()); 152 | $this->assertSame($contentType, $props['content_type']); 153 | $this->assertSame($deliveryMode, $props['delivery_mode']); 154 | $this->assertSame($serializer, $producer->getSerializer()); 155 | } 156 | 157 | public function testBootstrapConsumer() 158 | { 159 | $consumerName = 'smth'; 160 | $queueName = 'non-existing'; 161 | $callbackName = 'CallbackMock'; 162 | $callback = $this->getMockBuilder(ConsumerInterface::class) 163 | ->setMockClassName($callbackName) 164 | ->setMethods(['execute']) 165 | ->getMock(); 166 | $deserializer = 'json_decode'; 167 | $qos = [ 168 | 'prefetch_size' => 11, 169 | 'prefetch_count' => 11, 170 | 'global' => true, 171 | ]; 172 | $idleTimeout = 100; 173 | $idleTimeoutExitCode = 101; 174 | $proceedOnException = true; 175 | $this->loadExtension([ 176 | 'components' => [ 177 | 'rabbitmq' => [ 178 | 'class' => Configuration::class, 179 | 'connections' => [ 180 | [ 181 | 'host' => 'unreal', 182 | ], 183 | ], 184 | 'queues' => [ 185 | [ 186 | 'name' => $queueName, 187 | ] 188 | ], 189 | 'consumers' => [ 190 | [ 191 | 'name' => $consumerName, 192 | 'callbacks' => [ 193 | $queueName => $callbackName, 194 | ], 195 | 'qos' => $qos, 196 | 'idle_timeout' => $idleTimeout, 197 | 'idle_timeout_exit_code' => $idleTimeoutExitCode, 198 | 'proceed_on_exception' => $proceedOnException, 199 | 'deserializer' => $deserializer, 200 | ] 201 | ], 202 | ], 203 | ], 204 | ]); 205 | $consumer = \Yii::$container->get(sprintf(Configuration::CONSUMER_SERVICE_NAME, $consumerName)); 206 | $this->assertSame($consumerName, $consumer->getName()); 207 | $this->assertSame(array_keys([$queueName => $callback,]), array_keys($consumer->getQueues())); 208 | $this->assertSame($qos, $consumer->getQos()); 209 | $this->assertSame($idleTimeout, $consumer->getIdleTimeout()); 210 | $this->assertSame($idleTimeoutExitCode, $consumer->getIdleTimeoutExitCode()); 211 | $this->assertSame($deserializer, $consumer->getDeserializer()); 212 | $this->assertSame($proceedOnException, $consumer->getProceedOnException()); 213 | } 214 | 215 | public function testBootstrapLogger() 216 | { 217 | $options = [ 218 | 'log' => true, 219 | 'category' => 'some', 220 | 'print_console' => false, 221 | 'system_memory' => true, 222 | ]; 223 | $this->loadExtension([ 224 | 'components' => [ 225 | 'rabbitmq' => [ 226 | 'class' => Configuration::class, 227 | 'connections' => [ 228 | [ 229 | 'host' => 'unreal', 230 | ], 231 | ], 232 | 'logger' => $options, 233 | ], 234 | ], 235 | ]); 236 | $logger = \Yii::$container->get(Configuration::LOGGER_SERVICE_NAME); 237 | $this->assertSame($options, $logger->options); 238 | } 239 | 240 | public function testValidateCallbackInterface() 241 | { 242 | $callbackAlias = 'callback_mock'; 243 | $callbackName = 'WrongCallbackMock'; 244 | $queueName = 'queue'; 245 | $consumerName = 'consumer'; 246 | // not implementing interface 247 | $this 248 | ->getMockBuilder(\AnotherInterface::class) 249 | ->setMockClassName($callbackName) 250 | ->disableOriginalConstructor() 251 | ->getMock(); 252 | \Yii::$container->setSingleton($callbackAlias, ['class' => $callbackName]); 253 | $this->loadExtension([ 254 | 'components' => [ 255 | 'rabbitmq' => [ 256 | 'class' => Configuration::class, 257 | 'connections' => [ 258 | [ 259 | 'host' => 'unreal', 260 | ], 261 | ], 262 | 'queues' => [ 263 | [ 264 | 'name' => $queueName, 265 | ] 266 | ], 267 | 'consumers' => [ 268 | [ 269 | 'name' => $consumerName, 270 | 'callbacks' => [ 271 | $queueName => $callbackAlias, 272 | ], 273 | ] 274 | ], 275 | ], 276 | ], 277 | ]); 278 | $this->expectException(InvalidConfigException::class); 279 | \Yii::$app->rabbitmq->getConsumer($consumerName); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | destroyApplication(); 21 | } 22 | 23 | /** 24 | * Populates Yii::$app with a new application 25 | * The application will be destroyed on tearDown() automatically. 26 | * @param array $config The application configuration, if needed 27 | * @param string $appClass name of the application class to create 28 | */ 29 | protected function mockApplication($config = [], $appClass = '\yii\console\Application') 30 | { 31 | new $appClass(ArrayHelper::merge([ 32 | 'id' => 'testapp', 33 | 'basePath' => __DIR__, 34 | 'vendorPath' => $this->getVendorPath(), 35 | ], $config)); 36 | } 37 | 38 | protected function getVendorPath() 39 | { 40 | $vendor = dirname(__DIR__, 2) . '/vendor'; 41 | if (!is_dir($vendor)) { 42 | $vendor = dirname(__DIR__, 4); 43 | } 44 | return $vendor; 45 | } 46 | 47 | /** 48 | * Destroys application in Yii::$app by setting it to null. 49 | */ 50 | protected function destroyApplication() 51 | { 52 | if (\Yii::$app && \Yii::$app->has('session', true)) { 53 | \Yii::$app->session->close(); 54 | } 55 | \Yii::$app = null; 56 | } 57 | 58 | /** 59 | * Invokes a inaccessible method. 60 | * @param $object 61 | * @param $method 62 | * @param array $args 63 | * @param bool $revoke whether to make method inaccessible after execution 64 | * @return mixed 65 | * @since 2.0.11 66 | */ 67 | protected function invokeMethod($object, $method, $args = [], $revoke = true) 68 | { 69 | $reflection = new \ReflectionObject($object); 70 | $method = $reflection->getMethod($method); 71 | $method->setAccessible(true); 72 | $result = $method->invokeArgs($object, $args); 73 | if ($revoke) { 74 | $method->setAccessible(false); 75 | } 76 | return $result; 77 | } 78 | 79 | /** 80 | * Sets an inaccessible object property to a designated value. 81 | * @param $object 82 | * @param $propertyName 83 | * @param $value 84 | * @param bool $revoke whether to make property inaccessible after setting 85 | * @since 2.0.11 86 | */ 87 | protected function setInaccessibleProperty($object, $propertyName, $value, $revoke = true) 88 | { 89 | $class = new \ReflectionClass($object); 90 | while (!$class->hasProperty($propertyName)) { 91 | $class = $class->getParentClass(); 92 | } 93 | $property = $class->getProperty($propertyName); 94 | $property->setAccessible(true); 95 | $property->setValue($object, $value); 96 | if ($revoke) { 97 | $property->setAccessible(false); 98 | } 99 | } 100 | 101 | /** 102 | * Gets an inaccessible object property. 103 | * @param $object 104 | * @param $propertyName 105 | * @param bool $revoke whether to make property inaccessible after getting 106 | * @return mixed 107 | */ 108 | protected function getInaccessibleProperty($object, $propertyName, $revoke = true) 109 | { 110 | $class = new \ReflectionClass($object); 111 | while (!$class->hasProperty($propertyName)) { 112 | $class = $class->getParentClass(); 113 | } 114 | $property = $class->getProperty($propertyName); 115 | $property->setAccessible(true); 116 | $result = $property->getValue($object); 117 | if ($revoke) { 118 | $property->setAccessible(false); 119 | } 120 | return $result; 121 | } 122 | 123 | /** 124 | * Load extension to test app instance 125 | * @param array $config 126 | * @throws \mikemadisonweb\rabbitmq\exceptions\InvalidConfigException 127 | */ 128 | protected function loadExtension(array $config) 129 | { 130 | $this->mockApplication($config); 131 | $di = new DependencyInjection(); 132 | $di->bootstrap(\Yii::$app); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 'test', 15 | 'url' => null, 16 | 'host' => null, 17 | 'port' => 5672, 18 | 'user' => 'guest', 19 | 'password' => 'guest', 20 | 'vhost' => '/', 21 | 'connection_timeout' => 3, 22 | 'read_write_timeout' => 3, 23 | 'ssl_context' => null, 24 | 'keepalive' => false, 25 | 'heartbeat' => 0, 26 | 'channel_rpc_timeout' => 0.0, 27 | ]; 28 | 29 | $factory = new AbstractConnectionFactory(AMQPLazyConnection::class, $testOptions); 30 | $connection = $factory->createConnection(); 31 | $this->assertInstanceOf(AbstractConnection::class, $connection); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/components/ConsumerTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(AMQPLazyConnection::class) 27 | ->disableOriginalConstructor() 28 | ->setMethods(['channel']) 29 | ->getMock(); 30 | $channel = $this->getMockBuilder(AMQPChannel::class) 31 | ->disableOriginalConstructor() 32 | ->getMock(); 33 | $connection->method('channel') 34 | ->willReturn($channel); 35 | $routing = $this->createMock(Routing::class); 36 | $routing->expects($this->once()) 37 | ->method('declareAll'); 38 | $logger = \Yii::$container->get(Configuration::LOGGER_SERVICE_NAME); 39 | $consumer = new Consumer($connection, $routing, $logger, true); 40 | if (!empty($queues)) { 41 | $consumer->setQueues($queues); 42 | } 43 | $channel 44 | ->expects($consumeCount) 45 | ->method('basic_consume'); 46 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $consumer->consume()); 47 | } 48 | 49 | public function checkConsume() : array 50 | { 51 | return [ 52 | [[], $this->never()], 53 | [['queue' => 'callback'], $this->once()], 54 | [['queue1' => 'callback1', 'queue2' => 'callback2', 'queue3' => 'callback3'], $this->exactly(3)], 55 | ]; 56 | } 57 | 58 | public function testNoAutoDeclare() 59 | { 60 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 61 | ->disableOriginalConstructor() 62 | ->setMethods(['channel']) 63 | ->getMock(); 64 | $channel = $this->getMockBuilder(AMQPChannel::class) 65 | ->disableOriginalConstructor() 66 | ->getMock(); 67 | $connection->method('channel') 68 | ->willReturn($channel); 69 | $routing = $this->createMock(Routing::class); 70 | $routing->expects($this->never()) 71 | ->method('declareAll'); 72 | $logger = \Yii::$container->get(Configuration::LOGGER_SERVICE_NAME); 73 | $consumer = new Consumer($connection, $routing, $logger, false); 74 | $consumer->setQos(['prefetch_size' => 0, 'prefetch_count' => 0, 'global' => false]); 75 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $consumer->consume()); 76 | } 77 | 78 | public function testConsumeEvents() 79 | { 80 | $queue = 'test-queue'; 81 | $msgBody = 'Test message!'; 82 | $consumerName = 'test'; 83 | $callbackName = 'MockCallback'; 84 | $callback = $this->getMockBuilder(ConsumerInterface::class) 85 | ->setMockClassName($callbackName) 86 | ->getMock(); 87 | $this->loadExtension([ 88 | 'components' => [ 89 | 'rabbitmq' => [ 90 | 'class' => Configuration::class, 91 | 'connections' => [ 92 | [ 93 | 'host' => 'unreal', 94 | ], 95 | ], 96 | 'queues' => [ 97 | [ 98 | 'name' => $queue, 99 | ], 100 | ], 101 | 'consumers' => [ 102 | [ 103 | 'name' => $consumerName, 104 | 'callbacks' => [$queue => $callbackName], 105 | ] 106 | ], 107 | 'on before_consume' => function ($event) use ($msgBody) { 108 | $this->assertInstanceOf(RabbitMQConsumerEvent::class, $event); 109 | $this->assertSame($msgBody, $event->message->getBody()); 110 | }, 111 | 'on after_consume' => function ($event) use ($msgBody) { 112 | $this->assertInstanceOf(RabbitMQConsumerEvent::class, $event); 113 | $this->assertSame($msgBody, $event->message->getBody()); 114 | }, 115 | ], 116 | ], 117 | ]); 118 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 119 | ->disableOriginalConstructor() 120 | ->setMethods(['channel']) 121 | ->getMock(); 122 | $channel = $this->getMockBuilder(AMQPChannel::class) 123 | ->disableOriginalConstructor() 124 | ->getMock(); 125 | $connection->method('channel') 126 | ->willReturn($channel); 127 | $logger = $this->createMock(Logger::class); 128 | $routing = $this->createMock(Routing::class); 129 | $consumer = \Yii::$app->rabbitmq->getConsumer($consumerName); 130 | $this->setInaccessibleProperty($consumer, 'routing', $routing); 131 | $this->setInaccessibleProperty($consumer, 'conn', $connection); 132 | $this->setInaccessibleProperty($consumer, 'logger', $logger); 133 | $msg = new AMQPMessage($msgBody); 134 | $this->invokeMethod($consumer, 'onReceive', [$msg, $queue, [$callback, 'execute']]); 135 | } 136 | 137 | public function testOnReceive() 138 | { 139 | $queue = 'test-queue'; 140 | $this->loadExtension([ 141 | 'components' => [ 142 | 'rabbitmq' => [ 143 | 'class' => Configuration::class, 144 | 'connections' => [ 145 | [ 146 | 'host' => 'unreal', 147 | ], 148 | ], 149 | ], 150 | ], 151 | ]); 152 | $callback = $this->createMock(ConsumerInterface::class); 153 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 154 | ->disableOriginalConstructor() 155 | ->setMethods(['channel']) 156 | ->getMock(); 157 | $channel = $this->getMockBuilder(AMQPChannel::class) 158 | ->disableOriginalConstructor() 159 | ->getMock(); 160 | $connection->method('channel') 161 | ->willReturn($channel); 162 | $routing = $this->createMock(Routing::class); 163 | $routing->expects($this->never()) 164 | ->method('declareAll'); 165 | $logger = $this->createMock(Logger::class); 166 | $consumer = $this->getMockBuilder(Consumer::class) 167 | ->setConstructorArgs([$connection, $routing, $logger, false]) 168 | ->setMethods(['sendResult']) 169 | ->getMock(); 170 | $msgBody = 'Test message'; 171 | $consumer->method('sendResult') 172 | ->willThrowException(new \Exception($msgBody)); 173 | $msg = new AMQPMessage($msgBody); 174 | // No exception should be thrown 175 | $consumer->setProceedOnException(true); 176 | $before = $consumer->getConsumed(); 177 | $this->assertTrue($this->invokeMethod($consumer, 'onReceive', [$msg, $queue, [$callback, 'execute']])); 178 | $this->assertSame($before + 1, $consumer->getConsumed()); 179 | // Exception should be thrown 180 | $consumer->setProceedOnException(false); 181 | $this->expectExceptionMessage($msgBody); 182 | $callback->expects($this->once()) 183 | ->method('execute'); 184 | $logger->expects($this->once()) 185 | ->method('logError'); 186 | $this->invokeMethod($consumer, 'onReceive', [$msg, $queue, [$callback, 'execute']]); 187 | } 188 | 189 | /** 190 | * @dataProvider checkMsgTypes 191 | * @param $userData 192 | */ 193 | public function testOnReceiveDifferentTypes($userData) 194 | { 195 | $queue = 'test-queue'; 196 | $this->loadExtension([ 197 | 'components' => [ 198 | 'rabbitmq' => [ 199 | 'class' => Configuration::class, 200 | 'connections' => [ 201 | [ 202 | 'host' => 'unreal', 203 | ], 204 | ], 205 | ], 206 | ], 207 | ]); 208 | $callback = $this->createMock(ConsumerInterface::class); 209 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 210 | ->disableOriginalConstructor() 211 | ->setMethods(['channel']) 212 | ->getMock(); 213 | $channel = $this->getMockBuilder(AMQPChannel::class) 214 | ->disableOriginalConstructor() 215 | ->getMock(); 216 | $connection->method('channel') 217 | ->willReturn($channel); 218 | $routing = $this->createMock(Routing::class); 219 | $routing->expects($this->never()) 220 | ->method('declareAll'); 221 | $logger = $this->createMock(Logger::class); 222 | $consumer = $this->getMockBuilder(Consumer::class) 223 | ->setConstructorArgs([$connection, $routing, $logger, false]) 224 | ->setMethods(['sendResult']) 225 | ->getMock(); 226 | $consumer->setDeserializer('json_decode'); 227 | $msgBody = json_encode($userData); 228 | $msg = new AMQPMessage($msgBody); 229 | $headers['rabbitmq.serialized'] = 1; 230 | $headersTable = new AMQPTable($headers); 231 | $msg->set('application_headers', $headersTable); 232 | $this->invokeMethod($consumer, 'onReceive', [$msg, $queue, [$callback, 'execute']]); 233 | $this->assertEquals($userData, $msg->getBody()); 234 | } 235 | 236 | public function checkMsgTypes() : array 237 | { 238 | return [ 239 | ['String!'], 240 | [['array']], 241 | [1], 242 | [1.1], 243 | [null], 244 | [new \StdClass()], 245 | ]; 246 | } 247 | 248 | public function testForceStop() 249 | { 250 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 251 | ->disableOriginalConstructor() 252 | ->setMethods(['channel']) 253 | ->getMock(); 254 | $channel = $this->getMockBuilder(AMQPChannel::class) 255 | ->disableOriginalConstructor() 256 | ->getMock(); 257 | $channel->expects($this->once()) 258 | ->method('basic_cancel'); 259 | $connection->method('channel') 260 | ->willReturn($channel); 261 | $routing = $this->createMock(Routing::class); 262 | $routing->expects($this->never()) 263 | ->method('declareAll'); 264 | $logger = $this->createMock(Logger::class); 265 | $consumer = $this->getMockBuilder(Consumer::class) 266 | ->setConstructorArgs([$connection, $routing, $logger, false]) 267 | ->setMethods(['maybeStopConsumer']) 268 | ->getMock(); 269 | $consumer->setQueues(['queue' => 'callback']); 270 | $consumer->stopDaemon(); 271 | } 272 | 273 | public function testForceRestart() 274 | { 275 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 276 | ->disableOriginalConstructor() 277 | ->setMethods(['channel']) 278 | ->getMock(); 279 | $channel = $this->getMockBuilder(AMQPChannel::class) 280 | ->disableOriginalConstructor() 281 | ->getMock(); 282 | $connection->method('channel') 283 | ->willReturn($channel); 284 | $routing = $this->createMock(Routing::class); 285 | $logger = $this->createMock(Logger::class); 286 | $consumer = $this->getMockBuilder(Consumer::class) 287 | ->setConstructorArgs([$connection, $routing, $logger, false]) 288 | ->setMethods(['stopConsuming', 'renew', 'setup']) 289 | ->getMock(); 290 | $consumer->expects($this->once()) 291 | ->method('stopConsuming'); 292 | $consumer->expects($this->once()) 293 | ->method('renew'); 294 | $consumer->expects($this->once()) 295 | ->method('setup'); 296 | $consumer->setQueues(['queue' => 'callback']); 297 | $consumer->restartDaemon(); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /tests/components/ProducerTest.php: -------------------------------------------------------------------------------- 1 | loadExtension([ 23 | 'components' => [ 24 | 'rabbitmq' => [ 25 | 'class' => Configuration::class, 26 | 'connections' => [ 27 | [ 28 | 'host' => 'unreal', 29 | ], 30 | ], 31 | ], 32 | ], 33 | ]); 34 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 35 | ->disableOriginalConstructor() 36 | ->setMethods(['channel']) 37 | ->getMock(); 38 | $channel = $this->getMockBuilder(AMQPChannel::class) 39 | ->disableOriginalConstructor() 40 | ->getMock(); 41 | $channel->expects($this->once()) 42 | ->method('basic_publish'); 43 | $connection 44 | ->method('channel') 45 | ->willReturn($channel); 46 | $routing = $this->createMock(Routing::class); 47 | $routing->expects($this->exactly(2)) 48 | ->method('declareAll'); 49 | $routing->expects($this->exactly(2)) 50 | ->method('isExchangeExists') 51 | ->willReturnOnConsecutiveCalls(true, false); 52 | $logger = $this->createMock(Logger::class); 53 | $producer = new Producer($connection, $routing, $logger, true); 54 | $producer->setSafe(true); 55 | // Good attempt 56 | $producer->publish('Test message', 'exist'); 57 | // Non-existing exchange 58 | $this->expectException(RuntimeException::class); 59 | $producer->publish('Test message', 'not-exist'); 60 | } 61 | 62 | /** 63 | * Test events 64 | */ 65 | public function testPublishEvents() 66 | { 67 | $producerName = 'test'; 68 | $msg = 'Some-message'; 69 | $this->loadExtension([ 70 | 'components' => [ 71 | 'rabbitmq' => [ 72 | 'class' => Configuration::class, 73 | 'connections' => [ 74 | [ 75 | 'host' => 'unreal', 76 | ], 77 | ], 78 | 'producers' => [ 79 | [ 80 | 'name' => $producerName, 81 | ] 82 | ], 83 | 'on before_publish' => function ($event) use ($msg) { 84 | $this->assertInstanceOf(RabbitMQPublisherEvent::class, $event); 85 | $this->assertSame($msg, $event->message->getBody()); 86 | }, 87 | 'on after_publish' => function ($event) use ($msg) { 88 | $this->assertInstanceOf(RabbitMQPublisherEvent::class, $event); 89 | $this->assertSame($msg, $event->message->getBody()); 90 | }, 91 | ], 92 | ], 93 | ]); 94 | $producer = \Yii::$app->rabbitmq->getProducer($producerName); 95 | $routing = $this->createMock(Routing::class); 96 | $routing->method('declareAll'); 97 | $routing->method('isExchangeExists') 98 | ->willReturn(true); 99 | $this->setInaccessibleProperty($producer, 'routing', $routing); 100 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 101 | ->disableOriginalConstructor() 102 | ->setMethods(['channel']) 103 | ->getMock(); 104 | $channel = $this->getMockBuilder(AMQPChannel::class) 105 | ->disableOriginalConstructor() 106 | ->getMock(); 107 | $channel->expects($this->once()) 108 | ->method('basic_publish'); 109 | $connection 110 | ->method('channel') 111 | ->willReturn($channel); 112 | $this->setInaccessibleProperty($producer, 'conn', $connection); 113 | $producer->publish($msg, 'exchange'); 114 | } 115 | 116 | /** 117 | * Test inside framework with different message types 118 | * @dataProvider checkMsgEncoding 119 | * @param $initial 120 | * @param $encoded 121 | */ 122 | public function testPublishDifferentTypes($initial, $encoded) 123 | { 124 | $producerName = 'test'; 125 | $this->loadExtension([ 126 | 'components' => [ 127 | 'rabbitmq' => [ 128 | 'class' => Configuration::class, 129 | 'connections' => [ 130 | [ 131 | 'host' => 'unreal', 132 | ], 133 | ], 134 | 'producers' => [ 135 | [ 136 | 'name' => $producerName, 137 | ] 138 | ], 139 | 'on after_publish' => function ($event) use ($encoded) { 140 | $this->assertSame($encoded, $event->message->getBody()); 141 | }, 142 | ], 143 | ], 144 | ]); 145 | $producer = \Yii::$app->rabbitmq->getProducer($producerName); 146 | $routing = $this->createMock(Routing::class); 147 | $routing->method('declareAll'); 148 | $routing->method('isExchangeExists') 149 | ->willReturn(true); 150 | $this->setInaccessibleProperty($producer, 'routing', $routing); 151 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 152 | ->disableOriginalConstructor() 153 | ->setMethods(['channel']) 154 | ->getMock(); 155 | $channel = $this->getMockBuilder(AMQPChannel::class) 156 | ->disableOriginalConstructor() 157 | ->getMock(); 158 | $channel->expects($this->once()) 159 | ->method('basic_publish'); 160 | $connection 161 | ->method('channel') 162 | ->willReturn($channel); 163 | $this->setInaccessibleProperty($producer, 'conn', $connection); 164 | $producer->publish($initial, 'exchange'); 165 | } 166 | 167 | public function checkMsgEncoding() : array 168 | { 169 | return [ 170 | ['String!', 'String!'], 171 | [['array'], 'a:1:{i:0;s:5:"array";}'], 172 | [1, 'i:1;'], 173 | [null, 'N;'], 174 | [new \StdClass(), 'O:8:"stdClass":0:{}'], 175 | ]; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /tests/components/RoutingTest.php: -------------------------------------------------------------------------------- 1 | loadExtension([ 18 | 'components' => [ 19 | 'rabbitmq' => [ 20 | 'class' => Configuration::class, 21 | 'connections' => [ 22 | [ 23 | 'name' => $name, 24 | 'url' => 'amqp://user:pass@host:5432/vhost?query', 25 | ], 26 | ], 27 | 'exchanges' => [ 28 | [ 29 | 'name' => $name, 30 | 'type' => 'direct' 31 | ], 32 | ], 33 | 'queues' => [ 34 | [ 35 | 'name' => $name, 36 | 'durable' => true, 37 | ], 38 | [ 39 | 'durable' => false, 40 | ], 41 | ], 42 | 'bindings' => [ 43 | [ 44 | 'queue' => $name, 45 | 'exchange' => $name, 46 | 'routing_keys' => [$name], 47 | ], 48 | [ 49 | 'exchange' => $name, 50 | 'to_exchange' => $name, 51 | 'routing_keys' => [$name], 52 | ], 53 | [ 54 | 'queue' => $name, 55 | 'exchange' => $name, 56 | ], 57 | [ 58 | 'exchange' => $name, 59 | 'to_exchange' => $name, 60 | ], 61 | [ 62 | 'queue' => '', 63 | 'exchange' => $name, 64 | ], 65 | ], 66 | ], 67 | ], 68 | ]); 69 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 70 | ->disableOriginalConstructor() 71 | ->setMethods(['channel']) 72 | ->getMock(); 73 | $channel = $this->getMockBuilder(AMQPChannel::class) 74 | ->disableOriginalConstructor() 75 | ->getMock(); 76 | $connection->method('channel') 77 | ->willReturn($channel); 78 | $routing = \Yii::$app->rabbitmq->getRouting($connection); 79 | $this->assertTrue($routing->declareAll()); 80 | $this->assertFalse($routing->declareAll()); 81 | } 82 | 83 | /** 84 | * @dataProvider checkExceptions 85 | * @param $functionName 86 | */ 87 | public function testRoutingExceptions($functionName) 88 | { 89 | $this->loadExtension([ 90 | 'components' => [ 91 | 'rabbitmq' => [ 92 | 'class' => Configuration::class, 93 | 'connections' => [ 94 | [ 95 | 'url' => 'amqp://user:pass@host:5432/vhost?query', 96 | ], 97 | ], 98 | ], 99 | ], 100 | ]); 101 | $connection = \Yii::$app->rabbitmq->getConnection(); 102 | $routing = \Yii::$app->rabbitmq->getRouting($connection); 103 | $this->expectException(RuntimeException::class); 104 | $routing->$functionName('non-existing'); 105 | } 106 | 107 | /** 108 | * @return array 109 | */ 110 | public function checkExceptions() : array 111 | { 112 | return [ 113 | ['declareQueue'], 114 | ['declareExchange'], 115 | ['purgeQueue'], 116 | ['deleteQueue'], 117 | ['deleteExchange'], 118 | ]; 119 | } 120 | 121 | public function testRoutingNonExisting() 122 | { 123 | $this->loadExtension([ 124 | 'components' => [ 125 | 'rabbitmq' => [ 126 | 'class' => Configuration::class, 127 | 'connections' => [ 128 | [ 129 | 'url' => 'amqp://user:pass@host:5432/vhost?query', 130 | ], 131 | ], 132 | ], 133 | ], 134 | ]); 135 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 136 | ->disableOriginalConstructor() 137 | ->setMethods(['channel']) 138 | ->getMock(); 139 | $channel = $this->getMockBuilder(AMQPChannel::class) 140 | ->disableOriginalConstructor() 141 | ->getMock(); 142 | $exception = $this->createMock(AMQPProtocolChannelException::class); 143 | $channel 144 | ->expects($this->once()) 145 | ->method('exchange_declare') 146 | ->willThrowException($exception); 147 | $channel 148 | ->expects($this->once()) 149 | ->method('queue_declare') 150 | ->willThrowException($exception); 151 | $connection->method('channel') 152 | ->willReturn($channel); 153 | $routing = \Yii::$app->rabbitmq->getRouting($connection); 154 | $this->assertFalse($routing->isExchangeExists('non-existing')); 155 | $this->assertFalse($routing->isQueueExists('non-existing')); 156 | } 157 | 158 | public function testRoutingExisting() 159 | { 160 | $name = 'test'; 161 | $this->loadExtension([ 162 | 'components' => [ 163 | 'rabbitmq' => [ 164 | 'class' => Configuration::class, 165 | 'connections' => [ 166 | [ 167 | 'url' => 'amqp://user:pass@host:5432/vhost?query', 168 | ], 169 | ], 170 | 'exchanges' => [ 171 | [ 172 | 'name' => $name, 173 | 'type' => 'direct' 174 | ], 175 | ], 176 | 'queues' => [ 177 | [ 178 | 'name' => $name, 179 | 'durable' => true, 180 | ], 181 | [ 182 | 'durable' => false, 183 | ], 184 | ], 185 | ], 186 | ], 187 | ]); 188 | $connection = $this->getMockBuilder(AMQPLazyConnection::class) 189 | ->disableOriginalConstructor() 190 | ->setMethods(['channel']) 191 | ->getMock(); 192 | $channel = $this->getMockBuilder(AMQPChannel::class) 193 | ->disableOriginalConstructor() 194 | ->getMock(); 195 | $channel 196 | ->expects($this->once()) 197 | ->method('exchange_declare'); 198 | $channel 199 | ->expects($this->once()) 200 | ->method('queue_declare'); 201 | $channel 202 | ->expects($this->once()) 203 | ->method('queue_purge'); 204 | $connection->method('channel') 205 | ->willReturn($channel); 206 | $routing = \Yii::$app->rabbitmq->getRouting($connection); 207 | $this->assertTrue($routing->isExchangeExists($name)); 208 | $this->assertTrue($routing->isQueueExists($name)); 209 | // Test purging queue 210 | $routing->purgeQueue($name); 211 | // Test deleting all schema 212 | $channel 213 | ->expects($this->exactly(2)) 214 | ->method('queue_delete'); 215 | $channel 216 | ->expects($this->once()) 217 | ->method('exchange_delete'); 218 | $routing->deleteAll(); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /tests/controllers/RabbitMQControllerTest.php: -------------------------------------------------------------------------------- 1 | loadExtension( 21 | [ 22 | 'components' => [ 23 | 'rabbitmq' => [ 24 | 'class' => Configuration::class, 25 | 'connections' => [ 26 | [ 27 | 'host' => 'unreal', 28 | ], 29 | ], 30 | ], 31 | ], 32 | ] 33 | ); 34 | $this->controller = $this->getMockBuilder(RabbitMQController::class) 35 | ->setConstructorArgs([Configuration::EXTENSION_CONTROLLER_ALIAS, \Yii::$app]) 36 | ->setMethods(['stderr', 'stdout']) 37 | ->getMock(); 38 | } 39 | 40 | public function testConsumeAction() 41 | { 42 | // Check console flags existence 43 | $this->assertTrue(isset($this->controller->optionAliases()['l'])); 44 | $this->assertTrue(isset($this->controller->optionAliases()['m'])); 45 | $this->assertSame('messagesLimit', $this->controller->optionAliases()['m']); 46 | $this->assertSame('memoryLimit', $this->controller->optionAliases()['l']); 47 | $this->assertTrue(in_array('messagesLimit', $this->controller->options('consume'), true)); 48 | $this->assertTrue(in_array('memoryLimit', $this->controller->options('consume'), true)); 49 | 50 | // Invalid consumer name 51 | $this->expectException(InvalidConfigException::class); 52 | $response = $this->controller->runAction('consume', ['unknown']); 53 | $this->assertSame(Controller::EXIT_CODE_ERROR, $response); 54 | 55 | // Valid consumer name 56 | $name = 'valid'; 57 | $consumer = $this->getMockBuilder(Consumer::class) 58 | ->disableOriginalConstructor() 59 | ->setMethods(['getConsumerTag', 'consume']) 60 | ->getMock(); 61 | \Yii::$container->set(sprintf(Configuration::CONSUMER_SERVICE_NAME, $name), $consumer); 62 | $this->controller->debug = 'false'; 63 | $this->controller->memoryLimit = '1024'; 64 | $response = $this->controller->runAction('consume', [$name]); 65 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 66 | } 67 | 68 | public function testPublishAction() 69 | { 70 | // Invalid producer name 71 | $this->expectException(InvalidConfigException::class); 72 | $response = $this->controller->runAction('publish', ['unknown', 'unknown']); 73 | $this->assertSame(Controller::EXIT_CODE_ERROR, $response); 74 | 75 | // No data 76 | $name = 'valid'; 77 | $producer = $this->getMockBuilder(Producer::class) 78 | ->disableOriginalConstructor() 79 | ->setMethods(['publish']) 80 | ->getMock(); 81 | \Yii::$container->set(sprintf(Configuration::PRODUCER_SERVICE_NAME, $name), $producer); 82 | $response = $this->controller->runAction('publish', [$name, 'does not matter']); 83 | $this->assertSame(Controller::EXIT_CODE_ERROR, $response); 84 | } 85 | 86 | public function testDeclareAllAction() 87 | { 88 | $routing = $this->createMock(Routing::class); 89 | $routing->expects($this->exactly(2)) 90 | ->method('declareAll') 91 | ->willReturnOnConsecutiveCalls(false, true); 92 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 93 | $response = $this->controller->runAction('declare-all'); 94 | $this->assertSame(Controller::EXIT_CODE_ERROR, $response); 95 | $response = $this->controller->runAction('declare-all'); 96 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 97 | } 98 | 99 | public function testDeclareQueueAction() 100 | { 101 | $routing = $this->createMock(Routing::class); 102 | $routing->expects($this->exactly(2)) 103 | ->method('isQueueExists') 104 | ->willReturnOnConsecutiveCalls(true, false); 105 | $routing->expects($this->once()) 106 | ->method('declareQueue'); 107 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 108 | $response = $this->controller->runAction('declare-queue', ['queue-name']); 109 | $this->assertSame(Controller::EXIT_CODE_ERROR, $response); 110 | $response = $this->controller->runAction('declare-queue', ['queue-name']); 111 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 112 | } 113 | 114 | public function testDeclareExchangeAction() 115 | { 116 | $routing = $this->createMock(Routing::class); 117 | $routing->expects($this->exactly(2)) 118 | ->method('isExchangeExists') 119 | ->willReturnOnConsecutiveCalls(true, false); 120 | $routing->expects($this->once()) 121 | ->method('declareExchange'); 122 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 123 | $response = $this->controller->runAction('declare-exchange', ['exchange-name']); 124 | $this->assertSame(Controller::EXIT_CODE_ERROR, $response); 125 | $response = $this->controller->runAction('declare-exchange', ['exchange-name']); 126 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 127 | } 128 | 129 | public function testDeleteAllAction() 130 | { 131 | $this->controller->interactive = false; 132 | $routing = $this->createMock(Routing::class); 133 | $routing->expects($this->once()) 134 | ->method('deleteAll') 135 | ->willReturnOnConsecutiveCalls(false, true); 136 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 137 | $response = $this->controller->runAction('delete-all'); 138 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 139 | } 140 | 141 | public function testDeleteQueueAction() 142 | { 143 | $this->controller->interactive = false; 144 | $routing = $this->createMock(Routing::class); 145 | $routing->expects($this->once()) 146 | ->method('deleteQueue'); 147 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 148 | $response = $this->controller->runAction('delete-queue', ['queue-name']); 149 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 150 | } 151 | 152 | public function testDeleteExchangeAction() 153 | { 154 | $this->controller->interactive = false; 155 | $routing = $this->createMock(Routing::class); 156 | $routing->expects($this->once()) 157 | ->method('deleteExchange'); 158 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 159 | $response = $this->controller->runAction('delete-exchange', ['exchange-name']); 160 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 161 | } 162 | 163 | public function testPurgeQueueAction() 164 | { 165 | $this->controller->interactive = false; 166 | $routing = $this->createMock(Routing::class); 167 | $routing->expects($this->once()) 168 | ->method('purgeQueue'); 169 | \Yii::$container->setSingleton(Configuration::ROUTING_SERVICE_NAME, $routing); 170 | $response = $this->controller->runAction('purge-queue', ['queue-name']); 171 | $this->assertSame(Controller::EXIT_CODE_NORMAL, $response); 172 | } 173 | } 174 | --------------------------------------------------------------------------------