Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ coverage/
# 变异测试(Stryker)
.stryker-tmp/
reports/
# 本地性能评测数据与生成报告
/benchmarks/results/

# 日志
*.log
Expand Down
48 changes: 48 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,54 @@ pnpm run build:packages
完整 PR 检查的定义以 [test.yml](.github/workflows/test.yml) 为准。
文档修改重点检查命令、示例和相对链接;无需为纯文字改动重复运行全部运行时测试。

## HTTP 接入性能评测

`pnpm run bench` 测参数校验。比较接入 erest 前后的 HTTP 开销,先按上面的环境准备步骤
安装依赖并构建核心包与适配器,再执行以下命令(macOS、Linux 均可):

```bash
node benchmarks/run.mjs
node benchmarks/report.mjs benchmarks/results/latest.json
```

脚本对 Express、Koa、@leizm/web、H3 分别比较原生路由、原生路由加同等 Zod 校验、erest,
覆盖无 schema 的 GET、带路径/query/body 校验的 POST,以及增加 5ms 异步等待的 POST。
默认单并发与 50 并发、预热 1.5 秒、采样 3 秒、重复三轮,完整执行约需十二分钟。
报告包含吞吐、延迟分位数、客户端/服务端 CPU、内存快照和轮间波动。

macOS 本地复测可先聚焦 50 并发的 POST,增加预热和重复轮数(四框架共 84 次采样,约需十二分钟):

```bash
BENCH_SCENARIOS=post BENCH_CONCURRENCY=50 \
BENCH_ROUNDS=7 BENCH_MS=5000 BENCH_WARMUP_MS=3000 \
BENCH_OUT=benchmarks/results/mac-post.json node benchmarks/run.mjs
node benchmarks/report.mjs benchmarks/results/mac-post.json
open benchmarks/results/mac-post.html
```

同一轮对照使用相同 Node 版本和 lockfile,尽量减少其他 CPU 密集任务。比较 Mac 和云端
结果时,重点看各自环境中“原生 + Zod → erest”的相对变化、每请求 CPU 和轮间波动,
不要直接比较两台机器的绝对 QPS;客户端 CPU 接近饱和时,观察到的吞吐也可能受发压能力限制。

Linux 下可选用两个不同的、当前进程允许使用的 CPU 固定客户端和服务端;macOS 不设置
`BENCH_SERVER_CPU`,由操作系统调度:

```bash
BENCH_SERVER_CPU=0 taskset -c 1 node benchmarks/run.mjs
```

`BENCH_FRAMEWORKS=express`、`BENCH_SCENARIOS=post`、`BENCH_CONCURRENCY=50` 可筛选场景;
`BENCH_ROUNDS`、`BENCH_MS`、`BENCH_WARMUP_MS` 调整轮数和时长,`BENCH_OUT` 指定 JSON 输出。
快速试跑只检查响应一致性和非法输入拒绝,不能用于性能结论:

```bash
BENCH_ROUNDS=1 BENCH_MS=100 BENCH_WARMUP_MS=100 \
BENCH_OUT=benchmarks/results/smoke.json node benchmarks/run.mjs
```

`benchmarks/results/` 下的 JSON、CSV 和 HTML 都是被忽略的本地临时生成物,不随工具提交。
短测结果不是线上容量结论;只有在独立复测中出现一致趋势后,才适合讨论运行时优化。

## 合约专项

`test:contract` 在 Express、Koa、LeizmWeb、H3 下执行同一套 GET/POST/PUT/PATCH/DELETE 定义。
Expand Down
40 changes: 40 additions & 0 deletions benchmarks/fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from "zod";

export const schemas = {
params: z.object({ id: z.string().min(1) }),
query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(10) }),
body: z.object({
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
email: z.string().email(),
tags: z.array(z.string()).max(10),
address: z.object({ city: z.string(), zip: z.string() }),
}),
};
export const payload = {
name: "Tom",
age: 25,
email: "tom@example.com",
tags: ["node", "typescript", "api"],
address: { city: "Shenzhen", zip: "518000" },
};
export const path = "/users/u123?limit=20";
export function output(req) {
return { id: req.params.id, limit: Number(req.query.limit), ...req.body };
}
export const expectedPost = output({ params: { id: "u123" }, query: { limit: 20 }, body: payload });
export const expectedGet = { ok: true, message: "hello erest" };

export function validate(input) {
const result = {};
for (const key of ["params", "query", "body"]) {
const parsed = schemas[key].safeParse(input[key]);
if (!parsed.success) {
const error = new Error("invalid input");
error.status = 400;
throw error;
}
result[key] = parsed.data;
}
return result;
}
92 changes: 92 additions & 0 deletions benchmarks/load.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import http from "node:http";
import { performance } from "node:perf_hooks";
import { payload, path } from "./fixture.mjs";

// Closed-loop HTTP/1.1 keep-alive, one outstanding request per connection.
// Separate client/server processes; report client CPU to expose generator limits.
// Includes full response consumption, but correctness is checked before timing.
export async function load(port, scenario, concurrency, durationMs) {
const agent = new http.Agent({ keepAlive: true, maxSockets: concurrency, maxFreeSockets: concurrency });
const body = scenario === "get" ? undefined : JSON.stringify(payload);
const options = {
host: "127.0.0.1",
port,
agent,
method: body ? "POST" : "GET",
path: body ? path : "/ping",
headers: body
? {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
}
: {},
};
// 1us histogram resolution through 2s; last bucket counts longer responses.
const histogram = new Uint32Array(2_000_001);
let count = 0,
errors = 0,
totalLatency = 0,
maxLatency = 0;
const cpuStart = process.cpuUsage();
const start = performance.now();
const deadline = start + durationMs;
await Promise.all(
Array.from(
{ length: concurrency },
() =>
new Promise((resolve) => {
function next() {
if (performance.now() >= deadline) return resolve();
const requestStart = performance.now();
let finished = false;
function complete(failed) {
if (finished) return;
finished = true;
const latency = performance.now() - requestStart;
count++;
if (failed) errors++;
totalLatency += latency;
maxLatency = Math.max(maxLatency, latency);
histogram[Math.min(2_000_000, Math.floor(latency * 1000))]++;
next();
}
const req = http.request(options, (res) => {
res.resume();
res.once("end", () => complete(res.statusCode !== 200));
res.once("error", () => complete(true));
});
req.setTimeout(5000, () => req.destroy(new Error("request timeout")));
req.once("error", () => complete(true));
req.end(body);
}
next();
})
)
);
const elapsedMs = performance.now() - start;
const cpu = process.cpuUsage(cpuStart);
agent.destroy();
function percentile(p) {
const target = Math.ceil(count * p);
let n = 0;
for (let i = 0; i < histogram.length; i++) {
n += histogram[i];
if (n >= target) return i / 1000;
}
return null;
}
return {
requests: count,
errors,
elapsedMs,
rps: (count * 1000) / elapsedMs,
latencyMs: {
mean: totalLatency / count,
p50: percentile(0.5),
p95: percentile(0.95),
p99: percentile(0.99),
max: maxLatency,
},
clientCpuPercent: (cpu.user + cpu.system) / (elapsedMs * 10),
};
}
Loading
Loading