Compare commits

..

18 Commits

Author SHA1 Message Date
c5e415ca52 release: prepare v0.6.0-preview 2026-09-22 01:38:42 +08:00
9c9c8ab59a test: validate native TLS on Linux 2026-09-22 01:14:21 +08:00
e6c82a01fe test: add TLS performance matrix 2026-09-21 05:13:51 +08:00
847621d218 feat: add native TLS serving 2026-09-21 04:44:47 +08:00
4daf3b211c feat: cancel native requests on disconnect 2026-09-21 04:35:13 +08:00
62b0f09546 feat: support native streaming responses 2026-09-21 04:02:04 +08:00
694374fde8 docs: record native chunked support 2026-09-21 03:49:49 +08:00
22f1c9ebfb feat: support bounded chunked request bodies 2026-09-21 03:47:55 +08:00
b3cb9e8857 test: exercise native connection churn 2026-09-21 03:29:38 +08:00
efd3163902 test: verify native connection capacity recovery 2026-09-21 03:17:17 +08:00
241a568c83 test: cover native slow bodies and pipelining 2026-09-21 03:03:27 +08:00
22939fdb0b fix: harden native http protocol boundaries 2026-09-21 02:54:42 +08:00
3d5f225fb2 test: compare native concurrency and server memory 2026-09-21 02:35:48 +08:00
c36bb70f96 test: add native tcp performance baseline 2026-09-21 02:14:52 +08:00
05b4088a25 feat: add bounded native http server 2026-09-21 01:58:50 +08:00
3bbf4627a1 feat: remove Gin and standardize on nethttp 2026-09-21 00:57:06 +08:00
61c37e0dba test: verify remote business module upgrade flow 2026-09-21 00:23:22 +08:00
5561795d80 feat: standardize transport contracts across Gin and nethttp 2026-09-21 00:10:44 +08:00
53 changed files with 8468 additions and 397 deletions

View File

@@ -1,5 +1,59 @@
# Changelog
## Unreleased
- 统一标准库与 native 的真实 TCP benchmark,增加固定并发档位、响应状态与读写错误检查。
- 新增独立进程并发与长连接内存测量工具,记录成功吞吐、P95/P99、服务端分配、空闲连接堆内存和回收情况;修正并行 `ns/op` 与客户端/服务端混合分配的解释。
- 加固 native HTTP/1.1 解析边界:校验 Host、重复 Content-Length、Connection token、Transfer-Encoding、控制字符和响应头名称;补充 HTTP/1.0 Keep-Alive、HEAD、204 以及异常请求测试。
- native 支持带 trailers 的 chunked 请求体,并继续通过 `MaxBodyBytes`、读超时和头部上限控制解析内存与连接占用。
- native 响应实现 `http.Flusher`:HTTP/1.1 流式响应自动使用 chunked framing,普通响应继续自动生成 `Content-Length`。
- native 在长 Handler 运行期间监测客户端断开并取消请求 context,流式写失败同步取消;短请求延迟启动监测,管线数据保留给下一次请求。补充断开、监测退出和长连接复用验收。
- native 增加 `ServeTLS` / `RunTLS` 和 `TLSConfig` 启动入口,App.RunWith 可以直接启用 TLS;补充真实 TLS 客户端握手、请求和优雅退出验收。
- native 请求补齐 `Request.TLS` 连接状态;Linux/arm64 容器通过全量 race、vet、build,以及 TLS 并发、Keep-Alive、慢握手和连接上限回收验收。
- 补充慢请求体超时、大请求体和同连接管线请求验收,确认请求体未完整读取时不会进入业务 Handler,连续请求不会串线。
- 补充 `MaxConnections` 满载拒绝与槽位释放验收,确认请求完成后连接容量可再次使用。
- 增加高频建连/断连的状态池 churn 验收,覆盖连接对象回收与容量复用。
## v0.6.0-preview
- native 网络层完成慢请求头、慢 Content-Length / chunked body、慢 TLS 握手和 TLS 请求元数据验收,未完整读取的请求不会进入业务 Handler。
- 将 native 连接 churn race 验收扩展为 16 个 worker、3,200 次建连/断连,并覆盖明文与 TLS 的连接上限、Keep-Alive 和槽位回收。
- perfcheck 支持最长 30 分钟持续场景,新增 Linux 文件描述符采样;固定记录服务端堆、goroutine、连接数、accept 数和 FD 回收状态。
- 新增 `scripts/verifyLinux.py`,在 `golang:1.25` Linux 容器内统一执行 race、vet、build 和可选 TLS / 非 TLS 压测。
- 完成 Linux/arm64 30 秒高并发回归:128 并发流量、512 条空闲连接,TLS 与非 TLS 均为 0 错误,连接、goroutine 和 FD 回收正常;原始报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`。
## v0.5.0
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端,接管 TCP 监听、连接、Keep-Alive、请求解析、响应写回和优雅关闭。
- 新增 `App.RunWith` / `omni.Runner` 启动边界;native 通过标准 `http.Handler` 接入,现有 Handler、Service 和 Repository 无需修改。
- native 支持 `Content-Length` / chunked 请求体、请求头 / 请求体限制、读写超时和服务关闭时的请求取消;通过对象池和 `MaxConnections` / `MaxConcurrentRequests` 控制高并发内存。
- `omni.New()` 默认仍使用只依赖标准库的 `transport/nethttp` 路由,native 作为显式启用的网络层预览实现。
## v0.4.0
- **破坏性变更**:移除 Gin 适配器及其全部依赖,`omni.New()` 默认使用只依赖标准库的 `transport/nethttp`。
- 移除 Gin 专属的 `Config.Mode` / `WithMode` API;网络层运行模式由具体适配器或启动层自行配置。
- 公共 `httpx.Router`、`transport/transporttest.Run` 和 `omni.NewWithRouter` 保留,自研网络层无需修改业务 Handler / Service 即可接入。
- benchmark 改为标准库路由和完整 GoOmni 请求链,新增 `scripts/verifyPerformance.py` 作为 race、vet、build 和性能基线入口。
- 远程业务试用、脚手架、适配器契约和 MySQL 验收全部迁移到 `v0.4.0`。
## v0.3.1
- 新增独立业务模块验收 `scripts/verifyBusiness.py`:从远程 `v0.2.0` 升级到 `v0.3.1`,验证无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
- 脚手架与各项远程验收脚本默认跟随 `v0.3.1`,新项目会固定到最新补丁版本。
## v0.3.0
- 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。
- 统一参数、分组、方法、URL 解码、路由优先级及 404/405 规则;Gin 405 的 Allow 由适配层计算,避免方法树回退状态带入无关方法。
- 统一路由注册校验;非法路径、重复路由、参数名冲突和 catch-all 冲突在注册时 panic。
- `App.Handle` / `RouterGroup.Handle` 支持在完整业务中间件链内显式注册 HEAD、OPTIONS 与扩展方法。
- JSON 在提交响应前编码,编码失败可正确返回统一 500;通过 Context 已提交的响应不会因后续错误、panic 或重复响应追加内容,访问日志保留实际提交的状态。
- 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。
- **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。
- `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。
- 新增业务试用验收 `scripts/verifyBusiness.py`:独立目录内固定远程版本,执行 `v0.2.0` → `v0.3.0` 升级,验证生成业务服务的配置、日志、requestId、CRUD、参数校验、错误映射和优雅退出。
## v0.2.0
- 新增 `go-omni new --database mysql`,生成数据库配置、MySQL Repository、ServiceContext、`schema.sql` 和数据库就绪探针;默认仍使用内存存储。

84
PERFORMANCE.md Normal file
View File

@@ -0,0 +1,84 @@
# GoOmni 性能基线
这轮测量显示 native 与标准库处于相同吞吐量级,在本机短时测试中,native 的空闲长连接堆内存更少。当前 native 仅实现 HTTP 的部分能力,尚不能据此判断生产容量或替代完整标准库服务端。
## 运行方式
```sh
# race、vet、build,以及进程内和真实 TCP benchmark
python3 scripts/verifyPerformance.py
# 另加独立服务进程的并发、延迟和空闲长连接内存矩阵
python3 scripts/verifyPerformance.py --load --output /tmp/goOmniPerf.json
# 只运行独立进程矩阵,默认每档 2 秒、3 轮
go run ./internal/perfcheck --duration 2s --count 3 --output /tmp/goOmniPerf.json
# 在 Linux Docker 容器内固定运行 race、vet、build 和 TLS 压测
python3 scripts/verifyLinux.py --load --tls --duration 30s \
--concurrency 128 --idle 512 --count 1 \
--output performance/linuxSoakTLS.json
# 发布候选验收可把持续流量延长到 30 分钟
python3 scripts/verifyLinux.py --load --tls --duration 30m \
--concurrency 128 --idle 512 --count 1 \
--output performance/linuxSoakTLS-30m.json
```
可通过 `--concurrency 1,32,128` / `--idle 128,1024` 调整矩阵;传空字符串可跳过对应场景。工具只监听本机随机端口,每个场景启动独立服务进程,并在结束后关闭客户端、检查连接排空、关闭服务。响应状态、完整响应体、requestId 或传输错误均计入失败,不能把快速返回的错误页算成成功吞吐。
Linux 验收脚本使用 `golang:1.25` 容器,固定执行 `go test -race ./...`、`go vet ./...`、`go build ./...`,再按参数运行 perfcheck。perfcheck 在 Linux 读取 `/proc/self/fd`,报告连接建立期间的文件描述符峰值以及回收后的数量;同时记录服务端堆、goroutine、连接数和累计 accept 数。30 秒报告用于快速回归,30 分钟命令用于发布前持续压测。
## 2026-09-21 本机测量
环境:Go 1.25.0、darwin/arm64、Apple M2 Max、GOMAXPROCS=12。网络实现来自 `3d5f225` 工作树(native 与 v0.6.0-preview 相同);本次新增测量工具,不修改服务端实现。原始测量值见 [tcpBaseline.json](performance/tcpBaseline.json)。报告时间为 UTC,北京时间是 9 月 21 日。
每个并发档位持续约 2 秒、重复 3 轮,每轮交替标准库/native 的先后顺序。下表为各指标的三轮中位数;全部流量场景共完成 **2,660,828** 次请求,错误 **0**。另有 128 / 1,024 条空闲长连接的建立、保持和回收测试。
| 并发请求 | 标准库成功请求/秒 | native 成功请求/秒 | 标准库 P95 | native P95 | 标准库 P99 | native P99 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 19,731 | 21,497 | 0.074 ms | 0.070 ms | 0.139 ms | 0.119 ms |
| 32 | 90,099 | 88,812 | 1.016 ms | 0.983 ms | 1.507 ms | 1.507 ms |
| 128 | 112,422 | 112,105 | 3.277 ms | 3.408 ms | 4.719 ms | 4.981 ms |
128 并发时,两者吞吐差约 0.3%,native 的尾延迟略高。基于这组结果,暂时没有理由仅为健康接口吞吐就改成手写事件循环。
| 空闲 Keep-Alive 连接 | 标准库新增堆内存 | native 新增堆内存 | 标准库约每连接 | native 约每连接 |
| --- | ---: | ---: | ---: | ---: |
| 128 | 1.275 MiB | 0.658 MiB | 10,444 B | 5,389 B |
| 1,024 | 9.871 MiB | 5.137 MiB | 10,108 B | 5,260 B |
1,024 条空闲连接时,native 的新增堆内存约少 48%。这是**服务端 Go 堆内存增量**,不包括 goroutine 栈、进程其他内存和内核 socket 缓冲区,不能解释为整个进程内存减半。同档服务端 `StackInuse` 分别约为标准库 8.75 MiB、native 8.69 MiB,原始报告单独记录。
连接断开后,两者连接数都回到 0,goroutine 分别回到 2 / 3 的初始基线。一次 GC 后仍有对象池缓存;第二次 GC 后,总堆内存中位数回到约 2.10 / 2.08 MiB(启动基线约 1.54 MiB)。堆内存不会立刻精确回到启动数值,不把缓存保留解释为泄漏,也不把这轮短测解释为长期无泄漏证明。
128 并发时,**服务端单独累计分配**约为标准库 4,454 B/请求、47 次/请求,native 4,297 B/请求、55 次/请求。native 的分配字节较少、次数较多;具体来源需用分配 profile 确认,不能仅凭总量归因于 HTTP 桥接或 JSON。
## 统计边界
- 同一台机器上,压测客户端和服务端是两个独立进程。两者使用同一健康接口、相同小型 JSON 响应、固定 `requestId=perfCheck` 和 Keep-Alive;不启用访问日志、TLS 或数据库。
- 并发模型为闭环:每个 worker 等上一个请求完成后发下一个。实际经过时间包含最后请求排空;吞吐只计算成功响应。不能据此推断开放到达流量下的排队或过载表现。
- 延迟从客户端发起请求到完整响应校验完成,包含失败请求。直方图使用每个 2 的幂区间内 16 档,报告桶上界,舍入误差至多约 6.25%。小幅差别不应过度解释。
- 服务端 `runtime.MemStats` 通过独立 stdin/stdout 控制通道读取,客户端内存不计入。累计分配差值包含少量采样开销。堆峰值每 200 ms 采样,可能错过瞬时峰值;这些指标不是进程 RSS。
- Linux 文件描述符来自服务端子进程的 `/proc/self/fd`,包含标准输入输出、监听 socket 和连接;macOS 等没有该路径的平台会省略该字段。回收后的数量应回到启动附近,不能代替内核 socket 缓冲或 RSS 测量。
- 空闲场景在所有连接完成健康请求后做 GC 再采样。新增堆内存包括连接、缓冲区及分摊的运行时开销。`after` 为客户端断开并排空后一次 GC,`afterReclaim` 为第二次 GC,用于观察对象池缓存释放。
- native 配置上限为 8,192 连接 / 4,096 活跃请求;本轮并发低于上限,未验证过载保护容量。标准库没有同样的并发闸门;比较包括各自真实实现的开销。
- 可用 `python3 scripts/verifyPerformance.py --load --tls --duration 2s` 运行 TLS 版本的独立进程矩阵。该模式在每个子进程生成短期自签名证书,客户端仅用于本机基准验证;本报告的历史表格仍是无 TLS 数据。
## benchmark 口径修正
## TLS 验收记录
macOS arm64 本机的 5 秒 TLS 矩阵见 [tlsBaseline.json](performance/tlsBaseline.json):1 / 32 / 128 并发和 128 / 1,024 条空闲连接均为 0 错误;native 在 128 并发约 114,232 req/s,1,024 条空闲 TLS 连接的服务端堆增量约 8.94 MiB,连接关闭后 goroutine 回到基线。
Linux arm64 容器使用 Go 1.25.14、GOMAXPROCS=4 运行 3 秒矩阵,见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json):1 / 32 / 128 并发和 128 / 512 条空闲连接均为 0 错误;native 在 128 并发约 99,876 req/s,512 条空闲 TLS 连接的服务端堆增量约 4.47 MiB,连接关闭后 goroutine 回到基线。容器验证同时通过全量 race、vet、build。
Linux arm64 的 30 秒高并发回归见 [linuxSoak.json](performance/linuxSoak.json) 和 [linuxSoakTLS.json](performance/linuxSoakTLS.json):两份报告均使用 128 并发流量和 512 条空闲连接,所有场景错误为 0;native 非 TLS 约 146,614 req/s、TLS 约 149,720 req/s,空闲连接堆增量分别约 2.57 MiB 和 4.47 MiB,FD 均由 7 增至 519 并回到 7,goroutine 均回到启动附近。
`BenchmarkTransportTCPApplicationRequest/{nethttp,native}/concurrency{1,32,128}` 使用同一测试入口、客户端、应用与响应检查。原先独立的 `BenchmarkNativeApplicationRequest` 已合并,避免两个入口配置漂移。`verifyPerformance.py` 会一并运行。
并行 benchmark 的 **`ns/op` 是总经过时间除以请求数,反映吞吐成本,不是单请求延迟**。`B/op` / `allocs/op` 统计同一进程中的客户端与服务端总分配。此前报告的 native 15.4–16.7 µs/op、约 7.8 KB/op、100 allocs/op 应按这个含义理解,不能作为服务端延迟或独立内存指标。
`BenchmarkTransportRouteDispatch` 和 `BenchmarkTransportApplicationRequest` 继续保留,分别测进程内路由分发和完整框架处理链;不包含 TCP,不能直接与上述网络延迟比较。
后续通过 30 分钟命令完成发布前持续压测,并在目标反向代理和部署环境继续观察内核 socket 缓冲、FD 上限与真实业务流量;再通过 profile 决定是否优化对象分配或事件状态机。

View File

