Configuration.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. <?php declare(strict_types=1);
  2. namespace mikemadisonweb\rabbitmq;
  3. use mikemadisonweb\rabbitmq\components\Consumer;
  4. use mikemadisonweb\rabbitmq\components\Producer;
  5. use mikemadisonweb\rabbitmq\components\Routing;
  6. use mikemadisonweb\rabbitmq\exceptions\InvalidConfigException;
  7. use PhpAmqpLib\Connection\AbstractConnection;
  8. use PhpAmqpLib\Connection\AMQPLazyConnection;
  9. use PhpAmqpLib\Connection\AMQPSSLConnection;
  10. use Yii;
  11. use yii\base\Component;
  12. use yii\di\NotInstantiableException;
  13. use yii\helpers\ArrayHelper;
  14. class Configuration extends Component
  15. {
  16. const CONNECTION_SERVICE_NAME = 'rabbit_mq.connection.%s';
  17. const CONSUMER_SERVICE_NAME = 'rabbit_mq.consumer.%s';
  18. const PRODUCER_SERVICE_NAME = 'rabbit_mq.producer.%s';
  19. const ROUTING_SERVICE_NAME = 'rabbit_mq.routing';
  20. const LOGGER_SERVICE_NAME = 'rabbit_mq.logger';
  21. const DEFAULT_CONNECTION_NAME = 'default';
  22. const EXTENSION_CONTROLLER_ALIAS = 'rabbitmq';
  23. /**
  24. * Extension configuration default values
  25. * @var array
  26. */
  27. const DEFAULTS = [
  28. 'auto_declare' => true,
  29. 'connections' => [
  30. [
  31. 'name' => self::DEFAULT_CONNECTION_NAME,
  32. 'type' => AMQPLazyConnection::class,
  33. 'url' => null,
  34. 'host' => null,
  35. 'port' => 5672,
  36. 'user' => 'guest',
  37. 'password' => 'guest',
  38. 'vhost' => '/',
  39. 'connection_timeout' => 3,
  40. 'read_write_timeout' => 3,
  41. 'ssl_context' => null,
  42. 'keepalive' => false,
  43. 'heartbeat' => 0,
  44. 'channel_rpc_timeout' => 0.0,
  45. ],
  46. ],
  47. 'exchanges' => [
  48. [
  49. 'name' => null,
  50. 'type' => null,
  51. 'passive' => false,
  52. 'durable' => true,
  53. 'auto_delete' => false,
  54. 'internal' => false,
  55. 'nowait' => false,
  56. 'arguments' => null,
  57. 'ticket' => null,
  58. 'declare' => true,
  59. ],
  60. ],
  61. 'queues' => [
  62. [
  63. 'name' => '',
  64. 'passive' => false,
  65. 'durable' => true,
  66. 'exclusive' => false,
  67. 'auto_delete' => false,
  68. 'nowait' => false,
  69. 'arguments' => null,
  70. 'ticket' => null,
  71. 'declare' => true,
  72. ],
  73. ],
  74. 'bindings' => [
  75. [
  76. 'exchange' => null,
  77. 'queue' => null,
  78. 'to_exchange' => null,
  79. 'routing_keys' => [],
  80. ],
  81. ],
  82. 'producers' => [
  83. [
  84. 'name' => null,
  85. 'connection' => self::DEFAULT_CONNECTION_NAME,
  86. 'safe' => true,
  87. 'content_type' => 'text/plain',
  88. 'delivery_mode' => 2,
  89. 'serializer' => 'serialize',
  90. ],
  91. ],
  92. 'consumers' => [
  93. [
  94. 'name' => null,
  95. 'connection' => self::DEFAULT_CONNECTION_NAME,
  96. 'callbacks' => [],
  97. 'qos' => [
  98. 'prefetch_size' => 0,
  99. 'prefetch_count' => 0,
  100. 'global' => false,
  101. ],
  102. 'idle_timeout' => 0,
  103. 'idle_timeout_exit_code' => null,
  104. 'proceed_on_exception' => false,
  105. 'deserializer' => 'unserialize',
  106. 'systemd' => [
  107. 'memory_limit' => 0,
  108. 'workers' => 1
  109. ],
  110. ],
  111. ],
  112. 'logger' => [
  113. 'log' => false,
  114. 'category' => 'application',
  115. 'print_console' => true,
  116. 'system_memory' => false,
  117. ],
  118. ];
  119. public $auto_declare = null;
  120. public $connections = [];
  121. public $producers = [];
  122. public $consumers = [];
  123. public $queues = [];
  124. public $exchanges = [];
  125. public $bindings = [];
  126. public $logger = [];
  127. protected $isLoaded = false;
  128. /**
  129. * Get passed configuration
  130. * @return Configuration
  131. * @throws InvalidConfigException
  132. */
  133. public function getConfig() : Configuration
  134. {
  135. if(!$this->isLoaded) {
  136. $this->normalizeConnections();
  137. $this->validate();
  138. $this->completeWithDefaults();
  139. $this->isLoaded = true;
  140. }
  141. return $this;
  142. }
  143. /**
  144. * Get connection service
  145. * @param string $connectionName
  146. * @return object|AbstractConnection
  147. * @throws NotInstantiableException
  148. * @throws \yii\base\InvalidConfigException
  149. */
  150. public function getConnection(string $connectionName = '') : AbstractConnection
  151. {
  152. if ('' === $connectionName) {
  153. $connectionName = self::DEFAULT_CONNECTION_NAME;
  154. }
  155. return Yii::$container->get(sprintf(self::CONNECTION_SERVICE_NAME, $connectionName));
  156. }
  157. /**
  158. * Get producer service
  159. * @param string $producerName
  160. * @return Producer|object
  161. * @throws \yii\base\InvalidConfigException
  162. * @throws NotInstantiableException
  163. */
  164. public function getProducer(string $producerName)
  165. {
  166. return Yii::$container->get(sprintf(self::PRODUCER_SERVICE_NAME, $producerName));
  167. }
  168. /**
  169. * Get consumer service
  170. * @param string $consumerName
  171. * @return Consumer|object
  172. * @throws NotInstantiableException
  173. * @throws \yii\base\InvalidConfigException
  174. */
  175. public function getConsumer(string $consumerName)
  176. {
  177. return Yii::$container->get(sprintf(self::CONSUMER_SERVICE_NAME, $consumerName));
  178. }
  179. /**
  180. * Get routing service
  181. * @param AbstractConnection $connection
  182. * @return Routing|object|string
  183. * @throws NotInstantiableException
  184. * @throws \yii\base\InvalidConfigException
  185. */
  186. public function getRouting(AbstractConnection $connection)
  187. {
  188. return Yii::$container->get(Configuration::ROUTING_SERVICE_NAME, ['conn' => $connection]);
  189. }
  190. /**
  191. * Config validation
  192. * @throws InvalidConfigException
  193. */
  194. protected function validate()
  195. {
  196. $this->validateTopLevel();
  197. $this->validateMultidimensional();
  198. $this->validateRequired();
  199. $this->validateDuplicateNames(['connections', 'exchanges', 'queues', 'producers', 'consumers']);
  200. }
  201. /**
  202. * Validate multidimensional entries names
  203. * @throws InvalidConfigException
  204. */
  205. protected function validateMultidimensional()
  206. {
  207. $multidimensional = [
  208. 'connection' => $this->connections,
  209. 'exchange' => $this->exchanges,
  210. 'queue' => $this->queues,
  211. 'binding' => $this->bindings,
  212. 'producer' => $this->producers,
  213. 'consumer' => $this->consumers,
  214. ];
  215. foreach ($multidimensional as $configName => $configItem) {
  216. if (!is_array($configItem)) {
  217. throw new InvalidConfigException("Every {$configName} entry should be of type array.");
  218. }
  219. foreach ($configItem as $key => $value) {
  220. if (!is_int($key)) {
  221. throw new InvalidConfigException("Invalid key: `{$key}`. There should be a list of {$configName}s in the array.");
  222. }
  223. }
  224. }
  225. }
  226. /**
  227. * Validate top level options
  228. * @throws InvalidConfigException
  229. */
  230. protected function validateTopLevel()
  231. {
  232. if (($this->auto_declare !== null) && !is_bool($this->auto_declare)) {
  233. throw new InvalidConfigException("Option `auto_declare` should be of type boolean.");
  234. }
  235. if (!is_array($this->logger)) {
  236. throw new InvalidConfigException("Option `logger` should be of type array.");
  237. }
  238. $this->validateArrayFields($this->logger, self::DEFAULTS['logger']);
  239. }
  240. /**
  241. * Validate required options
  242. * @throws InvalidConfigException
  243. */
  244. protected function validateRequired()
  245. {
  246. foreach ($this->connections as $connection) {
  247. $this->validateArrayFields($connection, self::DEFAULTS['connections'][0]);
  248. if (!isset($connection['url']) && !isset($connection['host'])) {
  249. throw new InvalidConfigException('Either `url` or `host` options required for configuring connection.');
  250. }
  251. if (isset($connection['url']) && (isset($connection['host']) || isset($connection['port']))) {
  252. throw new InvalidConfigException('Connection options `url` and `host:port` should not be both specified, configuration is ambigious.');
  253. }
  254. if (!isset($connection['name'])) {
  255. throw new InvalidConfigException('Connection name is required when multiple connections is specified.');
  256. }
  257. if (isset($connection['type']) && !is_subclass_of($connection['type'], AbstractConnection::class)) {
  258. throw new InvalidConfigException('Connection type should be a subclass of PhpAmqpLib\Connection\AbstractConnection.');
  259. }
  260. if (!empty($connection['ssl_context']) && empty($connection['type'])) {
  261. throw new InvalidConfigException('If you are using a ssl connection, the connection type must be AMQPSSLConnection::class');
  262. }
  263. if (!empty($connection['ssl_context']) && $connection['type'] !== AMQPSSLConnection::class) {
  264. throw new InvalidConfigException('If you are using a ssl connection, the connection type must be AMQPSSLConnection::class');
  265. }
  266. }
  267. foreach ($this->exchanges as $exchange) {
  268. $this->validateArrayFields($exchange, self::DEFAULTS['exchanges'][0]);
  269. if (!isset($exchange['name'])) {
  270. throw new InvalidConfigException('Exchange name should be specified.');
  271. }
  272. if (!isset($exchange['type'])) {
  273. throw new InvalidConfigException('Exchange type should be specified.');
  274. }
  275. $allowed = ['direct', 'topic', 'fanout', 'headers', 'x-delayed-message'];
  276. if (!in_array($exchange['type'], $allowed, true)) {
  277. $allowed = implode(', ', $allowed);
  278. throw new InvalidConfigException("Unknown exchange type `{$exchange['type']}`. Allowed values are: {$allowed}");
  279. }
  280. }
  281. foreach ($this->queues as $queue) {
  282. $this->validateArrayFields($queue, self::DEFAULTS['queues'][0]);
  283. }
  284. foreach ($this->bindings as $binding) {
  285. $this->validateArrayFields($binding, self::DEFAULTS['bindings'][0]);
  286. if (!isset($binding['exchange'])) {
  287. throw new InvalidConfigException('Exchange name is required for binding.');
  288. }
  289. if (!$this->isNameExist($this->exchanges, $binding['exchange'])) {
  290. throw new InvalidConfigException("`{$binding['exchange']}` defined in binding doesn't configured in exchanges.");
  291. }
  292. if (isset($binding['routing_keys']) && !is_array($binding['routing_keys'])) {
  293. throw new InvalidConfigException('Option `routing_keys` should be an array.');
  294. }
  295. if ((!isset($binding['queue']) && !isset($binding['to_exchange'])) || isset($binding['queue'], $binding['to_exchange'])) {
  296. throw new InvalidConfigException('Either `queue` or `to_exchange` options should be specified to create binding.');
  297. }
  298. if (isset($binding['queue']) && !$this->isNameExist($this->queues, $binding['queue'])) {
  299. throw new InvalidConfigException("`{$binding['queue']}` defined in binding doesn't configured in queues.");
  300. }
  301. }
  302. foreach ($this->producers as $producer) {
  303. $this->validateArrayFields($producer, self::DEFAULTS['producers'][0]);
  304. if (!isset($producer['name'])) {
  305. throw new InvalidConfigException('Producer name is required.');
  306. }
  307. if (isset($producer['connection']) && !$this->isNameExist($this->connections, $producer['connection'])) {
  308. throw new InvalidConfigException("Connection `{$producer['connection']}` defined in producer doesn't configured in connections.");
  309. }
  310. if (isset($producer['safe']) && !is_bool($producer['safe'])) {
  311. throw new InvalidConfigException('Producer option safe should be of type boolean.');
  312. }
  313. if (!isset($producer['connection']) && !$this->isNameExist($this->connections, self::DEFAULT_CONNECTION_NAME)) {
  314. throw new InvalidConfigException("Connection for producer `{$producer['name']}` is required.");
  315. }
  316. if (isset($producer['serializer']) && !is_callable($producer['serializer'])) {
  317. throw new InvalidConfigException('Producer `serializer` option should be a callable.');
  318. }
  319. }
  320. foreach ($this->consumers as $consumer) {
  321. $this->validateArrayFields($consumer, self::DEFAULTS['consumers'][0]);
  322. if (!isset($consumer['name'])) {
  323. throw new InvalidConfigException('Consumer name is required.');
  324. }
  325. if (isset($consumer['connection']) && !$this->isNameExist($this->connections, $consumer['connection'])) {
  326. throw new InvalidConfigException("Connection `{$consumer['connection']}` defined in consumer doesn't configured in connections.");
  327. }
  328. if (!isset($consumer['connection']) && !$this->isNameExist($this->connections, self::DEFAULT_CONNECTION_NAME)) {
  329. throw new InvalidConfigException("Connection for consumer `{$consumer['name']}` is required.");
  330. }
  331. if (!isset($consumer['callbacks']) || empty($consumer['callbacks'])) {
  332. throw new InvalidConfigException("No callbacks specified for consumer `{$consumer['name']}`.");
  333. }
  334. if (isset($consumer['qos']) && !is_array($consumer['qos'])) {
  335. throw new InvalidConfigException('Consumer option `qos` should be of type array.');
  336. }
  337. if (isset($consumer['proceed_on_exception']) && !is_bool($consumer['proceed_on_exception'])) {
  338. throw new InvalidConfigException('Consumer option `proceed_on_exception` should be of type boolean.');
  339. }
  340. foreach ($consumer['callbacks'] as $queue => $callback) {
  341. if (!$this->isNameExist($this->queues, $queue)) {
  342. throw new InvalidConfigException("Queue `{$queue}` from {$consumer['name']} is not defined in queues.");
  343. }
  344. if (!is_string($callback)) {
  345. throw new InvalidConfigException('Consumer `callback` parameter value should be a class name or service name in DI container.');
  346. }
  347. }
  348. if (isset($consumer['deserializer']) && !is_callable($consumer['deserializer'])) {
  349. throw new InvalidConfigException('Consumer `deserializer` option should be a callable.');
  350. }
  351. }
  352. }
  353. /**
  354. * Validate config entry value
  355. * @param array $passed
  356. * @param array $required
  357. * @throws InvalidConfigException
  358. */
  359. protected function validateArrayFields(array $passed, array $required)
  360. {
  361. $undeclaredFields = array_diff_key($passed, $required);
  362. if (!empty($undeclaredFields)) {
  363. $asString = json_encode($undeclaredFields);
  364. throw new InvalidConfigException("Unknown options: {$asString}");
  365. }
  366. }
  367. /**
  368. * Check entrees for duplicate names
  369. * @param array $keys
  370. * @throws InvalidConfigException
  371. */
  372. protected function validateDuplicateNames(array $keys)
  373. {
  374. foreach ($keys as $key) {
  375. $names = [];
  376. foreach ($this->$key as $item) {
  377. if (!isset($item['name'])) {
  378. $item['name'] = '';
  379. }
  380. if (isset($names[$item['name']])) {
  381. throw new InvalidConfigException("Duplicate name `{$item['name']}` in {$key}");
  382. }
  383. $names[$item['name']] = true;
  384. }
  385. }
  386. }
  387. /**
  388. * Allow certain flexibility on connection configuration
  389. * @throws InvalidConfigException
  390. */
  391. protected function normalizeConnections()
  392. {
  393. if (empty($this->connections)) {
  394. throw new InvalidConfigException('Option `connections` should have at least one entry.');
  395. }
  396. if (ArrayHelper::isAssociative($this->connections)) {
  397. $this->connections[0] = $this->connections;
  398. }
  399. if (count($this->connections) === 1) {
  400. if (!isset($this->connections[0]['name'])) {
  401. $this->connections[0]['name'] = self::DEFAULT_CONNECTION_NAME;
  402. }
  403. }
  404. }
  405. /**
  406. * Merge passed config with extension defaults
  407. */
  408. protected function completeWithDefaults()
  409. {
  410. $defaults = self::DEFAULTS;
  411. if (null === $this->auto_declare) {
  412. $this->auto_declare = $defaults['auto_declare'];
  413. }
  414. if (empty($this->logger)) {
  415. $this->logger = $defaults['logger'];
  416. } else {
  417. foreach ($defaults['logger'] as $key => $option) {
  418. if (!isset($this->logger[$key])) {
  419. $this->logger[$key] = $option;
  420. }
  421. }
  422. }
  423. $multi = ['connections', 'bindings', 'exchanges', 'queues', 'producers', 'consumers'];
  424. foreach ($multi as $key) {
  425. foreach ($this->$key as &$item) {
  426. $item = array_replace_recursive($defaults[$key][0], $item);
  427. }
  428. }
  429. }
  430. /**
  431. * Check if an entry with specific name exists in array
  432. * @param array $multidimentional
  433. * @param string $name
  434. * @return bool
  435. */
  436. private function isNameExist(array $multidimentional, string $name)
  437. {
  438. if($name == '') {
  439. foreach ($multidimentional as $item) {
  440. if (!isset($item['name'])) {
  441. return true;
  442. }
  443. }
  444. return false;
  445. }
  446. $key = array_search($name, array_column($multidimentional, 'name'), true);
  447. if (is_int($key)) {
  448. return true;
  449. }
  450. return false;
  451. }
  452. }