PageRenderTime 62ms CodeModel.GetById 33ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php

https://github.com/jordscream/symfony
PHP | 361 lines | 257 code | 54 blank | 50 comment | 33 complexity | 25d400290046f2a1bd64a67d1f9f727c MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Messenger\Bridge\AmazonSqs\Transport;
  11. use AsyncAws\Sqs\Enum\QueueAttributeName;
  12. use AsyncAws\Sqs\Result\ReceiveMessageResult;
  13. use AsyncAws\Sqs\SqsClient;
  14. use AsyncAws\Sqs\ValueObject\MessageAttributeValue;
  15. use Symfony\Component\Messenger\Exception\InvalidArgumentException;
  16. use Symfony\Component\Messenger\Exception\TransportException;
  17. use Symfony\Contracts\HttpClient\HttpClientInterface;
  18. /**
  19. * A SQS connection.
  20. *
  21. * @author Jérémy Derussé <jeremy@derusse.com>
  22. *
  23. * @internal
  24. * @final
  25. */
  26. class Connection
  27. {
  28. private const AWS_SQS_FIFO_SUFFIX = '.fifo';
  29. private const MESSAGE_ATTRIBUTE_NAME = 'X-Symfony-Messenger';
  30. private const DEFAULT_OPTIONS = [
  31. 'buffer_size' => 9,
  32. 'wait_time' => 20,
  33. 'poll_timeout' => 0.1,
  34. 'visibility_timeout' => null,
  35. 'auto_setup' => true,
  36. 'access_key' => null,
  37. 'secret_key' => null,
  38. 'endpoint' => 'https://sqs.eu-west-1.amazonaws.com',
  39. 'region' => 'eu-west-1',
  40. 'queue_name' => 'messages',
  41. 'account' => null,
  42. 'sslmode' => null,
  43. ];
  44. private $configuration;
  45. private $client;
  46. /** @var ReceiveMessageResult */
  47. private $currentResponse;
  48. /** @var array[] */
  49. private $buffer = [];
  50. /** @var string|null */
  51. private $queueUrl;
  52. public function __construct(array $configuration, SqsClient $client = null)
  53. {
  54. $this->configuration = array_replace_recursive(self::DEFAULT_OPTIONS, $configuration);
  55. $this->client = $client ?? new SqsClient([]);
  56. }
  57. public function __destruct()
  58. {
  59. $this->reset();
  60. }
  61. /**
  62. * Creates a connection based on the DSN and options.
  63. *
  64. * Available options:
  65. *
  66. * * endpoint: absolute URL to the SQS service (Default: https://sqs.eu-west-1.amazonaws.com)
  67. * * region: name of the AWS region (Default: eu-west-1)
  68. * * queue_name: name of the queue (Default: messages)
  69. * * account: identifier of the AWS account
  70. * * access_key: AWS access key
  71. * * secret_key: AWS secret key
  72. * * buffer_size: number of messages to prefetch (Default: 9)
  73. * * wait_time: long polling duration in seconds (Default: 20)
  74. * * poll_timeout: amount of seconds the transport should wait for new message
  75. * * visibility_timeout: amount of seconds the message won't be visible
  76. * * auto_setup: Whether the queue should be created automatically during send / get (Default: true)
  77. */
  78. public static function fromDsn(string $dsn, array $options = [], HttpClientInterface $client = null): self
  79. {
  80. if (false === $parsedUrl = parse_url($dsn)) {
  81. throw new InvalidArgumentException(sprintf('The given Amazon SQS DSN "%s" is invalid.', $dsn));
  82. }
  83. $query = [];
  84. if (isset($parsedUrl['query'])) {
  85. parse_str($parsedUrl['query'], $query);
  86. }
  87. // check for extra keys in options
  88. $optionsExtraKeys = array_diff(array_keys($options), array_keys(self::DEFAULT_OPTIONS));
  89. if (0 < \count($optionsExtraKeys)) {
  90. throw new InvalidArgumentException(sprintf('Unknown option found: [%s]. Allowed options are [%s].', implode(', ', $optionsExtraKeys), implode(', ', array_keys(self::DEFAULT_OPTIONS))));
  91. }
  92. // check for extra keys in options
  93. $queryExtraKeys = array_diff(array_keys($query), array_keys(self::DEFAULT_OPTIONS));
  94. if (0 < \count($queryExtraKeys)) {
  95. throw new InvalidArgumentException(sprintf('Unknown option found in DSN: [%s]. Allowed options are [%s].', implode(', ', $queryExtraKeys), implode(', ', array_keys(self::DEFAULT_OPTIONS))));
  96. }
  97. $options = $query + $options + self::DEFAULT_OPTIONS;
  98. $configuration = [
  99. 'buffer_size' => (int) $options['buffer_size'],
  100. 'wait_time' => (int) $options['wait_time'],
  101. 'poll_timeout' => $options['poll_timeout'],
  102. 'visibility_timeout' => $options['visibility_timeout'],
  103. 'auto_setup' => (bool) $options['auto_setup'],
  104. 'queue_name' => (string) $options['queue_name'],
  105. ];
  106. $clientConfiguration = [
  107. 'region' => $options['region'],
  108. 'accessKeyId' => urldecode($parsedUrl['user'] ?? '') ?: $options['access_key'] ?? self::DEFAULT_OPTIONS['access_key'],
  109. 'accessKeySecret' => urldecode($parsedUrl['pass'] ?? '') ?: $options['secret_key'] ?? self::DEFAULT_OPTIONS['secret_key'],
  110. ];
  111. unset($query['region']);
  112. if ('default' !== ($parsedUrl['host'] ?? 'default')) {
  113. $clientConfiguration['endpoint'] = sprintf('%s://%s%s', ($query['sslmode'] ?? null) === 'disable' ? 'http' : 'https', $parsedUrl['host'], ($parsedUrl['port'] ?? null) ? ':'.$parsedUrl['port'] : '');
  114. if (preg_match(';^sqs\.([^\.]++)\.amazonaws\.com$;', $parsedUrl['host'], $matches)) {
  115. $clientConfiguration['region'] = $matches[1];
  116. }
  117. } elseif (self::DEFAULT_OPTIONS['endpoint'] !== $options['endpoint'] ?? self::DEFAULT_OPTIONS['endpoint']) {
  118. $clientConfiguration['endpoint'] = $options['endpoint'];
  119. }
  120. $parsedPath = explode('/', ltrim($parsedUrl['path'] ?? '/', '/'));
  121. if (\count($parsedPath) > 0 && !empty($queueName = end($parsedPath))) {
  122. $configuration['queue_name'] = $queueName;
  123. }
  124. $configuration['account'] = 2 === \count($parsedPath) ? $parsedPath[0] : $options['account'] ?? self::DEFAULT_OPTIONS['account'];
  125. return new self($configuration, new SqsClient($clientConfiguration, null, $client));
  126. }
  127. public function get(): ?array
  128. {
  129. if ($this->configuration['auto_setup']) {
  130. $this->setup();
  131. }
  132. foreach ($this->getNextMessages() as $message) {
  133. return $message;
  134. }
  135. return null;
  136. }
  137. /**
  138. * @return array[]
  139. */
  140. private function getNextMessages(): \Generator
  141. {
  142. yield from $this->getPendingMessages();
  143. yield from $this->getNewMessages();
  144. }
  145. /**
  146. * @return array[]
  147. */
  148. private function getPendingMessages(): \Generator
  149. {
  150. while (!empty($this->buffer)) {
  151. yield array_shift($this->buffer);
  152. }
  153. }
  154. /**
  155. * @return array[]
  156. */
  157. private function getNewMessages(): \Generator
  158. {
  159. if (null === $this->currentResponse) {
  160. $this->currentResponse = $this->client->receiveMessage([
  161. 'QueueUrl' => $this->getQueueUrl(),
  162. 'VisibilityTimeout' => $this->configuration['visibility_timeout'],
  163. 'MaxNumberOfMessages' => $this->configuration['buffer_size'],
  164. 'MessageAttributeNames' => ['All'],
  165. 'WaitTimeSeconds' => $this->configuration['wait_time'],
  166. ]);
  167. }
  168. if (!$this->fetchMessage()) {
  169. return;
  170. }
  171. yield from $this->getPendingMessages();
  172. }
  173. private function fetchMessage(): bool
  174. {
  175. if (!$this->currentResponse->resolve($this->configuration['poll_timeout'])) {
  176. return false;
  177. }
  178. foreach ($this->currentResponse->getMessages() as $message) {
  179. $headers = [];
  180. $attributes = $message->getMessageAttributes();
  181. if (isset($attributes[self::MESSAGE_ATTRIBUTE_NAME]) && 'String' === $attributes[self::MESSAGE_ATTRIBUTE_NAME]->getDataType()) {
  182. $headers = json_decode($attributes[self::MESSAGE_ATTRIBUTE_NAME]->getStringValue(), true);
  183. unset($attributes[self::MESSAGE_ATTRIBUTE_NAME]);
  184. }
  185. foreach ($attributes as $name => $attribute) {
  186. if ('String' !== $attribute->getDataType()) {
  187. continue;
  188. }
  189. $headers[$name] = $attribute->getStringValue();
  190. }
  191. $this->buffer[] = [
  192. 'id' => $message->getReceiptHandle(),
  193. 'body' => $message->getBody(),
  194. 'headers' => $headers,
  195. ];
  196. }
  197. $this->currentResponse = null;
  198. return true;
  199. }
  200. public function setup(): void
  201. {
  202. // Set to false to disable setup more than once
  203. $this->configuration['auto_setup'] = false;
  204. if ($this->client->queueExists([
  205. 'QueueName' => $this->configuration['queue_name'],
  206. 'QueueOwnerAWSAccountId' => $this->configuration['account'],
  207. ])->isSuccess()) {
  208. return;
  209. }
  210. if (null !== $this->configuration['account']) {
  211. throw new InvalidArgumentException(sprintf('The Amazon SQS queue "%s" does not exists (or you don\'t have permissions on it), and can\'t be created when an account is provided.', $this->configuration['queue_name']));
  212. }
  213. $parameters = ['QueueName' => $this->configuration['queue_name']];
  214. if (self::isFifoQueue($this->configuration['queue_name'])) {
  215. $parameters['FifoQueue'] = true;
  216. }
  217. $this->client->createQueue($parameters);
  218. $exists = $this->client->queueExists(['QueueName' => $this->configuration['queue_name']]);
  219. // Blocking call to wait for the queue to be created
  220. $exists->wait();
  221. if (!$exists->isSuccess()) {
  222. throw new TransportException(sprintf('Failed to crate the Amazon SQS queue "%s".', $this->configuration['queue_name']));
  223. }
  224. $this->queueUrl = null;
  225. }
  226. public function delete(string $id): void
  227. {
  228. $this->client->deleteMessage([
  229. 'QueueUrl' => $this->getQueueUrl(),
  230. 'ReceiptHandle' => $id,
  231. ]);
  232. }
  233. public function getMessageCount(): int
  234. {
  235. $response = $this->client->getQueueAttributes([
  236. 'QueueUrl' => $this->getQueueUrl(),
  237. 'AttributeNames' => [QueueAttributeName::APPROXIMATE_NUMBER_OF_MESSAGES],
  238. ]);
  239. $attributes = $response->getAttributes();
  240. return (int) ($attributes[QueueAttributeName::APPROXIMATE_NUMBER_OF_MESSAGES] ?? 0);
  241. }
  242. public function send(string $body, array $headers, int $delay = 0, ?string $messageGroupId = null, ?string $messageDeduplicationId = null): void
  243. {
  244. if ($this->configuration['auto_setup']) {
  245. $this->setup();
  246. }
  247. $parameters = [
  248. 'QueueUrl' => $this->getQueueUrl(),
  249. 'MessageBody' => $body,
  250. 'DelaySeconds' => $delay,
  251. 'MessageAttributes' => [],
  252. ];
  253. $specialHeaders = [];
  254. foreach ($headers as $name => $value) {
  255. if ('.' === $name[0] || self::MESSAGE_ATTRIBUTE_NAME === $name || \strlen($name) > 256 || '.' === substr($name, -1) || 'AWS.' === substr($name, 0, \strlen('AWS.')) || 'Amazon.' === substr($name, 0, \strlen('Amazon.')) || preg_match('/([^a-zA-Z0-9_\.-]+|\.\.)/', $name)) {
  256. $specialHeaders[$name] = $value;
  257. continue;
  258. }
  259. $parameters['MessageAttributes'][$name] = new MessageAttributeValue([
  260. 'DataType' => 'String',
  261. 'StringValue' => $value,
  262. ]);
  263. }
  264. if (!empty($specialHeaders)) {
  265. $parameters['MessageAttributes'][self::MESSAGE_ATTRIBUTE_NAME] = new MessageAttributeValue([
  266. 'DataType' => 'String',
  267. 'StringValue' => json_encode($specialHeaders),
  268. ]);
  269. }
  270. if (self::isFifoQueue($this->configuration['queue_name'])) {
  271. $parameters['MessageGroupId'] = null !== $messageGroupId ? $messageGroupId : __METHOD__;
  272. $parameters['MessageDeduplicationId'] = null !== $messageDeduplicationId ? $messageDeduplicationId : sha1(json_encode(['body' => $body, 'headers' => $headers]));
  273. }
  274. $this->client->sendMessage($parameters);
  275. }
  276. public function reset(): void
  277. {
  278. if (null !== $this->currentResponse) {
  279. // fetch current response in order to requeue in transit messages
  280. if (!$this->fetchMessage()) {
  281. $this->currentResponse->cancel();
  282. $this->currentResponse = null;
  283. }
  284. }
  285. foreach ($this->getPendingMessages() as $message) {
  286. $this->client->changeMessageVisibility([
  287. 'QueueUrl' => $this->getQueueUrl(),
  288. 'ReceiptHandle' => $message['id'],
  289. 'VisibilityTimeout' => 0,
  290. ]);
  291. }
  292. }
  293. private function getQueueUrl(): string
  294. {
  295. if (null !== $this->queueUrl) {
  296. return $this->queueUrl;
  297. }
  298. return $this->queueUrl = $this->client->getQueueUrl([
  299. 'QueueName' => $this->configuration['queue_name'],
  300. 'QueueOwnerAWSAccountId' => $this->configuration['account'],
  301. ])->getQueueUrl();
  302. }
  303. private static function isFifoQueue(string $queueName): bool
  304. {
  305. return self::AWS_SQS_FIFO_SUFFIX === substr($queueName, -\strlen(self::AWS_SQS_FIFO_SUFFIX));
  306. }
  307. }