diff --git a/.gitea/workflows/bazel.yml b/.gitea/workflows/bazel.yml index 016fe82..e4dbafc 100644 --- a/.gitea/workflows/bazel.yml +++ b/.gitea/workflows/bazel.yml @@ -56,8 +56,45 @@ jobs: build --@boost.asio//:ssl=boringssl - name: Bazel Test + id: test + shell: bash run: | - bazel test framework/... + set -o pipefail + bazel test //framework/... --test_output=errors --test_verbose_timeout_warnings 2>&1 | tee ci-test.log + - name: HTTP concurrency benchmark smoke test + id: concurrency_benchmark + shell: bash + run: | + set -o pipefail + bazel run //framework/tests:concurrency_benchmark -- \ + --server-threads 2 --concurrency 8 --requests 80 --warmup 8 \ + 2>&1 | tee ci-concurrency-benchmark.log + - name: Publish test and benchmark summary + if: always() + shell: bash + env: + TEST_RESULT: ${{ steps.test.outcome }} + BENCHMARK_RESULT: ${{ steps.concurrency_benchmark.outcome }} + run: | + summary_file="${GITHUB_STEP_SUMMARY:-}" + if [ -n "$summary_file" ]; then + { + echo "## khttpd framework tests (${{ matrix.os }})" + echo + echo "| Stage | Result |" + echo "| --- | --- |" + echo "| Test | ${TEST_RESULT} |" + echo "| Benchmark | ${BENCHMARK_RESULT} |" + echo + echo '```text' + sed -n '/khttpd HTTP 并发基准测试/,$p' ci-concurrency-benchmark.log 2>/dev/null || true + echo '```' + } >> "$summary_file" + else + echo "khttpd test: ${TEST_RESULT}" + echo "khttpd concurrency benchmark: ${BENCHMARK_RESULT}" + sed -n '/khttpd HTTP 并发基准测试/,$p' ci-concurrency-benchmark.log 2>/dev/null || true + fi example: needs: build strategy: diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index a3664ab..6578988 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -65,6 +65,14 @@ jobs: run: | set -o pipefail bazel test //framework/... --test_output=errors --test_verbose_timeout_warnings --verbose_failures 2>&1 | tee ci-test.log + - name: HTTP concurrency benchmark smoke test + id: concurrency_benchmark + shell: bash + run: | + set -o pipefail + bazel run //framework/tests:concurrency_benchmark -- \ + --server-threads 2 --concurrency 8 --requests 80 --warmup 8 \ + 2>&1 | tee ci-concurrency-benchmark.log - name: Upload failed test logs if: failure() uses: actions/upload-artifact@v4 @@ -73,6 +81,7 @@ jobs: path: | bazel-testlogs/** ci-test.log + ci-concurrency-benchmark.log if-no-files-found: ignore retention-days: 3 - name: Publish test summary @@ -93,6 +102,20 @@ jobs: echo "- Ref: \`${GITHUB_REF}\`" echo "- Commit: \`${GITHUB_SHA}\`" } >> "${GITHUB_STEP_SUMMARY}" + { + echo + echo "### HTTP concurrency benchmark smoke test" + echo + echo "| Stage | Result |" + echo "| --- | --- |" + echo "| Benchmark | ${{ steps.concurrency_benchmark.outcome }} |" + if [ -f ci-concurrency-benchmark.log ]; then + echo + echo '```text' + sed -n '/khttpd HTTP/,$p' ci-concurrency-benchmark.log + echo '```' + fi + } >> "${GITHUB_STEP_SUMMARY}" FAILED_LOG_ARTIFACT=khttpd-${{ matrix.os }}-test-diagnostics \ .github/scripts/write_failure_summary.sh example: diff --git a/MODULE.bazel b/MODULE.bazel index 3a459ee..40339aa 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.4.6", + version = "0.5.0", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/README.md b/README.md index c6006ad..4c3d3d6 100644 --- a/README.md +++ b/README.md @@ -629,6 +629,17 @@ router.map_exception([](const ValidationError& e) { `HttpException` is available when an exception should carry an HTTP status, JSON body, and validated headers directly. Unmapped exceptions return a generic JSON 500 response; exception details are logged server-side but are not sent to clients. +### HTTP Concurrency Benchmark + +The framework includes a local benchmark that starts a real `Server`, sends concurrent HTTP requests, and reports success/failure counts, throughput, average latency, and p50/p95/p99 latency: + +```bash +bazel run //framework/tests:concurrency_benchmark -- \ + --server-threads 4 --concurrency 32 --requests 2000 --warmup 100 +``` + +The defaults are intentionally bounded for development and CI. Adjust `--concurrency`, `--requests`, and `--server-threads` for a capacity test on the target machine. The result is a measurement of this workload, not a fixed framework limit; CPU, memory, file-descriptor limits, kernel backlog, payload size, and connection reuse all affect the supported concurrency. + ## License MIT License — see [LICENSE](LICENSE) for details. diff --git a/example/MODULE.bazel b/example/MODULE.bazel index 96f4c29..1250be8 100644 --- a/example/MODULE.bazel +++ b/example/MODULE.bazel @@ -8,7 +8,7 @@ bazel_dep(name = "boost", version = "1.90.0.bcr.1") bazel_dep(name = "boost.asio", version = "1.90.0.bcr.1") bazel_dep(name = "boost.mysql", version = "1.90.0.bcr.1") bazel_dep(name = "spdlog", version = "1.17.0") -bazel_dep(name = "khttpd", version = "0.4.6") +bazel_dep(name = "khttpd", version = "0.5.0") local_path_override( module_name = "khttpd", path = "..", diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 8e2d1b0..0d996d0 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -60,6 +60,20 @@ namespace khttpd::framework HttpRouter::HttpRouter() = default; + void HttpRouter::set_base_path(std::string path) + { + if (!path.empty() && path.front() != '/') throw std::invalid_argument("router base path must start with '/'"); + while (path.size() > 1 && path.back() == '/') path.pop_back(); + base_path_ = path == "/" ? std::string{} : std::move(path); + } + + std::string HttpRouter::apply_base_path(const std::string& path) const + { + if (base_path_.empty()) return path; + if (path.empty() || path.front() != '/') throw std::invalid_argument("route path must start with '/'"); + return base_path_ + (path == "/" ? std::string{} : path); + } + std::tuple, int, int> HttpRouter::parse_path_pattern( const std::string& path_pattern) { @@ -157,30 +171,31 @@ namespace khttpd::framework RouteDocumentation documentation, std::vector parameters) { + const auto registered_path = apply_base_path(path_pattern); if (documented) - record_route_descriptor(path_pattern, method, std::move(request_schema), std::move(response_schema), + record_route_descriptor(registered_path, method, std::move(request_schema), std::move(response_schema), std::move(documentation), std::move(parameters)); else route_descriptors_.erase(std::remove_if(route_descriptors_.begin(), route_descriptors_.end(), [&](const RouteDescriptor& descriptor) { - return descriptor.path == path_pattern && descriptor.method == method; + return descriptor.path == registered_path && descriptor.method == method; }), route_descriptors_.end()); for (auto& entry : routes_) { - if (entry.original_path == path_pattern) + if (entry.original_path == registered_path) { entry.handlers[method] = std::move(handler); spdlog::debug("Updated handler for route: {} {}", std::string(boost::beast::http::to_string(method)), - path_pattern); + registered_path); return; } } RouteEntry new_entry; - new_entry.original_path = path_pattern; - auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(path_pattern); + new_entry.original_path = registered_path; + auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(registered_path); new_entry.path_regex = std::move(regex); new_entry.param_names = std::move(params); new_entry.literal_segments_count = literal_count; @@ -190,7 +205,7 @@ namespace khttpd::framework routes_.push_back(std::move(new_entry)); std::sort(routes_.begin(), routes_.end(), RouteEntry::compare_specificity); spdlog::debug("Registered dynamic route: {} {} (literal:{}, dynamic:{})", - std::string(boost::beast::http::to_string(method)), path_pattern, literal_count, dynamic_count); + std::string(boost::beast::http::to_string(method)), registered_path, literal_count, dynamic_count); } void HttpRouter::add_typed_route(const std::string& path_pattern, @@ -327,18 +342,19 @@ namespace khttpd::framework void HttpRouter::stream(const std::string& path_pattern, const boost::beast::http::verb method, HttpStreamHandler handler) { - record_route_descriptor(path_pattern, method); + const auto registered_path = apply_base_path(path_pattern); + record_route_descriptor(registered_path, method); for (auto& entry : routes_) { - if (entry.original_path == path_pattern) + if (entry.original_path == registered_path) { entry.stream_handlers[method] = std::move(handler); return; } } RouteEntry entry; - entry.original_path = path_pattern; - auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(path_pattern); + entry.original_path = registered_path; + auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(registered_path); entry.path_regex = std::move(regex); entry.param_names = std::move(params); entry.literal_segments_count = literal_count; @@ -437,18 +453,19 @@ namespace khttpd::framework void HttpRouter::async_route(const std::string& path, boost::beast::http::verb method, HttpAsyncHandler handler) { - record_route_descriptor(path, method); - auto [path_regex, param_names, literal_count, dynamic_count] = parse_path_pattern(path); + const auto registered_path = apply_base_path(path); + record_route_descriptor(registered_path, method); + auto [path_regex, param_names, literal_count, dynamic_count] = parse_path_pattern(registered_path); for (auto& entry : routes_) { - if (entry.original_path == path) + if (entry.original_path == registered_path) { entry.async_handlers[method] = std::move(handler); return; } } RouteEntry entry; - entry.original_path = path; + entry.original_path = registered_path; entry.path_regex = std::move(path_regex); entry.param_names = std::move(param_names); entry.literal_segments_count = literal_count; diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index d1e8ffd..df01783 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -91,6 +91,9 @@ namespace khttpd::framework public: HttpRouter(); + void set_base_path(std::string path); + const std::string& base_path() const { return base_path_; } + void get(const std::string& path, HttpHandler handler); void post(const std::string& path, HttpHandler handler); void put(const std::string& path, HttpHandler handler); @@ -542,6 +545,7 @@ namespace khttpd::framework std::vector routes_; std::vector route_descriptors_; + std::string base_path_; std::vector> interceptors_; std::vector> exception_handlers_; @@ -555,6 +559,7 @@ namespace khttpd::framework bool documented = true, RouteDocumentation documentation = {}, std::vector parameters = {}); + std::string apply_base_path(const std::string& path) const; void add_typed_route(const std::string& path_pattern, boost::beast::http::verb method, detail::TypedRouteHandler handler, RouteDocumentation documentation = {}); void record_route_descriptor(const std::string& path, boost::beast::http::verb method, diff --git a/framework/router/websocket_router.cpp b/framework/router/websocket_router.cpp index eacce20..d2b93f7 100644 --- a/framework/router/websocket_router.cpp +++ b/framework/router/websocket_router.cpp @@ -1,6 +1,7 @@ #include "websocket_router.hpp" #include +#include #include #include "websocket/websocket_session.hpp" @@ -18,18 +19,26 @@ namespace khttpd::framework WebsocketRouter::WebsocketRouter() = default; + void WebsocketRouter::set_base_path(std::string path) + { + if (!path.empty() && path.front() != '/') throw std::invalid_argument("router base path must start with '/'"); + while (path.size() > 1 && path.back() == '/') path.pop_back(); + base_path_ = path == "/" ? std::string{} : std::move(path); + } + void WebsocketRouter::add_handler(const std::string& path, WebsocketOpenHandler on_open, WebsocketMessageHandler on_message, WebsocketCloseHandler on_close, WebsocketErrorHandler on_error) { + const auto registered_path = base_path_.empty() ? path : base_path_ + path; WebsocketRouteEntry entry{std::move(on_open), std::move(on_message), std::move(on_close), std::move(on_error)}; std::unique_lock lock(handlers_mutex_); for (auto& route : handlers_) - if (route.original_path == path) { route.handlers = std::move(entry); return; } - auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(path); - handlers_.push_back({path, std::move(regex), std::move(params), literal_count, dynamic_count, std::move(entry)}); + if (route.original_path == registered_path) { route.handlers = std::move(entry); return; } + auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(registered_path); + handlers_.push_back({registered_path, std::move(regex), std::move(params), literal_count, dynamic_count, std::move(entry)}); std::sort(handlers_.begin(), handlers_.end(), WebsocketRoute::compare_specificity); - spdlog::debug("Registered WebSocket handlers for path: {}", path); + spdlog::debug("Registered WebSocket handlers for path: {}", registered_path); } void WebsocketRouter::dispatch_open(const std::string& path, WebsocketContext& ctx) diff --git a/framework/router/websocket_router.hpp b/framework/router/websocket_router.hpp index cf4e440..38e0672 100644 --- a/framework/router/websocket_router.hpp +++ b/framework/router/websocket_router.hpp @@ -54,6 +54,9 @@ namespace khttpd::framework public: WebsocketRouter(); + void set_base_path(std::string path); + const std::string& base_path() const { return base_path_; } + void add_handler(const std::string& path, WebsocketOpenHandler on_open = {nullptr}, WebsocketMessageHandler on_message = {nullptr}, @@ -70,6 +73,7 @@ namespace khttpd::framework void dispatch(const std::string& path, WebsocketContext& ctx, const std::function& invoke); std::vector handlers_; + std::string base_path_; mutable std::shared_mutex handlers_mutex_; }; } diff --git a/framework/tests/BUILD.bazel b/framework/tests/BUILD.bazel index 001d1fd..c4a3eff 100644 --- a/framework/tests/BUILD.bazel +++ b/framework/tests/BUILD.bazel @@ -1,4 +1,4 @@ -load("@rules_cc//cc:defs.bzl", "cc_test") +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") cc_test( name = "sse_test", @@ -11,6 +11,28 @@ cc_test( ], ) +cc_binary( + name = "concurrency_benchmark", + srcs = [ + "concurrency_benchmark.cpp", + "concurrency_benchmark.hpp", + ], + copts = select({ + "@platforms//os:windows": [ + "/std:c++17", + "/wd4865", + "/wd5026", + "/wd5039", + ], + "//conditions:default": [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + }), + deps = ["//framework"], +) + cc_test( name = "context_test", srcs = ["context_test.cpp"], @@ -266,6 +288,23 @@ cc_test( ], ) +cc_test( + name = "concurrency_benchmark_test", + srcs = [ + "concurrency_benchmark_test.cpp", + "concurrency_benchmark.hpp", + ], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) + cc_test( name = "typed_route_test", srcs = ["typed_route_test.cpp"], diff --git a/framework/tests/concurrency_benchmark.cpp b/framework/tests/concurrency_benchmark.cpp new file mode 100644 index 0000000..64681ee --- /dev/null +++ b/framework/tests/concurrency_benchmark.cpp @@ -0,0 +1,201 @@ +#include "framework/context/http_context.hpp" +#include "framework/server.hpp" +#include "framework/tests/concurrency_benchmark.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; +namespace fs = boost::filesystem; +namespace khttpd_fw = khttpd::framework; + +namespace +{ + struct Options + { + int concurrency = 32; + int requests = 2000; + int server_threads = 4; + int warmup = 100; + }; + + struct TempWebRoot + { + fs::path path; + + TempWebRoot() + : path(fs::temp_directory_path() / fs::unique_path("khttpd-concurrency-benchmark-%%%%-%%%%-%%%%")) + { + fs::create_directories(path); + std::ofstream((path / "index.html").string()) << "ok"; + } + + ~TempWebRoot() + { + boost::system::error_code ignored; + fs::remove_all(path, ignored); + } + }; + + int parse_positive_value(const char* name, const char* value) + { + try + { + const int parsed = std::stoi(value); + if (parsed <= 0) throw std::invalid_argument("not positive"); + return parsed; + } + catch (const std::exception&) + { + throw std::runtime_error(std::string(name) + " must be a positive integer"); + } + } + + Options parse_options(int argc, char** argv) + { + Options options; + for (int i = 1; i < argc; ++i) + { + const std::string argument = argv[i]; + if (argument == "--help") + { + std::cout << "Usage: concurrency_benchmark [--concurrency N] [--requests N] [--server-threads N] [--warmup N]\n"; + std::exit(0); + } + if (i + 1 >= argc) throw std::runtime_error("missing value for " + argument); + const int value = parse_positive_value(argument.c_str(), argv[++i]); + if (argument == "--concurrency") options.concurrency = value; + else if (argument == "--requests") options.requests = value; + else if (argument == "--server-threads") options.server_threads = value; + else if (argument == "--warmup") options.warmup = value; + else throw std::runtime_error("unknown argument: " + argument); + } + return options; + } + + bool request_once(unsigned short port, int request_id, std::uint64_t& latency_us) + { + const auto started_at = std::chrono::steady_clock::now(); + try + { + net::io_context ioc; + beast::tcp_stream stream(ioc); + stream.expires_after(std::chrono::seconds(10)); + stream.connect(tcp::endpoint(net::ip::address_v4::loopback(), port)); + + http::request request{http::verb::get, "/benchmark?id=" + std::to_string(request_id), 11}; + request.set(http::field::host, "127.0.0.1"); + request.set(http::field::user_agent, "khttpd-concurrency-benchmark"); + request.keep_alive(false); + http::write(stream, request); + + beast::flat_buffer buffer; + http::response response; + http::read(stream, buffer, response); + beast::error_code ignored; + stream.socket().shutdown(tcp::socket::shutdown_both, ignored); + + latency_us = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started_at).count()); + return response.result() == http::status::ok && response.body() == "pong"; + } + catch (...) + { + latency_us = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started_at).count()); + return false; + } + } + + void run_requests(unsigned short port, int requests, int concurrency, std::vector& latencies, + std::atomic& successes, std::atomic& failures) + { + std::atomic next_request{0}; + std::vector> worker_latencies(static_cast(concurrency)); + std::vector workers; + workers.reserve(static_cast(concurrency)); + for (int worker = 0; worker < concurrency; ++worker) + { + workers.emplace_back([&, worker]() + { + auto& worker_samples = worker_latencies[static_cast(worker)]; + while (true) + { + const int request_id = next_request.fetch_add(1, std::memory_order_relaxed); + if (request_id >= requests) return; + std::uint64_t latency_us = 0; + if (request_once(port, request_id, latency_us)) successes.fetch_add(1, std::memory_order_relaxed); + else failures.fetch_add(1, std::memory_order_relaxed); + worker_samples.push_back(latency_us); + } + }); + } + for (auto& worker : workers) worker.join(); + for (auto& samples : worker_latencies) + { + latencies.insert(latencies.end(), samples.begin(), samples.end()); + } + } +} + +int main(int argc, char** argv) +{ + try + { + const Options options = parse_options(argc, argv); + TempWebRoot web_root; + auto server = std::make_shared(tcp::endpoint(tcp::v4(), 0), web_root.path.string(), options.server_threads); + server->get_http_router().get("/benchmark", [](khttpd_fw::HttpContext& context) + { + context.set_status(http::status::ok); + context.set_content_type("text/plain"); + context.set_body("pong"); + }); + const auto port = server->local_endpoint().port(); + std::thread server_thread([server]() { server->run(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::vector warmup_latencies; + std::atomic warmup_successes{0}; + std::atomic warmup_failures{0}; + run_requests(port, options.warmup, options.concurrency, warmup_latencies, warmup_successes, warmup_failures); + + std::vector latencies; + latencies.reserve(static_cast(options.requests)); + std::atomic successes{0}; + std::atomic failures{0}; + const auto started_at = std::chrono::steady_clock::now(); + run_requests(port, options.requests, options.concurrency, latencies, successes, failures); + const auto elapsed_us = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started_at).count()); + + server->stop(); + server_thread.join(); + + const khttpd_fw::benchmark::Stats stats(std::move(latencies), elapsed_us, successes.load(), failures.load()); + std::cout << khttpd_fw::benchmark::format_report(stats, options.server_threads, options.concurrency, options.requests); + return stats.failures() == 0 ? 0 : 1; + } + catch (const std::exception& error) + { + std::cerr << "concurrency benchmark failed: " << error.what() << "\n"; + return 2; + } +} diff --git a/framework/tests/concurrency_benchmark.hpp b/framework/tests/concurrency_benchmark.hpp new file mode 100644 index 0000000..63543c6 --- /dev/null +++ b/framework/tests/concurrency_benchmark.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace khttpd::framework::benchmark +{ + class Stats + { + public: + Stats(std::vector latencies_us, + std::uint64_t elapsed_us, + std::size_t successes, + std::size_t failures) + : latencies_us_(std::move(latencies_us)), elapsed_us_(elapsed_us), successes_(successes), failures_(failures) + { + std::sort(latencies_us_.begin(), latencies_us_.end()); + } + + double percentile(double fraction) const + { + if (latencies_us_.empty()) return 0.0; + const auto bounded_fraction = std::max(0.0, std::min(1.0, fraction)); + const auto index = static_cast(bounded_fraction * (latencies_us_.size() - 1) + 0.5); + return static_cast(latencies_us_[index]); + } + + double requests_per_second() const + { + if (elapsed_us_ == 0) return 0.0; + return static_cast(successes_) * 1000000.0 / static_cast(elapsed_us_); + } + + double average_latency_us() const + { + if (latencies_us_.empty()) return 0.0; + const auto total = std::accumulate(latencies_us_.begin(), latencies_us_.end(), std::uint64_t{0}); + return static_cast(total) / static_cast(latencies_us_.size()); + } + + std::size_t successes() const { return successes_; } + std::size_t failures() const { return failures_; } + + private: + std::vector latencies_us_; + std::uint64_t elapsed_us_; + std::size_t successes_; + std::size_t failures_; + }; + + inline std::string format_report(const Stats& stats, int server_threads, int concurrency, int requests) + { + const auto total = stats.successes() + stats.failures(); + const auto success_rate = total == 0 ? 0.0 : static_cast(stats.successes()) * 100.0 / static_cast(total); + std::ostringstream report; + report << "========================================\n" + << "khttpd HTTP 并发基准测试\n" + << "========================================\n" + << "测试配置\n" + << " 服务端线程数 : " << server_threads << "\n" + << " 并发客户端数 : " << concurrency << "\n" + << " 请求总数 : " << requests << "\n" + << "----------------------------------------\n" + << "测试结果\n" + << " 成功请求数 : " << stats.successes() << "\n" + << " 失败请求数 : " << stats.failures() << "\n" + << " 成功率 : " << std::fixed << std::setprecision(2) << success_rate << "%\n" + << " 吞吐量 : " << std::setprecision(2) << stats.requests_per_second() << " req/s\n" + << " 平均延迟 : " << std::setprecision(2) << stats.average_latency_us() << " us\n" + << " p50 延迟 : " << stats.percentile(0.50) << " us\n" + << " p95 延迟 : " << stats.percentile(0.95) << " us\n" + << " p99 延迟 : " << stats.percentile(0.99) << " us\n" + << "========================================\n"; + return report.str(); + } +} diff --git a/framework/tests/concurrency_benchmark_test.cpp b/framework/tests/concurrency_benchmark_test.cpp new file mode 100644 index 0000000..a2ef87b --- /dev/null +++ b/framework/tests/concurrency_benchmark_test.cpp @@ -0,0 +1,24 @@ +#include "framework/tests/concurrency_benchmark.hpp" + +#include + +TEST(ConcurrencyBenchmarkStatsTest, CalculatesPercentilesAndThroughput) +{ + const khttpd::framework::benchmark::Stats stats({1, 2, 3, 4, 5}, 1000000, 4, 1); + + EXPECT_DOUBLE_EQ(stats.percentile(0.50), 3.0); + EXPECT_DOUBLE_EQ(stats.percentile(0.95), 5.0); + EXPECT_DOUBLE_EQ(stats.percentile(0.99), 5.0); + EXPECT_DOUBLE_EQ(stats.requests_per_second(), 4.0); +} + +TEST(ConcurrencyBenchmarkStatsTest, FormatsReadableChineseReport) +{ + const khttpd::framework::benchmark::Stats stats({100, 200, 300}, 1000000, 3, 0); + const auto report = khttpd::framework::benchmark::format_report(stats, 4, 8, 80); + + EXPECT_NE(report.find("khttpd HTTP 并发基准测试"), std::string::npos); + EXPECT_NE(report.find("并发客户端数"), std::string::npos); + EXPECT_NE(report.find("吞吐量"), std::string::npos); + EXPECT_NE(report.find("p95 延迟"), std::string::npos); +} diff --git a/framework/tests/router_test.cpp b/framework/tests/router_test.cpp index 35b7798..d7802ae 100644 --- a/framework/tests/router_test.cpp +++ b/framework/tests/router_test.cpp @@ -89,6 +89,25 @@ TEST(HttpRouterTest, StaticRouteMatching) ASSERT_EQ(ctx2.get_response().result(), http::status::not_found); } +TEST(HttpRouterTest, GlobalBasePathPrefixesRoutesAndOpenApiDescriptors) +{ + khttpd_fw::HttpRouter router; + router.set_base_path("/gateway/api/"); + bool called = false; + router.get("/health", [&](khttpd_fw::HttpContext& ctx) { called = true; ctx.set_status(http::status::ok); }, + {"Health", "Health check"}); + + auto req = make_request(http::verb::get, "/gateway/api/health"); + http::response res; + auto ctx = create_http_context(req, res); + router.dispatch(ctx); + + EXPECT_TRUE(called); + EXPECT_EQ(res.result(), http::status::ok); + ASSERT_EQ(router.route_descriptors().size(), 1U); + EXPECT_EQ(router.route_descriptors().front().path, "/gateway/api/health"); +} + TEST(HttpRouterTest, DynamicRouteMatchingAndParamExtraction) { khttpd_fw::HttpRouter router;