@@ -1,17 +1,19 @@
# GoOmni
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gin 作为网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用只依赖标准库的网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.2.0
go get git.yuntuo.hk/k2safe/go-omni@v0.6.0-preview
```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
创建新业务项目:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.2.0
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy
@@ -40,7 +42,8 @@ go-omni/
├── validate/ # 参数校验
├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务
├── transport/gin/ # Gin 网络适配器,可替换
├── transport/nethttp/ # 标准库网络适配器
├── transport/native/ # 自研 HTTP/1.1 服务端预览
├── examples/ # 示例项目
└── cmd/ # go-omni 项目脚手架
```
@@ -80,17 +83,47 @@ func main() {
return omni.NotFound("USER_NOT_FOUND", "user was not found")
```
默认 Gin 适配器位于 `transport/gin`。业务层不需要导入 Gin;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
默认网络适配器位于 `transport/nethttp`。业务层不需要导入具体网络库;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
```go
application := omni.NewWithRouter(myRouter)
```
仓库还提供了只依赖标准库的 `transport/nethttp` 参考实现,可以直接验证这条替换路径。
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
`app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.4.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖 Gin:
`app.Engine()` 返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,业务 API 不锁定具体网络实现。
需要试用第一版自研网络层时,业务注册代码无需改变,只替换启动器:
```go
import (
"context"
"log"
"os"
"os/signal"
"syscall"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
log.Fatal(err)
}
```
`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
```go
func (s *UserService) Create(ctx *httpx.Context) error {
@@ -164,6 +197,16 @@ userService := service.NewUsers(services)
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
从远程版本生成并升级一个独立业务模块,可运行:
```sh
python3 scripts/verifyBusiness.py --version v0.6.0-preview
```
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.6.0-preview`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
在框架根目录运行:
```sh
@@ -172,7 +215,7 @@ go run ./examples/reference
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入 Gin。
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入网络库。
## 当前边界

View File

@@ -18,15 +18,37 @@
- 已验证初始化失败、缺少表结构、正常退出时关闭连接池,以及数据库停止后的 readiness 503、恢复后的 200。
- 自动验收入口:`python3 scripts/verifyDatabase.py --version v0.2.0`;结束后清理本次测试容器、匿名卷和临时项目。
## 3. 网络适配一致性(待完成)
## 3. 网络适配一致性(已完成,v0.4.0)
- Gin 与标准库适配器运行同一套契约测试。
- 标准库适配器运行公共契约测试,并为自研网络层保留相同的接入边界。
- 覆盖路由参数、分组、中间件、错误响应和取消传播。
- 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。
- 新增公共 `transport/transporttest.Run`,自研适配器可直接复用;覆盖静态 / 参数 / 通配路由、分组、405 Allow、鉴权短路、错误与 panic、响应提交、requestId、并发隔离、截止时间及真实客户端取消。
- 标准库路由实现明确的路径、方法、尾部斜杠、通配参数、Any 和 404/405 约定;不再引入 Gin 依赖。
- 完整用户 CRUD 已用标准库适配器验证;自研适配器可直接复用同一公共契约测试包。
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.4.0`,从源码目录外消费远程版本与公共契约测试包。
## 4. 实际业务试用(待完成)
## 4. 实际业务试用(已完成,v0.4.0)
- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。
- 根据真实接入问题收敛公共 API,再评估稳定版范围。
- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.4.0`,全程无本地 replace。
- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.4.0`。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 功能验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。
## 5. 自研网络层、稳定性与性能基线(v0.6.0-preview 验收完成)
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length / chunked 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
- 标准库与 native 共用 TCP benchmark,固定 1 / 32 / 128 并发;相同应用、客户端、超时和响应检查,避免两个测试入口逐渐失去可比性。
- 新增独立服务进程压测 `internal/perfcheck`,记录成功吞吐、P50/P95/P99、服务端分配、采样堆峰值,以及 128 / 1,024 条空闲长连接的堆内存、goroutine 和断开后的回收情况。原始报告保存在 `performance/tcpBaseline.json`。
- native 已支持受 `MaxBodyBytes` 约束的 `Content-Length` / chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。
- native 响应已支持标准 `http.Flusher`;HTTP/1.1 流式响应使用 chunked framing,普通响应继续走 `Content-Length`,HTTP/1.0 回退到关闭连接分帧。
- native 长请求已补客户端断开及流式写失败取消,监测退出后再复用 Reader;已验收普通/流式断开、慢 Handler 后连续 Keep-Alive 和管线数据不被误消费。短请求延迟启动监测,已有管线数据时取消依赖后续写失败或 Handler 返回。
- 已补充慢请求头、慢 Content-Length body、慢 chunked body、慢 TLS 握手、大请求体、HTTP/1.0 Keep-Alive、HEAD/204 和管线请求验收;Linux/arm64 容器已通过全量 race、vet、build,以及 3 秒 TLS 并发/空闲连接矩阵。原始报告见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json)。
- 已验证 `MaxConnections` 满载时快速拒绝新连接、请求完成后释放容量;后续仍需在目标部署环境观察文件描述符、内核 socket 缓冲和反向代理联动。
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。
- 自动验收入口:`python3 scripts/verifyPerformance.py`;Linux 固定验收入口:`python3 scripts/verifyLinux.py --load --tls --duration 30m`。真实部署仍需按目标代理和内核参数执行 30 分钟发布前压测。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。

View File

@@ -15,7 +15,7 @@ import (
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
// App is the top-level GoOmni application. Its public behavior depends on
@@ -29,10 +29,17 @@ type App struct {
closeErr error
}
// New creates an application using the default Gin adapter.
// Runner is the small lifecycle boundary used by an HTTP server
// implementation. The default App.RunContext uses net/http; a custom runner
// can own the listener, protocol parser and connection lifecycle.
type Runner interface {
Run(context.Context, http.Handler, string) error
}
// New creates an application using GoOmni's standard-library router.
func New(options ...config.Option) *App {
settings := configure(options...)
return newApp(ginrouter.New(settings.Mode), settings)
return newApp(netrouter.New(), settings)
}
// NewWithRouter creates an application with a custom network layer. A custom
@@ -68,7 +75,7 @@ func newApp(router httpx.Router, settings config.Config) *App {
func (a *App) Router() httpx.Router { return a.router }
// Engine is kept as a compatibility name for the old API. It returns a
// standard http.Handler, so callers no longer need a Gin type.
// standard http.Handler, so callers do not depend on a router type.
func (a *App) Engine() http.Handler { return a.router }
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
@@ -150,6 +157,25 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
return a.serveContext(ctx, server, listener)
}
// RunWith starts a custom server runner while keeping App-owned cleanup in the
// same lifecycle. The runner receives the transport-neutral App handler.
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error {
if runner == nil {
return errors.Join(errors.New("go-omni: nil runner"), a.Close())
}
if ctx == nil {
ctx = context.Background()
}
listenOn := a.config.Address
if listenOn == "" {
listenOn = ":8080"
}
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
return errors.Join(runner.Run(ctx, a, listenOn), a.Close())
}
func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error {
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()

View File

@@ -28,6 +28,12 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
})
}
// Handle registers an explicit method, including HEAD, OPTIONS and extension
// methods, through the same business middleware and error handling as GET.
func (a *App) Handle(method, path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, method, path, handler, middleware)
}
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodGet, path, handler, middleware)
}
@@ -80,6 +86,11 @@ func (g *RouterGroup) route(method, path string, handler httpx.Handler, middlewa
g.app.handle(g.group, method, path, handler, combined)
}
// Handle registers an explicit method within this group's middleware chain.
func (g *RouterGroup) Handle(method, path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(method, path, handler, middleware)
}
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodGet, path, handler, middleware)
}

View File

@@ -8,12 +8,10 @@ import (
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestAppReturnsStandardResponse(t *testing.T) {
app := New(WithMode(gin.TestMode))
app := New()
app.GET("/health", func(ctx *Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
@@ -41,7 +39,7 @@ func TestAppReturnsStandardResponse(t *testing.T) {
}
func TestAppReturnsAPIError(t *testing.T) {
app := New(WithMode(gin.TestMode))
app := New()
app.GET("/users/:id", func(ctx *Context) error {
return NotFound("USER_NOT_FOUND", "user was not found")
})
@@ -63,7 +61,7 @@ func TestAppReturnsAPIError(t *testing.T) {
}
func TestAppRecoversFromPanic(t *testing.T) {
app := New(WithMode(gin.TestMode))
app := New()
app.GET("/panic", func(ctx *Context) error {
panic("boom")
})
@@ -85,7 +83,7 @@ func TestAppRecoversFromPanic(t *testing.T) {
}
func TestAppRunContextShutsDownGracefully(t *testing.T) {
app := New(WithMode(gin.TestMode), WithShutdownTimeout(time.Second))
app := New(WithShutdownTimeout(time.Second))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- app.RunContext(ctx, "127.0.0.1:0") }()
@@ -102,7 +100,7 @@ func TestAppRunContextShutsDownGracefully(t *testing.T) {
}
func TestAppClosesResourcesInReverseOrder(t *testing.T) {
app := New(WithMode(gin.TestMode))
app := New()
var closed []string
app.OnShutdown(func() error {
closed = append(closed, "database")

View File

@@ -9,11 +9,10 @@ import (
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/auth"
"github.com/gin-gonic/gin"
)
func TestRequireAuthAndRole(t *testing.T) {
app := omni.New(omni.WithMode(gin.TestMode))
app := omni.New()
authenticator := auth.StaticAuthenticator{"good-token": {UserID: "user-1", Roles: []string{"user"}}}
private := app.Group("/private", auth.RequireAuth(authenticator))
private.GET("/me", func(ctx *omni.Context) error {
@@ -62,7 +61,7 @@ func TestRequireAuthAndRole(t *testing.T) {
}
func TestOptionalAuth(t *testing.T) {
app := omni.New(omni.WithMode(gin.TestMode))
app := omni.New()
app.GET("/public", func(ctx *omni.Context) error {
principal, authenticated := auth.FromContext(ctx)
return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID})

View File

@@ -3,7 +3,7 @@
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.2.0
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy
@@ -16,7 +16,7 @@ go run ./cmd/server
需要选择其他已发布版本时,使用完整版本号:
```sh
go-omni new --framework-version v0.2.0 --module example.com/billing ./billing
go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
```
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
@@ -48,8 +48,8 @@ MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
```sh
python3 scripts/verifyScaffold.py --version v0.2.0
python3 scripts/verifyDatabase.py --version v0.2.0
python3 scripts/verifyScaffold.py --version v0.6.0-preview
python3 scripts/verifyDatabase.py --version v0.6.0-preview
```
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.2.0"
const defaultFrameworkVersion = "v0.6.0-preview"
var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)

View File

@@ -11,7 +11,6 @@ const defaultRequestIDHeader = "X-Request-ID"
// Config controls the default runtime behavior of an App.
type Config struct {
Mode string
Address string
RequestIDHeader string
EnableLogger bool
@@ -53,11 +52,6 @@ func (e *ConfigError) Error() string { return "config " + e.Field + " " + e.Mess
// Option changes an App's configuration.
type Option func(*Config)
// WithMode sets the default transport's running mode.
func WithMode(mode string) Option {
return func(cfg *Config) { cfg.Mode = mode }
}
// WithRequestIDHeader changes the header used to read and return request IDs.
func WithRequestIDHeader(header string) Option {
return func(cfg *Config) {

View File

@@ -8,7 +8,7 @@ import (
func TestLoadFromEnvUsesDefaults(t *testing.T) {
for _, key := range []string{
"GO_OMNI_MODE", "GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
"GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
@@ -29,7 +29,6 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
}
func TestLoadFromEnvOverridesSettings(t *testing.T) {
t.Setenv("GO_OMNI_MODE", "release")
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
@@ -45,7 +44,7 @@ func TestLoadFromEnvOverridesSettings(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if cfg.Mode != "release" || cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger {
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger {
t.Fatalf("unexpected application settings: %+v", cfg)
}
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
@@ -80,11 +79,10 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
func TestWithConfigAndWithLogConfig(t *testing.T) {
settings := Default()
settings.Mode = "release"
settings.Log.Service = "orders"
cfg := Default()
WithConfig(settings)(&cfg)
if cfg.Mode != "release" || cfg.Log.Service != "orders" {
if cfg.Log.Service != "orders" {
t.Fatalf("complete config was not applied: %+v", cfg)
}

View File

@@ -18,7 +18,6 @@ func LoadFromEnv(prefix string) (Config, error) {
prefix = defaultEnvPrefix
}
cfg := Default()
cfg.Mode = envString(prefix+"MODE", cfg.Mode)
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)

View File

@@ -41,8 +41,8 @@ examples/reference/
调用路径:HTTP → Handler → Service → Repository。
- Handler 依赖 GoOmni,示例代码不导入 Gin,也不调用 `Engine()` / `Raw()`。
- Service、Repository、Model 不依赖 GoOmni 或 Gin,通过标准 `context.Context` 传递请求取消信号。
- Handler 依赖 GoOmni,示例代码不导入具体网络库,也不调用 `Engine()` / `Raw()`。
- Service、Repository、Model 不依赖 GoOmni 或网络库,通过标准 `context.Context` 传递请求取消信号。
- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。
- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。
- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。
@@ -156,4 +156,6 @@ go vet ./...
go build ./...
```
`TestUserCRUD` 使用标准库适配器运行完整业务注册代码;同一套 Handler、Service、Repository 也可通过 `omni.NewWithRouter` 接入自研适配器。
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。

View File

@@ -20,26 +20,24 @@ import (
func newApp(users service.UserRepository, logger *logx.Logger, options ...omni.Option) *omni.App {
application := omni.New(options...)
application.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
api := application.Group("/api/v1", handler.RequestLog(logger))
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, nil, nil)
registerRoutes(application, users, logger, nil, nil)
return application
}
func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, options ...omni.Option) *omni.App {
application := omni.New(options...)
registerRoutes(application, users, logger, auth.RequireAuth(authenticator), auth.RequireRole("admin"))
return application
}
// Registration and business layers are shared by every network adapter.
func registerRoutes(application *omni.App, users service.UserRepository, logger *logx.Logger, write, delete omni.Middleware) {
application.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
api := application.Group("/api/v1", handler.RequestLog(logger))
write := auth.RequireAuth(authenticator)
delete := auth.RequireRole("admin")
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, write, delete)
return application
}
func main() {

View File

@@ -18,7 +18,9 @@ import (
"git.yuntuo.hk/k2safe/go-omni/auth"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
type envelope struct {
@@ -29,7 +31,7 @@ type envelope struct {
}
func testApp() *omni.App {
return newApp(repository.NewMemoryUsers(), testLogger(io.Discard), omni.WithMode("test"))
return newApp(repository.NewMemoryUsers(), testLogger(io.Discard))
}
func testLogger(writer io.Writer) *logx.Logger {
@@ -84,7 +86,6 @@ func TestProtectedWriteRoutes(t *testing.T) {
repository.NewMemoryUsers(),
testLogger(io.Discard),
auth.StaticAuthenticator{"user-token": {UserID: "user-1", Roles: []string{"user"}}},
omni.WithMode("test"),
)
request(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED")
created := dataAs[model.User](t, requestWithAuthorization(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, "Bearer user-token", 201, "CREATED"))
@@ -103,7 +104,22 @@ func dataAs[T any](t *testing.T, value envelope) T {
}
func TestUserCRUD(t *testing.T) {
app := testApp()
for _, adapter := range []struct {
name string
newRouter func() httpx.Router
}{
{"nethttp", func() httpx.Router { return netrouter.New() }},
} {
t.Run(adapter.name, func(t *testing.T) {
application := omni.NewWithRouter(adapter.newRouter())
registerRoutes(application, repository.NewMemoryUsers(), testLogger(io.Discard), nil, nil)
checkUserCRUD(t, application)
})
}
}
func checkUserCRUD(t *testing.T, app *omni.App) {
t.Helper()
health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK"))
if health["status"] != "ok" {
t.Fatalf("unexpected health: %v", health)
@@ -207,7 +223,7 @@ func TestErrorTranslationAndRequestLogging(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
var logs bytes.Buffer
app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs), omni.WithMode("test"))
app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs))
result := request(t, app, "GET", "/api/v1/users/1", "", test.status, test.code)
if strings.Contains(result.Message, test.err.Error()) || len(result.Data) != 0 {
t.Fatalf("internal error details leaked: %+v", result)

19
go.mod
View File

@@ -3,36 +3,19 @@ module git.yuntuo.hk/k2safe/go-omni
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/go-sql-driver/mysql v1.9.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

61
go.sum
View File

@@ -1,22 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -27,67 +14,19 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -18,9 +18,11 @@ type Handler func(*Context) error
type Middleware func(Handler) Handler
// Router is the small contract the application needs from a network layer.
// The default implementation is backed by Gin, but another router or a
// future GoOmni transport can implement this interface without changing
// business handlers.
// The default implementation is backed by the standard library, but another
// router or a future GoOmni transport can implement this interface without
// changing business handlers. Register routes before serving requests. Implementations
// should pass transport/transporttest.Run; see transport/README.md for the
// portable path, method, fallback and context-propagation rules.
type Router interface {
http.Handler
Handle(method, path string, handler http.Handler)
@@ -76,7 +78,8 @@ func RoutePattern(ctx context.Context) string {
}
// Context provides the business-facing request and response API. It contains
// only standard-library HTTP types, so it remains usable when Gin is replaced.
// only standard-library HTTP types, so it remains usable when the network
// implementation is replaced.
type Context struct {
request *http.Request
writer http.ResponseWriter
@@ -162,16 +165,25 @@ func (c *Context) BindURI(value any) error { return validate.URI(c.params, value
// JSON is an escape hatch for responses that do not use the standard envelope.
func (c *Context) JSON(status int, value any) error {
if c.responded {
return nil
}
if c.writer == nil {
return apierrors.Internal(nil)
}
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.writer.WriteHeader(status)
if err := json.NewEncoder(c.writer).Encode(value); err != nil {
// Encode before committing headers so an invalid value can still produce
// the standard 500 response on every adapter.
data, err := json.Marshal(value)
if err != nil {
return apierrors.Internal(err)
}
c.responded = true
c.status = status
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.writer.WriteHeader(status)
if _, err := c.writer.Write(append(data, '\n')); err != nil {
return apierrors.Internal(err)
}
return nil
}
@@ -186,9 +198,15 @@ func (c *Context) Accepted(data any) error {
}
func (c *Context) NoContent() error {
c.writer.WriteHeader(http.StatusNoContent)
if c.responded {
return nil
}
if c.writer == nil {
return apierrors.Internal(nil)
}
c.responded = true
c.status = http.StatusNoContent
c.writer.WriteHeader(http.StatusNoContent)
return nil
}
@@ -213,7 +231,8 @@ func (c *Context) Status() int {
func (c *Context) Responded() bool { return c.responded }
// WriteError writes a stable public error response. Internal causes are not
// serialized and a partially written response is preserved.
// serialized. Responses committed through Context methods are preserved;
// direct Raw writes are outside this response-tracking contract.
func (c *Context) WriteError(err error) {
if c.responded {
return

678
internal/perfcheck/main.go Normal file
View File

@@ -0,0 +1,678 @@
// perfcheck compares real TCP servers in separate processes. It is a local
// measurement tool, not part of the framework's public CLI or server API.
package main
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"math/big"
"math/bits"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
const expectedBody = `{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"perfCheck"}`
type snapshot struct {
Address string `json:"address,omitempty"`
HeapAlloc uint64 `json:"heapAlloc"`
HeapInuse uint64 `json:"heapInuse"`
StackInuse uint64 `json:"stackInuse"`
TotalAlloc uint64 `json:"totalAlloc"`
Mallocs uint64 `json:"mallocs"`
NumGC uint32 `json:"numGc"`
Goroutines int `json:"goroutines"`
Connections int64 `json:"connections"`
Accepted int64 `json:"accepted"`
FileDescriptors int `json:"fileDescriptors,omitempty"`
}
type result struct {
Adapter string `json:"adapter"`
Scenario string `json:"scenario"`
Concurrency int `json:"concurrency"`
Run int `json:"run"`
Requests uint64 `json:"requests,omitempty"`
Errors uint64 `json:"errors"`
FirstError string `json:"firstError,omitempty"`
ElapsedSeconds float64 `json:"elapsedSeconds,omitempty"`
RequestsPerSecond float64 `json:"requestsPerSecond,omitempty"`
P50Millis float64 `json:"p50Millis,omitempty"`
P95Millis float64 `json:"p95Millis,omitempty"`
P99Millis float64 `json:"p99Millis,omitempty"`
ServerBytesPerRequest float64 `json:"serverBytesPerRequest,omitempty"`
ServerAllocsPerRequest float64 `json:"serverAllocsPerRequest,omitempty"`
SampledPeakHeapAlloc uint64 `json:"sampledPeakHeapAlloc,omitempty"`
SampledPeakFileDescriptors int `json:"sampledPeakFileDescriptors,omitempty"`
IdleHeapDelta int64 `json:"idleHeapDelta,omitempty"`
IdleHeapPerConnection float64 `json:"idleHeapPerConnection,omitempty"`
Before snapshot `json:"before"`
During snapshot `json:"during"`
After snapshot `json:"after"`
AfterReclaim snapshot `json:"afterReclaim"`
}
type report struct {
CreatedAt string `json:"createdAt"`
GoVersion string `json:"goVersion"`
Platform string `json:"platform"`
GOMAXPROCS int `json:"gomaxprocs"`
Duration string `json:"duration"`
TLS bool `json:"tls"`
Notes []string `json:"notes"`
Results []result `json:"results"`
}
type trackedListener struct {
net.Listener
active, accepted atomic.Int64
}
type trackedConn struct {
net.Conn
once sync.Once
active *atomic.Int64
}
func (l *trackedListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
l.active.Add(1)
l.accepted.Add(1)
return &trackedConn{Conn: c, active: &l.active}, nil
}
func (c *trackedConn) Close() error {
err := c.Conn.Close()
c.once.Do(func() { c.active.Add(-1) })
return err
}
func testTLSConfig() (*tls.Config, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
if err != nil {
return nil, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: now.Add(-time.Minute),
NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}},
MinVersion: tls.VersionTLS12,
}, nil
}
func main() {
serverMode := flag.String("server", "", "internal server subprocess: nethttp or native")
tlsEnabled := flag.Bool("tls", false, "run the comparison over TLS")
duration := flag.Duration("duration", 2*time.Second, "measured duration per traffic case")
concurrencyText := flag.String("concurrency", "1,32,128", "traffic concurrency levels")
idleText := flag.String("idle", "128,1024", "established idle Keep-Alive connection levels")
runs := flag.Int("count", 3, "repetitions; adapter order alternates each repetition")
output := flag.String("output", "", "optional JSON report path")
flag.Parse()
var err error
if *serverMode != "" {
err = serve(*serverMode, *tlsEnabled)
} else {
err = compare(*duration, *concurrencyText, *idleText, *runs, *output, *tlsEnabled)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func serve(adapter string, tlsEnabled bool) error {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
tracked := &trackedListener{Listener: listener}
var serveListener net.Listener = tracked
if tlsEnabled {
config, configErr := testTLSConfig()
if configErr != nil {
listener.Close()
return configErr
}
serveListener = tls.NewListener(tracked, config)
}
application := omni.New()
application.GET("/health", func(c *omni.Context) error {
return c.OK(map[string]string{"status": "ok"})
})
done := make(chan error, 1)
var shutdown func(context.Context) error
switch adapter {
case "native":
server := native.New(application)
server.MaxConnections = 8192
server.MaxConcurrentRequests = 4096
shutdown = server.Shutdown
go func() { done <- server.Serve(context.Background(), serveListener) }()
case "nethttp":
server := &http.Server{
Handler: application, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: time.Minute, MaxHeaderBytes: 1 << 20,
}
shutdown = server.Shutdown
go func() {
err := server.Serve(serveListener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
done <- err
}()
default:
listener.Close()
return fmt.Errorf("unknown server %q", adapter)
}
stop := func() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdown(ctx); err != nil {
return err
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
encoder := json.NewEncoder(os.Stdout)
if err := encoder.Encode(snapshot{Address: listener.Addr().String()}); err != nil {
return err
}
decoder := json.NewDecoder(os.Stdin)
for {
var command string
if err := decoder.Decode(&command); err != nil || command == "stop" {
return stop()
}
if command == "gc" {
runtime.GC()
}
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
if err := encoder.Encode(snapshot{
HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
StackInuse: memory.StackInuse, TotalAlloc: memory.TotalAlloc,
Mallocs: memory.Mallocs, NumGC: memory.NumGC,
Goroutines: runtime.NumGoroutine(), Connections: tracked.active.Load(), Accepted: tracked.accepted.Load(),
FileDescriptors: openFileDescriptorCount(),
}); err != nil {
return err
}
}
}
func openFileDescriptorCount() int {
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
return 0
}
return len(entries)
}
type child struct {
cmd *exec.Cmd
input io.WriteCloser
encoder *json.Encoder
decoder *json.Decoder
address string
tls bool
cancel context.CancelFunc
closed bool
}
func startChild(adapter string) (*child, error) {
return startChildMode(adapter, false)
}
func startChildMode(adapter string, tlsEnabled bool) (*child, error) {
executable, err := os.Executable()
if err != nil {
return nil, err
}
// This bounds even a broken server or protocol read; it is not a load
// duration. Keep it above the supported 30-minute soak plus drain time.
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Minute)
args := []string{"--server", adapter}
if tlsEnabled {
args = append(args, "--tls")
}
c := &child{cmd: exec.CommandContext(ctx, executable, args...), cancel: cancel, tls: tlsEnabled}
c.cmd.Stderr = os.Stderr
if c.input, err = c.cmd.StdinPipe(); err != nil {
cancel()
return nil, err
}
output, err := c.cmd.StdoutPipe()
if err != nil {
cancel()
c.input.Close()
return nil, err
}
if err := c.cmd.Start(); err != nil {
cancel()
c.input.Close()
output.Close()
return nil, err
}
c.encoder, c.decoder = json.NewEncoder(c.input), json.NewDecoder(output)
var ready snapshot
if err := c.decoder.Decode(&ready); err != nil {
cancel()
c.cmd.Wait()
return nil, err
}
c.address = ready.Address
return c, nil
}
func (c *child) snapshot(gc bool) (snapshot, error) {
command := "snapshot"
if gc {
command = "gc"
}
var value snapshot
if err := c.encoder.Encode(command); err != nil {
return value, err
}
err := c.decoder.Decode(&value)
return value, err
}
func (c *child) close() error {
if c.closed {
return nil
}
c.closed = true
defer c.cancel()
err := c.encoder.Encode("stop")
c.input.Close()
return errors.Join(err, c.cmd.Wait())
}
// Histograms use 16 subdivisions per power of two. Percentiles are bucket
// upper bounds (at most about 6.25% rounding), with bounded client memory.
type histogram [1024]uint64
func (h *histogram) add(d time.Duration) {
n := uint64(max(d.Nanoseconds(), 1))
exponent := bits.Len64(n) - 1
base := uint64(1) << exponent
sub := (n - base) * 16 / base
h[exponent*16+int(sub)]++
}
func (h *histogram) percentile(count uint64, percent uint64) float64 {
target := (count*percent + 99) / 100
if target == 0 {
return 0
}
var seen uint64
for bucket, n := range h {
seen += n
if seen >= target {
base := uint64(1) << (bucket / 16)
return float64(base+uint64(bucket%16+1)*base/16) / float64(time.Millisecond)
}
}
return 0
}
func exchange(client *http.Client, address string) error {
return exchangeMode(client, address, false)
}
func exchangeMode(client *http.Client, address string, tlsEnabled bool) error {
scheme := "http"
if tlsEnabled {
scheme = "https"
}
req, err := http.NewRequest(http.MethodGet, scheme+"://"+address+"/health", nil)
if err != nil {
return err
}
req.Header.Set("X-Request-ID", "perfCheck")
response, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
closeErr := response.Body.Close()
if response.StatusCode != 200 || response.Header.Get("X-Request-ID") != "perfCheck" || strings.TrimSpace(string(body)) != expectedBody {
return fmt.Errorf("invalid response status=%d body=%q", response.StatusCode, body)
}
return errors.Join(readErr, closeErr)
}
func traffic(c *child, concurrency int, duration time.Duration) (result, error) {
r := result{Scenario: "traffic", Concurrency: concurrency}
transport := &http.Transport{
MaxIdleConns: concurrency, MaxIdleConnsPerHost: concurrency,
MaxConnsPerHost: concurrency, IdleConnTimeout: time.Minute,
}
if c.tls {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test certificate is self-signed
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
defer client.CloseIdleConnections()
// Warm every worker before forcing a baseline GC in the server.
warmErrors := make(chan error, concurrency)
for i := 0; i < concurrency; i++ {
go func() { warmErrors <- exchangeMode(client, c.address, c.tls) }()
}
for i := 0; i < concurrency; i++ {
if err := <-warmErrors; err != nil {
return r, err
}
}
var err error
if r.Before, err = c.snapshot(true); err != nil {
return r, err
}
type workerResult struct {
latencies histogram
count, errors uint64
firstError string
}
workers := make([]workerResult, concurrency)
start := make(chan struct{})
var group sync.WaitGroup
var deadline time.Time
for i := range workers {
group.Add(1)
go func(w *workerResult) {
defer group.Done()
<-start
for time.Now().Before(deadline) {
began := time.Now()
err := exchangeMode(client, c.address, c.tls)
w.latencies.add(time.Since(began))
w.count++
if err != nil {
w.errors++
if w.firstError == "" {
w.firstError = err.Error()
}
}
}
}(&workers[i])
}
done := make(chan struct{})
go func() { group.Wait(); close(done) }()
begin := time.Now()
deadline = begin.Add(duration)
close(start)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
var sampleErr error
loading := true
for loading {
select {
case <-done:
loading = false
case <-ticker.C:
s, err := c.snapshot(false)
if err != nil {
sampleErr = err
} else if s.HeapAlloc > r.SampledPeakHeapAlloc {
r.SampledPeakHeapAlloc = s.HeapAlloc
}
if s.FileDescriptors > r.SampledPeakFileDescriptors {
r.SampledPeakFileDescriptors = s.FileDescriptors
}
}
}
r.ElapsedSeconds = time.Since(begin).Seconds()
if sampleErr != nil {
return r, sampleErr
}
var latencies histogram
for _, w := range workers {
r.Requests += w.count
r.Errors += w.errors
if r.FirstError == "" {
r.FirstError = w.firstError
}
for i, n := range w.latencies {
latencies[i] += n
}
}
if r.During, err = c.snapshot(false); err != nil {
return r, err
}
r.SampledPeakHeapAlloc = max(r.SampledPeakHeapAlloc, r.During.HeapAlloc)
r.SampledPeakFileDescriptors = max(r.SampledPeakFileDescriptors, r.During.FileDescriptors)
if r.Requests == 0 {
return r, errors.New("no requests completed")
}
r.RequestsPerSecond = float64(r.Requests-r.Errors) / r.ElapsedSeconds
r.P50Millis = latencies.percentile(r.Requests, 50)
r.P95Millis = latencies.percentile(r.Requests, 95)
r.P99Millis = latencies.percentile(r.Requests, 99)
r.ServerBytesPerRequest = float64(r.During.TotalAlloc-r.Before.TotalAlloc) / float64(r.Requests)
r.ServerAllocsPerRequest = float64(r.During.Mallocs-r.Before.Mallocs) / float64(r.Requests)
client.CloseIdleConnections()
r.After, err = drained(c)
if err == nil {
r.AfterReclaim, err = c.snapshot(true)
}
return r, err
}
func idle(c *child, count int) (result, error) {
r := result{Scenario: "idle", Concurrency: count}
var err error
if r.Before, err = c.snapshot(true); err != nil {
return r, err
}
connections := make([]net.Conn, 0, count)
defer func() {
for _, conn := range connections {
conn.Close()
}
}()
for i := 0; i < count; i++ {
conn, err := net.DialTimeout("tcp", c.address, 5*time.Second)
if err != nil {
return r, err
}
if c.tls {
secure := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: "localhost"}) // test certificate is self-signed
conn = secure
}
connections = append(connections, conn)
conn.SetDeadline(time.Now().Add(5 * time.Second))
if _, err := io.WriteString(conn, "GET /health HTTP/1.1\r\nHost: localhost\r\nX-Request-ID: perfCheck\r\n\r\n"); err != nil {
return r, err
}
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
if err != nil {
return r, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != 200 || strings.TrimSpace(string(body)) != expectedBody || response.Close {
return r, fmt.Errorf("invalid Keep-Alive response: status=%d error=%v", response.StatusCode, readErr)
}
conn.SetDeadline(time.Time{})
}
if r.During, err = c.snapshot(true); err != nil {
return r, err
}
if r.During.Connections != int64(count) {
return r, fmt.Errorf("idle connections: want %d, have %d", count, r.During.Connections)
}
if r.During.FileDescriptors > 0 && r.Before.FileDescriptors > 0 && r.During.FileDescriptors < r.Before.FileDescriptors+count {
return r, fmt.Errorf("idle file descriptors: want at least %d, have %d", r.Before.FileDescriptors+count, r.During.FileDescriptors)
}
r.IdleHeapDelta = int64(r.During.HeapAlloc) - int64(r.Before.HeapAlloc)
r.IdleHeapPerConnection = float64(r.IdleHeapDelta) / float64(count)
for _, conn := range connections {
conn.Close()
}
connections = nil
r.After, err = drained(c)
if err == nil {
r.AfterReclaim, err = c.snapshot(true)
}
return r, err
}
func drained(c *child) (snapshot, error) {
deadline := time.Now().Add(5 * time.Second)
for {
s, err := c.snapshot(false)
if err != nil {
return s, err
}
if s.Connections == 0 {
return c.snapshot(true)
}
if time.Now().After(deadline) {
return s, fmt.Errorf("%d connections remained after clients closed", s.Connections)
}
time.Sleep(10 * time.Millisecond)
}
}
func levels(text string) ([]int, error) {
var values []int
if text == "" {
return values, nil
}
for _, item := range strings.Split(text, ",") {
n, err := strconv.Atoi(strings.TrimSpace(item))
if err != nil || n < 1 || n > 4096 {
return nil, fmt.Errorf("invalid level %q (expected 1..4096)", item)
}
values = append(values, n)
}
return values, nil
}
func compare(duration time.Duration, concurrencyText, idleText string, count int, output string, tlsEnabled bool) error {
concurrency, err := levels(concurrencyText)
if err != nil {
return err
}
idleLevels, err := levels(idleText)
if err != nil {
return err
}
if count < 1 || duration < 200*time.Millisecond || duration > 30*time.Minute || len(concurrency)+len(idleLevels) == 0 {
return errors.New("count must be positive, duration 200ms..30m, and at least one scenario selected")
}
report := report{
CreatedAt: time.Now().UTC().Format(time.RFC3339), GoVersion: runtime.Version(),
Platform: runtime.GOOS + "/" + runtime.GOARCH, GOMAXPROCS: runtime.GOMAXPROCS(0), Duration: duration.String(), TLS: tlsEnabled,
Notes: []string{
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with <=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"FileDescriptors is read from /proc/self/fd on Linux and omitted on other platforms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection.",
},
}
var caseErrors []error
for run := 1; run <= count; run++ {
adapters := []string{"nethttp", "native"}
if run%2 == 0 {
adapters[0], adapters[1] = adapters[1], adapters[0]
}
for _, scenario := range []struct {
name string
levels []int
}{{"traffic", concurrency}, {"idle", idleLevels}} {
for _, level := range scenario.levels {
for _, adapter := range adapters {
c, err := startChildMode(adapter, tlsEnabled)
if err != nil {
return err
}
var value result
if scenario.name == "traffic" {
value, err = traffic(c, level, duration)
} else {
value, err = idle(c, level)
}
err = errors.Join(err, c.close())
value.Adapter, value.Run = adapter, run
if err != nil {
value.Errors++
value.FirstError = err.Error()
}
report.Results = append(report.Results, value)
if value.Errors > 0 {
caseErrors = append(caseErrors, fmt.Errorf("%s %s/%d run %d: %s", adapter, scenario.name, level, run, value.FirstError))
}
if scenario.name == "traffic" {
fmt.Printf("%s concurrency=%d run=%d: %.0f req/s p95=%.3fms p99=%.3fms server=%.0f B/req errors=%d\n", adapter, level, run, value.RequestsPerSecond, value.P95Millis, value.P99Millis, value.ServerBytesPerRequest, value.Errors)
} else {
fmt.Printf("%s idle=%d run=%d: server heap delta=%.2f MiB (%.0f B/conn), goroutines=%d->%d->%d, fds=%d->%d->%d errors=%d\n", adapter, level, run, float64(value.IdleHeapDelta)/(1<<20), value.IdleHeapPerConnection, value.Before.Goroutines, value.During.Goroutines, value.After.Goroutines, value.Before.FileDescriptors, value.During.FileDescriptors, value.After.FileDescriptors, value.Errors)
}
}
}
}
}
if output != "" {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(output, append(data, '\n'), 0644); err != nil {
return err
}
fmt.Printf("Report: %s\n", output)
}
return errors.Join(caseErrors...)
}

View File

@@ -0,0 +1,75 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// A load generator must never report a fast failure page as good throughput.
func TestExchangeRejectsInvalidResponses(t *testing.T) {
for _, tt := range []struct {
name string
status int
body string
requestID string
truncated bool
wantOK bool
}{
{"valid", 200, expectedBody, "perfCheck", false, true},
{"overload", 503, "server busy", "perfCheck", false, false},
{"wrongPayload", 200, `{"data":"wrong"}`, "perfCheck", false, false},
{"missingRequestId", 200, expectedBody, "", false, false},
{"truncated", 200, expectedBody, "perfCheck", true, false},
} {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Request-ID") != "perfCheck" {
t.Error("client did not send fixed requestId")
}
w.Header().Set("X-Request-ID", tt.requestID)
if tt.truncated {
w.Header().Set("Content-Length", "4096")
}
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
err := exchange(server.Client(), strings.TrimPrefix(server.URL, "http://"))
if (err == nil) != tt.wantOK {
t.Fatalf("valid=%v, error=%v", tt.wantOK, err)
}
})
}
}
func TestExchangeModeTLS(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-ID", "perfCheck")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(expectedBody))
}))
defer server.Close()
if err := exchangeMode(server.Client(), strings.TrimPrefix(server.URL, "https://"), true); err != nil {
t.Fatal(err)
}
}
func TestPercentileBounds(t *testing.T) {
var h histogram
// Known ranks, including a tail much slower than the median.
for i := 1; i <= 100; i++ {
h.add(time.Duration(i) * time.Millisecond)
}
for _, p := range []uint64{50, 95, 99} {
value := h.percentile(100, p)
if value < float64(p) || value > float64(p)*1.0625 {
t.Fatalf("p%d=%gms outside advertised bucket rounding", p, value)
}
}
if value := h.percentile(0, 95); value != 0 {
t.Fatalf("empty histogram returned %g", value)
}
}

View File

@@ -1,6 +1,6 @@
# logx
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖 Gin:
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖具体网络库:
```go
import "git.yuntuo.hk/k2safe/go-omni/logx"

View File

@@ -1,5 +1,5 @@
// Package logx provides the application logger used by GoOmni and business
// packages. Its public API is deliberately independent of Gin so the same
// packages. Its public API is deliberately independent of the network layer so the same
// logger can be used from HTTP handlers, jobs and command line programs.
package logx

View File

@@ -31,8 +31,8 @@ func RequestID(header string) httpx.Middleware {
}
// Recovery converts panics from handlers and middleware into the standard
// internal error. The network adapter does not need a Gin-specific recovery
// hook for this behavior.
// internal error. Recovery belongs to GoOmni's transport-neutral middleware
// rather than a router-specific hook.
func Recovery() httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) (err error) {
@@ -55,7 +55,7 @@ func AccessLog() httpx.Middleware {
started := time.Now()
err := next(ctx)
status := ctx.Status()
if err != nil {
if err != nil && !ctx.Responded() {
status = apierrors.From(err).Status
}
logx.InfoContext(ctx.Context(), "http.request",

View File

@@ -0,0 +1,57 @@
package middleware_test
import (
"bytes"
"encoding/json"
"errors"
"net/http/httptest"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
)
func TestAccessLogReportsCommittedStatus(t *testing.T) {
for _, tc := range []struct {
name string
handler httpx.Handler
status int
}{
{"unwritten error", func(c *httpx.Context) error { return apierrors.NotFound("MISSING", "missing") }, 404},
{"encoding error", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500},
{"committed error", func(c *httpx.Context) error { _ = c.Created("saved"); return errors.New("later error") }, 201},
{"committed panic", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("later panic") }, 202},
} {
t.Run(tc.name, func(t *testing.T) {
var output bytes.Buffer
logger, err := logx.New(logx.Config{Format: "json", Writer: &output})
if err != nil {
t.Fatal(err)
}
logx.SetDefault(logger)
defer logx.SetDefault(nil)
req := httptest.NewRequest("GET", "/users/42", nil)
req.Header.Set("X-Request-ID", "logRequest")
req = req.WithContext(httpx.WithRoutePattern(req.Context(), "/users/:id"))
w := httptest.NewRecorder()
ctx := httpx.NewContext(req, w, nil)
chain := middleware.RequestID("X-Request-ID")(middleware.AccessLog()(middleware.Recovery()(tc.handler)))
if err := chain(ctx); err != nil {
ctx.WriteError(err)
}
var entry struct {
Status int
Route string
RequestID string `json:"requestId"`
}
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
t.Fatal(err)
}
if entry.Status != tc.status || w.Code != tc.status || entry.Route != "/users/:id" || entry.RequestID != "logRequest" {
t.Fatalf("log=%s response=%d; expected status %d and request metadata", &output, w.Code, tc.status)
}
})
}
}

View File

@@ -13,6 +13,7 @@ import (
)
type App = app.App
type Runner = app.Runner
type RouterGroup = app.RouterGroup
type Principal = auth.Principal
type Authenticator = auth.Authenticator
@@ -34,8 +35,6 @@ func NewWithRouter(router Router, options ...Option) *App {
return app.NewWithRouter(router, options...)
}
func WithMode(mode string) Option { return config.WithMode(mode) }
func WithRequestIDHeader(header string) Option { return config.WithRequestIDHeader(header) }
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }

265
performance/linuxSoak.json Normal file
View File

@@ -0,0 +1,265 @@
{
"createdAt": "2026-09-21T17:29:24Z",
"goVersion": "go1.25.14",
"platform": "linux/arm64",
"gomaxprocs": 4,
"duration": "30s",
"tls": false,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"FileDescriptors is read from /proc/self/fd on Linux and omitted on other platforms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4456189,
"errors": 0,
"elapsedSeconds": 30.001393348,
"requestsPerSecond": 148532.7347403705,
"p50Millis": 0.589824,
"p95Millis": 2.62144,
"p99Millis": 3.538944,
"serverBytesPerRequest": 4427.911252417705,
"serverAllocsPerRequest": 47.07614668049313,
"sampledPeakHeapAlloc": 6219064,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 2105600,
"heapInuse": 4202496,
"stackInuse": 1015808,
"totalAlloc": 4172144,
"mallocs": 30855,
"numGc": 2,
"goroutines": 58,
"connections": 56,
"accepted": 56,
"fileDescriptors": 63
},
"during": {
"heapAlloc": 3500920,
"heapInuse": 5947392,
"stackInuse": 2097152,
"totalAlloc": 19735781560,
"mallocs": 209811062,
"numGc": 6570,
"goroutines": 130,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2828640,
"heapInuse": 5054464,
"stackInuse": 1015808,
"totalAlloc": 19735811920,
"mallocs": 209811396,
"numGc": 6571,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1722584,
"heapInuse": 3530752,
"stackInuse": 1015808,
"totalAlloc": 19735822392,
"mallocs": 209811421,
"numGc": 6572,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4398474,
"errors": 0,
"elapsedSeconds": 30.000375472,
"requestsPerSecond": 146613.96501871088,
"p50Millis": 0.622592,
"p95Millis": 2.62144,
"p99Millis": 3.670016,
"serverBytesPerRequest": 4591.297385411395,
"serverAllocsPerRequest": 59.0589788640333,
"sampledPeakHeapAlloc": 4980920,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 2202712,
"heapInuse": 4120576,
"stackInuse": 1671168,
"totalAlloc": 4300920,
"mallocs": 32647,
"numGc": 2,
"goroutines": 131,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"during": {
"heapAlloc": 4382752,
"heapInuse": 6021120,
"stackInuse": 1638400,
"totalAlloc": 20199003096,
"mallocs": 259802030,
"numGc": 9032,
"goroutines": 131,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2260152,
"heapInuse": 4382720,
"stackInuse": 557056,
"totalAlloc": 20199025048,
"mallocs": 259802362,
"numGc": 9033,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1645880,
"heapInuse": 3489792,
"stackInuse": 557056,
"totalAlloc": 20199035520,
"mallocs": 259802387,
"numGc": 9034,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 5162344,
"idleHeapPerConnection": 10082.703125,
"before": {
"heapAlloc": 1516408,
"heapInuse": 3301376,
"stackInuse": 458752,
"totalAlloc": 3006544,
"mallocs": 23238,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 6678752,
"heapInuse": 8675328,
"stackInuse": 4718592,
"totalAlloc": 10467200,
"mallocs": 59319,
"numGc": 4,
"goroutines": 514,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 6108264,
"heapInuse": 8069120,
"stackInuse": 491520,
"totalAlloc": 10563168,
"mallocs": 60468,
"numGc": 5,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1806072,
"heapInuse": 3629056,
"stackInuse": 491520,
"totalAlloc": 10573944,
"mallocs": 60497,
"numGc": 6,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 2692536,
"idleHeapPerConnection": 5258.859375,
"before": {
"heapAlloc": 1511104,
"heapInuse": 3309568,
"stackInuse": 425984,
"totalAlloc": 3001496,
"mallocs": 23227,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 4203640,
"heapInuse": 6283264,
"stackInuse": 4718592,
"totalAlloc": 8087336,
"mallocs": 58784,
"numGc": 4,
"goroutines": 515,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 4233984,
"heapInuse": 6316032,
"stackInuse": 458752,
"totalAlloc": 8182368,
"mallocs": 60013,
"numGc": 5,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1804784,
"heapInuse": 3686400,
"stackInuse": 458752,
"totalAlloc": 8193144,
"mallocs": 60042,
"numGc": 6,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
}
]
}

View File

@@ -0,0 +1,265 @@
{
"createdAt": "2026-09-21T17:27:47Z",
"goVersion": "go1.25.14",
"platform": "linux/arm64",
"gomaxprocs": 4,
"duration": "30s",
"tls": true,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"FileDescriptors is read from /proc/self/fd on Linux and omitted on other platforms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4442303,
"errors": 0,
"elapsedSeconds": 30.001232972,
"requestsPerSecond": 148070.68109987277,
"p50Millis": 0.589824,
"p95Millis": 2.62144,
"p99Millis": 3.670016,
"serverBytesPerRequest": 4475.325703807237,
"serverAllocsPerRequest": 49.07248492504901,
"sampledPeakHeapAlloc": 7609472,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 3340488,
"heapInuse": 5988352,
"stackInuse": 2195456,
"totalAlloc": 9430136,
"mallocs": 83639,
"numGc": 5,
"goroutines": 124,
"connections": 122,
"accepted": 122,
"fileDescriptors": 135
},
"during": {
"heapAlloc": 6485192,
"heapInuse": 8716288,
"stackInuse": 1998848,
"totalAlloc": 19890182936,
"mallocs": 218078486,
"numGc": 5603,
"goroutines": 130,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2853976,
"heapInuse": 5251072,
"stackInuse": 884736,
"totalAlloc": 19890213312,
"mallocs": 218078820,
"numGc": 5604,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1745496,
"heapInuse": 3686400,
"stackInuse": 884736,
"totalAlloc": 19890215312,
"mallocs": 218078842,
"numGc": 5605,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4491731,
"errors": 0,
"elapsedSeconds": 30.000919805,
"requestsPerSecond": 149719.77623337405,
"p50Millis": 0.622592,
"p95Millis": 2.62144,
"p99Millis": 3.538944,
"serverBytesPerRequest": 4807.806184297323,
"serverAllocsPerRequest": 61.06011045630293,
"sampledPeakHeapAlloc": 5859960,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 2685024,
"heapInuse": 5136384,
"stackInuse": 2621440,
"totalAlloc": 8854080,
"mallocs": 83570,
"numGc": 5,
"goroutines": 125,
"connections": 122,
"accepted": 122,
"fileDescriptors": 135
},
"during": {
"heapAlloc": 3345480,
"heapInuse": 5980160,
"stackInuse": 1605632,
"totalAlloc": 21604226160,
"mallocs": 274349161,
"numGc": 7802,
"goroutines": 131,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2764288,
"heapInuse": 5341184,
"stackInuse": 1605632,
"totalAlloc": 21604248208,
"mallocs": 274349494,
"numGc": 7803,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1654056,
"heapInuse": 3506176,
"stackInuse": 1605632,
"totalAlloc": 21604258680,
"mallocs": 274349519,
"numGc": 7804,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 7274360,
"idleHeapPerConnection": 14207.734375,
"before": {
"heapAlloc": 1515704,
"heapInuse": 3301376,
"stackInuse": 425984,
"totalAlloc": 3023896,
"mallocs": 23663,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 8790064,
"heapInuse": 12066816,
"stackInuse": 4718592,
"totalAlloc": 29570320,
"mallocs": 272972,
"numGc": 9,
"goroutines": 514,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 6138608,
"heapInuse": 8765440,
"stackInuse": 458752,
"totalAlloc": 29675376,
"mallocs": 274141,
"numGc": 10,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1820824,
"heapInuse": 3825664,
"stackInuse": 458752,
"totalAlloc": 29677376,
"mallocs": 274163,
"numGc": 11,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 4689576,
"idleHeapPerConnection": 9159.328125,
"before": {
"heapAlloc": 1520864,
"heapInuse": 3284992,
"stackInuse": 458752,
"totalAlloc": 3029160,
"mallocs": 23661,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 6210440,
"heapInuse": 9314304,
"stackInuse": 8880128,
"totalAlloc": 27150344,
"mallocs": 271905,
"numGc": 10,
"goroutines": 515,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 6230688,
"heapInuse": 9330688,
"stackInuse": 491520,
"totalAlloc": 27245184,
"mallocs": 273078,
"numGc": 11,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1817296,
"heapInuse": 3702784,
"stackInuse": 491520,
"totalAlloc": 27255960,
"mallocs": 273107,
"numGc": 12,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
}
]
}

1735
performance/tcpBaseline.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,592 @@
{
"createdAt": "2026-09-20T21:27:15Z",
"goVersion": "go1.25.0",
"platform": "darwin/arm64",
"gomaxprocs": 12,
"duration": "5s",
"tls": true,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 86935,
"errors": 0,
"elapsedSeconds": 5.000022,
"requestsPerSecond": 17386.92349753661,
"p50Millis": 0.0512,
"p95Millis": 0.1024,
"p99Millis": 0.180224,
"serverBytesPerRequest": 4514.537988152068,
"serverAllocsPerRequest": 49.1346753321447,
"sampledPeakHeapAlloc": 3349712,
"before": {
"heapAlloc": 1678936,
"heapInuse": 3661824,
"stackInuse": 655360,
"totalAlloc": 3167032,
"mallocs": 24353,
"numGc": 2,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 3337216,
"heapInuse": 5341184,
"stackInuse": 753664,
"totalAlloc": 395638392,
"mallocs": 4295876,
"numGc": 238,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1774168,
"heapInuse": 3792896,
"stackInuse": 688128,
"totalAlloc": 395642616,
"mallocs": 4295888,
"numGc": 239,
"goroutines": 2,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1741008,
"heapInuse": 3620864,
"stackInuse": 688128,
"totalAlloc": 395644800,
"mallocs": 4295894,
"numGc": 240,
"goroutines": 2,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 91798,
"errors": 0,
"elapsedSeconds": 5.000019125,
"requestsPerSecond": 18359.52977479861,
"p50Millis": 0.049152,
"p95Millis": 0.090112,
"p99Millis": 0.16384,
"serverBytesPerRequest": 4635.560426153075,
"serverAllocsPerRequest": 60.11798731998518,
"sampledPeakHeapAlloc": 3267488,
"before": {
"heapAlloc": 1653880,
"heapInuse": 3678208,
"stackInuse": 622592,
"totalAlloc": 3145792,
"mallocs": 24330,
"numGc": 2,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 1828360,
"heapInuse": 4022272,
"stackInuse": 688128,
"totalAlloc": 428680968,
"mallocs": 5543041,
"numGc": 264,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1687208,
"heapInuse": 3817472,
"stackInuse": 655360,
"totalAlloc": 428685256,
"mallocs": 5543054,
"numGc": 265,
"goroutines": 3,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1668072,
"heapInuse": 3588096,
"stackInuse": 655360,
"totalAlloc": 428687440,
"mallocs": 5543060,
"numGc": 266,
"goroutines": 3,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 443035,
"errors": 0,
"elapsedSeconds": 5.000301084,
"requestsPerSecond": 88601.66469127762,
"p50Millis": 0.278528,
"p95Millis": 1.015808,
"p99Millis": 1.507328,
"serverBytesPerRequest": 4524.947022244292,
"serverAllocsPerRequest": 49.153904319071856,
"sampledPeakHeapAlloc": 4957328,
"before": {
"heapAlloc": 2174976,
"heapInuse": 5406720,
"stackInuse": 1277952,
"totalAlloc": 4859520,
"mallocs": 39778,
"numGc": 4,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 3437936,
"heapInuse": 6717440,
"stackInuse": 2490368,
"totalAlloc": 2009569424,
"mallocs": 21816678,
"numGc": 1167,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 2301792,
"heapInuse": 4833280,
"stackInuse": 2195456,
"totalAlloc": 2009575408,
"mallocs": 21816709,
"numGc": 1168,
"goroutines": 2,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1983832,
"heapInuse": 3915776,
"stackInuse": 2195456,
"totalAlloc": 2009577336,
"mallocs": 21816714,
"numGc": 1169,
"goroutines": 2,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 422203,
"errors": 0,
"elapsedSeconds": 5.0001875,
"requestsPerSecond": 84437.43359624014,
"p50Millis": 0.294912,
"p95Millis": 1.015808,
"p99Millis": 1.507328,
"serverBytesPerRequest": 4655.5327176737255,
"serverAllocsPerRequest": 60.24485614739829,
"sampledPeakHeapAlloc": 3953128,
"before": {
"heapAlloc": 1991704,
"heapInuse": 5013504,
"stackInuse": 1081344,
"totalAlloc": 4644928,
"mallocs": 39578,
"numGc": 3,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 2969168,
"heapInuse": 6266880,
"stackInuse": 1212416,
"totalAlloc": 1970224808,
"mallocs": 25475137,
"numGc": 1687,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 2053344,
"heapInuse": 5062656,
"stackInuse": 851968,
"totalAlloc": 1970231512,
"mallocs": 25475175,
"numGc": 1688,
"goroutines": 3,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1754136,
"heapInuse": 3792896,
"stackInuse": 851968,
"totalAlloc": 1970233696,
"mallocs": 25475181,
"numGc": 1689,
"goroutines": 3,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 564759,
"errors": 0,
"elapsedSeconds": 5.000520667,
"requestsPerSecond": 112940.03916972512,
"p50Millis": 0.8192,
"p95Millis": 3.2768,
"p99Millis": 4.718592,
"serverBytesPerRequest": 4499.995148373023,
"serverAllocsPerRequest": 49.13783401415471,
"sampledPeakHeapAlloc": 7449240,
"before": {
"heapAlloc": 3409760,
"heapInuse": 6938624,
"stackInuse": 2457600,
"totalAlloc": 9389504,
"mallocs": 81651,
"numGc": 8,
"goroutines": 118,
"connections": 116,
"accepted": 116
},
"during": {
"heapAlloc": 5261072,
"heapInuse": 9068544,
"stackInuse": 3276800,
"totalAlloc": 2550802264,
"mallocs": 27832685,
"numGc": 775,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 3142160,
"heapInuse": 6086656,
"stackInuse": 2162688,
"totalAlloc": 2550814744,
"mallocs": 27832752,
"numGc": 776,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 2014424,
"heapInuse": 4038656,
"stackInuse": 2162688,
"totalAlloc": 2550816928,
"mallocs": 27832758,
"numGc": 777,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 571218,
"errors": 0,
"elapsedSeconds": 5.000520459,
"requestsPerSecond": 114231.70941574985,
"p50Millis": 0.720896,
"p95Millis": 3.538944,
"p99Millis": 5.24288,
"serverBytesPerRequest": 4630.001169430935,
"serverAllocsPerRequest": 60.118471406713375,
"sampledPeakHeapAlloc": 6594168,
"before": {
"heapAlloc": 2900712,
"heapInuse": 6209536,
"stackInuse": 1966080,
"totalAlloc": 9196776,
"mallocs": 86400,
"numGc": 6,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"during": {
"heapAlloc": 5161328,
"heapInuse": 8462336,
"stackInuse": 1998848,
"totalAlloc": 2653936784,
"mallocs": 34427153,
"numGc": 1026,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2950048,
"heapInuse": 6201344,
"stackInuse": 851968,
"totalAlloc": 2653948208,
"mallocs": 34427217,
"numGc": 1027,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1824888,
"heapInuse": 3809280,
"stackInuse": 851968,
"totalAlloc": 2653950392,
"mallocs": 34427223,
"numGc": 1028,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1857928,
"idleHeapPerConnection": 14515.0625,
"before": {
"heapAlloc": 1632120,
"heapInuse": 3538944,
"stackInuse": 524288,
"totalAlloc": 3074624,
"mallocs": 23744,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 3490048,
"heapInuse": 5808128,
"stackInuse": 1835008,
"totalAlloc": 9802696,
"mallocs": 86273,
"numGc": 5,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2814128,
"heapInuse": 5062656,
"stackInuse": 720896,
"totalAlloc": 9818288,
"mallocs": 86380,
"numGc": 6,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1732536,
"heapInuse": 3661824,
"stackInuse": 720896,
"totalAlloc": 9820216,
"mallocs": 86385,
"numGc": 7,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1212976,
"idleHeapPerConnection": 9476.375,
"before": {
"heapAlloc": 1626208,
"heapInuse": 3448832,
"stackInuse": 491520,
"totalAlloc": 3068760,
"mallocs": 23727,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 2839184,
"heapInuse": 5210112,
"stackInuse": 1802240,
"totalAlloc": 9147832,
"mallocs": 85868,
"numGc": 5,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2838168,
"heapInuse": 5169152,
"stackInuse": 688128,
"totalAlloc": 9163568,
"mallocs": 85970,
"numGc": 6,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1729792,
"heapInuse": 3563520,
"stackInuse": 688128,
"totalAlloc": 9166056,
"mallocs": 85980,
"numGc": 7,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 1024,
"run": 1,
"errors": 0,
"idleHeapDelta": 14527832,
"idleHeapPerConnection": 14187.3359375,
"before": {
"heapAlloc": 1626760,
"heapInuse": 3497984,
"stackInuse": 491520,
"totalAlloc": 3069240,
"mallocs": 23740,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 16154592,
"heapInuse": 20807680,
"stackInuse": 9207808,
"totalAlloc": 56229808,
"mallocs": 522226,
"numGc": 12,
"goroutines": 1026,
"connections": 1024,
"accepted": 1024
},
"after": {
"heapAlloc": 10815896,
"heapInuse": 14376960,
"stackInuse": 720896,
"totalAlloc": 56293624,
"mallocs": 522449,
"numGc": 13,
"goroutines": 2,
"connections": 0,
"accepted": 1024
},
"afterReclaim": {
"heapAlloc": 2202576,
"heapInuse": 4227072,
"stackInuse": 720896,
"totalAlloc": 56296208,
"mallocs": 522460,
"numGc": 14,
"goroutines": 2,
"connections": 0,
"accepted": 1024
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 1024,
"run": 1,
"errors": 0,
"idleHeapDelta": 9372528,
"idleHeapPerConnection": 9152.859375,
"before": {
"heapAlloc": 1625904,
"heapInuse": 3457024,
"stackInuse": 491520,
"totalAlloc": 3068472,
"mallocs": 23724,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 10998432,
"heapInuse": 14934016,
"stackInuse": 9240576,
"totalAlloc": 51165352,
"mallocs": 519121,
"numGc": 13,
"goroutines": 1027,
"connections": 1024,
"accepted": 1024
},
"after": {
"heapAlloc": 11019640,
"heapInuse": 15032320,
"stackInuse": 6553600,
"totalAlloc": 51226512,
"mallocs": 519340,
"numGc": 14,
"goroutines": 3,
"connections": 0,
"accepted": 1024
},
"afterReclaim": {
"heapAlloc": 2196104,
"heapInuse": 4153344,
"stackInuse": 6553600,
"totalAlloc": 51229000,
"mallocs": 519350,
"numGc": 15,
"goroutines": 3,
"connections": 0,
"accepted": 1024
}
}
]
}

View File

@@ -0,0 +1,592 @@
{
"createdAt": "2026-09-21T17:11:34Z",
"goVersion": "go1.25.14",
"platform": "linux/arm64",
"gomaxprocs": 4,
"duration": "3s",
"tls": true,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 36326,
"errors": 0,
"elapsedSeconds": 3.000028418,
"requestsPerSecond": 12108.551966390074,
"p50Millis": 0.08192,
"p95Millis": 0.1024,
"p99Millis": 0.139264,
"serverBytesPerRequest": 4475.917854979904,
"serverAllocsPerRequest": 49.04346748885096,
"sampledPeakHeapAlloc": 3669264,
"before": {
"heapAlloc": 1555696,
"heapInuse": 3522560,
"stackInuse": 491520,
"totalAlloc": 3107240,
"mallocs": 24268,
"numGc": 2,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 1789280,
"heapInuse": 3874816,
"stackInuse": 819200,
"totalAlloc": 165699432,
"mallocs": 1805821,
"numGc": 82,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1645816,
"heapInuse": 3629056,
"stackInuse": 753664,
"totalAlloc": 165700968,
"mallocs": 1805830,
"numGc": 83,
"goroutines": 2,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1625904,
"heapInuse": 3522560,
"stackInuse": 720896,
"totalAlloc": 165701872,
"mallocs": 1805836,
"numGc": 84,
"goroutines": 2,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 36410,
"errors": 0,
"elapsedSeconds": 3.000096335,
"requestsPerSecond": 12136.276950586656,
"p50Millis": 0.08192,
"p95Millis": 0.1024,
"p99Millis": 0.131072,
"serverBytesPerRequest": 4807.387201318319,
"serverAllocsPerRequest": 61.041444658060975,
"sampledPeakHeapAlloc": 3473824,
"before": {
"heapAlloc": 1546544,
"heapInuse": 3563520,
"stackInuse": 524288,
"totalAlloc": 3099040,
"mallocs": 24253,
"numGc": 2,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 1987016,
"heapInuse": 4014080,
"stackInuse": 524288,
"totalAlloc": 178136008,
"mallocs": 2246772,
"numGc": 96,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1557080,
"heapInuse": 3637248,
"stackInuse": 425984,
"totalAlloc": 178137480,
"mallocs": 2246784,
"numGc": 97,
"goroutines": 3,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1543112,
"heapInuse": 3473408,
"stackInuse": 425984,
"totalAlloc": 178138128,
"mallocs": 2246789,
"numGc": 98,
"goroutines": 3,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 297651,
"errors": 0,
"elapsedSeconds": 3.000748959,
"requestsPerSecond": 99192.23636061586,
"p50Millis": 0.172032,
"p95Millis": 1.179648,
"p99Millis": 2.097152,
"serverBytesPerRequest": 4478.059122932562,
"serverAllocsPerRequest": 49.0660605877353,
"sampledPeakHeapAlloc": 4147936,
"before": {
"heapAlloc": 2046152,
"heapInuse": 4554752,
"stackInuse": 884736,
"totalAlloc": 4736656,
"mallocs": 39461,
"numGc": 3,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 3132976,
"heapInuse": 5529600,
"stackInuse": 1212416,
"totalAlloc": 1337635432,
"mallocs": 14644023,
"numGc": 642,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 1946392,
"heapInuse": 4136960,
"stackInuse": 884736,
"totalAlloc": 1337638376,
"mallocs": 14644047,
"numGc": 643,
"goroutines": 2,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1657656,
"heapInuse": 3596288,
"stackInuse": 884736,
"totalAlloc": 1337639280,
"mallocs": 14644053,
"numGc": 644,
"goroutines": 2,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 271586,
"errors": 0,
"elapsedSeconds": 3.008536543,
"requestsPerSecond": 90271.79697448004,
"p50Millis": 0.172032,
"p95Millis": 1.114112,
"p99Millis": 2.097152,
"serverBytesPerRequest": 4810.125264188876,
"serverAllocsPerRequest": 61.068320900193676,
"sampledPeakHeapAlloc": 3706424,
"before": {
"heapAlloc": 1833336,
"heapInuse": 4136960,
"stackInuse": 1048576,
"totalAlloc": 4566744,
"mallocs": 39398,
"numGc": 3,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 2032480,
"heapInuse": 4661248,
"stackInuse": 851968,
"totalAlloc": 1310929424,
"mallocs": 16624699,
"numGc": 742,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 1875360,
"heapInuse": 4186112,
"stackInuse": 524288,
"totalAlloc": 1310933392,
"mallocs": 16624734,
"numGc": 743,
"goroutines": 3,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1593792,
"heapInuse": 3465216,
"stackInuse": 524288,
"totalAlloc": 1310934296,
"mallocs": 16624740,
"numGc": 744,
"goroutines": 3,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 322494,
"errors": 0,
"elapsedSeconds": 3.001132293,
"requestsPerSecond": 107457.44223012164,
"p50Millis": 0.589824,
"p95Millis": 2.752512,
"p99Millis": 25.165824,
"serverBytesPerRequest": 4475.7173528809835,
"serverAllocsPerRequest": 49.07075790557344,
"sampledPeakHeapAlloc": 7137248,
"before": {
"heapAlloc": 3503480,
"heapInuse": 6340608,
"stackInuse": 2228224,
"totalAlloc": 9426048,
"mallocs": 83598,
"numGc": 5,
"goroutines": 124,
"connections": 122,
"accepted": 122
},
"during": {
"heapAlloc": 6849936,
"heapInuse": 9125888,
"stackInuse": 2162688,
"totalAlloc": 1452818040,
"mallocs": 15908623,
"numGc": 415,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2816896,
"heapInuse": 5300224,
"stackInuse": 1048576,
"totalAlloc": 1452825560,
"mallocs": 15908667,
"numGc": 416,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1717496,
"heapInuse": 3710976,
"stackInuse": 1015808,
"totalAlloc": 1452826464,
"mallocs": 15908673,
"numGc": 417,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 299953,
"errors": 0,
"elapsedSeconds": 3.003243168,
"requestsPerSecond": 99876.36139359062,
"p50Millis": 0.622592,
"p95Millis": 2.752512,
"p99Millis": 32.505856,
"serverBytesPerRequest": 4817.3341823552355,
"serverAllocsPerRequest": 61.15272726060416,
"sampledPeakHeapAlloc": 5668056,
"before": {
"heapAlloc": 2118392,
"heapInuse": 4661248,
"stackInuse": 1605632,
"totalAlloc": 6143952,
"mallocs": 56351,
"numGc": 4,
"goroutines": 61,
"connections": 58,
"accepted": 58
},
"during": {
"heapAlloc": 3375320,
"heapInuse": 5996544,
"stackInuse": 1802240,
"totalAlloc": 1451117792,
"mallocs": 18399295,
"numGc": 528,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2766336,
"heapInuse": 5382144,
"stackInuse": 1769472,
"totalAlloc": 1451125632,
"mallocs": 18399341,
"numGc": 529,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1658104,
"heapInuse": 3588096,
"stackInuse": 1769472,
"totalAlloc": 1451126280,
"mallocs": 18399346,
"numGc": 530,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1838600,
"idleHeapPerConnection": 14364.0625,
"before": {
"heapAlloc": 1521656,
"heapInuse": 3260416,
"stackInuse": 458752,
"totalAlloc": 3029904,
"mallocs": 23674,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 3360256,
"heapInuse": 5718016,
"stackInuse": 1540096,
"totalAlloc": 9686296,
"mallocs": 86122,
"numGc": 5,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2682728,
"heapInuse": 4792320,
"stackInuse": 425984,
"totalAlloc": 9695376,
"mallocs": 86176,
"numGc": 6,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1604552,
"heapInuse": 3440640,
"stackInuse": 425984,
"totalAlloc": 9696584,
"mallocs": 86186,
"numGc": 7,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1191856,
"idleHeapPerConnection": 9311.375,
"before": {
"heapAlloc": 1521040,
"heapInuse": 3325952,
"stackInuse": 458752,
"totalAlloc": 3029464,
"mallocs": 23663,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 2712896,
"heapInuse": 4939776,
"stackInuse": 2555904,
"totalAlloc": 9083760,
"mallocs": 85842,
"numGc": 5,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2711312,
"heapInuse": 4907008,
"stackInuse": 425984,
"totalAlloc": 9091576,
"mallocs": 85898,
"numGc": 6,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1608416,
"heapInuse": 3416064,
"stackInuse": 425984,
"totalAlloc": 9092784,
"mallocs": 85908,
"numGc": 7,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 7269176,
"idleHeapPerConnection": 14197.609375,
"before": {
"heapAlloc": 1521080,
"heapInuse": 3383296,
"stackInuse": 458752,
"totalAlloc": 3029480,
"mallocs": 23669,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 8790256,
"heapInuse": 11960320,
"stackInuse": 4718592,
"totalAlloc": 29547624,
"mallocs": 272853,
"numGc": 9,
"goroutines": 514,
"connections": 512,
"accepted": 512
},
"after": {
"heapAlloc": 6121872,
"heapInuse": 8749056,
"stackInuse": 458752,
"totalAlloc": 29580224,
"mallocs": 272941,
"numGc": 10,
"goroutines": 2,
"connections": 0,
"accepted": 512
},
"afterReclaim": {
"heapAlloc": 1812232,
"heapInuse": 3817472,
"stackInuse": 458752,
"totalAlloc": 29581432,
"mallocs": 272951,
"numGc": 11,
"goroutines": 2,
"connections": 0,
"accepted": 512
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 4687096,
"idleHeapPerConnection": 9154.484375,
"before": {
"heapAlloc": 1520480,
"heapInuse": 3293184,
"stackInuse": 458752,
"totalAlloc": 3028752,
"mallocs": 23658,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 6207576,
"heapInuse": 9035776,
"stackInuse": 8880128,
"totalAlloc": 27129544,
"mallocs": 271820,
"numGc": 9,
"goroutines": 515,
"connections": 512,
"accepted": 512
},
"after": {
"heapAlloc": 6228280,
"heapInuse": 9027584,
"stackInuse": 491520,
"totalAlloc": 27168240,
"mallocs": 271921,
"numGc": 10,
"goroutines": 3,
"connections": 0,
"accepted": 512
},
"afterReclaim": {
"heapAlloc": 1812240,
"heapInuse": 3645440,
"stackInuse": 491520,
"totalAlloc": 27169448,
"mallocs": 271931,
"numGc": 11,
"goroutines": 3,
"connections": 0,
"accepted": 512
}
}
]
}

136
scripts/verifyBusiness.py Executable file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Verify a real generated business module across a released framework upgrade."""
import argparse
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from verifyScaffold import MODULE, run
def free_address():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return f"127.0.0.1:{listener.getsockname()[1]}"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.6.0-preview", help="Target framework release")
parser.add_argument("--from-version", default="v0.2.0", help="Version used before upgrade")
args = parser.parse_args()
env = dict(os.environ, GOWORK="off")
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"]))
with tempfile.TemporaryDirectory(prefix="go omni business ") as temporary:
root = Path(temporary).resolve()
env["GOBIN"] = str(root / "bin")
print(f"Installing released CLI {args.version}", flush=True)
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], root, env)
cli = root / "bin" / "go-omni"
project = root / "billing service"
run([
str(cli), "new", "--database", "memory", "--framework-version",
args.from_version, "--module", "example.com/billing", str(project),
], root, env)
run([args.go, "mod", "tidy"], project, env)
manifest = (project / "go.mod").read_text()
if f"{MODULE} {args.from_version}" not in manifest or "replace " in manifest:
raise AssertionError("initial business project was not pinned to the expected remote version")
run([args.go, "test", "-race", "./..."], project, env)
run([args.go, "get", f"{MODULE}@{args.version}"], project, env)
run([args.go, "mod", "tidy"], project, env)
upgraded = (project / "go.mod").read_text()
if f"{MODULE} {args.version}" not in upgraded or "replace " in upgraded:
raise AssertionError("business project upgrade did not produce a clean remote pin")
run([args.go, "test", "-race", "./..."], project, env)
binary = project / "billing-server"
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
address = free_address()
log_file = project / "business.log"
server_env = dict(env)
server_env.update(
GO_OMNI_HTTP_ADDR=address,
GO_OMNI_ENABLE_LOGGER="true",
GO_OMNI_LOG_FILE=str(log_file),
GO_OMNI_LOG_FORMAT="json",
GO_OMNI_SHUTDOWN_TIMEOUT="2s",
)
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(method, path, body=None, request_id="business-request"):
payload = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
f"http://{address}{path}", data=payload, method=method,
headers={"Content-Type": "application/json", "X-Request-ID": request_id},
)
try:
with client.open(req, timeout=3) as response:
return response.status, response.headers, json.load(response)
except urllib.error.HTTPError as error:
return error.code, error.headers, json.loads(error.read())
process = subprocess.Popen(
[str(binary)], cwd=project, env=server_env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
try:
deadline = time.monotonic() + 15
while True:
if process.poll() is not None:
output = process.stdout.read() if process.stdout else ""
raise RuntimeError(f"business service exited during startup: {output}")
try:
status, _, _ = request("GET", "/health/live")
if status == 200:
break
except OSError:
pass
if time.monotonic() >= deadline:
raise RuntimeError("business service did not start")
time.sleep(0.05)
status, headers, body = request("GET", "/health/ready")
if status != 200 or body.get("code") != "READY":
raise AssertionError(f"readiness failed: {status} {body}")
status, headers, body = request("POST", "/users", {"name": "broken"}, "invalid-request")
if status != 400 or body.get("code") != "INVALID_BODY" or headers.get("X-Request-ID") != "invalid-request":
raise AssertionError(f"validation contract failed: {status} {body}")
status, _, body = request("POST", "/users", {"name": "Alice", "email": "alice@example.com"}, "create-request")
if status != 201 or body.get("data", {}).get("email") != "alice@example.com":
raise AssertionError(f"business create failed: {status} {body}")
status, _, body = request("POST", "/users", {"name": "Duplicate", "email": "ALICE@example.com"}, "duplicate-request")
if status != 409 or body.get("code") != "EMAIL_TAKEN":
raise AssertionError(f"business error mapping failed: {status} {body}")
process.send_signal(signal.SIGTERM)
if process.wait(timeout=8) != 0:
raise AssertionError("business service did not exit cleanly")
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
content = log_file.read_text()
if '"http.request"' not in content or '"requestId":"create-request"' not in content:
raise AssertionError(f"access log did not contain business request correlation: {content}")
if '"service":"example.com/billing"' not in content:
raise AssertionError(f"business service name was not attached to logs: {content}")
if "private" in content.lower() and "password" in content.lower():
raise AssertionError("sensitive text leaked into business logs")
print(f"PASS business module: {args.from_version} -> {args.version}, clean remote pin, CRUD, validation, errors, requestId and logs", flush=True)
if __name__ == "__main__":
main()

View File

@@ -145,7 +145,7 @@ class Server:
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.2.0")
parser.add_argument("--version", default="v0.6.0-preview")
parser.add_argument("--cli", help="Use a locally built CLI")
parser.add_argument("--framework-dir", help="Local framework source for pre-release checks")
parser.add_argument("--image", default="mysql:8.4")
@@ -186,7 +186,7 @@ def main():
raise AssertionError(f"integration test did not pass: {name}\n{output}")
print("PASS real MySQL: commit, error/panic/cancel rollback, pool limits, health timeout, close", flush=True)
serverEnv = dict(env, GO_OMNI_DB_DSN=database.dsn, GO_OMNI_MODE="release",
serverEnv = dict(env, GO_OMNI_DB_DSN=database.dsn,
GO_OMNI_DB_PING_TIMEOUT="500ms", GO_OMNI_ENABLE_LOGGER="true")
server = Server(binary, project, serverEnv)
servers.append(server)

84
scripts/verifyLinux.py Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Run GoOmni's repeatable Linux checks inside a pinned Go Docker image."""
import argparse
from pathlib import Path
import shutil
import shlex
import subprocess
import sys
def workspace_output(root: Path, value: str) -> tuple[Path, str]:
requested = Path(value)
host_path = requested if requested.is_absolute() else root / requested
host_path = host_path.resolve()
try:
relative = host_path.relative_to(root)
except ValueError as exc:
raise SystemExit("--output must be inside the repository") from exc
host_path.parent.mkdir(parents=True, exist_ok=True)
return host_path, "/workspace/" + relative.as_posix()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", default="golang:1.25", help="Docker image (default: golang:1.25)")
parser.add_argument("--gomaxprocs", type=int, default=4)
parser.add_argument("--load", action="store_true", help="also run the isolated server matrix")
parser.add_argument("--tls", action="store_true", help="run the load matrix over TLS")
parser.add_argument("--duration", default="2s", help="duration per traffic case (up to 30m)")
parser.add_argument("--count", type=int, default=3)
parser.add_argument("--concurrency", default="1,32,128")
parser.add_argument("--idle", default="128,1024")
parser.add_argument("--output", help="JSON report path inside the repository")
args = parser.parse_args()
if args.gomaxprocs < 1 or args.count < 1:
parser.error("--gomaxprocs and --count must be positive")
if args.tls and not args.load:
parser.error("--tls requires --load")
root = Path(__file__).resolve().parent.parent
docker = shutil.which("docker")
if docker is None:
raise SystemExit("docker is required for Linux verification")
commands = [
"go test -race ./...",
"go vet ./...",
"go build ./...",
]
if args.load:
load = [
"go", "run", "./internal/perfcheck",
"--duration", args.duration,
"--count", str(args.count),
"--concurrency", args.concurrency,
"--idle", args.idle,
]
if args.tls:
load.append("--tls")
if args.output:
_, container_path = workspace_output(root, args.output)
load += ["--output", container_path]
commands.append(" ".join(shlex.quote(part) for part in load))
command = " && ".join(commands)
docker_args = [
docker, "run", "--rm", "--pull=missing",
"-v", f"{root}:/workspace",
"-w", "/workspace",
"-e", "GOWORK=off",
"-e", f"GOMAXPROCS={args.gomaxprocs}",
args.image, "sh", "-c", command,
]
print(f"Linux verification image: {args.image}", flush=True)
print(f"Linux verification command: {command}", flush=True)
result = subprocess.run(docker_args, cwd=root)
if result.returncode != 0:
raise SystemExit(f"Linux verification failed with exit code {result.returncode}")
print("PASS Linux verification", flush=True)
if __name__ == "__main__":
main()

55
scripts/verifyPerformance.py Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Run GoOmni's repeatable stability checks and transport performance baseline."""
import argparse
import os
from pathlib import Path
import subprocess
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--count", type=int, default=3)
parser.add_argument("--load", action="store_true", help="Also compare isolated servers and idle connection memory")
parser.add_argument("--tls", action="store_true", help="Run the isolated load matrix over TLS")
parser.add_argument("--duration", default="2s", help="Duration per load scenario")
parser.add_argument("--concurrency", default="1,32,128")
parser.add_argument("--idle", default="128,1024")
parser.add_argument("--output", help="JSON output path for --load")
args = parser.parse_args()
if args.count < 1:
parser.error("--count must be positive")
root = Path(__file__).resolve().parent.parent
env = dict(os.environ, GOWORK="off")
checks = [
([args.go, "test", "-race", "./..."], "race tests"),
([args.go, "vet", "./..."], "vet"),
([args.go, "build", "./..."], "build"),
(
[args.go, "test", "-run", "^$", "-bench", "^BenchmarkTransport",
"-benchmem", "-count", str(args.count), "./transport"],
"transport benchmarks",
),
]
if args.load:
command = [args.go, "run", "./internal/perfcheck", "--duration", args.duration,
"--count", str(args.count), "--concurrency", args.concurrency,
"--idle", args.idle]
if args.tls:
command.append("--tls")
if args.output:
command += ["--output", str(Path(args.output).resolve())]
checks.append((command, "isolated server concurrency and idle-memory matrix"))
for command, name in checks:
print(f"Running {name}", flush=True)
result = subprocess.run(command, cwd=root, env=env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
print(result.stdout, end="")
if result.returncode != 0:
raise SystemExit(f"{name} failed with exit code {result.returncode}")
print("PASS stability checks and transport performance baseline", flush=True)
if __name__ == "__main__":
main()

View File

@@ -29,7 +29,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
listener.bind(("127.0.0.1", 0))
address = f"127.0.0.1:{listener.getsockname()[1]}"
serverEnv = {key: value for key, value in env.items() if not key.startswith("GO_OMNI_")}
serverEnv.update(GO_OMNI_HTTP_ADDR=address, GO_OMNI_MODE="release",
serverEnv.update(GO_OMNI_HTTP_ADDR=address,
GO_OMNI_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s")
if cleanupMarker:
serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker)
@@ -91,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go", help="Go executable")
parser.add_argument("--version", default="v0.2.0", help="Released CLI version to install")
parser.add_argument("--version", default="v0.6.0-preview", help="Released CLI version to install")
parser.add_argument("--cli", help="Use a locally built CLI instead of installing")
parser.add_argument("--framework-version", help="Explicit version passed to the CLI")
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")
@@ -142,7 +142,7 @@ import (
omni "git.yuntuo.hk/k2safe/go-omni"
)
func main() {
app := omni.New(omni.WithMode("release"))
app := omni.New()
app.GET("/health/live", func(ctx *omni.Context) error { return ctx.OK(map[string]string{"status": "ok"}) })
app.OnShutdown(func() error { return os.WriteFile(os.Getenv("GO_OMNI_CLEANUP_MARKER"), []byte("closed"), 0600) })
if err := app.Run(os.Getenv("GO_OMNI_HTTP_ADDR")); err != nil { log.Fatal(err) }

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Run the public adapter contract from an independent consumer module."""
import argparse
import json
import os
from pathlib import Path
import tempfile
from verifyScaffold import MODULE, run
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.6.0-preview")
parser.add_argument("--framework-dir", help="Use a local checkout before release")
args = parser.parse_args()
env = dict(os.environ, GOWORK="off")
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"]))
with tempfile.TemporaryDirectory(prefix="go omni transport ") as temporary:
directory = Path(temporary).resolve()
module = f"module example.com/adaptercheck\n\ngo 1.22\n\nrequire {MODULE} {args.version}\n"
if args.framework_dir:
module += f"\nreplace {MODULE} => {json.dumps(str(Path(args.framework_dir).resolve()), ensure_ascii=False)}\n"
(directory / "go.mod").write_text(module)
(directory / "adapter_test.go").write_text(f'''package adaptercheck_test
import (
"testing"
"{MODULE}/httpx"
netrouter "{MODULE}/transport/nethttp"
"{MODULE}/transport/transporttest"
)
func TestContract(t *testing.T) {{
t.Run("nethttp", func(t *testing.T) {{
transporttest.Run(t, func() httpx.Router {{ return netrouter.New() }})
}})
}}
''')
source = "local checkout" if args.framework_dir else f"remote {args.version}"
print(f"Testing {source} in an independent consumer module", flush=True)
run([args.go, "mod", "tidy"], directory, env)
if not args.framework_dir and "replace " in (directory / "go.mod").read_text():
raise AssertionError("remote verification must not contain replace")
output = run([args.go, "test", "-race", "-count=1", "-json", "./..."], directory, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
if any(event.get("Action") == "skip" for event in events):
raise AssertionError("contract suite unexpectedly skipped tests")
for adapter in ("nethttp",):
name = f"TestContract/{adapter}"
if not any(event.get("Action") == "pass" and event.get("Test") == name for event in events):
raise AssertionError(f"{adapter} contract did not pass")
count = sum(event.get("Action") == "pass" and event.get("Test", "").startswith(name + "/")
for event in events)
print(f"PASS {adapter}: {count} contract checks, including business handlers, cancellation and concurrent requests", flush=True)
print("PASS independent module: nethttp accepted by the public contract suite", flush=True)
if __name__ == "__main__":
main()

View File

@@ -1,22 +1,111 @@
# Transport adapters
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
使用标准库适配器:
业务 Handler 依赖 GoOmni 的 `httpx.Context`,Service 接收标准 `context.Context`。适配器只在启动入口组装:
```go
import (
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
application := omni.NewWithRouter(netrouter.New())
registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
```
后续自研网络层时,实现以下能力即可接入现有 `app`、中间件和业务 Handler:
`transport/nethttp` 是当前内置的标准库路由实现。它提供一套可移植的路径、方法和错误回退规则;自研网络层可以直接实现同一个 `httpx.Router` 契约。
- `httpx.Router`:注册路由、分组并实现 `http.Handler`。
- `httpx.RouterGroup`:注册分组内路由。
- 适配器为请求 context 注入 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern`。
`transport/native` 是第一版自研 HTTP/1.1 服务端:它自己管理 TCP listener、连接、Keep-Alive、请求解析、响应写回和优雅关闭,再把请求交给现有 `http.Handler`。请求体支持 `Content-Length` 和 chunked,并统一受 `MaxBodyBytes` 限制;其他传输编码会被拒绝。它仍使用标准 `http.Request` / `http.ResponseWriter` 作为业务桥接,因此不会影响现有 Handler / Service。
业务层不应该导入具体 transport 包;它们只是网络层实现。
业务 Handler 需要流式返回时可以断言 `http.Flusher` 并调用 `Flush()`;HTTP/1.1 会自动使用 chunked 响应,普通未 Flush 的响应仍自动生成 `Content-Length`。HTTP/1.0 没有 chunked framing,会退回到写完后关闭连接。
Handler 运行超过约 5 ms 时,native 启动一次阻塞读取监测;读到 EOF 或连接错误会取消 `Request.Context()`,流式写入失败也会触发取消。Handler 返回后先停止监测,再复用连接和 Reader;普通短请求不会启动监测 goroutine。
如果客户端提前发送了下一条请求,监测器会保留管线数据并退出,避免预读不断占用内存。这种情况下,断开取消会延后到写失败或 Handler 返回;业务仍应为下游调用设置自己的超时。
服务端不是把连接交给一个全局串行 worker,而是每条连接独立处理请求;Keep-Alive 负责复用客户端连接,`Server` 内部的 `sync.Pool` 负责复用小型 Reader、响应对象和连接状态。Reader 默认只有 4 KiB,需要处理大请求头时按需增长。需要保护内存上限时,可以设置 `MaxConnections` 和 `MaxConcurrentRequests`;超出连接上限的连接会被关闭,超出请求并发上限的请求返回 503,不会无限排队。
这个设计借鉴了 Nginx 的资源边界,但没有照搬它的进程模型:Nginx 用少量 worker 进程和事件机制管理大量连接,并用 `worker_connections` 限制每个 worker 的同时连接数;Go runtime 自己已经用 epoll / kqueue 等网络轮询器唤醒阻塞的 goroutine,因此 native 采用“accept loop + 每连接轻量 goroutine + 请求并发闸门”的等价实现。这样业务 Handler 可以继续执行同步代码,慢请求不会阻塞其他连接;只有在真实压测证明 goroutine 调度或协议状态机成为瓶颈时,才需要继续下沉为手写事件循环。
使用自研服务端:
```go
application := omni.New()
server := native.New(nil)
if err := application.RunWith(ctx, server, ":8080"); err != nil {
log.Fatal(err)
}
```
`omni.New()` 默认仍使用 `transport/nethttp` 路由;native 服务端是显式启用的预览实现,等协议和连接边界稳定后再考虑切换默认启动器。
TLS 可以直接作为 `App.RunWith` 的启动配置:
```go
server := native.New(nil)
server.TLSConfig = &tls.Config{Certificates: certificates}
if err := application.RunWith(ctx, server, ":8443"); err != nil {
log.Fatal(err)
}
```
需要自己持有 listener 或配置证书文件时,分别使用 `ServeTLS` 或 `RunTLS`;TLS 握手仍受 native 的读超时、连接上限和优雅关闭控制,Handler 可通过 `Request.TLS` 读取标准 `tls.ConnectionState`。
## 可移植路由规则(v0.3.0)
| 项目 | 约定 |
| --- | --- |
| 路径 | 顶层路由必须以 `/` 开头;区分大小写。禁止重复斜杠、`.` / `..` 段、查询串和片段标识。 |
| 参数 | `/users/:id` 匹配一个非空路径段;参数必须占据整个段,同一路由内名称唯一。 |
| 通配 | `/files/*path` 仅允许出现在末尾,值包含开头的 `/`;`/files/` 对应 `/`,`/files` 不匹配。 |
| 路由优先级 | 相同方法内,从左向右比较,静态段优先于参数段;更具体的分支匹配失败后允许回退。 |
| 注册冲突 | 同方法重复注册、相同前缀处参数名冲突、catch-all 与同级分支冲突,均在注册时 panic。不同方法可用同一路径。 |
| 分组 | 支持嵌套与相对路径。`Group("/api").Handle("GET", "", h)` 注册 `/api`,传 `"/"` 则注册 `/api/`。 |
| 尾部斜杠 | `/users` 与 `/users/` 是不同路由;不自动重定向、补斜杠或清理请求路径。 |
| URL 编码 | 使用已解码的 `URL.Path`;例如 `%2F` 作为路径分隔符,`+` 保留为加号。 |
| 方法 | `Handle` 接受大写字母组成的方法名;HEAD 不自动复用 GET,OPTIONS 不自动成功。 |
| Any | 注册 GET、POST、PUT、PATCH、HEAD、OPTIONS、DELETE、CONNECT、TRACE 九种方法;其他方法需显式注册。 |
| 404 / 405 | 未找到路径返回纯文本 404;路径存在但方法不支持时返回纯文本 405,`Allow` 去重并按字母排序。 |
| 并发 | 启动前完成路由注册;允许并发处理请求,各请求参数、业务值、requestId 必须隔离。 |
HTTP 回退响应属于路由层,不进入应用路由中间件,不携带统一 JSON 封装或 requestId。业务 Handler 返回的错误则经过统一 JSON 错误处理。
业务层通过 `application.Handle("HEAD", path, handler)` 或 `group.Handle("OPTIONS", path, handler)` 显式注册其他方法,同样经过框架中间件和错误处理。
## 业务行为
- 同一套 Handler、Service、中间件用于两种适配器;应用、分组、嵌套分组、路由中间件依次进入、反序退出。
- `Use` 只影响后续注册的路由。鉴权失败短路处理链,panic 转为统一 500。
- URI / Query / JSON 绑定、业务错误、requestId 与业务日志关联一致。
- `JSON` / `OK` 等方法先编码再发送;编码失败返回统一 500。已通过 Context 提交响应后,后续错误、panic 或重复响应不会追加 JSON。
- 客户端断开会取消传入 Service 的 context;适配时保留原有 context 的值、截止时间和取消信号。
- `Raw()` 是高级逃生口;直接写入、流式响应、Hijack 等行为不属于上述 Context 响应追踪契约,勿与自动响应方法混用。
## 自研适配器验收
实现 `httpx.Router` / `httpx.RouterGroup`,在调用 Handler 前通过 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern` 注入请求元数据。使用外部测试包接入公共验收套件:
```go
package myrouter_test
import (
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/transport/transporttest"
"example.com/project/myrouter"
)
func TestContract(t *testing.T) {
transporttest.Run(t, func() httpx.Router { return myrouter.New() })
}
```
工厂需每次返回新的空路由器。套件依次创建路由器,内部会发送并发请求、打开本地 HTTP 测试服务,运行时应启用 race 检查:
```sh
go test -race ./transport/... ./examples/reference
python3 scripts/verifyTransports.py --version v0.6.0-preview
```
第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。
当前替换边界分成两层:`transport/nethttp` 提供标准库路由,`transport/native` 进一步接管 TCP 监听、连接管理和 HTTP/1.1 协议 I/O。`App.Run` / `App.RunContext` 仍使用标准库 `net/http.Server`,需要显式调用 `App.RunWith(ctx, native.New(nil), address)` 才会启用 native 预览实现。native 当前通过标准 `http.Request` / `http.ResponseWriter` 桥接到业务层,后续可在不改 Handler / Service 的前提下逐步替换解析器和响应写回。

View File

@@ -0,0 +1,125 @@
package transport_test
import (
"context"
"fmt"
"io"
"net"
"net/http"
"sync"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
// Both servers use the same application, client, request validation and fixed
// concurrency. B/op includes client AND server allocations in this process;
// ns/op is aggregate throughput cost, not individual request latency.
func BenchmarkTransportTCPApplicationRequest(b *testing.B) {
for _, adapter := range []string{"nethttp", "native"} {
for _, concurrency := range []int{1, 32, 128} {
b.Run(fmt.Sprintf("%s/concurrency%d", adapter, concurrency), func(b *testing.B) {
benchmarkTCP(b, adapter, concurrency)
})
}
}
}
func benchmarkTCP(b *testing.B, adapter string, concurrency int) {
application := omni.New()
application.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatal(err)
}
done := make(chan error, 1)
var shutdown func(context.Context) error
if adapter == "native" {
server := native.New(application)
server.MaxConnections = 1024
server.MaxConcurrentRequests = 1024
shutdown = server.Shutdown
go func() { done <- server.Serve(context.Background(), listener) }()
} else {
server := &http.Server{
Handler: application, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: time.Minute, MaxHeaderBytes: 1 << 20,
}
shutdown = server.Shutdown
go func() {
err := server.Serve(listener)
if err == http.ErrServerClosed {
err = nil
}
done <- err
}()
}
client := &http.Client{Transport: &http.Transport{
MaxIdleConns: 1024,
MaxIdleConnsPerHost: 1024,
MaxConnsPerHost: concurrency,
IdleConnTimeout: time.Minute,
}, Timeout: 5 * time.Second}
b.Cleanup(func() {
client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdown(ctx); err != nil {
b.Error(err)
}
select {
case err := <-done:
if err != nil {
b.Error(err)
}
case <-ctx.Done():
b.Error("server did not stop")
}
})
endpoint := "http://" + listener.Addr().String() + "/health"
request := func() error {
response, err := client.Get(endpoint)
if err != nil {
return err
}
_, readErr := io.Copy(io.Discard, response.Body)
closeErr := response.Body.Close()
if response.StatusCode != http.StatusOK || response.Header.Get("X-Request-ID") == "" {
return fmt.Errorf("unexpected response: status=%d", response.StatusCode)
}
if readErr != nil {
return readErr
}
return closeErr
}
if err := request(); err != nil {
b.Fatal(err)
}
errors := make(chan error, concurrency)
var workers sync.WaitGroup
b.ReportAllocs()
b.ResetTimer()
for worker := 0; worker < concurrency; worker++ {
workers.Add(1)
go func(worker int) {
defer workers.Done()
for index := worker; index < b.N; index += concurrency {
if err := request(); err != nil {
errors <- err
return
}
}
}(worker)
}
workers.Wait()
b.StopTimer()
close(errors)
for err := range errors {
b.Error(err)
}
}

View File

@@ -0,0 +1,48 @@
package transport_test
import (
"net/http"
"net/http/httptest"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
func BenchmarkTransportRouteDispatch(b *testing.B) {
b.Run("nethttp", func(b *testing.B) {
router := netrouter.New()
router.Handle(http.MethodGet, "/users/:id", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "/users/42", nil)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
writer := httptest.NewRecorder()
router.ServeHTTP(writer, request)
if writer.Code != http.StatusNoContent {
b.Fatalf("unexpected status %d", writer.Code)
}
}
})
}
func BenchmarkTransportApplicationRequest(b *testing.B) {
b.Run("nethttp", func(b *testing.B) {
application := omni.NewWithRouter(netrouter.New())
application.GET("/health", func(c *omni.Context) error {
return c.OK(map[string]string{"status": "ok"})
})
request := httptest.NewRequest(http.MethodGet, "/health", nil)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
writer := httptest.NewRecorder()
application.ServeHTTP(writer, request)
if writer.Code != http.StatusOK {
b.Fatalf("unexpected status %d", writer.Code)
}
}
})
}

View File

@@ -0,0 +1,15 @@
package transport_test
import (
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
"git.yuntuo.hk/k2safe/go-omni/transport/transporttest"
)
func TestAdapterContract(t *testing.T) {
t.Run("nethttp", func(t *testing.T) {
transporttest.Run(t, func() httpx.Router { return netrouter.New() })
})
}

View File

@@ -1,70 +0,0 @@
// Package gin adapts Gin to GoOmni's transport-neutral router contract.
// Business packages should import httpx and app, not this package.
package gin
import (
"net/http"
"git.yuntuo.hk/k2safe/go-omni/httpx"
ginlib "github.com/gin-gonic/gin"
)
// Router is the default Gin-backed GoOmni router.
type Router struct {
engine *ginlib.Engine
}
// New creates a Gin router without Gin's default logger or recovery middleware.
func New(mode string) *Router {
if mode != "" {
ginlib.SetMode(mode)
}
return &Router{engine: ginlib.New()}
}
// NewWithEngine wraps an existing Gin engine for incremental migration.
func NewWithEngine(engine *ginlib.Engine) *Router { return &Router{engine: engine} }
// Engine exposes the underlying Gin engine to adapter-specific integrations.
func (r *Router) Engine() *ginlib.Engine { return r.engine }
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
r.engine.ServeHTTP(writer, request)
}
func (r *Router) Handle(method, path string, handler http.Handler) {
r.engine.Handle(method, path, r.adapt(handler))
}
func (r *Router) Any(path string, handler http.Handler) { r.engine.Any(path, r.adapt(handler)) }
func (r *Router) Group(path string) httpx.RouterGroup {
return &group{group: r.engine.Group(path)}
}
type group struct{ group *ginlib.RouterGroup }
func (g *group) Handle(method, path string, handler http.Handler) {
g.group.Handle(method, path, adapt(handler))
}
func (g *group) Any(path string, handler http.Handler) { g.group.Any(path, adapt(handler)) }
func (g *group) Group(path string) httpx.RouterGroup {
return &group{group: g.group.Group(path)}
}
func (r *Router) adapt(handler http.Handler) ginlib.HandlerFunc { return adapt(handler) }
func adapt(handler http.Handler) ginlib.HandlerFunc {
return func(ctx *ginlib.Context) {
params := make(map[string]string, len(ctx.Params))
for _, param := range ctx.Params {
params[param.Key] = param.Value
}
requestContext := httpx.WithRouteParams(ctx.Request.Context(), params)
requestContext = httpx.WithRoutePattern(requestContext, ctx.FullPath())
request := ctx.Request.WithContext(requestContext)
handler.ServeHTTP(ctx.Writer, request)
}
}

View File

@@ -0,0 +1,119 @@
// Package routing defines registration rules shared by the built-in adapters.
// Request dispatch remains the responsibility of each adapter.
package routing
import (
"net/http"
"strings"
)
var Methods = [...]string{
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodHead, http.MethodOptions, http.MethodDelete, http.MethodConnect, http.MethodTrace,
}
type Segment struct {
Literal string
Name string
Wildcard bool
}
// Parse accepts absolute paths, whole-segment parameters and a terminal
// catch-all. It rejects ambiguous patterns before an adapter registers them.
func Parse(method, path string, handler http.Handler) []Segment {
if handler == nil {
panic("go-omni: nil route handler")
}
if method == "" || strings.IndexFunc(method, func(r rune) bool { return r < 'A' || r > 'Z' }) >= 0 {
panic("go-omni: route method must contain uppercase letters")
}
if path == "" || path[0] != '/' {
panic("go-omni: route path must start with /")
}
parts := strings.Split(path[1:], "/")
segments := make([]Segment, len(parts))
names := make(map[string]bool)
for i, part := range parts {
if part == "." || part == ".." || strings.ContainsAny(part, "?#") || (part == "" && i != len(parts)-1) {
panic("go-omni: invalid route path " + path)
}
if strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*") {
name := part[1:]
if name == "" || strings.ContainsAny(name, ":*") || names[name] {
panic("go-omni: route parameters must have unique, non-empty names")
}
wildcard := part[0] == '*'
if wildcard && i != len(parts)-1 {
panic("go-omni: catch-all must be the final segment")
}
names[name] = true
segments[i] = Segment{Name: name, Wildcard: wildcard}
} else {
if strings.ContainsAny(part, ":*") {
panic("go-omni: parameters must occupy a whole path segment")
}
segments[i] = Segment{Literal: part}
}
}
return segments
}
// CheckConflict enforces the portable ambiguity rules for one method.
func CheckConflict(existing, next []Segment) {
for i := 0; i < len(existing) && i < len(next); i++ {
a, b := existing[i], next[i]
if a.Wildcard || b.Wildcard {
panic("go-omni: catch-all conflicts with an existing route")
}
if a.Name != "" && b.Name != "" {
if a.Name != b.Name {
panic("go-omni: conflicting parameter names at the same route prefix")
}
continue
}
if a != b {
return
}
}
if len(existing) == len(next) {
panic("go-omni: duplicate route")
}
}
// Join supports relative group paths while preserving a final slash.
func Join(prefix, path string) string {
if path == "" {
if prefix == "" {
return "/"
}
return prefix
}
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
}
// Match uses the decoded URL.Path without slash normalization or redirects.
func Match(segments []Segment, path string) (map[string]string, bool) {
if !strings.HasPrefix(path, "/") {
return nil, false
}
parts := strings.Split(path[1:], "/")
params := make(map[string]string)
for i, current := range segments {
if i >= len(parts) {
return nil, false
}
if current.Wildcard {
params[current.Name] = "/" + strings.Join(parts[i:], "/")
return params, true
}
if current.Name != "" {
if parts[i] == "" {
return nil, false
}
params[current.Name] = parts[i]
} else if current.Literal != parts[i] {
return nil, false
}
}
return params, len(segments) == len(parts)
}

View File

@@ -0,0 +1,104 @@
package native
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
)
type observedReadConn struct {
net.Conn
reading chan struct{}
}
func (c *observedReadConn) Read(data []byte) (int, error) {
select {
case c.reading <- struct{}{}:
default:
}
return c.Conn.Read(data)
}
func TestDisconnectWatchStopsBeforeReaderReuse(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
conn := &observedReadConn{Conn: server, reading: make(chan struct{}, 1)}
reader := bufio.NewReader(conn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watch := newClientDisconnectWatch(reader, conn, cancel)
select {
case <-conn.reading:
case <-time.After(time.Second):
t.Fatal("disconnect watcher did not start reading")
}
watch.stop()
if ctx.Err() != nil {
t.Fatalf("interrupting the watcher canceled a healthy request: %v", ctx.Err())
}
if err := server.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
writeDone := make(chan error, 1)
go func() {
_, err := io.WriteString(client, "GET /next HTTP/1.1\r\n")
writeDone <- err
}()
line, err := reader.ReadString('\n')
if err != nil || line != "GET /next HTTP/1.1\r\n" {
t.Fatalf("reader retained watcher data or deadline: line=%q err=%v", line, err)
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
}
func TestDisconnectWatchPreservesPipelineAndWriteFailureCancels(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
pipeline := "GET /next HTTP/1.1\r\nHost: test\r\n\r\n"
reader := bufio.NewReader(strings.NewReader(pipeline))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watch := newClientDisconnectWatch(reader, server, cancel)
select {
case <-watch.done:
case <-time.After(time.Second):
t.Fatal("watcher should stop when pipelined data is available")
}
watch.stop()
if ctx.Err() != nil {
t.Fatal("pipeline data canceled the current request")
}
remaining, err := io.ReadAll(reader)
if err != nil || string(remaining) != pipeline {
t.Fatalf("pipeline bytes changed: data=%q err=%v", remaining, err)
}
// With pending pipeline data, EOF detection is deferred. A failed stream
// write must still cancel downstream work even when the handler ignores it.
client.Close()
writer := newResponseWriter(http.MethodGet)
writer.configure(&http.Request{ProtoMajor: 1, ProtoMinor: 1}, server, false, time.Second, cancel)
writer.Flush()
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatal("failed Flush did not cancel request context")
}
if _, err := writer.Write([]byte("more")); err == nil {
t.Fatal("failed Flush was not returned by subsequent Write")
}
if err := writer.writeTo(server, false, time.Second); err == nil {
t.Fatal("failed Flush was not returned on response completion")
}
writer.reset("")
if writer.cancel != nil || writer.writeErr != nil || writer.conn != nil {
t.Fatal("pooled response retained the previous request")
}
}

1105
transport/native/server.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
package native_test
import (
"context"
"io"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
func TestServerSlowHeadersCloseBeforeHandler(t *testing.T) {
var handlerCalls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
handlerCalls.Add(1)
}))
server.ReadHeaderTimeout = 40 * time.Millisecond
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- server.Serve(context.Background(), listener) }()
defer stopServer(t, server, listener, done)
waitForServer(t, listener.Addr().String())
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := io.WriteString(conn, "GET /health HTTP/1.1\r\nHost: local"); err != nil {
t.Fatal(err)
}
requirePeerClose(t, conn)
if handlerCalls.Load() != 0 {
t.Fatal("handler ran before slow headers completed")
}
}
func TestServerSlowChunkedBodyClosesBeforeHandler(t *testing.T) {
var handlerCalls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
handlerCalls.Add(1)
}))
server.ReadTimeout = 40 * time.Millisecond
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- server.Serve(context.Background(), listener) }()
defer stopServer(t, server, listener, done)
waitForServer(t, listener.Addr().String())
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
request := "POST /body HTTP/1.1\r\nHost: local\r\nTransfer-Encoding: chunked\r\n\r\n4\r\npa"
if _, err := io.WriteString(conn, request); err != nil {
t.Fatal(err)
}
requirePeerClose(t, conn)
if handlerCalls.Load() != 0 {
t.Fatal("handler ran before slow chunked body completed")
}
}

View File

@@ -0,0 +1,188 @@
package native_test
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
func startNativeTLS(t *testing.T, server *native.Server) (string, *tls.Config) {
t.Helper()
certificate := testCertificate(t)
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(leaf)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
})
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
t.Errorf("TLS shutdown: %v", err)
}
select {
case err := <-done:
if err != nil {
t.Errorf("TLS serve: %v", err)
}
case <-ctx.Done():
t.Error("TLS serve did not exit")
}
})
return listener.Addr().String(), &tls.Config{
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
}
}
func requirePeerClose(t *testing.T, conn net.Conn) {
t.Helper()
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := conn.Read(make([]byte, 1))
if err == nil {
t.Fatal("expected server to close the connection")
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Fatalf("client timed out; server did not close the connection: %v", err)
}
}
func TestNativeTLSSlowHandshakeTimesOut(t *testing.T) {
for _, prefix := range []string{"", "\x16\x03\x01\x00\x64\x01"} {
t.Run(fmt.Sprintf("partialBytes=%d", len(prefix)), func(t *testing.T) {
var calls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls.Add(1) }))
server.ReadHeaderTimeout = 50 * time.Millisecond
address, _ := startNativeTLS(t, server)
conn, err := net.DialTimeout("tcp", address, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := io.WriteString(conn, prefix); err != nil {
t.Fatal(err)
}
requirePeerClose(t, conn)
if calls.Load() != 0 {
t.Fatal("unfinished TLS handshake reached the handler")
}
})
}
}
func TestNativeTLSConnectionLimitAndKeepAlive(t *testing.T) {
var calls atomic.Int32
server := native.New(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
_, _ = io.WriteString(w, "healthy")
}))
server.MaxConnections = 1
address, config := startNativeTLS(t, server)
dial := func() (*tls.Conn, error) {
return tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, config)
}
first, err := dial()
if err != nil {
t.Fatal(err)
}
defer first.Close()
first.SetDeadline(time.Now().Add(3 * time.Second))
reader := bufio.NewReader(first)
for index := 0; index < 3; index++ {
if _, err := io.WriteString(first, "GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
t.Fatal(err)
}
response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || string(body) != "healthy" || response.Close {
t.Fatalf("Keep-Alive response body=%q err=%v close=%v", body, err, response.Close)
}
}
// A completed TLS handshake proves the first slot is held before testing
// rejection. TCP accept can still succeed; the TLS handshake must fail.
rejected, err := dial()
if err == nil {
rejected.Close()
t.Fatal("TLS connection exceeded MaxConnections")
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Fatalf("over-limit handshake hung: %v", err)
}
first.Close()
deadline := time.Now().Add(2 * time.Second)
var next *tls.Conn
for time.Now().Before(deadline) {
next, err = dial()
if err == nil {
break
}
time.Sleep(time.Millisecond)
}
if err != nil {
t.Fatalf("TLS connection capacity not released: %v", err)
}
defer next.Close()
next.SetDeadline(time.Now().Add(time.Second))
if _, err := io.WriteString(next, "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); err != nil {
t.Fatal(err)
}
response, err := http.ReadResponse(bufio.NewReader(next), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || string(body) != "healthy" || calls.Load() != 4 {
t.Fatalf("recovered response=%q calls=%d err=%v", body, calls.Load(), err)
}
}
func TestNativeTLSRequestMetadata(t *testing.T) {
server := native.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || !r.TLS.HandshakeComplete || r.TLS.NegotiatedProtocol != "http/1.1" {
http.Error(w, "missing TLS request metadata", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}))
address, config := startNativeTLS(t, server)
client := &http.Client{Transport: &http.Transport{TLSClientConfig: config}, Timeout: 2 * time.Second}
defer client.CloseIdleConnections()
response, err := client.Get("https://" + address + "/")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("TLS metadata unavailable to handler: status=%d", response.StatusCode)
}
}

View File

@@ -1,71 +1,47 @@
// Package nethttp provides a small standard-library router for GoOmni.
// It is intentionally independent of Gin and serves as the reference for a
// future project-owned network layer.
// Package nethttp provides GoOmni's standard-library router. Register routes
// before serving requests.
package nethttp
import (
"net/http"
"sort"
"strings"
"sync"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/transport/internal/routing"
)
// Router implements httpx.Router with net/http only. Register routes before
// serving requests.
type Router struct {
mu sync.RWMutex
routes []route
}
type Router struct{ routes []route }
type route struct {
method string
pattern string
segments []segment
segments []routing.Segment
handler http.Handler
score int
}
type segment struct {
literal string
name string
wildcard bool
}
// New creates an empty standard-library router.
func New() *Router { return &Router{} }
// ServeHTTP dispatches a request and injects route params and the matched
// pattern into its context for the transport-neutral handler chain.
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
r.mu.RLock()
routes := append([]route(nil), r.routes...)
r.mu.RUnlock()
var selected *route
var selectedParams map[string]string
pathMatched := false
allow := make(map[string]struct{})
for index := range routes {
candidate := &routes[index]
params, matched := match(candidate.segments, request.URL.Path)
allow := make(map[string]bool)
for index := range r.routes {
candidate := &r.routes[index]
params, matched := routing.Match(candidate.segments, request.URL.Path)
if !matched {
continue
}
pathMatched = true
if candidate.method != "*" && candidate.method != request.Method {
allow[candidate.method] = struct{}{}
if candidate.method != request.Method {
allow[candidate.method] = true
continue
}
if selected == nil || candidate.score > selected.score {
selected = candidate
selectedParams = params
if selected == nil || moreSpecific(candidate.segments, selected.segments) {
selected, selectedParams = candidate, params
}
}
if selected == nil {
if pathMatched {
if len(allow) > 0 {
methods := make([]string, 0, len(allow))
for method := range allow {
methods = append(methods, method)
@@ -73,37 +49,34 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
sort.Strings(methods)
writer.Header().Set("Allow", strings.Join(methods, ", "))
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
} else {
http.NotFound(writer, request)
}
http.NotFound(writer, request)
return
}
requestContext := httpx.WithRouteParams(request.Context(), selectedParams)
requestContext = httpx.WithRoutePattern(requestContext, selected.pattern)
selected.handler.ServeHTTP(writer, request.WithContext(requestContext))
ctx := httpx.WithRouteParams(request.Context(), selectedParams)
ctx = httpx.WithRoutePattern(ctx, selected.pattern)
selected.handler.ServeHTTP(writer, request.WithContext(ctx))
}
func (r *Router) Handle(method, path string, handler http.Handler) {
if handler == nil {
panic("go-omni/nethttp: nil route handler")
segments := routing.Parse(method, path, handler)
for _, existing := range r.routes {
if existing.method == method {
routing.CheckConflict(existing.segments, segments)
}
}
segments := parsePattern(path)
r.mu.Lock()
r.routes = append(r.routes, route{
method: method,
pattern: cleanPattern(path),
segments: segments,
handler: handler,
score: routeScore(method, segments),
})
r.mu.Unlock()
r.routes = append(r.routes, route{method, path, segments, handler})
}
func (r *Router) Any(path string, handler http.Handler) { r.Handle("*", path, handler) }
func (r *Router) Any(path string, handler http.Handler) {
for _, method := range routing.Methods {
r.Handle(method, path, handler)
}
}
func (r *Router) Group(path string) httpx.RouterGroup {
return &group{router: r, prefix: cleanPattern(path)}
return &group{router: r, prefix: routing.Join("", path)}
}
type group struct {
@@ -112,107 +85,24 @@ type group struct {
}
func (g *group) Handle(method, path string, handler http.Handler) {
g.router.Handle(method, joinPattern(g.prefix, path), handler)
g.router.Handle(method, routing.Join(g.prefix, path), handler)
}
func (g *group) Any(path string, handler http.Handler) { g.Handle("*", path, handler) }
func (g *group) Any(path string, handler http.Handler) {
g.router.Any(routing.Join(g.prefix, path), handler)
}
func (g *group) Group(path string) httpx.RouterGroup {
return &group{router: g.router, prefix: joinPattern(g.prefix, path)}
return &group{router: g.router, prefix: routing.Join(g.prefix, path)}
}
func cleanPattern(pattern string) string {
if pattern == "" {
return "/"
// Compare from the left: an earlier static segment wins even if another
// matching route has more static segments later in its path.
func moreSpecific(a, b []routing.Segment) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if (a[i].Name == "") != (b[i].Name == "") {
return a[i].Name == ""
}
}
if !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
pattern = strings.TrimRight(pattern, "/")
if pattern == "" {
return "/"
}
return pattern
}
func joinPattern(prefix, path string) string {
if prefix == "" || prefix == "/" {
return cleanPattern(path)
}
if path == "" || path == "/" {
return cleanPattern(prefix)
}
return cleanPattern(strings.TrimRight(prefix, "/") + "/" + strings.Trim(path, "/"))
}
func parsePattern(pattern string) []segment {
pattern = cleanPattern(pattern)
if pattern == "/" {
return nil
}
parts := strings.Split(strings.Trim(pattern, "/"), "/")
segments := make([]segment, 0, len(parts))
for _, part := range parts {
switch {
case strings.HasPrefix(part, ":") && len(part) > 1:
segments = append(segments, segment{name: part[1:]})
case strings.HasPrefix(part, "*") && len(part) > 1:
segments = append(segments, segment{name: part[1:], wildcard: true})
default:
segments = append(segments, segment{literal: part})
}
}
return segments
}
func match(segments []segment, path string) (map[string]string, bool) {
parts := splitPath(path)
params := make(map[string]string)
partIndex := 0
for segmentIndex, current := range segments {
if current.wildcard {
params[current.name] = strings.Join(parts[partIndex:], "/")
return params, true
}
if partIndex >= len(parts) {
return nil, false
}
if current.name != "" {
params[current.name] = parts[partIndex]
} else if current.literal != parts[partIndex] {
return nil, false
}
partIndex++
if segmentIndex == len(segments)-1 && partIndex != len(parts) {
return nil, false
}
}
if partIndex != len(parts) {
return nil, false
}
return params, true
}
func splitPath(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
return nil
}
return strings.Split(path, "/")
}
func routeScore(method string, segments []segment) int {
score := len(segments)
if method != "*" {
score += 1000
}
for _, current := range segments {
if current.literal != "" {
score += 10
}
if current.wildcard {
score -= 5
}
}
return score
return false
}

View File

@@ -10,7 +10,7 @@ import (
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
func TestRouterWorksWithoutGin(t *testing.T) {
func TestRouterWorks(t *testing.T) {
router := netrouter.New()
app := omni.NewWithRouter(router)
app.GET("/users/:id", func(ctx *omni.Context) error {

View File

@@ -0,0 +1,406 @@
package transporttest
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/app"
"git.yuntuo.hk/k2safe/go-omni/auth"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func application(t *testing.T, newRouter func() httpx.Router) {
t.Run("explicit application methods", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
mark := func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
c.SetResponseHeader("X-Middleware", "present")
return next(c)
}
}
a.Handle("HEAD", "/users", func(c *httpx.Context) error { return c.NoContent() }, mark)
group := a.Group("/api", mark)
group.Handle("OPTIONS", "/users", func(c *httpx.Context) error { return c.NoContent() })
group.Handle("REPORT", "/users", func(c *httpx.Context) error { return c.OK("report") })
for _, tc := range []struct {
method, path string
status int
}{
{"HEAD", "/users", 204}, {"OPTIONS", "/api/users", 204}, {"REPORT", "/api/users", 200},
} {
w := request(a, tc.method, tc.path, "")
if w.Code != tc.status || w.Header().Get("X-Middleware") != "present" || w.Header().Get("X-Request-ID") == "" {
t.Fatalf("%s bypassed the application chain: %d %v", tc.method, w.Code, w.Header())
}
}
})
t.Run("middleware order and registration snapshot", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
var calls []string
mark := func(name string) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
calls = append(calls, name+" in")
err := next(c)
calls = append(calls, name+" out")
return err
}
}
}
a.Use(mark("app"))
group := a.Group("/api", mark("group"))
nested := group.Group("/v1", mark("nested"))
nested.GET("/users/:id", func(c *httpx.Context) error {
calls = append(calls, "handler")
return c.OK(c.Param("id"))
}, mark("route"))
a.Use(mark("lateApp"))
group.Use(mark("lateGroup"))
request(a, "GET", "/api/v1/users/42", "")
want := "app in,group in,nested in,route in,handler,route out,nested out,group out,app out"
if got := strings.Join(calls, ","); got != want {
t.Fatalf("middleware order=%s", got)
}
})
t.Run("binding and business context", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
var logs bytes.Buffer
logger, err := logx.New(logx.Config{Format: "json", Writer: &logs})
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deadline := time.Now().Add(time.Minute)
a.POST("/users/:id", func(c *httpx.Context) error {
var uri struct {
ID int `uri:"id" binding:"min=1"`
}
var query struct {
PageSize int `form:"pageSize" binding:"min=1"`
}
var body struct {
Name string `json:"name" binding:"required"`
}
if err := c.BindURI(&uri); err != nil {
return err
}
if err := c.BindQuery(&query); err != nil {
return err
}
if err := c.BindJSON(&body); err != nil {
return err
}
// Business services consume standard context only.
result, err := businessResult(c.Context(), logger, deadline, uri.ID, query.PageSize, body.Name)
if err != nil {
return err
}
return c.Created(result)
})
req := httptest.NewRequest("POST", "/users/42?pageSize=20", strings.NewReader(`{"name":"Ada"}`))
ctx, cancel := context.WithDeadline(context.WithValue(req.Context(), contextKey{}, "original"), deadline)
defer cancel()
req = req.WithContext(ctx)
req.Header.Set("X-Request-ID", "businessRequest")
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
if w.Code != 201 || !strings.Contains(w.Body.String(), `"pageSize":20`) || !strings.Contains(w.Body.String(), `"requestId":"businessRequest"`) {
t.Fatalf("unexpected business response: %d %s", w.Code, w.Body)
}
if !strings.Contains(logs.String(), `"requestId":"businessRequest"`) {
t.Fatalf("business log lost request context: %s", &logs)
}
for _, tc := range []struct{ path, body, code string }{
{"/users/bad?pageSize=20", `{"name":"Ada"}`, "INVALID_PATH"},
{"/users/42?pageSize=bad", `{"name":"Ada"}`, "INVALID_QUERY"},
{"/users/42?pageSize=20", "{", "INVALID_BODY"},
{"/users/42?pageSize=20", "{}", "INVALID_BODY"},
} {
w := request(a, "POST", tc.path, tc.body)
assertEnvelope(t, w, 400, tc.code)
}
})
t.Run("authentication short circuit", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
calls := 0
provider := auth.StaticAuthenticator{
"admin": {UserID: "adminUser", Roles: []string{"admin"}},
"reader": {UserID: "readerUser"},
}
a.GET("/private", func(c *httpx.Context) error {
calls++
p, ok := auth.FromContext(c)
if !ok {
return errors.New("missing principal")
}
return c.OK(p.UserID)
}, auth.RequireAuth(provider), auth.RequireRole("admin"))
for _, tc := range []struct {
token string
status int
code string
}{
{"", 401, "UNAUTHENTICATED"}, {"invalid", 401, "UNAUTHENTICATED"},
{"reader", 403, "FORBIDDEN"}, {"admin", 200, "OK"},
} {
req := httptest.NewRequest("GET", "/private", nil)
if tc.token != "" {
req.Header.Set("Authorization", "Bearer "+tc.token)
}
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
assertEnvelope(t, w, tc.status, tc.code)
}
if calls != 1 {
t.Fatalf("unauthorized requests reached business handler: %d calls", calls)
}
})
t.Run("error recovery and committed response", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
for _, tc := range []struct {
path string
handler httpx.Handler
status int
code string
}{
{"/notFound", func(c *httpx.Context) error {
return fmt.Errorf("wrapped: %w", apierrors.NotFound("USER_MISSING", "user missing"))
}, 404, "USER_MISSING"},
{"/internal", func(c *httpx.Context) error { return errors.New("private database password") }, 500, "INTERNAL_ERROR"},
{"/panic", func(c *httpx.Context) error { panic("private database password") }, 500, "INTERNAL_ERROR"},
{"/encode", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500, "INTERNAL_ERROR"},
{"/written", func(c *httpx.Context) error {
if err := c.Created("saved"); err != nil {
return err
}
return errors.New("private database password")
}, 201, "CREATED"},
{"/panicAfterWrite", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("private database password") }, 202, "ACCEPTED"},
{"/doubleWrite", func(c *httpx.Context) error { _ = c.OK("first"); return c.Created("second") }, 200, "OK"},
} {
a.GET(tc.path, tc.handler)
w := request(a, "GET", tc.path, "")
assertEnvelope(t, w, tc.status, tc.code)
if strings.Contains(w.Body.String(), "private") {
t.Fatalf("internal cause leaked: %s", w.Body)
}
}
a.DELETE("/deleted", func(c *httpx.Context) error {
_ = c.NoContent()
return errors.New("after response")
})
w := request(a, "DELETE", "/deleted", "")
if w.Code != 204 || w.Body.Len() != 0 {
t.Fatalf("204 response=%d %s", w.Code, w.Body)
}
})
t.Run("concurrent request isolation", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/users/:id", func(c *httpx.Context) error {
c.Set("local", c.Param("id"))
value, _ := c.Get("local")
return c.OK(value)
})
var workers sync.WaitGroup
for i := 0; i < 32; i++ {
workers.Add(1)
go func(i int) {
defer workers.Done()
id := fmt.Sprint(i)
req := httptest.NewRequest("GET", "/users/"+id, nil)
req.Header.Set("X-Request-ID", "request"+id)
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
var body struct {
Data string
RequestID string `json:"requestId"`
}
err := json.Unmarshal(w.Body.Bytes(), &body)
if err != nil || body.Data != id || body.RequestID != "request"+id || w.Header().Get("X-Request-ID") != "request"+id {
t.Errorf("request %s leaked state: %s, error=%v", id, w.Body, err)
}
}(i)
}
workers.Wait()
})
t.Run("middleware panic", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/panic", func(c *httpx.Context) error {
t.Error("handler reached after middleware panic")
return nil
}, func(httpx.Handler) httpx.Handler {
return func(*httpx.Context) error { panic("private middleware state") }
})
assertEnvelope(t, request(a, "GET", "/panic", ""), 500, "INTERNAL_ERROR")
})
t.Run("write failure preserves committed response", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/write", func(c *httpx.Context) error { return c.Created("value") })
w := &failedWriter{ResponseRecorder: httptest.NewRecorder()}
a.ServeHTTP(w, httptest.NewRequest("GET", "/write", nil))
if w.Code != 201 || w.headers != 1 || w.writes != 1 {
t.Fatalf("error response appended after failed write: status=%d headers=%d writes=%d", w.Code, w.headers, w.writes)
}
})
t.Run("expired deadline reaches service", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/deadline", func(c *httpx.Context) error {
if !errors.Is(c.Context().Err(), context.DeadlineExceeded) {
t.Errorf("deadline lost: %v", c.Context().Err())
}
select {
case <-c.Context().Done():
default:
t.Error("expired request context was replaced")
}
return c.Context().Err()
})
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
req := httptest.NewRequest("GET", "/deadline", nil).WithContext(ctx)
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
assertEnvelope(t, w, 500, "INTERNAL_ERROR")
})
t.Run("client cancellation reaches service", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
started := make(chan struct{})
done := make(chan error, 1)
stop := make(chan struct{})
a.GET("/wait/:id", func(c *httpx.Context) error {
close(started)
err := waitService(c.Context(), stop)
done <- err
return err
})
server := httptest.NewServer(a)
ctx, cancel := context.WithCancel(context.Background())
defer func() { cancel(); close(stop); server.Close() }()
clientDone := make(chan error, 1)
req, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/wait/42", nil)
go func() {
resp, err := server.Client().Do(req)
if resp != nil {
resp.Body.Close()
}
clientDone <- err
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("handler did not start")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("service cancellation=%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("client cancellation did not reach service")
}
select {
case err := <-clientDone:
if !errors.Is(err, context.Canceled) {
t.Fatalf("client cancellation=%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("client did not return")
}
})
t.Run("HEAD over a real connection", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.Any("/head", func(c *httpx.Context) error { return c.OK("body") })
server := httptest.NewServer(a)
defer server.Close()
req, _ := http.NewRequest("HEAD", server.URL+"/head", nil)
client := server.Client()
client.Timeout = 5 * time.Second
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != 200 || len(body) != 0 || resp.Header.Get("X-Request-ID") == "" {
t.Fatalf("HEAD status=%d body=%q error=%v", resp.StatusCode, body, err)
}
})
}
func businessResult(ctx context.Context, logger *logx.Logger, deadline time.Time, id, size int, name string) (any, error) {
got, ok := ctx.Deadline()
if !ok || !got.Equal(deadline) || ctx.Value(contextKey{}) != "original" || logx.RequestID(ctx) != "businessRequest" {
return nil, errors.New("service lost request context")
}
logger.InfoContext(ctx, "business.read", "userId", id)
return map[string]any{"userId": id, "pageSize": size, "name": name}, nil
}
func waitService(ctx context.Context, stop <-chan struct{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-stop:
return errors.New("test cleanup")
}
}
func request(handler http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func assertEnvelope(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
t.Helper()
var body struct {
Code string
RequestID string `json:"requestId"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid response JSON: %s: %v", w.Body, err)
}
if w.Code != status || body.Code != code || body.RequestID == "" || body.RequestID != w.Header().Get("X-Request-ID") {
t.Fatalf("response=%d %s; want %d %s with requestId", w.Code, w.Body, status, code)
}
}
type failedWriter struct {
*httptest.ResponseRecorder
headers, writes int
}
func (w *failedWriter) WriteHeader(status int) {
w.headers++
w.ResponseRecorder.WriteHeader(status)
}
func (w *failedWriter) Write([]byte) (int, error) {
w.writes++
return 0, io.ErrClosedPipe
}

View File

@@ -0,0 +1,198 @@
// Package transporttest provides the reusable acceptance suite for GoOmni
// router adapters. Import it only from tests and pass a fresh router factory.
// Register routes before serving; the suite exercises concurrent requests.
package transporttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
// Run verifies routing and the same business handlers on a supplied adapter.
// Factories are called serially and must return a fresh, empty router.
func Run(t *testing.T, newRouter func() httpx.Router) {
t.Helper()
t.Run("routing", func(t *testing.T) { routing(t, newRouter) })
t.Run("registration", func(t *testing.T) { registration(t, newRouter) })
t.Run("application", func(t *testing.T) { application(t, newRouter) })
}
type contextKey struct{}
type routeResult struct {
Pattern string `json:"pattern"`
Params map[string]string `json:"params"`
Value string `json:"value"`
}
func routing(t *testing.T, newRouter func() httpx.Router) {
r := newRouter()
echo := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
value, _ := req.Context().Value(contextKey{}).(string)
_ = json.NewEncoder(w).Encode(routeResult{
Pattern: httpx.RoutePattern(req.Context()), Params: httpx.RouteParams(req.Context()), Value: value,
})
})
for _, path := range []string{
"/", "/users/:id", "/users/me", "/files/*path", "/slash/",
"/choice/:first/fixed/final", "/choice/static/:second/:third",
"/back/static", "/back/:id/details",
} {
r.Handle(http.MethodGet, path, echo)
}
// Registration order must not determine static-vs-parameter precedence.
r.Handle(http.MethodPost, "/users/me", echo)
r.Handle(http.MethodPost, "/users/:id", echo)
r.Handle(http.MethodHead, "/head", echo)
r.Handle(http.MethodOptions, "/head", echo)
r.Handle("REPORT", "/report", echo)
group := r.Group("/api/").Group("v1")
group.Handle(http.MethodGet, "/users/:id", echo)
group.Handle(http.MethodGet, "", echo)
group.Handle(http.MethodGet, "/", echo)
group.Any("/any", echo)
r.Any("/any", echo)
cases := []struct {
method, path, pattern, param, value string
status int
allow string
}{
{"GET", "/", "/", "", "", 200, ""},
{"GET", "/users/42?x=y", "/users/:id", "id", "42", 200, ""},
{"GET", "/users/me", "/users/me", "", "", 200, ""},
{"POST", "/users/me", "/users/me", "", "", 200, ""},
{"GET", "/users/%E4%B8%AD%E6%96%87", "/users/:id", "id", "中文", 200, ""},
{"GET", "/users/a%2Fb", "", "", "", 404, ""},
{"GET", "/users/a+b", "/users/:id", "id", "a+b", 200, ""},
{"GET", "/users/", "", "", "", 404, ""},
{"GET", "/users/42/", "", "", "", 404, ""},
{"GET", "/users//42", "", "", "", 404, ""},
{"GET", "/Users/42", "", "", "", 404, ""},
{"GET", "/slash", "", "", "", 404, ""},
{"GET", "/slash/", "/slash/", "", "", 200, ""},
{"GET", "/files", "", "", "", 404, ""},
{"GET", "/files/", "/files/*path", "path", "/", 200, ""},
{"GET", "/files/a%2Fb/c", "/files/*path", "path", "/a/b/c", 200, ""},
{"GET", "/files//a", "/files/*path", "path", "//a", 200, ""},
{"GET", "/choice/static/fixed/final", "/choice/static/:second/:third", "second", "fixed", 200, ""},
{"GET", "/back/static/details", "/back/:id/details", "id", "static", 200, ""},
{"GET", "/api/v1/users/42", "/api/v1/users/:id", "id", "42", 200, ""},
{"GET", "/api/v1", "/api/v1", "", "", 200, ""},
{"GET", "/api/v1/", "/api/v1/", "", "", 200, ""},
{"REPORT", "/report", "/report", "", "", 200, ""},
{"HEAD", "/head", "/head", "", "", 200, ""},
{"OPTIONS", "/head", "/head", "", "", 200, ""},
{"HEAD", "/users/42", "", "", "", 405, "GET, POST"},
{"OPTIONS", "/users/42", "", "", "", 405, "GET, POST"},
{"DELETE", "/users/me", "", "", "", 405, "GET, POST"},
{"GET", "/head", "", "", "", 405, "HEAD, OPTIONS"},
{"GET", "/missing", "", "", "", 404, ""},
{"REPORT", "/any", "", "", "", 405, "CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE"},
}
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "HEAD", "OPTIONS", "DELETE", "CONNECT", "TRACE"} {
for _, path := range []string{"/any", "/api/v1/any"} {
cases = append(cases, struct {
method, path, pattern, param, value string
status int
allow string
}{method, path, path, "", "", 200, ""})
}
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
// CONNECT has authority-form handling in net/http's parser; the
// adapter contract consumes an already parsed URL.Path.
if tc.method == "CONNECT" {
req.URL.Path = tc.path
}
req = req.WithContext(context.WithValue(req.Context(), contextKey{}, "preserved"))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tc.status || w.Header().Get("Allow") != tc.allow || w.Header().Get("Location") != "" {
t.Fatalf("status=%d allow=%q location=%q body=%s", w.Code, w.Header().Get("Allow"), w.Header().Get("Location"), w.Body)
}
if tc.status != 200 {
want := "404 page not found\n"
if tc.status == 405 {
want = "Method Not Allowed\n"
}
if w.Body.String() != want || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("unexpected fallback response: %s", w.Body)
}
return
}
var got routeResult
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
wantParams := map[string]string{}
if tc.param != "" {
wantParams[tc.param] = tc.value
}
// Routes with several parameters are checked in full.
if tc.pattern == "/choice/static/:second/:third" {
wantParams["third"] = "final"
}
if got.Pattern != tc.pattern || !reflect.DeepEqual(got.Params, wantParams) || got.Value != "preserved" {
t.Fatalf("route metadata=%+v, want %s %v with original context", got, tc.pattern, wantParams)
}
})
}
}
func registration(t *testing.T, newRouter func() httpx.Router) {
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
cases := []struct {
name string
register func(httpx.Router)
}{
{"nil handler", func(r httpx.Router) { r.Handle("GET", "/", nil) }},
{"empty method", func(r httpx.Router) { r.Handle("", "/", handler) }},
{"lowercase method", func(r httpx.Router) { r.Handle("get", "/", handler) }},
}
for _, path := range []string{"", "relative", "/:", "/*", "/a/*rest/b", "/a/:id/:id", "/a//b", "/a/../b", "/a?b", "/prefix:id", "/:a:b"} {
cases = append(cases, struct {
name string
register func(httpx.Router)
}{"invalid " + path, func(r httpx.Router) { r.Handle("GET", path, handler) }})
}
for _, pair := range [][2]string{
{"/users", "/users"}, {"/users/:id", "/users/:name/more"},
{"/files/*path", "/files/static"}, {"/files/static", "/files/*path"},
{"/", "/*path"}, {"/*path", "/"},
} {
cases = append(cases, struct {
name string
register func(httpx.Router)
}{"conflict " + pair[0] + " " + pair[1], func(r httpx.Router) {
r.Handle("GET", pair[0], handler)
r.Handle("GET", pair[1], handler)
}})
}
cases = append(cases, struct {
name string
register func(httpx.Router)
}{"Any conflicts with explicit method", func(r httpx.Router) {
r.Any("/any", handler)
r.Handle("GET", "/any", handler)
}})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("registration must panic")
}
}()
tc.register(newRouter())
})
}
}

View File

@@ -21,7 +21,7 @@ func newValidator() *validator.Validate {
}
// JSON binds and validates a JSON request body using standard-library HTTP
// types. It intentionally does not depend on Gin.
// types. It intentionally does not depend on a network framework.
func JSON(request *http.Request, value any) error {
if request == nil || request.Body == nil {
return apierrors.BadRequest("INVALID_BODY", "request body is invalid")