Compare commits
21 Commits
v0.1.0
...
v0.6.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e415ca52 | |||
| 9c9c8ab59a | |||
| e6c82a01fe | |||
| 847621d218 | |||
| 4daf3b211c | |||
| 62b0f09546 | |||
| 694374fde8 | |||
| 22f1c9ebfb | |||
| b3cb9e8857 | |||
| efd3163902 | |||
| 241a568c83 | |||
| 22939fdb0b | |||
| 3d5f225fb2 | |||
| c36bb70f96 | |||
| 05b4088a25 | |||
| 3bbf4627a1 | |||
| 61c37e0dba | |||
| 5561795d80 | |||
| affd02d5f7 | |||
| ff95692edb | |||
| 0bd71b17b4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ bin/
|
||||
dist/
|
||||
*.log
|
||||
coverage.out
|
||||
__pycache__/
|
||||
|
||||
77
CHANGELOG.md
Normal file
77
CHANGELOG.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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` 和数据库就绪探针;默认仍使用内存存储。
|
||||
- 两种存储模式的脚手架均提供完整 CRUD、分页、输入校验与统一响应。新生成项目的 `POST /users` 从直接返回用户对象改为标准响应封装,用户对象位于 `data`。
|
||||
- MySQL 启动时检查连接和表结构,失败时清理连接池;数据库停机后 readiness 返回 503,恢复后自动重新检查。
|
||||
- MySQL Repository 支持 `*sql.DB` 和 `*sql.Tx`,可在同一事务中复用业务查询。
|
||||
- 修复 `MaxIdleConns=0` 未生效的问题,数据库健康检查增加超时限制。
|
||||
- 新增隔离 MySQL 容器验收脚本,覆盖真实 CRUD、事务提交和错误/panic/取消回滚、持久化、资源清理及数据库停机恢复。
|
||||
|
||||
## v0.1.1
|
||||
|
||||
- 修复脚手架生成项目固定依赖不存在的 `v0.0.0`;远程安装的 CLI 默认使用自身版本,支持 `--framework-version` 指定完整版本号。
|
||||
- 显式远程版本禁用自动本地替换;本地替换路径支持空格,并按精确 module path 识别框架目录。
|
||||
- 生成项目通过 `signal.NotifyContext` 接入优雅退出,启动过程中的错误会先执行 defer 清理再报告。
|
||||
- `App.Run` 自动处理 `SIGINT` / `SIGTERM`;`RunContext` 仍由调用方控制,退出超时会关闭活跃连接。
|
||||
- 增加请求排空、超时取消、启动失败清理测试,以及远程脚手架和真实进程信号验收脚本。
|
||||
|
||||
## v0.1.0
|
||||
|
||||
- 首个可试用版本:应用生命周期、HTTP 抽象、Gin 与标准库路由适配器、统一响应与错误。
|
||||
- 提供配置、日志、校验、认证接口、数据库基础能力、健康探针和项目脚手架。
|
||||
84
PERFORMANCE.md
Normal file
84
PERFORMANCE.md
Normal 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 决定是否优化对象分配或事件状态机。
|
||||
91
README.md
91
README.md
@@ -1,13 +1,32 @@
|
||||
# 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.1.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.6.0-preview
|
||||
go-omni new --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。
|
||||
具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。
|
||||
|
||||
需要数据库持久化时,使用 `go-omni new --database mysql --module example.com/billing ./billing`。
|
||||
生成项目包含完整用户 CRUD、数据库 Repository、ServiceContext、建表脚本和就绪探针。
|
||||
先应用生成的 `schema.sql`,再配置 `GO_OMNI_DB_DSN`;启动时会检查连接与表结构,退出时释放连接池。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
@@ -23,9 +42,10 @@ go-omni/
|
||||
├── validate/ # 参数校验
|
||||
├── auth/ # 认证和权限接口
|
||||
├── database/ # database/sql 生命周期和事务
|
||||
├── transport/gin/ # Gin 网络适配器,可替换
|
||||
├── transport/nethttp/ # 标准库网络适配器
|
||||
├── transport/native/ # 自研 HTTP/1.1 服务端预览
|
||||
├── examples/ # 示例项目
|
||||
└── cmd/ # 后续 CLI
|
||||
└── cmd/ # go-omni 项目脚手架
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
@@ -63,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 {
|
||||
@@ -104,7 +154,8 @@ application.OnShutdown(logger.Close)
|
||||
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由
|
||||
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
|
||||
|
||||
`App` 同时实现标准 `http.Handler`,并提供 `RunContext`:收到 `SIGINT` / `SIGTERM` 后可以先停止接收新请求,再在超时时间内等待活跃请求完成。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
||||
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
|
||||
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
||||
|
||||
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
|
||||
|
||||
@@ -134,7 +185,11 @@ application.GET("/health/ready", checker.ReadinessHandler())
|
||||
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
|
||||
|
||||
```go
|
||||
services := servicecontext.New(logger, users)
|
||||
services, err := servicecontext.New(ctx, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer services.Close()
|
||||
userService := service.NewUsers(services)
|
||||
```
|
||||
|
||||
@@ -142,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
|
||||
@@ -150,8 +215,10 @@ 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` 注册,无需导入网络库。
|
||||
|
||||
## 当前边界
|
||||
|
||||
缓存、消息队列、定时任务和 CLI 暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
||||
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
||||
|
||||
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
54
ROADMAP.md
Normal file
54
ROADMAP.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# GoOmni 成熟度计划
|
||||
|
||||
## 1. 新项目启动与发布流程(已完成,v0.1.1)
|
||||
|
||||
- 修正脚手架固定版本引用,保留显式本地开发选项。
|
||||
- 生成服务处理退出信号,等待请求完成后清理资源。
|
||||
- 验收:源码目录外安装远程 CLI → 生成无 replace 的项目 → 编译 → 请求健康接口和业务接口 → SIGINT/SIGTERM 正常退出。
|
||||
- 自动验收入口:`python3 scripts/verifyScaffold.py --version v0.1.1`。
|
||||
- 已通过全量 race 测试、vet、build,以及从远程提交安装 CLI 后的无 replace 端到端验收;覆盖生成服务和 `App.Run` 的两种退出信号。
|
||||
|
||||
## 2. 业务数据接入(已完成,v0.2.0)
|
||||
|
||||
- 数据库初始化、Repository 和 ServiceContext 组合成完整业务启动流程。
|
||||
- Readiness 注册数据库探针,启动失败与服务退出时清理连接池。
|
||||
- 验收:真实数据库 CRUD、事务回滚、数据库不可用时 readiness 返回 503。
|
||||
- `go-omni new --database mysql` 已生成完整数据接入流程,默认内存模式仍然可用。
|
||||
- 独立 MySQL 8.4 容器已验证 CRUD、唯一邮箱、分页、服务重启持久化、事务提交与错误/panic/取消回滚。
|
||||
- 已验证初始化失败、缺少表结构、正常退出时关闭连接池,以及数据库停止后的 readiness 503、恢复后的 200。
|
||||
- 自动验收入口:`python3 scripts/verifyDatabase.py --version v0.2.0`;结束后清理本次测试容器、匿名卷和临时项目。
|
||||
|
||||
## 3. 网络适配一致性(已完成,v0.4.0)
|
||||
|
||||
- 标准库适配器运行公共契约测试,并为自研网络层保留相同的接入边界。
|
||||
- 覆盖路由参数、分组、中间件、错误响应和取消传播。
|
||||
- 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。
|
||||
- 新增公共 `transport/transporttest.Run`,自研适配器可直接复用;覆盖静态 / 参数 / 通配路由、分组、405 Allow、鉴权短路、错误与 panic、响应提交、requestId、并发隔离、截止时间及真实客户端取消。
|
||||
- 标准库路由实现明确的路径、方法、尾部斜杠、通配参数、Any 和 404/405 约定;不再引入 Gin 依赖。
|
||||
- 完整用户 CRUD 已用标准库适配器验证;自研适配器可直接复用同一公共契约测试包。
|
||||
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.4.0`,从源码目录外消费远程版本与公共契约测试包。
|
||||
|
||||
## 4. 实际业务试用(已完成,v0.4.0)
|
||||
|
||||
- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.4.0`,全程无本地 replace。
|
||||
- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。
|
||||
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
|
||||
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.4.0`。
|
||||
|
||||
## 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、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||
63
app/app.go
63
app/app.go
@@ -3,15 +3,19 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
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
|
||||
@@ -25,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
|
||||
@@ -64,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.
|
||||
@@ -104,9 +115,12 @@ func (a *App) Close() error {
|
||||
return a.closeErr
|
||||
}
|
||||
|
||||
// Run starts the HTTP server. The default address is :8080.
|
||||
// Run starts the HTTP server and handles SIGINT/SIGTERM. Use RunContext when
|
||||
// the caller owns cancellation. The default address is :8080.
|
||||
func (a *App) Run(address ...string) error {
|
||||
return a.RunContext(context.Background(), address...)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return a.RunContext(ctx, address...)
|
||||
}
|
||||
|
||||
// NewServer creates a standard HTTP server around the application.
|
||||
@@ -125,6 +139,9 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return a.Close()
|
||||
}
|
||||
listenOn := a.config.Address
|
||||
if listenOn == "" {
|
||||
listenOn = ":8080"
|
||||
@@ -133,8 +150,35 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
listenOn = address[0]
|
||||
}
|
||||
server := a.NewServer(listenOn)
|
||||
listener, err := net.Listen("tcp", server.Addr)
|
||||
if err != nil {
|
||||
return errors.Join(err, a.Close())
|
||||
}
|
||||
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.ListenAndServe() }()
|
||||
go func() { serveErr <- server.Serve(listener) }()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
@@ -151,6 +195,11 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
shutdownErr := server.Shutdown(shutdownCtx)
|
||||
if shutdownErr != nil {
|
||||
// Shutdown leaves active connections open on timeout. Close them so
|
||||
// request contexts are canceled before releasing shared resources.
|
||||
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||
}
|
||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||
shutdownErr = errors.Join(shutdownErr, err)
|
||||
}
|
||||
|
||||
163
app/lifecycle_test.go
Normal file
163
app/lifecycle_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
)
|
||||
|
||||
type closingListener struct {
|
||||
net.Listener
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (l *closingListener) Close() error {
|
||||
err := l.Listener.Close()
|
||||
l.once.Do(func() { close(l.closed) })
|
||||
return err
|
||||
}
|
||||
|
||||
func startTestServer(t *testing.T, application *App) (*closingListener, context.CancelFunc, <-chan error) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- application.serveContext(ctx, application.NewServer(""), wrapped) }()
|
||||
return wrapped, cancel, done
|
||||
}
|
||||
|
||||
func await(t *testing.T, event <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-event:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for server event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownDrainsRequestBeforeCleanup(t *testing.T) {
|
||||
application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(3*time.Second))
|
||||
started, release, cleaned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
t.Cleanup(unblock)
|
||||
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
return ctx.OK("finished")
|
||||
})
|
||||
application.OnShutdown(func() error { close(cleaned); return nil })
|
||||
listener, cancel, done := startTestServer(t, application)
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Get("http://" + listener.Addr().String() + "/slow")
|
||||
if err == nil {
|
||||
defer response.Body.Close()
|
||||
_, err = io.ReadAll(response.Body)
|
||||
if response.StatusCode != http.StatusOK {
|
||||
err = errors.New("request was not completed successfully")
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
await(t, started)
|
||||
cancel()
|
||||
await(t, listener.closed)
|
||||
select {
|
||||
case <-cleaned:
|
||||
t.Fatal("resources closed while a request was still running")
|
||||
default:
|
||||
}
|
||||
unblock()
|
||||
if err := <-requestDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server did not finish shutdown")
|
||||
}
|
||||
await(t, cleaned)
|
||||
}
|
||||
|
||||
func TestShutdownTimeoutClosesActiveConnection(t *testing.T) {
|
||||
application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(25*time.Millisecond))
|
||||
started, canceled := make(chan struct{}), make(chan struct{})
|
||||
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||
close(started)
|
||||
<-ctx.Context().Done()
|
||||
close(canceled)
|
||||
return nil
|
||||
})
|
||||
listener, cancel, done := startTestServer(t, application)
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Get("http://" + listener.Addr().String() + "/slow")
|
||||
if err == nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
}()
|
||||
await(t, started)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected shutdown deadline error, got %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown ignored timeout")
|
||||
}
|
||||
await(t, canceled)
|
||||
await(t, requestDone)
|
||||
}
|
||||
|
||||
func TestRunContextStartupFailureAndCanceledContextCloseResources(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
for _, canceled := range []bool{false, true} {
|
||||
application := NewWithRouter(nethttp.New())
|
||||
closeCount := 0
|
||||
application.OnShutdown(func() error { closeCount++; return nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if canceled {
|
||||
cancel()
|
||||
}
|
||||
err := application.RunContext(ctx, listener.Addr().String())
|
||||
cancel()
|
||||
if !canceled && err == nil {
|
||||
t.Fatal("expected occupied address error")
|
||||
}
|
||||
if canceled && err != nil {
|
||||
t.Fatalf("canceled start: %v", err)
|
||||
}
|
||||
_ = application.Close()
|
||||
if closeCount != 1 {
|
||||
t.Fatalf("cleanup ran %d times", closeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
12
app_test.go
12
app_test.go
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -1,11 +1,67 @@
|
||||
# go-omni CLI
|
||||
|
||||
生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
|
||||
```sh
|
||||
go run ./cmd/go-omni new --module example.com/billing ./billing
|
||||
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
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的
|
||||
`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。
|
||||
|
||||
需要选择其他已发布版本时,使用完整版本号:
|
||||
|
||||
```sh
|
||||
go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
||||
源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。
|
||||
|
||||
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
||||
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
||||
`--force` 才会覆盖脚手架文件。
|
||||
|
||||
也可以在任意目录显式指定源码路径:
|
||||
|
||||
```sh
|
||||
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
默认生成内存存储版本;选择 MySQL 时:
|
||||
|
||||
```sh
|
||||
go-omni new --database mysql --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库集成测试。
|
||||
先应用建表脚本,再设置 `GO_OMNI_DB_DSN` 启动。ServiceContext 负责连接池初始化、表结构检查、
|
||||
就绪探针注册和关闭。两种模式都提供用户 CRUD、分页、输入校验和统一错误响应。
|
||||
|
||||
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `/users` CRUD。
|
||||
退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。
|
||||
|
||||
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyScaffold.py --version v0.6.0-preview
|
||||
python3 scripts/verifyDatabase.py --version v0.6.0-preview
|
||||
```
|
||||
|
||||
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||
编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的
|
||||
信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。
|
||||
|
||||
发布前验证当前源码:
|
||||
|
||||
```sh
|
||||
go build -o /tmp/go-omni ./cmd/go-omni
|
||||
python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir .
|
||||
python3 scripts/verifyDatabase.py --cli /tmp/go-omni --framework-dir .
|
||||
```
|
||||
|
||||
数据库验收需要本地 Docker,使用独立 MySQL 8.4 容器;不会连接已有业务数据库,结束后清理测试容器与数据卷。
|
||||
|
||||
@@ -39,6 +39,17 @@ func runNew(args []string) error {
|
||||
switch {
|
||||
case arg == "--force":
|
||||
options.Force = true
|
||||
case arg == "--database":
|
||||
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||
return errors.New("--database requires memory or mysql")
|
||||
}
|
||||
index++
|
||||
options.Database = args[index]
|
||||
case strings.HasPrefix(arg, "--database="):
|
||||
options.Database = strings.TrimPrefix(arg, "--database=")
|
||||
if strings.TrimSpace(options.Database) == "" {
|
||||
return errors.New("--database requires memory or mysql")
|
||||
}
|
||||
case arg == "--module":
|
||||
if index+1 >= len(args) {
|
||||
return errors.New("--module requires a value")
|
||||
@@ -55,6 +66,17 @@ func runNew(args []string) error {
|
||||
options.FrameworkDir = args[index]
|
||||
case strings.HasPrefix(arg, "--framework-dir="):
|
||||
options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=")
|
||||
case arg == "--framework-version":
|
||||
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||
return errors.New("--framework-version requires a value")
|
||||
}
|
||||
index++
|
||||
options.FrameworkVersion = args[index]
|
||||
case strings.HasPrefix(arg, "--framework-version="):
|
||||
options.FrameworkVersion = strings.TrimPrefix(arg, "--framework-version=")
|
||||
if strings.TrimSpace(options.FrameworkVersion) == "" {
|
||||
return errors.New("--framework-version requires a value")
|
||||
}
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return fmt.Errorf("unknown option %q", arg)
|
||||
case target == "":
|
||||
@@ -82,4 +104,7 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
||||
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
||||
fmt.Fprintln(os.Stderr, " --database <memory|mysql> Select generated storage (default memory)")
|
||||
}
|
||||
|
||||
51
cmd/go-omni/main_test.go
Normal file
51
cmd/go-omni/main_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.1.0"}, {"--framework-version=v0.1.0"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "go-omni v0.1.0") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsEmptyFrameworkVersion(t *testing.T) {
|
||||
for _, args := range [][]string{{"--framework-version"}, {"--framework-version="}, {"--framework-version", " "}} {
|
||||
if err := runNew(args); err == nil {
|
||||
t.Fatalf("accepted missing framework version: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMySQLDatabaseOptions(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--database", "mysql"}, {"--database=mysql"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(project, "schema.sql")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"--database"}, {"--database="}, {"--database", " "}} {
|
||||
if err := runNew(args); err == nil {
|
||||
t.Fatalf("accepted missing database type: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
@@ -20,6 +22,8 @@ type Options struct {
|
||||
Dir string
|
||||
Module string
|
||||
FrameworkDir string
|
||||
FrameworkVersion string
|
||||
Database string
|
||||
Force bool
|
||||
}
|
||||
|
||||
@@ -28,6 +32,8 @@ var files = map[string]string{
|
||||
"README.md": "templates/README.md.tmpl",
|
||||
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
|
||||
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
|
||||
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
|
||||
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
|
||||
"internal/repository/memory.go": "templates/internal-repository-memory.go.tmpl",
|
||||
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
|
||||
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
|
||||
@@ -42,6 +48,20 @@ func Generate(options Options) error {
|
||||
if strings.TrimSpace(options.Module) == "" {
|
||||
return errors.New("module path is required")
|
||||
}
|
||||
database := strings.ToLower(strings.TrimSpace(options.Database))
|
||||
if database == "" {
|
||||
database = "memory"
|
||||
}
|
||||
if database != "memory" && database != "mysql" {
|
||||
return fmt.Errorf("unsupported database %q: choose memory or mysql", database)
|
||||
}
|
||||
version := strings.TrimSpace(options.FrameworkVersion)
|
||||
if version == "" {
|
||||
version = currentFrameworkVersion()
|
||||
}
|
||||
if !validFrameworkVersion(version) {
|
||||
return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version)
|
||||
}
|
||||
|
||||
directory, err := filepath.Abs(options.Dir)
|
||||
if err != nil {
|
||||
@@ -54,20 +74,43 @@ func Generate(options Options) error {
|
||||
return fmt.Errorf("create project directory: %w", err)
|
||||
}
|
||||
|
||||
// An explicit remote version opts out of automatic local development mode.
|
||||
replace := ""
|
||||
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
||||
replace = frameworkReplace(directory, options.FrameworkDir)
|
||||
}
|
||||
data := templateData{
|
||||
Module: options.Module,
|
||||
FrameworkReplace: frameworkReplace(directory, options.FrameworkDir),
|
||||
FrameworkVersion: version,
|
||||
FrameworkReplace: replace,
|
||||
Database: database,
|
||||
}
|
||||
paths := make([]string, 0, len(files))
|
||||
for path := range files {
|
||||
projectFiles := make(map[string]string, len(files)+3)
|
||||
for path, source := range files {
|
||||
projectFiles[path] = source
|
||||
}
|
||||
if database == "mysql" {
|
||||
projectFiles["internal/repository/mysql.go"] = "templates/internal-repository-mysql.go.tmpl"
|
||||
projectFiles["schema.sql"] = "templates/schema.sql.tmpl"
|
||||
projectFiles["internal/servicecontext/databaseIntegration_test.go"] = "templates/databaseIntegration_test.go.tmpl"
|
||||
}
|
||||
paths := make([]string, 0, len(projectFiles))
|
||||
for path := range projectFiles {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
content, err := render(files[path], data)
|
||||
content, err := render(projectFiles[path], data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render %s: %w", path, err)
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") {
|
||||
formatted, err := format.Source([]byte(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("format %s: %w", path, err)
|
||||
}
|
||||
content = string(formatted)
|
||||
}
|
||||
target := filepath.Join(directory, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
||||
@@ -88,7 +131,9 @@ func Generate(options Options) error {
|
||||
|
||||
type templateData struct {
|
||||
Module string
|
||||
FrameworkVersion string
|
||||
FrameworkReplace string
|
||||
Database string
|
||||
}
|
||||
|
||||
func render(name string, data templateData) (string, error) {
|
||||
@@ -149,7 +194,7 @@ func frameworkReplace(projectDir, configured string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relative)
|
||||
return strconv.Quote(filepath.ToSlash(relative))
|
||||
}
|
||||
|
||||
func canonicalPath(path string) string {
|
||||
@@ -172,9 +217,15 @@ func findFrameworkRoot() string {
|
||||
for {
|
||||
moduleFile := filepath.Join(directory, "go.mod")
|
||||
content, readErr := os.ReadFile(moduleFile)
|
||||
if readErr == nil && strings.Contains(string(content), "module git.yuntuo.hk/k2safe/go-omni") {
|
||||
if readErr == nil {
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == "module" && strings.Trim(fields[1], "\"") == frameworkModule {
|
||||
return directory
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
return ""
|
||||
|
||||
@@ -3,6 +3,8 @@ package scaffold
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -37,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read generated server: %v", err)
|
||||
}
|
||||
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.Run(settings.Address)"} {
|
||||
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(ctx, logger)", "services.Health.ReadinessHandler()", "application.RunContext(ctx, settings.Address)"} {
|
||||
if !strings.Contains(string(mainContent), expected) {
|
||||
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
||||
}
|
||||
@@ -54,6 +56,35 @@ func TestGenerateRejectsNonEmptyDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMySQLLayout(t *testing.T) {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", Database: "mysql", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"schema.sql", "internal/repository/mysql.go", "internal/repository/users.go", "internal/model/errors.go", "internal/servicecontext/databaseIntegration_test.go"} {
|
||||
if _, err := os.Stat(filepath.Join(project, path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "github.com/go-sql-driver/mysql v1.9.3") {
|
||||
t.Fatal("mysql driver version is not pinned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsUnknownDatabaseBeforeWriting(t *testing.T) {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", Database: "unknown"}); err == nil {
|
||||
t.Fatal("accepted unknown backend")
|
||||
}
|
||||
if _, err := os.Stat(project); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid backend created files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
||||
root := frameworkRoot(t)
|
||||
project := filepath.Join(t.TempDir(), "generated")
|
||||
@@ -73,11 +104,59 @@ func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replace != filepath.ToSlash(expected) {
|
||||
if replace != strconv.Quote(filepath.ToSlash(expected)) {
|
||||
t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRemoteProjectPinsVersion(t *testing.T) {
|
||||
for _, version := range []string{"v0.1.0", "v1.2.3-rc.1", "v0.0.0-20260920140624-8b19aa79eea0"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "require "+frameworkModule+" "+version+"\n") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("expected pinned remote dependency without replace: %s", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsInvalidVersionBeforeWriting(t *testing.T) {
|
||||
for _, version := range []string{"main", "latest", "v1.2", "v01.2.3", "v2.0.0", "v0.1.0-01", "v0.1.0\nreplace bad => ./bad"} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err == nil {
|
||||
t.Fatalf("accepted invalid version %q", version)
|
||||
}
|
||||
if _, err := os.Stat(project); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid version %q created a project: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkVersionUsesInstalledModuleMetadata(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
info *debug.BuildInfo
|
||||
want string
|
||||
}{
|
||||
{nil, defaultFrameworkVersion},
|
||||
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "(devel)"}}, defaultFrameworkVersion},
|
||||
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.1.0+dirty"}}, defaultFrameworkVersion},
|
||||
{&debug.BuildInfo{Main: debug.Module{Path: "example.com/unrelated", Version: "v1.2.3"}}, defaultFrameworkVersion},
|
||||
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.9.2", Sum: "h1:test"}}, "v0.9.2"},
|
||||
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.0.0-20260920140624-8b19aa79eea0", Sum: "h1:test"}}, "v0.0.0-20260920140624-8b19aa79eea0"},
|
||||
} {
|
||||
if got := frameworkVersion(test.info); got != test.want {
|
||||
t.Fatalf("version = %q, want %q", got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func frameworkRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
directory, err := os.Getwd()
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
This service was generated with `go-omni new`.
|
||||
|
||||
Framework dependency: `git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}`.
|
||||
|
||||
Storage: `{{.Database}}`.
|
||||
|
||||
{{if eq .Database "mysql"}}
|
||||
先创建业务数据库,再执行项目的建表脚本(替换示例数据库名、账号和地址):
|
||||
|
||||
```sh
|
||||
mysql -h 127.0.0.1 -u app -p app < schema.sql
|
||||
```
|
||||
|
||||
配置数据库连接后启动;初始化失败或缺少表结构时,服务会退出并关闭已创建的连接池:
|
||||
|
||||
```sh
|
||||
export GO_OMNI_DB_DSN='app:password@tcp(127.0.0.1:3306)/app?parseTime=true'
|
||||
```
|
||||
|
||||
应用只检查表结构,不会自动建库、建表或迁移。MySQL 驱动由本项目注册,核心框架仍依赖标准库 `database/sql`。
|
||||
{{else}}
|
||||
默认使用进程内存存储,服务重启后数据清空。需要 MySQL 时,生成新项目时选择 `--database mysql`。
|
||||
{{end}}
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
@@ -16,7 +38,67 @@ GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server
|
||||
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
|
||||
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
|
||||
|
||||
The generated layout keeps HTTP handlers, business services, repositories,
|
||||
models and a project-owned ServiceContext separate. Replace the memory
|
||||
repository with a database implementation when the service is ready for
|
||||
persistence, then add it to `internal/servicecontext`.
|
||||
服务使用 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`。部署时构建并运行服务二进制,
|
||||
使信号直接到达服务进程:
|
||||
|
||||
```sh
|
||||
go build -o server ./cmd/server
|
||||
./server
|
||||
```
|
||||
|
||||
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。
|
||||
{{if eq .Database "mysql"}}
|
||||
`/health/ready` 已注册数据库探针:连接失败返回 `503`,数据库恢复后重新返回 `200`。
|
||||
`/health/live` 始终只检查服务存活。`GO_OMNI_DB_PING_TIMEOUT` 限制初始化检查和数据库探针的等待时间。
|
||||
退出时先排空 HTTP 请求,再关闭数据库连接池,最后关闭日志。
|
||||
|
||||
数据库连接池参数:
|
||||
|
||||
```text
|
||||
GO_OMNI_DB_MAX_OPEN_CONNS=10
|
||||
GO_OMNI_DB_MAX_IDLE_CONNS=5
|
||||
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
|
||||
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
|
||||
GO_OMNI_DB_PING_TIMEOUT=5s
|
||||
```
|
||||
|
||||
`MAX_IDLE_CONNS=0` 禁用空闲连接,`MAX_OPEN_CONNS=0` 表示不限制连接数。
|
||||
项目会启用 MySQL `parseTime` 并按 UTC 读取 DATETIME 字段,JSON 字段保持 lowerCamelCase。
|
||||
|
||||
事务内复用 Repository(在业务代码中导入 `database/sql` 和项目的 `internal/repository`):
|
||||
|
||||
```go
|
||||
err := services.DB.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
users := repository.NewMySQLUsers(tx)
|
||||
_, err := users.Create(ctx, input)
|
||||
return err
|
||||
})
|
||||
```
|
||||
|
||||
业务函数返回错误、发生 panic 或 context 取消时会回滚。事务内必须使用基于 `tx` 创建的 Repository。
|
||||
|
||||
可选集成测试(只针对已应用 schema.sql 的独立测试数据库):
|
||||
|
||||
```sh
|
||||
GO_OMNI_TEST_MYSQL_DSN='app:password@tcp(127.0.0.1:3306)/test?parseTime=true' go test -race ./...
|
||||
```
|
||||
|
||||
未设置 `GO_OMNI_TEST_MYSQL_DSN` 时跳过数据库集成测试。框架仓库中的 `scripts/verifyDatabase.py`
|
||||
可以自动创建、验收和清理独立的 MySQL 容器。
|
||||
{{else}}
|
||||
内存模式没有外部依赖探针;后续添加依赖时,在 `internal/servicecontext` 注册检查与资源清理。
|
||||
{{end}}
|
||||
|
||||
示例接口(无认证,接入实际业务时按项目需求添加认证中间件):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/users` | 创建,JSON 包含 name、email |
|
||||
| GET | `/users/:id` | 查询 |
|
||||
| GET | `/users?page=1&pageSize=20` | 分页 |
|
||||
| PUT | `/users/:id` | 更新 name、email |
|
||||
| DELETE | `/users/:id` | 删除,返回 204 |
|
||||
|
||||
接口使用 GoOmni 统一响应,包含 code、message、data 和 requestId。邮箱按小写存储,重复返回 409;
|
||||
无效输入返回 400,找不到记录返回 404。项目按 Handler / Service / Repository / Model 分层,
|
||||
基础依赖集中放在 `internal/servicecontext`。
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/handler"
|
||||
"{{.Module}}/internal/repository"
|
||||
"{{.Module}}/internal/service"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
settings, err := config.LoadFromEnv("")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(settings.Log.Service) == "" {
|
||||
settings.Log.Service = "{{.Module}}"
|
||||
}
|
||||
logger, err := logx.New(settings.Log)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
defer logger.Close()
|
||||
// Access logs and business logs share the same configured sink.
|
||||
logx.SetDefault(logger)
|
||||
|
||||
users := repository.NewMemoryUsers()
|
||||
services := servicecontext.New(logger, users)
|
||||
services, err := servicecontext.New(ctx, logger)
|
||||
if err != nil { return err }
|
||||
defer services.Close()
|
||||
userService := service.NewUsers(services)
|
||||
userHandler := handler.NewUsers(userService)
|
||||
checker, err := health.New()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
application := omni.New(omni.WithConfig(settings))
|
||||
application.OnShutdown(logger.Close)
|
||||
application.GET("/health/live", checker.LivenessHandler())
|
||||
application.GET("/health/ready", checker.ReadinessHandler())
|
||||
application.OnShutdown(services.Close)
|
||||
application.GET("/health/live", services.Health.LivenessHandler())
|
||||
application.GET("/health/ready", services.Health.ReadinessHandler())
|
||||
userHandler.RegisterRoutes(application)
|
||||
if err := application.Run(settings.Address); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return application.RunContext(ctx, settings.Address)
|
||||
}
|
||||
|
||||
106
cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl
Normal file
106
cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl
Normal file
@@ -0,0 +1,106 @@
|
||||
package servicecontext_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/model"
|
||||
"{{.Module}}/internal/repository"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
)
|
||||
|
||||
// These tests require a dedicated database with schema.sql applied. Ordinary
|
||||
// go test runs skip them; scripts/verifyDatabase.py provisions an isolated DB.
|
||||
func databaseContext(t *testing.T) *servicecontext.Context {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("GO_OMNI_TEST_MYSQL_DSN")
|
||||
if dsn == "" { t.Skip("set GO_OMNI_TEST_MYSQL_DSN to a dedicated test database") }
|
||||
for _, key := range []string{"MAX_OPEN_CONNS", "MAX_IDLE_CONNS", "CONN_MAX_LIFETIME", "CONN_MAX_IDLE_TIME", "PING_TIMEOUT"} {
|
||||
t.Setenv("GO_OMNI_DB_"+key, "")
|
||||
}
|
||||
t.Setenv("GO_OMNI_DB_DSN", dsn)
|
||||
t.Setenv("GO_OMNI_DB_DRIVER", "mysql")
|
||||
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||
if err != nil { t.Fatal(err) }
|
||||
t.Cleanup(func() { _ = logger.Close() })
|
||||
services, err := servicecontext.New(context.Background(), logger)
|
||||
if err != nil { t.Fatal(err) }
|
||||
t.Cleanup(func() { _ = services.Close() })
|
||||
return services
|
||||
}
|
||||
|
||||
func TestDatabaseTransactions(t *testing.T) {
|
||||
services := databaseContext(t)
|
||||
ctx := context.Background()
|
||||
for _, mode := range []string{"commit", "error", "panic", "cancel"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
email := fmt.Sprintf("transaction-%d-%s@example.com", time.Now().UnixNano(), mode)
|
||||
t.Cleanup(func() { _, _ = services.DB.ExecContext(ctx, "DELETE FROM users WHERE email = ?", email) })
|
||||
txCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
abort := errors.New("abort business operation")
|
||||
var err error
|
||||
var recovered any
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
err = services.DB.InTx(txCtx, nil, func(tx *sql.Tx) error {
|
||||
users := repository.NewMySQLUsers(tx)
|
||||
if _, err := users.Create(txCtx, model.UserInput{Name: "transaction", Email: email}); err != nil { return err }
|
||||
switch mode {
|
||||
case "error": return abort
|
||||
case "panic": panic(abort)
|
||||
case "cancel": cancel(); return txCtx.Err()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
switch mode {
|
||||
case "commit": if err != nil || recovered != nil { t.Fatalf("commit: error=%v panic=%v", err, recovered) }
|
||||
case "error": if !errors.Is(err, abort) { t.Fatalf("lost business error: %v", err) }
|
||||
case "panic": if recovered != abort { t.Fatalf("lost panic: %v", recovered) }
|
||||
case "cancel": if !errors.Is(err, context.Canceled) { t.Fatalf("lost cancellation: %v", err) }
|
||||
}
|
||||
var count int
|
||||
if err := services.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE email = ?", email).Scan(&count); err != nil { t.Fatal(err) }
|
||||
want := 0
|
||||
if mode == "commit" { want = 1 }
|
||||
if count != want { t.Fatalf("%s persisted %d rows, want %d", mode, count, want) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabasePoolHealthAndClose(t *testing.T) {
|
||||
services := databaseContext(t)
|
||||
ctx := context.Background()
|
||||
if result := services.Health.Check(ctx); !result.Ready() || len(result.Checks) != 1 { t.Fatalf("missing database readiness: %+v", result) }
|
||||
|
||||
cfg := services.DB.Config()
|
||||
cfg.MaxOpenConns, cfg.MaxIdleConns = 1, 0
|
||||
pool, err := database.Open(ctx, cfg)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if idle := pool.Stats().Idle; idle != 0 { t.Fatalf("zero idle config retained %d connections", idle) }
|
||||
_ = pool.Close()
|
||||
|
||||
cfg.MaxIdleConns, cfg.PingTimeout = 1, 50*time.Millisecond
|
||||
pool, err = database.Open(ctx, cfg)
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer pool.Close()
|
||||
connection, err := pool.Conn(ctx)
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer connection.Close()
|
||||
started := time.Now()
|
||||
err = pool.Health(ctx)
|
||||
if !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > time.Second { t.Fatalf("health did not bound pool waiting: %v", err) }
|
||||
|
||||
if err := services.Close(); err != nil { t.Fatal(err) }
|
||||
if err := services.Close(); err != nil { t.Fatal(err) }
|
||||
if result := services.Health.Check(ctx); result.Ready() { t.Fatal("closed database was still ready") }
|
||||
}
|
||||
@@ -2,7 +2,9 @@ module {{.Module}}
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.0.0
|
||||
require git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}
|
||||
{{if eq .Database "mysql"}}require github.com/go-sql-driver/mysql v1.9.3
|
||||
{{end}}
|
||||
{{- if .FrameworkReplace}}
|
||||
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
|
||||
{{- end}}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni"
|
||||
"{{.Module}}/internal/model"
|
||||
"{{.Module}}/internal/service"
|
||||
)
|
||||
@@ -12,20 +13,109 @@ type Users struct {
|
||||
service *service.Users
|
||||
}
|
||||
|
||||
func NewUsers(service *service.Users) *Users { return &Users{service: service} }
|
||||
func NewUsers(service *service.Users) *Users {
|
||||
return &Users{service: service}
|
||||
}
|
||||
|
||||
func (h *Users) RegisterRoutes(application *omni.App) {
|
||||
application.POST("/users", h.Create)
|
||||
users := application.Group("/users")
|
||||
users.POST("", h.Create)
|
||||
users.GET("", h.List)
|
||||
users.GET("/:id", h.Get)
|
||||
users.PUT("/:id", h.Update)
|
||||
users.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
// HTTP-specific validation tags stay here, outside the business model.
|
||||
type userBody struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
|
||||
type userPath struct {
|
||||
ID int64 `uri:"id" binding:"gt=0"`
|
||||
}
|
||||
|
||||
type listQuery struct {
|
||||
Page int `form:"page,default=1" binding:"gte=1"`
|
||||
PageSize int `form:"pageSize,default=20" binding:"gte=1,lte=100"`
|
||||
}
|
||||
|
||||
func (h *Users) Create(ctx *omni.Context) error {
|
||||
var input model.UserInput
|
||||
if err := ctx.BindJSON(&input); err != nil {
|
||||
var body userBody
|
||||
if err := ctx.BindJSON(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := h.service.Create(ctx.Context(), input)
|
||||
user, err := h.service.Create(ctx.Request().Context(), model.UserInput(body))
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.Created(user)
|
||||
}
|
||||
|
||||
func (h *Users) Get(ctx *omni.Context) error {
|
||||
var path userPath
|
||||
if err := ctx.BindURI(&path); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := h.service.Get(ctx.Request().Context(), path.ID)
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.OK(user)
|
||||
}
|
||||
|
||||
func (h *Users) List(ctx *omni.Context) error {
|
||||
var query listQuery
|
||||
if err := ctx.BindQuery(&query); err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := h.service.List(ctx.Request().Context(), query.Page, query.PageSize)
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.OK(page)
|
||||
}
|
||||
|
||||
func (h *Users) Update(ctx *omni.Context) error {
|
||||
var path userPath
|
||||
if err := ctx.BindURI(&path); err != nil {
|
||||
return err
|
||||
}
|
||||
var body userBody
|
||||
if err := ctx.BindJSON(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := h.service.Update(ctx.Request().Context(), path.ID, model.UserInput(body))
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.OK(user)
|
||||
}
|
||||
|
||||
func (h *Users) Delete(ctx *omni.Context) error {
|
||||
var path userPath
|
||||
if err := ctx.BindURI(&path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.service.Delete(ctx.Request().Context(), path.ID); err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.NoContent()
|
||||
}
|
||||
|
||||
func userError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrUserNotFound):
|
||||
return omni.NotFound("USER_NOT_FOUND", "user was not found").WithCause(err)
|
||||
case errors.Is(err, model.ErrEmailTaken):
|
||||
return omni.NewError(http.StatusConflict, "EMAIL_TAKEN", "email is already in use").WithCause(err)
|
||||
case errors.Is(err, model.ErrInvalidUser):
|
||||
return omni.BadRequest("INVALID_USER", "name or email is invalid").WithCause(err)
|
||||
case errors.Is(err, model.ErrInvalidPage):
|
||||
return omni.BadRequest("INVALID_QUERY", "pagination is invalid").WithCause(err)
|
||||
default:
|
||||
// GoOmni masks unexpected errors; internal details stay in server logs.
|
||||
return err
|
||||
}
|
||||
return ctx.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
11
cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl
Normal file
11
cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors are translated into HTTP responses only by the handler layer.
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrEmailTaken = errors.New("email already in use")
|
||||
ErrInvalidUser = errors.New("invalid user")
|
||||
ErrInvalidPage = errors.New("invalid pagination")
|
||||
)
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User is the business entity; it has no dependency on HTTP or a database driver.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -10,7 +11,15 @@ type User struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UserInput is shared by creation and full replacement (PUT).
|
||||
type UserInput struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Items []User `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
@@ -2,30 +2,125 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"{{.Module}}/internal/model"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(context.Context, model.UserInput) (model.User, error)
|
||||
}
|
||||
|
||||
// MemoryUsers stores data only for this process. Replace it with a database
|
||||
// implementation of UserRepository when persistence is required.
|
||||
type MemoryUsers struct {
|
||||
mu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
users map[int64]model.User
|
||||
emails map[string]int64
|
||||
nextID int64
|
||||
users []model.User
|
||||
}
|
||||
|
||||
func NewMemoryUsers() *MemoryUsers { return &MemoryUsers{nextID: 1} }
|
||||
func NewMemoryUsers() *MemoryUsers {
|
||||
return &MemoryUsers{
|
||||
users: make(map[int64]model.User),
|
||||
emails: make(map[string]int64),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Create(_ context.Context, input model.UserInput) (model.User, error) {
|
||||
func (r *MemoryUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if _, exists := r.emails[input.Email]; exists {
|
||||
return model.User{}, model.ErrEmailTaken
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user := model.User{ID: r.nextID, Name: input.Name, Email: input.Email, CreatedAt: now, UpdatedAt: now}
|
||||
user := model.User{
|
||||
ID: r.nextID, Name: input.Name, Email: input.Email,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
r.nextID++
|
||||
r.users = append(r.users, user)
|
||||
r.users[user.ID] = user
|
||||
r.emails[user.Email] = user.ID
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
r.mu.RLock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
r.mu.RUnlock()
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
users := make([]model.User, 0, len(r.users))
|
||||
for _, user := range r.users {
|
||||
users = append(users, user)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// A stable order is essential: Go map iteration order changes between calls.
|
||||
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
|
||||
result := model.UserPage{
|
||||
Items: []model.User{}, Total: len(users), Page: page, PageSize: pageSize,
|
||||
}
|
||||
// Check the page before multiplying so even a very large page cannot overflow.
|
||||
if len(users) == 0 || page-1 > (len(users)-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
count := min(pageSize, len(users)-start)
|
||||
result.Items = users[start : start+count]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if owner, exists := r.emails[input.Email]; exists && owner != id {
|
||||
return model.User{}, model.ErrEmailTaken
|
||||
}
|
||||
delete(r.emails, user.Email)
|
||||
user.Name, user.Email = input.Name, input.Email
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
r.users[id] = user
|
||||
r.emails[user.Email] = id
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Delete(ctx context.Context, id int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
delete(r.users, id)
|
||||
delete(r.emails, user.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
139
cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl
Normal file
139
cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl
Normal file
@@ -0,0 +1,139 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"{{.Module}}/internal/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// SQLExecutor is implemented by both *sql.DB and *sql.Tx. A repository created
|
||||
// with a transaction keeps all of its queries on that same transaction.
|
||||
type SQLExecutor interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// MySQLUsers is the persistent implementation of UserRepository.
|
||||
// The schema is documented in schema.sql; migrations stay outside the framework.
|
||||
type MySQLUsers struct {
|
||||
db SQLExecutor
|
||||
}
|
||||
|
||||
func NewMySQLUsers(db SQLExecutor) *MySQLUsers {
|
||||
return &MySQLUsers{db: db}
|
||||
}
|
||||
|
||||
// CheckSchema validates that the application's migration has been applied.
|
||||
// Runtime startup never creates or alters tables.
|
||||
func (r *MySQLUsers) CheckSchema(ctx context.Context) error {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, name, email, created_at, updated_at FROM users LIMIT 0`)
|
||||
if err != nil { return err }
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (name, email, created_at, updated_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users WHERE id = ?`, id).Scan(
|
||||
&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 { return model.UserPage{}, model.ErrInvalidPage }
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize}
|
||||
if total == 0 || page-1 > (total-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset)
|
||||
if err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var user model.User
|
||||
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result.Items = append(result.Items, user)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6)
|
||||
WHERE id = ?`, input.Name, input.Email, id)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if changed == 0 {
|
||||
// MySQL may report zero affected rows when the replacement is identical;
|
||||
// distinguish that from an ID that does not exist.
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Delete(ctx context.Context, id int64) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted == 0 {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapMySQLError(err error) error {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return model.ErrEmailTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"{{.Module}}/internal/model"
|
||||
)
|
||||
|
||||
// UserRepository describes the storage operations the business needs.
|
||||
// Implementations must atomically enforce unique, normalized email addresses.
|
||||
type UserRepository interface {
|
||||
Create(context.Context, model.UserInput) (model.User, error)
|
||||
Get(context.Context, int64) (model.User, error)
|
||||
List(context.Context, int, int) (model.UserPage, error)
|
||||
Update(context.Context, int64, model.UserInput) (model.User, error)
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
@@ -2,20 +2,61 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"{{.Module}}/internal/model"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
)
|
||||
|
||||
type Users struct {
|
||||
context *servicecontext.Context
|
||||
services *servicecontext.Context
|
||||
}
|
||||
|
||||
func NewUsers(context *servicecontext.Context) *Users {
|
||||
return &Users{context: context}
|
||||
func NewUsers(services *servicecontext.Context) *Users {
|
||||
return &Users{services: services}
|
||||
}
|
||||
|
||||
func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
// Put business normalization and rules here.
|
||||
return s.context.Users.Create(ctx, input)
|
||||
input, err := normalize(input)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return s.services.Users.Create(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Users) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
return s.services.Users.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Users) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
return s.services.Users.List(ctx, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Users) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
input, err := normalize(input)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return s.services.Users.Update(ctx, id, input)
|
||||
}
|
||||
|
||||
func (s *Users) Delete(ctx context.Context, id int64) error {
|
||||
return s.services.Users.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func normalize(input model.UserInput) (model.UserInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
// This example treats the entire email address as case-insensitive.
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
address, err := mail.ParseAddress(input.Email)
|
||||
if input.Name == "" || utf8.RuneCountInString(input.Name) > 100 ||
|
||||
len(input.Email) > 254 || err != nil || address.Address != input.Email {
|
||||
return model.UserInput{}, model.ErrInvalidUser
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{if eq .Database "mysql"}}"fmt"
|
||||
"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/repository"
|
||||
)
|
||||
@@ -10,8 +16,51 @@ import (
|
||||
type Context struct {
|
||||
Logger *logx.Logger
|
||||
Users repository.UserRepository
|
||||
Health *health.Checker
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
}
|
||||
|
||||
func New(logger *logx.Logger, users repository.UserRepository) *Context {
|
||||
return &Context{Logger: logger, Users: users}
|
||||
// New initializes the project's dependencies before the HTTP listener opens.
|
||||
// The caller owns Logger; Close releases only resources created here.
|
||||
func New(ctx context.Context, logger *logx.Logger) (*Context, error) {
|
||||
if ctx == nil { ctx = context.Background() }
|
||||
if err := ctx.Err(); err != nil { return nil, err }
|
||||
checker, err := health.New()
|
||||
if err != nil { return nil, err }
|
||||
services := &Context{Logger: logger, Health: checker}
|
||||
{{if eq .Database "mysql"}}
|
||||
cfg, err := database.ConfigFromEnv("")
|
||||
if err != nil { return nil, err }
|
||||
if cfg.Driver != "mysql" { return nil, fmt.Errorf("this project requires GO_OMNI_DB_DRIVER=mysql") }
|
||||
dsn, err := mysql.ParseDSN(cfg.DSN)
|
||||
if err != nil { return nil, fmt.Errorf("parse mysql connection settings: %w", err) }
|
||||
if dsn.DBName == "" { return nil, fmt.Errorf("mysql connection settings require a database name") }
|
||||
// DATETIME columns are returned as time.Time in UTC in the business model.
|
||||
dsn.ParseTime = true
|
||||
dsn.Loc = time.UTC
|
||||
cfg.DSN = dsn.FormatDSN()
|
||||
db, err := database.Open(ctx, cfg)
|
||||
if err != nil { return nil, err }
|
||||
users := repository.NewMySQLUsers(db.DB)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
||||
defer cancel()
|
||||
if err := users.CheckSchema(checkCtx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("check users schema (apply schema.sql before starting): %w", err)
|
||||
}
|
||||
if err := checker.Add(health.Probe{Name: "database", Timeout: cfg.PingTimeout, Check: db.Health}); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
services.DB, services.Users = db, users
|
||||
{{else}}
|
||||
services.Users = repository.NewMemoryUsers()
|
||||
{{end}}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// Close is safe to call on startup failure and again during application exit.
|
||||
func (c *Context) Close() error {
|
||||
{{if eq .Database "mysql"}}if c != nil && c.DB != nil { return c.DB.Close() }{{end}}
|
||||
return nil
|
||||
}
|
||||
|
||||
9
cmd/go-omni/scaffold/templates/schema.sql.tmpl
Normal file
9
cmd/go-omni/scaffold/templates/schema.sql.tmpl
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(254) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
31
cmd/go-omni/scaffold/version.go
Normal file
31
cmd/go-omni/scaffold/version.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
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.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-]+)*)?$`)
|
||||
|
||||
func validFrameworkVersion(version string) bool {
|
||||
return frameworkVersionPattern.MatchString(version)
|
||||
}
|
||||
|
||||
func currentFrameworkVersion() string {
|
||||
info, _ := debug.ReadBuildInfo()
|
||||
return frameworkVersion(info)
|
||||
}
|
||||
|
||||
func frameworkVersion(info *debug.BuildInfo) string {
|
||||
if info != nil && info.Main.Path == frameworkModule && info.Main.Sum != "" && validFrameworkVersion(info.Main.Version) {
|
||||
return info.Main.Version
|
||||
}
|
||||
return defaultFrameworkVersion
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Health`:用于就绪检查。
|
||||
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||
|
||||
@@ -18,7 +18,13 @@ import (
|
||||
)
|
||||
|
||||
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database.Open(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
```
|
||||
|
||||
@@ -35,3 +41,18 @@ GO_OMNI_DB_PING_TIMEOUT=5s
|
||||
```
|
||||
|
||||
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
|
||||
|
||||
`MaxIdleConns=0` 会禁用空闲连接。初始化 Ping 失败会关闭已创建的连接池。
|
||||
|
||||
`go-omni new --database mysql` 会生成项目自己的 MySQL Repository、ServiceContext、
|
||||
数据库就绪探针和 `schema.sql`。核心包不需要知道业务表结构。
|
||||
|
||||
真实数据库验收(需要本地 Docker):
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyDatabase.py --version v0.2.0
|
||||
```
|
||||
|
||||
脚本仅操作本次创建的独立容器与数据库,验证 HTTP CRUD、服务重启持久化、事务提交与
|
||||
错误/panic/取消回滚、连接池关闭、缺少表结构时启动失败,以及停库后的 readiness 503 和恢复后的 200。
|
||||
结束时清理容器、匿名数据卷和临时项目。常规 `go test ./...` 不依赖 Docker。
|
||||
|
||||
@@ -93,6 +93,9 @@ type DB struct {
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,9 +106,9 @@ func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||
if cfg.MaxOpenConns > 0 {
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
if cfg.MaxIdleConns > 0 {
|
||||
// database/sql uses zero to disable idle connections; do not leave its
|
||||
// default pool size in place when the caller explicitly chooses zero.
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
}
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
||||
@@ -123,7 +126,12 @@ func (db *DB) Health(ctx context.Context) error {
|
||||
if db == nil || db.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
return db.PingContext(ctx)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, db.config.PingTimeout)
|
||||
defer cancel()
|
||||
return db.PingContext(checkCtx)
|
||||
}
|
||||
|
||||
// InTx commits only when work returns nil. Any work error or panic rolls back.
|
||||
|
||||
39
database/lifecycle_test.go
Normal file
39
database/lifecycle_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var failedPingClosed atomic.Int32
|
||||
var errPingUnavailable = errors.New("database unavailable")
|
||||
|
||||
type failedPingDriver struct{}
|
||||
type failedPingConnection struct{}
|
||||
|
||||
func (failedPingDriver) Open(string) (driver.Conn, error) { return failedPingConnection{}, nil }
|
||||
func (failedPingConnection) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (failedPingConnection) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") }
|
||||
func (failedPingConnection) Close() error { failedPingClosed.Add(1); return nil }
|
||||
func (failedPingConnection) Ping(context.Context) error { return errPingUnavailable }
|
||||
|
||||
func init() { sql.Register("goOmniFailPing", failedPingDriver{}) }
|
||||
|
||||
func TestOpenClosesPoolWhenStartupPingFails(t *testing.T) {
|
||||
failedPingClosed.Store(0)
|
||||
cfg := DefaultConfig()
|
||||
cfg.Driver, cfg.DSN = "goOmniFailPing", "test"
|
||||
pool, err := Open(context.Background(), cfg)
|
||||
if pool != nil || !errors.Is(err, errPingUnavailable) {
|
||||
t.Fatalf("unexpected startup result: db=%v err=%v", pool, err)
|
||||
}
|
||||
if closed := failedPingClosed.Load(); closed != 1 {
|
||||
t.Fatalf("startup failure closed %d connections, want 1", closed)
|
||||
}
|
||||
}
|
||||
@@ -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 测试不自动连接外部数据库。
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
19
go.mod
@@ -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
61
go.sum
@@ -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=
|
||||
|
||||
@@ -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
678
internal/perfcheck/main.go
Normal 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...)
|
||||
}
|
||||
75
internal/perfcheck/main_test.go
Normal file
75
internal/perfcheck/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# logx
|
||||
|
||||
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖 Gin:
|
||||
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖具体网络库:
|
||||
|
||||
```go
|
||||
import "git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
57
middleware/middleware_test.go
Normal file
57
middleware/middleware_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
omni.go
3
omni.go
@@ -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
265
performance/linuxSoak.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
265
performance/linuxSoakTLS.json
Normal file
265
performance/linuxSoakTLS.json
Normal 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
1735
performance/tcpBaseline.json
Normal file
File diff suppressed because it is too large
Load Diff
592
performance/tlsBaseline.json
Normal file
592
performance/tlsBaseline.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
592
performance/tlsBaselineLinux.json
Normal file
592
performance/tlsBaselineLinux.json
Normal 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
136
scripts/verifyBusiness.py
Executable 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()
|
||||
265
scripts/verifyDatabase.py
Normal file
265
scripts/verifyDatabase.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a generated MySQL service against a disposable local Docker database."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from verifyScaffold import MODULE, run
|
||||
|
||||
|
||||
def waitFor(check, description, timeout=20):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if check():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"timed out: {description}")
|
||||
|
||||
|
||||
class MySQL:
|
||||
def __init__(self, directory, env, image):
|
||||
self.directory, self.env, self.image = directory, dict(env), image
|
||||
self.name = "go-omni-check-" + uuid.uuid4().hex[:12]
|
||||
self.container = None
|
||||
self.password = secrets.token_hex(18)
|
||||
self.env.update(MYSQL_ROOT_PASSWORD=secrets.token_hex(18), MYSQL_PASSWORD=self.password)
|
||||
self.env["MYSQL_PWD"] = self.env["MYSQL_ROOT_PASSWORD"]
|
||||
|
||||
def sql(self, statement, database=None):
|
||||
args = ["docker", "exec", "-i", "--env", "MYSQL_PWD", self.container,
|
||||
"mysql", "--protocol=tcp", "-h127.0.0.1", "-uroot", "--batch", "--skip-column-names"]
|
||||
if database:
|
||||
args.append(database)
|
||||
result = subprocess.run(args, cwd=self.directory, env=self.env, input=statement,
|
||||
text=True, capture_output=True, timeout=10)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"test database SQL failed: {result.stderr}")
|
||||
return result.stdout.strip()
|
||||
|
||||
def wait(self):
|
||||
def ready():
|
||||
try:
|
||||
return self.sql("SELECT 1") == "1"
|
||||
except (RuntimeError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
waitFor(ready, "MySQL ready", timeout=90)
|
||||
|
||||
def start(self):
|
||||
endpoint = self.env.get("DOCKER_HOST") or run(
|
||||
["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
|
||||
self.directory, self.env).strip()
|
||||
if not endpoint.startswith(("unix://", "npipe://")):
|
||||
raise RuntimeError("database verification requires a local Docker context")
|
||||
# Docker may assign a different random published port after restart.
|
||||
# Choose the loopback port once so outage/recovery keeps the same DSN.
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
hostPort = listener.getsockname()[1]
|
||||
print("Starting isolated MySQL container", flush=True)
|
||||
self.container = run(["docker", "run", "--detach", "--name", self.name,
|
||||
"--label", "go-omni.test=database", "--publish", f"127.0.0.1:{hostPort}:3306",
|
||||
"--env", "MYSQL_ROOT_PASSWORD", "--env", "MYSQL_PASSWORD",
|
||||
"--env", "MYSQL_DATABASE=omni_test", "--env", "MYSQL_USER=omni_test",
|
||||
self.image], self.directory, self.env).strip()
|
||||
self.wait()
|
||||
port = run(["docker", "port", self.container, "3306/tcp"], self.directory, self.env).strip().rsplit(":", 1)[1]
|
||||
self.dsn = (f"omni_test:{self.password}@tcp(127.0.0.1:{port})/omni_test"
|
||||
"?parseTime=true&timeout=2s&readTimeout=2s&writeTimeout=2s")
|
||||
self.sql("CREATE DATABASE omni_empty; GRANT ALL ON omni_empty.* TO 'omni_test'@'%';")
|
||||
|
||||
def close(self):
|
||||
if self.container:
|
||||
# Remove only the container and anonymous volume created by this run.
|
||||
run(["docker", "rm", "--force", "--volumes", self.container], self.directory, self.env)
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(self, binary, directory, env):
|
||||
self.binary, self.directory, self.env = binary, directory, dict(env)
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
self.address = f"127.0.0.1:{listener.getsockname()[1]}"
|
||||
self.env["GO_OMNI_HTTP_ADDR"] = self.address
|
||||
self.process = None
|
||||
self.logPath = directory / f"server-{uuid.uuid4().hex[:8]}.log"
|
||||
self.client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
def request(self, method, path, body=None, expected=200):
|
||||
req = urllib.request.Request(f"http://{self.address}{path}", method=method,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", "X-Request-ID": "databaseCheck"})
|
||||
try:
|
||||
response = self.client.open(req, timeout=3)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
raw = response.read()
|
||||
if response.status != expected:
|
||||
raise AssertionError(f"{method} {path}: expected {expected}, got {response.status}: {raw!r}")
|
||||
if response.headers.get("X-Request-ID") != "databaseCheck":
|
||||
raise AssertionError("missing requestId header")
|
||||
if not raw:
|
||||
return None
|
||||
result = json.loads(raw)
|
||||
if result.get("requestId") != "databaseCheck":
|
||||
raise AssertionError("missing camelCase requestId")
|
||||
return result
|
||||
|
||||
def start(self):
|
||||
with self.logPath.open("w") as logs:
|
||||
self.process = subprocess.Popen([str(self.binary)], cwd=self.directory, env=self.env,
|
||||
stdout=logs, stderr=subprocess.STDOUT)
|
||||
def ready():
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"service exited: {self.logPath.read_text()}")
|
||||
try:
|
||||
return self.request("GET", "/health/live")["data"]["status"] == "ok"
|
||||
except (OSError, urllib.error.URLError):
|
||||
return False
|
||||
waitFor(ready, "service startup")
|
||||
|
||||
def stop(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.send_signal(signal.SIGTERM)
|
||||
code = self.process.wait(timeout=10)
|
||||
if code != 0:
|
||||
raise AssertionError(f"service exit {code}: {self.logPath.read_text()}")
|
||||
|
||||
def close(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
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("--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")
|
||||
args = parser.parse_args()
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith("GO_OMNI_")}
|
||||
env["GOWORK"] = "off"
|
||||
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE"), "git.yuntuo.hk"]))
|
||||
with tempfile.TemporaryDirectory(prefix="go omni database ") as temporary:
|
||||
directory = Path(temporary).resolve()
|
||||
if args.cli:
|
||||
cli = Path(args.cli).resolve()
|
||||
else:
|
||||
env["GOBIN"] = str(directory / "bin")
|
||||
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], directory, env)
|
||||
cli = directory / "bin" / "go-omni"
|
||||
command = [str(cli), "new", "--module", "example.com/databaseCheck", "--database", "mysql"]
|
||||
if args.framework_dir:
|
||||
command += ["--framework-dir", str(Path(args.framework_dir).resolve())]
|
||||
project = directory / "billing"
|
||||
run(command + [str(project)], directory, env)
|
||||
module = json.loads(run([args.go, "mod", "edit", "-json"], project, env))
|
||||
if not args.framework_dir and module.get("Replace"):
|
||||
raise AssertionError("remote MySQL project contains a local replace")
|
||||
if {entry["Path"]: entry["Version"] for entry in module["Require"]}.get(MODULE) != args.version:
|
||||
raise AssertionError("MySQL project did not pin the CLI version")
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
binary = directory / "server"
|
||||
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||
database = MySQL(directory, env, args.image)
|
||||
servers = []
|
||||
try:
|
||||
database.start()
|
||||
database.sql((project / "schema.sql").read_text(), "omni_test")
|
||||
testEnv = dict(env, GO_OMNI_TEST_MYSQL_DSN=database.dsn)
|
||||
output = run([args.go, "test", "-race", "-v", "./..."], project, testEnv)
|
||||
for name in ("TestDatabaseTransactions", "TestDatabasePoolHealthAndClose"):
|
||||
if f"--- PASS: {name}" not in output:
|
||||
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_DB_PING_TIMEOUT="500ms", GO_OMNI_ENABLE_LOGGER="true")
|
||||
server = Server(binary, project, serverEnv)
|
||||
servers.append(server)
|
||||
server.start()
|
||||
ready = server.request("GET", "/health/ready")
|
||||
if ready["code"] != "READY" or ready["data"]["checks"][0]["name"] != "database":
|
||||
raise AssertionError(f"database probe not registered: {ready}")
|
||||
server.request("POST", "/users", {"name": "", "email": "invalid"}, expected=400)
|
||||
created = server.request("POST", "/users", {"name": " Alice ", "email": "ALICE@example.com"}, expected=201)
|
||||
user = created["data"]
|
||||
if user["email"] != "alice@example.com" or not user.get("createdAt"):
|
||||
raise AssertionError(f"normalization/time parsing failed: {user}")
|
||||
path = f"/users/{user['id']}"
|
||||
duplicate = server.request("POST", "/users", {"name": "Duplicate", "email": "alice@example.com"}, expected=409)
|
||||
if duplicate["code"] != "EMAIL_TAKEN":
|
||||
raise AssertionError("duplicate email error was not mapped")
|
||||
if server.request("GET", path)["data"]["name"] != "Alice":
|
||||
raise AssertionError("GET did not load created user")
|
||||
page = server.request("GET", "/users?page=1&pageSize=1")["data"]
|
||||
if page["total"] != 1 or page["pageSize"] != 1:
|
||||
raise AssertionError(f"pagination mismatch: {page}")
|
||||
server.request("PUT", path, {"name": "Updated", "email": "updated@example.com"})
|
||||
server.stop()
|
||||
waitFor(lambda: database.sql("SELECT COUNT(*) FROM information_schema.processlist WHERE USER='omni_test'") == "0",
|
||||
"connection pool closed on service exit")
|
||||
server = Server(binary, project, serverEnv)
|
||||
servers.append(server)
|
||||
server.start()
|
||||
if server.request("GET", path)["data"]["name"] != "Updated":
|
||||
raise AssertionError("data did not survive service restart")
|
||||
print("PASS HTTP validation, unique email, create/read/update/list, restart persistence and pool cleanup", flush=True)
|
||||
|
||||
failureEnv = dict(serverEnv, GO_OMNI_DB_DSN=database.dsn.replace("/omni_test?", "/omni_empty?"))
|
||||
failed = subprocess.run([str(binary)], cwd=project, env=failureEnv, capture_output=True, text=True, timeout=10)
|
||||
if failed.returncode == 0 or "apply schema.sql" not in failed.stderr:
|
||||
raise AssertionError("missing schema did not fail startup before listening")
|
||||
waitFor(lambda: database.sql("SELECT COUNT(*) FROM information_schema.processlist WHERE DB='omni_empty'") == "0",
|
||||
"connection pool closed on schema failure")
|
||||
print("PASS missing schema fails startup and releases connections", flush=True)
|
||||
|
||||
run(["docker", "stop", "--time", "10", database.container], directory, database.env)
|
||||
readiness = server.request("GET", "/health/ready", expected=503)
|
||||
if readiness["code"] != "NOT_READY" or readiness["data"]["checks"][0]["status"] != "not_ready":
|
||||
raise AssertionError("database outage did not fail readiness")
|
||||
server.request("GET", "/health/live")
|
||||
unavailable = server.request("GET", path, expected=500)
|
||||
if unavailable["code"] != "INTERNAL_ERROR" or database.password in json.dumps(unavailable):
|
||||
raise AssertionError("database outage leaked an internal error")
|
||||
failed = subprocess.run([str(binary)], cwd=project, env=serverEnv, capture_output=True, text=True, timeout=10)
|
||||
if failed.returncode == 0 or "ping database" not in failed.stderr:
|
||||
raise AssertionError("offline database did not fail startup")
|
||||
run(["docker", "start", database.container], directory, database.env)
|
||||
database.wait()
|
||||
def recovered():
|
||||
try:
|
||||
return server.request("GET", "/health/ready")["code"] == "READY"
|
||||
except (AssertionError, OSError, urllib.error.URLError):
|
||||
return False
|
||||
waitFor(recovered, "application reconnects after MySQL restart")
|
||||
if server.request("GET", path)["data"]["name"] != "Updated":
|
||||
raise AssertionError("database restart lost persistent data")
|
||||
server.request("DELETE", path, expected=204)
|
||||
server.request("GET", path, expected=404)
|
||||
server.stop()
|
||||
waitFor(lambda: database.sql("SELECT COUNT(*) FROM information_schema.processlist WHERE USER='omni_test'") == "0",
|
||||
"final connection cleanup")
|
||||
print("PASS database outage: readiness 503, liveness 200, startup failure; recovery 200; delete 204/404", flush=True)
|
||||
finally:
|
||||
for server in servers:
|
||||
server.close()
|
||||
database.close()
|
||||
print("Removed test container, anonymous volume and temporary project", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
84
scripts/verifyLinux.py
Executable file
84
scripts/verifyLinux.py
Executable 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
55
scripts/verifyPerformance.py
Executable 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()
|
||||
159
scripts/verifyScaffold.py
Normal file
159
scripts/verifyScaffold.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a generated service outside the source tree using a real server process."""
|
||||
|
||||
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
|
||||
|
||||
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
|
||||
def run(args, cwd, env):
|
||||
result = subprocess.run(args, cwd=cwd, env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"{args[0]} {args[1:]} failed:\n{result.stdout}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
|
||||
with socket.socket() as listener:
|
||||
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_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s")
|
||||
if cleanupMarker:
|
||||
serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker)
|
||||
logFile = directory / f"{binary.name}-{stopSignal.name}.log"
|
||||
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
def request(path, data=None):
|
||||
req = urllib.request.Request(f"http://{address}{path}", data=data,
|
||||
headers={"Content-Type": "application/json", "X-Request-ID": "smokeRequest"})
|
||||
with client.open(req, timeout=2) as response:
|
||||
if response.headers.get("X-Request-ID") != "smokeRequest":
|
||||
raise AssertionError("request ID was not preserved")
|
||||
return response.status, json.load(response)
|
||||
|
||||
with logFile.open("w") as logs:
|
||||
process = subprocess.Popen([str(binary)], cwd=directory, env=serverEnv,
|
||||
stdout=logs, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"server exited during startup: {logFile.read_text()}")
|
||||
try:
|
||||
status, body = request("/health/live")
|
||||
break
|
||||
except (OSError, urllib.error.URLError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(f"server did not start: {logFile.read_text()}")
|
||||
time.sleep(0.05)
|
||||
if status != 200 or body["data"]["status"] != "ok":
|
||||
raise AssertionError(f"unexpected liveness: {body}")
|
||||
if not cleanupMarker:
|
||||
status, body = request("/health/ready")
|
||||
if status != 200 or body["code"] != "READY":
|
||||
raise AssertionError(f"unexpected readiness: {body}")
|
||||
status, body = request("/users", json.dumps({"name": "Smoke", "email": "smoke@example.com"}).encode())
|
||||
user = body.get("data", body)
|
||||
if status != 201 or user["name"] != "Smoke" or "createdAt" not in user:
|
||||
raise AssertionError(f"unexpected created user: {user}")
|
||||
process.send_signal(stopSignal)
|
||||
exitCode = process.wait(timeout=8)
|
||||
if exitCode != 0:
|
||||
raise AssertionError(f"{stopSignal.name} exit code {exitCode}: {logFile.read_text()}")
|
||||
if cleanupMarker and cleanupMarker.read_text() != "closed":
|
||||
raise AssertionError("shutdown callback was not executed")
|
||||
# A built binary, rather than `go run`, lets us verify the actual
|
||||
# service exited without leaving its child listening in the background.
|
||||
with socket.socket() as connection:
|
||||
connection.settimeout(1)
|
||||
if connection.connect_ex(("127.0.0.1", int(address.rsplit(":", 1)[1]))) == 0:
|
||||
raise AssertionError("server still accepts connections after exit")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
print(f"PASS {binary.name}: HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go", help="Go executable")
|
||||
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")
|
||||
parser.add_argument("--skip-run-check", action="store_true", help="Skip core Run signal checks when testing an older framework")
|
||||
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 smoke ") as temporary:
|
||||
directory = Path(temporary).resolve()
|
||||
if args.cli:
|
||||
cli = Path(args.cli).resolve()
|
||||
else:
|
||||
env["GOBIN"] = str(directory / "bin")
|
||||
print(f"Installing remote CLI {args.version}", flush=True)
|
||||
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], directory, env)
|
||||
cli = directory / "bin" / "go-omni"
|
||||
command = [str(cli), "new", "--module", "example.com/scaffoldSmoke"]
|
||||
if args.framework_version:
|
||||
command += ["--framework-version", args.framework_version]
|
||||
if args.framework_dir:
|
||||
command += ["--framework-dir", str(Path(args.framework_dir).resolve())]
|
||||
project = directory / "billing"
|
||||
run(command + [str(project)], directory, env)
|
||||
module = json.loads(run([args.go, "mod", "edit", "-json"], project, env))
|
||||
requirements = {entry["Path"]: entry["Version"] for entry in module["Require"]}
|
||||
expectedVersion = args.framework_version or args.version
|
||||
if requirements.get(MODULE) != expectedVersion:
|
||||
raise AssertionError(f"expected framework {expectedVersion}, got {requirements}")
|
||||
if not args.framework_dir and module.get("Replace"):
|
||||
raise AssertionError("remote generation must not contain local replace directives")
|
||||
print(f"PASS generated module pins {expectedVersion}; local replace: {bool(module.get('Replace'))}", flush=True)
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
run([args.go, "test", "./..."], project, env)
|
||||
binary = directory / "server"
|
||||
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||
for stopSignal in (signal.SIGINT, signal.SIGTERM):
|
||||
exerciseServer(binary, project, env, stopSignal)
|
||||
|
||||
if not args.skip_run_check:
|
||||
# Verify the framework's convenient Run API independently from the
|
||||
# generated main's caller-owned signal context.
|
||||
helper = project / "cmd" / "runCheck"
|
||||
helper.mkdir()
|
||||
helper.joinpath("main.go").write_text('''package main
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
)
|
||||
func main() {
|
||||
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) }
|
||||
}
|
||||
''')
|
||||
runBinary = directory / "runCheck"
|
||||
run([args.go, "build", "-o", str(runBinary), "./cmd/runCheck"], project, env)
|
||||
for stopSignal in (signal.SIGINT, signal.SIGTERM):
|
||||
marker = project / f"cleanup-{stopSignal.name}"
|
||||
exerciseServer(runBinary, project, env, stopSignal, marker)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
62
scripts/verifyTransports.py
Normal file
62
scripts/verifyTransports.py
Normal 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()
|
||||
@@ -1,8 +1,6 @@
|
||||
# Transport adapters
|
||||
|
||||
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
|
||||
|
||||
使用标准库适配器:
|
||||
业务 Handler 依赖 GoOmni 的 `httpx.Context`,Service 接收标准 `context.Context`。适配器只在启动入口组装:
|
||||
|
||||
```go
|
||||
import (
|
||||
@@ -11,12 +9,103 @@ import (
|
||||
)
|
||||
|
||||
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 的前提下逐步替换解析器和响应写回。
|
||||
|
||||
125
transport/benchmarkTCP_test.go
Normal file
125
transport/benchmarkTCP_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
48
transport/benchmark_test.go
Normal file
48
transport/benchmark_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
15
transport/contract_test.go
Normal file
15
transport/contract_test.go
Normal 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() })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
119
transport/internal/routing/pattern.go
Normal file
119
transport/internal/routing/pattern.go
Normal 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)
|
||||
}
|
||||
104
transport/native/disconnect_test.go
Normal file
104
transport/native/disconnect_test.go
Normal 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
1105
transport/native/server.go
Normal file
File diff suppressed because it is too large
Load Diff
1002
transport/native/server_test.go
Normal file
1002
transport/native/server_test.go
Normal file
File diff suppressed because it is too large
Load Diff
72
transport/native/slow_client_test.go
Normal file
72
transport/native/slow_client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
188
transport/native/tls_test.go
Normal file
188
transport/native/tls_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
406
transport/transporttest/application.go
Normal file
406
transport/transporttest/application.go
Normal 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
|
||||
}
|
||||
198
transport/transporttest/router.go
Normal file
198
transport/transporttest/router.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user