├── .github └── workflows │ ├── ci.yaml │ └── dispatch.yaml ├── .gitignore ├── LICENSE ├── README-CN.md ├── README.md ├── composer.json ├── examples ├── consumer-not-loop.php ├── consumer.php ├── producer-keepalive.php ├── producer.php ├── reader.php ├── schema │ ├── consumer.php │ ├── model.php │ └── producer.php └── workflows │ ├── consumer.php │ ├── keepalive.php │ └── producer.php └── src ├── Authentication ├── Authentication.php ├── Basic.php └── Jwt.php ├── Client.php ├── Compression ├── Compression.php ├── Factory.php ├── NoneCompression.php ├── ZlibCompression.php └── ZstdCompression.php ├── Consumer.php ├── ConsumerOptions.php ├── Exception ├── ConnectException.php ├── IOException.php ├── MessageNotFound.php ├── OptionsException.php └── RuntimeException.php ├── IO ├── AbstractIO.php ├── ChannelManager.php ├── EventLoop.php ├── Factory.php ├── Reader.php ├── Select.php └── StreamIO.php ├── Lookup ├── HttpLookupService.php ├── LookupService.php ├── Result.php └── TcpLookupService.php ├── Message.php ├── MessageOptions.php ├── MessageSchema.php ├── Options.php ├── PartitionConsumer.php ├── PartitionProducer.php ├── Policy └── DeadLetterPolicy.php ├── Producer.php ├── ProducerOptions.php ├── Proto ├── AuthData.php ├── AuthMethod.php ├── BaseCommand.php ├── BaseCommand │ └── Type.php ├── BrokerEntryMetadata.php ├── CommandAck.php ├── CommandAck │ ├── AckType.php │ └── ValidationError.php ├── CommandAckResponse.php ├── CommandActiveConsumerChange.php ├── CommandAddPartitionToTxn.php ├── CommandAddPartitionToTxnResponse.php ├── CommandAddSubscriptionToTxn.php ├── CommandAddSubscriptionToTxnResponse.php ├── CommandAuthChallenge.php ├── CommandAuthResponse.php ├── CommandCloseConsumer.php ├── CommandCloseProducer.php ├── CommandConnect.php ├── CommandConnected.php ├── CommandConsumerStats.php ├── CommandConsumerStatsResponse.php ├── CommandEndTxn.php ├── CommandEndTxnOnPartition.php ├── CommandEndTxnOnPartitionResponse.php ├── CommandEndTxnOnSubscription.php ├── CommandEndTxnOnSubscriptionResponse.php ├── CommandEndTxnResponse.php ├── CommandError.php ├── CommandFlow.php ├── CommandGetLastMessageId.php ├── CommandGetLastMessageIdResponse.php ├── CommandGetOrCreateSchema.php ├── CommandGetOrCreateSchemaResponse.php ├── CommandGetSchema.php ├── CommandGetSchemaResponse.php ├── CommandGetTopicsOfNamespace.php ├── CommandGetTopicsOfNamespace │ └── Mode.php ├── CommandGetTopicsOfNamespaceResponse.php ├── CommandLookupTopic.php ├── CommandLookupTopicResponse.php ├── CommandLookupTopicResponse │ └── LookupType.php ├── CommandMessage.php ├── CommandNewTxn.php ├── CommandNewTxnResponse.php ├── CommandPartitionedTopicMetadata.php ├── CommandPartitionedTopicMetadataResponse.php ├── CommandPartitionedTopicMetadataResponse │ └── LookupType.php ├── CommandPing.php ├── CommandPong.php ├── CommandProducer.php ├── CommandProducerSuccess.php ├── CommandReachedEndOfTopic.php ├── CommandRedeliverUnacknowledgedMessages.php ├── CommandSeek.php ├── CommandSend.php ├── CommandSendError.php ├── CommandSendReceipt.php ├── CommandSubscribe.php ├── CommandSubscribe │ ├── InitialPosition.php │ └── SubType.php ├── CommandSuccess.php ├── CommandTcClientConnectRequest.php ├── CommandTcClientConnectResponse.php ├── CommandUnsubscribe.php ├── CompressionType.php ├── EncryptionKeys.php ├── FeatureFlags.php ├── IntRange.php ├── KeyLongValue.php ├── KeySharedMeta.php ├── KeySharedMode.php ├── KeyValue.php ├── MessageIdData.php ├── MessageMetadata.php ├── ProducerAccessMode.php ├── ProtocolVersion.php ├── Schema.php ├── Schema │ └── Type.php ├── ServerError.php ├── SingleMessageMetadata.php ├── Subscription.php └── TxnAction.php ├── PulsarApi.proto ├── Reader.php ├── ReaderOptions.php ├── Response.php ├── Schema ├── AbstractSchema.php ├── ISchema.php ├── SchemaDouble.php ├── SchemaInt16.php ├── SchemaInt32.php ├── SchemaInt64.php ├── SchemaInt8.php ├── SchemaJson.php └── SchemaString.php ├── SubscriptionType.php ├── TLSOptions.php ├── TopicManage.php ├── Traits └── ProducerKeepAlive.php └── Util ├── Buffer.php ├── Builder.php ├── Helper.php ├── Packer.php ├── Tracking.php ├── TypeParser.php ├── ping.bytes └── pong.bytes /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Push Tag Test Pulsar 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | start: 9 | strategy: 10 | matrix: 11 | php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4" ] 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | # checkout 16 | - name: checkout 17 | uses: actions/checkout@v3.0.0 18 | 19 | # with 20 | - name: with php version-${{matrix.php}} 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | 25 | # initial pulsar 26 | - name: initial pulsar server 27 | run: | 28 | chmod -c o+w `pwd`/.github 29 | ls -al 30 | docker run -itd --privileged --name pulsar -v `pwd`/.github:/pulsar/tokens -p 6650:6650 -p 8080:8080 apachepulsar/pulsar bin/pulsar standalone 31 | echo "-- Wait for Pulsar service to be ready" 32 | until curl http://localhost:8080/metrics > /dev/null 2>&1 ; do sleep 1; done 33 | 34 | # create token 35 | - name: create authentication token 36 | run: | 37 | docker exec pulsar bin/pulsar tokens create-secret-key --output /pulsar/tokens/jwt.key --base64 38 | docker exec pulsar bin/pulsar tokens create --secret-key file:///pulsar/tokens/jwt.key --subject workflows > `pwd`/.github/jwt.token 39 | ls -al .github 40 | cat .github/jwt.key 41 | 42 | # grant permission 43 | - name: grant permission 44 | run: | 45 | docker exec pulsar bin/pulsar-admin namespaces grant-permission public/default --actions produce,consume --role workflows 46 | docker exec pulsar bin/pulsar-admin namespaces permissions public/default 47 | 48 | 49 | # configure standalone.conf 50 | - name: configure standalone.conf 51 | run: | 52 | docker cp pulsar:/pulsar/conf/standalone.conf . 53 | sed -i 's/authenticationEnabled=false/authenticationEnabled=true/g' standalone.conf 54 | sed -i 's/authenticationProviders=/authenticationProviders=org.apache.pulsar.broker.authentication.AuthenticationProviderToken/g' standalone.conf 55 | sed -i 's/brokerClientAuthenticationPlugin=/brokerClientAuthenticationPlugin=org.apache.pulsar.client.impl.auth.AuthenticationToken/g' standalone.conf 56 | sed -i 's/brokerClientAuthenticationParameters=/brokerClientAuthenticationParameters=file:\/\/\/pulsar\/tokens\/jwt.token/g' standalone.conf 57 | sed -i 's/tokenSecretKey=/tokenSecretKey=file:\/\/\/pulsar\/tokens\/jwt.key/g' standalone.conf 58 | 59 | # restart pulsar 60 | - name: restart pulsar 61 | run: | 62 | docker cp standalone.conf pulsar:/pulsar/conf/standalone.conf 63 | docker restart pulsar 64 | echo "-- Wait for Pulsar service to be ready" 65 | until curl http://localhost:8080/metrics > /dev/null 2>&1 ; do sleep 1; done 66 | 67 | 68 | - name: test pulsar 69 | run: | 70 | rm -rf composer.lock && composer install 71 | ls -al 72 | php examples/workflows/producer.php 73 | php examples/workflows/consumer.php 74 | -------------------------------------------------------------------------------- /.github/workflows/dispatch.yaml: -------------------------------------------------------------------------------- 1 | name: Dispatch Test Pulsar 2 | on: workflow_dispatch 3 | 4 | jobs: 5 | start: 6 | strategy: 7 | matrix: 8 | php: [ "7.0" , "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3" ] 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | # checkout 13 | - name: checkout 14 | uses: actions/checkout@v3.0.0 15 | 16 | # with 17 | - name: with php version-${{matrix.php}} 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | 22 | # initial pulsar 23 | - name: initial pulsar server 24 | run: | 25 | chmod -c o+w `pwd`/.github 26 | ls -al 27 | docker run -itd --privileged --name pulsar -v `pwd`/.github:/pulsar/tokens -p 6650:6650 -p 8080:8080 apachepulsar/pulsar bin/pulsar standalone 28 | echo "-- Wait for Pulsar service to be ready" 29 | until curl http://localhost:8080/metrics > /dev/null 2>&1 ; do sleep 1; done 30 | 31 | # create token 32 | - name: create authentication token 33 | run: | 34 | docker exec pulsar bin/pulsar tokens create-secret-key --output /pulsar/tokens/jwt.key --base64 35 | docker exec pulsar bin/pulsar tokens create --secret-key file:///pulsar/tokens/jwt.key --subject workflows > `pwd`/.github/jwt.token 36 | ls -al .github 37 | cat .github/jwt.key 38 | 39 | # grant permission 40 | - name: grant permission 41 | run: | 42 | docker exec pulsar bin/pulsar-admin namespaces grant-permission public/default --actions produce,consume --role workflows 43 | docker exec pulsar bin/pulsar-admin namespaces permissions public/default 44 | 45 | 46 | # configure standalone.conf 47 | - name: configure standalone.conf 48 | run: | 49 | docker cp pulsar:/pulsar/conf/standalone.conf . 50 | sed -i 's/authenticationEnabled=false/authenticationEnabled=true/g' standalone.conf 51 | sed -i 's/authenticationProviders=/authenticationProviders=org.apache.pulsar.broker.authentication.AuthenticationProviderToken/g' standalone.conf 52 | sed -i 's/brokerClientAuthenticationPlugin=/brokerClientAuthenticationPlugin=org.apache.pulsar.client.impl.auth.AuthenticationToken/g' standalone.conf 53 | sed -i 's/brokerClientAuthenticationParameters=/brokerClientAuthenticationParameters=file:\/\/\/pulsar\/tokens\/jwt.token/g' standalone.conf 54 | sed -i 's/tokenSecretKey=/tokenSecretKey=file:\/\/\/pulsar\/tokens\/jwt.key/g' standalone.conf 55 | 56 | # restart pulsar 57 | - name: restart pulsar 58 | run: | 59 | docker cp standalone.conf pulsar:/pulsar/conf/standalone.conf 60 | docker restart pulsar 61 | echo "-- Wait for Pulsar service to be ready" 62 | until curl http://localhost:8080/metrics > /dev/null 2>&1 ; do sleep 1; done 63 | 64 | 65 | - name: test pulsar 66 | run: | 67 | rm -rf composer.lock && composer install 68 | ls -al 69 | php examples/workflows/producer.php 70 | php examples/workflows/consumer.php 71 | php examples/workflows/keepalive.php 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sunny 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 10 | furnished 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ikilobyte/pulsar-client-php", 3 | "description": "PHP Native Client library for Apache Pulsar", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "Pulsar", 8 | "Client", 9 | "Library" 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Pulsar\\": "src/" 14 | } 15 | }, 16 | "require": { 17 | "php": ">=7.1", 18 | "google/crc32": "^0.1.0", 19 | "protobuf-php/protobuf": "^0.1.3", 20 | "ext-curl": "*" 21 | }, 22 | "require-dev": { 23 | "symfony/var-dumper": "^3.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/consumer-not-loop.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt('token')); 17 | 18 | $options->setConnectTimeout(3); 19 | $options->setTopic('persistent://public/default/demo'); 20 | $options->setSubscription('logic'); 21 | $options->setSubscriptionType(SubscriptionType::Shared); 22 | $options->setNackRedeliveryDelay(20); 23 | $consumer = new Consumer('pulsar://localhost:6650', $options); 24 | $consumer->connect(); 25 | 26 | $running = true; 27 | pcntl_signal(SIGTERM, function () use (&$running) { 28 | $running = false; 29 | }); 30 | 31 | // kill -15 $PID 32 | while ($running) { 33 | try { 34 | $message = $consumer->receive(false); 35 | 36 | echo sprintf('Got message 【%s】messageID[%s] topic[%s] publishTime[%s]', 37 | $message->getPayload(), 38 | $message->getMessageId(), 39 | $message->getTopic(), 40 | $message->getPublishTime() 41 | ) . "\n"; 42 | 43 | // ... 44 | 45 | // Remember to confirm that the message is complete after processing 46 | $consumer->ack($message); 47 | 48 | 49 | // When processing fails, you can also execute the Nack 50 | // The message will be re-delivered after the specified time 51 | // $consumer->nack($message); 52 | 53 | } catch (MessageNotFound $e) { 54 | // enum code see Exception/MessageNotFound.php 55 | $code = $e->getCode(); 56 | if ($code == MessageNotFound::Ignore) { 57 | continue; 58 | } 59 | 60 | throw $e; 61 | } catch (Throwable $e) { 62 | echo $e->getMessage(); 63 | throw $e; 64 | } finally { 65 | pcntl_signal_dispatch(); 66 | } 67 | } 68 | 69 | $consumer->close(); -------------------------------------------------------------------------------- /examples/consumer.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt('token')); 16 | 17 | $options->setConnectTimeout(3); 18 | $options->setTopic('persistent://public/default/demo'); 19 | $options->setSubscription('logic'); 20 | $options->setSubscriptionType(SubscriptionType::Shared); 21 | $options->setNackRedeliveryDelay(20); 22 | $consumer = new Consumer('pulsar://localhost:6650', $options); 23 | $consumer->connect(); 24 | 25 | while (true) { 26 | $message = $consumer->receive(); 27 | echo sprintf('Got message 【%s】messageID[%s] topic[%s] publishTime[%s]', 28 | $message->getPayload(), 29 | $message->getMessageId(), 30 | $message->getTopic(), 31 | $message->getPublishTime() 32 | ) . "\n"; 33 | 34 | // ... 35 | 36 | // Remember to confirm that the message is complete after processing 37 | $consumer->ack($message); 38 | 39 | // When processing fails, you can also execute the Nack 40 | // The message will be re-delivered after the specified time 41 | // $consumer->nack($message); 42 | } 43 | 44 | $consumer->close(); -------------------------------------------------------------------------------- /examples/producer-keepalive.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | protected static $inner = []; 25 | 26 | 27 | /** 28 | * @param string $topic 29 | * @return Producer 30 | * @throws IOException 31 | * @throws OptionsException 32 | * @throws \Pulsar\Exception\RuntimeException 33 | */ 34 | public static function get(string $topic): Producer 35 | { 36 | if (!isset(self::$inner[ $topic ])) { 37 | self::create($topic); 38 | } 39 | 40 | return self::$inner[ $topic ]; 41 | } 42 | 43 | 44 | /** 45 | * @param string $topic 46 | * @return void 47 | * @throws IOException 48 | * @throws OptionsException 49 | * @throws \Pulsar\Exception\RuntimeException 50 | */ 51 | private static function create(string $topic) 52 | { 53 | $options = new ProducerOptions(); 54 | 55 | // If permission authentication is available 56 | // Only JWT authentication is currently supported 57 | // $options->setAuthentication(new Jwt('token')); 58 | 59 | $options->setConnectTimeout(3); 60 | $options->setTopic($topic); 61 | $options->setCompression(Compression::ZLIB); 62 | $options->setKeepalive(true); 63 | $producer = new Producer('pulsar://localhost:6650', $options); 64 | $producer->connect(); 65 | 66 | self::$inner[ $topic ] = $producer; 67 | } 68 | } 69 | 70 | 71 | $server = new Server('0.0.0.0', 1234); 72 | $server->set([ 73 | 'enable_coroutine' => true, 74 | 'hook_flags' => SWOOLE_HOOK_ALL, 75 | ]); 76 | 77 | $server->on('request', function ($req, Response $resp) { 78 | 79 | // Should be taken from here to keep this connection from being closed 80 | $producer = ProducerStore::get('persistent://public/default/demo'); 81 | 82 | $id = $producer->send('hello'); 83 | $resp->end(json_encode(['id' => $id])); 84 | }); 85 | 86 | $server->start(); -------------------------------------------------------------------------------- /examples/producer.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt('token')); 15 | 16 | $options->setConnectTimeout(3); 17 | $options->setTopic('persistent://public/default/demo'); 18 | $options->setCompression(Compression::ZLIB); 19 | $producer = new Producer('pulsar://localhost:6650', $options); 20 | $producer->connect(); 21 | 22 | for ($i = 0; $i < 10; $i++) { 23 | $messageID = $producer->send(sprintf('hello %d', $i)); 24 | echo 'messageID ' . $messageID . "\n"; 25 | } 26 | 27 | // Sending delayed messages 28 | for ($i = 0; $i < 10; $i++) { 29 | $producer->send(sprintf('hello-delay %d', $i), [ 30 | MessageOptions::DELAY_SECONDS => $i * 5, // Seconds 31 | ]); 32 | } 33 | 34 | // close 35 | $producer->close(); -------------------------------------------------------------------------------- /examples/reader.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt('token')); 16 | 17 | $options->setConnectTimeout(3); 18 | $options->setTopic('persistent://public/default/demo'); // support partition topic 19 | 20 | // Read the latest message 21 | $options->setStartMessageID(Message::latestMessageIdData()); 22 | 23 | // From the earliest message 24 | // $options->setStartMessageID(Message::earliestMessageIdData()); 25 | 26 | // Start reading from a message 27 | // $options->setStartMessageID(Message::deserialize('621:103:0')); 28 | 29 | $reader = new Reader('pulsar://localhost:6650', $options); 30 | $reader->connect(); 31 | 32 | while (true) { 33 | $message = $reader->next(); 34 | echo sprintf('Got message 【%s】messageID[%s] topic[%s] publishTime[%s]', 35 | $message->getPayload(), 36 | $message->getMessageId(), 37 | $message->getTopic(), 38 | $message->getPublishTime() 39 | ) . "\n"; 40 | 41 | } 42 | 43 | $reader->close(); -------------------------------------------------------------------------------- /examples/schema/consumer.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt('token')); 18 | 19 | 20 | $define = '{"type":"record","name":"Person","fields":[{"name":"id","type":"int"},{"name":"name","type":"string"},{"name":"age","type":"int"}]}'; 21 | 22 | // JSON Schema 23 | $schema = new SchemaJson($define, ['key' => 'value']); 24 | 25 | 26 | $options->setConnectTimeout(3); 27 | $options->setTopic('persistent://public/default/demo'); 28 | $options->setSubscription('logic'); 29 | $options->setSubscriptionType(SubscriptionType::Shared); 30 | $options->setNackRedeliveryDelay(20); 31 | $options->setSchema($schema); 32 | $consumer = new Consumer('pulsar://localhost:6650', $options); 33 | $consumer->connect(); 34 | 35 | while (true) { 36 | $message = $consumer->receive(); 37 | 38 | $person = new Person(); 39 | $message->getSchemaValue($person); 40 | 41 | echo sprintf('Got message [%s] messageID[%s] topic[%s] publishTime[%s] id[%d] name[%s]', 42 | $message->getPayload(), 43 | $message->getMessageId(), 44 | $message->getTopic(), 45 | $message->getPublishTime(), 46 | $person->id, 47 | $person->name 48 | ) . "\n"; 49 | 50 | 51 | // ... 52 | 53 | // Remember to confirm that the message is complete after processing 54 | $consumer->ack($message); 55 | 56 | // When processing fails, you can also execute the Nack 57 | // The message will be re-delivered after the specified time 58 | // $consumer->nack($message); 59 | } 60 | 61 | $consumer->close(); -------------------------------------------------------------------------------- /examples/schema/model.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt('token')); 18 | 19 | $define = '{"type":"record","name":"Person","fields":[{"name":"id","type":"int"},{"name":"name","type":"string"},{"name":"age","type":"int"}]}'; 20 | 21 | // JSON Schema 22 | $schema = new SchemaJson($define, ['key' => 'value']); 23 | 24 | 25 | $options->setConnectTimeout(3); 26 | $options->setTopic('persistent://public/default/demo'); 27 | $options->setCompression(Compression::ZLIB); 28 | $options->setSchema($schema); 29 | $producer = new Producer('pulsar://localhost:6650', $options); 30 | $producer->connect(); 31 | 32 | 33 | for ($i = 0; $i < 10; $i++) { 34 | 35 | $person = new Person(); 36 | $person->id = $i; 37 | $person->name = 'Tony ' . $i; 38 | $person->age = $i; 39 | 40 | // directly send Person Object No need to manually convert to json string 41 | $messageID = $producer->send($person); 42 | echo 'messageID ' . $messageID . "\n"; 43 | } 44 | 45 | // close 46 | $producer->close(); -------------------------------------------------------------------------------- /examples/workflows/consumer.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt($token)); 16 | 17 | $options->setConnectTimeout(3); 18 | $options->setTopic('persistent://public/default/demo'); 19 | $options->setSubscription('workflows'); 20 | $options->setSubscriptionType(SubscriptionType::Shared); 21 | $options->setNackRedeliveryDelay(20); 22 | $consumer = new Consumer('pulsar://localhost:6650', $options); 23 | $consumer->connect(); 24 | 25 | $receive = $total = 0; 26 | 27 | while (true) { 28 | 29 | if ($total >= 1200) { 30 | exit(1); 31 | } 32 | 33 | if ($receive >= 30) { 34 | break; 35 | } 36 | 37 | try { 38 | $message = $consumer->receive(false); 39 | $receive += 1; 40 | 41 | echo sprintf('Got message 【%s】messageID[%s] topic[%s] publishTime[%s]', 42 | $message->getPayload(), 43 | $message->getMessageId(), 44 | $message->getTopic(), 45 | $message->getPublishTime() 46 | ) . "\n"; 47 | 48 | $consumer->ack($message); 49 | 50 | } catch (MessageNotFound $e) { 51 | if ($e->getCode() == MessageNotFound::Ignore) { 52 | continue; 53 | } 54 | die($e->getMessage()); 55 | } catch (Throwable $e) { 56 | die($e->getMessage()); 57 | } finally { 58 | $total += 1; 59 | } 60 | } 61 | 62 | $consumer->close(); -------------------------------------------------------------------------------- /examples/workflows/keepalive.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | protected static $inner = []; 30 | 31 | 32 | /** 33 | * @param string $topic 34 | * @return Producer 35 | * @throws IOException 36 | * @throws OptionsException 37 | * @throws \Pulsar\Exception\RuntimeException 38 | */ 39 | public static function get(string $topic): Producer 40 | { 41 | if (!isset(self::$inner[ $topic ])) { 42 | self::create($topic); 43 | } 44 | 45 | return self::$inner[ $topic ]; 46 | } 47 | 48 | 49 | /** 50 | * @param string $topic 51 | * @return void 52 | * @throws IOException 53 | * @throws OptionsException 54 | * @throws \Pulsar\Exception\RuntimeException 55 | */ 56 | private static function create(string $topic) 57 | { 58 | $options = new ProducerOptions(); 59 | 60 | // If permission authentication is available 61 | // Only JWT authentication is currently supported 62 | // $options->setAuthentication(new Jwt('token')); 63 | 64 | $options->setConnectTimeout(3); 65 | $options->setTopic($topic); 66 | $options->setCompression(Compression::ZLIB); 67 | $options->setKeepalive(true); 68 | $token = file_get_contents(__DIR__ . '/../../.github/jwt.token'); 69 | $options->setAuthentication(new Jwt($token)); 70 | 71 | $producer = new Producer('pulsar://localhost:6650', $options); 72 | $producer->connect(); 73 | 74 | self::$inner[ $topic ] = $producer; 75 | } 76 | } 77 | 78 | 79 | $server = new SwooleServer('0.0.0.0', 1234); 80 | $server->set([ 81 | 'enable_coroutine' => true, 82 | 'hook_flags' => SWOOLE_HOOK_ALL, 83 | ]); 84 | 85 | $server->on('start', function () { 86 | echo "http server started\n"; 87 | sleep(3); 88 | for ($i = 0; $i < 10; $i++) { 89 | $result = file_get_contents('http://127.0.0.1:1234'); 90 | echo $result . "\n"; 91 | } 92 | }); 93 | 94 | $counter = 0; 95 | $server->on('request', function (SwooleRequest $req, SwooleResponse $resp) use (&$counter, $server) { 96 | 97 | // Should be taken from here to keep this connection from being closed 98 | try { 99 | 100 | $topic = 'persistent://public/default/keepalive-' . $counter; 101 | $producer = ProducerStore::get($topic); 102 | $id = $producer->send('hello'); 103 | $result = [ 104 | 'id' => $id, 105 | 'topic' => $topic, 106 | ]; 107 | $resp->end(json_encode($result)); 108 | $counter += 1; 109 | 110 | if ($counter >= 10) { 111 | sleep(300); 112 | $server->shutdown(); 113 | } 114 | } catch (Throwable $e) { 115 | $server->shutdown(); 116 | } 117 | }); 118 | 119 | $server->start(); -------------------------------------------------------------------------------- /examples/workflows/producer.php: -------------------------------------------------------------------------------- 1 | setAuthentication(new Jwt($token)); 16 | 17 | $options->setInitialSubscriptionName('workflows'); 18 | $options->setConnectTimeout(3); 19 | $options->setTopic('persistent://public/default/demo'); 20 | $options->setCompression(Compression::ZLIB); 21 | $producer = new Producer('pulsar://localhost:6650', $options); 22 | $producer->connect(); 23 | 24 | for ($i = 0; $i < 10; $i++) { 25 | $messageID = $producer->send(sprintf('hello %d', $i)); 26 | echo sprintf("message id %s \n", $messageID); 27 | } 28 | 29 | // Sending delayed messages 30 | for ($i = 0; $i < 10; $i++) { 31 | $messageID = $producer->send(sprintf('hello-delay %d', $i), [ 32 | MessageOptions::DELAY_SECONDS => $i * 5, // Seconds 33 | ]); 34 | echo sprintf("message id %s delay %s\n", $messageID, $i * 5); 35 | } 36 | 37 | 38 | // Send Batch messages 39 | $messages = []; 40 | for ($i = 0; $i < 10; $i++) { 41 | $messages[] = json_encode([ 42 | 'id' => $i, 43 | 'date' => date('Y-m-d H:i:s'), 44 | ]); 45 | } 46 | 47 | $messageID = $producer->send($messages); 48 | echo sprintf("batch message id %s\n", $messageID); 49 | 50 | // close 51 | $producer->close(); 52 | 53 | -------------------------------------------------------------------------------- /src/Authentication/Authentication.php: -------------------------------------------------------------------------------- 1 | user = $user; 27 | $this->password = $password; 28 | } 29 | 30 | 31 | public function authMethodName(): string 32 | { 33 | return 'basic'; 34 | } 35 | 36 | 37 | /** 38 | * @return string 39 | */ 40 | public function authData(): string 41 | { 42 | return sprintf('%s:%s', $this->user, $this->password); 43 | } 44 | 45 | 46 | /** 47 | * @return string 48 | */ 49 | public function authorization(): string 50 | { 51 | return sprintf('Basic %s', base64_encode($this->authData())); 52 | } 53 | } -------------------------------------------------------------------------------- /src/Authentication/Jwt.php: -------------------------------------------------------------------------------- 1 | token = $token; 31 | } 32 | 33 | /** 34 | * @inheritDoc 35 | */ 36 | public function authMethodName(): string 37 | { 38 | return 'token'; 39 | } 40 | 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function authData() 46 | { 47 | return $this->token; 48 | } 49 | 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function authorization(): string 55 | { 56 | return sprintf('Bearer %s', $this->token); 57 | } 58 | } -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | 86 | */ 87 | protected $connections = []; 88 | 89 | 90 | /** 91 | * @var int reconnect count 92 | */ 93 | protected $reconnect = 0; 94 | 95 | 96 | /** 97 | * @param string $url 98 | * @param Options $options 99 | * @throws Exception\OptionsException 100 | */ 101 | public function __construct(string $url, Options $options) 102 | { 103 | $options->setUrl($url); 104 | $this->url = $url; 105 | $this->options = $options; 106 | 107 | $parse = $this->options->getUrl(); 108 | $this->serviceHost = $parse['host']; 109 | $this->servicePort = $parse['port']; 110 | $this->serviceScheme = $parse['scheme']; 111 | $this->fetchPartitionTopicMetadata(); 112 | } 113 | 114 | 115 | /** 116 | * @return void 117 | * @throws Exception\OptionsException 118 | */ 119 | protected function fetchPartitionTopicMetadata() 120 | { 121 | $this->eventloop = new Select(); 122 | $this->lookupService = $this->makeLookupService(); 123 | $this->topicManage = new TopicManage(); 124 | 125 | // lookup 126 | foreach ($this->options->getTopics() as $topic) { 127 | $partition = $this->lookupService->getPartitionedTopicMetadata( 128 | $this->options->validateTopic($topic) 129 | ); 130 | $this->topicManage->setPartitions($topic, $partition); 131 | } 132 | } 133 | 134 | 135 | 136 | /** 137 | * @return void 138 | */ 139 | protected function initialization() 140 | { 141 | foreach ($this->topicManage->all() as $topic) { 142 | $result = $this->lookupService->lookup($topic); 143 | 144 | $connectionKey = $result->format(); 145 | 146 | if (!isset($this->connections[ $connectionKey ])) { 147 | 148 | $connection = Factory::create($this->options); 149 | 150 | // Establishing a tcp connection 151 | $connection->connect( 152 | $result->getHost(), 153 | $result->getPort(), 154 | $this->options->offsetGet(Options::CONNECT_TIMEOUT) 155 | ); 156 | 157 | $connection->handshake( 158 | $this->options->offsetGet(Options::Authentication), 159 | $result->getBrokerServiceUrl() 160 | ); 161 | 162 | // There may be multiple topics assigned to the same connection 163 | // Only one connection will be created for the same broker 164 | $this->connections[ $connectionKey ] = $connection; 165 | } 166 | 167 | $this->topicManage->setConnection($topic, $this->connections[ $connectionKey ]); 168 | } 169 | 170 | // Add to Eventloop 171 | foreach ($this->connections as $connection) { 172 | $connection->setKeepalive($this->options->getKeepalive()); 173 | $this->eventloop->addRead($connection); 174 | } 175 | 176 | $this->isHandshake = true; 177 | $this->lookupService->close(); 178 | } 179 | 180 | 181 | /** 182 | * @return LookupService 183 | * @throws Exception\OptionsException 184 | */ 185 | protected function makeLookupService(): LookupService 186 | { 187 | if (in_array($this->serviceScheme, ['http', 'https'])) { 188 | return new HttpLookupService($this->options, $this->serviceScheme); 189 | } 190 | 191 | return new TcpLookupService($this->options); 192 | } 193 | 194 | 195 | /** 196 | * @return void 197 | */ 198 | protected function close() 199 | { 200 | foreach ($this->connections as $connection) { 201 | $connection->close(); 202 | } 203 | } 204 | 205 | 206 | /** 207 | * all connections send PING command 208 | * 209 | * @return void 210 | */ 211 | protected function ping() 212 | { 213 | foreach ($this->connections as $connection) { 214 | $connection->ping(); 215 | } 216 | } 217 | } -------------------------------------------------------------------------------- /src/Compression/Compression.php: -------------------------------------------------------------------------------- 1 | data[ self::TOPICS ] = $topics; 80 | } 81 | 82 | 83 | /** 84 | * @param string $name 85 | * @return void 86 | */ 87 | public function setConsumerName(string $name) 88 | { 89 | $this->data[ self::NAME ] = $name; 90 | } 91 | 92 | /** 93 | * @param string $subscription 94 | * @return void 95 | */ 96 | public function setSubscription(string $subscription) 97 | { 98 | $this->data[ self::SUBSCRIPTION ] = $subscription; 99 | } 100 | 101 | /** 102 | * @param int $subType 103 | * @return void 104 | */ 105 | public function setSubscriptionType(int $subType) 106 | { 107 | $this->data[ self::SUBSCRIPTION_TYPE ] = $subType; 108 | } 109 | 110 | 111 | /** 112 | * @param InitialPosition $position 113 | * @return void 114 | */ 115 | public function setSubscriptionInitialPosition(InitialPosition $position) 116 | { 117 | $this->data[ self::INITIAL_POSITION ] = $position; 118 | } 119 | 120 | 121 | /** 122 | * @return InitialPosition 123 | */ 124 | public function getSubscriptionInitialPosition(): InitialPosition 125 | { 126 | return $this->data[ self::INITIAL_POSITION ] ?? InitialPosition::Latest(); 127 | } 128 | 129 | 130 | /** 131 | * @param int $seconds 132 | * @return void 133 | */ 134 | public function setNackRedeliveryDelay(int $seconds) 135 | { 136 | $this->data[ self::NACK_REDELIVERY_DELAY ] = $seconds; 137 | } 138 | 139 | 140 | 141 | /** 142 | * @param int $size 143 | * @return void 144 | */ 145 | public function setReceiveQueueSize(int $size) 146 | { 147 | if ($size < 0) { 148 | $size = 1000; 149 | } 150 | $this->data[ self::RECEIVE_QUEUE_SIZE ] = $size; 151 | } 152 | 153 | 154 | /** 155 | * @param int $maxRedeliveryCount 156 | * @param string $deadLetterTopic 157 | * @param string $initialSubscriptionName 158 | * @return void 159 | * @throws OptionsException 160 | */ 161 | public function setDeadLetterPolicy(int $maxRedeliveryCount, string $deadLetterTopic = '', string $initialSubscriptionName = '') 162 | { 163 | if ($maxRedeliveryCount <= 0) { 164 | throw new OptionsException('maxRedeliveryCount Must be greater than 0'); 165 | } 166 | 167 | $config = [ 168 | 'max' => $maxRedeliveryCount, 169 | 'topic' => $deadLetterTopic, 170 | 'subscription' => $initialSubscriptionName, 171 | ]; 172 | $this->data[ self::DEAD_LETTER_POLICY ] = new DeadLetterPolicy($config, $this); 173 | } 174 | 175 | 176 | /** 177 | * @param bool $status true 178 | * @param int $interval Reconnection interval (sleep) 179 | * @param int $limit <= 0(No limit) 180 | * @return void 181 | */ 182 | public function setReconnectPolicy(bool $status, int $interval = 5, int $limit = -1) 183 | { 184 | $this->data[ self::ENABLE_RECONNECT ] = [ 185 | 'status' => $status, // Whether to open 186 | 'interval' => $interval, // sleep 187 | 'limit' => $limit, // limit 188 | ]; 189 | } 190 | 191 | 192 | /** 193 | * @return array 194 | */ 195 | public function getReconnectPolicy(): array 196 | { 197 | return $this->data[ self::ENABLE_RECONNECT ] ?? [ 198 | 'status' => true, 199 | 'interval' => 5, 200 | 'limit' => -1, 201 | ]; 202 | } 203 | 204 | 205 | /** 206 | * @return DeadLetterPolicy 207 | * @throws OptionsException 208 | */ 209 | public function getDeadLetterPolicy(): DeadLetterPolicy 210 | { 211 | return $this->data[ self::DEAD_LETTER_POLICY ] ?? new DeadLetterPolicy([], $this); 212 | } 213 | 214 | /** 215 | * @return int|mixed 216 | */ 217 | public function getReceiveQueueSize() 218 | { 219 | return $this->data[ self::RECEIVE_QUEUE_SIZE ] ?? 1000; 220 | } 221 | 222 | /** 223 | * @return string 224 | * @throws OptionsException 225 | */ 226 | public function getSubscriptionName(): string 227 | { 228 | $subscription = trim($this->data[ ConsumerOptions::SUBSCRIPTION ] ?? ''); 229 | if (empty($subscription)) { 230 | throw new OptionsException('subscription name is required for consumer'); 231 | } 232 | 233 | return $subscription; 234 | } 235 | 236 | 237 | /** 238 | * @return string 239 | * @throws OptionsException 240 | */ 241 | public function getSubscriptionType(): string 242 | { 243 | 244 | $subscriptionType = trim($this->data[ ConsumerOptions::SUBSCRIPTION_TYPE ] ?? SubscriptionType::Exclusive); 245 | if ($subscriptionType < SubscriptionType::Exclusive || $subscriptionType > SubscriptionType::Key_Shared) { 246 | throw new OptionsException('subscription type Out of range'); 247 | } 248 | $this->offsetSet(ConsumerOptions::SUBSCRIPTION_TYPE, $subscriptionType); 249 | return $subscriptionType; 250 | } 251 | 252 | /** 253 | * @return int|mixed 254 | */ 255 | public function getNackRedeliveryDelay() 256 | { 257 | $delay = $this->data[ self::NACK_REDELIVERY_DELAY ] ?? 10; 258 | return $delay <= 0 ? 10 : $delay; 259 | } 260 | 261 | 262 | /** 263 | * @return mixed|string 264 | * @throws \Exception 265 | */ 266 | public function getConsumerName() 267 | { 268 | if (!isset($this->data[ ConsumerOptions::NAME ])) { 269 | $this->data[ ConsumerOptions::NAME ] = base64_encode(random_bytes(6)); 270 | } 271 | 272 | return $this->data[ ConsumerOptions::NAME ]; 273 | } 274 | } -------------------------------------------------------------------------------- /src/Exception/ConnectException.php: -------------------------------------------------------------------------------- 1 | pongBytes)) { 91 | $this->pongBytes = file_get_contents(__DIR__ . '/../Util/pong.bytes'); 92 | } 93 | // reply PONG 94 | $this->write($this->pongBytes); 95 | } 96 | 97 | 98 | /** 99 | * @return void 100 | */ 101 | public function ping() 102 | { 103 | if (empty($this->pingBytes)) { 104 | $this->pingBytes = file_get_contents(__DIR__ . '/../Util/ping.bytes'); 105 | } 106 | 107 | // 30 seconds interval 108 | if (time() - $this->lastSendPingCommandTime < 30) { 109 | return; 110 | } 111 | 112 | // send PING command 113 | // Used to stay active 114 | $this->write($this->pingBytes); 115 | $this->lastSendPingCommandTime = time(); 116 | } 117 | 118 | /** 119 | * @return resource 120 | */ 121 | public function getSocket() 122 | { 123 | return $this->socket; 124 | } 125 | 126 | 127 | /** 128 | * @return float|int 129 | */ 130 | public function getMaxMessageSize() 131 | { 132 | return $this->maxMessageSize; 133 | } 134 | 135 | 136 | 137 | /** 138 | * @param bool $status 139 | * @return void 140 | */ 141 | public function setKeepalive(bool $status) 142 | { 143 | $this->keepalive = $status; 144 | } 145 | 146 | 147 | /** 148 | * @return int 149 | */ 150 | public function fd(): int 151 | { 152 | return $this->fd; 153 | } 154 | 155 | /** 156 | * Handling readable events 157 | * 158 | * @return mixed 159 | */ 160 | abstract public function handleRead(); 161 | 162 | 163 | /** 164 | * @param string $host 165 | * @param int $port 166 | * @param $timeout 167 | * @return mixed 168 | */ 169 | abstract public function connect(string $host, int $port, $timeout = null); 170 | 171 | 172 | /** 173 | * @param string $buffer 174 | * @return mixed 175 | */ 176 | abstract public function write(string $buffer); 177 | 178 | 179 | /** 180 | * @param int $size 181 | * @return string 182 | */ 183 | abstract public function read(int $size): string; 184 | 185 | 186 | /** 187 | * @return mixed 188 | */ 189 | abstract public function handshake(?Authentication $authentication = null, string $brokerServiceUrl = ''); 190 | } -------------------------------------------------------------------------------- /src/IO/ChannelManager.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected $sockets = []; 29 | 30 | 31 | /** 32 | * @var array 33 | */ 34 | protected $reads = []; 35 | 36 | 37 | /** 38 | * @param $seconds 39 | * @return Response|null 40 | * @throws IOException 41 | * @throws RuntimeException 42 | */ 43 | public function wait($seconds = null) 44 | { 45 | $reads = array_values($this->reads); 46 | // Shield Interrupted system call 47 | $n = @stream_select($reads, $writes, $excepts, $seconds); 48 | if ($n <= 0) { 49 | return null; 50 | } 51 | 52 | // Disrupting the order is so that consumers can consume fairly 53 | shuffle($reads); 54 | 55 | $response = null; 56 | foreach ($reads as $socket) { 57 | 58 | $io = $this->sockets[ (int)$socket ]; 59 | $response = $io->handleRead(); 60 | 61 | // TODO There should be a better way to handle this here 62 | if (!is_null($response)) { 63 | break; 64 | } 65 | } 66 | 67 | return $response; 68 | } 69 | 70 | 71 | 72 | /** 73 | * @param AbstractIO $io 74 | * @return void 75 | */ 76 | public function addRead(AbstractIO $io) 77 | { 78 | $socket = $io->getSocket(); 79 | $this->sockets[ (int)$socket ] = $io; 80 | $this->reads[ (int)$socket ] = $socket; 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /src/Lookup/HttpLookupService.php: -------------------------------------------------------------------------------- 1 | options = $options; 55 | $this->address = trim($options->getUrl()['url'], '/'); 56 | $this->schema = $scheme; 57 | } 58 | 59 | 60 | /** 61 | * @param string $topic 62 | * @return Result 63 | * @throws RuntimeException 64 | * @throws OptionsException 65 | */ 66 | public function lookup(string $topic): Result 67 | { 68 | $parseTopic = $this->parseTopic($topic); 69 | $url = sprintf( 70 | '%s%s', 71 | $this->address, 72 | sprintf(self::LOOKUP_URI, implode('/', $parseTopic)) 73 | ); 74 | $result = $this->request($url); 75 | 76 | if (!isset($result['brokerUrl'])) { 77 | throw new RuntimeException('Get Broker Address Url Failed'); 78 | } 79 | 80 | $brokerUrl = $this->options->isTLS() ? $result['brokerUrlTls'] : $result['brokerUrl']; 81 | $parse = parse_url($brokerUrl); 82 | return new Result($parse['host'], $parse['port'], $brokerUrl); 83 | } 84 | 85 | 86 | /** 87 | * @param string $topic 88 | * @return int 89 | * @throws RuntimeException 90 | * @throws OptionsException 91 | */ 92 | public function getPartitionedTopicMetadata(string $topic): int 93 | { 94 | $parseTopic = $this->parseTopic($topic); 95 | $url = sprintf( 96 | '%s%s', 97 | $this->address, 98 | sprintf(self::PARTITIONS_URI, implode('/', $parseTopic)) 99 | ); 100 | 101 | $result = $this->request($url); 102 | if (!isset($result['partitions'])) { 103 | throw new RuntimeException('Failed to get partition metadata'); 104 | } 105 | 106 | return $result['partitions']; 107 | } 108 | 109 | 110 | /** 111 | * @param string $topic 112 | * @return array 113 | */ 114 | protected function parseTopic(string $topic): array 115 | { 116 | list($scheme, $parts) = explode('://', $topic); 117 | list($tenant, $namespace, $topic) = explode('/', $parts); 118 | 119 | return [ 120 | 'scheme' => $scheme, 121 | 'tenant' => $tenant, 122 | 'namespace' => $namespace, 123 | 'topic' => $topic, 124 | ]; 125 | } 126 | 127 | 128 | /** 129 | * @param string $url 130 | * @return array 131 | * @throws RuntimeException 132 | * @throws OptionsException 133 | */ 134 | protected function request(string $url): array 135 | { 136 | $ch = curl_init(); 137 | curl_setopt($ch, CURLOPT_URL, $url); 138 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 139 | curl_setopt($ch, CURLOPT_HEADER, false); 140 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 141 | 142 | if ($this->schema == 'http') { 143 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); 144 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); 145 | } else { 146 | $tls = $this->options->getTLS()->getData(); 147 | 148 | // ca 149 | if ($tls['cafile']) { 150 | curl_setopt($ch, CURLOPT_CAINFO, $tls['cafile']); 151 | } 152 | 153 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $tls['verify_peer']); 154 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $tls['verify_peer_name'] ? 2 : false); 155 | curl_setopt($ch, CURLOPT_SSLCERT, $tls['local_cert']); 156 | curl_setopt($ch, CURLOPT_SSLKEY, $tls['local_pk']); 157 | 158 | curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'pem'); 159 | curl_setopt($ch, CURLOPT_SSLKEYTYPE, 'pem'); 160 | } 161 | 162 | $headers = []; 163 | 164 | /** 165 | * @var $auth Authentication 166 | */ 167 | if ($auth = $this->options->offsetGet(Options::Authentication)) { 168 | $headers[] = sprintf('Authorization: %s', $auth->authorization()); 169 | } 170 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 171 | $response = curl_exec($ch); 172 | if (empty($response)) { 173 | throw new RuntimeException(sprintf('curl_errno[%s] %s', curl_errno($ch), curl_error($ch))); 174 | } 175 | 176 | $result = json_decode($response, true); 177 | if (!is_array($result)) { 178 | throw new RuntimeException('Pulsar Connect Failed'); 179 | } 180 | return $result; 181 | } 182 | 183 | 184 | /** 185 | * @return void 186 | */ 187 | public function close() 188 | { 189 | unset($this->client); 190 | } 191 | } -------------------------------------------------------------------------------- /src/Lookup/LookupService.php: -------------------------------------------------------------------------------- 1 | host = $host; 45 | $this->port = $port; 46 | $parse = parse_url($brokerServiceUrl); 47 | if (isset($parse['host'])) { 48 | $this->brokerServiceUrl = sprintf('%s:%d', $parse['host'], $parse['port']); 49 | } 50 | } 51 | 52 | 53 | /** 54 | * @return string 55 | */ 56 | public function getHost(): string 57 | { 58 | return $this->host; 59 | } 60 | 61 | /** 62 | * @return int 63 | */ 64 | public function getPort(): int 65 | { 66 | return $this->port; 67 | } 68 | 69 | 70 | /** 71 | * @return string 72 | */ 73 | public function getBrokerServiceUrl(): string 74 | { 75 | return $this->brokerServiceUrl; 76 | } 77 | 78 | 79 | /** 80 | * @return string 81 | */ 82 | public function format(): string 83 | { 84 | return sprintf('%s:%d-%s', $this->host, $this->port, $this->brokerServiceUrl); 85 | } 86 | } -------------------------------------------------------------------------------- /src/Lookup/TcpLookupService.php: -------------------------------------------------------------------------------- 1 | getUrl(); 64 | $this->host = $parse['host']; 65 | $this->port = $parse['port']; 66 | $this->options = $options; 67 | $this->connection = Factory::create($options); 68 | $this->connection->connect($this->host, $this->port); 69 | $this->connection->handshake($options->offsetGet(Options::Authentication)); 70 | } 71 | 72 | 73 | /** 74 | * @param string $topic 75 | * @return Result 76 | * @throws IOException 77 | * @throws RuntimeException 78 | * @throws OptionsException 79 | */ 80 | public function lookup(string $topic): Result 81 | { 82 | $subCommand = $this->request($this->connection, $topic); 83 | 84 | for ($i = 0; $i < 20; $i++) { 85 | 86 | list($brokerServiceUrl, $proxyServiceUrl) = $this->getBrokerAddress($subCommand); 87 | 88 | $parse = parse_url($brokerServiceUrl); 89 | 90 | switch ($subCommand->getResponse()->value()) { 91 | 92 | // 1、Connect to a broker using a broker 93 | // 2、Send lookups to new brokers 94 | // 3、until you return to Connect 95 | // 4、Maximum 20 attempts 96 | case CommandLookupTopicResponse\LookupType::Redirect_VALUE: 97 | 98 | // clone the Options 99 | $options = clone $this->options; 100 | 101 | $options->setUrl($brokerServiceUrl); 102 | 103 | // create Connection 104 | $connection = Factory::create($options); 105 | 106 | // establish a connection 107 | $connection->connect($parse['host'], $parse['port']); 108 | 109 | // handshake 110 | $connection->handshake($options->offsetGet(Options::Authentication)); 111 | 112 | // lookup 113 | $subCommand = $this->request($connection, $topic, $subCommand->getAuthoritative()); 114 | 115 | break; 116 | 117 | // is the broker where the current connection is located 118 | // But it also creates a new connection 119 | // Instead of using this current connection 120 | // TLS is supported at this time 121 | case CommandLookupTopicResponse\LookupType::Connect_VALUE: 122 | 123 | return new Result($parse['host'], $parse['port'], $proxyServiceUrl); 124 | 125 | // 126 | default: 127 | throw new RuntimeException($subCommand->getMessage()); 128 | } 129 | } 130 | 131 | throw new RuntimeException('Maximum number of topic searches exceeded'); 132 | } 133 | 134 | 135 | 136 | /** 137 | * @param string $topic 138 | * @return int 139 | * @throws IOException 140 | * @throws RuntimeException 141 | */ 142 | public function getPartitionedTopicMetadata(string $topic): int 143 | { 144 | $command = new CommandPartitionedTopicMetadata(); 145 | $command->setRequestId(Helper::getRequestID()); 146 | $command->setTopic($topic); 147 | 148 | $results = $this->connection->writeCommand(Type::PARTITIONED_METADATA(), $command)->wait(); 149 | 150 | /** 151 | * @var $subCommand CommandPartitionedTopicMetadataResponse 152 | */ 153 | $subCommand = $results->subCommand; 154 | if ($subCommand->getResponse()->value() == CommandPartitionedTopicMetadataResponse\LookupType::Failed_VALUE) { 155 | throw new RuntimeException($subCommand->getMessage()); 156 | } 157 | 158 | return $subCommand->getPartitions(); 159 | } 160 | 161 | 162 | 163 | /** 164 | * @param CommandLookupTopicResponse $response 165 | * @return array 166 | * @throws OptionsException 167 | */ 168 | protected function getBrokerAddress(CommandLookupTopicResponse $response): array 169 | { 170 | if ($this->options->isTLS()) { 171 | $brokerServiceUrl = $response->getBrokerServiceUrlTls(); 172 | } else { 173 | $brokerServiceUrl = $response->getBrokerServiceUrl(); 174 | } 175 | 176 | // Through the current service agent 177 | $proxyBrokerServiceUrl = $brokerServiceUrl; 178 | 179 | if ($response->getProxyThroughServiceUrl()) { 180 | $brokerServiceUrl = $this->options->data['url']; 181 | } 182 | 183 | return [$brokerServiceUrl, $proxyBrokerServiceUrl]; 184 | } 185 | 186 | 187 | 188 | /** 189 | * @param AbstractIO $connect 190 | * @param string $topic 191 | * @param bool $authoritative 192 | * @return CommandLookupTopicResponse 193 | * @throws IOException 194 | */ 195 | protected function request(AbstractIO $connect, string $topic, bool $authoritative = false): CommandLookupTopicResponse 196 | { 197 | $command = new CommandLookupTopic(); 198 | $command->setRequestId(Helper::getRequestID()); 199 | $command->setAuthoritative($authoritative); 200 | $command->setTopic($topic); 201 | $response = $connect->writeCommand(Type::LOOKUP(), $command)->wait(); 202 | 203 | /** 204 | * @var $subCommand CommandLookupTopicResponse 205 | */ 206 | $subCommand = $response->subCommand; 207 | return $subCommand; 208 | } 209 | 210 | /** 211 | * @return void 212 | */ 213 | public function close() 214 | { 215 | $this->connection->close(); 216 | } 217 | } -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | id = $id; 109 | $this->consumerID = $consumerID; 110 | $this->publishTime = $publishTime; 111 | $this->topic = $topic; 112 | $this->payload = $payload; 113 | $this->batchNums = $batchNums; 114 | $this->batchIdx = $batchIdx; 115 | $this->redeliveryCount = $redeliveryCount; 116 | $this->properties = $properties; 117 | } 118 | 119 | /** 120 | * @return string 121 | */ 122 | public function getMessageId(): string 123 | { 124 | return Helper::serializeID($this->id); 125 | } 126 | 127 | 128 | /** 129 | * @return int 130 | * The number is self-incrementing when nack is used and is maintained by the pulsar broker 131 | * The quantity is reset when the consumer disconnects 132 | */ 133 | public function getRedeliveryCount(): int 134 | { 135 | return $this->redeliveryCount; 136 | } 137 | 138 | /** 139 | * @return int 140 | */ 141 | public function getConsumerID(): int 142 | { 143 | return $this->consumerID; 144 | } 145 | 146 | /** 147 | * @return MessageIdData 148 | */ 149 | public function getMessageIdData(): MessageIdData 150 | { 151 | return $this->id; 152 | } 153 | 154 | /** 155 | * @return string 156 | */ 157 | public function getPublishTime(): string 158 | { 159 | return $this->publishTime; 160 | } 161 | 162 | /** 163 | * @return string 164 | */ 165 | public function getPayload(): string 166 | { 167 | return $this->payload; 168 | } 169 | 170 | 171 | /** 172 | * @return string 173 | */ 174 | public function getTopic(): string 175 | { 176 | return $this->topic; 177 | } 178 | 179 | 180 | /** 181 | * @return array 182 | */ 183 | public function getProperties(): array 184 | { 185 | if (is_null($this->properties)) { 186 | return []; 187 | } 188 | 189 | $results = []; 190 | foreach ($this->properties as $kv) { 191 | 192 | /** 193 | * @var $kv KeyValue 194 | */ 195 | $results[ $kv->getKey() ] = $kv->getValue(); 196 | } 197 | 198 | return $results; 199 | } 200 | 201 | /** 202 | * @return bool 203 | */ 204 | public function canAck(): bool 205 | { 206 | return Tracking::tryAck($this->id, $this->batchIdx); 207 | } 208 | 209 | 210 | /** 211 | * 生成最新的消息ID给reader使用 212 | * 213 | * @return MessageIdData 214 | */ 215 | public static function latestMessageIdData(): MessageIdData 216 | { 217 | $id = new MessageIdData(); 218 | $id->setLedgerId(PHP_INT_MAX); 219 | $id->setEntryId(PHP_INT_MAX); 220 | return $id; 221 | } 222 | 223 | 224 | /** 225 | * 生成最早的消息ID给reader使用 226 | * 227 | * @return MessageIdData 228 | */ 229 | public static function earliestMessageIdData(): MessageIdData 230 | { 231 | $id = new MessageIdData(); 232 | $id->setLedgerId(-1); 233 | $id->setEntryId(-1); 234 | return $id; 235 | } 236 | 237 | 238 | /** 239 | * 将消息ID转成MessageID对象 240 | * 241 | * @param string $messageID 242 | * @return MessageIdData 243 | * @throws RuntimeException 244 | */ 245 | public static function deserialize(string $messageID): MessageIdData 246 | { 247 | return Helper::unserializeID($messageID); 248 | } 249 | } -------------------------------------------------------------------------------- /src/MessageOptions.php: -------------------------------------------------------------------------------- 1 | data = $data; 67 | } 68 | 69 | 70 | /** 71 | * @return string 72 | */ 73 | public function getKey(): string 74 | { 75 | return (string)( $this->data[ self::KEY ] ?? '' ); 76 | } 77 | 78 | 79 | /** 80 | * @return float|int|null 81 | */ 82 | public function getDeliverAtTime() 83 | { 84 | $seconds = $this->data[ self::DELAY_SECONDS ] ?? null; 85 | 86 | if ($seconds) { 87 | return (int)( ( time() + $seconds ) * 1000 ); 88 | } 89 | return null; 90 | } 91 | 92 | 93 | /** 94 | * @return int 95 | */ 96 | public function getSequenceID(): int 97 | { 98 | return (int)( $this->data[ self::SEQUENCE_ID ] ?? Helper::getSequenceId() ); 99 | } 100 | 101 | 102 | /** 103 | * @return array 104 | * @throws OptionsException 105 | */ 106 | public function getProperties(): array 107 | { 108 | $properties = $this->data[ self::PROPERTIES ] ?? []; 109 | if (!is_array($properties)) { 110 | throw new OptionsException('properties need array values'); 111 | } 112 | 113 | return $properties; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/MessageSchema.php: -------------------------------------------------------------------------------- 1 | options) { 31 | $this->options = $options; 32 | } 33 | } 34 | 35 | 36 | /** 37 | * @param $data 38 | * @return void 39 | * @throws RuntimeException 40 | * @throws \ReflectionException 41 | */ 42 | public function getSchemaValue(&$data = null) 43 | { 44 | $schema = $this->options->getSchema(); 45 | if (!$schema) { 46 | return; 47 | } 48 | 49 | $decode = $schema->decode($this->getPayload()); 50 | $type = $schema->getProtoSchema()->getType()->value(); 51 | 52 | if ($type == Type::Json_VALUE) { 53 | $this->jsonToClassObject($data, $decode); 54 | } else { 55 | $data = $decode; 56 | } 57 | } 58 | 59 | 60 | /** 61 | * @throws RuntimeException 62 | * @throws \ReflectionException 63 | */ 64 | public function jsonToClassObject(&$source, array $items) 65 | { 66 | if (!is_object($source)) { 67 | throw new RuntimeException('JSON Schema Source Must Class Object'); 68 | } 69 | 70 | $reflect = new \ReflectionClass($source); 71 | $properties = $reflect->getProperties(); 72 | 73 | foreach ($properties as $property) { 74 | $property->setAccessible(true); 75 | $name = $property->getName(); 76 | if (isset($items[ $name ])) { 77 | $property->setValue($source, $items[ $name ]); 78 | } 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 10, 50 | ]; 51 | 52 | 53 | /** 54 | * @param string $url 55 | * @return void 56 | */ 57 | public function setUrl(string $url) 58 | { 59 | $this->data[ self::URL ] = $url; 60 | } 61 | 62 | 63 | /** 64 | * @return array 65 | * @throws OptionsException 66 | */ 67 | public function getUrl(): array 68 | { 69 | $url = $this->data[ self::URL ]; 70 | $parse = parse_url($url); 71 | if (!isset($parse['host'], $parse['port'])) { 72 | throw new OptionsException('url fail miss host or port'); 73 | } 74 | 75 | if (!isset($parse['scheme'])) { 76 | $parse['scheme'] = 'pulsar'; 77 | } 78 | 79 | $parse['scheme'] = strtolower($parse['scheme']); 80 | $parse['url'] = $url; 81 | return $parse; 82 | } 83 | 84 | 85 | /** 86 | * @param Authentication $authentication 87 | * @return void 88 | */ 89 | public function setAuthentication(Authentication $authentication) 90 | { 91 | $this->data[ self::Authentication ] = $authentication; 92 | } 93 | 94 | 95 | 96 | /** 97 | * @param int $seconds 98 | * @return void 99 | */ 100 | public function setConnectTimeout(int $seconds) 101 | { 102 | $this->data[ self::CONNECT_TIMEOUT ] = $seconds; 103 | } 104 | 105 | 106 | /** 107 | * @param string $topic 108 | * @return void 109 | */ 110 | public function setTopic(string $topic) 111 | { 112 | $this->data[ self::TOPICS ] = [$topic]; 113 | } 114 | 115 | 116 | /** 117 | * @return array 118 | * @throws OptionsException 119 | */ 120 | public function getTopics(): array 121 | { 122 | $topics = $this->data[ self::TOPICS ] ?? []; 123 | if (empty($topics)) { 124 | throw new OptionsException('topic is required'); 125 | } 126 | 127 | return $topics; 128 | } 129 | 130 | 131 | 132 | /** 133 | * This connection pool is not enabled by default 134 | * 135 | * @return bool 136 | */ 137 | public function getKeepalive(): bool 138 | { 139 | return false; 140 | } 141 | 142 | 143 | /** 144 | * @param ISchema $schema 145 | * @return void 146 | */ 147 | public function setSchema(ISchema $schema) 148 | { 149 | $this->data['schema'] = $schema; 150 | } 151 | 152 | 153 | /** 154 | * @param TLSOptions $options 155 | * @return void 156 | */ 157 | public function setTLS(TLSOptions $options) 158 | { 159 | $this->data['tls'] = $options; 160 | } 161 | 162 | 163 | /** 164 | * @return TLSOptions|null 165 | * @throws OptionsException 166 | */ 167 | public function getTLS() 168 | { 169 | $tls = $this->data['tls'] ?? null; 170 | if (empty($tls)) { 171 | throw new OptionsException('TLSOptions Must Required'); 172 | } 173 | 174 | return $tls; 175 | } 176 | 177 | 178 | /** 179 | * @return bool 180 | * @throws OptionsException 181 | */ 182 | public function isTLS(): bool 183 | { 184 | return in_array($this->getUrl()['scheme'], ['pulsar+ssl', 'https']); 185 | } 186 | 187 | /** 188 | * @return ISchema|null 189 | */ 190 | public function getSchema() 191 | { 192 | return $this->data['schema'] ?? null; 193 | } 194 | 195 | /** 196 | * @param string $topic 197 | * @return string 198 | * @throws OptionsException 199 | */ 200 | public function validateTopic(string $topic): string 201 | { 202 | if (empty($topic)) { 203 | throw new OptionsException('topic is required'); 204 | } 205 | 206 | $parts = explode('/', $topic); 207 | if (count($parts) < 5) { 208 | throw new OptionsException('topic is format [persistent://tenant/namespace/topic]'); 209 | } 210 | 211 | if (empty($parts[4])) { 212 | throw new OptionsException('topic is empty'); 213 | } 214 | 215 | return $topic; 216 | } 217 | 218 | 219 | /** 220 | * @return array 221 | */ 222 | public function all(): array 223 | { 224 | return $this->data; 225 | } 226 | 227 | 228 | /** 229 | * @param $offset 230 | * @return bool 231 | */ 232 | public function offsetExists($offset): bool 233 | { 234 | return isset($this->data[ $offset ]); 235 | } 236 | 237 | 238 | /** 239 | * @param $offset 240 | * @return mixed|null 241 | */ 242 | public function offsetGet($offset) 243 | { 244 | return $this->data[ $offset ] ?? null; 245 | } 246 | 247 | 248 | /** 249 | * @param $offset 250 | * @param $value 251 | * @return void 252 | */ 253 | public function offsetSet($offset, $value) 254 | { 255 | $this->data[ $offset ] = $value; 256 | } 257 | 258 | 259 | /** 260 | * @param $offset 261 | * @return void 262 | * @throws OptionsException 263 | */ 264 | public function offsetUnset($offset) 265 | { 266 | throw new OptionsException('Prohibit deletion'); 267 | } 268 | } -------------------------------------------------------------------------------- /src/PartitionConsumer.php: -------------------------------------------------------------------------------- 1 | id = $id; 87 | $this->connection = $connection; 88 | 89 | /** 90 | * @var $options ConsumerOptions|ReaderOptions 91 | */ 92 | $this->options = $options; 93 | $this->topic = $topic; 94 | $this->name = sprintf('%s-%d', $this->options->getConsumerName(), $id); 95 | $this->receiveQueueSize = $this->options->getReceiveQueueSize(); 96 | $this->subscribe(); 97 | } 98 | 99 | 100 | /** 101 | * @return void 102 | * @throws Exception\IOException 103 | * @throws Exception\OptionsException 104 | * @throws \Exception 105 | */ 106 | protected function subscribe() 107 | { 108 | $command = new CommandSubscribe(); 109 | $command->setConsumerId($this->id); 110 | $command->setTopic($this->topic); 111 | $command->setRequestId(Helper::getRequestID()); 112 | $command->setSubType(CommandSubscribe\SubType::valueOf($this->options->getSubscriptionType())); 113 | $command->setConsumerName($this->name); 114 | $command->setSubscription($this->options->getSubscriptionName()); 115 | 116 | $command->setDurable(true); 117 | $command->setInitialPosition($this->options->getSubscriptionInitialPosition()); 118 | $command->setReplicateSubscriptionState(false); 119 | 120 | // only reader interface 121 | if ($this->options instanceof ReaderOptions) { 122 | $command->setDurable(false); 123 | $command->setStartMessageId($this->options->getStartMessageID()); 124 | } 125 | 126 | // set schema 127 | if ($schema = $this->options->getSchema()) { 128 | $command->setSchema($schema->getProtoSchema()); 129 | } 130 | 131 | $this->connection->writeCommand(Type::SUBSCRIBE(), $command)->wait(); 132 | } 133 | 134 | 135 | /** 136 | * @throws \Exception 137 | */ 138 | public function flow() 139 | { 140 | if ($this->availablePermits >= $this->receiveQueueSize) { 141 | return; 142 | } 143 | 144 | $supplement = $this->receiveQueueSize - $this->availablePermits; 145 | 146 | if ($this->availablePermits <= $this->receiveQueueSize / 2) { 147 | $flow = new CommandFlow(); 148 | $flow->setConsumerId($this->id); 149 | $flow->setMessagePermits($supplement); 150 | $this->connection->writeCommand(Type::FLOW(), $flow); 151 | $this->availablePermits += $supplement; 152 | } 153 | } 154 | 155 | 156 | /** 157 | * @param Message $message 158 | * @return void 159 | * @throws IOException 160 | */ 161 | public function ack(Message $message) 162 | { 163 | // send CommandAck 164 | $command = new CommandAck(); 165 | $command->setConsumerId($this->id); 166 | $command->setAckType(AckType::Individual()); 167 | $command->addMessageId($message->getMessageIdData()); 168 | $command->setTxnidLeastBits(null); 169 | $command->setTxnidMostBits(null); 170 | $this->connection->writeCommand(Type::ACK(), $command); 171 | } 172 | 173 | 174 | /** 175 | * @param Message $message 176 | * @return void 177 | * @throws \Exception 178 | */ 179 | public function nack(Message $message) 180 | { 181 | // send CommandRedeliverUnacknowledgedMessages 182 | $command = new CommandRedeliverUnacknowledgedMessages(); 183 | $command->setConsumerId($this->id); 184 | $command->addMessageIds($message->getMessageIdData()); 185 | $this->connection->writeCommand(Type::REDELIVER_UNACKNOWLEDGED_MESSAGES(), $command); 186 | } 187 | 188 | 189 | /** 190 | * @return void 191 | * @throws Exception\IOException 192 | * @throws \Exception 193 | */ 194 | public function close() 195 | { 196 | $command = new CommandCloseConsumer(); 197 | $command->setConsumerId($this->id); 198 | $command->setRequestId(Helper::getRequestID()); 199 | $this->connection->writeCommand(Type::CLOSE_CONSUMER(), $command)->wait(); 200 | } 201 | 202 | 203 | 204 | /** 205 | * @return string 206 | */ 207 | public function getTopic(): string 208 | { 209 | return $this->topic; 210 | } 211 | 212 | /** 213 | * @param int $size 214 | * @return void 215 | */ 216 | public function decrement(int $size = 1) 217 | { 218 | $this->availablePermits -= $size; 219 | } 220 | } -------------------------------------------------------------------------------- /src/PartitionProducer.php: -------------------------------------------------------------------------------- 1 | id = $id; 72 | $this->connection = $connection; 73 | $this->options = $options; 74 | $this->topic = $topic; 75 | $this->name = sprintf('%s-%d', $this->options->getProducerName(), $this->id); 76 | $this->create(); 77 | } 78 | 79 | 80 | /** 81 | * @return string 82 | */ 83 | public function getTopic(): string 84 | { 85 | return $this->topic; 86 | } 87 | 88 | 89 | 90 | /** 91 | * @return void 92 | * @throws Exception\IOException 93 | * @throws \Exception 94 | */ 95 | protected function create() 96 | { 97 | $commandProducer = new CommandProducer(); 98 | $commandProducer->setTopic($this->topic); 99 | $commandProducer->setProducerId($this->id); 100 | $commandProducer->setRequestId(Helper::getRequestID()); 101 | $commandProducer->setProducerName($this->name); 102 | $commandProducer->setUserProvidedProducerName(true); 103 | $commandProducer->setEncrypted(false); 104 | $commandProducer->setTxnEnabled(false); 105 | $commandProducer->setProducerAccessMode(ProducerAccessMode::Shared()); 106 | 107 | // Name of the initial subscription of the topic. 108 | if ($name = $this->options->getInitialSubscriptionName()) { 109 | $commandProducer->setInitialSubscriptionName($name); 110 | } 111 | 112 | // set schema 113 | if ($schema = $this->options->getSchema()) { 114 | $commandProducer->setSchema($schema->getProtoSchema()); 115 | } 116 | 117 | $this->connection->writeCommand(Type::PRODUCER(), $commandProducer)->wait(); 118 | } 119 | 120 | 121 | 122 | /** 123 | * @throws Exception\IOException 124 | * @throws \Exception 125 | */ 126 | public function close() 127 | { 128 | $command = new CommandCloseProducer(); 129 | $command->setProducerId($this->id); 130 | $command->setRequestId(Helper::getRequestID()); 131 | $this->connection->writeCommand(Type::CLOSE_PRODUCER(), $command)->wait(); 132 | } 133 | 134 | 135 | /** 136 | * @param Buffer $buffer 137 | * @return mixed 138 | * @throws RuntimeException 139 | */ 140 | public function send(Buffer $buffer) 141 | { 142 | $bytes = $buffer->bytes(); 143 | $maxSize = $this->connection->getMaxMessageSize(); 144 | if (strlen($bytes) > $maxSize) { 145 | throw new RuntimeException(sprintf('Message length exceeded, %d allowed', $maxSize)); 146 | } 147 | 148 | return $this->connection->write($bytes)->wait(); 149 | } 150 | 151 | 152 | /** 153 | * @param Buffer $buffer 154 | * @return void 155 | */ 156 | public function sendAsync(Buffer $buffer) 157 | { 158 | $this->connection->write($buffer->bytes()); 159 | } 160 | 161 | /** 162 | * @return string 163 | */ 164 | public function getName(): string 165 | { 166 | return $this->name; 167 | } 168 | 169 | 170 | /** 171 | * @return int 172 | */ 173 | public function getID(): int 174 | { 175 | return $this->id; 176 | } 177 | } -------------------------------------------------------------------------------- /src/Policy/DeadLetterPolicy.php: -------------------------------------------------------------------------------- 1 | config = $config; 57 | $this->options = $options; 58 | 59 | if (empty($this->config['subscription'])) { 60 | // Consistent with consumers 61 | $this->config['subscription'] = $options->getSubscriptionName(); 62 | } 63 | } 64 | 65 | 66 | /** 67 | * @param Message $message 68 | * @return bool 69 | * @throws Exception\IOException 70 | * @throws Exception\OptionsException 71 | * @throws Exception\RuntimeException 72 | */ 73 | public function trigger(Message $message): bool 74 | { 75 | $max = $this->config['max'] ?? 0; 76 | 77 | // Dead letter queue delivery is not turned on 78 | if ($max <= 0) { 79 | return false; 80 | } 81 | 82 | $id = $message->getMessageId(); 83 | 84 | if (!isset($this->data[ $id ])) { 85 | $this->data[ $id ] = 0; 86 | } 87 | 88 | $count = $this->data[ $id ] += 1; 89 | 90 | if ($count < $max) { 91 | return false; 92 | } 93 | 94 | 95 | // Sending messages to the dead letter queue 96 | $this->storage($message); 97 | 98 | unset($this->data[ $id ]); 99 | return true; 100 | } 101 | 102 | 103 | 104 | 105 | /** 106 | * @param Message $message 107 | * @return void 108 | * @throws Exception\IOException 109 | * @throws Exception\OptionsException 110 | * @throws Exception\RuntimeException 111 | */ 112 | protected function storage(Message $message) 113 | { 114 | $topic = $this->config['topic']; 115 | 116 | // Recommend use default format 117 | if (empty($topic)) { 118 | $topic = sprintf( 119 | '%s-%s-DLQ', 120 | // replace partition topic flag 121 | preg_replace('/-partition-\d+/', '', $message->getTopic()), 122 | $this->options->getSubscriptionName() 123 | ); 124 | } 125 | 126 | $options = new ProducerOptions(); 127 | $options->setConnectTimeout(3); 128 | $options->setTopic($topic); 129 | $options->setInitialSubscriptionName($this->config['subscription']); 130 | $options->setCompression(Compression::ZLIB); 131 | 132 | /** 133 | * @var $authentication Authentication 134 | */ 135 | $authentication = $this->options->offsetGet(Options::Authentication); 136 | if ($authentication) { 137 | $options->setAuthentication($authentication); 138 | } 139 | 140 | $producer = new Producer($this->options->getUrl()['url'], $options); 141 | $producer->connect(); 142 | $producer->send($message->getPayload()); 143 | $producer->close(); 144 | } 145 | } -------------------------------------------------------------------------------- /src/Producer.php: -------------------------------------------------------------------------------- 1 | 44 | */ 45 | protected $producers = []; 46 | 47 | 48 | /** 49 | * @var array> 50 | */ 51 | protected $callbacks = []; 52 | 53 | 54 | /** 55 | * @param string $url 56 | * @param ProducerOptions $options 57 | * @throws Exception\OptionsException 58 | * @throws RuntimeException 59 | */ 60 | public function __construct(string $url, ProducerOptions $options) 61 | { 62 | // validate keepalive condition 63 | if ($options->getKeepalive()) { 64 | 65 | if (!extension_loaded('swoole')) { 66 | throw new RuntimeException('Keepalive require swoole extension'); 67 | } 68 | 69 | if (Coroutine::getCid() === -1) { 70 | throw new RuntimeException('Keepalive Must be in a coroutine environment'); 71 | } 72 | } 73 | 74 | parent::__construct($url, $options); 75 | } 76 | 77 | 78 | /** 79 | * @return void 80 | * @throws Exception\IOException 81 | */ 82 | public function connect() 83 | { 84 | // Establish tcp connection And complete the pulsar server handshake 85 | parent::initialization(); 86 | 87 | // Enable Keepalive 88 | if ($this->options->getKeepalive()) { 89 | Coroutine::create(function () { 90 | while ($this->keepalive) { 91 | 92 | /** 93 | * @var $response Response 94 | */ 95 | $response = $this->eventloop->wait(3); 96 | if (is_null($response)) { 97 | continue; 98 | } 99 | 100 | $fd = $response->fd(); 101 | if ($fd <= 0) { 102 | continue; 103 | } 104 | 105 | // Push data to Channel 106 | ChannelManager::get($fd)->push($response); 107 | } 108 | }); 109 | } 110 | 111 | // Send CreateProducer Command 112 | foreach ($this->topicManage->all() as $id => $topic) { 113 | $connection = $this->topicManage->getConnection($topic); 114 | if ($this->options->getKeepalive()) { 115 | ChannelManager::init($connection->fd()); 116 | } 117 | 118 | $this->producers[] = new PartitionProducer($id, $topic, $connection, $this->options); 119 | } 120 | } 121 | 122 | 123 | 124 | 125 | /** 126 | * @param mixed $payload 127 | * @param array $options 128 | * @return string 129 | * @throws RuntimeException 130 | * @throws \Exception 131 | */ 132 | public function send($payload, array $options = []): string 133 | { 134 | $producer = $this->getPartitionProducer(); 135 | $messageOptions = new MessageOptions($options); 136 | 137 | $messages = is_array($payload) ? $payload : [$payload]; 138 | $builder = new Builder( 139 | $producer, 140 | $this->options, 141 | $messageOptions, 142 | $messages, 143 | $messageOptions->getSequenceID() 144 | ); 145 | $buffer = $builder->resolve(); 146 | 147 | /** 148 | * @var $response Response 149 | */ 150 | $response = $producer->send($buffer); 151 | 152 | /** 153 | * @var $receipt CommandSendReceipt 154 | */ 155 | $receipt = $response->getSubCommand(); 156 | $receipt->getMessageId()->setPartition($producer->getID()); 157 | return Helper::serializeID($receipt->getMessageId()); 158 | } 159 | 160 | 161 | 162 | /** 163 | * @param string $payload 164 | * @param callable $callable 165 | * @param array $options 166 | * @return void 167 | * @throws RuntimeException|OptionsException 168 | * @throws \Exception 169 | * @deprecated 1.3.0 170 | */ 171 | public function sendAsync(string $payload, callable $callable, array $options = []) 172 | { 173 | if ($this->options->getKeepalive()) { 174 | throw new RuntimeException('Must be Without keepalive'); 175 | } 176 | 177 | $messageOptions = new MessageOptions($options); 178 | $sequenceID = $messageOptions->getSequenceID(); 179 | 180 | $producer = $this->getPartitionProducer(); 181 | 182 | $builder = new Builder( 183 | $producer, 184 | $this->options, 185 | $messageOptions, 186 | [$payload], 187 | $sequenceID 188 | ); 189 | $buffer = $builder->resolve(); 190 | $producer->sendAsync($buffer); 191 | $this->callbacks[ $sequenceID ] = [$producer->getID(), $callable]; 192 | } 193 | 194 | 195 | /** 196 | * @return void 197 | * @throws Exception\IOException 198 | * @throws RuntimeException 199 | * @deprecated 1.3.0 200 | */ 201 | public function wait() 202 | { 203 | if ($this->options->getKeepalive()) { 204 | throw new RuntimeException('Must be Without keepalive'); 205 | } 206 | 207 | do { 208 | 209 | // It actually takes data from the memory buffer 210 | $response = $this->eventloop->wait(); 211 | 212 | /** 213 | * @var $receipt CommandSendReceipt 214 | */ 215 | $receipt = $response->getSubCommand(); 216 | 217 | $seqID = $receipt->getSequenceId(); 218 | 219 | $callbackData = $this->callbacks[ $seqID ]; 220 | 221 | $receipt->getMessageId()->setPartition($callbackData[0]); 222 | 223 | // Execute callback 224 | call_user_func($callbackData[1], Helper::serializeID($receipt->getMessageId())); 225 | 226 | // Removing 227 | unset($this->callbacks[ $seqID ]); 228 | 229 | } while (count($this->callbacks)); 230 | } 231 | 232 | 233 | /** 234 | * @return void 235 | * @throws Exception\IOException 236 | * @throws \Exception 237 | * close producer and socket 238 | */ 239 | public function close() 240 | { 241 | foreach ($this->producers as $producer) { 242 | $producer->close(); 243 | } 244 | 245 | parent::close(); 246 | 247 | // set keepalive false notify event loop exit 248 | $this->keepalive = false; 249 | } 250 | 251 | 252 | /** 253 | * @return PartitionProducer 254 | */ 255 | protected function getPartitionProducer(): PartitionProducer 256 | { 257 | return $this->producers[ mt_rand(0, count($this->producers) - 1) ]; 258 | } 259 | 260 | 261 | /** 262 | * @param Buffer $buffer 263 | * @return float|int 264 | */ 265 | protected function getChecksum(Buffer $buffer) 266 | { 267 | $crc = CRC32::create(CRC32::CASTAGNOLI); 268 | $crc->update($buffer->bytes()); 269 | return hexdec($crc->hash()); 270 | } 271 | 272 | 273 | /** 274 | * @param AbstractMessage $message 275 | * @param MessageOptions $options 276 | * @return void 277 | * @throws OptionsException 278 | */ 279 | protected function appendProperties(AbstractMessage &$message, MessageOptions $options) 280 | { 281 | foreach ($options->getProperties() as $key => $val) { 282 | $kv = new KeyValue(); 283 | $kv->setKey($key); 284 | if (is_array($val)) { 285 | $val = json_encode($val, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 286 | } 287 | $kv->setValue($val); 288 | 289 | /** 290 | * @var $message MessageMetadata|SingleMessageMetadata 291 | */ 292 | $message->addProperties($kv); 293 | } 294 | } 295 | } -------------------------------------------------------------------------------- /src/ProducerOptions.php: -------------------------------------------------------------------------------- 1 | data[ self::NAME ] = $name; 52 | } 53 | 54 | 55 | /** 56 | * Name of the initial subscription of the topic. 57 | * 58 | * @param string $name 59 | * @return void 60 | */ 61 | public function setInitialSubscriptionName(string $name) 62 | { 63 | $this->data[ self::INITIAL_SUBSCRIPTION_NAME ] = $name; 64 | } 65 | 66 | /** 67 | * Whether to enable keep-alive 68 | * 69 | * @param bool $status 70 | * @return void 71 | */ 72 | public function setKeepalive(bool $status) 73 | { 74 | $this->data[ self::KEEPALIVE ] = $status; 75 | } 76 | 77 | 78 | /** 79 | * @param int $type 80 | * @return void 81 | * @throws Exception\RuntimeException 82 | */ 83 | public function setCompression(int $type) 84 | { 85 | $this->data[ self::CompressionType ] = Factory::create($type); 86 | } 87 | 88 | 89 | /** 90 | * @param Compression $provider 91 | * @return void 92 | */ 93 | public function setCompressionProvider(Compression $provider) 94 | { 95 | $this->data[ self::CompressionType ] = $provider; 96 | } 97 | 98 | /** 99 | * @return Compression 100 | * @throws Exception\RuntimeException 101 | */ 102 | public function getCompression(): Compression 103 | { 104 | return $this->data[ self::CompressionType ] ?? Factory::create(Compression::NONE); 105 | } 106 | 107 | 108 | /** 109 | * @return string 110 | * @throws \Exception 111 | */ 112 | public function getProducerName(): string 113 | { 114 | if (!isset($this->data[ self::NAME ])) { 115 | $this->data[ self::NAME ] = base64_encode(random_bytes(6)); 116 | } 117 | 118 | return $this->data[ self::NAME ]; 119 | } 120 | 121 | 122 | /** 123 | * @return int|mixed|string 124 | */ 125 | public function getInitialSubscriptionName() 126 | { 127 | return $this->data[ self::INITIAL_SUBSCRIPTION_NAME ] ?? ''; 128 | } 129 | 130 | 131 | /** 132 | * @return bool 133 | */ 134 | public function getKeepalive(): bool 135 | { 136 | return $this->data[ self::KEEPALIVE ] ?? false; 137 | } 138 | } -------------------------------------------------------------------------------- /src/Proto/AuthMethod.php: -------------------------------------------------------------------------------- 1 | extensions !== null) { 32 | return $this->extensions; 33 | } 34 | 35 | return $this->extensions = new \Protobuf\Extension\ExtensionFieldMap(__CLASS__); 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function unknownFieldSet() 42 | { 43 | return $this->unknownFieldSet; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public static function fromStream($stream, ?\Protobuf\Configuration $configuration = null) 50 | { 51 | return new self($stream, $configuration); 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public static function fromArray(array $values) 58 | { 59 | $message = new self(); 60 | $values = array_merge([ 61 | ], $values); 62 | 63 | return $message; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | public static function descriptor() 70 | { 71 | return \google\protobuf\DescriptorProto::fromArray([ 72 | 'name' => 'CommandPing', 73 | ]); 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | */ 79 | public function toStream(?\Protobuf\Configuration $configuration = null) 80 | { 81 | $config = $configuration ?: \Protobuf\Configuration::getInstance(); 82 | $context = $config->createWriteContext(); 83 | $stream = $context->getStream(); 84 | 85 | $this->writeTo($context); 86 | $stream->seek(0); 87 | 88 | return $stream; 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function writeTo(\Protobuf\WriteContext $context) 95 | { 96 | $stream = $context->getStream(); 97 | $writer = $context->getWriter(); 98 | $sizeContext = $context->getComputeSizeContext(); 99 | 100 | if ($this->extensions !== null) { 101 | $this->extensions->writeTo($context); 102 | } 103 | 104 | return $stream; 105 | } 106 | 107 | /** 108 | * {@inheritdoc} 109 | */ 110 | public function readFrom(\Protobuf\ReadContext $context) 111 | { 112 | $reader = $context->getReader(); 113 | $length = $context->getLength(); 114 | $stream = $context->getStream(); 115 | 116 | $limit = ( $length !== null ) 117 | ? ( $stream->tell() + $length ) 118 | : null; 119 | 120 | while ($limit === null || $stream->tell() < $limit) { 121 | 122 | if ($stream->eof()) { 123 | break; 124 | } 125 | 126 | $key = $reader->readVarint($stream); 127 | $wire = \Protobuf\WireFormat::getTagWireType($key); 128 | $tag = \Protobuf\WireFormat::getTagFieldNumber($key); 129 | 130 | if ($stream->eof()) { 131 | break; 132 | } 133 | 134 | $extensions = $context->getExtensionRegistry(); 135 | $extension = $extensions ? $extensions->findByNumber(__CLASS__, $tag) : null; 136 | 137 | if ($extension !== null) { 138 | $this->extensions()->add($extension, $extension->readFrom($context, $wire)); 139 | 140 | continue; 141 | } 142 | 143 | if ($this->unknownFieldSet === null) { 144 | $this->unknownFieldSet = new \Protobuf\UnknownFieldSet(); 145 | } 146 | 147 | $data = $reader->readUnknown($stream, $wire); 148 | $unknown = new \Protobuf\Unknown($tag, $wire, $data); 149 | 150 | $this->unknownFieldSet->add($unknown); 151 | 152 | } 153 | } 154 | 155 | /** 156 | * {@inheritdoc} 157 | */ 158 | public function serializedSize(\Protobuf\ComputeSizeContext $context) 159 | { 160 | $calculator = $context->getSizeCalculator(); 161 | $size = 0; 162 | 163 | if ($this->extensions !== null) { 164 | $size += $this->extensions->serializedSize($context); 165 | } 166 | 167 | return $size; 168 | } 169 | 170 | /** 171 | * {@inheritdoc} 172 | */ 173 | public function clear() 174 | { 175 | } 176 | 177 | /** 178 | * {@inheritdoc} 179 | */ 180 | public function merge(\Protobuf\Message $message) 181 | { 182 | if (!$message instanceof \Pulsar\Proto\CommandPing) { 183 | throw new \InvalidArgumentException(sprintf('Argument 1 passed to %s must be a %s, %s given', __METHOD__, __CLASS__, get_class($message))); 184 | } 185 | } 186 | 187 | 188 | } 189 | 190 | -------------------------------------------------------------------------------- /src/Proto/CommandPong.php: -------------------------------------------------------------------------------- 1 | extensions !== null) { 32 | return $this->extensions; 33 | } 34 | 35 | return $this->extensions = new \Protobuf\Extension\ExtensionFieldMap(__CLASS__); 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function unknownFieldSet() 42 | { 43 | return $this->unknownFieldSet; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public static function fromStream($stream, ?\Protobuf\Configuration $configuration = null) 50 | { 51 | return new self($stream, $configuration); 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public static function fromArray(array $values) 58 | { 59 | $message = new self(); 60 | $values = array_merge([ 61 | ], $values); 62 | 63 | return $message; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | public static function descriptor() 70 | { 71 | return \google\protobuf\DescriptorProto::fromArray([ 72 | 'name' => 'CommandPong', 73 | ]); 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | */ 79 | public function toStream(?\Protobuf\Configuration $configuration = null) 80 | { 81 | $config = $configuration ?: \Protobuf\Configuration::getInstance(); 82 | $context = $config->createWriteContext(); 83 | $stream = $context->getStream(); 84 | 85 | $this->writeTo($context); 86 | $stream->seek(0); 87 | 88 | return $stream; 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function writeTo(\Protobuf\WriteContext $context) 95 | { 96 | $stream = $context->getStream(); 97 | $writer = $context->getWriter(); 98 | $sizeContext = $context->getComputeSizeContext(); 99 | 100 | if ($this->extensions !== null) { 101 | $this->extensions->writeTo($context); 102 | } 103 | 104 | return $stream; 105 | } 106 | 107 | /** 108 | * {@inheritdoc} 109 | */ 110 | public function readFrom(\Protobuf\ReadContext $context) 111 | { 112 | $reader = $context->getReader(); 113 | $length = $context->getLength(); 114 | $stream = $context->getStream(); 115 | 116 | $limit = ( $length !== null ) 117 | ? ( $stream->tell() + $length ) 118 | : null; 119 | 120 | while ($limit === null || $stream->tell() < $limit) { 121 | 122 | if ($stream->eof()) { 123 | break; 124 | } 125 | 126 | $key = $reader->readVarint($stream); 127 | $wire = \Protobuf\WireFormat::getTagWireType($key); 128 | $tag = \Protobuf\WireFormat::getTagFieldNumber($key); 129 | 130 | if ($stream->eof()) { 131 | break; 132 | } 133 | 134 | $extensions = $context->getExtensionRegistry(); 135 | $extension = $extensions ? $extensions->findByNumber(__CLASS__, $tag) : null; 136 | 137 | if ($extension !== null) { 138 | $this->extensions()->add($extension, $extension->readFrom($context, $wire)); 139 | 140 | continue; 141 | } 142 | 143 | if ($this->unknownFieldSet === null) { 144 | $this->unknownFieldSet = new \Protobuf\UnknownFieldSet(); 145 | } 146 | 147 | $data = $reader->readUnknown($stream, $wire); 148 | $unknown = new \Protobuf\Unknown($tag, $wire, $data); 149 | 150 | $this->unknownFieldSet->add($unknown); 151 | 152 | } 153 | } 154 | 155 | /** 156 | * {@inheritdoc} 157 | */ 158 | public function serializedSize(\Protobuf\ComputeSizeContext $context) 159 | { 160 | $calculator = $context->getSizeCalculator(); 161 | $size = 0; 162 | 163 | if ($this->extensions !== null) { 164 | $size += $this->extensions->serializedSize($context); 165 | } 166 | 167 | return $size; 168 | } 169 | 170 | /** 171 | * {@inheritdoc} 172 | */ 173 | public function clear() 174 | { 175 | } 176 | 177 | /** 178 | * {@inheritdoc} 179 | */ 180 | public function merge(\Protobuf\Message $message) 181 | { 182 | if (!$message instanceof \Pulsar\Proto\CommandPong) { 183 | throw new \InvalidArgumentException(sprintf('Argument 1 passed to %s must be a %s, %s given', __METHOD__, __CLASS__, get_class($message))); 184 | } 185 | } 186 | 187 | 188 | } 189 | 190 | -------------------------------------------------------------------------------- /src/Proto/CommandReachedEndOfTopic.php: -------------------------------------------------------------------------------- 1 | consumer_id !== null; 41 | } 42 | 43 | /** 44 | * Get 'consumer_id' value 45 | * 46 | * @return int 47 | */ 48 | public function getConsumerId() 49 | { 50 | return $this->consumer_id; 51 | } 52 | 53 | /** 54 | * Set 'consumer_id' value 55 | * 56 | * @param int $value 57 | */ 58 | public function setConsumerId($value) 59 | { 60 | $this->consumer_id = $value; 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function extensions() 67 | { 68 | if ($this->extensions !== null) { 69 | return $this->extensions; 70 | } 71 | 72 | return $this->extensions = new \Protobuf\Extension\ExtensionFieldMap(__CLASS__); 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | public function unknownFieldSet() 79 | { 80 | return $this->unknownFieldSet; 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public static function fromStream($stream, ?\Protobuf\Configuration $configuration = null) 87 | { 88 | return new self($stream, $configuration); 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public static function fromArray(array $values) 95 | { 96 | if (!isset($values['consumer_id'])) { 97 | throw new \InvalidArgumentException('Field "consumer_id" (tag 1) is required but has no value.'); 98 | } 99 | 100 | $message = new self(); 101 | $values = array_merge([ 102 | ], $values); 103 | 104 | $message->setConsumerId($values['consumer_id']); 105 | 106 | return $message; 107 | } 108 | 109 | /** 110 | * {@inheritdoc} 111 | */ 112 | public static function descriptor() 113 | { 114 | return \google\protobuf\DescriptorProto::fromArray([ 115 | 'name' => 'CommandReachedEndOfTopic', 116 | 'field' => [ 117 | \google\protobuf\FieldDescriptorProto::fromArray([ 118 | 'number' => 1, 119 | 'name' => 'consumer_id', 120 | 'type' => \google\protobuf\FieldDescriptorProto\Type::TYPE_UINT64(), 121 | 'label' => \google\protobuf\FieldDescriptorProto\Label::LABEL_REQUIRED(), 122 | ]), 123 | ], 124 | ]); 125 | } 126 | 127 | /** 128 | * {@inheritdoc} 129 | */ 130 | public function toStream(?\Protobuf\Configuration $configuration = null) 131 | { 132 | $config = $configuration ?: \Protobuf\Configuration::getInstance(); 133 | $context = $config->createWriteContext(); 134 | $stream = $context->getStream(); 135 | 136 | $this->writeTo($context); 137 | $stream->seek(0); 138 | 139 | return $stream; 140 | } 141 | 142 | /** 143 | * {@inheritdoc} 144 | */ 145 | public function writeTo(\Protobuf\WriteContext $context) 146 | { 147 | $stream = $context->getStream(); 148 | $writer = $context->getWriter(); 149 | $sizeContext = $context->getComputeSizeContext(); 150 | 151 | if ($this->consumer_id === null) { 152 | throw new \UnexpectedValueException('Field "\\pulsar\\proto\\CommandReachedEndOfTopic#consumer_id" (tag 1) is required but has no value.'); 153 | } 154 | 155 | if ($this->consumer_id !== null) { 156 | $writer->writeVarint($stream, 8); 157 | $writer->writeVarint($stream, $this->consumer_id); 158 | } 159 | 160 | if ($this->extensions !== null) { 161 | $this->extensions->writeTo($context); 162 | } 163 | 164 | return $stream; 165 | } 166 | 167 | /** 168 | * {@inheritdoc} 169 | */ 170 | public function readFrom(\Protobuf\ReadContext $context) 171 | { 172 | $reader = $context->getReader(); 173 | $length = $context->getLength(); 174 | $stream = $context->getStream(); 175 | 176 | $limit = ( $length !== null ) 177 | ? ( $stream->tell() + $length ) 178 | : null; 179 | 180 | while ($limit === null || $stream->tell() < $limit) { 181 | 182 | if ($stream->eof()) { 183 | break; 184 | } 185 | 186 | $key = $reader->readVarint($stream); 187 | $wire = \Protobuf\WireFormat::getTagWireType($key); 188 | $tag = \Protobuf\WireFormat::getTagFieldNumber($key); 189 | 190 | if ($stream->eof()) { 191 | break; 192 | } 193 | 194 | if ($tag === 1) { 195 | \Protobuf\WireFormat::assertWireType($wire, 4); 196 | 197 | $this->consumer_id = $reader->readVarint($stream); 198 | 199 | continue; 200 | } 201 | 202 | $extensions = $context->getExtensionRegistry(); 203 | $extension = $extensions ? $extensions->findByNumber(__CLASS__, $tag) : null; 204 | 205 | if ($extension !== null) { 206 | $this->extensions()->add($extension, $extension->readFrom($context, $wire)); 207 | 208 | continue; 209 | } 210 | 211 | if ($this->unknownFieldSet === null) { 212 | $this->unknownFieldSet = new \Protobuf\UnknownFieldSet(); 213 | } 214 | 215 | $data = $reader->readUnknown($stream, $wire); 216 | $unknown = new \Protobuf\Unknown($tag, $wire, $data); 217 | 218 | $this->unknownFieldSet->add($unknown); 219 | 220 | } 221 | } 222 | 223 | /** 224 | * {@inheritdoc} 225 | */ 226 | public function serializedSize(\Protobuf\ComputeSizeContext $context) 227 | { 228 | $calculator = $context->getSizeCalculator(); 229 | $size = 0; 230 | 231 | if ($this->consumer_id !== null) { 232 | $size += 1; 233 | $size += $calculator->computeVarintSize($this->consumer_id); 234 | } 235 | 236 | if ($this->extensions !== null) { 237 | $size += $this->extensions->serializedSize($context); 238 | } 239 | 240 | return $size; 241 | } 242 | 243 | /** 244 | * {@inheritdoc} 245 | */ 246 | public function clear() 247 | { 248 | $this->consumer_id = null; 249 | } 250 | 251 | /** 252 | * {@inheritdoc} 253 | */ 254 | public function merge(\Protobuf\Message $message) 255 | { 256 | if (!$message instanceof \Pulsar\Proto\CommandReachedEndOfTopic) { 257 | throw new \InvalidArgumentException(sprintf('Argument 1 passed to %s must be a %s, %s given', __METHOD__, __CLASS__, get_class($message))); 258 | } 259 | 260 | $this->consumer_id = ( $message->consumer_id !== null ) ? $message->consumer_id : $this->consumer_id; 261 | } 262 | 263 | 264 | } 265 | 266 | -------------------------------------------------------------------------------- /src/Proto/CommandSubscribe/InitialPosition.php: -------------------------------------------------------------------------------- 1 | 40 | */ 41 | protected $consumers = []; 42 | 43 | 44 | /** 45 | * @param string $url 46 | * @param ReaderOptions $options 47 | * @throws OptionsException 48 | */ 49 | public function __construct(string $url, ReaderOptions $options) 50 | { 51 | parent::__construct($url, $options); 52 | $this->messageQueue = new SplQueue(); 53 | } 54 | 55 | 56 | /** 57 | * @return void 58 | * @throws Exception\IOException 59 | * @throws OptionsException 60 | */ 61 | public function connect() 62 | { 63 | parent::initialization(); 64 | 65 | // Send Subscribe Command 66 | foreach ($this->topicManage->all() as $id => $topic) { 67 | $this->consumers[ $id ] = new PartitionConsumer( 68 | $id, 69 | $topic, 70 | $this->topicManage->getConnection($topic), 71 | $this->options 72 | ); 73 | } 74 | } 75 | 76 | 77 | /** 78 | * @return void 79 | * @throws \Exception 80 | */ 81 | protected function flow() 82 | { 83 | foreach ($this->consumers as $consumer) { 84 | $consumer->flow(); 85 | } 86 | } 87 | 88 | 89 | /** 90 | * @return Message 91 | * @throws Exception\IOException 92 | * @throws RuntimeException 93 | * @throws \Exception 94 | */ 95 | public function next(): Message 96 | { 97 | if (!$this->isHandshake) { 98 | throw new RuntimeException('not connect to pulsar server'); 99 | } 100 | 101 | // send FLOW command 102 | $this->flow(); 103 | 104 | // get message from local queue 105 | if (!$this->messageQueue->isEmpty()) { 106 | return $this->messageQueue->dequeue(); 107 | } 108 | 109 | $response = $this->eventloop->wait($this->getWaitSeconds()); 110 | 111 | // ping 112 | $this->ping(); 113 | 114 | if (is_null($response)) { 115 | return $this->next(); 116 | } 117 | 118 | 119 | /** 120 | * @var $commandMessage CommandMessage 121 | */ 122 | $commandMessage = $response->getSubCommand(); 123 | if (!( $commandMessage instanceof CommandMessage )) { 124 | return $this->next(); 125 | } 126 | 127 | 128 | $consumer = $this->getPartitionConsumer($commandMessage->getConsumerId()); 129 | 130 | /** 131 | * @var $messages array 132 | */ 133 | $messages = Packer::decode($commandMessage, $response->getBuffer(), $consumer->getTopic()); 134 | 135 | foreach ($messages as $message) { 136 | $this->messageQueue->enqueue($message); 137 | } 138 | 139 | $consumer->decrement(sizeof($messages)); 140 | 141 | return $this->messageQueue->dequeue(); 142 | } 143 | 144 | 145 | /** 146 | * @return void 147 | * @throws Exception\IOException 148 | */ 149 | public function close() 150 | { 151 | // Send Close Command 152 | foreach ($this->consumers as $consumer) { 153 | $consumer->close(); 154 | } 155 | 156 | // Close tcp connection 157 | parent::close(); 158 | } 159 | 160 | 161 | 162 | /** 163 | * @param int $consumerID 164 | * @return PartitionConsumer 165 | */ 166 | protected function getPartitionConsumer(int $consumerID): PartitionConsumer 167 | { 168 | return $this->consumers[ $consumerID ]; 169 | } 170 | 171 | 172 | /** 173 | * @return int 174 | */ 175 | protected function getWaitSeconds(): int 176 | { 177 | return 30; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/ReaderOptions.php: -------------------------------------------------------------------------------- 1 | data[ self::NAME ] = $name; 50 | } 51 | 52 | 53 | 54 | /** 55 | * @param MessageIdData $message 56 | * @return void 57 | */ 58 | public function setStartMessageID(MessageIdData $message) 59 | { 60 | $this->data[ self::START_MESSAGE_ID ] = $message; 61 | } 62 | 63 | 64 | /** 65 | * @return int|mixed|MessageIdData 66 | */ 67 | public function getStartMessageID() 68 | { 69 | return $this->data[ self::START_MESSAGE_ID ] ?? Message::latestMessageIdData(); 70 | } 71 | 72 | 73 | /** 74 | * @return null 75 | */ 76 | public function getSubscriptionInitialPosition() 77 | { 78 | return null; 79 | } 80 | 81 | 82 | /** 83 | * @param int $size 84 | * @return void 85 | */ 86 | public function setReceiveQueueSize(int $size) 87 | { 88 | if ($size <= 0) { 89 | $size = 1000; 90 | } 91 | $this->data[ self::RECEIVE_QUEUE_SIZE ] = $size; 92 | } 93 | 94 | 95 | 96 | /** 97 | * @return int|mixed 98 | */ 99 | public function getReceiveQueueSize() 100 | { 101 | return $this->data[ self::RECEIVE_QUEUE_SIZE ] ?? 1000; 102 | } 103 | 104 | 105 | /** 106 | * @return string 107 | * @throws \Exception 108 | */ 109 | public function getSubscriptionName(): string 110 | { 111 | return sprintf('reader-%s', base64_encode(random_bytes(6))); 112 | } 113 | 114 | 115 | /** 116 | * @return string 117 | */ 118 | public function getSubscriptionType(): string 119 | { 120 | // Exclusive only 121 | return SubscriptionType::Exclusive; 122 | } 123 | 124 | /** 125 | * @return int|mixed|string 126 | * @throws \Exception 127 | */ 128 | public function getConsumerName(): string 129 | { 130 | if (!isset($this->data[ self::NAME ])) { 131 | $this->data[ self::NAME ] = base64_encode(random_bytes(6)); 132 | } 133 | 134 | return $this->data[ self::NAME ]; 135 | } 136 | 137 | } -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | buffer = $buffer; 57 | $this->baseCommand = $message; 58 | $this->subCommand = $this->getSubCommand(); 59 | $this->fd = $fd; 60 | $this->checkError(); 61 | } 62 | 63 | 64 | /** 65 | * @return BaseCommand 66 | */ 67 | public function getBaseCommand(): BaseCommand 68 | { 69 | return $this->baseCommand; 70 | } 71 | 72 | 73 | /** 74 | * @return Buffer 75 | */ 76 | public function getBuffer(): Buffer 77 | { 78 | return $this->buffer; 79 | } 80 | 81 | /** 82 | * @return int 83 | */ 84 | public function fd(): int 85 | { 86 | return $this->fd; 87 | } 88 | 89 | 90 | /** 91 | * @return AbstractMessage 92 | * @throws RuntimeException 93 | */ 94 | public function getSubCommand(): AbstractMessage 95 | { 96 | $type = $this->baseCommand->getType(); 97 | $method = TypeParser::parseMethodName($type, 'get'); 98 | if (!method_exists($this->baseCommand, $method)) { 99 | throw new RuntimeException($method . ' method Not Found'); 100 | } 101 | 102 | return call_user_func([$this->baseCommand, $method]); 103 | } 104 | 105 | 106 | /** 107 | * @return void 108 | * @throws RuntimeException 109 | */ 110 | protected function checkError() 111 | { 112 | if ($this->subCommand instanceof CommandError) { 113 | throw new RuntimeException( 114 | $this->subCommand->getMessage(), 115 | $this->subCommand->getError()->value() 116 | ); 117 | } 118 | 119 | $this->checkServerError(); 120 | } 121 | 122 | 123 | /** 124 | * @return void 125 | * @throws RuntimeException 126 | */ 127 | protected function checkServerError() 128 | { 129 | if ($this->subCommand instanceof CommandSendError) { 130 | throw new RuntimeException( 131 | $this->subCommand->getMessage(), 132 | $this->subCommand->getError()->value() 133 | ); 134 | } 135 | } 136 | } -------------------------------------------------------------------------------- /src/Schema/AbstractSchema.php: -------------------------------------------------------------------------------- 1 | properties = $properties; 36 | } 37 | 38 | 39 | 40 | /** 41 | * @param string $format 42 | * @param string $value 43 | * @return string 44 | * @throws \Exception 45 | */ 46 | protected function pack(string $format, string $value): string 47 | { 48 | $data = pack($format, $value); 49 | if ($data === false) { 50 | throw new \Exception(sprintf('%s Encode Exception', static::class)); 51 | } 52 | 53 | return $data; 54 | } 55 | 56 | 57 | 58 | 59 | /** 60 | * @param string $format 61 | * @param string $value 62 | * @return mixed 63 | * @throws \Exception 64 | */ 65 | protected function unpack(string $format, string $value) 66 | { 67 | $data = unpack($format, $value); 68 | if (!isset($data[1])) { 69 | throw new \Exception(sprintf('Schema %s Error', static::class)); 70 | } 71 | 72 | return $data[1]; 73 | } 74 | 75 | /** 76 | * @return KeyValue|null 77 | */ 78 | protected function getProperties() 79 | { 80 | if (empty($this->properties)) { 81 | return null; 82 | } 83 | 84 | $kv = new KeyValue(); 85 | foreach ($this->properties as $key => $val) { 86 | $kv->setKey($key); 87 | $kv->setValue($val); 88 | } 89 | 90 | return $kv; 91 | } 92 | } -------------------------------------------------------------------------------- /src/Schema/ISchema.php: -------------------------------------------------------------------------------- 1 | pack('d', $data); 31 | } 32 | 33 | 34 | /** 35 | * @param $payload 36 | * @return float 37 | * @throws \Exception 38 | */ 39 | public function decode($payload): float 40 | { 41 | return $this->unpack('d', $payload); 42 | } 43 | 44 | 45 | /** 46 | * @return Schema 47 | */ 48 | public function getProtoSchema(): Schema 49 | { 50 | $schema = new Schema(); 51 | $type = Schema\Type::Double(); 52 | $schema->setSchemaData(Stream::wrap('')); 53 | $schema->setType($type); 54 | $schema->setName($type->name()); 55 | 56 | if ($properties = $this->getProperties()) { 57 | $schema->addProperties($properties); 58 | } 59 | 60 | return $schema; 61 | } 62 | } -------------------------------------------------------------------------------- /src/Schema/SchemaInt16.php: -------------------------------------------------------------------------------- 1 | pack('v', $data); 32 | } 33 | 34 | 35 | /** 36 | * @param $payload 37 | * @return int 38 | * @throws \Exception 39 | */ 40 | public function decode($payload): int 41 | { 42 | return $this->unpack('v', $payload); 43 | } 44 | 45 | 46 | /** 47 | * @return Schema 48 | */ 49 | public function getProtoSchema(): Schema 50 | { 51 | $schema = new Schema(); 52 | $type = Schema\Type::Int16(); 53 | $schema->setSchemaData(Stream::wrap('')); 54 | $schema->setType($type); 55 | $schema->setName($type->name()); 56 | 57 | if ($properties = $this->getProperties()) { 58 | $schema->addProperties($properties); 59 | } 60 | 61 | return $schema; 62 | } 63 | } -------------------------------------------------------------------------------- /src/Schema/SchemaInt32.php: -------------------------------------------------------------------------------- 1 | pack('V', $data); 32 | } 33 | 34 | 35 | /** 36 | * @param $payload 37 | * @return int 38 | * @throws \Exception 39 | */ 40 | public function decode($payload): int 41 | { 42 | return $this->unpack('V', $payload); 43 | } 44 | 45 | 46 | /** 47 | * @return Schema 48 | */ 49 | public function getProtoSchema(): Schema 50 | { 51 | $schema = new Schema(); 52 | $type = Schema\Type::Int32(); 53 | $schema->setSchemaData(Stream::wrap('')); 54 | $schema->setType($type); 55 | $schema->setName($type->name()); 56 | 57 | if ($properties = $this->getProperties()) { 58 | $schema->addProperties($properties); 59 | } 60 | 61 | return $schema; 62 | } 63 | } -------------------------------------------------------------------------------- /src/Schema/SchemaInt64.php: -------------------------------------------------------------------------------- 1 | pack('P', $data); 32 | } 33 | 34 | 35 | /** 36 | * @param $payload 37 | * @return int 38 | * @throws \Exception 39 | */ 40 | public function decode($payload): int 41 | { 42 | return $this->unpack('P', $payload); 43 | } 44 | 45 | 46 | /** 47 | * @return Schema 48 | */ 49 | public function getProtoSchema(): Schema 50 | { 51 | $schema = new Schema(); 52 | $type = Schema\Type::Int64(); 53 | $schema->setSchemaData(Stream::wrap('')); 54 | $schema->setType($type); 55 | $schema->setName($type->name()); 56 | 57 | if ($properties = $this->getProperties()) { 58 | $schema->addProperties($properties); 59 | } 60 | 61 | return $schema; 62 | } 63 | } -------------------------------------------------------------------------------- /src/Schema/SchemaInt8.php: -------------------------------------------------------------------------------- 1 | setSchemaData(Stream::wrap('')); 52 | $schema->setType($type); 53 | $schema->setName($type->name()); 54 | 55 | if ($properties = $this->getProperties()) { 56 | $schema->addProperties($properties); 57 | } 58 | 59 | return $schema; 60 | } 61 | } -------------------------------------------------------------------------------- /src/Schema/SchemaJson.php: -------------------------------------------------------------------------------- 1 | define = $schemaDefine; 29 | } 30 | 31 | 32 | 33 | /** 34 | * @param $data 35 | * @return string 36 | * @throws \Exception 37 | */ 38 | public function encode($data): string 39 | { 40 | if (!is_object($data)) { 41 | throw new RuntimeException(sprintf('JSON Schema Must be Class Object')); 42 | } 43 | 44 | return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 45 | } 46 | 47 | 48 | /** 49 | * @param $payload 50 | * @return mixed 51 | * @throws \Exception 52 | */ 53 | public function decode($payload): array 54 | { 55 | $items = json_decode($payload, true); 56 | if (json_last_error()) { 57 | throw new RuntimeException(sprintf('Json Schema Decode Error %s', json_last_error_msg())); 58 | } 59 | 60 | return $items; 61 | } 62 | 63 | 64 | /** 65 | * @return Schema 66 | */ 67 | public function getProtoSchema(): Schema 68 | { 69 | $schema = new Schema(); 70 | $type = Schema\Type::Json(); 71 | $schema->setSchemaData(Stream::wrap($this->define)); 72 | 73 | $schema->setType($type); 74 | $schema->setName('JSON'); 75 | 76 | if ($properties = $this->getProperties()) { 77 | $schema->addProperties($properties); 78 | } 79 | 80 | return $schema; 81 | } 82 | } -------------------------------------------------------------------------------- /src/Schema/SchemaString.php: -------------------------------------------------------------------------------- 1 | setSchemaData(Stream::wrap('')); 52 | $schema->setType($type); 53 | $schema->setName($type->name()); 54 | 55 | if ($properties = $this->getProperties()) { 56 | $schema->addProperties($properties); 57 | } 58 | 59 | return $schema; 60 | } 61 | } -------------------------------------------------------------------------------- /src/SubscriptionType.php: -------------------------------------------------------------------------------- 1 | true, 18 | 'verify_peer' => true, 19 | 'allow_self_signed' => true, 20 | 'cafile' => '', 21 | ]; 22 | 23 | /** 24 | * @param string $certFilePath 25 | * @param string $keyFilePath 26 | */ 27 | public function __construct(string $certFilePath, string $keyFilePath) 28 | { 29 | if ($certFilePath) { 30 | $this->data['local_cert'] = $certFilePath; 31 | } 32 | 33 | if ($certFilePath) { 34 | $this->data['local_pk'] = $keyFilePath; 35 | } 36 | 37 | // without TLS cert 38 | if (empty($certFilePath) && empty($keyFilePath)) { 39 | $this->setValidateHostname(false); 40 | $this->setAllowInsecureConnection(true); 41 | } 42 | } 43 | 44 | /** 45 | * @param string $caCertPath 46 | * @return self 47 | */ 48 | public function setTrustCertsFilePath(string $caCertPath): self 49 | { 50 | $this->data['cafile'] = $caCertPath; 51 | return $this; 52 | } 53 | 54 | 55 | /** 56 | * @param bool $verifyPeerName 57 | * @return self 58 | */ 59 | public function setValidateHostname(bool $verifyPeerName): self 60 | { 61 | $this->data['verify_peer_name'] = $verifyPeerName; 62 | return $this; 63 | } 64 | 65 | 66 | /** 67 | * @param bool $insecure 68 | * @return self 69 | */ 70 | public function setAllowInsecureConnection(bool $insecure): self 71 | { 72 | $this->data['verify_peer'] = !$insecure; 73 | return $this; 74 | } 75 | 76 | 77 | /** 78 | * @return false[] 79 | */ 80 | public function getData(): array 81 | { 82 | return $this->data; 83 | } 84 | } -------------------------------------------------------------------------------- /src/TopicManage.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | protected $connections = []; 40 | 41 | 42 | /** 43 | * @param string $topic 44 | * @param int $partition 45 | * @return void 46 | */ 47 | public function setPartitions(string $topic, int $partition) 48 | { 49 | 50 | // count partitions 51 | $this->partitions += $partition; 52 | 53 | // Not a partition topic 54 | if ($partition <= 0) { 55 | $this->data[] = $topic; 56 | return; 57 | } 58 | 59 | // partition topic 60 | for ($i = 0; $i < $partition; $i++) { 61 | $this->data[] = sprintf('%s-partition-%d', $topic, $i); 62 | } 63 | } 64 | 65 | 66 | /** 67 | * @return int 68 | */ 69 | public function countPartitions(): int 70 | { 71 | return $this->partitions; 72 | } 73 | 74 | 75 | /** 76 | * @return int 77 | */ 78 | public function size(): int 79 | { 80 | return count($this->data); 81 | } 82 | 83 | 84 | /** 85 | * @return array 86 | * for consumer 87 | */ 88 | public function all(): array 89 | { 90 | return $this->data; 91 | } 92 | 93 | 94 | /** 95 | * @param string $topic 96 | * @return AbstractIO 97 | */ 98 | public function getConnection(string $topic): AbstractIO 99 | { 100 | return $this->connections[ $topic ]; 101 | } 102 | 103 | 104 | /** 105 | * @param string $topic 106 | * @param AbstractIO $connection 107 | * @return void 108 | */ 109 | public function setConnection(string $topic, AbstractIO $connection) 110 | { 111 | $this->connections[ $topic ] = $connection; 112 | } 113 | } -------------------------------------------------------------------------------- /src/Traits/ProducerKeepAlive.php: -------------------------------------------------------------------------------- 1 | eventloop->wait(); 44 | return; 45 | } 46 | 47 | while ($this->keepalive) { 48 | $this->eventloop->wait(); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/Util/Buffer.php: -------------------------------------------------------------------------------- 1 | data = $data; 48 | $this->length = strlen($data); 49 | } 50 | 51 | 52 | /** 53 | * @param int $size 54 | * @return false|string 55 | */ 56 | public function read(int $size): string 57 | { 58 | $buffer = (string)substr($this->data, $this->readerIdx, $size); 59 | $this->readerIdx += $size; 60 | return $buffer; 61 | } 62 | 63 | 64 | /** 65 | * @return int 66 | */ 67 | public function readUint32(): int 68 | { 69 | return Helper::readUint32($this->read(4)); 70 | } 71 | 72 | 73 | /** 74 | * @return int 75 | */ 76 | public function readUint16(): int 77 | { 78 | return Helper::readUint16($this->read(2)); 79 | } 80 | 81 | /** 82 | * Remaining readable length 83 | * 84 | * @return int 85 | */ 86 | public function readableLength(): int 87 | { 88 | return $this->length - $this->readerIdx; 89 | } 90 | 91 | /** 92 | * @return false|string 93 | */ 94 | public function bytes() 95 | { 96 | if ($this->readerIdx <= 0) { 97 | return $this->data; 98 | } 99 | 100 | return substr($this->data, $this->readerIdx); 101 | } 102 | 103 | 104 | /** 105 | * @return int 106 | */ 107 | public function length(): int 108 | { 109 | return strlen($this->data); 110 | } 111 | 112 | /** 113 | * @param int $idx 114 | * @return void 115 | */ 116 | public function skip(int $idx) 117 | { 118 | $this->readerIdx += $idx; 119 | } 120 | 121 | /** 122 | * @param string $buffer 123 | * @param int $offset 124 | * @return void 125 | * @throws \Exception 126 | */ 127 | public function put(string $buffer, int $offset) 128 | { 129 | if ($offset < 0) { 130 | throw new \Exception('offset Must be greater than 0'); 131 | } 132 | 133 | if ($offset > strlen($this->data)) { 134 | throw new \Exception('offset Out of range'); 135 | } 136 | 137 | $this->data = sprintf( 138 | '%s%s%s', 139 | substr($this->data, 0, $offset), 140 | $buffer, 141 | substr($this->data, $offset) 142 | ); 143 | $this->writerIdx = $this->length(); 144 | } 145 | 146 | /** 147 | * @param string $buffer 148 | * @return int 149 | */ 150 | public function write(string $buffer): int 151 | { 152 | $this->data .= $buffer; 153 | $this->length = $this->length(); 154 | $this->writerIdx = $this->length; 155 | return strlen($buffer); 156 | } 157 | 158 | 159 | /** 160 | * @param int $number 161 | * @return void 162 | */ 163 | public function writeUint32(int $number) 164 | { 165 | $this->write(Helper::writeUint32($number)); 166 | } 167 | 168 | /** 169 | * @param int $number 170 | * @return void 171 | */ 172 | public function writeUint16(int $number) 173 | { 174 | $this->write(Helper::writeUint16($number)); 175 | } 176 | 177 | } -------------------------------------------------------------------------------- /src/Util/Builder.php: -------------------------------------------------------------------------------- 1 | 59 | */ 60 | protected $messages; 61 | 62 | /** 63 | * @var int 64 | */ 65 | protected $sequenceID; 66 | 67 | 68 | /** 69 | * @var ISchema|null 70 | */ 71 | protected $schema; 72 | 73 | /** 74 | * @var string 75 | */ 76 | protected $uncompressedPacket = ''; 77 | 78 | 79 | /** 80 | * @param PartitionProducer $producer 81 | * @param ProducerOptions $producerOptions 82 | * @param MessageOptions $messageOptions 83 | * @param array $messages 84 | * @param int $sequenceID 85 | */ 86 | public function __construct( 87 | PartitionProducer $producer, 88 | ProducerOptions $producerOptions, 89 | MessageOptions $messageOptions, 90 | array $messages, 91 | int $sequenceID) 92 | { 93 | $this->producer = $producer; 94 | $this->producerOptions = $producerOptions; 95 | $this->messageOptions = $messageOptions; 96 | $this->messages = $messages; 97 | $this->sequenceID = $sequenceID; 98 | 99 | // schema 100 | $this->schema = $this->producerOptions->getSchema(); 101 | 102 | // buffer 103 | $this->buffer = new Buffer(); 104 | } 105 | 106 | 107 | /** 108 | * @throws OptionsException 109 | * @throws RuntimeException 110 | * @throws \Exception 111 | * @see https://pulsar.apache.org/docs/3.1.x/developing-binary-protocol/ 112 | */ 113 | public function resolve(): Buffer 114 | { 115 | // BaseCommand 116 | $baseCommand = new BaseCommand(); 117 | $baseCommand->setType(Type::SEND()); 118 | 119 | // CommandSend 120 | $commandSend = new CommandSend(); 121 | $commandSend->setProducerId($this->producer->getID()); 122 | $commandSend->setSequenceId($this->sequenceID); 123 | $commandSend->setNumMessages(1); 124 | $commandSend->setTxnidLeastBits(null); 125 | $commandSend->setTxnidMostBits(null); 126 | 127 | // set Send Command To BaseCommand 128 | $baseCommand->setSend($commandSend); 129 | 130 | // serialize BaseCommand 131 | $baseCommandBytes = $baseCommand->toStream()->getContents(); 132 | 133 | // [commandSize] 134 | $this->buffer->writeUint32(strlen($baseCommandBytes)); 135 | 136 | // [command] 137 | $this->buffer->write($baseCommandBytes); 138 | 139 | // [magicNumber] 140 | $this->buffer->writeUint16(0x0e01); 141 | 142 | // support zlib、zstd、none 143 | $compressionProvider = $this->producerOptions->getCompression(); 144 | 145 | // metadata 146 | $metadata = new MessageMetadata(); 147 | $metadata->setProducerName($this->producer->getName()); 148 | $metadata->setSequenceId(0); 149 | $metadata->setPublishTime(time() * 1000); 150 | $metadata->setNumMessagesInBatch(count($this->messages)); 151 | $metadata->setCompression($compressionProvider->getType()); 152 | $metadata->setPartitionKey($this->messageOptions->getKey()); 153 | $metadata->setDeliverAtTime($this->messageOptions->getDeliverAtTime()); 154 | 155 | // schema payload encode 156 | foreach ($this->messages as $payload) { 157 | if ($this->schema) { 158 | $payload = $this->schema->encode($payload); 159 | } 160 | $this->uncompressedPacket .= $this->singleMessage($payload); 161 | } 162 | 163 | $metadata->setUncompressedSize(strlen($this->uncompressedPacket)); 164 | $msgMetadataBytes = $metadata->toStream()->getContents(); 165 | $msgMetadataSize = strlen($msgMetadataBytes); 166 | 167 | // make checksum bytes 168 | $compressionPacket = $compressionProvider->encode($this->uncompressedPacket); 169 | $checksumBuffer = new Buffer(); 170 | $checksumBuffer->writeUint32($msgMetadataSize); // [metadataSize] 171 | $checksumBuffer->write($msgMetadataBytes); // [metadata] 172 | $checksumBuffer->write($compressionPacket); // [payload] 173 | 174 | // [checksum] === [metadataSize] [metadata] [payload] 175 | $this->buffer->writeUint32($this->getChecksum($checksumBuffer)); 176 | 177 | // [metadataSize] 178 | $this->buffer->writeUint32($msgMetadataSize); 179 | 180 | // [metadata] 181 | $this->buffer->write($msgMetadataBytes); 182 | 183 | // [payload] 184 | $this->buffer->write($compressionPacket); 185 | 186 | // [totalSize] 187 | $this->buffer->put(Helper::writeUint32($this->buffer->length()), 0); 188 | 189 | return $this->buffer; 190 | } 191 | 192 | 193 | /** 194 | * @param AbstractMessage $message 195 | * @param MessageOptions $options 196 | * @return void 197 | * @throws OptionsException 198 | */ 199 | protected function appendProperties(AbstractMessage &$message, MessageOptions $options) 200 | { 201 | foreach ($options->getProperties() as $key => $val) { 202 | $kv = new KeyValue(); 203 | $kv->setKey($key); 204 | if (is_array($val)) { 205 | $val = json_encode($val, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 206 | } 207 | $kv->setValue($val); 208 | 209 | /** 210 | * @var $message MessageMetadata|SingleMessageMetadata 211 | */ 212 | $message->addProperties($kv); 213 | } 214 | } 215 | 216 | 217 | /** 218 | * @param string $payload 219 | * @return string 220 | * @throws OptionsException SingleMessageMetadata 221 | */ 222 | protected function singleMessage(string $payload): string 223 | { 224 | // singleMessageMetadata 225 | $singleMsgMetadata = new SingleMessageMetadata(); 226 | $singleMsgMetadata->setPayloadSize(strlen($payload)); 227 | $singleMsgMetadata->setEventTime(time() * 1000); 228 | $singleMsgMetadata->setPartitionKey($this->messageOptions->getKey()); 229 | $this->appendProperties($singleMsgMetadata, $this->messageOptions); 230 | $singleMsgMetadataBytes = $singleMsgMetadata->toStream()->getContents(); 231 | 232 | // [metadataSize] [metadata] [payload] 233 | $packet = ''; 234 | $packet .= Helper::writeUint32(strlen($singleMsgMetadataBytes)); // [metadataSize] 235 | $packet .= $singleMsgMetadataBytes; // [metadata] 236 | $packet .= $payload; 237 | 238 | return $packet; 239 | } 240 | 241 | 242 | 243 | /** 244 | * @param Buffer $buffer 245 | * @return float|int 246 | */ 247 | protected function getChecksum(Buffer $buffer) 248 | { 249 | $crc = CRC32::create(CRC32::CASTAGNOLI); 250 | $crc->update($buffer->bytes()); 251 | return hexdec($crc->hash()); 252 | } 253 | } -------------------------------------------------------------------------------- /src/Util/Helper.php: -------------------------------------------------------------------------------- 1 | getLedgerId(), $idData->getEntryId(), max($idData->getPartition(), 0)); 80 | } 81 | 82 | 83 | /** 84 | * @param string $id 85 | * @return MessageIdData 86 | * @throws RuntimeException 87 | */ 88 | public static function unserializeID(string $id): MessageIdData 89 | { 90 | if (substr_count($id, ':') != 2) { 91 | throw new RuntimeException('Wrong message ID format'); 92 | } 93 | 94 | list($ledgerID, $entryID, $partition) = explode(':', $id); 95 | $id = new MessageIdData(); 96 | $id->setLedgerId($ledgerID); 97 | $id->setEntryId($entryID); 98 | $id->setPartition($partition); 99 | return $id; 100 | } 101 | 102 | 103 | /** 104 | * @return int 105 | */ 106 | public static function getRequestID(): int 107 | { 108 | return ++self::$requestID; 109 | } 110 | 111 | 112 | /** 113 | * @return int 114 | */ 115 | public static function getSequenceId(): int 116 | { 117 | return (int)( microtime(true) * 1000 ) + ++self::$seqID; 118 | } 119 | } -------------------------------------------------------------------------------- /src/Util/Packer.php: -------------------------------------------------------------------------------- 1 | setType($type); 41 | call_user_func([$baseCommand, TypeParser::parseMethodName($type)], $message); 42 | 43 | // [totalSize] [commandSize] [command] 44 | $cmdBytes = $baseCommand->toStream()->getContents(); 45 | $cmdSize = strlen($cmdBytes); 46 | $buffer = new Buffer(); 47 | $buffer->writeUint32($cmdSize + 4); 48 | $buffer->writeUint32($cmdSize); 49 | $buffer->write($cmdBytes); 50 | 51 | return $buffer; 52 | } 53 | 54 | 55 | /** 56 | * @param CommandMessage $commandMessage 57 | * @param Buffer $buffer 58 | * @param string $topic 59 | * @return array 60 | * @throws RuntimeException 61 | * @throws Exception 62 | */ 63 | public static function decode(CommandMessage $commandMessage, Buffer $buffer, string $topic): array 64 | { 65 | // [metadataSize] [metadata] [payload] 66 | $checksumBytes = ''; 67 | 68 | // [magicNumber] 69 | $magicNumber = $buffer->readUint16(); 70 | 71 | // [checksum] 72 | $checksum = $buffer->readUint32(); 73 | 74 | // [metadataSize] 75 | $metadataSizeBytes = $buffer->read(4); 76 | $checksumBytes .= $metadataSizeBytes; 77 | $metadataSize = unpack('Nsize', $metadataSizeBytes)['size']; 78 | 79 | // [metadata] 80 | $metadataBytes = $buffer->read($metadataSize); 81 | $checksumBytes .= $metadataBytes; 82 | 83 | // unSerialize 84 | $metadata = new MessageMetadata($metadataBytes); 85 | 86 | // [payloads] 87 | $payloadBytes = $buffer->read($buffer->readableLength()); 88 | $checksumBytes .= $payloadBytes; 89 | 90 | // checksum verify fail 91 | if ($checksum != self::calculateChecksum($checksumBytes)) { 92 | throw new RuntimeException('checksum verify failed'); 93 | } 94 | 95 | $compressionProvider = Factory::create($metadata->getCompression()->value()); 96 | $decodePayload = $compressionProvider->decode($payloadBytes, $metadata->getUncompressedSize()); 97 | $buffer = new Buffer($decodePayload); 98 | 99 | $messages = []; 100 | $batchNums = $metadata->getNumMessagesInBatch(); 101 | // The default value is 1 102 | if ($batchNums <= 0) { 103 | $batchNums = 1; 104 | } 105 | $batchIdx = 0; 106 | $messageIDData = $commandMessage->getMessageId(); 107 | $trackingValue = 0; 108 | while ($buffer->readableLength()) { 109 | if ($metadata->hasNumMessagesInBatch()) { 110 | list($properties, $payload) = self::readSingleMessage($buffer); 111 | } else { 112 | $payload = self::readMessage($buffer); 113 | $properties = $metadata->getPropertiesList(); 114 | } 115 | 116 | $messages[] = new Message( 117 | $messageIDData, 118 | $commandMessage->getConsumerId(), 119 | (string)$metadata->getPublishTime(), 120 | $topic, 121 | $payload, 122 | $batchNums, 123 | $batchIdx, 124 | $commandMessage->getRedeliveryCount(), 125 | $properties 126 | ); 127 | $trackingValue += $batchIdx; 128 | $batchIdx += 1; 129 | } 130 | 131 | Tracking::add($messageIDData, $trackingValue); 132 | return $messages; 133 | } 134 | 135 | 136 | /** 137 | * @param string $bytes 138 | * @return float|int 139 | */ 140 | protected static function calculateChecksum(string $bytes) 141 | { 142 | $hc = CRC32::create(CRC32::CASTAGNOLI); 143 | $hc->update($bytes); 144 | return hexdec($hc->hash()); 145 | } 146 | 147 | 148 | 149 | /** 150 | * @param Buffer $buffer 151 | * @return false|string 152 | */ 153 | protected static function readMessage(Buffer $buffer) 154 | { 155 | // Format [payload] 156 | return $buffer->read($buffer->readableLength()); 157 | } 158 | 159 | 160 | 161 | /** 162 | * @param Buffer $buffer 163 | * @return array 164 | */ 165 | protected static function readSingleMessage(Buffer $buffer): array 166 | { 167 | // Format [metadataSize] [metadata] [payload] 168 | 169 | // [metadataSize] 170 | $size = $buffer->readUint32(); 171 | 172 | // [metadata] 173 | $singleMetadataBuffer = $buffer->read($size); 174 | $singleMetadata = new SingleMessageMetadata($singleMetadataBuffer); 175 | 176 | // [payload] 177 | $payload = $buffer->read($singleMetadata->getPayloadSize()); 178 | return [$singleMetadata->getPropertiesList(), $payload]; 179 | } 180 | } -------------------------------------------------------------------------------- /src/Util/Tracking.php: -------------------------------------------------------------------------------- 1 | value(); 32 | if ($value == Type::LOOKUP_VALUE) { 33 | return 'setLookupTopic'; 34 | } 35 | 36 | if ($value == Type::REDELIVER_UNACKNOWLEDGED_MESSAGES_VALUE) { 37 | return 'setRedeliverUnacknowledgedMessages'; 38 | } 39 | 40 | // lookupTopicResponse 41 | if ($value == Type::LOOKUP_RESPONSE_VALUE) { 42 | return 'getLookupTopicResponse'; 43 | } 44 | 45 | if ($value == Type::PARTITIONED_METADATA_VALUE) { 46 | return 'setPartitionMetadata'; 47 | } 48 | 49 | if ($value == Type::PARTITIONED_METADATA_RESPONSE_VALUE) { 50 | return 'getPartitionMetadataResponse'; 51 | } 52 | 53 | foreach (explode('_', $type->name()) as $part) { 54 | $method .= ucfirst(strtolower($part)); 55 | } 56 | 57 | return $method; 58 | } 59 | } -------------------------------------------------------------------------------- /src/Util/ping.bytes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikilobyte/pulsar-client-php/179e1d8ee8b2595775a43f422463da936aed01f9/src/Util/ping.bytes -------------------------------------------------------------------------------- /src/Util/pong.bytes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikilobyte/pulsar-client-php/179e1d8ee8b2595775a43f422463da936aed01f9/src/Util/pong.bytes --------------------------------------------------------------------------------