├── tests ├── phpunit.sh ├── bootstrap.php ├── RespTest.php └── MainTest.php ├── src ├── Exception │ ├── SubscribeException.php │ └── UnsubscribeException.php ├── Message.php ├── Resp.php ├── Connection.php ├── Subscriber.php └── CommandInvoker.php ├── .gitignore ├── composer.json └── README.md /tests/phpunit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if (( "$#" != 1 )) 4 | then 5 | echo "The target cannot be empty" 6 | exit 1 7 | fi 8 | 9 | /usr/local/bin/php8 vendor/bin/phpunit --bootstrap=tests/bootstrap.php $1 10 | -------------------------------------------------------------------------------- /src/Exception/SubscribeException.php: -------------------------------------------------------------------------------- 1 | set([ 8 | 'hook_flags' => SWOOLE_HOOK_ALL, 9 | ]); 10 | $scheduler->add(function () use ($func) { 11 | call_user_func($func); 12 | }); 13 | $scheduler->start(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.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 itself is not needed 16 | composer.phar 17 | vendor 18 | 19 | # Mac DS_Store Files 20 | .DS_Store 21 | 22 | # phpunit itself is not needed 23 | phpunit.phar 24 | # local phpunit config 25 | /phpunit.xml 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mix/redis-subscriber", 3 | "description": "Redis native protocol Subscriber based on Swoole coroutine", 4 | "type": "library", 5 | "keywords": [ 6 | "mix", 7 | "swoole", 8 | "redis", 9 | "subscribe", 10 | "subscriber" 11 | ], 12 | "homepage": "https://openmix.org/mix-php", 13 | "license": "Apache-2.0", 14 | "authors": [ 15 | { 16 | "name": "liu,jian", 17 | "email": "coder.keda@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=7.0.0", 22 | "ext-swoole": ">=4.4.4" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Mix\\Redis\\Subscriber\\": "src/" 27 | } 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^7.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/RespTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(\Mix\Redis\Subscriber\Resp::build(null), "$-1\r\n"); 16 | $this->assertEquals(\Mix\Redis\Subscriber\Resp::build(1), ":1\r\n"); 17 | $this->assertEquals(\Mix\Redis\Subscriber\Resp::build('foo'), "$3\r\nfoo\r\n"); 18 | $this->assertEquals(\Mix\Redis\Subscriber\Resp::build(['foo', 'bar']), "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"); 19 | $this->assertEquals(\Mix\Redis\Subscriber\Resp::build([1, [2, '4'], 2, 'bar']), "*4\r\n:1\r\n*2\r\n:2\r\n$1\r\n4\r\n:2\r\n$3\r\nbar\r\n"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Resp.php: -------------------------------------------------------------------------------- 1 | |null $args 27 | * @return string the serialized string 28 | */ 29 | public static function build(mixed $args): string 30 | { 31 | if ($args == 'ping') { 32 | return "PING" . static::CRLF; 33 | } 34 | 35 | switch (true) { 36 | case is_null($args): 37 | return "$-1". static::CRLF; 38 | case is_int($args): 39 | return ':' . $args . static::CRLF; 40 | case is_string($args): 41 | return '$' . strlen($args) . static::CRLF . $args . static::CRLF; 42 | case is_array($args): 43 | $result = '*' . count($args) . static::CRLF; 44 | foreach ($args as $arg) { 45 | $result .= static::build($arg); 46 | } 47 | return $result; 48 | default: 49 | throw new Exception('invalid args'); 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Mix Redis Subscriber 2 | 3 | Redis native protocol Subscriber based on Swoole coroutine 4 | 5 | 基于 Swoole 协程的 Redis 原生协议订阅库 6 | 7 | 使用 Socket 直接连接 Redis 服务器,不依赖 phpredis 扩展,该订阅器有如下优点: 8 | 9 | - 平滑修改:可随时增加、取消订阅通道,实现无缝切换通道的需求。 10 | - 跨协程安全关闭:可在任意时刻关闭订阅。 11 | - 通道获取消息:该库封装风格参考 golang 语言 [go-redis](https://github.com/go-redis/redis) 库封装,通过 channel 获取订阅的消息。 12 | 13 | ## Installation 14 | 15 | - Swoole >= 4.4 16 | 17 | ``` 18 | composer require mix/redis-subscriber 19 | ``` 20 | 21 | ## 订阅频道 22 | 23 | - 连接、订阅失败会抛出异常 24 | 25 | ```php 26 | $sub = new \Mix\Redis\Subscriber\Subscriber('127.0.0.1', 6379, '', 5); // 连接失败将抛出异常 27 | $sub->subscribe('foo', 'bar'); // 订阅失败将抛出异常 28 | 29 | $chan = $sub->channel(); 30 | while (true) { 31 | $data = $chan->pop(); 32 | if (empty($data)) { // 手动close与redis异常断开都会导致返回false 33 | if (!$sub->closed) { 34 | // redis异常断开处理 35 | var_dump('Redis connection is disconnected abnormally'); 36 | } 37 | break; 38 | } 39 | var_dump($data); 40 | } 41 | ``` 42 | 43 | 接收到订阅消息: 44 | 45 | ``` 46 | object(Mix\Redis\Subscriber\Message)#8 (2) { 47 | ["channel"]=> 48 | string(2) "foo" 49 | ["payload"]=> 50 | string(4) "test" 51 | } 52 | ``` 53 | 54 | ## 全部方法 55 | 56 | | 方法 | 描述 | 57 | | --- | --- | 58 | | subscribe(string ...$channels) : void | 增加订阅 | 59 | | unsubscribe(string ...$channels) : void | 取消订阅 | 60 | | channel() : Swoole\Coroutine\Channel | 获取消息通道 | 61 | | close() : void | 关闭订阅 | 62 | 63 | ## License 64 | 65 | Apache License Version 2.0, http://www.apache.org/licenses/ 66 | -------------------------------------------------------------------------------- /tests/MainTest.php: -------------------------------------------------------------------------------- 1 | subscribe('foo', 'bar'); // 订阅失败将抛出异常 14 | $sub->subscribe('foo1', 'bar1'); 15 | $sub->unsubscribe('foo', 'bar'); 16 | 17 | go(function () { 18 | $redis = new \Redis(); 19 | $redis->connect('127.0.0.1', 6379); 20 | $redis->publish('foo', 'foodata'); 21 | $redis->publish('foo1', 'foo1data'); 22 | }); 23 | 24 | $chan = $sub->channel(); 25 | while (true) { 26 | $data = $chan->pop(); 27 | if (empty($data)) { // 手动close与redis异常断开都会导致返回false 28 | if (!$sub->closed) { 29 | // redis异常断开处理 30 | var_dump('Redis connection is disconnected abnormally'); 31 | } 32 | break; 33 | } 34 | $this->assertEquals($data->channel, 'foo1'); 35 | $this->assertEquals($data->payload, 'foo1data'); 36 | break; 37 | } 38 | $sub->close(); 39 | }; 40 | run($func); 41 | } 42 | 43 | public function testPsubscribe(): void 44 | { 45 | $func = function () { 46 | $sub = new \Mix\Redis\Subscriber\Subscriber('127.0.0.1', 6379, '', 5); 47 | $sub->psubscribe('foo.*', 'bar'); // 订阅失败将抛出异常 48 | $sub->psubscribe('foo1.*', 'bar1'); 49 | $sub->punsubscribe('foo.*', 'bar'); 50 | 51 | go(function () { 52 | $redis = new \Redis(); 53 | $redis->connect('127.0.0.1', 6379); 54 | $redis->publish('foo.1', 'foodata'); 55 | $redis->publish('foo1.1', 'foo1data'); 56 | }); 57 | 58 | $chan = $sub->channel(); 59 | while (true) { 60 | $data = $chan->pop(); 61 | if (empty($data)) { // 手动close与redis异常断开都会导致返回false 62 | if (!$sub->closed) { 63 | // redis异常断开处理 64 | var_dump('Redis connection is disconnected abnormally'); 65 | } 66 | break; 67 | } 68 | $this->assertEquals($data->pattern, 'foo1.*'); 69 | $this->assertEquals($data->channel, 'foo1.1'); 70 | $this->assertEquals($data->payload, 'foo1data'); 71 | break; 72 | } 73 | $sub->close(); 74 | }; 75 | run($func); 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | host = $host; 52 | $this->port = $port; 53 | $this->timeout = $timeout; 54 | $client = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP); 55 | $client->set([ 56 | 'open_eof_check' => true, 57 | 'package_eof' => static::EOF, 58 | ]); 59 | if (!$client->connect($host, $port, $timeout)) { 60 | throw new \Swoole\Exception(sprintf('Redis connect failed (host: %s, port: %s) %d %s', $host, $port, $client->errCode, $client->errMsg)); 61 | } 62 | $this->client = $client; 63 | } 64 | 65 | /** 66 | * Send 67 | * @param string $data 68 | * @return bool 69 | * @throws \Swoole\Exception 70 | */ 71 | public function send(string $data) 72 | { 73 | $len = strlen($data); 74 | $size = $this->client->send($data); 75 | if ($size === false) { 76 | throw new \Swoole\Exception($this->client->errMsg, $this->client->errCode); 77 | } 78 | if ($len !== $size) { 79 | throw new \Swoole\Exception('The sending data is incomplete, it may be that the socket has been closed by the peer.'); 80 | } 81 | return true; 82 | } 83 | 84 | /** 85 | * Recv 86 | * @return string|bool 87 | */ 88 | public function recv() 89 | { 90 | return $this->client->recv(-1); 91 | } 92 | 93 | /** 94 | * Close 95 | */ 96 | public function close() 97 | { 98 | if (!$this->closed && !$this->client->close()) { 99 | $errMsg = $this->client->errMsg; 100 | $errCode = $this->client->errCode; 101 | if ($errMsg == '' && $errCode == 0) { 102 | return; 103 | } 104 | throw new \Swoole\Exception($errMsg, $errCode); 105 | } 106 | $this->closed = true; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Subscriber.php: -------------------------------------------------------------------------------- 1 | host = $host; 68 | $this->port = $port; 69 | $this->password = $password; 70 | $this->timeout = $timeout; 71 | $this->prefix = $prefix; 72 | $this->connect(); 73 | } 74 | 75 | /** 76 | * Connect 77 | * @throws \Swoole\Exception 78 | */ 79 | protected function connect() 80 | { 81 | $connection = new Connection($this->host, $this->port, $this->timeout); 82 | $this->commandInvoker = new CommandInvoker($connection); 83 | if ('' != (string)$this->password) { 84 | $this->commandInvoker->invoke(["auth", $this->password], 1); 85 | } 86 | } 87 | 88 | /** 89 | * Subscribe 90 | * @param string ...$channels 91 | * @throws \Swoole\Exception 92 | * @throws \Throwable 93 | */ 94 | public function subscribe(string ...$channels) 95 | { 96 | $channels = array_map(function ($channel) { 97 | return $this->prefix . $channel; 98 | }, $channels); 99 | $result = $this->commandInvoker->invoke(["subscribe", ...$channels], count($channels)); 100 | foreach ($result as $value) { 101 | if ($value === false) { 102 | $this->commandInvoker->interrupt(); 103 | throw new SubscribeException('Subscribe failed'); 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * Unsubscribe 110 | * @param string ...$channels 111 | * @throws \Swoole\Exception 112 | * @throws \Throwable 113 | */ 114 | public function unsubscribe(string ...$channels) 115 | { 116 | $channels = array_map(function ($channel) { 117 | return $this->prefix . $channel; 118 | }, $channels); 119 | $result = $this->commandInvoker->invoke(["unsubscribe", ...$channels], count($channels)); 120 | foreach ($result as $value) { 121 | if ($value === false) { 122 | $this->commandInvoker->interrupt(); 123 | throw new UnsubscribeException('Unsubscribe failed'); 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * PSubscribe. 130 | * @throws \Swoole\Exception 131 | * @throws Throwable 132 | */ 133 | public function psubscribe(string ...$channels) 134 | { 135 | $channels = array_map(function ($channel) { 136 | return $this->prefix . $channel; 137 | }, $channels); 138 | $result = $this->commandInvoker->invoke(['psubscribe', ...$channels], count($channels)); 139 | foreach ($result as $value) { 140 | if ($value === false) { 141 | $this->commandInvoker->interrupt(); 142 | throw new SubscribeException('Psubscribe failed'); 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * PUnsubscribe. 149 | * @throws \Swoole\Exception 150 | * @throws Throwable 151 | */ 152 | public function punsubscribe(string ...$channels) 153 | { 154 | $channels = array_map(function ($channel) { 155 | return $this->prefix . $channel; 156 | }, $channels); 157 | $result = $this->commandInvoker->invoke(['punsubscribe', ...$channels], count($channels)); 158 | foreach ($result as $value) { 159 | if ($value === false) { 160 | $this->commandInvoker->interrupt(); 161 | throw new UnsubscribeException('Punsubscribe failed'); 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * Channel 168 | * @return \Swoole\Coroutine\Channel 169 | */ 170 | public function channel() 171 | { 172 | return $this->commandInvoker->channel(); 173 | } 174 | 175 | /** 176 | * Close 177 | * @throws \Swoole\Exception 178 | */ 179 | public function close() 180 | { 181 | $this->closed = true; 182 | $this->commandInvoker->interrupt(); 183 | } 184 | 185 | /** 186 | * Ping 187 | * @param int $timeout 188 | * @throws \Swoole\Exception 189 | */ 190 | public function ping(int $timeout = 1) 191 | { 192 | return $this->commandInvoker->ping($timeout); 193 | } 194 | 195 | } 196 | -------------------------------------------------------------------------------- /src/CommandInvoker.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 48 | $this->resultChannel = new Channel(); 49 | $this->pingChannel = new Channel(); 50 | $this->messageChannel = new Channel(100); 51 | Coroutine::create(function () use ($connection) { 52 | $this->receive($connection); 53 | }); 54 | } 55 | 56 | /** 57 | * Receive 58 | * @param Connection $connection 59 | * @throws \Swoole\Exception 60 | */ 61 | public function receive(Connection $connection) 62 | { 63 | $buffer = null; 64 | while (true) { 65 | $line = $connection->recv(); 66 | if ($line === false || $line === "") { 67 | $this->interrupt(); 68 | break; 69 | } 70 | $line = substr($line, 0, -(strlen(static::CRLF))); 71 | 72 | if ($line == '+OK') { 73 | $this->resultChannel->push($line); 74 | continue; 75 | } 76 | 77 | if ($line == '*3') { 78 | if (!empty($buffer)) { 79 | $this->resultChannel->push($buffer); 80 | $buffer = null; 81 | } 82 | $buffer[] = $line; 83 | continue; 84 | } 85 | 86 | $buffer[] = $line; 87 | 88 | $type = $buffer[2] ?? false; 89 | 90 | if ($type == 'subscribe' && count($buffer) == 6) { 91 | $this->resultChannel->push($buffer); 92 | $buffer = null; 93 | continue; 94 | } 95 | 96 | if ($type == 'unsubscribe' && count($buffer) == 6) { 97 | $this->resultChannel->push($buffer); 98 | $buffer = null; 99 | continue; 100 | } 101 | 102 | if ($type == 'message' && count($buffer) == 7) { 103 | $message = new Message(); 104 | $message->channel = $buffer[4]; 105 | $message->payload = $buffer[6]; 106 | $timerID = Timer::after(30 * 1000, function () use ($message) { 107 | static::error(sprintf('Message channel (%s) is 30 seconds full, disconnected', $message->channel)); 108 | $this->interrupt(); 109 | }); 110 | $this->messageChannel->push($message); 111 | Timer::clear($timerID); 112 | $buffer = null; 113 | continue; 114 | } 115 | 116 | if ($type == 'psubscribe' && count($buffer) == 6) { 117 | $this->resultChannel->push($buffer); 118 | $buffer = null; 119 | continue; 120 | } 121 | 122 | if ($type == 'punsubscribe' && count($buffer) == 6) { 123 | $this->resultChannel->push($buffer); 124 | $buffer = null; 125 | continue; 126 | } 127 | 128 | if ($type == 'pmessage' && count($buffer) == 9) { 129 | $message = new Message(); 130 | $message->pattern = $buffer[4]; 131 | $message->channel = $buffer[6]; 132 | $message->payload = $buffer[8]; 133 | $timerID = Timer::after(30 * 1000, function () use ($message) { 134 | static::error(sprintf('Message channel (%s) is 30 seconds full, disconnected', $message->channel)); 135 | $this->interrupt(); 136 | }); 137 | $this->messageChannel->push($message); 138 | Timer::clear($timerID); 139 | $buffer = null; 140 | continue; 141 | } 142 | 143 | if ($type == 'pong' && count($buffer) == 5) { 144 | $this->pingChannel->push('pong'); 145 | $buffer = null; 146 | continue; 147 | } 148 | } 149 | } 150 | 151 | /** 152 | * Invoke 153 | * @param string $command 154 | * @param int $number 155 | * @return array 156 | * @throws \Swoole\Exception 157 | */ 158 | public function invoke(mixed $command, int $number) 159 | { 160 | try { 161 | $this->connection->send(Resp::build($command)); 162 | } catch (\Throwable $e) { 163 | $this->interrupt(); 164 | throw $e; 165 | } 166 | $result = []; 167 | for ($i = 0; $i < $number; $i++) { 168 | $result[] = $this->resultChannel->pop(); 169 | } 170 | return $result; 171 | } 172 | 173 | /** 174 | * Channel 175 | * @return Channel 176 | */ 177 | public function channel() 178 | { 179 | return $this->messageChannel; 180 | } 181 | 182 | /** 183 | * Interrupt 184 | * @return bool 185 | * @throws \Swoole\Exception 186 | */ 187 | public function interrupt() 188 | { 189 | $this->connection->close(); 190 | $this->resultChannel->close(); 191 | $this->messageChannel->close(); 192 | return true; 193 | } 194 | 195 | /** 196 | * Ping 197 | * @param int $timeout 198 | * @return string 199 | * @throws \Swoole\Exception 200 | */ 201 | public function ping(int $timeout = 1) 202 | { 203 | $this->connection->send(Resp::build('ping')); 204 | return $this->pingChannel->pop($timeout); 205 | } 206 | 207 | /** 208 | * Print error 209 | * @param \Throwable $ex 210 | */ 211 | protected static function error(string $message) 212 | { 213 | $time = date('Y-m-d H:i:s'); 214 | echo "[error] $time $message\n"; 215 | } 216 | 217 | } 218 | --------------------------------------------------------------------------------