Compare commits

...

2 Commits

53 changed files with 1486 additions and 75 deletions

6
API.md
View File

@@ -19,7 +19,7 @@
| CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` | | CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` |
| 业务逻辑目录 | `internal/logic/<group>`,使用标准 context.Context | | 业务逻辑目录 | `internal/logic/<group>`,使用标准 context.Context |
| 依赖装配 | `internal/svc` | | 依赖装配 | `internal/svc` |
| 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` | | 接口文档 | 自动生成 `docs/openapi.json`,HTTP 端点由 `server.endpoints.openapi` 配置 |
这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。 这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。
@@ -262,7 +262,7 @@ go test ./...
- `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。 - `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。
- 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。 - 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。
迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.12.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。 迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.13.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。
## 参数语义 ## 参数语义
@@ -327,3 +327,5 @@ python3 scripts/verifyManagedProject.py
``` ```
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。 Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.13.0-preview(2026-09-24)
- 新增通用 lifecycle.Controller,svc.RequestShutdown 支持依赖主动通知正常停止或致命故障,通知不阻塞,首个故障原因保留;生成入口排空 HTTP 后关闭依赖与日志,故障退出码为 1。
- App.RunContext、ServeContext、RunWith 保留自定义 context 取消原因;正常信号取消仍正常返回。
- 健康检查默认使用 App 响应模板和错误目录,支持 raw JSON;HTTP 状态保持 200/503,避免业务 HTTP 200 策略掩盖未就绪。内部探针原因仅供日志,失败状态继续记录到日志/指标。
- 生成项目通过 server.endpoints 配置 liveness/readiness/openapi 的开关与路径;全局中间件继续生效,OpenAPI 保持原始规范。禁用端点不保留路径,启用路径与业务 GET/参数/通配路由冲突时在监听前报错。
- 生成器最低框架版本更新到 v0.13.0-preview;manifest v2 和业务文件保持。v0.12 项目需要显式升级其 svc 扩展,main/Handler 由重新生成更新。
- 增加故障退出、排空/清理顺序、初始化期间通知、子进程退出码、健康格式、损坏编码器、端点禁用/改名/冲突的回归验收。
## v0.12.0-preview(2026-09-24) ## v0.12.0-preview(2026-09-24)
- 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。 - 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界 # 生成项目与文件维护边界
v0.12.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 v0.13.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发 ## 创建和日常开发
@@ -95,6 +95,62 @@ func (c *ServiceContext) AppOptions() []omni.Option {
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。 日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
## 依赖主动申请退出
从 v0.13.0-preview 起,生成的 svc 提供 `RequestShutdown(err)`。数据库客户端、节点租约、后台任务等发现不可恢复故障时,由项目决定是否退出:
```go
// 在 svc 的依赖监控回调中调用;err 是实际故障原因。
c.RequestShutdown(err)
```
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
## 配置内置接口
生成项目在 `etc/config.yaml` 明确列出以下默认配置;可以独立关闭、修改路径,或让健康接口返回原始 JSON:
```yaml
server:
endpoints:
liveness:
enabled: true
path: /health/live
responseMode: app
readiness:
enabled: true
path: /health/ready
responseMode: app
openapi:
enabled: true
path: /openapi.json
```
`responseMode: app` 使用项目 App 的响应模板/自定义 Encoder。健康成功使用项目成功码和成功提示;未就绪的 `NOT_READY` 可在项目 Go 错误目录配置业务码和公开提示,无需在 `.api` 再声明。
健康探针的 HTTP 状态固定遵循探针语义:存活/就绪为 200,未就绪为 503;项目错误目录或自定义编码器不能把未就绪改为 HTTP 200。响应模板 `${status}` 同样使用探针状态。失败继续进入业务失败日志/指标,探针底层原因保留在错误链供日志使用,不直接返回给调用方。
`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。
环境变量覆盖配置文件,例如 `GO_OMNI_ENDPOINTS_READINESS_PATH=/readyz`、`GO_OMNI_ENDPOINTS_READINESS_RESPONSE_MODE=raw`、`GO_OMNI_ENDPOINTS_OPENAPI_ENABLED=false`;liveness/readiness/openapi 都支持对应的 ENABLED 和 PATH。Config/AppOptions 的最终有效配置决定是否注册。
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
## 从 v0.12 生成项目升级
manifest v2 和现有目录保留。升级同版 CLI 与框架依赖后执行 `api generate`,生成器自动更新 main 和 Handler;`svc`、配置与业务文件仍按字节保留,所以需要在项目拥有的 svc 文件补入以下扩展:
1. `serviceContext.go` 导入 `git.yuntuo.hk/k2safe/go-omni/lifecycle`,给 ServiceContext 增加 `Lifecycle *lifecycle.Controller`;创建 c 时用 `lifecycle.New(ctx)` 初始化,并在后续依赖初始化前设置 `ctx = c.Lifecycle.Context()`。
2. 增加 `func (c *ServiceContext) RequestShutdown(err error) { c.Lifecycle.RequestShutdown(err) }`。
3. 构造失败的 defer 在 `c.Close()` 后保留 `c.Lifecycle.Err()`;Close 的 `closeOnce.Do` 开头调用 `c.Lifecycle.RequestShutdown(nil)`,重复关闭仍由已有 once 保护。
4. 配置默认值若来自 `config.Default()`,自动具有默认端点配置;如需禁用或改路径,在 YAML 或项目 Config 中显式设置。手动拼 Config 的项目需要自行填写要启用的端点。
精确实现可对照 [示例 svc](examples/managedService/internal/svc/serviceContext.go) 和 [关闭逻辑](examples/managedService/internal/svc/close.go),也可在临时目录用新版 CLI 生成参考。不要覆盖原有数据库/客户端初始化;不需要手改 main、Handler 或引入另一套启动器。最后执行 `api generate --check`、`go test ./...`、`go build .`,并验证故障退出。
## App 默认响应格式 ## App 默认响应格式
在 **入口** `.api` 声明一次: 在 **入口** `.api` 声明一次:
@@ -157,7 +213,7 @@ get /download (DownloadReq)
## 已有服务接入步骤 ## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.12.0-preview: 每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.13.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。

View File

@@ -2,7 +2,7 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.12.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **从这份 README 开始接入。** 当前版本 **`v0.13.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
@@ -28,7 +28,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh ```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
export PATH="$(go env GOPATH)/bin:$PATH" export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help go-omni --help
``` ```
@@ -184,7 +184,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行: 在业务项目的 **go.mod 所在目录**执行:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
``` ```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -435,6 +435,8 @@ server:
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。 自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。 `Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
## 日常开发与升级 ## 日常开发与升级
@@ -452,7 +454,7 @@ go test ./...
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
CLI 与业务 module 固定使用 `v0.12.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 CLI 与业务 module 固定使用 `v0.13.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入 ## 按需深入

View File

@@ -1,4 +1,43 @@
# v0.12.0-preview # v0.13.0-preview
本版补齐项目依赖主动退出与内置接口配置。ID 租约、数据库或后台任务等故障由项目判断,框架通过统一生命周期完成退出;不内置业务规则。
## 使用与升级
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
# 在业务 module 中执行
go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
go-omni api generate
```
新项目自动获得全部扩展。v0.12 的 config/svc 属于项目文件,重新生成会保留它们;需按 [升级步骤](PROJECT.md#从-v012-生成项目升级) 给 svc 补入 Lifecycle、RequestShutdown 与清理衔接,main 和 Handler 由生成器更新。旧 v0.11 目录仍按原迁移规则在新目录生成。
## 行为
- `svc.RequestShutdown(err)` 非阻塞通知:停止接收请求、排空、关闭依赖和日志。首个非空故障保留,故障退出码为 1;正常停止在没有其他故障时退出码为 0。初始化期间通知、并发通知和排空期间报告故障均可处理。
- App 生命周期入口保留异常 context 取消原因;符合 context.Canceled 的信号取消保持正常停止。生成入口还读取 Controller.Err,保留显式依赖故障,即使该故障包装了 context.Canceled。
- `/health/ready` 修复绕过响应配置的问题,默认与 liveness 一起使用项目 App 的响应格式。就绪成功使用项目成功码与提示;未就绪使用 `NOT_READY` 的项目错误定义,同时固定 HTTP 503。raw 模式只返回健康数据;内部探针原因仅供日志。
- `server.endpoints` 控制三个内置 GET 接口的开关和路径,健康端点另有 responseMode。支持环境变量覆盖。OpenAPI 保持原始规范,不包业务公共体;关闭 HTTP 端点不影响生成文档。
- `.api` 不再固定保留三个默认路径;启用的内置路径与业务 GET/参数/通配路由重叠时,启动前返回明确错误并清理已创建的依赖。关闭或改名后允许项目接管旧路径。全局中间件继续执行。
## 验收
源码测试和实际生成项目验证记录见 [生命周期与端点验收](performance/lifecycleEndpointsAcceptance.md)。复验命令:
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json
python3 scripts/verifyManagedRelease.py --version v0.13.0-preview --report performance/lifecycleEndpointsRemote.json
```
远程验收在发布后执行,报告单独提交,不改写发布标签。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。
---
# v0.12.0-preview(历史版本)
本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。 本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。
@@ -26,7 +65,7 @@ go run .
## 验收与范围 ## 验收与范围
发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md)。远程发布后使用以下命令独立验收安装产物;结果另行提交至仓库,不改写已发布标签: 发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过。发布后的空缓存远程验收也已通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md) 和 [远程产物报告](performance/managedProjectRemote.json)。报告作为单独提交保存,v0.12.0-preview 标签保持在发布代码提交 `158b8002b355f09237cd12f49d9b66ee78d493f3`,没有改写。复验命令:
```sh ```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...

View File

@@ -14,6 +14,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/config" "git.yuntuo.hk/k2safe/go-omni/config"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/middleware" "git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response" "git.yuntuo.hk/k2safe/go-omni/response"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
@@ -183,7 +184,8 @@ func (a *App) NewServer(address string) *http.Server {
} }
// RunContext serves until the context is canceled, then performs a graceful shutdown. // RunContext serves until the context is canceled, then performs a graceful shutdown.
func (a *App) RunContext(ctx context.Context, address ...string) error { func (a *App) RunContext(ctx context.Context, address ...string) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@@ -205,7 +207,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
// RunWith starts a custom server runner while keeping App-owned cleanup in the // RunWith starts a custom server runner while keeping App-owned cleanup in the
// same lifecycle. The runner receives the transport-neutral App handler and // same lifecycle. The runner receives the transport-neutral App handler and
// owns transport/TLS settings; built-in transport selection is bypassed. // owns transport/TLS settings; built-in transport selection is bypassed.
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error { func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if runner == nil { if runner == nil {
return errors.Join(errors.New("go-omni: nil runner"), a.Close()) return errors.Join(errors.New("go-omni: nil runner"), a.Close())
} }
@@ -218,6 +221,13 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close()) return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
} }
func shutdownResult(ctx context.Context, err error) error {
if cause := lifecycle.Cause(ctx); cause != nil && !errors.Is(err, cause) {
return errors.Join(err, cause)
}
return err
}
func (a *App) listenAddress(address ...string) string { func (a *App) listenAddress(address ...string) string {
listenOn := a.config.Address listenOn := a.config.Address
if listenOn == "" { if listenOn == "" {

119
app/fatalShutdown_test.go Normal file
View File

@@ -0,0 +1,119 @@
package app
import (
"context"
"errors"
"io"
"net"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func TestFatalShutdownDrainsAndPreservesCause(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative} {
t.Run(transport, func(t *testing.T) {
app := New(config.WithTransport(transport), config.WithShutdownTimeout(2*time.Second))
control := lifecycle.New(context.Background())
defer control.RequestShutdown(nil)
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
var closed atomic.Int32
closeErr := errors.New("cleanup diagnostic")
app.OnShutdown(func() error { closed.Add(1); return closeErr })
app.GET("/work", func(c *httpx.Context) error {
close(entered)
<-release
if closed.Load() != 0 {
return errors.New("closed during request")
}
return c.OK("done")
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- app.ServeContext(control.Context(), listener) }()
requestDone := make(chan error, 1)
client := http.Client{Timeout: 3 * time.Second}
go func() {
res, err := client.Get("http://" + listener.Addr().String() + "/work")
if err == nil {
_, err = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != 200 {
err = errors.New("request failed")
}
}
requestDone <- err
}()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("request never started")
}
fatal := errors.New("lease lost")
control.RequestShutdown(fatal)
select {
case err := <-done:
t.Fatal("did not drain", err)
case <-time.After(30 * time.Millisecond):
}
if closed.Load() != 0 {
t.Fatal("closed dependency before drain")
}
unblock()
select {
case err := <-requestDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("request stuck")
}
select {
case err := <-done:
if !errors.Is(err, fatal) || !errors.Is(err, closeErr) {
t.Fatal("lost cause", err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown stuck")
}
_ = app.Close()
if closed.Load() != 1 {
t.Fatal("cleanup repeated")
}
})
}
}
func TestCanceledStartupPreservesFault(t *testing.T) {
fatal := errors.New("dependency failed during startup")
ctx, cancel := context.WithCancelCause(context.Background())
cancel(fatal)
app := New()
if err := app.RunContext(ctx); !errors.Is(err, fatal) {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
if err = New().ServeContext(ctx, listener); !errors.Is(err, fatal) {
t.Fatal(err)
}
normal, stop := context.WithCancel(context.Background())
stop()
if err = New().RunContext(normal); err != nil {
t.Fatal("normal stop failed", err)
}
}

View File

@@ -17,7 +17,8 @@ import (
// ServeContext serves a caller-created listener using the configured transport // ServeContext serves a caller-created listener using the configured transport
// and TLS. App takes ownership of the listener, including on startup failure, // and TLS. App takes ownership of the listener, including on startup failure,
// and runs resource cleanup after the server has stopped. // and runs resource cleanup after the server has stopped.
func (a *App) ServeContext(ctx context.Context, listener net.Listener) error { func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if listener == nil { if listener == nil {
return errors.Join(errors.New("go-omni: nil listener"), a.Close()) return errors.Join(errors.New("go-omni: nil listener"), a.Close())
} }

View File

@@ -6,7 +6,7 @@
```sh ```sh
# 仓库读取权限与 PATH 配置见根目录 README # 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
go-omni new --module example.com/hello ./hello go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -41,14 +41,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移 ## 版本与迁移
CLI 与框架依赖固定使用 v0.12.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: CLI 与框架依赖固定使用 v0.13.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh ```sh
# 在 go-omni 源码仓库中执行 # 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
``` ```
新模板需要 v0.12.0-preview 的流式能力与 manifest v2。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 新模板需要 v0.13.0-preview 的依赖退出与端点配置能力,继续使用 manifest v2。v0.12 项目需按 [升级步骤](../../PROJECT.md#从-v012-生成项目升级) 补入 svc 生命周期字段和方法,生成器保留这些项目文件。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令 ## 数据库迁移命令

View File

@@ -87,7 +87,6 @@ func TestInvalidDefinitions(t *testing.T) {
"service user { @handler go\n @status 201\n post /go\n}", "service user { @handler go\n @status 201\n post /go\n}",
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}", "type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}", "@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
"service user { @handler go\n get /health/ready\n}",
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}", "type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
"service user { @handler go\n get /go\n @handler Go\n get /other\n}", "service user { @handler go\n get /go\n @handler Go\n get /other\n}",
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}", "type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",

