Initial commit

This commit is contained in:
2021-01-18 16:09:51 +03:00
commit 7c6284efcb
19 changed files with 827 additions and 0 deletions

122
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,122 @@
name: CI
on:
- pull_request
- push
jobs:
tests:
name: Tests
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
php:
- '7.4'
- '8.0'
dependencies:
- lowest
- highest
services:
nsqd:
image: nsqio/nsq:v1.2.0
options: --entrypoint /nsqd
ports:
- 4150:4150
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: pcov
- name: Setup Problem Matchers for PHPUnit
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Determine Composer cache directory
id: composer-cache
run: echo "::set-output name=directory::$(composer config cache-dir)"
- name: Cache Composer dependencies
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.directory }}
key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.dependencies }}-composer-
- name: Install highest dependencies
run: composer update --no-progress --no-interaction --prefer-dist
if: ${{ matrix.dependencies == 'highest' }}
- name: Install lowest dependencies
run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest
if: ${{ matrix.dependencies == 'lowest' }}
- name: Run tests
run: vendor/bin/phpunit --coverage-clover=build/coverage-report.xml
- name: Upload code coverage
uses: codecov/codecov-action@v1
with:
file: build/coverage-report.xml
php-cs-fixer:
name: PHP-CS-Fixer
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
- name: Install dependencies
run: composer update --no-progress --no-interaction --prefer-dist
- name: Run script
run: composer phpcs
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
- name: Install dependencies
run: composer update --no-progress --no-interaction --prefer-dist
- name: Run script
run: composer phpstan
psalm:
name: Psalm
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
- name: Install dependencies
run: composer update --no-progress --no-interaction --prefer-dist
- name: Run script
run: composer psalm

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/vendor/
/composer.lock

