Compare commits

...

6 Commits

61 changed files with 3138 additions and 453 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ bin/
dist/
*.log
coverage.out
__pycache__/

48
CHANGELOG.md Normal file
View File

@@ -0,0 +1,48 @@
# Changelog
## 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 与标准库路由适配器、统一响应与错误。
- 提供配置、日志、校验、认证接口、数据库基础能力、健康探针和项目脚手架。

20
PERFORMANCE.md Normal file
View File

@@ -0,0 +1,20 @@
# GoOmni 性能基线
这份基线用于比较未来自研网络层,不代表生产容量承诺。测试只测进程内请求分发和 GoOmni 请求链,不包含真实 TCP、TLS、数据库或业务存储延迟。
运行:
```sh
python3 scripts/verifyPerformance.py
```
本次记录环境:Go 1.25.0、darwin/arm64、Apple M2 Max;每项连续运行 3 次:
```text
BenchmarkTransportRouteDispatch/nethttp 568–671 ns/op 1344 B/op 13 allocs/op
BenchmarkTransportApplicationRequest/nethttp 2017–2208 ns/op 3091 B/op 34 allocs/op
```
`BenchmarkTransportRouteDispatch` 只覆盖标准库路由、路径参数和 `http.Handler` 调用;`BenchmarkTransportApplicationRequest` 还包含 GoOmni Context、RequestID、统一 JSON 响应和默认中间件链。
比较新网络层时,应使用相同 Go 版本、机器和 benchmark 命令,并同时运行 `-race` 全量测试。性能下降需要结合分配次数、取消传播、错误响应和业务契约结果一起判断。

View File

@@ -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.4.0
```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
创建新业务项目:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.4.0
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,9 @@ go-omni/
├── validate/ # 参数校验
├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务
├── transport/gin/ # Gin 网络适配器,可替换
├── transport/nethttp/ # 标准库网络适配器
├── examples/ # 示例项目
└── cmd/ # 后续 CLI
└── cmd/ # go-omni 项目脚手架
```
## 快速开始
@@ -63,17 +82,19 @@ 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 不锁定具体网络实现。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
```go
func (s *UserService) Create(ctx *httpx.Context) error {
@@ -104,7 +125,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 +156,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 +168,16 @@ userService := service.NewUsers(services)
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
从远程版本生成并升级一个独立业务模块,可运行:
```sh
python3 scripts/verifyBusiness.py --version v0.4.0
```
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.4.0`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
标准库路由和完整 GoOmni 请求链的可重复性能基线见 [PERFORMANCE.md](PERFORMANCE.md),运行 `python3 scripts/verifyPerformance.py` 可重新采样。
在框架根目录运行:
```sh
@@ -150,8 +186,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)。

44
ROADMAP.md Normal file
View File

@@ -0,0 +1,44 @@
# 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. 稳定性与性能基线(进行中)
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
- 已具备全量 race、vet、build 验收;生产压测、长连接和真实部署规模验证仍待完成。
- 自动验收入口:`python3 scripts/verifyPerformance.py`。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,稳定性与性能验证、自研网络内核尚未完成。

View File

@@ -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,10 @@ type App struct {
closeErr error
}
// New creates an application using the default Gin adapter.
// 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 +68,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 +108,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 +132,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 +143,16 @@ 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)
}
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 +169,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
View 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)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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.4.0
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.4.0 --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.4.0
python3 scripts/verifyDatabase.py --version v0.4.0
```
脚本在源码目录外安装远程 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 容器;不会连接已有业务数据库,结束后清理测试容器与数据卷。

View File

@@ -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
View 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)
}
}
}

View File

@@ -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 ""

View File

@@ -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()

View File

@@ -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`。

View File

@@ -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)
}

View 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") }
}

View File

@@ -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}}

View File

@@ -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)
}

View 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")
)

View File

@@ -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"`
}

View File

@@ -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
}

View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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;

View 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.4.0"
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
}

View File

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

View File

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

View File

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

View File

@@ -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。

View File

@@ -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.

View 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)
}
}

View File

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

View File

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

View File

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

19
go.mod
View File

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

61
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,8 +34,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) }

136
scripts/verifyBusiness.py Executable file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Verify a real generated business module across a released framework upgrade."""
import argparse
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from verifyScaffold import MODULE, run
def free_address():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return f"127.0.0.1:{listener.getsockname()[1]}"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.4.0", 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
View 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.4.0")
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()

38
scripts/verifyPerformance.py Executable file
View File

@@ -0,0 +1,38 @@
#!/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)
args = parser.parse_args()
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",
),
]
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
View 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.4.0", 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()

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Run the public adapter contract from an independent consumer module."""
import argparse
import json
import os
from pathlib import Path
import tempfile
from verifyScaffold import MODULE, run
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.4.0")
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: both adapters accepted by the same public contract suite", flush=True)
if __name__ == "__main__":
main()

View File

@@ -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,67 @@ 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`。
## 可移植路由规则(v0.3.0)
业务层不应该导入具体 transport 包;它们只是网络层实现。
| 项目 | 约定 |
| --- | --- |
| 路径 | 顶层路由必须以 `/` 开头;区分大小写。禁止重复斜杠、`.` / `..` 段、查询串和片段标识。 |
| 参数 | `/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.4.0
```
第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。
当前替换边界是路由与 HTTP 适配层,`App.Run` 仍使用标准库 `net/http.Server`。未来自研监听、连接管理或协议解析时,可让自研服务调用 `App.ServeHTTP`(提供标准 HTTP 请求/响应桥接),由自研启动层管理监听与关闭,并在退出时调用 `App.Close`;本阶段没有实现新的网络内核。

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 "/"
}
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})
// 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 == ""
}
}
return segments
}
func match(segments []segment, path string) (map[string]string, bool) {
parts := splitPath(path)
params := make(map[string]string)
partIndex := 0
for segmentIndex, current := range segments {
if current.wildcard {
params[current.name] = strings.Join(parts[partIndex:], "/")
return params, true
}
if partIndex >= len(parts) {
return nil, false
}
if current.name != "" {
params[current.name] = parts[partIndex]
} else if current.literal != parts[partIndex] {
return nil, false
}
partIndex++
if segmentIndex == len(segments)-1 && partIndex != len(parts) {
return nil, false
}
}
if partIndex != len(parts) {
return nil, false
}
return params, true
}
func splitPath(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
return nil
}
return strings.Split(path, "/")
}
func routeScore(method string, segments []segment) int {
score := len(segments)
if method != "*" {
score += 1000
}
for _, current := range segments {
if current.literal != "" {
score += 10
}
if current.wildcard {
score -= 5
}
}
return score
return false
}

View File

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

View File

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

View File

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

View File

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