View File

@@ -218,9 +218,6 @@ func Compile(spec *Spec) (*Compiled, error) {
return nil, fmt.Errorf("duplicate route %s", pathKey) return nil, fmt.Errorf("duplicate route %s", pathKey)
} }
paths[pathKey] = true paths[pathKey] = true
if r.Path == "/health/live" || r.Path == "/health/ready" || r.Path == "/openapi.json" {
return nil, fmt.Errorf("route %s is reserved by the generated server", r.Path)
}
if r.Method != strings.ToLower(r.Method) { if r.Method != strings.ToLower(r.Method) {
return nil, errors.New("HTTP methods must be lowercase") return nil, errors.New("HTTP methods must be lowercase")
} }

View File

@@ -0,0 +1,60 @@
package apigen
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestConfigurableBuiltinRoutesConsumer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "endpoints")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service healthApi {\n @handler customReady\n get /health/ready\n @handler customDoc\n get /openapi.json\n}\n")
_, err := Generate(Options{Dir: dir, API: api, Module: "example.com/endpoints", FrameworkDir: frameworkRoot(t)})
if err != nil {
t.Fatal("built-in paths must be permitted in API contracts", err)
}
writeFile(t, filepath.Join(dir, "endpoints_test.go"), `package main
import (
"context"
"io"
"net/http/httptest"
"testing"
"strings"
"example.com/endpoints/internal/config"
"example.com/endpoints/internal/svc"
"example.com/endpoints/internal/handler"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestProjectOwnsDisabledBuiltinPaths(t *testing.T){
cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
logger,_:=logx.New(cfg.Server.Log);defer logger.Close()
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};defer deps.Close()
if _,err=handler.NewApp(deps);err==nil || !strings.Contains(err.Error(),"overlaps business"){t.Fatal("expected preflight collision",err)}
deps.Config.Server.Endpoints.Readiness.Enabled=false
deps.Config.Server.Endpoints.OpenAPI.Enabled=false
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
for _,path:=range []string{"/health/ready","/openapi.json"}{
out:=httptest.NewRecorder();app.ServeHTTP(out,httptest.NewRequest("GET",path,nil))
if out.Code!=501{t.Fatal("expected generated business Logic stub",path,out.Code,out.Body.String())}
}
deps.Config.Server.Endpoints.Readiness.Enabled=true
deps.Config.Server.Endpoints.Readiness.Path="/readyz"
deps.Config.Server.Endpoints.OpenAPI.Enabled=true
deps.Config.Server.Endpoints.OpenAPI.Path="/spec.json"
app2,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app2.Close()
for _,path:=range []string{"/readyz","/spec.json"}{out:=httptest.NewRecorder();app2.ServeHTTP(out,httptest.NewRequest("GET",path,nil));if out.Code!=200{t.Fatal(path,out.Code)}}
}
`)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatal(err, string(output))
}
}

View File

