diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac38a1e..ad08c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,3 +68,78 @@ jobs: - name: Print logs if: failure() run: php .github/print-logs.php + + http3: + name: HTTP/3 (Linux + PHP 8.4) + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential autoconf libtool pkg-config \ + cmake ninja-build libgnutls28-dev libpsl-dev libnghttp2-dev git + + # 发行版自带 libcurl 未编译 QUIC,这里从源码构建带 HTTP/3 的 curl + - name: Build curl with HTTP/3 (ngtcp2 + nghttp3) + run: | + set -e + PREFIX=/usr/local + export PKG_CONFIG_PATH=$PREFIX/lib/pkgconfig:$PKG_CONFIG_PATH + + git clone --depth 1 --branch v1.6.0 https://github.com/ngtcp2/nghttp3.git + cd nghttp3 + git submodule update --init + autoreconf -fi + ./configure --prefix=$PREFIX --enable-lib-only + make -j"$(nproc)" + sudo make install + cd .. + + git clone --depth 1 --branch v1.6.0 https://github.com/ngtcp2/ngtcp2.git + cd ngtcp2 + git submodule update --init + autoreconf -fi + ./configure --prefix=$PREFIX --enable-lib-only --with-gnutls + make -j"$(nproc)" + sudo make install + cd .. + + git clone --depth 1 --branch curl-8_10_1 https://github.com/curl/curl.git + cd curl + autoreconf -fi + ./configure --prefix=$PREFIX --with-gnutls --with-nghttp2 \ + --with-nghttp3 --with-ngtcp2 + make -j"$(nproc)" + sudo make install + cd .. + + echo "$PREFIX/lib" | sudo tee /etc/ld.so.conf.d/zz-local.conf + sudo ldconfig + /usr/local/bin/curl --version | head -n1 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: curl, openssl, mbstring, json + coverage: none + tools: composer:v2 + + # 若环境仍不支持 HTTP/3,直接让 job 失败,避免“假绿” + - name: Verify HTTP/3 support + run: | + php -r 'exit((defined("CURL_VERSION_HTTP3") && (curl_version()["features"] & CURL_VERSION_HTTP3)) ? 0 : 1);' + php -r 'echo "libcurl: " . curl_version()["version"] . PHP_EOL;' + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run HTTP/3 tests + run: ./vendor/bin/phpunit -c ./tests/phpunit.xml --filter Http3Test + + - name: Print server logs (on failure) + if: failure() + run: php .github/print-logs.php diff --git a/README.md b/README.md index e11980a..4652d14 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ YurunHttp,支持智能识别 Curl/Swoole 场景的高性能 Http Client。 -支持链式操作,简单易用。支持并发批量请求、HTTP2、WebSocket 全双工通信协议。 +支持链式操作,简单易用。支持并发批量请求、HTTP2、HTTP3、WebSocket 全双工通信协议。 非常适合用于开发通用 SDK 包,不必再为 Swoole 协程兼容而头疼! @@ -28,6 +28,7 @@ YurunHttp 的目标是做最好用的 PHP HTTP Client 开发包! * SSL 证书(HTTPS) * 并发批量请求 * HTTP2 +* HTTP3(Curl Handler,基于 QUIC) * WebSocket * Curl & Swoole 环境智能兼容 * 连接池 @@ -46,6 +47,8 @@ API 文档:[https://apidoc.gitee.com/yurunsoft/YurunHttp](https://apidoc.gitee > 每个小版本的更新日志请移步到 Release 查看 +v5.1.0 支持 HTTP/3(基于 QUIC,Curl Handler,需 libcurl >= 7.66 且编译 QUIC 支持;Swoole Handler 不支持) + v5.0.1 支持 WebSocket 压缩及指定 Opcode v5.0.0 支持 `psr/http-message` `~2.0` 版本 (PHP >= 7.1) @@ -267,6 +270,23 @@ Curl: `php --ri curl` Swoole: `php --ri swoole` +### Http3 用法 + +> 仅支持 Curl Handler(基于 QUIC),Swoole Handler 会抛出异常。 + +```php +$http = new HttpRequest; +$http->protocolVersion = '3.0'; // 这句是关键 +$response = $http->get('https://www.taobao.com/'); +``` + +使用 HTTP/3 需注意: + +* 必须使用 HTTPS 地址(QUIC over TLS)。 +* 需要 libcurl >= 7.66 且编译了 QUIC 支持(ngtcp2 / quiche),可通过 `php --ri curl` 查看。 +* 当服务器不支持 HTTP/3 时,Curl 会自动降级到 HTTP/2 / HTTP/1.1。 +* Swoole Handler 不支持 HTTP/3,发起 `3.0` 请求会抛出 `Swoole handler does not support HTTP/3` 异常。 + ### Http2 全双工用法 > 该用法仅支持 Swoole diff --git a/composer.json b/composer.json index 1d5a69a..818d557 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "yurunsoft/yurun-http", - "description": "YurunHttp 是开源的 PHP HTTP 类库,支持链式操作,简单易用。支持 Curl、Swoole,支持 Http、Http2、WebSocket!", + "description": "YurunHttp 是开源的 PHP HTTP 类库,支持链式操作,简单易用。支持 Curl、Swoole,支持 Http、Http2、Http3、WebSocket!", "require": { "php": ">=7.1", "psr/http-message": "~1.0|~2.0", diff --git a/phpstan.neon b/phpstan.neon index 1be0aa9..bbb04d4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -26,4 +26,5 @@ parameters: - message: '#Method \S+ has no return typehint specified.#' paths: - - tests/unit/**Test.php \ No newline at end of file + - tests/unit/**Test.php + - '#Constant CURL_HTTP_VERSION_3 not found.#' diff --git a/src/YurunHttp/Handler/Curl.php b/src/YurunHttp/Handler/Curl.php index 9d2c40e..99bc479 100644 --- a/src/YurunHttp/Handler/Curl.php +++ b/src/YurunHttp/Handler/Curl.php @@ -366,6 +366,10 @@ private function buildCurlHandlerEx(&$request, $handler, $uri = null, $method = $httpVersion = \CURL_HTTP_VERSION_2; } break; + case '3.0': + // HTTP/3 基于 QUIC,需 libcurl >= 7.66 且编译了 QUIC 支持 + $httpVersion = \defined('\CURL_HTTP_VERSION_3') ? CURL_HTTP_VERSION_3 : 30; + break; default: $httpVersion = \CURL_HTTP_VERSION_1_1; break; @@ -419,6 +423,10 @@ private function getResponse($request, $handler, $body, $receiveHeaders) // body $result = new Response($body, curl_getinfo($handler, \CURLINFO_HTTP_CODE)); + // 实际使用的 HTTP 协议版本(CURLINFO_HTTP_VERSION 自 PHP 7.3 引入) + $curlHttpVersion = \defined('\CURLINFO_HTTP_VERSION') ? curl_getinfo($handler, \CURLINFO_HTTP_VERSION) : 0; + $httpVersion = $this->getCurlHttpVersion($curlHttpVersion); + // headers $headers = $this->parseHeaderOneRequest($receiveHeaders); foreach ($headers as $name => $value) @@ -443,10 +451,35 @@ private function getResponse($request, $handler, $body, $receiveHeaders) return $result->withRequest($request) ->withCookieOriginParams($cookies) + ->withHttpVersion($httpVersion) ->withError(curl_error($handler)) ->withErrno(curl_errno($handler)); } + /** + * 将 curl 的 CURLINFO_HTTP_VERSION 枚举值转换为协议版本字符串. + * + * @param int $version + * + * @return string + */ + private function getCurlHttpVersion($version) + { + switch ($version) + { + case \defined('\CURL_HTTP_VERSION_1_0') ? \CURL_HTTP_VERSION_1_0 : 1: + return '1.0'; + case \defined('\CURL_HTTP_VERSION_1_1') ? \CURL_HTTP_VERSION_1_1 : 2: + return '1.1'; + case \defined('\CURL_HTTP_VERSION_2') ? \CURL_HTTP_VERSION_2 : 3: + return '2.0'; + case \defined('\CURL_HTTP_VERSION_3') ? CURL_HTTP_VERSION_3 : 30: + return '3.0'; + default: + return ''; + } + } + /** * parseHeaderOneRequest. * @@ -615,7 +648,7 @@ private function parseHeaders(&$request, &$options) { $request = $request->withHeader('User-Agent', $request->getAttribute(Attributes::USER_AGENT, self::$defaultUA)); } - if (!$request->hasHeader('Connection') && $request->getProtocolVersion() >= 1.1) + if (!$request->hasHeader('Connection') && $request->getProtocolVersion() >= 1.1 && '3.0' !== $request->getProtocolVersion()) { $request = $request->withHeader('Connection', 'Keep-Alive')->withHeader('Keep-Alive', '300'); } diff --git a/src/YurunHttp/Handler/Swoole.php b/src/YurunHttp/Handler/Swoole.php index ec68b01..3a32206 100644 --- a/src/YurunHttp/Handler/Swoole.php +++ b/src/YurunHttp/Handler/Swoole.php @@ -232,6 +232,10 @@ public function send(&$request) */ public function sendDefer($request) { + if ('3.0' === $request->getProtocolVersion()) + { + throw new \RuntimeException('Swoole handler does not support HTTP/3'); + } $isHttp2 = '2.0' === $request->getProtocolVersion(); if ($poolIsEnabled = $this->poolIsEnabled) { diff --git a/src/YurunHttp/Http/Response.php b/src/YurunHttp/Http/Response.php index f2b6835..7d294f7 100644 --- a/src/YurunHttp/Http/Response.php +++ b/src/YurunHttp/Http/Response.php @@ -56,6 +56,13 @@ class Response extends Psr7Response */ protected $streamId; + /** + * 实际使用的 HTTP 协议版本,如 '1.1'、'2.0'、'3.0'. + * + * @var string + */ + protected $httpVersion = ''; + /** * Request. * @@ -374,6 +381,31 @@ public function getStreamId() return $this->streamId; } + /** + * 设置实际使用的 HTTP 协议版本. + * + * @param string $httpVersion + * + * @return static + */ + public function withHttpVersion($httpVersion) + { + $self = clone $this; + $self->httpVersion = $httpVersion; + + return $self; + } + + /** + * 获取实际使用的 HTTP 协议版本,如 '1.1'、'2.0'、'3.0'. + * + * @return string + */ + public function getHttpVersion() + { + return $this->httpVersion; + } + /** * 设置请求体. * diff --git a/tests/unit/Http3/Http3Test.php b/tests/unit/Http3/Http3Test.php new file mode 100644 index 0000000..477b347 --- /dev/null +++ b/tests/unit/Http3/Http3Test.php @@ -0,0 +1,58 @@ +markTestSkipped('CURL_HTTP_VERSION_3 未定义,当前 PHP/libcurl 版本不支持 HTTP/3'); + } + + $curlVersion = curl_version(); + // HTTP/3 需要 libcurl >= 7.66.0(0x074200) + if ($curlVersion['version_number'] < 0x074200) + { + $this->markTestSkipped(sprintf('libcurl %s < 7.66.0,不支持 HTTP/3', $curlVersion['version'])); + } + + // 检测 libcurl 是否编译了 HTTP/3(QUIC) 支持 + if (\defined('\CURL_VERSION_HTTP3') && !($curlVersion['features'] & \CURL_VERSION_HTTP3)) + { + $this->markTestSkipped('当前 libcurl 未编译 HTTP/3(QUIC) 支持'); + } + + // 强制使用 Curl Handler(Swoole 不支持 HTTP/3) + YurunHttp::setDefaultHandler(Curl::class); + + $http = new HttpRequest(); + $http->protocolVersion = '3.0'; + $http->timeout = 10000; + $http->connectTimeout = 10000; + + $response = $http->get('https://www.taobao.com/'); + + // 环境/网络不支持时跳过,而不是判定失败 + if (0 !== $response->errno()) + { + $this->markTestSkipped(sprintf('HTTP/3 请求失败(环境/网络原因): %s', $response->error())); + } + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertNotEmpty($response->getBody()); + + // 校验响应确实是 HTTP/3(QUIC) + $this->assertEquals('3.0', $response->getHttpVersion(), sprintf( + '响应协议版本不是 HTTP/3,实际为: %s', + $response->getHttpVersion() + )); + } +}