├── .gitignore ├── README.md ├── composer.json ├── src ├── Client.php ├── ConnectionPool.php ├── Emitter.php ├── ParallelClient.php ├── ProxyHelper.php ├── Request.php └── Response.php └── tests ├── RequestTest.php └── start.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .buildpath 3 | .project 4 | .settings 5 | vendor 6 | composer.lock 7 | examples 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Http client 2 | Asynchronous http client for PHP based on workerman. 3 | 4 | - Asynchronous requests. 5 | 6 | - Uses PSR-7 interfaces for requests, responses. 7 | 8 | - Build-in connection pool. 9 | 10 | - Support Http proxy. 11 | 12 | - Parallel Request. (use 'revolt/event-loop') 13 | 14 | # Installation 15 | `composer require workerman/http-client` 16 | 17 | # Examples 18 | **example.php** 19 | ```php 20 | onWorkerStart = function () { 27 | $http = new Workerman\Http\Client(); 28 | 29 | $http->get('https://example.com/', function ($response) { 30 | var_dump($response->getStatusCode()); 31 | echo $response->getBody(); 32 | }, function ($exception) { 33 | echo $exception; 34 | }); 35 | 36 | $http->post('https://example.com/', ['key1' => 'value1', 'key2' => 'value2'], function ($response) { 37 | var_dump($response->getStatusCode()); 38 | echo $response->getBody(); 39 | }, function ($exception) { 40 | echo $exception; 41 | }); 42 | 43 | $http->request('https://example.com/', [ 44 | 'method' => 'POST', 45 | 'version' => '1.1', 46 | 'headers' => ['Connection' => 'keep-alive'], 47 | 'data' => ['key1' => 'value1', 'key2' => 'value2'], 48 | 'success' => function ($response) { 49 | echo $response->getBody(); 50 | }, 51 | 'error' => function ($exception) { 52 | echo $exception; 53 | } 54 | ]); 55 | }; 56 | Worker::runAll(); 57 | ``` 58 | 59 | Run with commands `php example.php start` or `php example.php start -d` 60 | 61 | # Optinons 62 | ```php 63 | onWorkerStart = function(){ 68 | $options = [ 69 | 'max_conn_per_addr' => 128, 70 | 'keepalive_timeout' => 15, 71 | 'connect_timeout' => 30, 72 | 'timeout' => 30, 73 | ]; 74 | $http = new Workerman\Http\Client($options); 75 | 76 | $http->get('http://example.com/', function($response){ 77 | var_dump($response->getStatusCode()); 78 | echo $response->getBody(); 79 | }, function($exception){ 80 | echo $exception; 81 | }); 82 | }; 83 | Worker::runAll(); 84 | ``` 85 | 86 | # Proxy 87 | ```php 88 | require __DIR__ . '/vendor/autoload.php'; 89 | use Workerman\Worker; 90 | $worker = new Worker(); 91 | $worker->onWorkerStart = function(){ 92 | $options = [ 93 | 'max_conn_per_addr' => 128, 94 | 'keepalive_timeout' => 15, 95 | 'connect_timeout' => 30, 96 | 'timeout' => 30, 97 | // 'context' => [ 98 | // 'http' => [ 99 | // // All use '$http' will cross proxy. The highest priority here. !!! 100 | // 'proxy' => 'http://127.0.0.1:1080', 101 | // ], 102 | // ], 103 | ]; 104 | $http = new Workerman\Http\Client($options); 105 | 106 | $http->request('https://example.com/', [ 107 | 'method' => 'GET', 108 | 'proxy' => 'http://127.0.0.1:1080', 109 | // 'proxy' => 'socks5://127.0.0.1:1081', 110 | 'success' => function ($response) { 111 | echo $response->getBody(); 112 | }, 113 | 'error' => function ($exception) { 114 | echo $exception; 115 | } 116 | ]); 117 | }; 118 | Worker::runAll(); 119 | 120 | ``` 121 | 122 | # Parallel 123 | 124 | This feature requires http-client version >= v3.0 . 125 | When using the fiber driver, you must install revolt/event-loop. 126 | 127 | ```php 128 | use Workerman\Worker; 129 | use Workerman\Events\Fiber; 130 | use Workerman\Events\Swoole; 131 | use Workerman\Events\Swow; 132 | 133 | require_once __DIR__ . '/vendor/autoload.php'; 134 | 135 | $worker = new Worker(); 136 | $worker->eventLoop = Fiber::class; // or Swoole::class or Swow::class 137 | $worker->onWorkerStart = function () { 138 | $http = new Workerman\Http\ParallelClient(); 139 | 140 | $http->batch([ 141 | ['http://example.com', ['method' => 'POST', 'data' => ['key1' => 'value1', 'key2' => 'value2']]], 142 | ['https://example2.com', ['method' => 'GET']], 143 | ]); 144 | $http->push('http://example3.com'); 145 | 146 | $result = $http->await(false); 147 | // $result: 148 | // [ 149 | // [bool $isSuccess = true, Workerman\Http\Response $response], 150 | // [bool $isSuccess = false, Throwable $error], 151 | // [bool $isSuccess, Workerman\Http\Response $response], 152 | // ] 153 | 154 | }; 155 | Worker::runAll(); 156 | ``` 157 | 158 | # License 159 | 160 | MIT 161 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "workerman/http-client", 3 | "type" : "library", 4 | "homepage": "https://www.workerman.net", 5 | "license" : "MIT", 6 | "require": { 7 | "workerman/psr7": ">=1.4.3", 8 | "workerman/workerman": "^5.1 || dev-master" 9 | }, 10 | "autoload": { 11 | "psr-4": {"Workerman\\Http\\": "./src"} 12 | }, 13 | "require-dev": { 14 | "phpunit/phpunit": "^11.0", 15 | "revolt/event-loop": "^1.0" 16 | }, 17 | "autoload-dev": { 18 | "psr-4": { 19 | "Workerman\\Http\\": "./src", 20 | "tests\\": "tests" 21 | } 22 | }, 23 | "scripts": { 24 | "test": "php tests/start.php start" 25 | }, 26 | "minimum-stability": "dev", 27 | "prefer-stable": true 28 | } 29 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | namespace Workerman\Http; 15 | 16 | use Exception; 17 | use RuntimeException; 18 | use Throwable; 19 | use Workerman\Coroutine\Channel; 20 | use Workerman\Coroutine; 21 | use Workerman\Timer; 22 | 23 | /** 24 | * Class Http\Client 25 | * @package Workerman\Http 26 | */ 27 | #[\AllowDynamicProperties] 28 | class Client 29 | { 30 | /** 31 | * 32 | *[ 33 | * address=>[ 34 | * [ 35 | * 'url'=>x, 36 | * 'address'=>x 37 | * 'options'=>['method', 'data'=>x, 'success'=>callback, 'error'=>callback, 'headers'=>[..], 'version'=>1.1] 38 | * ], 39 | * .. 40 | * ], 41 | * .. 42 | * ] 43 | * @var array 44 | */ 45 | protected array $queue = []; 46 | 47 | /** 48 | * @var ?ConnectionPool 49 | */ 50 | protected ?ConnectionPool $_connectionPool = null; 51 | 52 | protected Channel $locker; 53 | 54 | /** 55 | * Client constructor. 56 | * 57 | * @param array $options 58 | */ 59 | public function __construct(array $options = []) 60 | { 61 | $this->_connectionPool = new ConnectionPool($options); 62 | $this->_connectionPool->on('idle', array($this, 'process')); 63 | $this->locker = new Channel(1); 64 | } 65 | 66 | /** 67 | * Request. 68 | * 69 | * @param $url string 70 | * @param array $options ['method'=>'get', 'data'=>x, 'success'=>callback, 'error'=>callback, 'headers'=>[..], 'version'=>1.1] 71 | * @return mixed|Response 72 | * @throws Throwable 73 | */ 74 | public function request(string $url, array $options = []): mixed 75 | { 76 | $options['url'] = $url; 77 | $suspend = false; 78 | $isCoroutine = !isset($options['success']) && Coroutine::isCoroutine(); 79 | if ($isCoroutine) { 80 | $options['is_coroutine'] = true; 81 | $result = $exception = null; 82 | $coroutine = Coroutine::getCurrent(); 83 | $options['success'] = function ($response) use ($coroutine, &$result, &$suspend) { 84 | $result = $response; 85 | $suspend && $coroutine->resume(); 86 | }; 87 | $options['error'] = function ($throwable) use ($coroutine, &$exception, &$suspend) { 88 | $exception = $throwable; 89 | $suspend && $coroutine->resume(); 90 | }; 91 | } 92 | try { 93 | $address = $this->parseAddress($url); 94 | $this->queuePush($address, ['url' => $url, 'address' => $address, 'options' => $options]); 95 | $this->process($address); 96 | } catch (Throwable $exception) { 97 | $this->deferError($options, $exception); 98 | if ($isCoroutine) { 99 | throw $exception; 100 | } 101 | return null; 102 | } 103 | if ($isCoroutine) { 104 | $suspend = true; 105 | $coroutine->suspend(); 106 | if ($exception) { 107 | throw $exception; 108 | } 109 | return $result; 110 | } 111 | return null; 112 | } 113 | 114 | /** 115 | * Get. 116 | * 117 | * @param $url 118 | * @param null $success_callback 119 | * @param null $error_callback 120 | * @return mixed|Response 121 | * @throws Throwable 122 | */ 123 | public function get($url, $success_callback = null, $error_callback = null): mixed 124 | { 125 | $options = []; 126 | if ($success_callback) { 127 | $options['success'] = $success_callback; 128 | } 129 | if ($error_callback) { 130 | $options['error'] = $error_callback; 131 | } 132 | return $this->request($url, $options); 133 | } 134 | 135 | /** 136 | * Post. 137 | * 138 | * @param $url 139 | * @param array $data 140 | * @param null $success_callback 141 | * @param null $error_callback 142 | * @return mixed|Response 143 | * @throws Throwable 144 | */ 145 | public function post($url, $data = [], $success_callback = null, $error_callback = null): mixed 146 | { 147 | $options = []; 148 | if ($data) { 149 | $options['data'] = $data; 150 | } 151 | if ($success_callback) { 152 | $options['success'] = $success_callback; 153 | } 154 | if ($error_callback) { 155 | $options['error'] = $error_callback; 156 | } 157 | $options['method'] = 'POST'; 158 | return $this->request($url, $options); 159 | } 160 | 161 | /** 162 | * Process. 163 | * User should not call this. 164 | * 165 | * @param $address 166 | * @return void 167 | * @throws Exception 168 | */ 169 | public function process($address): void 170 | { 171 | $this->locker->push(true); 172 | $task = $this->queueCurrent($address); 173 | if (!$task) { 174 | $this->locker->pop(); 175 | return; 176 | } 177 | 178 | $url = $task['url']; 179 | $address = $task['address']; 180 | 181 | $connection = $this->_connectionPool->fetch($address, strpos($url, 'https') === 0, $task['options']['proxy'] ?? ''); 182 | // No connection is in idle state then wait. 183 | if (!$connection) { 184 | $this->locker->pop(); 185 | return; 186 | } 187 | 188 | $connection->errorHandler = function(Throwable $exception) use ($task) { 189 | $this->deferError($task['options'], $exception); 190 | }; 191 | $this->queuePop($address); 192 | $this->locker->pop(); 193 | $options = $task['options']; 194 | $request = new Request($url); 195 | $data = $options['data'] ?? ''; 196 | if ($data || $data === '0' || $data === 0) { 197 | $method = isset($options['method']) ? strtoupper($options['method']) : null; 198 | if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) { 199 | $request->write($options['data']); 200 | } else { 201 | $options['query'] = $data; 202 | } 203 | } 204 | $request->setOptions($options)->attachConnection($connection); 205 | 206 | $client = $this; 207 | $request->once('success', function($response) use ($task, $client, $request) { 208 | $client->recycleConnectionFromRequest($request, $response); 209 | try { 210 | $new_request = Request::redirect($request, $response); 211 | } catch (Exception $exception) { 212 | $this->deferError($task['options'], $exception); 213 | return; 214 | } 215 | // No redirect. 216 | if (!$new_request) { 217 | if (!empty($task['options']['success'])) { 218 | call_user_func($task['options']['success'], $response); 219 | } 220 | return; 221 | } 222 | 223 | // Redirect. 224 | $uri = $new_request->getUri(); 225 | $url = (string)$uri; 226 | $options = $new_request->getOptions(); 227 | // According to RFC 7231, for HTTP status codes 301, 302, or 303, the client should switch the request 228 | // method to GET and remove any payload data 229 | if (in_array($response->getStatusCode(), [301, 302, 303])) { 230 | $options['method'] = 'GET'; 231 | $options['data'] = NULL; 232 | } 233 | $address = $this->parseAddress($url); 234 | $task = [ 235 | 'url' => $url, 236 | 'options' => $options, 237 | 'address' => $address 238 | ]; 239 | $this->queueUnshift($address, $task); 240 | $this->process($address); 241 | })->once('error', function($exception) use ($task, $client, $request) { 242 | $client->recycleConnectionFromRequest($request); 243 | $this->deferError($task['options'], $exception); 244 | }); 245 | 246 | if (isset($options['progress'])) { 247 | $request->on('progress', $options['progress']); 248 | } 249 | 250 | $state = $connection->getStatus(false); 251 | if ($state === 'CLOSING' || $state === 'CLOSED') { 252 | $connection->reconnect(); 253 | } 254 | 255 | $state = $connection->getStatus(false); 256 | if ($state === 'CLOSED' || $state === 'CLOSING') { 257 | return; 258 | } 259 | 260 | $request->end(''); 261 | } 262 | 263 | /** 264 | * Recycle connection from request. 265 | * 266 | * @param $request Request 267 | * @param $response Response|null 268 | */ 269 | public function recycleConnectionFromRequest(Request $request, ?Response $response = null): void 270 | { 271 | $connection = $request->getConnection(); 272 | if (!$connection) { 273 | return; 274 | } 275 | $connection->onConnect = $connection->onClose = $connection->onMessage = $connection->onError = null; 276 | $request_header_connection = strtolower($request->getHeaderLine('Connection')); 277 | $response_header_connection = $response ? strtolower($response->getHeaderLine('Connection')) : ''; 278 | // Close Connection without header Connection: keep-alive 279 | if ('keep-alive' !== $request_header_connection || 'keep-alive' !== $response_header_connection || $request->getProtocolVersion() !== '1.1') { 280 | $connection->close(); 281 | } 282 | $request->detachConnection($connection); 283 | $this->_connectionPool->recycle($connection); 284 | } 285 | 286 | /** 287 | * Parse address from url. 288 | * 289 | * @param $url 290 | * @return string 291 | */ 292 | protected function parseAddress($url): string 293 | { 294 | $info = parse_url($url); 295 | if (empty($info) || !isset($info['host'])) { 296 | throw new RuntimeException("invalid url: $url"); 297 | } 298 | $port = $info['port'] ?? (str_starts_with($url, 'https') ? 443 : 80); 299 | return "tcp://{$info['host']}:{$port}"; 300 | } 301 | 302 | /** 303 | * Queue push. 304 | * 305 | * @param $address 306 | * @param $task 307 | */ 308 | protected function queuePush($address, $task): void 309 | { 310 | if (!isset($this->queue[$address])) { 311 | $this->queue[$address] = []; 312 | } 313 | $this->queue[$address][] = $task; 314 | } 315 | 316 | /** 317 | * Queue unshift. 318 | * 319 | * @param $address 320 | * @param $task 321 | */ 322 | protected function queueUnshift($address, $task): void 323 | { 324 | if (!isset($this->queue[$address])) { 325 | $this->queue[$address] = []; 326 | } 327 | $this->queue[$address] += [$task]; 328 | } 329 | 330 | /** 331 | * Queue current item. 332 | * 333 | * @param $address 334 | * @return mixed|null 335 | */ 336 | protected function queueCurrent($address): mixed 337 | { 338 | if (empty($this->queue[$address])) { 339 | return null; 340 | } 341 | reset($this->queue[$address]); 342 | return current($this->queue[$address]); 343 | } 344 | 345 | /** 346 | * Queue pop. 347 | * 348 | * @param $address 349 | */ 350 | protected function queuePop($address): void 351 | { 352 | unset($this->queue[$address][key($this->queue[$address])]); 353 | if (empty($this->queue[$address])) { 354 | unset($this->queue[$address]); 355 | } 356 | } 357 | 358 | /** 359 | * @param $options 360 | * @param $exception 361 | * @return void 362 | */ 363 | protected function deferError($options, $exception): void 364 | { 365 | if ($options['is_coroutine'] ?? false) { 366 | if ($options['error']) { 367 | call_user_func($options['error'], $exception); 368 | return; 369 | } 370 | throw $exception; 371 | } 372 | if (isset($options['error'])) { 373 | Timer::add(0.000001, $options['error'], [$exception], false); 374 | } 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/ConnectionPool.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | namespace Workerman\Http; 15 | 16 | use Exception; 17 | use Throwable; 18 | use \Workerman\Connection\AsyncTcpConnection; 19 | use \Workerman\Timer; 20 | use \Workerman\Worker; 21 | 22 | /** 23 | * Class ConnectionPool 24 | * @package Workerman\Http 25 | */ 26 | class ConnectionPool extends Emitter 27 | { 28 | /** 29 | * @var array 30 | */ 31 | protected array $idle = []; 32 | 33 | /** 34 | * @var array 35 | */ 36 | protected array $using = []; 37 | 38 | /** 39 | * @var int 40 | */ 41 | protected int $timer = 0; 42 | 43 | /** 44 | * @var array 45 | */ 46 | protected array $options = [ 47 | 'max_conn_per_addr' => 128, 48 | 'keepalive_timeout' => 15, 49 | 'connect_timeout' => 30, 50 | 'timeout' => 30, 51 | ]; 52 | 53 | /** 54 | * ConnectionPool constructor. 55 | * 56 | * @param array $option 57 | */ 58 | public function __construct(array $option = []) 59 | { 60 | $this->options = array_merge($this->options, $option); 61 | } 62 | 63 | /** 64 | * Fetch an idle connection. 65 | * 66 | * @param $address 67 | * @param bool $ssl 68 | * @param string $proxy 69 | * @return mixed 70 | * @throws Exception 71 | */ 72 | public function fetch($address, bool $ssl = false, string $proxy = ''): mixed 73 | { 74 | $max_con = $this->options['max_conn_per_addr']; 75 | $targetAddress = $address; 76 | $address = ProxyHelper::addressKey($address, $proxy); 77 | if (!empty($this->using[$address])) { 78 | if (count($this->using[$address]) >= $max_con) { 79 | return null; 80 | } 81 | } 82 | if (empty($this->idle[$address])) { 83 | $connection = $this->create($targetAddress, $ssl, $proxy); 84 | $this->idle[$address][$connection->id] = $connection; 85 | } 86 | $connection = array_pop($this->idle[$address]); 87 | if (!isset($this->using[$address])) { 88 | $this->using[$address] = []; 89 | } 90 | $this->using[$address][$connection->id] = $connection; 91 | $connection->pool['request_time'] = time(); 92 | $this->tryToCreateConnectionCheckTimer(); 93 | return $connection; 94 | } 95 | 96 | /** 97 | * Recycle a connection. 98 | * 99 | * @param $connection AsyncTcpConnection 100 | */ 101 | public function recycle(AsyncTcpConnection $connection): void 102 | { 103 | $connection_id = $connection->id; 104 | $address = $connection->address; 105 | unset($this->using[$address][$connection_id]); 106 | if (empty($this->using[$address])) { 107 | unset($this->using[$address]); 108 | } 109 | if ($connection->getStatus(false) === 'ESTABLISHED') { 110 | $this->idle[$address][$connection_id] = $connection; 111 | $connection->pool['idle_time'] = time(); 112 | $connection->onConnect = $connection->onMessage = $connection->onError = 113 | $connection->onClose = $connection->onBufferFull = $connection->onBufferDrain = null; 114 | } 115 | $this->tryToCreateConnectionCheckTimer(); 116 | $this->emit('idle', $address); 117 | } 118 | 119 | /** 120 | * Delete a connection. 121 | * 122 | * @param $connection 123 | */ 124 | public function delete($connection): void 125 | { 126 | $connection_id = $connection->id; 127 | $address = $connection->address; 128 | unset($this->idle[$address][$connection_id]); 129 | if (empty($this->idle[$address])) { 130 | unset($this->idle[$address]); 131 | } 132 | unset($this->using[$address][$connection_id]); 133 | if (empty($this->using[$address])) { 134 | unset($this->using[$address]); 135 | } 136 | } 137 | 138 | /** 139 | * Close timeout connection. 140 | * @throws Throwable 141 | */ 142 | public function closeTimeoutConnection(): void 143 | { 144 | if (empty($this->idle) && empty($this->using)) { 145 | Timer::del($this->timer); 146 | $this->timer = 0; 147 | return; 148 | } 149 | $time = time(); 150 | $keepalive_timeout = $this->options['keepalive_timeout']; 151 | foreach ($this->idle as $address => $connections) { 152 | if (empty($connections)) { 153 | unset($this->idle[$address]); 154 | continue; 155 | } 156 | foreach ($connections as $connection) { 157 | if ($time - $connection->pool['idle_time'] >= $keepalive_timeout) { 158 | $this->delete($connection); 159 | $connection->close(); 160 | } 161 | } 162 | } 163 | 164 | $connect_timeout = $this->options['connect_timeout']; 165 | $timeout = $this->options['timeout']; 166 | foreach ($this->using as $address => $connections) { 167 | if (empty($connections)) { 168 | unset($this->using[$address]); 169 | continue; 170 | } 171 | foreach ($connections as $connection) { 172 | $state = $connection->getStatus(false); 173 | if ($state === 'CONNECTING') { 174 | $diff = $time - $connection->pool['connect_time']; 175 | if ($diff >= $connect_timeout) { 176 | $connection->onClose = null; 177 | if ($connection->onError) { 178 | try { 179 | call_user_func($connection->onError, $connection, 1, 'connect ' . $connection->getRemoteAddress() . ' timeout after ' . $diff . ' seconds'); 180 | } catch (Throwable $exception) { 181 | $this->delete($connection); 182 | $connection->close(); 183 | throw $exception; 184 | } 185 | } 186 | $this->delete($connection); 187 | $connection->close(); 188 | } 189 | } elseif ($state === 'ESTABLISHED') { 190 | $diff = $time - $connection->pool['request_time']; 191 | if ($diff >= $timeout) { 192 | if ($connection->onError) { 193 | try { 194 | call_user_func($connection->onError, $connection, 128, 'read ' . $connection->getRemoteAddress() . ' timeout after ' . $diff . ' seconds'); 195 | } catch (Throwable $exception) { 196 | $this->delete($connection); 197 | $connection->close(); 198 | throw $exception; 199 | } 200 | } 201 | $this->delete($connection); 202 | $connection->close(); 203 | } 204 | } 205 | } 206 | } 207 | gc_collect_cycles(); 208 | } 209 | 210 | /** 211 | * Create a connection. 212 | * 213 | * @param $address 214 | * @param bool $ssl 215 | * @param string $proxy 216 | * @return AsyncTcpConnection 217 | * @throws Exception 218 | */ 219 | protected function create($address, bool $ssl = false, string $proxy = ''): AsyncTcpConnection 220 | { 221 | $context = [ 222 | 'ssl' => [ 223 | 'verify_peer' => false, 224 | 'verify_peer_name' => false, 225 | 'allow_self_signed' => true 226 | ], 227 | 'http' => [ 228 | 'proxy' => $proxy, 229 | ] 230 | ]; 231 | if (!empty( $this->options['context'])) { 232 | $context = array_merge($context, $this->options['context']); 233 | } 234 | if (!$ssl) { 235 | unset($context['ssl']); 236 | } 237 | if (empty($proxy)) { 238 | unset($context['http']['proxy']); 239 | } 240 | if (!class_exists(Worker::class) || is_null(Worker::$globalEvent)) { 241 | throw new Exception('Only the workerman environment is supported.'); 242 | } 243 | $connection = new AsyncTcpConnection($address, $context); 244 | if ($ssl) { 245 | $connection->transport = 'ssl'; 246 | } 247 | ProxyHelper::setConnectionProxy($connection, $context); 248 | $connection->address = ProxyHelper::addressKey($address, $proxy); 249 | $connection->connect(); 250 | $connection->pool = ['connect_time' => time()]; 251 | return $connection; 252 | } 253 | 254 | /** 255 | * Create Timer. 256 | */ 257 | protected function tryToCreateConnectionCheckTimer(): void 258 | { 259 | if (!$this->timer) { 260 | $this->timer = Timer::add(1, [$this, 'closeTimeoutConnection']); 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/Emitter.php: -------------------------------------------------------------------------------- 1 | [[listener1, once?], [listener2,once?], ..], ..] 7 | */ 8 | protected array $eventListenerMap = []; 9 | 10 | /** 11 | * On. 12 | * 13 | * @param $event_name 14 | * @param $listener 15 | * @return $this 16 | */ 17 | public function on($event_name, $listener): static 18 | { 19 | $this->emit('newListener', $event_name, $listener); 20 | $this->eventListenerMap[$event_name][] = array($listener, 0); 21 | return $this; 22 | } 23 | 24 | /** 25 | * Once. 26 | * 27 | * @param $event_name 28 | * @param $listener 29 | * @return $this 30 | */ 31 | public function once($event_name, $listener): static 32 | { 33 | $this->eventListenerMap[$event_name][] = array($listener, 1); 34 | return $this; 35 | } 36 | 37 | /** 38 | * RemoveListener. 39 | * 40 | * @param $event_name 41 | * @param $listener 42 | * @return $this 43 | */ 44 | public function removeListener($event_name, $listener): static 45 | { 46 | if(!isset($this->eventListenerMap[$event_name])) 47 | { 48 | return $this; 49 | } 50 | foreach($this->eventListenerMap[$event_name] as $key=>$item) 51 | { 52 | if($item[0] === $listener) 53 | { 54 | $this->emit('removeListener', $event_name, $listener); 55 | unset($this->eventListenerMap[$event_name][$key]); 56 | } 57 | } 58 | if(empty($this->eventListenerMap[$event_name])) 59 | { 60 | unset($this->eventListenerMap[$event_name]); 61 | } 62 | return $this; 63 | } 64 | 65 | /** 66 | * RemoveAllListeners. 67 | * 68 | * @param null $event_name 69 | * @return $this 70 | */ 71 | public function removeAllListeners($event_name = null): static 72 | { 73 | $this->emit('removeListener', $event_name); 74 | if(null === $event_name) 75 | { 76 | $this->eventListenerMap = []; 77 | return $this; 78 | } 79 | unset($this->eventListenerMap[$event_name]); 80 | return $this; 81 | } 82 | 83 | /** 84 | * 85 | * Listeners. 86 | * 87 | * @param $event_name 88 | * @return array 89 | */ 90 | public function listeners($event_name): array 91 | { 92 | if(empty($this->eventListenerMap[$event_name])) 93 | { 94 | return []; 95 | } 96 | $listeners = []; 97 | foreach($this->eventListenerMap[$event_name] as $item) 98 | { 99 | $listeners[] = $item[0]; 100 | } 101 | return $listeners; 102 | } 103 | 104 | /** 105 | * Emit. 106 | * 107 | * @param null $event_name 108 | * @return bool 109 | */ 110 | public function emit($event_name = null): bool 111 | { 112 | if(empty($event_name) || empty($this->eventListenerMap[$event_name])) 113 | { 114 | return false; 115 | } 116 | foreach($this->eventListenerMap[$event_name] as $key=>$item) 117 | { 118 | $args = func_get_args(); 119 | unset($args[0]); 120 | call_user_func_array($item[0], $args); 121 | // once ? 122 | if($item[1]) 123 | { 124 | unset($this->eventListenerMap[$event_name][$key]); 125 | if(empty($this->eventListenerMap[$event_name])) 126 | { 127 | unset($this->eventListenerMap[$event_name]); 128 | } 129 | } 130 | } 131 | return true; 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /src/ParallelClient.php: -------------------------------------------------------------------------------- 1 | parallel = new Parallel($options['concurrent'] ?? -1); 26 | parent::__construct($options); 27 | } 28 | 29 | /** 30 | * Push a request to parallel. 31 | * 32 | * @param string $url 33 | * @param array $options 34 | * @return void 35 | * @throws Throwable 36 | */ 37 | public function push(string $url, array $options = []): void 38 | { 39 | $this->parallel->add(function () use ($url, $options) { 40 | return $this->request($url, $options); 41 | }); 42 | } 43 | 44 | 45 | /** 46 | * Batch requests to parallel. 47 | * 48 | * @param array $requests 49 | * @return void 50 | * @throws Throwable 51 | */ 52 | public function batch(array $requests): void 53 | { 54 | foreach ($requests as $key => $request) { 55 | $this->parallel->add(function () use ($request) { 56 | return $this->request($request[0], $request[1]); 57 | }, $key); 58 | } 59 | } 60 | 61 | /** 62 | * Wait for all requests to complete. 63 | * 64 | * @param bool $errorThrow 65 | * @return array 66 | * @throws Throwable 67 | */ 68 | public function await(bool $errorThrow = false): array 69 | { 70 | $results = $this->parallel->wait(); 71 | $exceptions = $this->parallel->getExceptions(); 72 | if ($errorThrow && $exceptions) { 73 | throw current($exceptions); 74 | } 75 | $data = []; 76 | foreach ($results as $key => $response) { 77 | $data[$key] = [true, $response]; 78 | } 79 | foreach ($exceptions as $key => $exception) { 80 | $data[$key] = [false, $exception]; 81 | } 82 | ksort($data); 83 | return $data; 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/ProxyHelper.php: -------------------------------------------------------------------------------- 1 | proxySocks5 = $proxyString; 17 | } else if ($proxyScheme === 'http') { 18 | $connection->proxyHttp = $proxyString; 19 | } 20 | } 21 | } 22 | 23 | public static function addressKey(string $address, string $proxyString): string 24 | { 25 | if (!str_contains($proxyString, '://')) { 26 | return $address; 27 | } else { 28 | return explode('//', $proxyString)[1] ?? ''; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | namespace Workerman\Http; 15 | 16 | use Exception; 17 | use Psr\Http\Message\MessageInterface; 18 | use RuntimeException; 19 | use Throwable; 20 | use \Workerman\Connection\AsyncTcpConnection; 21 | use Workerman\Psr7\MultipartStream; 22 | use Workerman\Psr7\UriResolver; 23 | use Workerman\Psr7\Uri; 24 | use function Workerman\Psr7\_parse_message; 25 | use function Workerman\Psr7\rewind_body; 26 | use function Workerman\Psr7\str; 27 | 28 | /** 29 | * Class Request 30 | * @package Workerman\Http 31 | */ 32 | #[\AllowDynamicProperties] 33 | class Request extends \Workerman\Psr7\Request 34 | { 35 | /** 36 | * @var ?AsyncTcpConnection 37 | */ 38 | protected ?AsyncTcpConnection $connection = null; 39 | 40 | /** 41 | * @var ?Emitter 42 | */ 43 | protected ?Emitter $emitter = null; 44 | 45 | /** 46 | * @var ?Response 47 | */ 48 | protected ?Response $response = null; 49 | 50 | /** 51 | * @var string 52 | */ 53 | protected string $receiveBuffer = ''; 54 | 55 | /** 56 | * @var int 57 | */ 58 | protected int $expectedLength = 0; 59 | 60 | /** 61 | * @var int 62 | */ 63 | protected int $chunkedLength = 0; 64 | 65 | /** 66 | * @var string 67 | */ 68 | protected string $chunkedData = ''; 69 | 70 | /** 71 | * @var bool 72 | */ 73 | protected bool $writeable = true; 74 | 75 | /** 76 | * @var bool 77 | */ 78 | protected bool $selfConnection = false; 79 | 80 | /** 81 | * @var array 82 | */ 83 | protected array $options = [ 84 | 'allow_redirects' => [ 85 | 'max' => 5 86 | ] 87 | ]; 88 | 89 | /** 90 | * Request constructor. 91 | * @param string $url 92 | */ 93 | public function __construct($url) 94 | { 95 | $this->emitter = new Emitter(); 96 | $headers = [ 97 | 'User-Agent' => 'workerman/http-client', 98 | 'Connection' => 'keep-alive' 99 | ]; 100 | parent::__construct('GET', $url, $headers, '', '1.1'); 101 | } 102 | 103 | /** 104 | * @param $options 105 | * @return $this 106 | */ 107 | public function setOptions($options): static 108 | { 109 | $this->options = array_merge($this->options, $options); 110 | return $this; 111 | } 112 | 113 | /** 114 | * @return array 115 | */ 116 | public function getOptions(): array 117 | { 118 | return $this->options; 119 | } 120 | 121 | /** 122 | * @param $event 123 | * @param $callback 124 | * @return $this 125 | */ 126 | public function on($event, $callback): static 127 | { 128 | $this->emitter->on($event, $callback); 129 | return $this; 130 | } 131 | 132 | /** 133 | * @param $event 134 | * @param $callback 135 | * @return $this 136 | */ 137 | public function once($event, $callback): static 138 | { 139 | $this->emitter->once($event, $callback); 140 | return $this; 141 | } 142 | 143 | /** 144 | * @param $event 145 | */ 146 | public function emit($event): void 147 | { 148 | $args = func_get_args(); 149 | call_user_func_array(array($this->emitter, 'emit'), $args); 150 | } 151 | 152 | /** 153 | * @param $event 154 | * @param $listener 155 | * @return $this 156 | */ 157 | public function removeListener($event, $listener): static 158 | { 159 | $this->emitter->removeListener($event, $listener); 160 | return $this; 161 | } 162 | 163 | /** 164 | * @param null $event 165 | * @return $this 166 | */ 167 | public function removeAllListeners($event = null): static 168 | { 169 | $this->emitter->removeAllListeners($event); 170 | return $this; 171 | } 172 | 173 | /** 174 | * @param $event 175 | * @return $this 176 | */ 177 | public function listeners($event): static 178 | { 179 | $this->emitter->listeners($event); 180 | return $this; 181 | } 182 | 183 | /** 184 | * Connect. 185 | * 186 | * @return void 187 | * @throws Exception 188 | */ 189 | protected function connect(): void 190 | { 191 | $host = $this->getUri()->getHost(); 192 | $port = $this->getUri()->getPort(); 193 | if (!$port) { 194 | $port = $this->getDefaultPort(); 195 | } 196 | $context = []; 197 | if (!empty( $this->options['context'])) { 198 | $context = $this->options['context']; 199 | } 200 | $ssl = $this->getUri()->getScheme() === 'https'; 201 | if (!$ssl) { 202 | unset($context['ssl']); 203 | } 204 | $connection = new AsyncTcpConnection("tcp://$host:$port", $context); 205 | if ($ssl) { 206 | $connection->transport = 'ssl'; 207 | } 208 | ProxyHelper::setConnectionProxy($connection, $context); 209 | $this->attachConnection($connection); 210 | $this->selfConnection = true; 211 | $connection->connect(); 212 | } 213 | 214 | /** 215 | * @param string|array $data 216 | * @return $this 217 | */ 218 | public function write(string|array $data = ''): static 219 | { 220 | if (!$this->writeable()) { 221 | $this->emitError(new RuntimeException('Request pending and can not send request again')); 222 | return $this; 223 | } 224 | 225 | if (empty($data) && $data !== '0') { 226 | return $this; 227 | } 228 | 229 | if (is_array($data)) { 230 | if (isset($data['multipart'])) { 231 | $multipart = new MultipartStream($data['multipart']); 232 | $this->withHeader('Content-Type', 'multipart/form-data; boundary=' . $multipart->getBoundary()); 233 | $data = $multipart; 234 | } else { 235 | $data = http_build_query($data, '', '&'); 236 | } 237 | } 238 | 239 | $this->getBody()->write($data); 240 | return $this; 241 | } 242 | 243 | /** 244 | * @param $buffer 245 | * @return void 246 | */ 247 | public function writeToResponse($buffer): void 248 | { 249 | $this->emit('progress', $buffer); 250 | $this->response->getBody()->write($buffer); 251 | } 252 | 253 | /** 254 | * @param string $data 255 | * @throws Exception 256 | */ 257 | public function end(string $data = ''): void 258 | { 259 | if (isset($this->options['version'])) { 260 | $this->withProtocolVersion($this->options['version']); 261 | } 262 | 263 | if (isset($this->options['method'])) { 264 | $this->withMethod($this->options['method']); 265 | } 266 | 267 | if (isset($this->options['headers'])) { 268 | foreach ($this->options['headers'] as $key => $value) { 269 | $this->withHeader($key, $value); 270 | } 271 | } 272 | 273 | $query = $this->options['query'] ?? ''; 274 | if ($query || $query === '0') { 275 | $userParams = []; 276 | if (is_array($query)) { 277 | $userParams = $query; 278 | } else { 279 | parse_str((string)$query, $userParams); 280 | } 281 | 282 | $originalParams = []; 283 | parse_str($this->getUri()->getQuery(), $originalParams); 284 | $mergedParams = array_merge($originalParams, $userParams); 285 | $mergedQuery = http_build_query($mergedParams, '', '&', PHP_QUERY_RFC3986); 286 | $uri = $this->getUri()->withQuery($mergedQuery); 287 | $this->withUri($uri); 288 | } 289 | 290 | if ($data !== '') { 291 | $this->write($data); 292 | } 293 | 294 | if ((($data || $data === '0') || $this->getBody()->getSize()) && !$this->hasHeader('Content-Type')) { 295 | $this->withHeader('Content-Type', 'application/x-www-form-urlencoded'); 296 | } 297 | 298 | if (!$this->connection) { 299 | $this->connect(); 300 | } else { 301 | if ($this->connection->getStatus(false) === 'CONNECTING') { 302 | $this->connection->onConnect = array($this, 'onConnect'); 303 | return; 304 | } 305 | $this->doSend(); 306 | } 307 | } 308 | 309 | /** 310 | * @return bool 311 | */ 312 | public function writeable(): bool 313 | { 314 | return $this->writeable; 315 | } 316 | 317 | public function doSend(): void 318 | { 319 | if (!$this->writeable()) { 320 | $this->emitError(new RuntimeException('Request pending and can not send request again')); 321 | return; 322 | } 323 | 324 | $this->writeable = false; 325 | 326 | $body_size = $this->getBody()->getSize(); 327 | if ($body_size) { 328 | $this->withHeaders(['Content-Length' => $body_size]); 329 | } 330 | 331 | $package = str($this); 332 | $this->connection->send($package); 333 | } 334 | 335 | public function onConnect(): void 336 | { 337 | try { 338 | $this->doSend(); 339 | } catch (Throwable $e) { 340 | $this->emitError($e); 341 | } 342 | } 343 | 344 | /** 345 | * @param $connection 346 | * @param $receive_buffer 347 | */ 348 | public function onMessage($connection, $receive_buffer): void 349 | { 350 | try { 351 | $this->receiveBuffer .= $receive_buffer; 352 | if (!strpos($this->receiveBuffer, "\r\n\r\n")) { 353 | return; 354 | } 355 | 356 | $response_data = _parse_message($this->receiveBuffer); 357 | 358 | if (!preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/', $response_data['start-line'])) { 359 | throw new \InvalidArgumentException('Invalid response string: ' . $response_data['start-line']); 360 | } 361 | $parts = explode(' ', $response_data['start-line'], 3); 362 | 363 | $this->response = new Response( 364 | $parts[1], 365 | $response_data['headers'], 366 | '', 367 | explode('/', $parts[0])[1], 368 | $parts[2] ?? null 369 | ); 370 | 371 | $this->checkComplete($response_data['body']); 372 | } catch (Throwable $e) { 373 | $this->emitError($e); 374 | } 375 | } 376 | 377 | /** 378 | * @param $body 379 | */ 380 | protected function checkComplete($body): void 381 | { 382 | $status_code = $this->response->getStatusCode(); 383 | $content_length = $this->response->getHeaderLine('Content-Length'); 384 | if ($content_length === '0' || ($status_code >= 100 && $status_code < 200) 385 | || $status_code === 204 || $status_code === 304) { 386 | $this->emitSuccess(); 387 | return; 388 | } 389 | 390 | $transfer_encoding = $this->response->getHeaderLine('Transfer-Encoding'); 391 | // Chunked 392 | if ($transfer_encoding && !str_contains($transfer_encoding, 'identity')) { 393 | $this->connection->onMessage = array($this, 'handleChunkedData'); 394 | $this->handleChunkedData($this->connection, $body); 395 | } else { 396 | $this->connection->onMessage = array($this, 'handleData'); 397 | $content_length = (int)$this->response->getHeaderLine('Content-Length'); 398 | if (!$content_length) { 399 | // Wait close 400 | $this->connection->onClose = array($this, 'emitSuccess'); 401 | } else { 402 | $this->expectedLength = $content_length; 403 | } 404 | $this->handleData($this->connection, $body); 405 | } 406 | } 407 | 408 | /** 409 | * @param $connection 410 | * @param $data 411 | */ 412 | public function handleData($connection, $data): void 413 | { 414 | try { 415 | $body = $this->response->getBody(); 416 | $this->writeToResponse($data); 417 | if ($this->expectedLength) { 418 | $receive_length = $body->getSize(); 419 | if ($this->expectedLength <= $receive_length) { 420 | $this->emitSuccess(); 421 | } 422 | } 423 | } catch (Throwable $e) { 424 | $this->emitError($e); 425 | } 426 | } 427 | 428 | /** 429 | * @param $connection 430 | * @param $buffer 431 | */ 432 | public function handleChunkedData($connection, $buffer): void 433 | { 434 | try { 435 | if ($buffer !== '') { 436 | $this->chunkedData .= $buffer; 437 | } 438 | 439 | $receive_len = strlen($this->chunkedData); 440 | if ($receive_len < 2) { 441 | return; 442 | } 443 | // Get chunked length 444 | if ($this->chunkedLength === 0) { 445 | $crlf_position = strpos($this->chunkedData, "\r\n"); 446 | if ($crlf_position === false && strlen($this->chunkedData) > 1024) { 447 | $this->emitError(new RuntimeException('bad chunked length')); 448 | return; 449 | } 450 | 451 | if ($crlf_position === false) { 452 | return; 453 | } 454 | $length_chunk = substr($this->chunkedData, 0, $crlf_position); 455 | if (str_contains($crlf_position, ';')) { 456 | list($length_chunk) = explode(';', $length_chunk, 2); 457 | } 458 | $length = hexdec(ltrim(trim($length_chunk), "0")); 459 | if ($length === 0) { 460 | $this->emitSuccess(); 461 | return; 462 | } 463 | $this->chunkedLength = $length + 2; 464 | $this->chunkedData = substr($this->chunkedData, $crlf_position + 2); 465 | $this->handleChunkedData($connection, ''); 466 | return; 467 | } 468 | // Get chunked data 469 | if ($receive_len >= $this->chunkedLength) { 470 | $this->writeToResponse(substr($this->chunkedData, 0, $this->chunkedLength - 2)); 471 | $this->chunkedData = substr($this->chunkedData, $this->chunkedLength); 472 | $this->chunkedLength = 0; 473 | $this->handleChunkedData($connection, ''); 474 | } 475 | } catch (Throwable $e) { 476 | $this->emitError($e); 477 | } 478 | } 479 | 480 | /** 481 | * onError. 482 | */ 483 | public function onError($connection, $code, $msg): void 484 | { 485 | $this->emitError(new RuntimeException($msg, $code)); 486 | } 487 | 488 | /** 489 | * emitSuccess. 490 | */ 491 | public function emitSuccess(): void 492 | { 493 | $this->emit('success', $this->response); 494 | } 495 | 496 | public function emitError($e): void 497 | { 498 | try { 499 | $this->emit('error', $e); 500 | } finally { 501 | $this->connection && $this->connection->destroy(); 502 | } 503 | } 504 | 505 | /** 506 | * redirect. 507 | * 508 | * @param Request $request 509 | * @param Response $response 510 | * @return bool|MessageInterface 511 | */ 512 | public static function redirect(Request $request, Response $response): bool|MessageInterface 513 | { 514 | $options = $request->getOptions(); 515 | if (!str_starts_with($response->getStatusCode(), '3') 516 | || !$response->hasHeader('Location') 517 | || self::getMaxRedirects($options) 518 | ) { 519 | return false; 520 | } 521 | $location = UriResolver::resolve( 522 | $request->getUri(), 523 | new Uri($response->getHeaderLine('Location')) 524 | ); 525 | rewind_body($request); 526 | 527 | return (new Request($location))->setOptions($options)->withBody($request->getBody()); 528 | } 529 | 530 | /** 531 | * @param array $options 532 | * @return bool 533 | */ 534 | private static function getMaxRedirects(array &$options): bool 535 | { 536 | $current = $options['__redirect_count'] ?? 0; 537 | $options['__redirect_count'] = $current + 1; 538 | $max = $options['allow_redirects']['max']; 539 | 540 | return $options['__redirect_count'] > $max; 541 | } 542 | 543 | /** 544 | * onUnexpectClose. 545 | */ 546 | public function onUnexpectClose(): void 547 | { 548 | $this->emitError(new RuntimeException('The connection to ' . $this->connection->getRemoteIp() . ' has been closed.')); 549 | } 550 | 551 | /** 552 | * @return int 553 | */ 554 | protected function getDefaultPort(): int 555 | { 556 | return ('https' === $this->getUri()->getScheme()) ? 443 : 80; 557 | } 558 | 559 | /** 560 | * detachConnection. 561 | * 562 | * @return void 563 | */ 564 | public function detachConnection(): void 565 | { 566 | $this->cleanConnection(); 567 | // 不是连接池的连接则断开 568 | if ($this->selfConnection) { 569 | $this->connection->close(); 570 | return; 571 | } 572 | $this->writeable = true; 573 | } 574 | 575 | /** 576 | * @return ?AsyncTcpConnection 577 | */ 578 | public function getConnection(): ?AsyncTcpConnection 579 | { 580 | return $this->connection; 581 | } 582 | 583 | /** 584 | * attachConnection. 585 | * 586 | * @param $connection AsyncTcpConnection 587 | * @return $this 588 | */ 589 | public function attachConnection(AsyncTcpConnection $connection): static 590 | { 591 | $connection->onConnect = array($this, 'onConnect'); 592 | $connection->onMessage = array($this, 'onMessage'); 593 | $connection->onError = array($this, 'onError'); 594 | $connection->onClose = array($this, 'onUnexpectClose'); 595 | $this->connection = $connection; 596 | 597 | return $this; 598 | } 599 | 600 | /** 601 | * cleanConnection. 602 | */ 603 | protected function cleanConnection(): void 604 | { 605 | $connection = $this->connection; 606 | $connection->onConnect = $connection->onMessage = $connection->onError = 607 | $connection->onClose = $connection->onBufferFull = $connection->onBufferDrain = null; 608 | $this->connection = null; 609 | $this->emitter->removeAllListeners(); 610 | } 611 | } 612 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | namespace Workerman\Http; 15 | 16 | /** 17 | * Class Response 18 | * @package Workerman\Http 19 | */ 20 | #[\AllowDynamicProperties] 21 | class Response extends \Workerman\Psr7\Response 22 | { 23 | 24 | } 25 | -------------------------------------------------------------------------------- /tests/RequestTest.php: -------------------------------------------------------------------------------- 1 | 'v1', 'k2' => 'v2']; 25 | $http->get('http://127.0.0.1:7171/get?' . http_build_query($data), function ($response) use (&$successCalled, $data) { 26 | $successCalled = true; 27 | $this->assertInstanceOf(Response::class, $response); 28 | $this->assertEquals(200, $response->getStatusCode()); 29 | $this->assertEquals(json_encode($data), $response->getBody()); 30 | }, function ($exception) use (&$errorCalled) { 31 | $errorCalled = true; 32 | }); 33 | for ($i = 0; $i < 10; $i++) { 34 | if ($successCalled || $errorCalled) { 35 | break; 36 | } 37 | Timer::sleep(0.1); 38 | } 39 | $this->assertTrue($successCalled); 40 | $this->assertFalse($errorCalled); 41 | 42 | } 43 | 44 | public function testException() 45 | { 46 | $successCalled = false; 47 | $errorCalled = false; 48 | $http = new Client(); 49 | $http->get('http://127.0.0.1:7171/exception', function ($response) use (&$successCalled) { 50 | $successCalled = true; 51 | }, function ($exception) use (&$errorCalled) { 52 | $errorCalled = true; 53 | }); 54 | for ($i = 0; $i < 10; $i++) { 55 | if ($successCalled || $errorCalled) { 56 | break; 57 | } 58 | Timer::sleep(0.1); 59 | } 60 | $this->assertFalse($successCalled); 61 | $this->assertTrue($errorCalled); 62 | } 63 | 64 | public function testBadAddressException() 65 | { 66 | $successCalled = false; 67 | $errorCalled = false; 68 | $http = new Client(); 69 | $http->get(':bad_address/exception', function ($response) use (&$successCalled) { 70 | $successCalled = true; 71 | }, function ($exception) use (&$errorCalled) { 72 | $errorCalled = true; 73 | }); 74 | for ($i = 0; $i < 10; $i++) { 75 | if ($successCalled || $errorCalled) { 76 | break; 77 | } 78 | Timer::sleep(0.1); 79 | } 80 | $this->assertFalse($successCalled); 81 | $this->assertTrue($errorCalled); 82 | } 83 | 84 | /** 85 | * Test POST request with callbacks 86 | */ 87 | public function testPostRequestWithCallbacks() 88 | { 89 | $successCalled = false; 90 | $errorCalled = false; 91 | $http = new Client(); 92 | 93 | $postData = ['key1' => 'value1', 'key2' => 'value2']; 94 | 95 | $http->post('http://127.0.0.1:7171/post', $postData, function ($response) use (&$successCalled, $postData) { 96 | $this->assertInstanceOf(Response::class, $response); 97 | $this->assertEquals(200, $response->getStatusCode()); 98 | 99 | $body = json_decode($response->getBody(), true); 100 | $this->assertEquals($postData, $body); 101 | $successCalled = true; 102 | 103 | }, function ($exception) use (&$errorCalled) { 104 | $errorCalled = true; 105 | }); 106 | 107 | for ($i = 0; $i < 10; $i++) { 108 | if ($successCalled || $errorCalled) { 109 | break; 110 | } 111 | Timer::sleep(0.1); 112 | } 113 | $this->assertTrue($successCalled); 114 | $this->assertFalse($errorCalled); 115 | } 116 | 117 | /** 118 | * Test custom request with options 119 | */ 120 | public function testCustomRequestWithOptions() 121 | { 122 | $successCalled = false; 123 | $errorCalled = false; 124 | $http = new Client(); 125 | 126 | $options = [ 127 | 'method' => 'POST', 128 | 'version' => '1.1', 129 | 'headers' => ['Connection' => 'keep-alive'], 130 | 'data' => ['key1' => 'value1', 'key2' => 'value2'], 131 | 'success' => function ($response) use (&$successCalled) { 132 | $this->assertInstanceOf(Response::class, $response); 133 | $this->assertEquals(200, $response->getStatusCode()); 134 | $successCalled = true; 135 | }, 136 | 'error' => function ($exception) use (&$errorCalled) { 137 | $errorCalled = true; 138 | } 139 | ]; 140 | 141 | $http->request('http://127.0.0.1:7171/post', $options); 142 | 143 | for ($i = 0; $i < 10; $i++) { 144 | if ($successCalled || $errorCalled) { 145 | break; 146 | } 147 | Timer::sleep(0.1); 148 | } 149 | $this->assertTrue($successCalled); 150 | $this->assertFalse($errorCalled); 151 | } 152 | 153 | /** 154 | * Test file upload 155 | */ 156 | public function testFileUpload() 157 | { 158 | $successCalled = false; 159 | $errorCalled = false; 160 | $http = new Client(); 161 | 162 | $multipart = new MultipartStream([ 163 | [ 164 | 'name' => 'file', 165 | 'contents' => fopen(__FILE__, 'r'), 166 | 'filename' => 'test.php' 167 | ], 168 | [ 169 | 'name' => 'json', 170 | 'contents' => json_encode(['a' => 1, 'b' => 2]) 171 | ] 172 | ]); 173 | 174 | $boundary = $multipart->getBoundary(); 175 | 176 | $options = [ 177 | 'method' => 'POST', 178 | 'version' => '1.1', 179 | 'headers' => [ 180 | 'Connection' => 'keep-alive', 181 | 'Content-Type' => "multipart/form-data; boundary=$boundary", 182 | 'Content-Length' => $multipart->getSize() 183 | ], 184 | 'data' => $multipart, 185 | 'success' => function ($response) use (&$successCalled) { 186 | $this->assertInstanceOf(Response::class, $response); 187 | $this->assertEquals(200, $response->getStatusCode()); 188 | $this->assertEquals(md5_file(__FILE__) . ' {"json":"{\"a\":1,\"b\":2}"}', $response->getBody()); 189 | $successCalled = true; 190 | }, 191 | 'error' => function ($exception) use (&$errorCalled) { 192 | $errorCalled = true; 193 | } 194 | ]; 195 | 196 | $http->request('http://127.0.0.1:7171/upload', $options); 197 | 198 | for ($i = 0; $i < 10; $i++) { 199 | if ($successCalled || $errorCalled) { 200 | break; 201 | } 202 | Timer::sleep(0.1); 203 | } 204 | $this->assertTrue($successCalled); 205 | $this->assertFalse($errorCalled); 206 | 207 | } 208 | 209 | 210 | /** 211 | * Test progress stream handling 212 | */ 213 | public function testProgressStreamHandling() 214 | { 215 | $successCalled = false; 216 | $http = new Client(); 217 | 218 | $options = [ 219 | 'method' => 'GET', 220 | 'progress' => function ($buffer) use (&$progressCalled) { 221 | static $i = 0; 222 | $this->assertEquals((string)$i++, $buffer); 223 | if ($i == 10) { 224 | $progressCalled = true; 225 | } 226 | }, 227 | 'success' => function ($response) use (&$successCalled) { 228 | $this->assertInstanceOf(Response::class, $response); 229 | $this->assertEquals(200, $response->getStatusCode()); 230 | $successCalled = true; 231 | } 232 | ]; 233 | 234 | $http->request('http://127.0.0.1:7171/stream', $options); 235 | 236 | for ($i = 0; $i < 20; $i++) { 237 | if ($successCalled) { 238 | break; 239 | } 240 | Timer::sleep(0.1); 241 | } 242 | $this->assertTrue($successCalled); 243 | } 244 | 245 | /** 246 | * Test synchronous GET request 247 | */ 248 | public function testSynchronousGetRequest() 249 | { 250 | $http = new Client(); 251 | $data = ['k1' => 'v1', 'k2' => 'v2']; 252 | $response = $http->get('http://127.0.0.1:7171/get?' . http_build_query($data)); 253 | 254 | $this->assertInstanceOf(Response::class, $response); 255 | $this->assertEquals(200, $response->getStatusCode()); 256 | $this->assertEquals(json_encode($data), $response->getBody()); 257 | 258 | } 259 | 260 | /** 261 | * Test synchronous POST request 262 | */ 263 | public function testSynchronousPostRequest() 264 | { 265 | $http = new Client(); 266 | 267 | $postData = ['key1' => 'value1', 'key2' => 'value2']; 268 | 269 | $response = $http->post('http://127.0.0.1:7171/post', $postData); 270 | 271 | $this->assertInstanceOf(Response::class, $response); 272 | $this->assertEquals(200, $response->getStatusCode()); 273 | 274 | $body = json_decode($response->getBody(), true); 275 | $this->assertEquals($postData, $body); 276 | } 277 | 278 | /** 279 | * Test synchronous custom request 280 | */ 281 | public function testSynchronousCustomRequest() 282 | { 283 | $http = new Client(); 284 | 285 | $options = [ 286 | 'method' => 'POST', 287 | 'version' => '1.1', 288 | 'headers' => ['Connection' => 'keep-alive'], 289 | 'data' => ['key1' => 'value1', 'key2' => 'value2'], 290 | ]; 291 | 292 | $response = $http->request('http://127.0.0.1:7171/post', $options); 293 | 294 | $this->assertInstanceOf(Response::class, $response); 295 | $this->assertEquals(200, $response->getStatusCode()); 296 | 297 | $body = json_decode($response->getBody(), true); 298 | $this->assertEquals($options['data'], $body); 299 | } 300 | 301 | public function testSynchronousException() 302 | { 303 | $throw = false; 304 | try { 305 | $http = new Client(); 306 | $options = [ 307 | 'method' => 'POST', 308 | 'version' => '1.1', 309 | 'data' => ['key1' => 'value1', 'key2' => 'value2'], 310 | ]; 311 | $http->request('http://127.0.0.1:7171/exception', $options); 312 | } catch (Throwable $e) { 313 | $throw = true; 314 | $this->assertInstanceOf(RuntimeException::class, $e); 315 | } 316 | 317 | $this->assertTrue($throw); 318 | 319 | } 320 | 321 | public function testSynchronousBadAddressException() 322 | { 323 | $throw = false; 324 | try { 325 | $http = new Client(); 326 | $options = [ 327 | 'method' => 'POST', 328 | 'version' => '1.1', 329 | 'data' => ['key1' => 'value1', 'key2' => 'value2'], 330 | ]; 331 | $http->request(':bad_address/exception', $options); 332 | } catch (Throwable $e) { 333 | $throw = true; 334 | $this->assertInstanceOf(RuntimeException::class, $e); 335 | } 336 | 337 | $this->assertTrue($throw); 338 | 339 | } 340 | 341 | } 342 | -------------------------------------------------------------------------------- /tests/start.php: -------------------------------------------------------------------------------- 1 | run([ 18 | __DIR__ . '/../vendor/bin/phpunit', 19 | '--colors=always', 20 | ...glob(__DIR__ . '/*Test.php') 21 | ]); 22 | }, Fiber::class); 23 | } 24 | 25 | 26 | if (extension_loaded('Swoole')) { 27 | create_test_worker(function () { 28 | (new PHPUnit\TextUI\Application)->run([ 29 | __DIR__ . '/../vendor/bin/phpunit', 30 | '--colors=always', 31 | ...glob(__DIR__ . '/*Test.php') 32 | ]); 33 | }, Swoole::class); 34 | } 35 | 36 | if (extension_loaded('Swow')) { 37 | create_test_worker(function () { 38 | (new PHPUnit\TextUI\Application)->run([ 39 | __DIR__ . '/../vendor/bin/phpunit', 40 | '--colors=always', 41 | ...glob(__DIR__ . '/*Test.php') 42 | ]); 43 | }, Swow::class); 44 | } 45 | 46 | function create_test_worker(Closure $callable, $eventLoopClass): void 47 | { 48 | $worker = new Worker(); 49 | $worker->eventLoop = $eventLoopClass; 50 | $worker->onWorkerStart = function () use ($callable, $eventLoopClass) { 51 | $fp = fopen(__FILE__, 'r+'); 52 | flock($fp, LOCK_EX); 53 | echo PHP_EOL . PHP_EOL. PHP_EOL . '[TEST EVENT-LOOP: ' . basename(str_replace('\\', '/', $eventLoopClass)) . ']' . PHP_EOL; 54 | try { 55 | $callable(); 56 | } catch (Throwable $e) { 57 | echo $e; 58 | } finally { 59 | flock($fp, LOCK_UN); 60 | } 61 | Timer::repeat(1, function () use ($fp) { 62 | if (flock($fp, LOCK_EX | LOCK_NB)) { 63 | if(function_exists('posix_kill')) { 64 | posix_kill(posix_getppid(), SIGINT); 65 | } else { 66 | Worker::stopAll(); 67 | } 68 | } 69 | }); 70 | }; 71 | } 72 | 73 | $http = new Worker('http://127.0.0.1:7171'); 74 | 75 | $http->onMessage = function ($connection, Request $request) { 76 | $path = $request->path(); 77 | switch ($path) { 78 | case '/get': 79 | $connection->send(json_encode($request->get())); 80 | return; 81 | case '/post': 82 | $connection->send(json_encode($request->post())); 83 | return; 84 | case '/upload': 85 | $connection->send(md5_file($request->file('file')['tmp_name']) . ' ' . json_encode($request->post())); 86 | return; 87 | case '/stream': 88 | $id = Timer::repeat(0.1, function () use ($connection, &$id) { 89 | static $i = 0; 90 | if ($i === 0) { 91 | // 发送thunk 头 92 | $connection->send(new Response(200, ['Transfer-Encoding' => 'chunked'])); 93 | } 94 | $connection->send(new Chunk($i++)); 95 | if ($i > 10) { 96 | Timer::del($id); 97 | $connection->send(new Chunk('')); 98 | } 99 | }); 100 | return; 101 | case '/exception': 102 | $connection->close(); 103 | return; 104 | default: 105 | $connection->send('Hello World'); 106 | } 107 | }; 108 | 109 | Worker::runAll(); 110 | --------------------------------------------------------------------------------