@@ -19,7 +19,7 @@ import (
) )
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.12.0-preview" const minimumVersion = "v0.13.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct { type Options struct {
@@ -141,13 +141,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if !filepath.IsAbs(path) { if !filepath.IsAbs(path) {
path = filepath.Join(directory, path) path = filepath.Join(directory, path)
} }
if _, err := os.Stat(filepath.Join(path, "httpx", "transfer.go")); err == nil { if _, err := os.Stat(filepath.Join(path, "lifecycle", "controller.go")); err == nil {
localCurrent = true localCurrent = true
} }
} }
} }
if !localCurrent { if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates managed startup and streaming support; use %s or a current local replacement", minimumVersion) return Output{}, fmt.Errorf("existing framework dependency predates dependency shutdown and configurable endpoint support; use %s or a current local replacement", minimumVersion)
} }
} }
if o.FrameworkDir != "" { if o.FrameworkDir != "" {

View File

@@ -70,6 +70,7 @@ func TestManagedGeneratedProject(t *testing.T) {
mainBefore := readFile(t, filepath.Join(dir, "main.go")) mainBefore := readFile(t, filepath.Join(dir, "main.go"))
files := map[string]string{ files := map[string]string{
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go", "consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
"shutdown_test.go": "shutdown_test.go",
} }
for path, fixture := range files { for path, fixture := range files {
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture))) writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
@@ -78,7 +79,7 @@ func TestManagedGeneratedProject(t *testing.T) {
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n") writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
} }
configPath := filepath.Join(dir, "internal/config/config.go") configPath := filepath.Join(dir, "internal/config/config.go")
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`", 1)) writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go") svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1) source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1) source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1)

View File

@@ -13,13 +13,15 @@ import (
omni %q omni %q
%q %q
%q %q
%q
%q
) )
// NewApp is generated HTTP composition. The caller owns deps and its logger. // NewApp is generated HTTP composition. The caller owns deps and its logger.
func NewApp(deps *svc.ServiceContext)(*omni.App,error){ func NewApp(deps *svc.ServiceContext)(*omni.App,error){
if deps==nil || deps.Health==nil{return nil,fmt.Errorf("service context and health checker required")} if deps==nil || deps.Lifecycle==nil{return nil,fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")}
cfg:=deps.Config.Server cfg:=deps.Config.Server
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}} for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
`, frameworkModule, frameworkModule+"/httpx", o.Module+"/internal/svc") `, frameworkModule, frameworkModule+"/httpx", frameworkModule+"/config", frameworkModule+"/health", o.Module+"/internal/svc")
if spec.DefaultResponse != "" { if spec.DefaultResponse != "" {
for _, r := range spec.Responses { for _, r := range spec.Responses {
if r.Name == spec.DefaultResponse { if r.Name == spec.DefaultResponse {
@@ -28,13 +30,30 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){
} }
} }
} }
source += `if err:=cfg.Validate();err!=nil{return nil,err} source += "if err:=cfg.Validate();err!=nil{return nil,err}\n"
source += "if err:=cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{\n"
for _, route := range spec.Routes {
source += fmt.Sprintf("{Method:%q,Path:%q},\n", route.Method, route.Path)
}
if o.Storage == "local" {
source += "{Method: \"GET\",Path: \"/storage/object\"},\n"
}
source += `}...);err!=nil{return nil,err}
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health==nil{return nil,fmt.Errorf("enabled health endpoints require a checker")}
app:=omni.New(omni.WithConfig(cfg)) app:=omni.New(omni.WithConfig(cfg))
app.Use(deps.GlobalMiddleware()...) app.Use(deps.GlobalMiddleware()...)
if err:=RegisterRoutes(app,deps);err!=nil{return nil,err} if err:=RegisterRoutes(app,deps);err!=nil{_ = app.Close();return nil,err}
app.GET("/openapi.json",OpenAPI) if cfg.Endpoints.OpenAPI.Enabled{app.GET(cfg.Endpoints.OpenAPI.Path,OpenAPI)}
app.GET("/health/live",deps.Health.LivenessHandler()) if cfg.Endpoints.Liveness.Enabled{
app.GET("/health/ready",deps.Health.ReadinessHandler()) var options []health.HandlerOption
if cfg.Endpoints.Liveness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
app.GET(cfg.Endpoints.Liveness.Path,deps.Health.LivenessHandler(options...))
}
if cfg.Endpoints.Readiness.Enabled{
options:=[]health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
if cfg.Endpoints.Readiness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
app.GET(cfg.Endpoints.Readiness.Path,deps.Health.ReadinessHandler(options...))
}
` `
if o.Storage == "local" { if o.Storage == "local" {
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")} source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}

View File

@@ -32,11 +32,14 @@ func run(ctx context.Context,path string)(runErr error){
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err} deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
// RunContext drains HTTP requests before returning. This scope owns dependencies // RunContext drains HTTP requests before returning. This scope owns dependencies
// and closes them exactly once, before closing the borrowed logger. // and closes them exactly once, before closing the borrowed logger.
defer func(){runErr=errors.Join(runErr,deps.Close())}() defer func(){
runErr=errors.Join(runErr,deps.Close())
if cause:=deps.Lifecycle.Err();cause!=nil && !errors.Is(runErr,cause){runErr=errors.Join(runErr,cause)}
}()
app,err:=handler.NewApp(deps);if err!=nil{return err} app,err:=handler.NewApp(deps);if err!=nil{return err}
defer func(){runErr=errors.Join(runErr,app.Close())}() defer func(){runErr=errors.Join(runErr,app.Close())}()
logger.InfoContext(ctx,"service.starting","address",app.Config().Address) logger.InfoContext(ctx,"service.starting","address",app.Config().Address)
return app.RunContext(ctx) return app.RunContext(deps.Lifecycle.Context())
} }
`, `,
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n", ".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
@@ -58,6 +61,10 @@ go run .
GET /health/live and /health/ready check the running service. GET /openapi.json GET /health/live and /health/ready check the running service. GET /openapi.json
serves the same document as docs/openapi.json. Bind address defaults to localhost. serves the same document as docs/openapi.json. Bind address defaults to localhost.
Configure server.endpoints to disable or move each built-in route. Health
responseMode is app by default (project format), or raw for plain health data.
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services. Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
The greeting starter works immediately; other new operation stubs return 501 until The greeting starter works immediately; other new operation stubs return 501 until
their business logic is implemented. Authentication hooks also fail closed with 501. their business logic is implemented. Authentication hooks also fail closed with 501.
@@ -174,6 +181,16 @@ Do not close borrowed resources or attach duplicate cleanup hooks to App. The st
context cancels on signals; long-lived clients/workers that requests depend on need context cancels on signals; long-lived clients/workers that requests depend on need
their own lifetime, canceled by OnClose after HTTP draining. their own lifetime, canceled by OnClose after HTTP draining.
svc.RequestShutdown(err) lets a dependency report a fatal service failure.
It returns immediately. Generated main stops accepting requests, drains them,
closes dependencies and logs, then exits nonzero with the original cause.
nil requests a normal stop. Concurrent calls keep the first non-nil failure;
notifications during initialization or draining are retained. Close must stop
and join project-owned monitors before the final shutdown result is returned.
The svc lifecycle context signals draining, not permission to close resources
still used by active requests. Liveness and readiness are different: shutdown
makes readiness 503; it does not turn a running process's liveness into failure.
Named middleware is composed in declaration order (first is outermost). Implement Named middleware is composed in declaration order (first is outermost). Implement
the generated hooks or replace entries in svc; never bypass an auth the generated hooks or replace entries in svc; never bypass an auth
hook merely to make the server start. Middleware names do not imply a JWT policy. hook merely to make the server start. Middleware names do not imply a JWT policy.

View File

@@ -9,16 +9,23 @@ import (
var Opened, Closed, DownloadClosed atomic.Int32 var Opened, Closed, DownloadClosed atomic.Int32
var Started = make(chan struct{}, 1) var Started = make(chan struct{}, 1)
var Release = make(chan struct{}) var Release = make(chan struct{})
var Current atomic.Pointer[ServiceContext]
var FatalDependency = errors.New("test dependency cannot continue")
// This fake client exercises the same lifetime contract as a database/client. // This fake client exercises the same lifetime contract as a database/client.
type Client struct{} type Client struct{}
func initClient(ctx context.Context, c *ServiceContext) error { func initClient(ctx context.Context, c *ServiceContext) error {
Current.Store(c)
Opened.Add(1) Opened.Add(1)
c.Client = &Client{} c.Client = &Client{}
c.OnClose(func() error { Closed.Add(1); return nil }) c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
if c.Config.FailInit { if c.Config.FailInit {
return errors.New("client initialization failed") return errors.New("client initialization failed")
} }
if c.Config.ShutdownInit {
c.RequestShutdown(FatalDependency)
return nil
}
return ctx.Err() return ctx.Err()
} }

View File

@@ -71,9 +71,10 @@ type countedReader struct{ io.Reader }
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil } func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
func (l *Logic) Slow(context.Context) (*types.Receipt, error) { func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
before := svc.Closed.Load()
svc.Started <- struct{}{} svc.Started <- struct{}{}
<-svc.Release <-svc.Release
if svc.Closed.Load() != 0 { if svc.Closed.Load() != before {
return nil, errors.New("dependency closed before drain") return nil, errors.New("dependency closed before drain")
} }
return &types.Receipt{Bytes: 1}, nil return &types.Receipt{Bytes: 1}, nil

View File

@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"example.com/managed/internal/config"
"example.com/managed/internal/handler"
"example.com/managed/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestGeneratedEndpointSettings(t *testing.T) {
cfg := config.Default()
cfg.Server.Log.Writer = io.Discard
cfg.Server.Endpoints.Liveness.Path = "/livez"
cfg.Server.Endpoints.Readiness.Path = "/readyz"
cfg.Server.Endpoints.OpenAPI.Enabled = false
logger, err := logx.New(cfg.Server.Log)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deps, err := svc.New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
app, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer app.Close()
for _, path := range []string{"/health/live", "/health/ready", "/openapi.json"} {
if out := response(t, app, "GET", path, nil, nil); out.Code != 404 {
t.Fatal(path, out.Code)
}
}
for _, path := range []string{"/readyz", "/livez"} {
out := response(t, app, "GET", path, nil, nil)
var data map[string]any
_ = json.Unmarshal(out.Body.Bytes(), &data)
if out.Code != 200 || data["code"] != float64(0) || data["msg"] != "success" || data["message"] != nil || data["requestId"] != nil {
t.Fatal(path, out.Code, out.Body.String())
}
}
deps.RequestShutdown(errors.New("private shutdown cause"))
out := response(t, app, "GET", "/readyz", nil, nil)
if out.Code != 503 || strings.Contains(out.Body.String(), "private") {
t.Fatal(out.Code, out.Body.String())
}
if out = response(t, app, "GET", "/livez", nil, nil); out.Code != 200 {
t.Fatal("liveness should still work")
}
// NewApp uses the project's latest configuration, including endpoint switches.
deps.Config.Server.Endpoints.Readiness.ResponseMode = "raw"
deps.Config.Server.Endpoints.Liveness.Enabled = false
deps.Config.Server.Endpoints.OpenAPI.Enabled = true
deps.Config.Server.Endpoints.OpenAPI.Path = "/spec.json"
app2, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer app2.Close()
out = response(t, app2, "GET", "/readyz", nil, nil)
if out.Code != 503 || strings.Contains(out.Body.String(), `"code"`) || !strings.Contains(out.Body.String(), `"status":"not_ready"`) {
t.Fatal(out.Body.String())
}
out = response(t, app2, "GET", "/spec.json", nil, nil)
if out.Code != 200 || !strings.Contains(out.Body.String(), `"openapi":`) || strings.Contains(out.Body.String(), `"msg":"操作成功"`) {
t.Fatal("OpenAPI was wrapped")
}
deps.Config.Server.Endpoints.Readiness.Path = "/check"
if _, err = handler.NewApp(deps); err == nil || !strings.Contains(err.Error(), "overlaps business") {
t.Fatal("collision accepted", err)
}
deps.Config.Server.Endpoints.Readiness.Enabled = false
if a, err := handler.NewApp(deps); err != nil {
t.Fatal("disabled endpoint reserves path", err)
} else {
_ = a.Close()
}
}
func TestGeneratedFailedProbe(t *testing.T) {
cfg := config.Default()
cfg.Server.Log.Writer = io.Discard
logger, _ := logx.New(cfg.Server.Log)
defer logger.Close()
deps, err := svc.New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
if err = deps.Health.Add(health.Probe{Name: "database", Check: func(context.Context) error { return errors.New("private connection details") }}); err != nil {
t.Fatal(err)
}
app, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer app.Close()
out := response(t, app, "GET", "/health/ready", nil, nil)
if out.Code != 503 || strings.Contains(out.Body.String(), "private") || !strings.Contains(out.Body.String(), `"msg":`) {
t.Fatal(out.Code, out.Body.String())
}
}
func TestGeneratedDependencyShutdown(t *testing.T) {
for _, transport := range []string{"nethttp", "native"} {
t.Run(transport, func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
_ = listener.Close()
t.Setenv("GO_OMNI_TRANSPORT", transport)
svc.Started = make(chan struct{}, 1)
svc.Release = make(chan struct{})
before := svc.Closed.Load()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
path := configFile(t, address, false)
go func() { done <- run(ctx, path) }()
base := "http://" + address
waitReady(t, base, done)
deps := svc.Current.Load()
requestDone := make(chan error, 1)
go func() {
client := http.Client{Timeout: 3 * time.Second}
res, err := client.Get(base + "/slow")
if err == nil {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != 200 {
err = io.ErrUnexpectedEOF
}
}
requestDone <- err
}()
select {
case <-svc.Started:
case <-time.After(3 * time.Second):
t.Fatal("request did not start")
}
deps.RequestShutdown(svc.FatalDependency)
// Inspect the same generated readiness handler during draining, even if
// the network listener has already stopped accepting new connections.
probe, _ := handler.NewApp(deps)
out := httptest.NewRecorder()
probe.ServeHTTP(out, httptest.NewRequest("GET", "/health/ready", nil))
_ = probe.Close()
if out.Code != 503 {
t.Fatal("draining remained ready", out.Body.String())
}
if svc.Closed.Load() != before {
t.Fatal("dependency closed before drain")
}
close(svc.Release)
select {
case err = <-requestDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("request stuck")
}
select {
case err = <-done:
if !errors.Is(err, svc.FatalDependency) {
t.Fatal("fatal cause lost", err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown stuck")
}
if svc.Closed.Load() != before+1 {
t.Fatal("cleanup did not run once")
}
})
}
}
func TestGeneratedFatalProcessExit(t *testing.T) {
if path := os.Getenv("OMNI_TEST_FATAL_CONFIG"); path != "" {
os.Args = []string{os.Args[0], "-config", path}
main()
return
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"shutdownInit":true,"server":{"address":"127.0.0.1:0"}}`), 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedFatalProcessExit$")
cmd.Env = append(os.Environ(), "OMNI_TEST_FATAL_CONFIG="+path)
output, err := cmd.CombinedOutput()
var exit *exec.ExitError
if !errors.As(err, &exit) || exit.ExitCode() != 1 {
t.Fatal("fatal shutdown must exit 1", err, string(output))
}
if !strings.Contains(string(output), svc.FatalDependency.Error()) || strings.Count(string(output), "test.client.closed") != 1 {
t.Fatal("missing failure or cleanup", string(output))
}
}
func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"server":{"address":"127.0.0.1:0","endpoints":{"readiness":{"path":"/check"}}}}`), 0600); err != nil {
t.Fatal(err)
}
before := svc.Closed.Load()
err := run(context.Background(), path)
if err == nil || !strings.Contains(err.Error(), "overlaps business") || svc.Closed.Load() != before+1 {
t.Fatal("collision leaked resources or started serving", err)
}
}

View File

@@ -8,7 +8,7 @@ import (
) )
func TestNewWithRemoteFrameworkVersion(t *testing.T) { func TestNewWithRemoteFrameworkVersion(t *testing.T) {
for _, flag := range [][]string{{"--framework-version", "v0.12.0-preview"}, {"--framework-version=v0.12.0-preview"}} { for _, flag := range [][]string{{"--framework-version", "v0.13.0-preview"}, {"--framework-version=v0.13.0-preview"}} {
project := filepath.Join(t.TempDir(), "billing") project := filepath.Join(t.TempDir(), "billing")
args := append([]string{"--module", "example.com/billing"}, flag...) args := append([]string{"--module", "example.com/billing"}, flag...)
if err := runNew(append(args, project)); err != nil { if err := runNew(append(args, project)); err != nil {
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.Contains(string(content), "go-omni v0.12.0-preview") || strings.Contains(string(content), "replace ") { if !strings.Contains(string(content), "go-omni v0.13.0-preview") || strings.Contains(string(content), "replace ") {
t.Fatalf("CLI did not pin remote dependency: %s", content) t.Fatalf("CLI did not pin remote dependency: %s", content)
} }
} }

View File

@@ -4,6 +4,7 @@ import "errors"
func(c *Context)Close()error{ func(c *Context)Close()error{
if c==nil{return nil} if c==nil{return nil}
c.closeOnce.Do(func(){ c.closeOnce.Do(func(){
if c.Lifecycle!=nil{c.Lifecycle.RequestShutdown(nil)}
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock() c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())} for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}} {{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}

View File

@@ -11,12 +11,14 @@ import (
"git.yuntuo.hk/k2safe/go-omni/health" "git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"{{.Module}}/internal/settings" "{{.Module}}/internal/settings"
"{{.Module}}/internal/middleware" "{{.Module}}/internal/middleware"
) )
// Context owns infrastructure, while Logger is borrowed from main. // Context owns infrastructure, while Logger is borrowed from main.
// Add business repositories here; HTTP contracts do not define database tables. // Add business repositories here; HTTP contracts do not define database tables.
type Context struct { type Context struct {
Lifecycle *lifecycle.Controller
Config settings.Config Config settings.Config
cleanups []func()error cleanups []func()error
Logger *logx.Logger Logger *logx.Logger
@@ -40,13 +42,14 @@ type Context struct {
// tests without infrastructure; selected adapters still require valid settings. // tests without infrastructure; selected adapters still require valid settings.
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
if ctx==nil{ctx=context.Background()} if ctx==nil{ctx=context.Background()}
if err=ctx.Err();err!=nil{return nil,err} if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
if len(configs)>1{return nil,errors.New("expected at most one configuration")} if len(configs)>1{return nil,errors.New("expected at most one configuration")}
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]} cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
_=cfg _=cfg
checker,err:=health.New();if err!=nil{return nil,err} checker,err:=health.New();if err!=nil{return nil,err}
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults()} c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Lifecycle:lifecycle.New(ctx)}
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}() ctx=c.Lifecycle.Context()
defer func(){if err!=nil{err=errors.Join(err,c.Close());if cause:=c.Lifecycle.Err();cause!=nil && !errors.Is(err,cause){err=errors.Join(err,cause)}}}()
{{if eq .Database "mysql"}} {{if eq .Database "mysql"}}
if err=cfg.Validate();err!=nil{return nil,err} if err=cfg.Validate();err!=nil{return nil,err}
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN) dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
@@ -59,3 +62,7 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}} {{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
return c,nil return c,nil
} }
// RequestShutdown reports a fatal dependency failure, or a normal stop when
// err is nil. It returns immediately; generated main drains and cleans up.
func(c *Context)RequestShutdown(err error){c.Lifecycle.RequestShutdown(err)}

View File

@@ -4,6 +4,19 @@ server:
enableLogger: true enableLogger: true
slowRequestThreshold: 500ms slowRequestThreshold: 500ms
shutdownTimeout: 10s shutdownTimeout: 10s
{{if .API}} endpoints:
liveness:
enabled: true
path: /health/live
responseMode: app
readiness:
enabled: true
path: /health/ready
responseMode: app
openapi:
enabled: true
path: /openapi.json
{{end}}
log: log:
service: {{printf "%q" .Module}} service: {{printf "%q" .Module}}
level: info level: info

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty). // Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their // Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum. // downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.12.0-preview" const defaultFrameworkVersion = "v0.13.0-preview"
// This target is not a claim that the release already exists. Source users // This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published. // select a local framework checkout until the module templates are published.

View File

@@ -30,6 +30,7 @@ type Config struct {
Transport string `json:"transport"` Transport string `json:"transport"`
TLS TLSConfig `json:"tls"` TLS TLSConfig `json:"tls"`
HTTP HTTPConfig `json:"http"` HTTP HTTPConfig `json:"http"`
Endpoints EndpointsConfig `json:"endpoints"`
RequestIDHeader string `json:"requestIdHeader"` RequestIDHeader string `json:"requestIdHeader"`
EnableLogger bool `json:"enableLogger"` EnableLogger bool `json:"enableLogger"`
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
@@ -47,12 +48,16 @@ func Default() Config {
Transport: TransportNetHTTP, Transport: TransportNetHTTP,
RequestIDHeader: defaultRequestIDHeader, RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second, ShutdownTimeout: 10 * time.Second,
Endpoints: DefaultEndpoints(),
Log: logx.Default(), Log: logx.Default(),
} }
} }
// Validate checks configuration values before an application starts. // Validate checks configuration values before an application starts.
func (c Config) Validate() error { func (c Config) Validate() error {
if err := c.Endpoints.Validate(); err != nil {
return err
}
if err := c.Errors.Validate(); err != nil { if err := c.Errors.Validate(); err != nil {
return err return err
} }

119
config/endpoints.go Normal file
View File

@@ -0,0 +1,119 @@
package config
import (
"fmt"
"strings"
"unicode"
)
// EndpointsConfig controls routes installed by a generated service. A manually
// assembled App still registers its own routes explicitly.
type EndpointsConfig struct {
Liveness HealthEndpointConfig `json:"liveness"`
Readiness HealthEndpointConfig `json:"readiness"`
OpenAPI EndpointConfig `json:"openapi"`
}
type EndpointConfig struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
}
type HealthEndpointConfig struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
ResponseMode string `json:"responseMode"` // app (default) or raw
}
func DefaultEndpoints() EndpointsConfig {
return EndpointsConfig{
Liveness: HealthEndpointConfig{Enabled: true, Path: "/health/live", ResponseMode: "app"},
Readiness: HealthEndpointConfig{Enabled: true, Path: "/health/ready", ResponseMode: "app"},
OpenAPI: EndpointConfig{Enabled: true, Path: "/openapi.json"},
}
}
func (c EndpointsConfig) enabled() []struct{ name, path string } {
var result []struct{ name, path string }
for _, endpoint := range []struct {
name string
enabled bool
path string
}{
{"liveness", c.Liveness.Enabled, c.Liveness.Path},
{"readiness", c.Readiness.Enabled, c.Readiness.Path},
{"openapi", c.OpenAPI.Enabled, c.OpenAPI.Path},
} {
if endpoint.enabled {
result = append(result, struct{ name, path string }{endpoint.name, endpoint.path})
}
}
return result
}
func (c EndpointsConfig) Validate() error {
for _, mode := range []string{c.Liveness.ResponseMode, c.Readiness.ResponseMode} {
if mode != "" && mode != "app" && mode != "raw" {
return fmt.Errorf("endpoints: responseMode must be app or raw")
}
}
paths := map[string]string{}
for _, endpoint := range c.enabled() {
p := endpoint.path
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, ":*?#%\\{}") ||
strings.IndexFunc(p, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return fmt.Errorf("endpoints.%s.path must be an absolute literal URL path", endpoint.name)
}
parts := strings.Split(p[1:], "/")
for i, part := range parts {
if part == "." || part == ".." || (part == "" && i < len(parts)-1) {
return fmt.Errorf("endpoints.%s.path contains an invalid segment", endpoint.name)
}
}
if previous := paths[p]; previous != "" {
return fmt.Errorf("endpoints.%s and endpoints.%s share path %s", previous, endpoint.name, p)
}
paths[p] = endpoint.name
}
return nil
}
// EndpointRoute describes an already validated business route. Only GET
// patterns can overlap these built-in GET endpoints.
type EndpointRoute struct{ Method, Path string }
// ValidateRoutes rejects a built-in route that would shadow a business route
// or conflict with its catch-all. Disabled endpoints reserve no paths.
func (c EndpointsConfig) ValidateRoutes(routes ...EndpointRoute) error {
if err := c.Validate(); err != nil {
return err
}
for _, endpoint := range c.enabled() {
for _, route := range routes {
if strings.EqualFold(route.Method, "GET") && endpointOverlap(route.Path, endpoint.path) {
return fmt.Errorf("endpoints.%s path %s overlaps business route GET %s; change its path or disable it", endpoint.name, endpoint.path, route.Path)
}
}
}
return nil
}
func endpointOverlap(pattern, path string) bool {
a, b := strings.Split(strings.TrimPrefix(pattern, "/"), "/"), strings.Split(path[1:], "/")
for i, part := range a {
if i >= len(b) {
return false
}
if strings.HasPrefix(part, "*") {
return true
}
if strings.HasPrefix(part, ":") {
if b[i] == "" {
return false
}
} else if part != b[i] {
return false
}
}
return len(a) == len(b)
}

66
config/endpoints_test.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestEndpointConfiguration(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("endpoints:\n liveness:\n enabled: false\n readiness:\n path: /readyz\n responseMode: raw\n openapi:\n enabled: false\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := DecodeFile(path, Default())
if err != nil {
t.Fatal(err)
}
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
t.Fatal(cfg.Endpoints)
}
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
cfg, err = ApplyEnv(cfg, "TEST_")
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
t.Fatal(cfg.Endpoints, err)
}
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
t.Fatal("invalid bool accepted")
}
}
func TestEndpointRouteConflicts(t *testing.T) {
for _, route := range []EndpointRoute{{"GET", "/health/ready"}, {"get", "/health/:name"}, {"GET", "/health/*rest"}, {"GET", "/*rest"}} {
cfg := DefaultEndpoints()
if err := cfg.ValidateRoutes(route); err == nil {
t.Fatal("overlap accepted", route)
}
cfg.Readiness.Enabled = false
cfg.Liveness.Enabled = false
cfg.OpenAPI.Enabled = false
if err := cfg.ValidateRoutes(route); err != nil {
t.Fatal("disabled endpoint reserves path", err)
}
}
if err := DefaultEndpoints().ValidateRoutes(EndpointRoute{"POST", "/health/ready"}, EndpointRoute{"GET", "/other/*rest"}); err != nil {
t.Fatal(err)
}
for _, path := range []string{"", "ready", "//ready", "/a/../ready", "/:ready", "/ready?x=1", "/a%2fb", "/ready\n"} {
cfg := DefaultEndpoints()
cfg.Readiness.Path = path
if err := cfg.Validate(); err == nil {
t.Fatal("invalid path", path)
}
}
cfg := DefaultEndpoints()
cfg.Readiness.Path = cfg.Liveness.Path
if err := cfg.Validate(); err == nil {
t.Fatal("duplicate endpoints accepted")
}
cfg = DefaultEndpoints()
cfg.Readiness.ResponseMode = "unknown"
if err := cfg.Validate(); err == nil {
t.Fatal("invalid mode accepted")
}
}

View File

@@ -49,6 +49,26 @@ func applyEnv(cfg Config, prefix string) (Config, error) {
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment) cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
var err error var err error
for _, endpoint := range []struct {
name string
enabled *bool
path *string
mode *string
}{
{"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode},
{"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode},
{"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil},
} {
key := prefix + "ENDPOINTS_" + endpoint.name
*endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled)
if err != nil {
return Config{}, err
}
*endpoint.path = envString(key+"_PATH", *endpoint.path)
if endpoint.mode != nil {
*endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode)
}
}
for _, field := range []struct { for _, field := range []struct {
name string name string
value *time.Duration value *time.Duration

View File

@@ -10,14 +10,14 @@
"entry": "api/managed.api", "entry": "api/managed.api",
"generated": { "generated": {
"docs/openapi.json": "9eb707aefeefd3f4881c774534e2d143949c0edb1d8a4aabd762f978f6203df1", "docs/openapi.json": "9eb707aefeefd3f4881c774534e2d143949c0edb1d8a4aabd762f978f6203df1",
"internal/handler/appGen.go": "dbadfb73165e0e6ef10e8277dd1b8a0edc2992dac3690d634808ac92829c2654", "internal/handler/appGen.go": "c6f078932e1a598a46faebba397f4e753db9d8f4f29a02f9b6077a0557d6c14e",
"internal/handler/demo/handlersGen.go": "7a6f9d5e105e44b07196cc540cee3f0562d5f3d1c5f0bc094434db1064d0339c", "internal/handler/demo/handlersGen.go": "7a6f9d5e105e44b07196cc540cee3f0562d5f3d1c5f0bc094434db1064d0339c",
"internal/handler/openapiGen.go": "612920ed25e5dc7a4f673be56041d47b70aa3216707ff282e52dfe503c2d705a", "internal/handler/openapiGen.go": "612920ed25e5dc7a4f673be56041d47b70aa3216707ff282e52dfe503c2d705a",
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399", "internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e", "internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4", "internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
"internal/types/typesGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344", "internal/types/typesGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
"main.go": "09ae81857b806f3f7dca608399b1881d94bc9c4b2258e774e77d694fcadbab65" "main.go": "a12db5b7df0f2fdca4939a7382daee04c85ddb63ef0bccd1a2ce26b718f42579"
}, },
"contract": { "contract": {
"routes": { "routes": {

View File

@@ -3,7 +3,14 @@
This checked-in example uses a relative local framework replacement (`../..`). This checked-in example uses a relative local framework replacement (`../..`).
Run `go mod tidy && go run .` here. No external database or cloud account is required. Run `go mod tidy && go run .` here. No external database or cloud account is required.
The Go-only error catalog is `internal/apierrors/catalog.go`, registered by svc. The Go-only error catalog is `internal/apierrors/catalog.go`, registered by svc.
The generated main and handlers are unmodified. Try: The generated main and handlers are unmodified. Built-in endpoint switches and
paths are configured in etc/config.yaml under server.endpoints. Health replies
use the project envelope by default; responseMode: raw opts out. Readiness
failures stay HTTP 503, independently of business error status policies.
Dependencies can call svc.RequestShutdown(err); the generated entry drains
requests, closes resources and retains the failure for a nonzero exit.
Try:
```sh ```sh
curl -i 'http://127.0.0.1:8080/check?kind=fixed' curl -i 'http://127.0.0.1:8080/check?kind=fixed'

View File

@@ -4,6 +4,10 @@ server:
enableLogger: true enableLogger: true
slowRequestThreshold: 500ms slowRequestThreshold: 500ms
shutdownTimeout: 10s shutdownTimeout: 10s
endpoints:
liveness: {enabled: true, path: /health/live, responseMode: app}
readiness: {enabled: true, path: /health/ready, responseMode: app}
openapi: {enabled: true, path: /openapi.json}
log: log:
service: "example.com/managed" service: "example.com/managed"
level: info level: info

View File

@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0 go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.12.0-preview require git.yuntuo.hk/k2safe/go-omni v0.13.0-preview
require ( require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -6,12 +6,14 @@ import (
omni "git.yuntuo.hk/k2safe/go-omni" omni "git.yuntuo.hk/k2safe/go-omni"
"example.com/managed/internal/svc" "example.com/managed/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/health"
) )
// NewApp is generated HTTP composition. The caller owns deps and its logger. // NewApp is generated HTTP composition. The caller owns deps and its logger.
func NewApp(deps *svc.ServiceContext) (*omni.App, error) { func NewApp(deps *svc.ServiceContext) (*omni.App, error) {
if deps == nil || deps.Health == nil { if deps == nil || deps.Lifecycle == nil {
return nil, fmt.Errorf("service context and health checker required") return nil, fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")
} }
cfg := deps.Config.Server cfg := deps.Config.Server
for _, option := range deps.AppOptions() { for _, option := range deps.AppOptions() {
@@ -24,13 +26,42 @@ func NewApp(deps *svc.ServiceContext) (*omni.App, error) {
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, err return nil, err
} }
if err := cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{
{Method: "get", Path: "/check"},
{Method: "get", Path: "/other"},
{Method: "get", Path: "/raw"},
{Method: "post", Path: "/upload"},
{Method: "post", Path: "/callback"},
{Method: "get", Path: "/download"},
{Method: "get", Path: "/slow"},
}...); err != nil {
return nil, err
}
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health == nil {
return nil, fmt.Errorf("enabled health endpoints require a checker")
}
app := omni.New(omni.WithConfig(cfg)) app := omni.New(omni.WithConfig(cfg))
app.Use(deps.GlobalMiddleware()...) app.Use(deps.GlobalMiddleware()...)
if err := RegisterRoutes(app, deps); err != nil { if err := RegisterRoutes(app, deps); err != nil {
_ = app.Close()
return nil, err return nil, err
} }
app.GET("/openapi.json", OpenAPI) if cfg.Endpoints.OpenAPI.Enabled {
app.GET("/health/live", deps.Health.LivenessHandler()) app.GET(cfg.Endpoints.OpenAPI.Path, OpenAPI)
app.GET("/health/ready", deps.Health.ReadinessHandler()) }
if cfg.Endpoints.Liveness.Enabled {
var options []health.HandlerOption
if cfg.Endpoints.Liveness.ResponseMode == "raw" {
options = append(options, health.WithRawResponse())
}
app.GET(cfg.Endpoints.Liveness.Path, deps.Health.LivenessHandler(options...))
}
if cfg.Endpoints.Readiness.Enabled {
options := []health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
if cfg.Endpoints.Readiness.ResponseMode == "raw" {
options = append(options, health.WithRawResponse())
}
app.GET(cfg.Endpoints.Readiness.Path, deps.Health.ReadinessHandler(options...))
}
return app, nil return app, nil
} }

View File

@@ -8,6 +8,9 @@ func (c *ServiceContext) Close() error {
return nil return nil
} }
c.closeOnce.Do(func() { c.closeOnce.Do(func() {
if c.Lifecycle != nil {
c.Lifecycle.RequestShutdown(nil)
}
c.closeMu.Lock() c.closeMu.Lock()
c.closed = true c.closed = true
cleanups := c.cleanups cleanups := c.cleanups

View File

@@ -9,12 +9,14 @@ import (
"example.com/managed/internal/middleware" "example.com/managed/internal/middleware"
"git.yuntuo.hk/k2safe/go-omni/health" "git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
) )
// Context owns infrastructure, while Logger is borrowed from main. // Context owns infrastructure, while Logger is borrowed from main.
// Add business repositories here; HTTP contracts do not define database tables. // Add business repositories here; HTTP contracts do not define database tables.
type ServiceContext struct { type ServiceContext struct {
Lifecycle *lifecycle.Controller
Config config.Config Config config.Config
cleanups []func() error cleanups []func() error
Logger *logx.Logger Logger *logx.Logger
@@ -34,7 +36,7 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
ctx = context.Background() ctx = context.Background()
} }
if err = ctx.Err(); err != nil { if err = ctx.Err(); err != nil {
return nil, err return nil, errors.Join(err, lifecycle.Cause(ctx))
} }
if len(configs) > 1 { if len(configs) > 1 {
return nil, errors.New("expected at most one configuration") return nil, errors.New("expected at most one configuration")
@@ -48,12 +50,18 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
if err != nil { if err != nil {
return nil, err return nil, err
} }
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults()} c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Lifecycle: lifecycle.New(ctx)}
ctx = c.Lifecycle.Context()
defer func() { defer func() {
if err != nil { if err != nil {
err = errors.Join(err, c.Close()) err = errors.Join(err, c.Close())
if cause := c.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) {
err = errors.Join(err, cause)
}
} }
}() }()
return c, nil return c, nil
} }
func (c *ServiceContext) RequestShutdown(err error) { c.Lifecycle.RequestShutdown(err) }

View File

@@ -41,12 +41,17 @@ func run(ctx context.Context, path string) (runErr error) {
} }
// RunContext drains HTTP requests before returning. This scope owns dependencies // RunContext drains HTTP requests before returning. This scope owns dependencies
// and closes them exactly once, before closing the borrowed logger. // and closes them exactly once, before closing the borrowed logger.
defer func() { runErr = errors.Join(runErr, deps.Close()) }() defer func() {
runErr = errors.Join(runErr, deps.Close())
if cause := deps.Lifecycle.Err(); cause != nil && !errors.Is(runErr, cause) {
runErr = errors.Join(runErr, cause)
}
}()
app, err := handler.NewApp(deps) app, err := handler.NewApp(deps)
if err != nil { if err != nil {
return err return err
} }
defer func() { runErr = errors.Join(runErr, app.Close()) }() defer func() { runErr = errors.Join(runErr, app.Close()) }()
logger.InfoContext(ctx, "service.starting", "address", app.Config().Address) logger.InfoContext(ctx, "service.starting", "address", app.Config().Address)
return app.RunContext(ctx) return app.RunContext(deps.Lifecycle.Context())
} }

View File

@@ -14,4 +14,8 @@ app.GET("/health/live", checker.LivenessHandler())
app.GET("/health/ready", checker.ReadinessHandler()) app.GET("/health/ready", checker.ReadinessHandler())
``` ```
就绪探针会保留每项检查结果,并在至少一项失败时返回 `503` 和 `NOT_READY`。 从 v0.13.0-preview 起,两个 Handler 默认都使用 App 响应模板/自定义 Encoder。就绪成功使用项目成功码和提示;失败以 `NOT_READY` 查询项目错误目录,返回项目字段、业务码和公开提示。HTTP 状态保持 200/503,不受业务错误 HTTP 200 策略影响。底层探针原因不会直接放入 HTTP 响应,保留到失败错误链供日志使用;内部 `Checker.Check` 仍提供检查错误。
`ReadinessHandler(health.WithRawResponse())` 只返回健康数据,不包公共体。`health.WithShutdownContext(ctx)` 让服务开始退出时立即标记为未就绪;存活检查不受影响。超时依赖 Probe.Check 自己响应 context 取消,框架不会遗留 goroutine 去强行打断不合作的检查。
生成项目的开关、路径和响应模式统一配置在 `server.endpoints`,不需要修改 Handler。完整配置、依赖主动退出和升级方法见 [项目指南](../PROJECT.md)。

View File

@@ -3,14 +3,15 @@ package health
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time" "time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
) )
const ( const (
@@ -38,6 +39,7 @@ type CheckResult struct {
type Result struct { type Result struct {
Status string `json:"status"` Status string `json:"status"`
Checks []CheckResult `json:"checks,omitempty"` Checks []CheckResult `json:"checks,omitempty"`
cause error
} }
func (r Result) Ready() bool { return r.Status == StatusReady } func (r Result) Ready() bool { return r.Status == StatusReady }
@@ -118,6 +120,7 @@ func (c *Checker) Check(ctx context.Context) Result {
result.Status = StatusNotReady result.Status = StatusNotReady
checkResult.Status = StatusNotReady checkResult.Status = StatusNotReady
checkResult.Error = err.Error() checkResult.Error = err.Error()
result.cause = errors.Join(result.cause, fmt.Errorf("%s: %w", probe.Name, err))
} }
result.Checks = append(result.Checks, checkResult) result.Checks = append(result.Checks, checkResult)
} }
@@ -126,28 +129,78 @@ func (c *Checker) Check(ctx context.Context) Result {
// LivenessHandler reports that the process is running. It does not execute // LivenessHandler reports that the process is running. It does not execute
// dependency probes and is safe to use for orchestrator liveness checks. // dependency probes and is safe to use for orchestrator liveness checks.
func (c *Checker) LivenessHandler() httpx.Handler { func (c *Checker) LivenessHandler(options ...HandlerOption) httpx.Handler {
settings := handlerSettings(options)
return func(ctx *httpx.Context) error { return func(ctx *httpx.Context) error {
return ctx.OK(map[string]string{"status": "ok"}) data := map[string]string{"status": "ok"}
if settings.raw {
return ctx.JSON(http.StatusOK, data)
}
return ctx.RespondStatus(http.StatusOK, data, nil)
} }
} }
// ReadinessHandler reports whether all registered dependencies are ready. // ReadinessHandler reports whether all registered dependencies are ready.
// Failure uses HTTP 503 while preserving GoOmni's response envelope. // Failure always uses HTTP 503. App response configuration is used by default;
func (c *Checker) ReadinessHandler() httpx.Handler { // WithRawResponse returns just health data. Internal probe errors remain in the
// recorded failure for logs, not in the HTTP response.
func (c *Checker) ReadinessHandler(options ...HandlerOption) httpx.Handler {
settings := handlerSettings(options)
return func(ctx *httpx.Context) error { return func(ctx *httpx.Context) error {
result := c.Check(ctx.Context()) var result Result
if settings.shutdown != nil && settings.shutdown.Err() != nil {
result = Result{Status: StatusNotReady, cause: context.Cause(settings.shutdown)}
} else {
result = c.Check(ctx.Context())
// A stop may arrive while the probes are running.
if settings.shutdown != nil && settings.shutdown.Err() != nil {
result.Status = StatusNotReady
result.cause = errors.Join(result.cause, context.Cause(settings.shutdown))
}
}
for i := range result.Checks {
result.Checks[i].Error = ""
}
status := http.StatusOK status := http.StatusOK
code, message := "READY", "ready" var failure error
if !result.Ready() { if !result.Ready() {
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
code, message = "NOT_READY", "service is not ready" failure = apierrors.New(status, "NOT_READY", "service is not ready").WithCause(result.cause)
} }
return ctx.JSON(status, response.Response{ if settings.raw {
Code: code, if failure != nil {
Message: message, resolved := ctx.ResolveError(failure)
Data: result, resolved.Status = status
RequestID: ctx.RequestID(), ctx.RecordFailure(resolved)
}) }
return ctx.JSON(status, result)
}
return ctx.RespondStatus(status, result, failure)
} }
} }
type handlerConfig struct {
raw bool
shutdown context.Context
}
type HandlerOption func(*handlerConfig)
// WithRawResponse bypasses the App envelope for this probe's health data.
func WithRawResponse() HandlerOption { return func(c *handlerConfig) { c.raw = true } }
// WithShutdownContext marks readiness false as soon as service shutdown begins.
// It never changes liveness or closes any project resource.
func WithShutdownContext(ctx context.Context) HandlerOption {
return func(c *handlerConfig) { c.shutdown = ctx }
}
func handlerSettings(options []HandlerOption) handlerConfig {
var cfg handlerConfig
for _, option := range options {
if option != nil {
option(&cfg)
}
}
return cfg
}

View File

@@ -1,6 +1,7 @@
package health package health
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -10,6 +11,9 @@ import (
"time" "time"
omni "git.yuntuo.hk/k2safe/go-omni" omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp" "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
) )
@@ -43,11 +47,119 @@ func TestCheckerReadinessReportsAllProbes(t *testing.T) {
if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil { if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "connection refused" { if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "" {
t.Fatalf("unexpected readiness response: %+v", body) t.Fatalf("unexpected readiness response: %+v", body)
} }
} }
func TestProbeResponsePolicies(t *testing.T) {
for _, raw := range []bool{false, true} {
t.Run(map[bool]string{true: "raw", false: "app"}[raw], func(t *testing.T) {
probeErr := errors.New("private database connection details")
checker, _ := New(Probe{Name: "database", Check: func(context.Context) error { return probeErr }})
app := omni.New(omni.WithResponse(response.Config{Body: map[string]any{"code": "${code}", "msg": "${message}", "data": "${data}", "httpStatus": "${status}"}, CodeType: "int32", SuccessCode: 200, ErrorCode: 9999, SuccessMessage: "操作成功", DataMode: "always"}), omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"NOT_READY": {Code: 503001, Message: "暂未就绪"}}}))
var failure *apierrors.APIError
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { err := next(c); failure = c.Failure(); return err }
})
var opts []HandlerOption
if raw {
opts = append(opts, WithRawResponse())
}
app.GET("/ready", checker.ReadinessHandler(opts...))
app.GET("/live", checker.LivenessHandler(opts...))
for _, path := range []string{"/ready", "/live"} {
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", path, nil))
want := 200
if path == "/ready" {
want = 503
}
if out.Code != want {
t.Fatal(out.Code, out.Body.String())
}
var body map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if raw {
if _, ok := body["code"]; ok || body["status"] == nil {
t.Fatal(body)
}
} else {
if body["httpStatus"] != float64(want) {
t.Fatal(body)
}
if want == 503 && (body["code"] != float64(503001) || body["msg"] != "暂未就绪") {
t.Fatal(body)
}
if want == 200 && (body["code"] != float64(200) || body["msg"] != "操作成功") {
t.Fatal(body)
}
}
if want == 503 && (failure == nil || failure.Status != 503 || !errors.Is(failure, probeErr)) {
t.Fatal("failure/log cause lost", failure)
}
if bytes.Contains(out.Body.Bytes(), []byte(probeErr.Error())) {
t.Fatal("probe cause leaked")
}
}
})
}
}
func TestShutdownReadinessAndCustomEncoderStatus(t *testing.T) {
ctx, stop := context.WithCancel(context.Background())
defer stop()
checker, _ := New()
app := omni.New(omni.WithResponseEncoder(response.EncoderFunc(func(r response.Result) (response.Payload, error) {
return response.JSON(200, map[string]any{"status": r.Status, "data": r.Data})
})))
app.GET("/ready", checker.ReadinessHandler(WithShutdownContext(ctx)))
app.GET("/live", checker.LivenessHandler())
stop()
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/ready", nil))
if out.Code != 503 {
t.Fatal("encoder masked failed readiness", out.Code)
}
out = httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/live", nil))
if out.Code != 200 {
t.Fatal("shutdown changed liveness", out.Code)
}
}
func TestBrokenProbeEncoderCannotReportHealthy(t *testing.T) {
for _, panicEncoder := range []bool{false, true} {
for _, unhealthy := range []bool{false, true} {
checker, _ := New()
if unhealthy {
_ = checker.Add(Probe{Name: "dependency", Check: func(context.Context) error { return errors.New("offline") }})
}
app := omni.New(omni.WithResponseEncoder(response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Data != nil {
if panicEncoder {
panic("private encoder detail")
}
return response.Payload{}, errors.New("private encoder detail")
}
return response.JSON(200, map[string]bool{"acknowledged": true})
})))
app.GET("/ready", checker.ReadinessHandler())
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/ready", nil))
want := 500
if unhealthy {
want = 503
}
if out.Code != want || out.Body.Len() != 0 {
t.Fatal(out.Code, out.Body.String())
}
}
}
}
func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) { func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) {
checker, err := New(Probe{ checker, err := New(Probe{
Name: "slow", Name: "slow",

View File

@@ -57,6 +57,9 @@ func (c *Context) writeResult(result response.Result) (err error) {
if err != nil { if err != nil {
return apierrors.Internal(err) return apierrors.Internal(err)
} }
if result.PreserveStatus {
payload.Status = result.Status
}
if payload.Status < 200 || payload.Status > 599 { if payload.Status < 200 || payload.Status > 599 {
return apierrors.Internal(fmt.Errorf("response encoder returned invalid status %d", payload.Status)) return apierrors.Internal(fmt.Errorf("response encoder returned invalid status %d", payload.Status))
} }
@@ -66,6 +69,41 @@ func (c *Context) writeResult(result response.Result) (err error) {
return c.WriteResponse(payload.Status, payload.Header, payload.Body) return c.WriteResponse(payload.Status, payload.Header, payload.Body)
} }
// RespondStatus uses the current response format and error catalog, but keeps
// the explicit HTTP status even if a catalog/custom encoder selects another.
// This is useful for protocol endpoints such as health probes. Ordinary
// business handlers should use OK/Created or return their error as usual.
func (c *Context) RespondStatus(status int, data any, err error) error {
if c.Responded() {
return nil
}
if status < 200 || status > 599 || status == 204 || status == 205 || status == 304 {
return apierrors.Internal(fmt.Errorf("response requires a body-bearing HTTP status"))
}
result := response.Result{Status: status, Code: "OK", Message: "success", Data: data,
RequestID: c.RequestID(), PreserveStatus: true}
if err != nil {
failure := c.ResolveError(err)
failure.Status = status
c.failure = failure
result.Code, result.Message, result.BusinessCode = failure.Code, failure.Message, failure.BusinessCode
result.Err, result.Failed = err, true
}
writeErr := c.writeResult(result)
if writeErr != nil && !c.Responded() {
// A broken encoder must not fall through to a business error policy
// that acknowledges failures with HTTP 200. Commit a bodyless failure.
failureStatus := status
if failureStatus < 400 {
failureStatus = http.StatusInternalServerError
}
c.failure = c.ResolveError(writeErr)
c.failure.Status = failureStatus
_ = c.WriteResponse(failureStatus, nil, nil)
}
return writeErr
}
// Bytes, Text and XML are explicit wire responses; they bypass any envelope. // Bytes, Text and XML are explicit wire responses; they bypass any envelope.
func (c *Context) Bytes(status int, contentType string, body []byte) error { func (c *Context) Bytes(status int, contentType string, body []byte) error {
return c.WriteResponse(status, http.Header{"Content-Type": {contentType}}, body) return c.WriteResponse(status, http.Header{"Content-Type": {contentType}}, body)

20
lifecycle/README.md Normal file
View File

@@ -0,0 +1,20 @@
# lifecycle
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
手写服务也可组合现有 App:
```go
control := lifecycle.New(ctx)
defer control.RequestShutdown(nil)
// 在依赖的故障回调中调用,不阻塞等待排空。
control.RequestShutdown(cause)
// RunContext 会观察到已发生的退出通知,不必等监听成功才能通知。
err := app.RunContext(control.Context())
```
实际使用时 App 和依赖通常先初始化,再由并发监控回调请求退出。资源在 HTTP 排空完成后由调用方关闭,并读取 `control.Err()` 合并清理期间报告的首个故障。通知本身不会关闭资源、执行 os.Exit 或发送操作系统信号。
`RequestShutdown(nil)` 表示正常停止,非空 error 表示故障。Controller 保留首个非空故障,重复调用和并发调用安全。`Cause(ctx)` 将符合 errors.Is(err, context.Canceled) 的取消原因视为正常停止(包含新版 Go 的信号原因),保留其他取消原因和 DeadlineExceeded。显式 RequestShutdown(err) 上报的非空错误始终由 Controller.Err 保留,包括包装 context.Canceled 的错误。App.RunContext、ServeContext、RunWith 均保留这些异常原因,与网络适配器无关。

67
lifecycle/controller.go Normal file
View File

@@ -0,0 +1,67 @@
// Package lifecycle coordinates service shutdown without owning project resources.
package lifecycle
import (
"context"
"errors"
"sync"
)
// Controller connects dependency failures to the server's graceful shutdown.
// Construct it with New. RequestShutdown never waits for draining or cleanup.
type Controller struct {
ctx context.Context
cancel context.CancelCauseFunc
mu sync.Mutex
cause error
}
func New(parent context.Context) *Controller {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithCancelCause(parent)
return &Controller{ctx: ctx, cancel: cancel}
}
func (c *Controller) Context() context.Context { return c.ctx }
// RequestShutdown asks the service to stop. nil is a normal stop; a non-nil
// error is fatal. The first fatal error is retained even if a normal signal
// already began draining. Concurrent and repeated calls are safe.
func (c *Controller) RequestShutdown(err error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.cause == nil && err != nil {
c.cause = err
}
c.cancel(err)
}
// Err is read after resources have stopped reporting failures. It returns the
// first dependency failure, or an abnormal parent cancellation cause.
func (c *Controller) Err() error {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.cause != nil {
return c.cause
}
return Cause(c.ctx)
}
// Cause treats cancellation-compatible errors (including signal causes in
// newer Go versions) as normal stops. Deadlines and other causes are failures.
// Controller.Err additionally retains every explicitly reported non-nil error.
func Cause(ctx context.Context) error {
if ctx == nil {
return nil
}
cause := context.Cause(ctx)
if errors.Is(cause, context.Canceled) {
return nil
}
return cause
}

View File

@@ -0,0 +1,67 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"sync"
"testing"
)
func TestShutdownRetainsFirstFailure(t *testing.T) {
parent, signal := context.WithCancel(context.Background())
c := New(parent)
signal()
if c.Err() != nil {
t.Fatal("signal treated as failure")
}
first := errors.New("dependency stopped")
c.RequestShutdown(first)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); c.RequestShutdown(errors.New("later")); c.RequestShutdown(nil); _ = c.Err() }()
}
wg.Wait()
if !errors.Is(c.Err(), first) || c.Context().Err() == nil {
t.Fatal(c.Err())
}
}
func TestShutdownBeforeServingAndParentCauses(t *testing.T) {
fatal := errors.New("initialization fault")
c := New(nil)
c.RequestShutdown(fatal)
if c.Context().Err() == nil || !errors.Is(Cause(c.Context()), fatal) || !errors.Is(c.Err(), fatal) {
t.Fatal("lost early shutdown")
}
c.RequestShutdown(nil)
if !errors.Is(c.Err(), fatal) {
t.Fatal("cleanup erased failure")
}
parent, cancel := context.WithCancelCause(context.Background())
child := New(parent)
cancel(fatal)
if !errors.Is(child.Err(), fatal) {
t.Fatal("lost parent failure")
}
normal := New(nil)
normal.RequestShutdown(nil)
if normal.Err() != nil {
t.Fatal(normal.Err())
}
}
func TestCancellationCompatibleCauseAndExplicitFailure(t *testing.T) {
cause := fmt.Errorf("stop: %w", context.Canceled)
ctx, cancel := context.WithCancelCause(context.Background())
cancel(cause)
if Cause(ctx) != nil {
t.Fatal("cancellation-compatible stop treated as a fault")
}
c := New(nil)
c.RequestShutdown(cause)
if !errors.Is(c.Err(), cause) {
t.Fatal("explicit dependency failure suppressed")
}
}

View File

@@ -27,6 +27,9 @@ type APIError = apierrors.APIError
type Config = config.Config type Config = config.Config
type TLSConfig = config.TLSConfig type TLSConfig = config.TLSConfig
type HTTPConfig = config.HTTPConfig type HTTPConfig = config.HTTPConfig
type EndpointsConfig = config.EndpointsConfig
type EndpointConfig = config.EndpointConfig
type HealthEndpointConfig = config.HealthEndpointConfig
type Option = config.Option type Option = config.Option
type Response = response.Response type Response = response.Response
type LogConfig = logx.Config type LogConfig = logx.Config

View File

@@ -0,0 +1,49 @@
# 依赖主动退出与内置接口配置验收
日期:2026-09-24;环境:Go 1.27.1,darwin/arm64;目标版本:v0.13.0-preview。
## 验收范围
- 通用生命周期控制器连接项目依赖与生成入口。项目决定故障是否需要退出;框架不判断 ID 租约或其他业务规则。
- `svc.RequestShutdown(err)` 立即通知并保留首个非空原因,生成入口排空 HTTP 后关闭依赖与日志;重复或并发通知不会重复清理。
- 生成项目通过 `server.endpoints` 控制 liveness、readiness、OpenAPI 的开关与路径。健康检查默认使用项目响应模板和错误目录,可显式选择 raw 数据。
- 就绪失败固定 HTTP 503,即使项目业务错误统一使用 HTTP 200;原始探针错误保留给日志,不进入公开响应。OpenAPI 不包装公共体。
## 源码与实际生成项目
回归命令:
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json
```
最终全量 race 测试(包括生成器集成测试)、go vet、go build 全部通过;修改文件的 gofmt、Python 语法、文档本地链接及 git diff --check 通过,本地报告的源码哈希与发布源码一致。
- `lifecycle` 测试覆盖并发通知、正常停止后报告故障、初始化前通知、父 context 原因,以及包装 context.Canceled 的显式故障。
- `TestFatalShutdownDrainsAndPreservesCause` 在 nethttp/native 上验证:在途请求完成前不关闭依赖,关闭仅一次,故障和清理错误都保留;提前取消的启动同样保留故障。
- `TestManagedGeneratedProject` 使用实际 CLI 生成的 main/Handler/DTO,在项目文件中加入模拟客户端和配置,子项目执行 race 测试与根目录构建。新增子测试验证依赖通知后的排空顺序、初始化期间通知、端点配置、冲突后的资源释放。
- `TestGeneratedFatalProcessExit` 启动真实子进程执行生成的 main:故障退出码为 1,输出保留实际原因,模拟客户端只关闭一次。
- 健康测试覆盖数字 code、项目 msg、成功模板、NOT_READY 目录映射、raw 数据、关闭中的 readiness、失败日志状态与内部原因。目录的 HTTP 200 和自定义 Encoder 都不能把未就绪改成 HTTP 200;编码器出错或 panic 时也不会误报健康。
- 配置测试覆盖默认值合并、显式 false、环境变量、非法路径/模式、重复端点及业务参数/通配路径冲突。`TestConfigurableBuiltinRoutesConsumer` 用真实生成项目验证:默认冲突明确报错;禁用或改名后项目可接管原路径。
- 原生成项目回归继续覆盖 Go 错误目录继承、模板参数、404/405、中间件错误、raw body、multipart 上传、流式下载、初始化/监听失败清理、业务文件保留和生成漂移检查。
- [本地真实进程报告](lifecycleEndpointsLocal.json) 最近一轮通过,耗时 4.33 秒,记录验收源码哈希;根目录 go run/go build、nethttp/native 的 SIGINT/SIGTERM、端口释放及上传下载请求均通过。二进制正常信号退出码为 0;go run 包装进程允许被信号终止,子服务必须退出并释放端口。
- 源码示例 `examples/managedService` 编译、race 测试与 `api generate --check` 通过;示例保留相对 replace 以运行本地源码。远程验收会重新生成无 replace 的项目。
## 验证过程中修正的问题
真实进程验收发现 Go 1.27 的 signal.NotifyContext 使用可匹配 context.Canceled 的信号原因;精确相等判断会把正常信号误报为故障。生命周期现在使用 errors.Is 识别正常取消,同时 Controller 单独保留项目显式报告的非空错误。修正后两种网络层的 SIGINT/SIGTERM 退出验证通过。
一次全量测试中,S3 协议夹具的故意损坏请求触发 SDK 随机重试,耗尽共享测试期限。仅将测试自建客户端设置为一次尝试,生产客户端配置保持不变,并单独检查 Open 创建的客户端可访问夹具;连续三次 race 回归通过。此调整不改变存储运行时行为。
## 升级与边界
v0.12 项目的 svc/config 是开发者文件,重新生成仍会保留。升级需按 [PROJECT.md](../PROJECT.md#从-v012-生成项目升级) 在 svc 增加 Lifecycle、RequestShutdown 和清理衔接;main/Handler 由生成器自动更新。新项目直接获得这些扩展。
请求排空使用已有 shutdownTimeout;忽略 context 的业务 goroutine 无法被框架强制结束,项目 Close 必须停止并等待自建监控任务。内置接口继续经过全局中间件,访问规则由项目设置。
没有修改 eTax/ePay,也未连接业务服务器、真实数据库或云存储。所有服务与子进程均在本地临时环境验收并回收;本轮不是压力测试或业务迁移验收。
远程安装验收在不可变标签发布后执行,报告单独提交,不改写标签。

View File

@@ -0,0 +1,54 @@
{
"passed": true,
"mode": "localSource",
"durationSeconds": 4.33,
"checks": [
"rootGoRun",
"rootGoBuild",
"stableRegeneration",
"checkMainOwnership",
"contractChangePreview",
"logicSignatureCheckPersists",
"businessSourcePreserved",
"fixedConsumerBuild",
"nethttpSIGINT",
"nethttpSIGTERM",
"nativeSIGINT",
"nativeSIGTERM",
"listenerReleased",
"temporaryProcessesRemoved",
"nethttpGeneratedTransfers",
"nativeGeneratedTransfers",
"malformedMultipart400",
"goOwnedErrors",
"default404And405"
],
"sourceHashes": {
"cmd/go-omni/apigen/generate.go": "c1808f5aff5c0475859f808e52ae29d7ac4234ecf2f784073c93bfee5bb7a2b7",
"cmd/go-omni/apigen/templates.go": "ec73080836763bbe60721351295df8471ed93691c62766172238ea1df390215e",
"cmd/go-omni/apigen/startup.go": "ea6d671a2e580911cdb954b96356e401f0f98bc41fb9e7b0c30239222fce923f",
"cmd/go-omni/apigen/managed_test.go": "ff9ce6e9fa6cff63c8b52f5e0c43879f9b08b5750ab72b617d32bec114d955da",
"cmd/go-omni/apigen/contractChanges.go": "ea190447e4260e0b3f3d28554ae61eac73f289aab601a16c7521dabc59fd736f",
"cmd/go-omni/apigen/logicCheck.go": "9fa1aa8cfef3a7b54dc5afb9b4320ec4e86ea5b46fc03705505216921279fa04",
"cmd/go-omni/apigen/write.go": "e415bb1e2db4674c5de7c81681b26a71925ddadbd647903b72674555e54911f1",
"cmd/go-omni/api.go": "eac8ed809000a939e3dfa7e3c01af67a26c705306143654a6b5a8e8733f2ab13",
"config/config.go": "9933e6ac581d75907afa1285440b61b7d5729d0ac0ce761cd8255473fd7e6028",
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "c68f553e2500a395d77aa20c7b962fa257c47ef71b3ad36324413821b36ca0ec",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/client.go": "db886c2d4dd27e67f7446265c20147335d4898d2d2091518e67403806ccd9b69",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1"
}
}

View File

@@ -41,8 +41,19 @@
## 边界 ## 边界
未修改 eTax/ePay;未连接业务服务器、真实数据库或云存储;没有负载/soak 测试。以上源码验收不代替远程产物验收。数据库/云存储配置组合通过生成项目编译与既有测试,不能据此声称真实环境集成完成。 未修改 eTax/ePay;未连接业务服务器、真实数据库或云存储;没有负载/soak 测试。源码与远程产物均在本地临时服务上验收。数据库/云存储配置组合通过生成项目编译与既有测试,不能据此声称真实环境集成完成。
multipart 通过流式 Parts 处理,不产生临时文件。业务负责字段/文件内容校验、存储和实际签名算法。请求读取后再提交业务副作用;超限或读取失败不能回滚业务自行提前写出的数据。Range、SSE 等特殊行为仍可显式选择手写 Handler。 multipart 通过流式 Parts 处理,不产生临时文件。业务负责字段/文件内容校验、存储和实际签名算法。请求读取后再提交业务副作用;超限或读取失败不能回滚业务自行提前写出的数据。Range、SSE 等特殊行为仍可显式选择手写 Handler。
项目维护边界见 [PROJECT.md](../PROJECT.md),可运行示例见 [managedService](../examples/managedService/README.md)。 项目维护边界见 [PROJECT.md](../PROJECT.md),可运行示例见 [managedService](../examples/managedService/README.md)。
## 发布后的远程产物验收
2026-09-24 执行 `python3 scripts/verifyManagedRelease.py --version v0.12.0-preview` 通过,耗时 28.89 秒;[机器报告](managedProjectRemote.json) 记录依赖校验和与验收源码哈希。
- 不可变标签对应代码提交 `158b8002b355f09237cd12f49d9b66ee78d493f3`;CLI 从远程仓库安装,使用全新 GOMODCACHE。新生成项目不传 framework-dir 或 framework-version,自动固定 CLI 自身版本。
- 框架 module 为 v0.12.0-preview,校验和 `h1:o9dN8YdhE9o4EcGWAuFb/EGCcQlWRR7IMUc/G7Ymkeg=`;消费者无本地 replace,四个生成消费者验证同一校验和。
- 实际生成新目录,根目录 go run/go build;修改 DTO 后 dry-run/check 只读,重新生成保留业务源码,签名问题持续报错;修复签名后检查、测试与构建通过。
- nethttp/native 二进制分别验证 SIGINT/SIGTERM、退出码和端口释放。下载、原文回执、上传、损坏 multipart、默认 404/405 和 Go 错误目录通过真实本地 HTTP 请求验证。
- 四个消费者 race 回归全部通过:TestManagedGeneratedProject、TestErrorInheritanceConsumer、TestErrorParamsConsumer、TestCompatibilityConsumer;覆盖初始化/监听失败清理、先排空后关闭依赖、错误参数与继承、手写 hook 和自定义标量。
- 临时缓存、生成项目、进程和监听端口已回收。这个报告在发布后单独提交,不移动已经发布的标签。

View File

@@ -0,0 +1,40 @@
{
"passed": true,
"frameworkVersion": "v0.12.0-preview",
"frameworkSum": "h1:o9dN8YdhE9o4EcGWAuFb/EGCcQlWRR7IMUc/G7Ymkeg=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 28.89,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars"
],
"sourceHashes": {
"scripts/verifyManagedRelease.py": "cab31299386a17c10181eb7f1936165ea49fa0565c083c2846807d83297fb7c1",
"scripts/verifyManagedProject.py": "bed710ae5d78017069e8bfd3e2df4943089641cfde179f67a3a8f1883369c41b",
"cmd/go-omni/apigen/managed_test.go": "a591f7c8c799100f05be71b09a8088a29f21d64612394af20f4711a24a7cb32c",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/client.go": "272ad9d980cd1b6e8de02298b5d273e693e2abcb902a96ea8f3b2ff53d7b0082",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
"cmd/go-omni/apigen/testdata/managed/operations.go": "d20e792380b9c8f8bb37f12522de78ff2dcc986005ab999dbb0e8bfedf0ec9c4"
}
}

