├── README.md
├── composer.json
├── examples
└── test.php
└── src
├── Client.php
└── UnretryableException.php
/README.md:
--------------------------------------------------------------------------------
1 | # redis-queue
2 | Message queue system written in PHP based on [workerman](https://github.com/walkor/workerman) and backed by Redis.
3 |
4 | # Install
5 | ```
6 | composer require workerman/redis-queue
7 | ```
8 |
9 | # Usage
10 | test.php
11 | ```php
12 | onWorkerStart = function () {
21 | $client = new Client('redis://127.0.0.1:6379');
22 |
23 | $client->subscribe('user-1', function($data) {
24 | echo "user-1\n";
25 | var_export($data);
26 | });
27 |
28 | $client->subscribe('user-2', function($data) {
29 | echo "user-2\n";
30 | var_export($data);
31 | });
32 |
33 | $client->onConsumeFailure(function (\Throwable $exception, $package) {
34 | echo "consume failure\n";
35 | echo $exception->getMessage(), "\n";
36 | var_export($package);
37 | });
38 |
39 | Timer::add(1, function() use ($client) {
40 | $client->send('user-1', ['some', 'data']);
41 | });
42 | };
43 |
44 | Worker::runAll();
45 | ```
46 |
47 | Run with command `php test.php start` or `php test.php start -d`.
48 |
49 | # API
50 |
51 | * Client::__construct()
52 | * Client::send()
53 | * Client::subscribe()
54 | * Client::unsubscribe()
55 | * Client::onConsumeFailure()
56 |
57 | -------------------------------------------------------
58 |
59 |
60 | ### __construct (string $address, [array $options])
61 |
62 | Create an instance by $address and $options.
63 |
64 | * `$address` for example `redis://ip:6379`.
65 |
66 | * `$options` is the client connection options. Defaults:
67 | * `auth`: default ''
68 | * `db`: default 0
69 | * `retry_seconds`: Retry interval after consumption failure
70 | * `max_attempts`: Maximum number of retries after consumption failure
71 |
72 | -------------------------------------------------------
73 |
74 |
75 | ### send(String $queue, Mixed $data, [int $dely=0])
76 |
77 | Send a message to a queue
78 |
79 | * `$queue` is the queue to publish to, `String`
80 | * `$data` is the message to publish, `Mixed`
81 | * `$dely` is delay seconds for delayed consumption, `Int`
82 |
83 | -------------------------------------------------------
84 |
85 |
86 | ### subscribe(mixed $queue, callable $callback)
87 |
88 | Subscribe to a queue or queues
89 |
90 | * `$queue` is a `String` queue or an `Array` which has as keys the queue name to subscribe.
91 | * `$callback` - `function (Mixed $data)`, `$data` is the data sent by `send($queue, $data)`.
92 |
93 | -------------------------------------------------------
94 |
95 |
96 | ### unsubscribe(mixed $queue)
97 |
98 | Unsubscribe from a queue or queues
99 |
100 | -------------------------------------------------------
101 |
102 |
103 | ### onConsumeFailure(callable $callback)
104 |
105 | When consumption fails onConsumeFailure is triggered.
106 |
107 | * `$callback` - `function (\Throwable $exception, array $package)`, `$package` contains information such as data queue attempts
108 |
109 | -------------------------------------------------------
110 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name" : "workerman/redis-queue",
3 | "type" : "library",
4 | "homepage": "http://www.workerman.net",
5 | "license" : "MIT",
6 | "description": "Message queue system written in PHP based on workerman and backed by Redis.",
7 | "require": {
8 | "php": ">=7.0",
9 | "workerman/redis" : "^1.0||^2.0",
10 | "workerman/workerman" : ">=4.0.20"
11 | },
12 | "autoload": {
13 | "psr-4": {"Workerman\\RedisQueue\\": "./src"}
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/examples/test.php:
--------------------------------------------------------------------------------
1 | onWorkerStart = function () {
11 | $client = new Client('redis://127.0.0.1:6379');
12 | $client->subscribe('user-1', function ($data) {
13 | echo "user-1\n";
14 | var_export($data);
15 | }, function ($data) {
16 | echo "user-1 failed\n";
17 | var_export($data);
18 | });
19 | $client->subscribe('user-2', function ($data) {
20 | echo "user-2\n";
21 | var_export($data);
22 | }, function ($data) {
23 | echo "user-2 failed\n";
24 | var_export($data);
25 | });
26 | $client->onConsumeFailure(function (\Throwable $exception, $package) {
27 | echo "consume failure\n";
28 | echo $exception->getMessage(), "\n";
29 | var_export($package);
30 | });
31 | Timer::add(1, function () use ($client) {
32 | $client->send('user-1', [666, 777]);
33 | });
34 | };
35 |
36 | Worker::runAll();
37 |
--------------------------------------------------------------------------------
/src/Client.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright walkor
12 | * @link http://www.workerman.net/
13 | * @license http://www.opensource.org/licenses/mit-license.php MIT License
14 | */
15 |
16 | namespace Workerman\RedisQueue;
17 |
18 | use RuntimeException;
19 | use Workerman\Timer;
20 | use Workerman\Redis\Client as Redis;
21 | use Psr\Log\LoggerInterface;
22 |
23 | /**
24 | * Class Client
25 | * @package Workerman\RedisQueue
26 | */
27 | #[\AllowDynamicProperties]
28 | class Client
29 | {
30 | /**
31 | * Queue waiting for consumption
32 | */
33 | const QUEUE_WAITING = '{redis-queue}-waiting';
34 |
35 | /**
36 | * Queue with delayed consumption
37 | */
38 | const QUEUE_DELAYED = '{redis-queue}-delayed';
39 |
40 | /**
41 | * Queue with consumption failure
42 | */
43 | const QUEUE_FAILED = '{redis-queue}-failed';
44 |
45 | /**
46 | * @var Redis
47 | */
48 | protected $_redisSubscribe;
49 |
50 | /**
51 | * @var Redis
52 | */
53 | protected $_redisSend;
54 |
55 | /**
56 | * @var array
57 | */
58 | protected $_subscribeQueues = [];
59 |
60 | /**
61 | * @var LoggerInterface
62 | */
63 | protected $_logger = null;
64 |
65 | /**
66 | * consume failure callback
67 | * @var callable
68 | */
69 | protected $_consumeFailure = null;
70 | /**
71 | * @var array
72 | */
73 | protected $_options = [
74 | 'retry_seconds' => 5,
75 | 'max_attempts' => 5,
76 | 'auth' => '',
77 | 'db' => 0,
78 | 'prefix' => '',
79 | ];
80 |
81 | /**
82 | * Client constructor.
83 | * @param $address
84 | * @param array $options
85 | */
86 | public function __construct($address, $options = [])
87 | {
88 | $this->_redisSubscribe = new Redis($address, $options);
89 | $this->_redisSubscribe->brPoping = 0;
90 | $this->_redisSend = new Redis($address, $options);
91 | if (isset($options['auth']) && $options['auth'] !== '') {
92 | $this->_redisSubscribe->auth($options['auth']);
93 | $this->_redisSend->auth($options['auth']);
94 | }
95 | if (isset($options['db'])) {
96 | $this->_redisSubscribe->select($options['db']);
97 | $this->_redisSend->select($options['db']);
98 | }
99 | $this->_options = array_merge($this->_options, $options);
100 | }
101 |
102 | /**
103 | * Send.
104 | *
105 | * @param $queue
106 | * @param $data
107 | * @param int $delay
108 | * @param callable $cb
109 | */
110 | public function send($queue, $data, $delay = 0, $cb = null)
111 | {
112 | static $_id = 0;
113 | $id = \microtime(true) . '.' . (++$_id);
114 | $now = time();
115 | $package_str = \json_encode([
116 | 'id' => $id,
117 | 'time' => $now,
118 | 'delay' => $delay,
119 | 'attempts' => 0,
120 | 'queue' => $queue,
121 | 'data' => $data
122 | ]);
123 | if (\is_callable($delay)) {
124 | $cb = $delay;
125 | $delay = 0;
126 | }
127 | if ($cb) {
128 | $cb = function ($ret) use ($cb) {
129 | $cb((bool)$ret);
130 | };
131 | if ($delay == 0) {
132 | $this->_redisSend->lPush($this->_options['prefix'] . static::QUEUE_WAITING . $queue, $package_str, $cb);
133 | } else {
134 | $this->_redisSend->zAdd($this->_options['prefix'] . static::QUEUE_DELAYED, $now + $delay, $package_str, $cb);
135 | }
136 | return;
137 | }
138 | if ($delay == 0) {
139 | $this->_redisSend->lPush($this->_options['prefix'] . static::QUEUE_WAITING . $queue, $package_str);
140 | } else {
141 | $this->_redisSend->zAdd($this->_options['prefix'] . static::QUEUE_DELAYED, $now + $delay, $package_str);
142 | }
143 | }
144 |
145 | /**
146 | * Set the consume failure callback.
147 | *
148 | * @param callable $callback
149 | */
150 | public function onConsumeFailure(callable $callback)
151 | {
152 | $this->_consumeFailure = $callback;
153 | }
154 |
155 | /**
156 | * Subscribe.
157 | *
158 | * @param string|array $queue
159 | * @param callable $callback
160 | */
161 | public function subscribe($queue, callable $callback)
162 | {
163 | $queue = (array)$queue;
164 | foreach ($queue as $q) {
165 | $redis_key = $this->_options['prefix'] . static::QUEUE_WAITING . $q;
166 | $this->_subscribeQueues[$redis_key] = $callback;
167 | }
168 | $this->pull();
169 | }
170 |
171 | /**
172 | * Unsubscribe.
173 | *
174 | * @param string|array $queue
175 | * @return void
176 | */
177 | public function unsubscribe($queue)
178 | {
179 | $queue = (array)$queue;
180 | foreach ($queue as $q) {
181 | $redis_key = $this->_options['prefix'] . static::QUEUE_WAITING . $q;
182 | unset($this->_subscribeQueues[$redis_key]);
183 | }
184 | }
185 |
186 | /**
187 | * tryToPullDelayQueue.
188 | */
189 | protected function tryToPullDelayQueue()
190 | {
191 | static $retry_timer = 0;
192 | if ($retry_timer) {
193 | return;
194 | }
195 | $retry_timer = Timer::add(1, function () {
196 | $now = time();
197 | $options = ['LIMIT', 0, 128];
198 | $this->_redisSend->zrevrangebyscore($this->_options['prefix'] . static::QUEUE_DELAYED, $now, '-inf', $options, function ($items) {
199 | if ($items === false) {
200 | throw new RuntimeException($this->_redisSend->error());
201 | }
202 | foreach ($items as $package_str) {
203 | $this->_redisSend->zRem($this->_options['prefix'] . static::QUEUE_DELAYED, $package_str, function ($result) use ($package_str) {
204 | if ($result !== 1) {
205 | return;
206 | }
207 | $package = \json_decode($package_str, true);
208 | if (!$package) {
209 | $this->_redisSend->lPush($this->_options['prefix'] . static::QUEUE_FAILED, $package_str);
210 | return;
211 | }
212 | $this->_redisSend->lPush($this->_options['prefix'] . static::QUEUE_WAITING . $package['queue'], $package_str);
213 | });
214 | }
215 | });
216 | });
217 | }
218 |
219 | /**
220 | * pull.
221 | */
222 | public function pull()
223 | {
224 | $this->tryToPullDelayQueue();
225 | if (!$this->_subscribeQueues || $this->_redisSubscribe->brPoping) {
226 | return;
227 | }
228 | $cb = function ($data) use (&$cb) {
229 | if ($data) {
230 | $this->_redisSubscribe->brPoping = 0;
231 | $redis_key = $data[0];
232 | $package_str = $data[1];
233 | $package = json_decode($package_str, true);
234 | if (!$package) {
235 | $this->_redisSend->lPush($this->_options['prefix'] . static::QUEUE_FAILED, $package_str);
236 | } else {
237 | if (!isset($this->_subscribeQueues[$redis_key])) {
238 | // 取消订阅,放回队列
239 | $this->_redisSend->rPush($redis_key, $package_str);
240 | } else {
241 | $callback = $this->_subscribeQueues[$redis_key];
242 | try {
243 | \call_user_func($callback, $package['data']);
244 | } catch (UnretryableException $e) {
245 | $this->log((string)$e);
246 | $package['max_attempts'] = $this->_options['max_attempts'];
247 | $package['error'] = $e->getMessage();
248 | $this->fail($package);
249 | } catch (\Throwable $e) {
250 | $this->log((string)$e);
251 | $package['max_attempts'] = $this->_options['max_attempts'];
252 | $package['error'] = $e->getMessage();
253 | $package_modified = null;
254 | if ($this->_consumeFailure) {
255 | try {
256 | $package_modified = \call_user_func($this->_consumeFailure, $e, $package);
257 | } catch (\Throwable $ta) {
258 | $this->log((string)$ta);
259 | }
260 | }
261 | if (is_array($package_modified)) {
262 | $package['data'] = $package_modified['data'] ?? $package['data'];
263 | $package['attempts'] = $package_modified['attempts'] ?? $package['attempts'];
264 | $package['max_attempts'] = $package_modified['max_attempts'] ?? $package['max_attempts'];
265 | $package['error'] = $package_modified['error'] ?? $package['error'];
266 | }
267 | if (++$package['attempts'] > $package['max_attempts']) {
268 | $this->fail($package);
269 | } else {
270 | $this->retry($package);
271 | }
272 | }
273 | }
274 | }
275 | }
276 | if ($this->_subscribeQueues) {
277 | $this->_redisSubscribe->brPoping = 1;
278 | Timer::add(0.000001, [$this->_redisSubscribe, 'brPop'], [\array_keys($this->_subscribeQueues), 1, $cb], false);
279 | }
280 | };
281 | $this->_redisSubscribe->brPoping = 1;
282 | $this->_redisSubscribe->brPop(\array_keys($this->_subscribeQueues), 1, $cb);
283 | }
284 |
285 | /**
286 | * @param $package
287 | */
288 | protected function retry($package)
289 | {
290 | $delay = time() + $this->_options['retry_seconds'] * ($package['attempts']);
291 | $this->_redisSend->zAdd($this->_options['prefix'] . static::QUEUE_DELAYED, $delay, \json_encode($package, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
292 | }
293 |
294 | /**
295 | * @param $package
296 | */
297 | protected function fail($package)
298 | {
299 | $this->_redisSend->lPush($this->_options['prefix'] . static::QUEUE_FAILED, \json_encode($package, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
300 | }
301 |
302 | /**
303 | * @param $message
304 | * @return void
305 | */
306 | protected function log($message)
307 | {
308 | if ($this->_logger) {
309 | $this->_logger->info($message);
310 | return;
311 | }
312 | echo $message . PHP_EOL;
313 | }
314 |
315 | /**
316 | * @param $logger
317 | * @return mixed|LoggerInterface
318 | */
319 | public function logger($logger = null)
320 | {
321 | if ($logger) {
322 | $this->_logger = $logger;
323 | }
324 | return $this->_logger;
325 | }
326 | }
327 |
--------------------------------------------------------------------------------
/src/UnretryableException.php:
--------------------------------------------------------------------------------
1 |