├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── spec └── Nsq │ ├── Message │ └── JsonMessageSpec.php │ └── NsqPoolSpec.php └── src └── Nsq ├── Exception ├── PubException.php └── SocketException.php ├── Message ├── JsonMessage.php └── MessageInterface.php ├── NsqPool.php ├── Response.php └── Socket ├── PhpSocket.php └── SocketInterface.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /bin 3 | /composer.lock 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 DATA-DOG 2 | 3 | The MIT license, reference http://www.opensource.org/licenses/mit-license.php 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NSQ publisher library for PHP 2 | 3 | This library ONLY publishes messages to NSQ nodes. Requires standard php socket extension. 4 | 5 | ## Install 6 | 7 | Add to composer.json: 8 | 9 | ``` json 10 | { 11 | "require": { 12 | "data-dog/php-nsq": "~0.2.0" 13 | } 14 | } 15 | ``` 16 | 17 | ## Usage example 18 | 19 | ``` php 20 | publish('my_topic', new JsonMessage(['message' => 'data'])); 34 | ``` 35 | 36 | ## Run tests 37 | 38 | composer install 39 | ./bin/phpspec run 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-dog/php-nsq", 3 | "description": "NSQ publisher for PHP", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "nsq", "publisher", "pub" 8 | ], 9 | "homepage": "https://github.com/DATA-DOG/php-nsq", 10 | 11 | "require": { 12 | "php": ">=5.3.0", 13 | 14 | "ext-sockets": "*" 15 | }, 16 | 17 | "require-dev": { 18 | "phpspec/phpspec": "~2.0" 19 | }, 20 | 21 | "config": { 22 | "bin-dir": "bin" 23 | }, 24 | 25 | "autoload": { 26 | "psr-0": { 27 | "Nsq\\": "src/" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /spec/Nsq/Message/JsonMessageSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType('Nsq\Message\JsonMessage'); 13 | } 14 | 15 | function it_should_implement_message_interface() 16 | { 17 | $this->shouldImplement('Nsq\Message\MessageInterface'); 18 | } 19 | 20 | function it_should_transform_payload_to_json() 21 | { 22 | $this->beConstructedWith(array('key' => 'val', 'arr' => [])); 23 | $this->payload()->shouldBe('{"key":"val","arr":[]}'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /spec/Nsq/NsqPoolSpec.php: -------------------------------------------------------------------------------- 1 | __toString()->willReturn('conn1'); 22 | $conn2->__toString()->willReturn('conn2'); 23 | $this->beConstructedWith($conn1, $conn2); 24 | 25 | $failed->isOk()->willReturn(false); 26 | $failed->code()->willReturn('E_FAILED'); 27 | 28 | $success->isOk()->willReturn(true); 29 | $success->code()->willReturn('OK'); 30 | } 31 | 32 | function it_is_initializable() 33 | { 34 | $this->shouldHaveType('Nsq\NsqPool'); 35 | } 36 | 37 | function it_should_publish_message_to_all_connections($conn1, $conn2, $message, $success) 38 | { 39 | $topic = 'topic'; 40 | $conn1->publish($topic, $message)->shouldBeCalled()->willReturn($success); 41 | $conn2->publish($topic, $message)->shouldBeCalled()->willReturn($success); 42 | 43 | $this->publish($topic, $message, NsqPool::NSQ_ALL); 44 | } 45 | 46 | function it_should_fail_if_not_all_messages_are_published($conn1, $conn2, $message, $success, $failed) 47 | { 48 | $topic = 'topic'; 49 | $conn1->publish($topic, $message)->shouldBeCalled()->willReturn($success); 50 | $conn2->publish($topic, $message)->shouldBeCalled()->willReturn($failed); 51 | 52 | $this->shouldThrow('Nsq\Exception\PubException')->duringPublish($topic, $message, NsqPool::NSQ_ALL); 53 | } 54 | 55 | function it_should_allow_one_node_to_fail_by_default($conn1, $conn2, $message, $success, $failed) 56 | { 57 | $topic = 'topic'; 58 | $conn1->publish($topic, $message)->shouldBeCalled()->willReturn($success); 59 | $conn2->publish($topic, $message)->shouldBeCalled()->willReturn($failed); 60 | 61 | $this->publish($topic, $message); 62 | } 63 | 64 | function it_should_not_allow_both_nodes_to_fail_by_default($conn1, $conn2, $message, $failed) 65 | { 66 | $topic = 'topic'; 67 | $conn1->publish($topic, $message)->shouldBeCalled()->willReturn($failed); 68 | $conn2->publish($topic, $message)->shouldBeCalled()->willReturn($failed); 69 | 70 | $this->shouldThrow('Nsq\Exception\PubException')->duringPublish($topic, $message); 71 | } 72 | 73 | function it_should_publish_to_only_one_node($conn1, $conn2, $message, $success) 74 | { 75 | $topic = 'topic'; 76 | $conn1->publish($topic, $message)->shouldBeCalled()->willReturn($success); 77 | $conn2->publish($topic, $message)->shouldNotBeCalled(); 78 | 79 | $this->publish($topic, $message, NsqPool::NSQ_ONLY_ONE); 80 | } 81 | 82 | function it_should_fail_if_none_of_nodes_were_successful($conn1, $conn2, $message, $failed) 83 | { 84 | $topic = 'topic'; 85 | $conn1->publish($topic, $message)->shouldBeCalled()->willReturn($failed); 86 | $conn2->publish($topic, $message)->shouldBeCalled()->willReturn($failed); 87 | 88 | $this->shouldThrow('Nsq\Exception\PubException')->duringPublish($topic, $message, NsqPool::NSQ_ONLY_ONE); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Nsq/Exception/PubException.php: -------------------------------------------------------------------------------- 1 | data = $data; 12 | } 13 | 14 | public function payload() 15 | { 16 | $json = @json_encode($this->data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT); 17 | 18 | if (JSON_ERROR_NONE !== json_last_error()) { 19 | throw new \InvalidArgumentException($this->transformJsonError()); 20 | } 21 | return $json; 22 | } 23 | 24 | private function transformJsonError() 25 | { 26 | if (function_exists('json_last_error_msg')) { 27 | return json_last_error_msg(); 28 | } 29 | 30 | switch (json_last_error()) { 31 | case JSON_ERROR_DEPTH: 32 | return 'Maximum stack depth exceeded.'; 33 | 34 | case JSON_ERROR_STATE_MISMATCH: 35 | return 'Underflow or the modes mismatch.'; 36 | 37 | case JSON_ERROR_CTRL_CHAR: 38 | return 'Unexpected control character found.'; 39 | 40 | case JSON_ERROR_SYNTAX: 41 | return 'Syntax error, malformed JSON.'; 42 | 43 | case JSON_ERROR_UTF8: 44 | return 'Malformed UTF-8 characters, possibly incorrectly encoded.'; 45 | 46 | default: 47 | return 'Unknown error.'; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Nsq/Message/MessageInterface.php: -------------------------------------------------------------------------------- 1 | connections = array_map(function (SocketInterface $connection) { 44 | return $connection; 45 | }, func_get_args()); 46 | } 47 | 48 | /** 49 | * Add a socket connection to NSQ node 50 | * 51 | * @param SocketInterface $connection 52 | */ 53 | public function addConnection(SocketInterface $connection) 54 | { 55 | $this->connections[] = $connection; 56 | return $this; 57 | } 58 | 59 | /** 60 | * Publish a message to NSQ 61 | * 62 | * @param string $topic 63 | * @param \Nsq\MessageInterface $msg 64 | * @param string $strategy 65 | * @return void 66 | */ 67 | public function publish($topic, MessageInterface $msg, $strategy = self::NSQ_AT_LEAST_ONE) 68 | { 69 | $this->doPublish($topic, array($msg), $strategy); 70 | } 71 | 72 | /** 73 | * Publish multiple messages to NSQ 74 | * 75 | * @param string $topic 76 | * @param array $msgs - elements are instance of \Nsq\Message\MessageInterface 77 | * @param string $strategy 78 | * @return void 79 | */ 80 | public function mpublish($topic, array $msgs, $strategy = self::NSQ_AT_LEAST_ONE) 81 | { 82 | $this->doPublish($topic, $msgs, $strategy); 83 | } 84 | 85 | /** 86 | * Does the actual publishing work 87 | * 88 | * @param string $topic 89 | * @param array $msgs 90 | * @param string $strategy 91 | * 92 | * @throws PubException - if strategy requirements are not met 93 | */ 94 | protected function doPublish($topic, array $msgs, $strategy) 95 | { 96 | $success = 0; 97 | $errs = array(); 98 | if (count($this->connections) === 0) { 99 | $errs[] = "There are no NSQ connections in the pool."; 100 | } 101 | foreach ($this->connections as $connection) { 102 | try { 103 | if (count($msgs) > 1) { 104 | $response = $connection->mpublish($topic, $msgs); 105 | } else { 106 | $response = $connection->publish($topic, $msgs[0]); 107 | } 108 | if ($response->isOk()) { 109 | $success++; 110 | } 111 | $errs[] = "{$connection} -> {$response->code()}"; 112 | if (self::NSQ_ONLY_ONE === $strategy && $success === 1) { 113 | return; // one node has received a message 114 | } 115 | } catch(SocketException $e) { 116 | // do nothing here, does not increment success count 117 | $errs[] = "{$connection} -> has failed with socket exception: {$e->getMessage()}."; 118 | } 119 | } 120 | if ($strategy === self::NSQ_QUORUM) { 121 | $required = ceil(count($this->connections) / 2) + 1; 122 | } elseif ($strategy === self::NSQ_ALL) { 123 | $required = count($this->connections); 124 | } else { 125 | $required = 1; // defaults to at least one 126 | } 127 | if ($required > $success) { 128 | throw new PubException("Required at least {$required} nodes to be successful, but only {$success} were, details:\n\t".implode("\n\t", $errs)); 129 | } 130 | } 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/Nsq/Response.php: -------------------------------------------------------------------------------- 1 | code = $code; 12 | } 13 | 14 | public function isOk() 15 | { 16 | return $this->code === 'OK'; 17 | } 18 | 19 | public function code() 20 | { 21 | return $this->code; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Nsq/Socket/PhpSocket.php: -------------------------------------------------------------------------------- 1 | self::SOCKET_TIMEOUT_S, 50 | 'usec' => self::SOCKET_TIMEOUT_MS, 51 | ); 52 | 53 | /** 54 | * @param string $host 55 | * @param int $port 56 | * @param array $timeout - socket timeout [sec => int, usec => int] 57 | * 58 | * @throws \Nsq\Exception\SocketException - when fails to connect 59 | */ 60 | public function __construct($host, $port = 4150, array $timeout = array()) 61 | { 62 | $this->host = $host; 63 | $this->port = $port; 64 | $this->timeout = array_merge($this->timeout, $timeout); 65 | } 66 | 67 | /** 68 | * Closes a socket if it was open 69 | */ 70 | public function __destruct() 71 | { 72 | // close the socket if opened 73 | if (is_resource($this->socket)) { 74 | @socket_shutdown($this->socket); 75 | @socket_close($this->socket); 76 | } 77 | } 78 | 79 | /** 80 | * {@inheritDoc} 81 | */ 82 | public function publish($topic, MessageInterface $msg) 83 | { 84 | $msg = $msg->payload(); 85 | $cmd = sprintf("PUB %s\n%s%s", $topic, pack('N', strlen($msg)), $msg); 86 | $this->write($cmd); 87 | return $this->response(); 88 | } 89 | 90 | /** 91 | * {@inheritDoc} 92 | */ 93 | public function mpublish($topic, array $msgs) 94 | { 95 | if (!count($msgs)) { 96 | throw new \InvalidArgumentException("Expecting at least one message to publish."); 97 | } 98 | $cmd = sprintf("MPUB %s\n%s", $topic, pack('N', count($msgs))); 99 | foreach ($msgs as $msg) { 100 | $msg = $msg->payload(); 101 | $cmd .= pack('N', strlen($msg)); 102 | $cmd .= $msg; 103 | } 104 | $this->write($cmd); 105 | return $this->response(); 106 | } 107 | 108 | /** 109 | * {@inheritDoc} 110 | */ 111 | public function __toString() 112 | { 113 | return "{$this->host}:{$this->port}"; 114 | } 115 | 116 | /** 117 | * Writes data. 118 | * 119 | * @param string $data 120 | * @return void 121 | */ 122 | private function write($data) 123 | { 124 | for ($written = 0, $fwrite = 0; $written < strlen($data); $written += $fwrite) { 125 | $fwrite = @socket_write($this->getConnection(), substr($data, $written)); 126 | if ($fwrite === false) { 127 | $this->error("Failed to write buffer to socket"); 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Reads up to $length bytes. 134 | * 135 | * @return string 136 | */ 137 | private function read($length) 138 | { 139 | $read = 0; 140 | $parts = []; 141 | 142 | while ($read < $length) { 143 | $data = @socket_read($this->socket, $length - $read, PHP_BINARY_READ); 144 | if ($data === false) { 145 | $this->error("Failed to read data from socket"); 146 | } 147 | $read += strlen($data); 148 | $parts[] = $data; 149 | } 150 | 151 | return implode($parts); 152 | } 153 | 154 | /** 155 | * {@inheritDoc} 156 | */ 157 | private function response() 158 | { 159 | $len = $this->readInt(); 160 | if ($len <= 0) { 161 | throw new SocketException("Failed to read response, length is: {$len}"); 162 | } 163 | // read frame type 164 | switch ($type = $this->readInt()) { 165 | case self::NSQ_RESPONSE: 166 | case self::NSQ_ERROR: 167 | return new Response($this->readString($len - 4)); 168 | default: 169 | throw new SocketException("Unsupported NSQ response frame type: {$type}"); 170 | } 171 | } 172 | 173 | /** 174 | * Read a length and unpack binary data 175 | * 176 | * @param integer $len 177 | * @return string - trimmed 178 | */ 179 | private function readString($len) 180 | { 181 | $data = unpack("c{$len}chars", $this->read($len)); 182 | $ret = ""; 183 | foreach($data as $c) { 184 | if ($c > 0) { 185 | $ret .= chr($c); 186 | } 187 | } 188 | return trim($ret); 189 | } 190 | 191 | /** 192 | * Read and unpack integer (4 bytes) 193 | * 194 | * @return integer 195 | */ 196 | private function readInt() 197 | { 198 | list(,$res) = unpack('N', $this->read(4)); 199 | if (PHP_INT_SIZE !== 4) { 200 | $res = sprintf("%u", $res); 201 | } 202 | return intval($res); 203 | } 204 | 205 | /** 206 | * Fail with connection error 207 | * 208 | * @param string $msg 209 | * 210 | * @throws \Nsq\Exception\SocketException 211 | */ 212 | private function error($msg) 213 | { 214 | $errmsg = @socket_strerror($errno = socket_last_error($this->socket)); 215 | throw new SocketException("{$errmsg} -> {$msg}", $errno); 216 | } 217 | 218 | /** 219 | * Lazy socket connection 220 | * 221 | * @return resource Socket connection 222 | */ 223 | private function getConnection() 224 | { 225 | if ($this->socket !== null) { 226 | return $this->socket; 227 | } 228 | 229 | // see http://www.php.net/manual/en/function.socket-create.php 230 | $this->socket = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP); 231 | if ($this->socket === false) { 232 | throw new SocketException("Failed to open TCP stream socket"); 233 | } 234 | if (@socket_connect($this->socket, $this->host, $this->port) === false) { 235 | $this->error("Failed to connect socket to {$this->host}:{$this->port}"); 236 | } 237 | if (@socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, $this->timeout) === false) { 238 | $this->error("Failed to set socket stream timeout option"); 239 | } 240 | // must send a protocol version 241 | $this->write(self::NSQ_V2); 242 | 243 | return $this->socket; 244 | } 245 | } 246 | 247 | 248 | -------------------------------------------------------------------------------- /src/Nsq/Socket/SocketInterface.php: -------------------------------------------------------------------------------- 1 |