View File

@@ -16,6 +16,9 @@ type Result struct {
Err error Err error
BusinessCode any BusinessCode any
Failed bool Failed bool
// PreserveStatus keeps an explicit protocol status through catalog mapping
// and HTTP output (for example, readiness must remain 503 when unhealthy).
PreserveStatus bool
} }
// IsError preserves failure semantics independently of the HTTP status. // IsError preserves failure semantics independently of the HTTP status.

View File

@@ -17,10 +17,14 @@ type errorEncoder struct {
func (e *errorEncoder) ErrorRegistry() *apierrors.Registry { return e.registry } func (e *errorEncoder) ErrorRegistry() *apierrors.Registry { return e.registry }
func (e *errorEncoder) Encode(r Result) (Payload, error) { func (e *errorEncoder) Encode(r Result) (Payload, error) {
status := r.Status
if r.Err != nil { if r.Err != nil {
err := e.registry.Resolve(r.Err) err := e.registry.Resolve(r.Err)
r.Status, r.Code, r.Message, r.BusinessCode, r.Failed = err.Status, err.Code, err.Message, err.BusinessCode, true r.Status, r.Code, r.Message, r.BusinessCode, r.Failed = err.Status, err.Code, err.Message, err.BusinessCode, true
} }
if r.PreserveStatus {
r.Status = status
}
return e.next.Encode(r) return e.next.Encode(r)
} }

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verify source-generated root entrypoints with real local processes only.""" """Verify source-generated root entrypoints with real local processes only."""
import argparse
import hashlib import hashlib
import json import json
import os import os
@@ -85,6 +86,8 @@ def serve(args, project, env, transport, sig, strict_exit, contract=False, logs=
else: else:
raise RuntimeError('server readiness timed out') raise RuntimeError('server readiness timed out')
if contract: if contract:
status, body = request(base + '/health/ready')
assert status == 200 and json.loads(body) == {'code': 0, 'msg': 'success', 'data': {'status': 'ready'}}
status, body = request(base + '/check?kind=fixed') status, body = request(base + '/check?kind=fixed')
assert status == 400 and json.loads(body) == {'code': 400002, 'msg': '用户名格式不正确', 'data': None} assert status == 400 and json.loads(body) == {'code': 400002, 'msg': '用户名格式不正确', 'data': None}
assert json.loads(request(base + '/missing')[1])['msg'] == '接口不存在' assert json.loads(request(base + '/missing')[1])['msg'] == '接口不存在'
@@ -128,6 +131,9 @@ def serve(args, project, env, transport, sig, strict_exit, contract=False, logs=
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--report", type=Path, default=ROOT / "performance/managedProjectLocal.json")
args = parser.parse_args()
started = time.monotonic() started = time.monotonic()
env = dict(os.environ, GOWORK='off', GOFLAGS='') env = dict(os.environ, GOWORK='off', GOFLAGS='')
# Prevent inherited project configuration from binding a real server or loading secrets. # Prevent inherited project configuration from binding a real server or loading secrets.
@@ -156,10 +162,11 @@ def main():
for transport in ('nethttp', 'native'): for transport in ('nethttp', 'native'):
serve([str(work/'managedExample')], example, env, transport, signal.SIGTERM, True, contract=True, logs=work) serve([str(work/'managedExample')], example, env, transport, signal.SIGTERM, True, contract=True, logs=work)
sources = ['cmd/go-omni/apigen/generate.go', 'cmd/go-omni/apigen/templates.go', 'cmd/go-omni/apigen/startup.go', 'cmd/go-omni/apigen/managed_test.go', 'cmd/go-omni/apigen/contractChanges.go', 'cmd/go-omni/apigen/logicCheck.go', 'cmd/go-omni/apigen/write.go', 'cmd/go-omni/api.go', 'config/config.go', 'httpx/transfer.go', 'httpx/multipart.go', 'scripts/verifyManagedProject.py'] sources = ['cmd/go-omni/apigen/generate.go', 'cmd/go-omni/apigen/templates.go', 'cmd/go-omni/apigen/startup.go', 'cmd/go-omni/apigen/managed_test.go', 'cmd/go-omni/apigen/contractChanges.go', 'cmd/go-omni/apigen/logicCheck.go', 'cmd/go-omni/apigen/write.go', 'cmd/go-omni/api.go', 'config/config.go', 'httpx/transfer.go', 'httpx/multipart.go', 'scripts/verifyManagedProject.py']
sources += ['lifecycle/controller.go', 'health/health.go', 'config/endpoints.go', 'httpx/response.go', 'app/app.go', 'app/transport.go']
sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/scaffold/templates').glob('api-*.tmpl')] sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/scaffold/templates').glob('api-*.tmpl')]
sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/apigen/testdata/managed').glob('*.go')] sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/apigen/testdata/managed').glob('*.go')]
report = {'passed': True, 'mode': 'localSource', 'durationSeconds': round(time.monotonic()-started, 2), 'checks': ['rootGoRun', 'rootGoBuild', 'stableRegeneration', 'checkMainOwnership', 'contractChangePreview', 'logicSignatureCheckPersists', 'businessSourcePreserved', 'fixedConsumerBuild', 'nethttpSIGINT', 'nethttpSIGTERM', 'nativeSIGINT', 'nativeSIGTERM', 'listenerReleased', 'temporaryProcessesRemoved', 'nethttpGeneratedTransfers', 'nativeGeneratedTransfers', 'malformedMultipart400', 'goOwnedErrors', 'default404And405'], 'sourceHashes': {p: hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in sources}} report = {'passed': True, 'mode': 'localSource', 'durationSeconds': round(time.monotonic()-started, 2), 'checks': ['rootGoRun', 'rootGoBuild', 'stableRegeneration', 'checkMainOwnership', 'contractChangePreview', 'logicSignatureCheckPersists', 'businessSourcePreserved', 'fixedConsumerBuild', 'nethttpSIGINT', 'nethttpSIGTERM', 'nativeSIGINT', 'nativeSIGTERM', 'listenerReleased', 'temporaryProcessesRemoved', 'nethttpGeneratedTransfers', 'nativeGeneratedTransfers', 'malformedMultipart400', 'goOwnedErrors', 'default404And405'], 'sourceHashes': {p: hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in sources}}
(ROOT/'performance/managedProjectLocal.json').write_text(json.dumps(report, indent=2)+'\n') args.report.write_text(json.dumps(report, indent=2)+'\n')
print('PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed') print('PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed')

View File

@@ -81,9 +81,9 @@ def main():
evidence = "".join(e.get("Output", "") for e in events) evidence = "".join(e.get("Output", "") for e in events)
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence) sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch" assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars"] checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup"]
sources = ["scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"] sources = ["lifecycle/controller.go", "config/endpoints.go", "health/health.go", "httpx/response.go", "app/app.go", "app/transport.go", "scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True, "freshModuleCache": True, "noLocalReplace": True,

View File

@@ -2,8 +2,10 @@ package s3
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"git.yuntuo.hk/k2safe/go-omni/internal/storagefixture" "git.yuntuo.hk/k2safe/go-omni/internal/storagefixture"
"git.yuntuo.hk/k2safe/go-omni/storage"
"git.yuntuo.hk/k2safe/go-omni/storage/storagetest" "git.yuntuo.hk/k2safe/go-omni/storage/storagetest"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/credentials"
@@ -24,7 +26,25 @@ func TestS3ProtocolAndR2Config(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
storagetest.Run(t, s) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err = s.Get(ctx, "open-config-missing"); !errors.Is(err, storage.ErrNotFound) {
t.Fatal("configured client did not reach protocol fixture", err)
}
// Malformed-body cases intentionally cause SDK errors. Disable retries on
// this test-owned client so randomized backoff cannot exhaust the shared
// contract deadline. Open above still checks the production client config.
client, err := minio.New(strings.TrimPrefix(server.URL, "https://"), &minio.Options{
Creds: creds, Region: "us-east-1", Secure: true, Transport: server.Client().Transport, MaxRetries: 1,
})
if err != nil {
t.Fatal(err)
}
contractStore, err := New(client, "testbucket", 64)
if err != nil {
t.Fatal(err)
}
storagetest.Run(t, contractStore)
cfg, err := R2Config(strings.Repeat("a", 32), "bucket", creds) cfg, err := R2Config(strings.Repeat("a", 32), "bucket", creds)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)