RabbitMQService.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. <?php
  2. namespace App\Services;
  3. use App\Tools\Tools;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Support\Str;
  6. use PhpAmqpLib\Channel\AMQPChannel;
  7. use PhpAmqpLib\Connection\AMQPStreamConnection;
  8. use PhpAmqpLib\Message\AMQPMessage;
  9. use PhpAmqpLib\Wire\AMQPTable;
  10. /**
  11. * RabbitMQ 的发布与消费。
  12. *
  13. * 消费策略:阻塞等待消息,每次只取一条;任务处理结束无论成功失败一律 ack,
  14. * 不重试、不进死信队列。失败只记日志。
  15. */
  16. class RabbitMQService
  17. {
  18. private AMQPStreamConnection $connection;
  19. private AMQPChannel $channel;
  20. /**
  21. * @var array{host: string, port: int|string, user: string, password: string, virtual_host: string}
  22. */
  23. private array $config;
  24. /**
  25. * @var array<string, array{max_length?: int}>
  26. */
  27. private array $queues;
  28. public function __construct()
  29. {
  30. $this->config = config('queue.connections.rabbitmq');
  31. $this->queues = config('mint.rabbitmq.queues', []);
  32. $this->connect();
  33. }
  34. private function connect(): void
  35. {
  36. $this->connection = new AMQPStreamConnection(
  37. $this->config['host'],
  38. $this->config['port'],
  39. $this->config['user'],
  40. $this->config['password'],
  41. $this->config['virtual_host']
  42. );
  43. $this->channel = $this->connection->channel();
  44. // QoS:每次只投递一条未确认消息
  45. $this->channel->basic_qos(null, 1, null);
  46. }
  47. public function isConnected(): bool
  48. {
  49. return $this->connection->isConnected();
  50. }
  51. public function getChannel(): AMQPChannel
  52. {
  53. return $this->channel;
  54. }
  55. /**
  56. * 声明配置中的全部队列。
  57. *
  58. * @return list<string> 队列名
  59. */
  60. public function createQueue(): array
  61. {
  62. foreach (array_keys($this->queues) as $name) {
  63. $this->setupQueue($name);
  64. }
  65. return array_keys($this->queues);
  66. }
  67. /**
  68. * 声明一个持久化队列(不存在则创建)。
  69. */
  70. public function setupQueue(string $queueName): void
  71. {
  72. $queueConfig = $this->queues[$queueName] ?? [];
  73. $args = [];
  74. if (! empty($queueConfig['max_length'])) {
  75. $args['x-max-length'] = (int) $queueConfig['max_length'];
  76. }
  77. $this->channel->queue_declare(
  78. $queueName,
  79. false, // passive
  80. true, // durable
  81. false, // exclusive
  82. false, // auto_delete
  83. false, // nowait
  84. new AMQPTable($args)
  85. );
  86. }
  87. public function publishMessage(string $queueName, array $data): string
  88. {
  89. try {
  90. $this->setupQueue($queueName);
  91. $msgId = (string) Str::uuid();
  92. $message = new AMQPMessage(
  93. json_encode($data, JSON_UNESCAPED_UNICODE),
  94. [
  95. 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
  96. 'timestamp' => time(),
  97. 'message_id' => $msgId,
  98. 'content_type' => 'application/json; charset=utf-8',
  99. ]
  100. );
  101. $this->channel->basic_publish($message, '', $queueName);
  102. Log::info("Message published to queue: {$queueName} msg id={$msgId}");
  103. return $msgId;
  104. } catch (\Exception $e) {
  105. Log::error("Failed to publish message to queue: {$queueName}", [
  106. 'error' => $e->getMessage(),
  107. ]);
  108. throw $e;
  109. }
  110. }
  111. /**
  112. * 阻塞消费队列。
  113. *
  114. * 每条消息处理结束(成功、抛异常、JSON 解析失败)都 ack,不重试。
  115. * 处理完 $loopLimit 条或检测到 .stop 文件后退出;$loopLimit 为 0 表示不限。
  116. *
  117. * @param callable(mixed $data, AMQPMessage $message): void $callback
  118. * @param int|null $loopLimit 默认取 mint.mq.consumer_loop_limit
  119. */
  120. public function consume(string $queueName, callable $callback, ?int $loopLimit = null): void
  121. {
  122. $this->setupQueue($queueName);
  123. $loopLimit ??= (int) config('mint.mq.consumer_loop_limit', 0);
  124. $processed = 0;
  125. $consumerCallback = function (AMQPMessage $msg) use ($callback, $queueName, $loopLimit, &$processed): void {
  126. $messageId = $msg->has('message_id') ? $msg->get('message_id') : null;
  127. $context = ['queue' => $queueName, 'message_id' => $messageId];
  128. try {
  129. $data = json_decode($msg->getBody(), true, flags: JSON_THROW_ON_ERROR);
  130. Log::info('mq message start', $context);
  131. $callback($data, $msg);
  132. Log::info('mq message done', $context);
  133. } catch (\Throwable $e) {
  134. Log::error('mq message failed', $context + [
  135. 'error' => $e->getMessage(),
  136. 'exception' => $e::class,
  137. ]);
  138. } finally {
  139. $msg->ack();
  140. }
  141. $processed++;
  142. if (($loopLimit > 0 && $processed >= $loopLimit) || Tools::isStop()) {
  143. Log::info('mq consumer exit', $context + ['processed' => $processed]);
  144. $msg->getChannel()->basic_cancel($msg->getConsumerTag());
  145. }
  146. };
  147. $this->channel->basic_qos(null, 1, null);
  148. $this->channel->basic_consume(
  149. $queueName,
  150. '', // consumer_tag
  151. false, // no_local
  152. false, // no_ack:手动 ack
  153. false, // exclusive
  154. false, // nowait
  155. $consumerCallback
  156. );
  157. Log::info("mq consumer start queue={$queueName}", ['loop_limit' => $loopLimit]);
  158. // 阻塞等待,直到回调里 basic_cancel
  159. $this->channel->consume();
  160. Log::info("mq consumer stopped queue={$queueName}", ['processed' => $processed]);
  161. }
  162. public function close(): void
  163. {
  164. $this->channel->close();
  165. $this->connection->close();
  166. }
  167. }