feat(http2): HTTP/2 静态文件服务支持 gzip/brotli 压缩
- http2.c: 文件内容读取到内存后按需压缩(优先 brotli,回退 gzip) - 响应头自动添加 content-encoding: br 或 gzip - 不可压缩类型(图片等)跳过压缩 - 修复 curl 测试:添加 --compressed 自动解压 - 新增 4 项 HTTP/2 压缩集成测试(HTML/CSS/JS brotli、图片不压缩) - 49 项集成测试全部通过,35 个单元测试全部通过
This commit is contained in:
parent
2436218d3e
commit
e9d7af80b8
156
http2.c
156
http2.c
@ -19,10 +19,63 @@
|
|||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#include <zlib.h>
|
||||||
|
#include <brotli/encode.h>
|
||||||
|
|
||||||
/* 最大 HTTP/2 会话数 */
|
/* 最大 HTTP/2 会话数 */
|
||||||
#define MAX_HTTP2_SESSIONS 1024
|
#define MAX_HTTP2_SESSIONS 1024
|
||||||
|
|
||||||
|
/* 压缩辅助函数(与 static.c 独立,保持模块边界) */
|
||||||
|
static bool is_compressible_mime(const char *mime_type) {
|
||||||
|
if (!mime_type) return false;
|
||||||
|
return (
|
||||||
|
strstr(mime_type, "text/") != NULL ||
|
||||||
|
strstr(mime_type, "application/javascript") != NULL ||
|
||||||
|
strstr(mime_type, "application/json") != NULL ||
|
||||||
|
strstr(mime_type, "application/xml") != NULL ||
|
||||||
|
strstr(mime_type, "application/manifest") != NULL ||
|
||||||
|
strstr(mime_type, "image/svg") != NULL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ssize_t gzip_compress(const char *src, size_t src_len,
|
||||||
|
char *dst, size_t dst_cap) {
|
||||||
|
z_stream strm = {0};
|
||||||
|
if (deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED,
|
||||||
|
15 + 16, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
strm.avail_in = (uInt)src_len;
|
||||||
|
strm.next_in = (Bytef *)src;
|
||||||
|
strm.avail_out = (uInt)dst_cap;
|
||||||
|
strm.next_out = (Bytef *)dst;
|
||||||
|
if (deflate(&strm, Z_FINISH) != Z_STREAM_END) {
|
||||||
|
deflateEnd(&strm);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
size_t compressed_len = dst_cap - strm.avail_out;
|
||||||
|
deflateEnd(&strm);
|
||||||
|
if (compressed_len >= src_len * 0.95) return 0;
|
||||||
|
return (ssize_t)compressed_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ssize_t brotli_compress(const char *src, size_t src_len,
|
||||||
|
char *dst, size_t dst_cap) {
|
||||||
|
size_t encoded_size = dst_cap;
|
||||||
|
BROTLI_BOOL ok = BrotliEncoderCompress(
|
||||||
|
BROTLI_DEFAULT_QUALITY,
|
||||||
|
BROTLI_DEFAULT_WINDOW,
|
||||||
|
BROTLI_MODE_GENERIC,
|
||||||
|
src_len,
|
||||||
|
(const uint8_t *)src,
|
||||||
|
&encoded_size,
|
||||||
|
(uint8_t *)dst
|
||||||
|
);
|
||||||
|
if (!ok) return -1;
|
||||||
|
if (encoded_size >= src_len * 0.95) return 0;
|
||||||
|
return (ssize_t)encoded_size;
|
||||||
|
}
|
||||||
|
|
||||||
static http2_session_t *g_sessions[MAX_HTTP2_SESSIONS];
|
static http2_session_t *g_sessions[MAX_HTTP2_SESSIONS];
|
||||||
static int g_session_count = 0;
|
static int g_session_count = 0;
|
||||||
|
|
||||||
@ -463,19 +516,26 @@ static ssize_t http2_data_source_read_callback(
|
|||||||
nghttp2_data_source *source,
|
nghttp2_data_source *source,
|
||||||
void *user_data __attribute__((unused))) {
|
void *user_data __attribute__((unused))) {
|
||||||
|
|
||||||
if (source->fd < 0) {
|
http2_stream_data_t *stream = (http2_stream_data_t *)source->ptr;
|
||||||
|
if (!stream || !stream->response_body) {
|
||||||
*data_flags |= NGHTTP2_DATA_FLAG_EOF;
|
*data_flags |= NGHTTP2_DATA_FLAG_EOF;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ssize_t n = read(source->fd, buf, length);
|
size_t remaining = stream->response_len - stream->response_sent;
|
||||||
if (n < 0) {
|
if (remaining == 0) {
|
||||||
return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
|
*data_flags |= NGHTTP2_DATA_FLAG_EOF;
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
if (n == 0) {
|
|
||||||
|
size_t to_send = length < remaining ? length : remaining;
|
||||||
|
memcpy(buf, stream->response_body + stream->response_sent, to_send);
|
||||||
|
stream->response_sent += to_send;
|
||||||
|
|
||||||
|
if (stream->response_sent >= stream->response_len) {
|
||||||
*data_flags |= NGHTTP2_DATA_FLAG_EOF;
|
*data_flags |= NGHTTP2_DATA_FLAG_EOF;
|
||||||
}
|
}
|
||||||
return (ssize_t)n;
|
return (ssize_t)to_send;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream) {
|
static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream) {
|
||||||
@ -593,6 +653,72 @@ static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 读取文件内容到内存 */
|
||||||
|
int64_t file_size = st.st_size;
|
||||||
|
char *file_buf = (char *)malloc((size_t)file_size);
|
||||||
|
if (!file_buf) {
|
||||||
|
close(file_fd);
|
||||||
|
nghttp2_nv hdrs[] = {
|
||||||
|
{(uint8_t *)":status", (uint8_t *)"500", 7, 3, 0}};
|
||||||
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssize_t read_total = 0;
|
||||||
|
while (read_total < file_size) {
|
||||||
|
ssize_t n = read(file_fd, file_buf + read_total, (size_t)(file_size - read_total));
|
||||||
|
if (n <= 0) break;
|
||||||
|
read_total += n;
|
||||||
|
}
|
||||||
|
close(file_fd);
|
||||||
|
|
||||||
|
if (read_total != file_size) {
|
||||||
|
free(file_buf);
|
||||||
|
nghttp2_nv hdrs[] = {
|
||||||
|
{(uint8_t *)":status", (uint8_t *)"500", 7, 3, 0}};
|
||||||
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 判断是否需要压缩 */
|
||||||
|
bool use_gzip = false;
|
||||||
|
bool use_brotli = false;
|
||||||
|
const char *mime = http_mime_type(real_path);
|
||||||
|
|
||||||
|
if (stream->request.method != HTTP_HEAD && is_compressible_mime(mime) && file_size > 256) {
|
||||||
|
char *compress_buf = (char *)malloc((size_t)file_size);
|
||||||
|
if (compress_buf) {
|
||||||
|
if (h2->brotli_enabled) {
|
||||||
|
ssize_t cl = brotli_compress(file_buf, (size_t)file_size, compress_buf, (size_t)file_size);
|
||||||
|
if (cl > 0) {
|
||||||
|
free(file_buf);
|
||||||
|
file_buf = compress_buf;
|
||||||
|
file_size = cl;
|
||||||
|
use_brotli = true;
|
||||||
|
} else {
|
||||||
|
free(compress_buf);
|
||||||
|
}
|
||||||
|
} else if (h2->gzip_enabled) {
|
||||||
|
ssize_t cl = gzip_compress(file_buf, (size_t)file_size, compress_buf, (size_t)file_size);
|
||||||
|
if (cl > 0) {
|
||||||
|
free(file_buf);
|
||||||
|
file_buf = compress_buf;
|
||||||
|
file_size = cl;
|
||||||
|
use_gzip = true;
|
||||||
|
} else {
|
||||||
|
free(compress_buf);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
free(compress_buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 存储到流 */
|
||||||
|
stream->response_body = file_buf;
|
||||||
|
stream->response_len = (size_t)file_size;
|
||||||
|
stream->response_sent = 0;
|
||||||
|
|
||||||
/* 构建响应头 */
|
/* 构建响应头 */
|
||||||
nghttp2_nv hdrs[16];
|
nghttp2_nv hdrs[16];
|
||||||
int num_hdrs = 0;
|
int num_hdrs = 0;
|
||||||
@ -600,15 +726,22 @@ static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream)
|
|||||||
hdrs[num_hdrs++] = (nghttp2_nv){
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
(uint8_t *)":status", (uint8_t *)"200", 7, 3, 0};
|
(uint8_t *)":status", (uint8_t *)"200", 7, 3, 0};
|
||||||
|
|
||||||
const char *mime = http_mime_type(real_path);
|
|
||||||
hdrs[num_hdrs++] = (nghttp2_nv){
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
(uint8_t *)"content-type", (uint8_t *)mime, 12, strlen(mime), 0};
|
(uint8_t *)"content-type", (uint8_t *)mime, 12, strlen(mime), 0};
|
||||||
|
|
||||||
char content_length_str[32];
|
char content_length_str[32];
|
||||||
snprintf(content_length_str, sizeof(content_length_str), "%ld", (long)st.st_size);
|
snprintf(content_length_str, sizeof(content_length_str), "%ld", (long)file_size);
|
||||||
hdrs[num_hdrs++] = (nghttp2_nv){
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
(uint8_t *)"content-length", (uint8_t *)content_length_str, 14, strlen(content_length_str), 0};
|
(uint8_t *)"content-length", (uint8_t *)content_length_str, 14, strlen(content_length_str), 0};
|
||||||
|
|
||||||
|
if (use_brotli) {
|
||||||
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
|
(uint8_t *)"content-encoding", (uint8_t *)"br", 16, 2, 0};
|
||||||
|
} else if (use_gzip) {
|
||||||
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
|
(uint8_t *)"content-encoding", (uint8_t *)"gzip", 16, 4, 0};
|
||||||
|
}
|
||||||
|
|
||||||
hdrs[num_hdrs++] = (nghttp2_nv){
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
(uint8_t *)"etag", (uint8_t *)etag, 4, strlen(etag), 0};
|
(uint8_t *)"etag", (uint8_t *)etag, 4, strlen(etag), 0};
|
||||||
|
|
||||||
@ -620,14 +753,15 @@ static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream)
|
|||||||
|
|
||||||
/* HEAD 请求:不发送 body */
|
/* HEAD 请求:不发送 body */
|
||||||
if (stream->request.method == HTTP_HEAD) {
|
if (stream->request.method == HTTP_HEAD) {
|
||||||
close(file_fd);
|
free(stream->response_body);
|
||||||
|
stream->response_body = NULL;
|
||||||
|
stream->response_len = 0;
|
||||||
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, NULL);
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, NULL);
|
||||||
} else {
|
} else {
|
||||||
nghttp2_data_provider provider;
|
nghttp2_data_provider provider;
|
||||||
provider.source.fd = file_fd;
|
provider.source.ptr = stream;
|
||||||
provider.read_callback = http2_data_source_read_callback;
|
provider.read_callback = http2_data_source_read_callback;
|
||||||
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, &provider);
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, &provider);
|
||||||
stream->file_fd = file_fd;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -147,7 +147,7 @@ assert_body_contains() {
|
|||||||
local expect="$2"
|
local expect="$2"
|
||||||
local desc="${3:-$url}"
|
local desc="${3:-$url}"
|
||||||
local body
|
local body
|
||||||
body=$(curl -s -k "$url")
|
body=$(curl -s -k --compressed "$url")
|
||||||
if echo "$body" | grep -q "$expect"; then
|
if echo "$body" | grep -q "$expect"; then
|
||||||
echo " ✓ $desc — 响应体包含 '$expect'"
|
echo " ✓ $desc — 响应体包含 '$expect'"
|
||||||
pass
|
pass
|
||||||
@ -316,6 +316,48 @@ assert_post_form() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert_http2_brotli() {
|
||||||
|
local url="$1"
|
||||||
|
local desc="${2:-$url}"
|
||||||
|
local encoding
|
||||||
|
encoding=$(curl --http2 -k -s -D - -o /dev/null "$url" | grep -i "Content-Encoding:" | tr -d '\r' || true)
|
||||||
|
if echo "$encoding" | grep -qi "br"; then
|
||||||
|
echo " ✓ $desc — HTTP/2 返回 brotli 压缩"
|
||||||
|
pass
|
||||||
|
else
|
||||||
|
echo " ✗ $desc — HTTP/2 期望 brotli 压缩, 实际: $encoding"
|
||||||
|
fail
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_http2_gzip() {
|
||||||
|
local url="$1"
|
||||||
|
local desc="${2:-$url}"
|
||||||
|
local encoding
|
||||||
|
encoding=$(curl --http2 -k -s -D - -o /dev/null "$url" | grep -i "Content-Encoding:" | tr -d '\r' || true)
|
||||||
|
if echo "$encoding" | grep -qi "gzip"; then
|
||||||
|
echo " ✓ $desc — HTTP/2 返回 gzip 压缩"
|
||||||
|
pass
|
||||||
|
else
|
||||||
|
echo " ✗ $desc — HTTP/2 期望 gzip 压缩, 实际: $encoding"
|
||||||
|
fail
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_http2_not_compressed() {
|
||||||
|
local url="$1"
|
||||||
|
local desc="${2:-$url}"
|
||||||
|
local encoding
|
||||||
|
encoding=$(curl --http2 -k -s -D - -o /dev/null "$url" | grep -i "Content-Encoding:" | tr -d '\r' || true)
|
||||||
|
if [[ -z "$encoding" ]]; then
|
||||||
|
echo " ✓ $desc — HTTP/2 无压缩"
|
||||||
|
pass
|
||||||
|
else
|
||||||
|
echo " ✗ $desc — HTTP/2 期望无压缩, 实际: $encoding"
|
||||||
|
fail
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
assert_status_405() {
|
assert_status_405() {
|
||||||
local url="$1"
|
local url="$1"
|
||||||
local desc="${2:-$url}"
|
local desc="${2:-$url}"
|
||||||
@ -484,7 +526,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# HTTP/2 响应体
|
# HTTP/2 响应体
|
||||||
h2_body=$(curl --http2 -k -s "https://$HOST/")
|
h2_body=$(curl --http2 -k -s --compressed "https://$HOST/")
|
||||||
if echo "$h2_body" | grep -q "Cocoon"; then
|
if echo "$h2_body" | grep -q "Cocoon"; then
|
||||||
echo " ✓ HTTP/2 响应体 — 包含 'Cocoon'"
|
echo " ✓ HTTP/2 响应体 — 包含 'Cocoon'"
|
||||||
pass
|
pass
|
||||||
@ -526,6 +568,12 @@ if [[ -n "$H2_ETAG" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# HTTP/2 压缩测试
|
||||||
|
assert_http2_brotli "https://$HOST/index.html" "HTTP/2 HTML brotli"
|
||||||
|
assert_http2_brotli "https://$HOST/style.css" "HTTP/2 CSS brotli"
|
||||||
|
assert_http2_brotli "https://$HOST/app.js" "HTTP/2 JS brotli"
|
||||||
|
assert_http2_not_compressed "https://$HOST/image.png" "HTTP/2 图片不压缩"
|
||||||
|
|
||||||
# 停止 HTTP/2 服务器,恢复 HTTP 服务器
|
# 停止 HTTP/2 服务器,恢复 HTTP 服务器
|
||||||
kill_server
|
kill_server
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user