19
.php_cs.dist Normal file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env php
<?php
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@PhpCsFixer' => true,
'@PhpCsFixer:risky' => true,
'@PSR12' => true,
'@PSR12:risky' => true,
'declare_strict_types' => true,
'php_unit_internal_class' => false,
'php_unit_test_class_requires_covers' => false,
'yoda_style' => true,
])
->setFinder(
PhpCsFixer\Finder::create()
->in(__DIR__)
);

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 nsqphp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# Nsq PHP
<img src="https://github.com/nsqphp/nsqphp/raw/main/logo.png" alt="" align="left" width="150">
A NSQ Client library for PHP.
[![Latest Stable Version](https://poser.pugx.org/nsq/nsq/v)](//packagist.org/packages/nsq/nsq) [![Total Downloads](https://poser.pugx.org/nsq/nsq/downloads)](//packagist.org/packages/nsq/nsq) [![Latest Unstable Version](https://poser.pugx.org/nsq/nsq/v/unstable)](//packagist.org/packages/nsq/nsq) [![License](https://poser.pugx.org/nsq/nsq/license)](//packagist.org/packages/nsq/nsq)
[![codecov](https://codecov.io/gh/nsqphp/nsqphp/branch/main/graph/badge.svg?token=AYUMC3OO2B)](https://codecov.io/gh/nsqphp/nsqphp)
Installation
------------
This library is installable via [Composer](https://getcomposer.org/):
```bash
composer require nsq/nsq
```
Requirements
------------
This library requires PHP 7.4 or later.
Although not required, it is recommended that you install the [phpinnacle/ext-buffer](https://github.com/phpinnacle/ext-buffer) to speed up [phpinnacle/buffer](https://github.com/phpinnacle/buffer) .
Features
--------
- [x] SUB
- [x] PUB
- [ ] Feature Negotiation
- [ ] Discovery
- [ ] Backoff
- [ ] TLS
- [ ] Snappy
- [ ] Sampling
- [ ] AUTH
License:
--------
The MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. Maintained by [Spiral Scout](https://spiralscout.com).

52
composer.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "nsq/nsq",
"type": "library",
"description": "NSQ Client for PHP",
"homepage": "https://github.com/nsqphp/nsqphp",
"license": "MIT",
"authors": [
{
"name": "Konstantin Grachev",
"email": "me@grachevko.ru"
}
],
"require": {
"php": ">=7.4",
"ext-json": "*",
"clue/socket-raw": "^1.5",
"phpinnacle/buffer": "^1.2"
},
"require-dev": {
"ergebnis/composer-normalize": "9999999-dev",
"friendsofphp/php-cs-fixer": "^2.18",
"phpstan/phpstan": "^0.12.68",
"phpstan/phpstan-phpunit": "^0.12.17",
"phpstan/phpstan-strict-rules": "^0.12.9",
"phpunit/phpunit": "^9.5",
"vimeo/psalm": "^4.4"
},
"config": {
"sort-packages": true
},
"autoload": {
"psr-4": {
"Nsq\\": "src/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"phpcs": [
"vendor/bin/php-cs-fixer fix --verbose --diff --dry-run"
],
"phpstan": [
"vendor/bin/phpstan analyse"
],
"psalm": [
"vendor/bin/psalm"
],
"tests": [
"vendor/bin/phpunit --verbose"
]
}
}

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
version: '3.7'
services:
nsqd:
image: nsqio/nsq:v1.2.0
command: /nsqd
ports:
- 4150:4150
nsqadmin:
image: nsqio/nsq:v1.2.0
command: /nsqadmin --nsqd-http-address=nsqd:4151 --http-address=0.0.0.0:4171
ports:
- 4171:4171

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

10
phpstan.neon Normal file
View File

@ -0,0 +1,10 @@
includes:
- vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
parameters:
level: 7
paths:
- src
- tests

20
phpunit.xml.dist Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
colors="true"
>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./src/</directory>
</include>
</coverage>
<php>
<ini name="error_reporting" value="-1"/>
</php>
<testsuites>
<testsuite name="Nsq Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

16
psalm.xml Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0"?>
<psalm
allowPhpStormGenerics="true"
ignoreInternalFunctionFalseReturn="false"
ignoreInternalFunctionNullReturn="false"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src"/>
<ignoreFiles>
<directory name="vendor"/>
</ignoreFiles>
</projectFiles>
</psalm>

19
src/Config.php Normal file
View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Nsq;
/**
* @psalm-immutable
*/
final class Config
{
public string $address;
public function __construct(
string $address
) {
$this->address = $address;
}
}

136
src/Connection.php Normal file
View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Nsq;
use LogicException;
use PHPinnacle\Buffer\ByteBuffer;
use Socket\Raw\Factory;
use Socket\Raw\Socket;
use Throwable;
use function json_encode;
use function pack;
use function sprintf;
use const JSON_FORCE_OBJECT;
use const JSON_THROW_ON_ERROR;
use const PHP_EOL;
final class Connection
{
private const OK = 'OK';
private const HEARTBEAT = '_heartbeat_';
private const CLOSE_WAIT = 'CLOSE_WAIT';
private const TYPE_RESPONSE = 0;
private const TYPE_ERROR = 1;
private const TYPE_MESSAGE = 2;
private const BYTES_SIZE = 4;
private const BYTES_TYPE = 4;
private const BYTES_ATTEMPTS = 2;
private const BYTES_TIMESTAMP = 8;
private const BYTES_ID = 16;
private const MAGIC_V2 = ' V2';
public Socket $socket;
public bool $closed = false;
private function __construct(Socket $socket)
{
$this->socket = $socket;
}
/**
* @psalm-suppress UnsafeInstantiation
*
* @return static
*/
public static function connect(Config $config): self
{
$socket = (new Factory())->createClient($config->address);
$socket->write(self::MAGIC_V2);
// @phpstan-ignore-next-line
return new self($socket);
}
/**
* @psalm-param array<string, string|numeric> $arr
*
* @psalm-suppress PossiblyFalseOperand
*/
public function identify(array $arr): string
{
$body = json_encode($arr, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT);
$size = pack('N', \strlen($body));
return 'IDENTIFY '.PHP_EOL.$size.$body;
}
/**
* @psalm-suppress PossiblyFalseOperand
*/
public function auth(string $secret): string
{
$size = pack('N', \strlen($secret));
return 'AUTH'.PHP_EOL.$size.$secret;
}
public function write(string $buffer): void
{
if ($this->closed) {
throw new LogicException('This connection is closed, create new one.');
}
try {
$this->socket->write($buffer);
} catch (Throwable $e) {
$this->closed = true;
throw $e;
}
}
public function read(): ?Message
{
$socket = $this->socket;
$buffer = new ByteBuffer($socket->read(self::BYTES_SIZE + self::BYTES_TYPE));
$size = $buffer->consumeUint32();
$type = $buffer->consumeUint32();
$buffer->append($socket->read($size - self::BYTES_TYPE));
if (self::TYPE_RESPONSE === $type) {
$response = $buffer->consume($size - self::BYTES_TYPE);
if (self::OK === $response || self::CLOSE_WAIT === $response) {
return null;
}
if (self::HEARTBEAT === $response) {
$socket->write('NOP'.PHP_EOL);
return null;
}
throw new LogicException(sprintf('Unexpected response from nsq: "%s"', $response));
}
if (self::TYPE_ERROR === $type) {
throw new LogicException(sprintf('NSQ return error: "%s"', $socket->read($size)));
}
if (self::TYPE_MESSAGE !== $type) {
throw new LogicException(sprintf('Expecting "%s" type, but NSQ return: "%s"', self::TYPE_MESSAGE, $type));
}
$timestamp = $buffer->consumeInt64();
$attempts = $buffer->consumeUint16();
$id = $buffer->consume(self::BYTES_ID);
$body = $buffer->consume($size - self::BYTES_TYPE - self::BYTES_TIMESTAMP - self::BYTES_ATTEMPTS - self::BYTES_ID);
return new Message($timestamp, $attempts, $id, $body);
}
}

51
src/Envelope.php Normal file
View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Nsq;
/**
* @psalm-immutable
*/
final class Envelope
{
public Message $message;
/**
* @var callable
*/
private $acknowledge;
/**
* @var callable
*/
private $requeue;
/**
* @var callable
*/
private $touching;
public function __construct(Message $message, callable $ack, callable $req, callable $touch)
{
$this->message = $message;
$this->acknowledge = $ack;
$this->requeue = $req;
$this->touching = $touch;
}
public function ack(): void
{
\call_user_func($this->acknowledge);
}
public function retry(int $timeout): void
{
\call_user_func($this->requeue, $timeout);
}
public function touch(): void
{
\call_user_func($this->touching);
}
}

27
src/Message.php Normal file
View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Nsq;
/**
* @psalm-immutable
*/
final class Message
{
public int $timestamp;
public int $attempts;
public string $id;
public string $body;
public function __construct(int $timestamp, int $attempts, string $id, string $body)
{
$this->timestamp = $timestamp;
$this->attempts = $attempts;
$this->id = $id;
$this->body = $body;
}
}

99
src/Reader.php Normal file
View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Nsq;
use Throwable;
use function sprintf;
use const PHP_EOL;
class Reader
{
private Connection $connection;
public function __construct(Connection $connection)
{
$this->connection = $connection;
}
public function __destruct()
{
$this->close();
}
/**
* Subscribe to a topic/channel.
*/
public function sub(string $topic, string $channel): void
{
$buffer = sprintf('SUB %s %s', $topic, $channel).PHP_EOL;
$this->connection->write($buffer);
$this->connection->read();
}
/**
* Update RDY state (indicate you are ready to receive N messages).
*/
public function rdy(int $count): void
{
$this->connection->write('RDY '.$count.PHP_EOL);
}
/**
* Finish a message (indicate successful processing).
*/
public function fin(string $id): void
{
$this->connection->write('FIN '.$id.PHP_EOL);
}
/**
* Re-queue a message (indicate failure to process)
* The re-queued message is placed at the tail of the queue, equivalent to having just published it,
* but for various implementation specific reasons that behavior should not be explicitly relied upon and may change in the future.
* Similarly, a message that is in-flight and times out behaves identically to an explicit REQ.
*/
public function req(string $id, int $timeout): void
{
$this->connection->write(sprintf('REQ %s %s', $id, $timeout).PHP_EOL);
}
/**
* Reset the timeout for an in-flight message.
*/
public function touch(string $id): void
{
$this->connection->write('TOUCH '.$id.PHP_EOL);
}
public function consume(?float $timeout = null): ?Message
{
if (false === $this->connection->socket->selectRead($timeout)) {
return null;
}
return $this->connection->read() ?? $this->consume(0);
}
/**
* Cleanly close your connection (no more messages are sent).
*/
public function close(): void
{
if ($this->connection->closed) {
return;
}
$this->connection->closed = true;
$this->connection->socket->write('CLS'.PHP_EOL);
$this->connection->read();
try {
$this->connection->socket->close();
} catch (Throwable $e) {
}
}
}

74
src/Subscriber.php Normal file
View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Nsq;
use Generator;
use LogicException;
final class Subscriber
{
private Reader $reader;
public function __construct(Reader $reader)
{
$this->reader = $reader;
}
/**
* @psalm-return Generator<int, Envelope|null, true|null, void>
*/
public function subscribe(string $topic, string $channel, ?float $timeout = 0): Generator
{
$reader = $this->reader;
$reader->sub($topic, $channel);
$reader->rdy(1);
while (true) {
$message = $reader->consume($timeout);
if (null === $message) {
if (true === yield null) {
break;
}
continue;
}
$finished = false;
$envelop = new Envelope(
$message,
static function () use ($reader, $message, &$finished): void {
if ($finished) {
throw new LogicException('Can\'t ack, message already finished.');
}
$finished = true;
$reader->fin($message->id);
},
static function (int $timeout) use ($reader, $message, &$finished): void {
if ($finished) {
throw new LogicException('Can\'t retry, message already finished.');
}
$finished = true;
$reader->req($message->id, $timeout);
},
static function () use ($reader, $message): void {
$reader->touch($message->id);
},
);
if (true === yield $envelop) {
break;
}
$reader->rdy(1);
}
$reader->close();
}
}

68
src/Writer.php Normal file
View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Nsq;
use function array_map;
use function implode;
use function pack;
use function sprintf;
use const PHP_EOL;
final class Writer
{
private Connection $connection;
public function __construct(Connection $connection)
{
$this->connection = $connection;
}
/**
* @psalm-suppress PossiblyFalseOperand
*/
public function pub(string $topic, string $body): void
{
$size = pack('N', \strlen($body));
$buffer = 'PUB '.$topic.PHP_EOL.$size.$body;
$this->connection->write($buffer);
$this->connection->read();
}
/**
* @psalm-param array<mixed, mixed> $bodies
*
* @psalm-suppress PossiblyFalseOperand
*/
public function mpub(string $topic, array $bodies): void
{
$num = pack('N', \count($bodies));
$mb = implode('', array_map(static function ($body): string {
return pack('N', \strlen($body)).$body;
}, $bodies));
$size = pack('N', \strlen($num.$mb));
$buffer = 'MPUB '.$topic.PHP_EOL.$size.$num.$mb;
$this->connection->write($buffer);
$this->connection->read();
}
/**
* @psalm-suppress PossiblyFalseOperand
*/
public function dpub(string $topic, int $deferTime, string $body): void
{
$size = pack('N', \strlen($body));
$buffer = sprintf('DPUB %s %s', $topic, $deferTime).PHP_EOL.$size.$body;
$this->connection->write($buffer);
$this->connection->read();
}
}

35
tests/NsqTest.php Normal file
View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Nsq\Config;
use Nsq\Connection;
use Nsq\Envelope;
use Nsq\Reader;
use Nsq\Subscriber;
use Nsq\Writer;
use PHPUnit\Framework\TestCase;
final class NsqTest extends TestCase
{
public function test(): void
{
$config = new Config('tcp://localhost:4150');
$writer = new Writer(Connection::connect($config));
$writer->pub(__FUNCTION__, __FUNCTION__);
$subscriber = new Subscriber(new Reader(Connection::connect($config)));
$generator = $subscriber->subscribe(__FUNCTION__, __FUNCTION__, 1);
$envelope = $generator->current();
static::assertInstanceOf(Envelope::class, $envelope);
/** @var Envelope $envelope */
static::assertSame(__FUNCTION__, $envelope->message->body);
$envelope->ack();
$generator->next();
static::assertNull($generator->current());
}
}