Compare commits

...

6 Commits

88 changed files with 4556 additions and 1400 deletions

View File

@@ -13,7 +13,9 @@
- [x] 文档工具:字段注释、example/枚举描述进入 DTO 与 OpenAPI;保注释、幂等格式化及 check;显式 Go 错误目录元数据导出与文档合并,不自动执行项目代码。 - [x] 文档工具:字段注释、example/枚举描述进入 DTO 与 OpenAPI;保注释、幂等格式化及 check;显式 Go 错误目录元数据导出与文档合并,不自动执行项目代码。
- [x] 共享契约:受控 roots/摘要、循环/深度/文件数/路径保护;分源文件 DTO、多个独立 module 的工作区检查;版本提示与基础模板升级预览,保留业务文件。 - [x] 共享契约:受控 roots/摘要、循环/深度/文件数/路径保护;分源文件 DTO、多个独立 module 的工作区检查;版本提示与基础模板升级预览,保留业务文件。
- [x] 集成:实际生成项目验证新能力、连续生成、业务文件保留、失败清理、nethttp/native 请求与退出;现有错误继承/参数/上传下载回归。 - [x] 集成:实际生成项目验证新能力、连续生成、业务文件保留、失败清理、nethttp/native 请求与退出;现有错误继承/参数/上传下载回归。
- [ ] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。 - [x] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
已发布 `v0.14.0-preview`。逐项验收、可替换的业务适配点与远程报告见 [验收记录](reports/businessDevelopmentAcceptance.md);接入和升级见 [开发指南](DEVELOPMENT.md)。
## 核心约定 ## 核心约定

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.14.0-preview) # 通用业务开发指南(v0.16.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
@@ -21,12 +21,17 @@ delete /item
| 契约 | Logic 返回 | 成功数据 | | 契约 | Logic 返回 | 成功数据 |
| --- | --- | --- | | --- | --- | --- |
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 | | `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
| `returns ([]Item)` | `([]types.Item, error)` | 数组;nil 统一输出 `[]` | | `returns ([]Item)` | `([]types.Item, error)` | 对象数组;nil 统一输出 `[]` |
| `returns ([]string)` | `([]string, error)` | 字符串数组 |
| `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 |
| `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null |
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` | | `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
| `returns (null)` | `error` | HTTP 200 和空业务数据 | | `returns (null)` | `error` | HTTP 200 和空业务数据 |
| 不写 returns | `error` | HTTP 204,无响应体 | | 不写 returns | `error` | HTTP 204,无响应体 |
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。根类型当前支持以上四种形式,嵌套字段继续支持既有 slice/map/pointer/scalar/any。 统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。嵌套字段继续支持既有 slice/map/pointer/scalar/any。
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
## 契约驱动的请求策略 ## 契约驱动的请求策略
@@ -113,6 +118,23 @@ duration 使用 `250ms`、`3s`;slice/map 使用 JSON,如 `[1,3,5]`、`{"retr
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。 手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
### 一次覆盖完整配置与 HTTP/日志短名称
```go
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{
Prefix: "MY_APP_",
Empty: "reject", // allow / ignore / reject,项目自行选择
Aliases: config.ServerEnvAliases("MY_APP_", "server"),
// Lookup 可注入测试或项目配置源;默认 os.LookupEnv。
})
```
不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。
自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。
新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。
## 依赖、日志与中间件 ## 依赖、日志与中间件
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖: `internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
@@ -128,7 +150,7 @@ func (c *ServiceContext) Init(ctx context.Context) error {
} }
``` ```
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。项目清理函数仍须自行停止、等待后台任务并保证能返回,框架不能强制杀死任意 goroutine。 每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
中间件用业务所需的小接口接收依赖,避免反向导入 svc: 中间件用业务所需的小接口接收依赖,避免反向导入 svc:
@@ -185,21 +207,9 @@ go-omni api generate --check
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。 格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
Go Catalog 是错误的唯一来源时,显式导出文档元数据: Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。
```go OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。
// 在项目自己的导出命令中;appErrors.Catalog 与 AppOptions 注册同一函数。
if err := omnierrors.ExportCatalog(os.Stdout, appErrors.Catalog()); err != nil {
log.Fatal(err)
}
```
```sh
go run ./cmd/exportErrors > api/omni.errors.json
go-omni api generate
```
文件是派生产物,应纳入版本控制并由业务 CI 检查导出漂移。CLI 自动读取入口目录的 `omni.errors.json`,校验错误目录、合并状态/模板参数/具体示例到 OpenAPI;不会运行项目 Go,不创建另一份运行期注册器。带独立错误目录的路由继续使用自己的目录。没有路由级精确错误列表时,文档描述 App 目录的公共错误集合;特殊自定义 Encoder 的文档由项目显式扩展。
## 两个服务共享契约 ## 两个服务共享契约
@@ -241,9 +251,57 @@ go-omni workspace check --file omni.workspace.json
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。 各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
### 同一份 Go 错误目录供多个服务生成
在 workspace 清单显式声明提供目录的 Go 包和函数:
```json
{
"errorCatalogs": [{
"dir": ".",
"package": "example.com/project/internal/apierrors",
"function": "Catalog",
"output": "contracts/common/errors.json"
}],
"services": [{"dir":"services/api"}, {"dir":"services/id"}]
}
```
`dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。
只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。
各服务在自己的 `api/omni.imports.json` 引用共享 JSON:
```json
{
"roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}},
"errors": {
"source": "@common/errors.json",
"include": ["USERNAME_INVALID", "INTERNAL_ERROR"],
"routes": {"GET /permissions":["INTERNAL_ERROR"]}
}
}
```
`include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。
**这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。
```sh
# 首次生成导出一次共享目录,所有服务预检通过才写入
# 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。
go-omni workspace generate --file omni.workspace.json
go-omni workspace format --file omni.workspace.json
go-omni workspace generate --file omni.workspace.json
go-omni workspace check --file omni.workspace.json
```
共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。
## v0.13 项目升级 ## v0.13 项目升级
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。 先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.16.0-preview(或显式引用当前源码)。
```sh ```sh
go-omni api upgrade --dir . --dry-run go-omni api upgrade --dir . --dry-run

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界 # 生成项目与文件维护边界
v0.14.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 v0.16.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发 ## 创建和日常开发
@@ -85,7 +85,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
} }
``` ```
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go;可从同一 Catalog 显式导出 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。 业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。普通 `.api` 编译不执行 Go;可在 workspace 显式配置 Catalog 函数,统一导出共享文档元数据,也可沿用 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。 `GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
@@ -93,7 +93,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。 最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。 日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
## 依赖主动申请退出 ## 依赖主动申请退出
@@ -106,7 +106,7 @@ c.RequestShutdown(err)
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。 这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。 多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。 `Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
@@ -208,7 +208,7 @@ get /download (DownloadReq)
## 已有服务接入步骤 ## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.0-preview: 每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.16.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,13 +2,19 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.14.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **从这份 README 开始接入。** 当前版本 **`v0.16.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 适配器。
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。 响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。 本版补齐 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
## 选择接入方式 ## 选择接入方式
@@ -30,7 +36,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.14.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
export PATH="$(go env GOPATH)/bin:$PATH" export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help go-omni --help
``` ```
@@ -186,7 +192,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行: 在业务项目的 **go.mod 所在目录**执行:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.14.0-preview go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
``` ```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -456,7 +462,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.14.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 CLI 与业务 module 固定使用 `v0.16.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入 ## 按需深入

View File

@@ -1,4 +1,68 @@
# v0.14.0-preview # v0.16.0-preview
补齐通用契约与生成体验,业务响应协议继续由项目定义:
- `returns([]string/[]int64/[]bool/[]any)` 及其他支持的基础类型数组,贯通 Logic、Handler、OpenAPI 和生成检查。nil 根数组输出 `[]`;DTO 数组、nullable、null、raw 和 204 保持原语义。字节数组明确拒绝 base64/数组歧义。
- OpenAPI 以标准 components / `$ref` 共享重复响应、schema、示例;保留同一 HTTP 200 下的成功与失败、模板参数、数字业务码和 data:null。
- workspace 显式配置 Go Catalog 提供方,一次导出共享 JSON,多服务通过受控根引用。支持服务/路由的文档错误列表,运行期 App 目录继承不变。check 核对导出漂移并保持项目文件不变。
- EnvOptions.Aliases + ServerEnvAliases 统一完整配置与框架短名称的 Lookup、类型转换、空值规则。完整名称优先,显式空值不等于未设置;旧 ApplyEnv 的空值/逗号列表行为保留。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
---
# v0.15.0-preview(历史版本)
增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。
- svc 登记成对、同名的 OnStop/OnDrain,Context 用于早期退出通知,WorkContext 供已接受工作在收尾期间使用。同一阶段并发执行,存在内部先后依赖的组件组合登记。
- server.lifecycle 配置 stopTimeout、drainTimeout、cancelTimeout。阶段超时取消工作并给未返回钩子收尾窗口;错误与阶段/组件名称可通过错误链追踪。
- 不能确认后台已经结束时,返回 ErrResourcesRetained,保留共享资源和借用日志,生成进程非零退出。未返回的 Stop 不会与自己的 Drain 重叠;错误、panic 不阻止其他组件尝试收尾。
- task.Runner 增加 StopAccepting/Wait,原 Shutdown/Close 继续可用。核心 lifecycle 不依赖 task 或其他适配器。
- 初始化和监听失败复用同一流程;重复关闭只执行一次。生成器继续保留配置、svc 和业务代码。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。发布后的空缓存远程生成项目与真实 MySQL/GORM 验收也通过,CLI/框架同版且无本地 replace;标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
---
# v0.14.1-preview(历史版本)
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
在 svc.Init 中调用 `gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))`;通用 Open 工厂保留其他方言入口。适配器保护借用池不被初始化失败路径关闭,并为初始化查询设置时限。查询使用 WithContext,SQL 日志接入 logx,请求标识、慢查询和错误分类贯通;默认不输出绑定参数与驱动完整错误文本。
v0.14.0 项目更新 module 与 CLI 即可,不改生成目录或 main。模板最低运行时保持 v0.14.0;修正新版 CLI 把最低兼容版本误当自身版本、导致同版消费者出现版本提示的问题。其他数据库方言和管理额外池的插件未纳入本次实库验收,原生 PrepareStmt 的生命周期限制见 [接入说明](database/gormx/README.md)。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
```
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过。发布后的空缓存远程 MySQL/GORM 和生成项目验收均通过,无本地 replace,CLI 与框架使用同版。标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`,详见 [GORM 验收](reports/gormAcceptance.md)。
---
# v0.14.0-preview(历史版本)
本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。 本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
@@ -24,6 +88,8 @@ go-omni new --module example.com/billing ./billing
详见 [业务开发指南](DEVELOPMENT.md)、[数据库接入](database/mysql/README.md) 和 [验收记录](reports/businessDevelopmentAcceptance.md)。 详见 [业务开发指南](DEVELOPMENT.md)、[数据库接入](database/mysql/README.md) 和 [验收记录](reports/businessDevelopmentAcceptance.md)。
全量 race(含生成项目)、vet、build 与本地进程验收通过。发布后的空缓存远程消费者和隔离 MySQL 验收也均通过;CLI/框架同版且无本地 replace。标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告单独提交。
仍需业务维护:契约、配置、svc 依赖创建/探针/清理登记、错误目录、Logic、Repository 和权限中间件。特殊协议可使用保留的扩展点。声明式上传的暂存磁盘和并发量需配置容量;共享契约版本标签由项目管理,框架校验摘要但不下载依赖。此版仍为 preview,不代表 eTax/ePay 已完成业务迁移。 仍需业务维护:契约、配置、svc 依赖创建/探针/清理登记、错误目录、Logic、Repository 和权限中间件。特殊协议可使用保留的扩展点。声明式上传的暂存磁盘和并发量需配置容量;共享契约版本标签由项目管理,框架校验摘要但不下载依赖。此版仍为 preview,不代表 eTax/ePay 已完成业务迁移。
--- ---

View File

@@ -1,5 +1,7 @@
# GoOmni 成熟度计划 # GoOmni 成熟度计划
当前进展:`v0.14.0-preview` 已完成契约驱动的业务开发能力、通用 Runtime/配置和官方 MySQL 适配器,并通过全量 race、实际生成服务及空缓存远程验收。新项目/升级以 [README](README.md) 和 [开发指南](DEVELOPMENT.md) 为准;下方为历史阶段记录,完整本轮验收见 [报告](reports/businessDevelopmentAcceptance.md)。具体 eTax/ePay 业务迁移与业务回归仍由接入项目完成。
## 1. 新项目启动与发布流程(已完成,v0.1.1) ## 1. 新项目启动与发布流程(已完成,v0.1.1)
- 修正脚手架固定版本引用,保留显式本地开发选项。 - 修正脚手架固定版本引用,保留显式本地开发选项。

View File

@@ -1,5 +1,7 @@
# go-omni CLI # go-omni CLI
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。 v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
@@ -8,7 +10,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
```sh ```sh
# 仓库读取权限与 PATH 配置见根目录 README # 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
go-omni new --module example.com/hello ./hello go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -43,14 +45,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移 ## 版本与迁移
CLI 与框架依赖固定使用 v0.14.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: CLI 与框架依赖固定使用 v0.16.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.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 模板最低需要 v0.16.0-preview 的统一环境变量别名能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令 ## 数据库迁移命令

View File

@@ -0,0 +1,104 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPrimitiveArrayContracts(t *testing.T) {
for _, name := range []string{"string", "bool", "int", "int8", "int16", "int32", "int64", "uint", "uint16", "uint32", "uint64", "float32", "float64", "any"} {
t.Run(name, func(t *testing.T) {
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
schema := operation(t, document(t, c), "/list", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
data := schema["properties"].(map[string]any)["data"].(map[string]any)
if data["type"] != "array" || data["items"] == nil {
t.Fatal(data)
}
if name == "any" && len(data["items"].(map[string]any)) != 0 {
t.Fatal("dynamic JSON was constrained")
}
if responseGoType("[]"+name) != "[]"+name || responseNeedsTypes("[]"+name) {
t.Fatal("wrong Go signature/import")
}
})
}
for _, name := range []string{"uint8", "File", "Unknown", "[]string"} {
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatal("unsupported root accepted", name)
}
}
}
func TestPrimitiveArrayConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "arrays")
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/arrays.api"), Module: "example.com/arrays", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
for _, name := range []string{"strings", "integers", "dynamic", "empty", "raw"} {
writeFile(t, filepath.Join(dir, "internal/logic/arrays/"+name+".go"), "package arrays\n")
}
writeFile(t, filepath.Join(dir, "internal/logic/arrays/values.go"), readFile(t, "testdata/arrays/values.go"))
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/arrays/consumer_test.go"))
o.API = filepath.Join(dir, "api/arrays.api")
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/logic/arrays/values.go")), "企业") {
t.Fatal("logic overwritten")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
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 out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(raw))
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(raw, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}

View File

@@ -2,41 +2,73 @@ package apigen
import ( import (
"errors" "errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response" "git.yuntuo.hk/k2safe/go-omni/response"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
const catalogDocumentFile = "omni.errors.json" const catalogDocumentFile = "omni.errors.json"
func readDocumentCatalog(root string, spec *Spec) error { // DocumentErrors selects documentation only. Runtime App inheritance and
path := filepath.Join(root, catalogDocumentFile) // route-local @errors declarations are never changed by this metadata.
stat, err := os.Lstat(path) // A nil Include lists all errors; [] lists none. Routes override Include.
if errors.Is(err, os.ErrNotExist) { type DocumentErrors struct {
return nil Source string `json:"source"`
Include []string `json:"include"`
Routes map[string][]string `json:"routes,omitempty"`
}
func readDocumentCatalog(root string, spec *Spec, overrides map[string][]byte) error {
name := catalogDocumentFile
path := filepath.Join(root, name)
// Shared metadata uses the same explicitly authorized roots as .api imports.
if selection := spec.DocumentErrors; selection != nil {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
return errors.New("api: use either local omni.errors.json or shared errors.source, not both")
}
name = selection.Source
alias, sub, ok := strings.Cut(strings.TrimPrefix(name, "@"), "/")
declared, found := spec.SharedRoots[alias]
if !strings.HasPrefix(name, "@") || !ok || !found || sub == "" || filepath.Ext(sub) != ".json" {
return errors.New("api: errors.source must name a JSON file in a declared @root")
}
if err := safePath(declared.Path, sub); err != nil {
return err
}
path = filepath.Join(declared.Path, filepath.FromSlash(sub))
} }
if err != nil { raw, ok := overrides[path]
return err if !ok {
} stat, err := os.Lstat(path)
if !stat.Mode().IsRegular() { if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil {
return errors.New("api: error metadata must be a regular file") return nil
} }
file, err := os.Open(path) if err != nil {
if err != nil { return err
return err }
} if !stat.Mode().IsRegular() {
defer file.Close() return errors.New("api: error metadata must be a regular file")
raw, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) }
if err != nil { f, err := os.Open(path)
return err if err != nil {
return err
}
defer f.Close()
raw, err = io.ReadAll(io.LimitReader(f, (1<<20)+1))
if err != nil {
return err
}
} }
if len(raw) > 1<<20 { if len(raw) > 1<<20 {
return errors.New("api: error metadata exceeds 1 MiB") return errors.New("api: error metadata exceeds 1 MiB")
} }
var catalog apierrors.Catalog var catalog apierrors.Catalog
if err = apierrors.DecodeCatalogJSON(raw, &catalog); err != nil { if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
return err return err
} }
registry, err := apierrors.NewRegistry(catalog) registry, err := apierrors.NewRegistry(catalog)
@@ -45,10 +77,70 @@ func readDocumentCatalog(root string, spec *Spec) error {
} }
normalized := registry.Catalog() normalized := registry.Catalog()
spec.DocumentCatalog = &normalized spec.DocumentCatalog = &normalized
spec.Sources[catalogDocumentFile] = raw spec.Sources[name] = raw
spec.SourcePaths[catalogDocumentFile] = path spec.SourcePaths[name] = path
return validateDocumentSelection(spec)
}
func documentRouteCatalog(spec *Spec, route Route) apierrors.Catalog {
catalog := spec.DocumentCatalog.Clone()
if spec.DocumentErrors == nil {
return catalog
}
include := spec.DocumentErrors.Include
if selected, ok := spec.DocumentErrors.Routes[strings.ToUpper(route.Method)+" "+route.Path]; ok {
include = selected
}
if include != nil {
selected := map[string]apierrors.Definition{}
for _, key := range include {
selected[key] = catalog.Definitions[key]
}
catalog.Definitions = selected
}
return catalog
}
func validateDocumentSelection(spec *Spec) error {
cfg := spec.DocumentErrors
if cfg == nil {
return nil
}
validate := func(keys []string) error {
seen := map[string]bool{}
for _, key := range keys {
if _, ok := spec.DocumentCatalog.Definitions[key]; !ok {
return fmt.Errorf("api: unknown documentation error %q", key)
}
if seen[key] {
return fmt.Errorf("api: duplicate documentation error %q", key)
}
seen[key] = true
}
return nil
}
if err := validate(cfg.Include); err != nil {
return err
}
routes := map[string]Route{}
for _, r := range spec.Routes {
routes[strings.ToUpper(r.Method)+" "+r.Path] = r
}
for key, keys := range cfg.Routes {
r, ok := routes[key]
if !ok {
return fmt.Errorf("api: unknown documentation route %q", key)
}
if len(r.Errors) > 0 {
return fmt.Errorf("api: %s already declares route-local errors", key)
}
if err := validate(keys); err != nil {
return err
}
}
return nil return nil
} }
func validateDocumentCatalog(spec *Spec) error { func validateDocumentCatalog(spec *Spec) error {
if spec.DocumentCatalog == nil { if spec.DocumentCatalog == nil {
return nil return nil
@@ -61,7 +153,7 @@ func validateDocumentCatalog(spec *Spec) error {
if err != nil { if err != nil {
return err return err
} }
if err = policy.ValidateErrors(*spec.DocumentCatalog); err != nil { if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil {
return err return err
} }
} }

View File

@@ -70,7 +70,7 @@ func Compile(spec *Spec) (*Compiled, error) {
resolving := map[string]bool{} resolving := map[string]bool{}
var resolve func(string, int) (reflect.Type, error) var resolve func(string, int) (reflect.Type, error)
var expression func(ast.Expr, int) (reflect.Type, error) var expression func(ast.Expr, int) (reflect.Type, error)
primitives := map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
expression = func(e ast.Expr, depth int) (reflect.Type, error) { expression = func(e ast.Expr, depth int) (reflect.Type, error) {
if depth > 32 { if depth > 32 {
return nil, errors.New("type nesting exceeds 32") return nil, errors.New("type nesting exceeds 32")
@@ -80,7 +80,7 @@ func Compile(spec *Spec) (*Compiled, error) {
if t, ok := scalarTypes[e.Name]; ok { if t, ok := scalarTypes[e.Name]; ok {
return t, nil return t, nil
} }
if t, ok := primitives[e.Name]; ok { if t, ok := primitiveTypes[e.Name]; ok {
return t, nil return t, nil
} }
return resolve(e.Name, depth+1) return resolve(e.Name, depth+1)
@@ -313,6 +313,7 @@ func Compile(spec *Spec) (*Compiled, error) {
return nil, err return nil, err
} }
decorateDocument(doc, spec, c.Bindings) decorateDocument(doc, spec, c.Bindings)
compactDocument(doc)
c.Document, err = json.MarshalIndent(doc, "", " ") c.Document, err = json.MarshalIndent(doc, "", " ")
return c, err return c, err
} }

View File

@@ -213,7 +213,7 @@ func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contr
if len(r.Errors) == 0 && documentRegistry != nil { if len(r.Errors) == 0 && documentRegistry != nil {
registry = documentRegistry registry = documentRegistry
op["x-omni-error-catalog-scope"] = "app" op["x-omni-error-catalog-scope"] = "app"
for key, definition := range registry.Catalog().Definitions { for key, definition := range documentRouteCatalog(spec, r).Definitions {
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params}) grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
} }
} }

View File

@@ -0,0 +1,86 @@
package apigen
import "encoding/json"
// compactDocument only replaces standard OpenAPI Reference Object positions.
// Payloads (including examples containing a literal "$ref") are never walked.
// Mixed HTTP 200 responses retain both success and error alternatives.
func compactDocument(doc map[string]any) {
type slot struct {
parent map[string]any
key string
}
components := doc["components"].(map[string]any)
var responses, schemas, examples []slot
for _, path := range doc["paths"].(map[string]any) {
for _, value := range path.(map[string]any) {
op, ok := value.(map[string]any)
if !ok {
continue
}
rs, _ := op["responses"].(map[string]any)
for status := range rs {
responses = append(responses, slot{rs, status})
}
}
}
factor := func(kind string, slots []slot) {
counts := map[string]int{}
for _, s := range slots {
raw, _ := json.Marshal(s.parent[s.key])
counts[string(raw)]++
}
shared, _ := components[kind].(map[string]any)
if shared == nil {
shared = map[string]any{}
}
for _, s := range slots {
raw, _ := json.Marshal(s.parent[s.key])
count := counts[string(raw)]
if count < 2 {
continue
}
name := "Omni_" + digest(raw)
ref := map[string]any{"$ref": "#/components/" + kind + "/" + name}
refJSON, _ := json.Marshal(ref)
// Keep small objects inline if components would increase size.
if (count-1)*len(raw) <= count*len(refJSON)+len(name)+4 {
continue
}
shared[name] = s.parent[s.key]
s.parent[s.key] = map[string]any{"$ref": "#/components/" + kind + "/" + name}
}
if len(shared) > 0 {
components[kind] = shared
}
}
// Share whole responses before counting inner objects. Otherwise a catalog
// copied across 100 routes can create hundreds of unnecessary components
// that only one shared response ultimately uses.
factor("responses", responses)
collectMedia := func(value any) {
resp := value.(map[string]any)
content, _ := resp["content"].(map[string]any)
for _, value := range content {
media := value.(map[string]any)
if media["schema"] != nil {
schemas = append(schemas, slot{media, "schema"})
}
es, _ := media["examples"].(map[string]any)
for name := range es {
examples = append(examples, slot{es, name})
}
}
}
for _, s := range responses {
collectMedia(s.parent[s.key])
}
sharedResponses, _ := components["responses"].(map[string]any)
for _, resp := range sharedResponses {
collectMedia(resp)
}
factor("examples", examples)
factor("schemas", schemas)
}

View File

@@ -0,0 +1,107 @@
package apigen
import (
"encoding/json"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"strings"
"testing"
)
func TestSharedErrorDocumentReferences(t *testing.T) {
source := `@app(defaultResponse: Common)
response Common(codeType:int32;successCode:0;errorCode:9999;messageField:msg;dataMode:always)
service demo {
`
for i := 0; i < 16; i++ {
kind := "string"
if i%2 == 0 {
kind = "int64"
}
source += fmt.Sprintf("@handler list%d\nget /list%d returns ([]%s)\n", i, i, kind)
}
source += "}\n"
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{}}
for i := 0; i < 80; i++ {
status := 400
if i%2 == 0 {
status = 200
}
catalog.Definitions[fmt.Sprintf("ERROR_%d", i)] = apierrors.Definition{Status: status, Code: 400000 + i, Message: strings.Repeat("说明", 40) + "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}
}
spec.DocumentCatalog = &catalog
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
var raw map[string]any
if err = json.Unmarshal(compiled.Document, &raw); err != nil {
t.Fatal(err)
}
components := raw["components"].(map[string]any)
for _, kind := range []string{"responses", "examples"} {
if len(components[kind].(map[string]any)) == 0 {
t.Fatal("missing shared", kind)
}
}
expanded := document(t, compiled)
for i := 0; i < 16; i++ {
responses := operation(t, expanded, fmt.Sprintf("/list%d", i), "get")["responses"].(map[string]any)
for _, status := range []string{"200", "400"} {
media := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
if len(media["examples"].(map[string]any)) != 40 {
t.Fatal("examples lost")
}
if status == "200" {
choices := media["schema"].(map[string]any)["anyOf"].([]any)
if len(choices) != 2 || choices[0].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)["type"] != "array" {
t.Fatal("200 success shape lost")
}
}
for _, example := range media["examples"].(map[string]any) {
value := example.(map[string]any)["value"].(map[string]any)
if _, ok := value["data"]; !ok || value["data"] != nil || !strings.Contains(value["msg"].(string), "企业名称不能超过100个字符") {
t.Fatal(value)
}
}
}
}
expandedBytes, _ := json.MarshalIndent(expanded, "", " ")
if len(compiled.Document)*5 > len(expandedBytes) {
t.Fatalf("poor dedup: %d vs %d", len(compiled.Document), len(expandedBytes))
}
again, err := Compile(spec)
if err != nil || string(again.Document) != string(compiled.Document) {
t.Fatal("non-deterministic document", err)
}
before, _ := json.Marshal(raw)
compactDocument(raw)
data, _ := json.Marshal(raw)
if string(data) != string(before) {
t.Fatal("compaction not idempotent")
}
}
func TestSharedSchemasAndLiteralExampleReference(t *testing.T) {
doc := map[string]any{"components": map[string]any{}, "paths": map[string]any{}}
paths := doc["paths"].(map[string]any)
for _, name := range []string{"first", "second", "third"} {
schema := map[string]any{"type": "object", "description": strings.Repeat("schema description ", 40)}
example := map[string]any{"summary": strings.Repeat("example ", 50), "value": map[string]any{"$ref": "literal business field"}}
paths["/"+name] = map[string]any{"get": map[string]any{"responses": map[string]any{"200": map[string]any{"description": name, "content": map[string]any{"application/json": map[string]any{"schema": schema, "examples": map[string]any{"same": example}}}}}}}
}
compactDocument(doc)
components := doc["components"].(map[string]any)
if len(components["schemas"].(map[string]any)) != 1 {
t.Fatal("schema not shared")
}
for _, example := range components["examples"].(map[string]any) {
if example.(map[string]any)["value"].(map[string]any)["$ref"] != "literal business field" {
t.Fatal("payload reference interpreted")
}
}
}

View File

@@ -19,11 +19,12 @@ import (
) )
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.14.0-preview" const minimumVersion = "v0.16.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 {
lockHeld bool lockHeld bool
documentCatalogs map[string][]byte
Database, Storage string Database, Storage string
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
Check bool Check bool
@@ -48,7 +49,7 @@ func Generate(o Options) (Output, error) {
if o.API == "" || o.Dir == "" { if o.API == "" || o.Dir == "" {
return Output{}, errors.New("--api and --dir are required") return Output{}, errors.New("--api and --dir are required")
} }
spec, err := Parse(o.API) spec, err := parseWithCatalogs(o.API, o.documentCatalogs)
if err != nil { if err != nil {
return Output{}, err return Output{}, err
} }
@@ -145,13 +146,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, "lifecycle", "runtime.go")); err == nil { if source, err := os.ReadFile(filepath.Join(path, "config", "env.go")); err == nil && strings.Contains(string(source), "func ServerEnvAliases(") {
localCurrent = true localCurrent = true
} }
} }
} }
if !localCurrent { if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates the generated Runtime, request policy and structured MySQL support; use %s or a current local replacement", minimumVersion) return Output{}, fmt.Errorf("existing framework dependency predates the generated configuration helpers; use %s or a current local replacement", minimumVersion)
} }
} }
if o.FrameworkDir != "" { if o.FrameworkDir != "" {
@@ -242,8 +243,12 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
} }
} }
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld) out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
if requiredVersion != "" && requiredVersion != minimumVersion { expectedVersion := minimumVersion
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: minimumVersion, Message: "project dependency differs from this CLI template version; use matching CLI/framework versions, or verify the explicit local replacement"}) if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
expectedVersion = info.Main.Version
}
if requiredVersion != "" && requiredVersion != expectedVersion {
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: expectedVersion, Message: "project dependency differs from this CLI version; use matching CLI/framework versions, or verify the explicit local replacement"})
sortDiagnostics(out.Diagnostics) sortDiagnostics(out.Diagnostics)
} }
return out, err return out, err
@@ -450,7 +455,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
continue continue
} }
hasAutomatic = true hasAutomatic = true
needsTypes = needsTypes || r.Request != "" || responseGoType(r.Response) != "" needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response)
needsBinder = needsBinder || !r.CustomBinding needsBinder = needsBinder || !r.CustomBinding
} }
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc") text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
@@ -566,7 +571,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} }
} }
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors") service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
if r.Request != "" || responseGoType(r.Response) != "" { if r.Request != "" || responseNeedsTypes(r.Response) {
service += fmt.Sprintf("%q;", o.Module+"/internal/types") service += fmt.Sprintf("%q;", o.Module+"/internal/types")
} }
if streamingBody || r.Produces != "" { if streamingBody || r.Produces != "" {

View File

@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
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", "shutdown_test.go": "shutdown_test.go",
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.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)))
@@ -79,7 +80,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\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1)) writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n BackgroundMode string `json:\"backgroundMode\"`\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 nil", "return initClient(ctx,c)", 1) source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)

View File

@@ -74,7 +74,7 @@ func document(t *testing.T, c *Compiled) map[string]any {
if err := json.Unmarshal(c.Document, &doc); err != nil { if err := json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return doc return expandDocumentRefs(t, doc, doc).(map[string]any)
} }
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any { func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
t.Helper() t.Helper()
@@ -303,3 +303,21 @@ func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
t.Fatal("auth metadata lost") t.Fatal("auth metadata lost")
} }
} }
// Existing semantic assertions resolve the standard local references emitted
// by the compiler. documentRefs_test additionally checks the actual references.
func expandDocumentRefs(t *testing.T, doc map[string]any, v any) any {
t.Helper()
switch v:=v.(type){
case map[string]any:
if ref,ok:=v["$ref"].(string);ok && strings.HasPrefix(ref,"#/components/") && len(v)==1 {
var target any=doc
for _,part:=range strings.Split(strings.TrimPrefix(ref,"#/"),"/"){m,ok:=target.(map[string]any);if !ok{t.Fatal("invalid reference",ref)};target=m[part]}
if target==nil{t.Fatal("dangling reference",ref)};return expandDocumentRefs(t,doc,target)
}
out:=map[string]any{};for k,x:=range v{out[k]=expandDocumentRefs(t,doc,x)};return out
case []any:
out:=make([]any,len(v));for i,x:=range v{out[i]=expandDocumentRefs(t,doc,x)};return out
default:return v
}
}

View File

@@ -17,6 +17,7 @@ import (
type Spec struct { type Spec struct {
DocumentCatalog *apierrors.Catalog DocumentCatalog *apierrors.Catalog
DocumentErrors *DocumentErrors
SharedRoots map[string]SharedRoot SharedRoots map[string]SharedRoot
SourcePaths map[string]string SourcePaths map[string]string
RequestPolicy contract.RequestPolicy RequestPolicy contract.RequestPolicy
@@ -72,7 +73,9 @@ type parser struct {
imports []string imports []string
} }
func Parse(path string) (*Spec, error) { func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
entry, err := filepath.Abs(path) entry, err := filepath.Abs(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -82,11 +85,12 @@ func Parse(path string) (*Spec, error) {
return nil, err return nil, err
} }
root := filepath.Dir(entry) root := filepath.Dir(entry)
roots, err := readSharedRoots(root) shared, err := readSharedConfig(root)
if err != nil { if err != nil {
return nil, err return nil, err
} }
spec := &Spec{Version: "1.0.0", SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)} roots := shared.Roots
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
visiting, visited := map[string]bool{}, map[string]bool{} visiting, visited := map[string]bool{}, map[string]bool{}
total := 0 total := 0
var visit func(string, string, int) error var visit func(string, string, int) error
@@ -169,7 +173,7 @@ func Parse(path string) (*Spec, error) {
if err = visit(entry, "", 0); err != nil { if err = visit(entry, "", 0); err != nil {
return nil, err return nil, err
} }
if err = readDocumentCatalog(root, spec); err != nil { if err = readDocumentCatalog(root, spec, catalogs); err != nil {
return nil, err return nil, err
} }
if err = validateSharedPins(spec); err != nil { if err = validateSharedPins(spec); err != nil {

View File

@@ -73,6 +73,8 @@ func TestConfiguration(t *testing.T){
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)} cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
if cfg.Server.Log.File==""{t.Fatal("file logger absent")} if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)} t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
t.Setenv("GO_OMNI_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %v",err)}
t.Setenv("GO_OMNI_SERVER_LOG_OUTPUT","stderr");cfg,err=Load("");if err!=nil||cfg.Server.Log.Output!="stderr"{t.Fatalf("canonical output: %v",err)}
_=os.Getenv("unused") _=os.Getenv("unused")
}` }`
endpoint := "" endpoint := ""

View File

@@ -4,11 +4,12 @@ import (
"fmt" "fmt"
"git.yuntuo.hk/k2safe/go-omni/contract" "git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/openapi" "git.yuntuo.hk/k2safe/go-omni/openapi"
"reflect"
"strings" "strings"
) )
// Response roots deliberately allow named DTOs, []DTO, *DTO and null. Keeping // Response roots allow named DTOs, []DTO, []primitive, *DTO and null.
// these four shapes explicit makes nil/empty behavior uniform across encoders. // A nil root slice is always encoded as an empty JSON array.
func responseShape(value string) (kind, name string) { func responseShape(value string) (kind, name string) {
if value == "null" { if value == "null" {
return "null", "" return "null", ""
@@ -27,6 +28,9 @@ func responseGoType(value string) string {
return "" return ""
} }
if kind == "array" { if kind == "array" {
if primitiveTypes[name] != nil {
return "[]" + name
}
return "[]types." + name return "[]types." + name
} }
return "*types." + name return "*types." + name
@@ -36,9 +40,25 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
if kind == "null" { if kind == "null" {
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
} }
if kind == "array" && primitiveTypes[name] != nil {
if name == "File" {
return nil, fmt.Errorf("File is request-only; use a response DTO")
}
// Reuse the contract compiler, including integer bounds and byte-slice rules.
t := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.SliceOf(primitiveTypes[name]), Tag: `json:"value"`}})
b, err := contract.Describe(t)
if err != nil {
return nil, err
}
s, err := b.ResponseSchema()
if err != nil {
return nil, err
}
return s.Properties["value"], nil
}
b, ok := bindings[name] b, ok := bindings[name]
if !ok { if !ok {
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, *Name or null", name) return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
} }
s, err := b.ResponseSchema() s, err := b.ResponseSchema()
if err != nil { if err != nil {
@@ -52,3 +72,7 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
} }
return s, nil return s, nil
} }
var primitiveTypes = map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
func responseNeedsTypes(value string) bool { return strings.Contains(responseGoType(value), "types.") }

View File

@@ -23,48 +23,49 @@ type SharedRoot struct {
SHA256 string `json:"sha256,omitempty"` SHA256 string `json:"sha256,omitempty"`
} }
type sharedConfig struct { type sharedConfig struct {
Roots map[string]SharedRoot `json:"roots"` Roots map[string]SharedRoot `json:"roots"`
Errors *DocumentErrors `json:"errors,omitempty"`
} }
func readSharedRoots(root string) (map[string]SharedRoot, error) { func readSharedConfig(root string) (sharedConfig, error) {
stat, err := os.Lstat(filepath.Join(root, importsFile)) stat, err := os.Lstat(filepath.Join(root, importsFile))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil, nil return sharedConfig{}, nil
} }
if err != nil { if err != nil {
return nil, err return sharedConfig{}, err
} }
if !stat.Mode().IsRegular() { if !stat.Mode().IsRegular() {
return nil, errors.New("api: shared root configuration must be a regular file") return sharedConfig{}, errors.New("api: shared root configuration must be a regular file")
} }
f, err := os.Open(filepath.Join(root, importsFile)) f, err := os.Open(filepath.Join(root, importsFile))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil, nil return sharedConfig{}, nil
} }
if err != nil { if err != nil {
return nil, err return sharedConfig{}, err
} }
defer f.Close() defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, 65537)) raw, err := io.ReadAll(io.LimitReader(f, 65537))
if err != nil { if err != nil {
return nil, err return sharedConfig{}, err
} }
if len(raw) > 65536 { if len(raw) > 65536 {
return nil, errors.New("api: shared root config too large") return sharedConfig{}, errors.New("api: shared root config too large")
} }
var cfg sharedConfig var cfg sharedConfig
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil { if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
return nil, err return sharedConfig{}, err
} }
if len(cfg.Roots) > 16 { if len(cfg.Roots) > 16 {
return nil, errors.New("api: at most 16 shared roots") return sharedConfig{}, errors.New("api: at most 16 shared roots")
} }
for name, r := range cfg.Roots { for name, r := range cfg.Roots {
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 { if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
return nil, errors.New("api: invalid shared root") return sharedConfig{}, errors.New("api: invalid shared root")
} }
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") { if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
return nil, errors.New("api: shared sha256 must be 64 lowercase hex characters") return sharedConfig{}, errors.New("api: shared sha256 must be 64 lowercase hex characters")
} }
path := r.Path path := r.Path
if !filepath.IsAbs(path) { if !filepath.IsAbs(path) {
@@ -72,22 +73,22 @@ func readSharedRoots(root string) (map[string]SharedRoot, error) {
} }
r.Path, err = filepath.EvalSymlinks(path) r.Path, err = filepath.EvalSymlinks(path)
if err != nil { if err != nil {
return nil, err return sharedConfig{}, err
} }
r.Path, err = filepath.Abs(r.Path) r.Path, err = filepath.Abs(r.Path)
if err != nil { if err != nil {
return nil, err return sharedConfig{}, err
} }
stat, err := os.Stat(r.Path) stat, err := os.Stat(r.Path)
if err != nil { if err != nil {
return nil, err return sharedConfig{}, err
} }
if !stat.IsDir() { if !stat.IsDir() {
return nil, errors.New("api: shared root must be a directory") return sharedConfig{}, errors.New("api: shared root must be a directory")
} }
cfg.Roots[name] = r cfg.Roots[name] = r
} }
return cfg.Roots, nil return cfg, nil
} }
func sourceWithin(root, name string) (string, error) { func sourceWithin(root, name string) (string, error) {
real, err := filepath.EvalSymlinks(name) real, err := filepath.EvalSymlinks(name)
@@ -122,7 +123,7 @@ func sourceMetadata(spec *Spec) map[string]any {
return map[string]any{"files": hashes, "shared": shared} return map[string]any{"files": hashes, "shared": shared}
} }
func sharedSeeds(spec *Spec, directory string) ([]byte, error) { func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
cfg := sharedConfig{Roots: map[string]SharedRoot{}} cfg := sharedConfig{Roots: map[string]SharedRoot{}, Errors: spec.DocumentErrors}
for name, r := range spec.SharedRoots { for name, r := range spec.SharedRoots {
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path) rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
if err != nil { if err != nil {

View File

@@ -13,6 +13,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"MODULE/internal/config" "MODULE/internal/config"
"MODULE/internal/handler" "MODULE/internal/handler"
"MODULE/internal/svc" "MODULE/internal/svc"
@@ -27,7 +28,9 @@ func main(){
func run(ctx context.Context,path string)(runErr error){ func run(ctx context.Context,path string)(runErr error){
cfg,err:=config.Load(path);if err!=nil{return err} cfg,err:=config.Load(path);if err!=nil{return err}
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err} logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
defer func(){runErr=errors.Join(runErr,logger.Close())}() // Unjoined background work may still log. The owning process exits on this
// error; do not close its borrowed logger before that work has finished.
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
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.
@@ -182,7 +185,16 @@ then immediately register each cleanup with OnClose. A later initialization fail
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
once before route registration; logging is configured earlier through Config. once before route registration; logging is configured earlier through Config.
The generated main runs HTTP draining before closing svc resources, then the logger. The generated main runs HTTP draining, all OnStop hooks, all OnDrain hooks, resource
OnClose callbacks, then logger close. Stop/drain hooks within a phase run concurrently;
combine components with ordering dependencies into one pair. Pair OnStop/OnDrain
with the same name. OnDrain returns nil only after final work and acknowledgements
have finished. Use WorkContext for accepted background work; Context is an early
shutdown notification. server.lifecycle configures stopTimeout (5s), drainTimeout
(10s), and cancelTimeout (5s grace per timed-out phase). Zero uses defaults.
Timeout/failure cancels WorkContext. If hooks cannot prove completion, resources
and the borrowed logger are retained and the generated process exits unsuccessfully.
Errors retain their causes. No automatic retry of close or force killing of goroutines.
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
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.

View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"encoding/json"
"example.com/arrays/internal/config"
"example.com/arrays/internal/handler"
"example.com/arrays/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"net/http/httptest"
"strings"
"testing"
)
func TestArrayWire(t *testing.T) {
cfg := config.Default()
cfg.Server.Log.Writer = io.Discard
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, want := range map[string]string{
"/strings": `["企业","备注"]`, "/integers": `[9223372036854775807,-3]`, "/dynamic": `["text",7,true,null,{"name":"企业"},[false,2]]`, "/empty": `[]`, "/raw": `[true,false]`,
} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
got := strings.TrimSpace(w.Body.String())
if w.Code != 200 {
t.Fatal(got)
}
if path != "/raw" {
var obj map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil {
t.Fatal(err)
}
got = string(obj["data"])
}
if got != want {
t.Fatalf("%s got %s want %s", path, got, want)
}
}
}

View File

@@ -0,0 +1,13 @@
package arrays
import "context"
func (s *Logic) Strings(context.Context) ([]string, error) { return []string{"企业", "备注"}, nil }
func (s *Logic) Integers(context.Context) ([]int64, error) {
return []int64{9223372036854775807, -3}, nil
}
func (s *Logic) Dynamic(context.Context) ([]any, error) {
return []any{"text", 7, true, nil, map[string]any{"name": "企业"}, []any{false, 2}}, nil
}
func (s *Logic) Empty(context.Context) ([]any, error) { return nil, nil }
func (s *Logic) Raw(context.Context) ([]bool, error) { return []bool{true, false}, nil }

View File

@@ -0,0 +1,49 @@
package svc
import (
"context"
"errors"
"git.yuntuo.hk/k2safe/go-omni/task"
"sync/atomic"
"time"
)
type BackgroundState struct {
Stop, Drain, Release, Started chan struct{}
Wrote atomic.Bool
Future *task.Future
}
var Background atomic.Pointer[BackgroundState]
func initBackground(c *ServiceContext) error {
state := &BackgroundState{Stop: make(chan struct{}), Drain: make(chan struct{}), Release: make(chan struct{}), Started: make(chan struct{})}
baseline := Closed.Load()
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1, TaskTimeout: time.Minute, OnError: func(string, error) {}})
if err != nil {
return err
}
c.OnStop("jobs", func(ctx context.Context) error { close(state.Stop); return runner.StopAccepting(ctx) })
c.OnDrain("jobs", func(ctx context.Context) error { close(state.Drain); return runner.Wait(ctx) })
state.Future, err = runner.Submit(c.WorkContext(), "finish", func(ctx context.Context) error {
close(state.Started)
if c.Config.BackgroundMode == "auto" {
<-state.Stop
} else {
<-state.Release
}
if ctx.Err() != nil {
return ctx.Err()
}
if Closed.Load() != baseline {
return errors.New("shared client closed before background write")
}
state.Wrote.Store(true)
return nil
})
if err == nil {
<-state.Started
}
Background.Store(state)
return err
}

View File

@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
Opened.Add(1) Opened.Add(1)
c.Client = &Client{} c.Client = &Client{}
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil }) c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
if c.Config.BackgroundMode != "" {
if err := initBackground(c); err != nil {
return err
}
}
if c.Config.FailInit { if c.Config.FailInit {
return errors.New("client initialization failed") return errors.New("client initialization failed")
} }

View File

@@ -0,0 +1,155 @@
package main
import (
"context"
"encoding/json"
"errors"
"example.com/managed/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func phasedConfig(t *testing.T, address, mode string, fail bool) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
drainTimeout := "1s"
if mode == "stuck" {
drainTimeout = "50ms"
}
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
return path
}
func await(t *testing.T, ch <-chan struct{}) {
t.Helper()
select {
case <-ch:
case <-time.After(3 * time.Second):
t.Fatal("phase did not arrive")
}
}
func TestGeneratedBackgroundPhases(t *testing.T) {
for _, transport := range []string{"nethttp", "native"} {
t.Run(transport, func(t *testing.T) {
t.Setenv("GO_OMNI_TRANSPORT", transport)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := listener.Addr().String()
_ = listener.Close()
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 := phasedConfig(t, addr, "hold", false)
go func() { done <- run(ctx, path) }()
waitReady(t, "http://"+addr, done)
state := svc.Background.Load()
defer func() {
select {
case <-state.Release:
default:
close(state.Release)
}
}()
requestDone := make(chan error, 1)
go func() {
client := http.Client{Timeout: 3 * time.Second}
resp, e := client.Get("http://" + addr + "/slow")
if e == nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != 200 {
e = errors.New("HTTP drain failed")
}
}
requestDone <- e
}()
await(t, svc.Started)
deps := svc.Current.Load()
deps.RequestShutdown(svc.FatalDependency)
if deps.WorkContext().Err() != nil {
t.Fatal("accepted work canceled at signal")
}
select {
case <-state.Stop:
t.Fatal("stopped background before HTTP finished")
default:
}
close(svc.Release)
if err := <-requestDone; err != nil {
t.Fatal(err)
}
await(t, state.Drain)
if svc.Closed.Load() != before {
t.Fatal("client closed during background drain")
}
close(state.Release)
if err := <-done; !errors.Is(err, svc.FatalDependency) || errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal(err)
}
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
t.Fatal("missing final write or cleanup")
}
if err := state.Future.Wait(context.Background()); err != nil {
t.Fatal(err)
}
})
}
}
func TestGeneratedBackgroundFailureCleanup(t *testing.T) {
for _, failInit := range []bool{false, true} {
t.Run(map[bool]string{true: "initialization", false: "listen"}[failInit], func(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
before := svc.Closed.Load()
err = run(context.Background(), phasedConfig(t, l.Addr().String(), "auto", failInit))
if err == nil || errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal(err)
}
state := svc.Background.Load()
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
t.Fatal("failure cleanup did not join background first")
}
})
}
}
func TestGeneratedBackgroundTimeoutProcess(t *testing.T) {
if path := os.Getenv("OMNI_PHASE_TIMEOUT_CONFIG"); path != "" {
os.Args = []string{os.Args[0], "-config", path}
main()
return
}
path := phasedConfig(t, "127.0.0.1:0", "stuck", true)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedBackgroundTimeoutProcess$")
cmd.Env = append(os.Environ(), "OMNI_PHASE_TIMEOUT_CONFIG="+path)
output, err := cmd.CombinedOutput()
var exit *exec.ExitError
if !errors.As(err, &exit) || exit.ExitCode() != 1 || ctx.Err() != nil {
t.Fatal(err, string(output))
}
if strings.Contains(string(output), "test.client.closed") || !strings.Contains(string(output), "shared resources retained") || !strings.Contains(string(output), "client initialization failed") {
t.Fatal(string(output))
}
}

View File

@@ -78,6 +78,12 @@ func PlanUpgrade(dir string) (UpgradePlan, error) {
names[name] = true names[name] = true
} }
} }
// v0.14 already owns config/svc extensions. Only bump the module here;
// Generate updates generator-owned main/runtime files without replaying the
// v0.13 seed migration over developer configuration and dependency code.
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, "v0.14.0-preview") >= 0 {
names = map[string]bool{}
}
sorted := make([]string, 0, len(names)) sorted := make([]string, 0, len(names))
for name := range names { for name := range names {
sorted = append(sorted, name) sorted = append(sorted, name)

View File

@@ -60,6 +60,49 @@ func TestUpgradeRejectsEscapingEntry(t *testing.T) {
t.Fatal("invalid upgrade wrote project") t.Fatal("invalid upgrade wrote project")
} }
} }
func TestUpgradeV014PreservesExtensions(t *testing.T) {
dir := filepath.Join(t.TempDir(), "service")
if _, err := New(Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
t.Fatal(err)
}
m.TemplateVersion = "v0.14.0-preview"
raw, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
mod := filepath.Join(dir, "go.mod")
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
owned := map[string]string{}
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
path := filepath.Join(dir, name)
value := readFile(t, path)
if strings.HasSuffix(name, ".go") {
value += "\n// project extension kept\n"
} else {
value += "\n# project configuration kept\n"
}
writeFile(t, path, value)
owned[path] = value
}
plan, err := PlanUpgrade(dir)
if err != nil || len(plan.Changes) != 1 || plan.Changes[0].Path != "go.mod" {
t.Fatal(plan, err)
}
if _, err := ApplyUpgrade(dir); err != nil {
t.Fatal(err)
}
for path, want := range owned {
if readFile(t, path) != want {
t.Fatal("developer file modified", path)
}
}
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
t.Fatal(err)
}
}
func TestSafeInfrastructureUpgrade(t *testing.T) { func TestSafeInfrastructureUpgrade(t *testing.T) {
dir := oldInfrastructureProject(t) dir := oldInfrastructureProject(t)
before := snapshot(t, dir) before := snapshot(t, dir)

View File

@@ -16,7 +16,8 @@ type WorkspaceService struct {
Module string `json:"module,omitempty"` Module string `json:"module,omitempty"`
} }
type WorkspaceConfig struct { type WorkspaceConfig struct {
Services []WorkspaceService `json:"services"` Services []WorkspaceService `json:"services"`
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
} }
// Workspace operates independent Go modules listed in an explicit local file. // Workspace operates independent Go modules listed in an explicit local file.
@@ -83,6 +84,16 @@ func Workspace(path, command string, w io.Writer) error {
} }
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true}) options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
} }
var catalogs map[string][]byte
if command != "format" {
catalogs, err = prepareWorkspaceCatalogs(root, cfg.ErrorCatalogs, command == "check")
if err != nil {
return err
}
for i := range options {
options[i].documentCatalogs = catalogs
}
}
var failures error var failures error
for _, o := range options { for _, o := range options {
if command == "format" { if command == "format" {
@@ -101,6 +112,13 @@ func Workspace(path, command string, w io.Writer) error {
if failures != nil { if failures != nil {
return failures return failures
} }
if command == "generate" {
for path, data := range catalogs {
if err = writeWorkspaceCatalog(path, data); err != nil {
return err
}
}
}
for _, o := range options { for _, o := range options {
switch command { switch command {
case "format": case "format":

View File

@@ -0,0 +1,217 @@
package apigen
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
gotoken "go/token"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
)
// WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog.
// Only workspace generate/check execute providers. Parsing an .api never does.
type WorkspaceCatalog struct {
Dir string `json:"dir"`
Package string `json:"package"`
Function string `json:"function"`
Output string `json:"output"`
}
type limitedCatalogBuffer struct {
bytes.Buffer
limit int
}
func (b *limitedCatalogBuffer) Write(p []byte) (int, error) {
if len(p) > b.limit-b.Len() {
return 0, errors.New("catalog provider output exceeds limit")
}
return b.Buffer.Write(p)
}
func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) {
if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil {
return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output")
}
if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil {
return nil, err
}
if err := safePath(root, cfg.Output); err != nil {
return nil, err
}
if filepath.Ext(cfg.Output) != ".json" {
return nil, errors.New("workspace catalog output must be .json")
}
dir := filepath.Join(root, filepath.FromSlash(cfg.Dir))
if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() {
return nil, errors.New("workspace catalog dir must contain go.mod")
}
temp, err := os.MkdirTemp("", "go-omni-catalog-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(temp)
// Overlay a virtual Go file under the provider module. This allows internal
// packages without creating files or changing go.mod/go.sum in that module.
virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go")
if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) {
return nil, errors.New("reserved catalog export path already exists")
}
source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function)
backing := filepath.Join(temp, "main.go")
if err = os.WriteFile(backing, []byte(source), 0600); err != nil {
return nil, err
}
overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}})
overlayPath := filepath.Join(temp, "overlay.json")
if err = os.WriteFile(overlayPath, overlay, 0600); err != nil {
return nil, err
}
// Keep all module metadata writes in the temporary directory as well.
// -mod=readonly alone must not be relied on to protect every go.sum update.
moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil {
return nil, err
}
mf, err := modfile.Parse("go.mod", moduleData, nil)
if err != nil {
return nil, errors.New("workspace catalog has invalid go.mod")
}
replacements := append([]*modfile.Replace(nil), mf.Replace...)
for _, r := range replacements {
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil {
return nil, err
}
}
}
moduleData, err = mf.Format()
if err != nil {
return nil, err
}
modPath := filepath.Join(temp, "go.mod")
if err = os.WriteFile(modPath, moduleData, 0600); err != nil {
return nil, err
}
if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil {
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
return nil, err
}
} else if !errors.Is(e, os.ErrNotExist) {
return nil, e
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
executable := filepath.Join(temp, "catalogExport")
// Build first, then execute the binary directly: cancellation kills the
// provider itself instead of only terminating a go-run wrapper process.
build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual)
build.Dir = dir
build.Env = append(os.Environ(), "GOWORK=off")
build.WaitDelay = 2 * time.Second
build.Stdout = &limitedCatalogBuffer{limit: 64 << 10}
build.Stderr = &limitedCatalogBuffer{limit: 64 << 10}
if err = build.Run(); err != nil {
return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err)
}
cmd := exec.CommandContext(ctx, executable)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
cmd.WaitDelay = 2 * time.Second
stdout := &limitedCatalogBuffer{limit: 1 << 20}
stderr := &limitedCatalogBuffer{limit: 64 << 10}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err())
}
// Provider output can contain arbitrary application data. Do not echo it.
return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err)
}
var catalog apierrors.Catalog
if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil {
return nil, errors.New("workspace catalog provider did not emit a valid catalog")
}
var normalized bytes.Buffer
if err = apierrors.ExportCatalog(&normalized, catalog); err != nil {
return nil, err
}
return normalized.Bytes(), nil
}
func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) {
if len(configs) > 16 {
return nil, errors.New("workspace supports at most 16 catalog providers")
}
result := map[string][]byte{}
for _, cfg := range configs {
if err := safePath(root, cfg.Output); err != nil {
return nil, err
}
path := filepath.Join(root, filepath.FromSlash(cfg.Output))
for prior := range result {
if strings.EqualFold(prior, path) {
return nil, errors.New("workspace catalog outputs overlap")
}
}
data, err := exportWorkspaceCatalog(root, cfg)
if err != nil {
return nil, err
}
if check {
old, err := os.ReadFile(path)
if err != nil || !bytes.Equal(old, data) {
return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output)
}
}
result[path] = data
}
return result, nil
}
func writeWorkspaceCatalog(path string, data []byte) error {
mode := os.FileMode(0644)
if stat, err := os.Lstat(path); err == nil {
if !stat.Mode().IsRegular() {
return errors.New("workspace catalog output must be a regular file")
}
mode = stat.Mode().Perm()
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
old, err := os.ReadFile(path)
if err == nil && bytes.Equal(old, data) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if err = f.Chmod(mode); err != nil {
_ = f.Close()
return err
}
_, writeErr := io.Copy(f, bytes.NewReader(data))
err = errors.Join(writeErr, f.Close())
if err != nil {
return err
}
return os.Rename(f.Name(), path)
}

View File

@@ -0,0 +1,168 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWorkspaceSharedGoCatalog(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
"INVALID":{Code:"INVALID",Message:"invalid input"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"},
}}}
`)
if err := os.MkdirAll(filepath.Join(root, "contracts"), 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"api", "id"} {
dir := filepath.Join(root, "services", name)
if _, err := New(Options{Dir: dir, Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
}
workspace := filepath.Join(root, "omni.workspace.json")
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`)
if err := Workspace(workspace, "generate", io.Discard); err != nil {
t.Fatal(err)
}
if err := Workspace(workspace, "format", io.Discard); err != nil {
t.Fatal(err)
}
if err := Workspace(workspace, "generate", io.Discard); err != nil {
t.Fatal(err)
}
before := snapshot(t, root)
if err := Workspace(workspace, "check", io.Discard); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("check wrote project files")
}
for _, name := range []string{"api", "id"} {
entry := filepath.Join(root, "services", name, "api/service.api")
spec, err := Parse(entry)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
op := operation(t, document(t, c), "/api/greet/{name}", "get")
if op["responses"].(map[string]any)["404"] == nil || len(spec.Errors) != 0 || len(spec.Routes[0].Errors) != 0 {
t.Fatal("selection changed runtime catalog")
}
if _, err = os.Stat(filepath.Join(filepath.Dir(entry), catalogDocumentFile)); !os.IsNotExist(err) {
t.Fatal("shared catalog was copied")
}
}
shared := filepath.Join(root, "contracts/errors.json")
data := readFile(t, shared)
writeFile(t, shared, strings.Replace(data, "invalid input", "stale", 1))
before = snapshot(t, root)
if err := Workspace(workspace, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "catalog drift") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("drift check wrote files")
}
if err := Workspace(workspace, "generate", io.Discard); err != nil {
t.Fatal(err)
}
if got := readFile(t, shared); got != data {
t.Fatal("catalog not refreshed")
}
// Failed provider/preflight may not update even the shared metadata.
source := filepath.Join(root, "internal/apierrors/catalog.go")
writeFile(t, source, readFile(t, source)+"\ninvalid go source")
before = snapshot(t, root)
if err := Workspace(workspace, "generate", io.Discard); err == nil {
t.Fatal("provider failure ignored")
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("provider failure mutated project")
}
}
func TestDocumentSelectionBoundaries(t *testing.T) {
root := t.TempDir()
local := filepath.Join(root, "api")
shared := filepath.Join(root, "shared")
entry := filepath.Join(local, "service.api")
writeFile(t, entry, "service demo {\n@handler list\nget /list returns ([]string)\n}\n")
writeFile(t, filepath.Join(shared, "errors.json"), `{"defaultStatus":400,"definitions":{"INVALID":{"code":400002,"message":"invalid"}}}`)
for _, cfg := range []string{
`{"source":"@common/errors.json","include":["UNKNOWN"]}`,
`{"source":"@common/errors.json","routes":{"GET /absent":["INVALID"]}}`,
`{"source":"@common/../api/service.api"}`,
`{"source":"../shared/errors.json"}`,
} {
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":`+cfg+`}`)
if _, err := Parse(entry); err == nil {
t.Fatal("bad selection accepted", cfg)
}
}
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":{"source":"@common/errors.json","include":[]}}`)
spec, err := Parse(entry)
if err != nil {
t.Fatal(err)
}
if len(documentRouteCatalog(spec, spec.Routes[0]).Definitions) != 0 {
t.Fatal("empty include ignored")
}
seeded, err := sharedSeeds(spec, filepath.Join(root, "generated"))
if err != nil {
t.Fatal(err)
}
var cfg sharedConfig
if err = json.Unmarshal(seeded, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Errors.Include == nil || !bytes.Contains(seeded, []byte(`"include": []`)) {
t.Fatal("empty selection lost during relocation")
}
}
func TestWorkspaceCatalogPathsAndProviderValidation(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/test\ngo 1.24.0\n")
good := WorkspaceCatalog{Dir: ".", Package: "example.com/test/internal/errors", Function: "Catalog", Output: "errors.json"}
cases := []WorkspaceCatalog{}
for _, change := range []func(*WorkspaceCatalog){
func(c *WorkspaceCatalog) { c.Dir = "../outside" },
func(c *WorkspaceCatalog) { c.Output = "../outside.json" },
func(c *WorkspaceCatalog) { c.Package = "example.com/pkg;bad" },
func(c *WorkspaceCatalog) { c.Function = "Catalog()" },
func(c *WorkspaceCatalog) { c.Output = "go.mod" },
} {
c := good
change(&c)
cases = append(cases, c)
}
for _, c := range cases {
if _, err := exportWorkspaceCatalog(root, c); err == nil {
t.Fatal("bad provider accepted", c)
}
}
outside := filepath.Join(t.TempDir(), "errors.json")
writeFile(t, outside, `{}`)
if err := os.Symlink(outside, filepath.Join(root, "errors.json")); err != nil {
t.Fatal(err)
}
if _, err := prepareWorkspaceCatalogs(root, []WorkspaceCatalog{good}, false); err == nil {
t.Fatal("symlink output accepted")
}
if readFile(t, outside) != `{}` {
t.Fatal("outside file modified")
}
}

View File

@@ -8,7 +8,7 @@ import (
) )
func TestNewWithRemoteFrameworkVersion(t *testing.T) { func TestNewWithRemoteFrameworkVersion(t *testing.T) {
for _, flag := range [][]string{{"--framework-version", "v0.14.0-preview"}, {"--framework-version=v0.14.0-preview"}} { for _, flag := range [][]string{{"--framework-version", "v0.16.0-preview"}, {"--framework-version=v0.16.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.14.0-preview") || strings.Contains(string(content), "replace ") { if !strings.Contains(string(content), "go-omni v0.16.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

@@ -1,7 +1,7 @@
package settings package settings
import ( import (
"os" "os"
"strings" {{if and (not .API) (ne .Storage "none")}}"strings"{{end}}
"time" "time"
"git.yuntuo.hk/k2safe/go-omni/config" "git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
@@ -41,10 +41,10 @@ func Load(path string)(Config,error){
cfg:=Default();var err error cfg:=Default();var err error
cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err} cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err}
if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}} if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}}
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_"});if err!=nil{return Config{},err} cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_",Empty:"ignore",Aliases:config.ServerEnvAliases("GO_OMNI_","server")});if err!=nil{return Config{},err}
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc). output,outputSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT");if !outputSet {output=os.Getenv("GO_OMNI_LOG_OUTPUT")}
cfg.Server,err=config.ApplyEnv(cfg.Server,"");if err!=nil{return Config{},err} file,fileSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_FILE");if !fileSet {file=os.Getenv("GO_OMNI_LOG_FILE")}
if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}} if output!="" && file=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}}
return cfg,cfg.Validate() return cfg,cfg.Validate()
} }
func(c Config)Validate()error{ func(c Config)Validate()error{

View File

@@ -28,6 +28,8 @@ type Context struct {
{{end}} {{end}}
} }
// Init is project-owned. Add dependencies here and immediately register each // Init is project-owned. Add dependencies here and immediately register each
// cleanup with c.OnClose. Returning an error automatically rolls everything back. // cleanup with c.OnClose. Background components use paired OnStop/OnDrain hooks
// and WorkContext. Failed initialization stops/drains before releasing resources;
// unjoined background work returns ErrResourcesRetained instead of closing its dependencies.
// Generated infrastructure is ready here. Never create another App or logger. // Generated infrastructure is ready here. Never create another App or logger.
func(c *Context)Init(ctx context.Context)error {return nil} func(c *Context)Init(ctx context.Context)error {return nil}

View File

@@ -18,7 +18,8 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]} cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
if err=cfg.Validate();err!=nil{return nil,err} if err=cfg.Validate();err!=nil{return nil,err}
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(),Runtime:lifecycle.NewRuntime(ctx)} runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Server.Lifecycle);if err!=nil{return nil,err}
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:runtime}
ctx=logx.WithLogger(c.Context(),logger) ctx=logx.WithLogger(c.Context(),logger)
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}() defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
{{if eq .Database "mysql"}} {{if eq .Database "mysql"}}

View File

@@ -4,7 +4,11 @@ server:
enableLogger: true enableLogger: true
slowRequestThreshold: 500ms slowRequestThreshold: 500ms
shutdownTimeout: 10s shutdownTimeout: 10s
{{if .API}} endpoints: {{if .API}} lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
endpoints:
liveness: liveness:
enabled: true enabled: true
path: /health/live path: /health/live

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.14.0-preview" const defaultFrameworkVersion = "v0.16.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.

66
config/aliases_test.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"strings"
"testing"
"time"
)
func TestUnifiedEnvAliases(t *testing.T) {
type project struct {
Server Config `json:"server"`
Label string `json:"label"`
}
base := project{Server: Default(), Label: "base"}
for _, policy := range []string{"allow", "ignore", "reject"} {
t.Run(policy, func(t *testing.T) {
env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""}
opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
got, err := OverlayEnv(base, opts)
if policy == "reject" {
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatal(err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got.Server.Address != "127.0.0.1:8099" {
t.Fatal("alias ignored")
}
if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" {
t.Fatal("empty policy differs")
}
env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001"
env["TEST_HTTP_READ_TIMEOUT"] = "3s"
env["TEST_ENABLE_LOGGER"] = "false"
env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]`
got, err = OverlayEnv(base, opts)
if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 {
t.Fatal(got, err)
}
env["TEST_SERVER_ADDRESS"] = ""
got, err = OverlayEnv(base, opts)
if err != nil {
t.Fatal(err)
}
expected := ""
if policy == "ignore" {
expected = base.Server.Address
}
if got.Server.Address != expected {
t.Fatal("empty canonical unexpectedly fell through to alias")
}
})
}
if base.Server.Address != ":8080" || base.Label != "base" {
t.Fatal("defaults mutated")
}
}
func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) {
opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }}
if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") {
t.Fatal(err)
}
}

View File

@@ -7,6 +7,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/contract" "git.yuntuo.hk/k2safe/go-omni/contract"
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/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response" "git.yuntuo.hk/k2safe/go-omni/response"
) )
@@ -27,22 +28,23 @@ type TLSConfig struct {
// Config controls the default runtime behavior of an App. // Config controls the default runtime behavior of an App.
type Config struct { type Config struct {
Request contract.RequestPolicy `json:"request"` Request contract.RequestPolicy `json:"request"`
Address string `json:"address"` Address string `json:"address"`
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"` 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"`
TrustedProxies []string `json:"trustedProxies"` TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"` ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Log logx.Config `json:"log"` Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
Errors apierrors.Catalog `json:"errors"` Log logx.Config `json:"log"`
Response response.Config `json:"response"` Errors apierrors.Catalog `json:"errors"`
ResponseEncoder response.Encoder `json:"-"` Response response.Config `json:"response"`
LogInstance *logx.Logger `json:"-"` ResponseEncoder response.Encoder `json:"-"`
LogInstance *logx.Logger `json:"-"`
} }
func Default() Config { func Default() Config {
@@ -51,6 +53,7 @@ func Default() Config {
Transport: TransportNetHTTP, Transport: TransportNetHTTP,
RequestIDHeader: defaultRequestIDHeader, RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second, ShutdownTimeout: 10 * time.Second,
Lifecycle: lifecycle.DefaultShutdownConfig(),
Endpoints: DefaultEndpoints(), Endpoints: DefaultEndpoints(),
Log: logx.Default(), Log: logx.Default(),
} }
@@ -58,6 +61,9 @@ func Default() Config {
// 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.Lifecycle.Validate(); err != nil {
return err
}
if err := c.Request.Validate(); err != nil { if err := c.Request.Validate(); err != nil {
return err return err
} }

View File

@@ -1,135 +1,83 @@
package config package config
import ( import (
"fmt" "encoding/json"
"os" "os"
"strconv"
"strings" "strings"
"time"
) )
const defaultEnvPrefix = "GO_OMNI_" const defaultEnvPrefix = "GO_OMNI_"
// LoadFromEnv loads application settings from GO_OMNI_* variables. The // ServerEnvAliases returns the documented short names for Config fields.
// database settings remain owned by database.ConfigFromEnv. // field is the JSON path of Config in a project (usually "server"), or ""
func LoadFromEnv(prefix string) (Config, error) { // for Config itself. Canonical names win over aliases. Both use the same
return applyEnv(Default(), prefix) // EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax.
func ServerEnvAliases(prefix, field string) map[string][]string {
root := prefix
if field != "" {
for _, part := range strings.Split(field, ".") {
root += envName(part) + "_"
}
}
names := map[string]string{
"ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE",
"REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD",
"TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT",
"LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE",
"HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES",
"LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT",
}
for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} {
for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} {
if endpoint == "OPENAPI" && part == "RESPONSE_MODE" {
continue
}
name := "ENDPOINTS_" + endpoint + "_" + part
names[name] = name
}
}
aliases := map[string][]string{}
for key, alias := range names {
if root+key != prefix+alias {
aliases[root+key] = []string{prefix + alias}
}
}
return aliases
} }
// ApplyEnv overlays nonempty environment values on a caller's defaults or file // LoadFromEnv loads defaults using the legacy short-name conventions.
// settings, then validates the result. Caller-owned slices are not retained. func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) }
// ApplyEnv retains legacy whitespace trimming, ignored empty variables and
// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with
// ServerEnvAliases for one explicit empty-value policy across the whole config.
func ApplyEnv(defaults Config, prefix string) (Config, error) { func ApplyEnv(defaults Config, prefix string) (Config, error) {
defaults.Errors = defaults.Errors.Clone()
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
return applyEnv(defaults, prefix)
}
func applyEnv(cfg Config, prefix string) (Config, error) {
prefix = strings.TrimSpace(prefix) prefix = strings.TrimSpace(prefix)
if prefix == "" { if prefix == "" {
prefix = defaultEnvPrefix prefix = defaultEnvPrefix
} }
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address) lookup := func(key string) (string, bool) {
cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport) raw, ok := os.LookupEnv(key)
cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", cfg.TLS.CertFile) raw = strings.TrimSpace(raw)
cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", cfg.TLS.KeyFile) if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader) values := strings.Split(raw, ",")
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" { for i := range values {
cfg.TrustedProxies = strings.Split(value, ",") values[i] = strings.TrimSpace(values[i])
for i := range cfg.TrustedProxies {
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
}
}
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File)
cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service)
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
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 {
name string
value *time.Duration
}{
{"HTTP_READ_HEADER_TIMEOUT", &cfg.HTTP.ReadHeaderTimeout},
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout},
} {
if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
*field.value, err = time.ParseDuration(value)
if err != nil {
return Config{}, fmt.Errorf("%s%s must be a duration: %w", prefix, field.name, err)
} }
data, _ := json.Marshal(values)
raw = string(data)
} }
return raw, ok
} }
if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" { defaults.Errors = defaults.Errors.Clone()
cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value) defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
if err != nil { defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err) cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
} if err != nil {
}
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
return Config{}, err return Config{}, err
} }
if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil { if err = cfg.Validate(); err != nil {
return Config{}, err
}
if value := strings.TrimSpace(os.Getenv(prefix + "SLOW_REQUEST_THRESHOLD")); value != "" {
cfg.SlowRequestThreshold, err = time.ParseDuration(value)
if err != nil {
return Config{}, fmt.Errorf("%sSLOW_REQUEST_THRESHOLD must be a duration: %w", prefix, err)
}
}
if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" {
cfg.ShutdownTimeout, err = time.ParseDuration(value)
if err != nil {
return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err)
}
}
if err := cfg.Validate(); err != nil {
return Config{}, err return Config{}, err
} }
return cfg, nil return cfg, nil
} }
func envString(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envBool(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
}
return parsed, nil
}

View File

@@ -159,7 +159,7 @@ func LoadFile(path, prefix string) (Config, error) {
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
return applyEnv(cfg, prefix) return ApplyEnv(cfg, prefix)
} }
// Reloader only accepts log.level changes. Static changes and invalid files // Reloader only accepts log.level changes. Static changes and invalid files

37
config/lifecycle_test.go Normal file
View File

@@ -0,0 +1,37 @@
package config_test
import (
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"os"
"path/filepath"
"testing"
"time"
)
func TestLifecycleConfiguration(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "PHASE_TEST_")
if err != nil {
t.Fatal(err)
}
if cfg.Lifecycle != (lifecycle.ShutdownConfig{StopTimeout: 2 * time.Second, DrainTimeout: 3 * time.Second, CancelTimeout: 4 * time.Second}) {
t.Fatal(cfg.Lifecycle)
}
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
}
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
t.Fatal(cfg.Lifecycle, err)
}
for _, v := range []string{"bad", "-1s"} {
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
t.Fatal("invalid duration accepted")
}
}
}

View File

@@ -23,6 +23,9 @@ type EnvOptions struct {
Prefix string Prefix string
Empty string Empty string
Lookup func(string) (string, bool) Lookup func(string) (string, bool)
// Aliases maps canonical absolute environment names to ordered fallback names.
// A present canonical value wins, including an explicit empty value.
Aliases map[string][]string
} }
// ApplyDefaults applies envDefault tags to zero fields before file overlays. // ApplyDefaults applies envDefault tags to zero fields before file overlays.
@@ -162,13 +165,22 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
} }
seen[key] = true seen[key] = true
raw, present := options.Lookup(key) raw, present := options.Lookup(key)
selectedKey := key
if !present && !defaults {
for _, alias := range options.Aliases[key] {
if raw, present = options.Lookup(alias); present {
selectedKey = alias
break
}
}
}
if defaults { if defaults {
raw, present = field.Tag.Lookup("envDefault") raw, present = field.Tag.Lookup("envDefault")
present = present && dst.IsZero() present = present && dst.IsZero()
} }
if present && raw == "" && !defaults { if present && raw == "" && !defaults {
if empty == "reject" { if empty == "reject" {
return fmt.Errorf("config: %s must not be empty", key) return fmt.Errorf("config: %s must not be empty", selectedKey)
} }
if empty == "ignore" { if empty == "ignore" {
present = false present = false
@@ -177,7 +189,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
if present { if present {
parsed := reflect.New(dst.Type()).Elem() parsed := reflect.New(dst.Type()).Elem()
if err := parseEnvValue(parsed, raw); err != nil { if err := parseEnvValue(parsed, raw); err != nil {
return fmt.Errorf("config: %s must be a valid %s", key, dst.Type()) return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type())
} }
dst.Set(parsed) dst.Set(parsed)
} }

View File

@@ -2,6 +2,8 @@
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。 结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
使用 GORM 的项目可显式接入 [gormx 可选适配器](gormx/README.md),复用本包连接池,并将 SQL 日志接入 logx;普通 database 包不导入 GORM。
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动: `database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。 - `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。

104
database/gormx/README.md Normal file
View File

@@ -0,0 +1,104 @@
# 可选 GORM 适配器
从 `v0.14.1-preview` 提供。`gormx` 把 GORM 接到 go-omni 已初始化的 `*sql.DB`,保留一份连接池和一处资源所有权。使用 GORM v2 API(本版验收版本为 `gorm.io/gorm v1.31.2`、MySQL dialect `v1.6.0`)。
核心 `omni`、`database`、`database/mysql` 不导入此包;普通项目的编译依赖链不包含 GORM。仓库同一 Go module 的 go.mod 会记录可选适配器依赖,使用方显式导入 `database/gormx` 才编译它。本版没有新增默认 ORM 或 `--orm` 脚手架开关。
## 生成项目接入
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
go-omni new --database mysql --module example.com/billing ./billing
```
按 [MySQL 配置说明](../mysql/README.md) 设置连接信息、TLS 和池大小。在开发者维护的 `internal/svc/serviceContext.go` 中增加 `ORM *gorm.DB` 字段,并填写现有 Init:
```go
import (
"context"
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
"gorm.io/gorm"
)
// 向现有 ServiceContext 添加 ORM *gorm.DB 字段。
// 保留 Runtime/Config/Logger/DB 等现有字段。
func (c *ServiceContext) Init(ctx context.Context) error {
orm, err := gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))
if err != nil {
return err
}
c.ORM = orm
return nil
}
```
执行 `go mod tidy`。main、runtimeGen、Handler 和路由无需修改;重新生成保留 svc 的字段与 Init。已有项目先 `go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview`,同样接入;v0.14.0 生成项目的目录和生命周期契约不变。
Repository 接收 `*gorm.DB`,查询使用请求 context:
```go
err := repo.db.WithContext(ctx).Where("id = ?", id).First(&item).Error
err = repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 同一事务内始终使用 tx。
return tx.Create(&item).Error
})
```
现有模型、Hooks、Scopes、事务、命名策略和错误处理继续由项目维护。共享连接池不会把分别发起的 SQL/GORM 事务自动合并;不要在一个事务内切回根 ORM 或 c.DB 执行本应属于该事务的写入。
## 连接池与失败处理
- MySQL 配置、TLS、字符集、会话参数、池大小及健康检查由底层 `database/mysql` 管理,适配器不再设置另一套 DSN 或池参数。
- GORM 方言的版本协商继续执行,自动 Ping 关闭以复用已完成的池初始化。初始化查询使用传入 context,并受池的 PingTimeout 限制;可用 `WithInitTimeout(3*time.Second)` 单独指定。
- 返回成功后不保留启动 context,每次查询用 `WithContext(ctx)`。查询取消、错误、事务提交/回滚保持 GORM 原生语义。
- 对方言初始化失败时 GORM 自动关闭连接池的路径提供保护;错误仍保留 errors.Is/As,原池继续归调用方管理。生成项目的 Init 返回错误时,再由 Runtime 统一回滚该项目资源。
- 服务排空后由 go-omni 关闭原池;`orm.DB()` 返回的仍是那一个池。不要给它重复登记 Close,也不要让 Repository 关闭它。
适配器自身不创建后台 worker、不自动建表、不执行 AutoMigrate、不改写默认事务或全表更新保护,也不替业务重试写入。
## SQL 日志
默认借用当前 context 的 logx,也可显式 `WithLogger(c.Logger)`。每个 ORM 可以使用独立日志实例;不会改动进程全局 Logger。
```go
sqlLog := gormx.DefaultLogConfig()
sqlLog.Level = "info" // silent / error / warn / info
sqlLog.SlowThreshold = 300*time.Millisecond
orm, err := gormx.MySQL(ctx, c.DB,
gormx.WithLogger(c.Logger),
gormx.WithLogConfig(sqlLog),
gormx.WithConfig(gorm.Config{
TranslateError: true,
// NamingStrategy 等原生选项可按项目添加。
}),
)
```
默认记录错误和超过 200 ms 的慢查询,忽略 ErrRecordNotFound 的错误事件;`SlowThreshold: 0` 关闭慢查询事件。事件为 `gorm.query`、`gorm.slowQuery`、`gorm.queryFailed`,携带 durationMs、rowsAffected、sql 和 context 中的 requestId/traceId。GORM 的 `Debug()`/`LogMode` 使用副本,不改变其他请求的日志级别;最终输出还受 logx 自身级别控制。
默认不插入绑定参数、不记录驱动完整错误文案,只记录错误类别;SQL 正文最多保留 4096 字节,截断后附加省略号。明确开启 `IncludeParameters` / `IncludeErrorDetails` 才输出这些内容。日志不会解析和脱敏直接写在原始 SQL 里的字面量,查询仍应使用占位符。仅需要日志适配的既有 GORM 实例,也可使用 `NewLogger(logger, config)` 返回的原生 `logger.Interface`。
`WithConfig` 中若显式设置 `gorm.Config.Logger`,则使用项目自己的 Logger,该 Logger 自行负责脱敏和上下文。配置和 Plugins/ClauseBuilders 容器会复制;用户自定义插件、NamingStrategy 等对象仍由用户保证并发安全。
## 其他方言与高级配置
MySQL 便捷入口封装了下面的通用入口:
```go
orm, err := gormx.Open(ctx, pool,
func(conn gorm.ConnPool) gorm.Dialector {
return mysql.New(mysql.Config{Conn: conn})
},
gormx.WithLogger(logger),
)
```
其他方言可由项目提供相同形式的工厂,必须使用传入的 Conn,不能创建或替换连接池。本版真实数据库验收覆盖 MySQL 8.4,不代表其他数据库已经联调。
不能通过 WithConfig 的 ConnPool/Dialector 覆盖此约定。初始化期间不暴露裸 sql.DB;需要它的自定义插件应在 Open 成功后显式注册。创建额外池或替换 ConnPool 的路由插件不属于本适配器的一池所有权范围。
PrepareStmt 默认关闭;若显式开启,保留 GORM 的缓存行为,容量、TTL 和缓存清理由项目按 GORM 接口管理。当前固定的 GORM 版本,PreparedStmtDB.Close 只清理语句,不提供停止其缓存清理 goroutine 的公开接口;因此应在服务初始化时创建一次 ORM,避免按请求反复创建。适配器不修改第三方缓存实现,也不宣称解决其生命周期限制。
参考:[GORM 现有连接池](https://gorm.io/docs/connecting_to_the_database.html#Existing-database-connection)、[GORM Logger](https://gorm.io/docs/logger.html)。本版验收见 [GORM 验收报告](../../reports/gormAcceptance.md)。

121
database/gormx/gorm.go Normal file
View File

@@ -0,0 +1,121 @@
// Package gormx optionally attaches GORM to a pool owned by GoOmni. It never
// creates a pool, changes pool limits, migrates tables or closes the owner.
package gormx
import (
"context"
"errors"
"maps"
"reflect"
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
)
// DialectorFactory must use the supplied connection pool. It must not create a
// pool or retain/use the ORM before Open succeeds. For example, return
// mysql.New(mysql.Config{Conn: conn}). Other dialects can use the same contract.
type DialectorFactory func(conn gorm.ConnPool) gorm.Dialector
type options struct {
config gorm.Config
logger *logx.Logger
logConfig LogConfig
timeout time.Duration
}
type Option func(*options)
// WithConfig preserves native GORM behavior, including naming, hooks, prepared
// statements and transactions. ConnPool/Dialector are supplied by this adapter;
// automatic Ping is disabled because the owning pool already passed startup Ping.
// A custom Config.Logger takes precedence over the logx adapter.
func WithConfig(config gorm.Config) Option { return func(o *options) { o.config = config } }
func WithLogger(logger *logx.Logger) Option { return func(o *options) { o.logger = logger } }
func WithLogConfig(config LogConfig) Option { return func(o *options) { o.logConfig = config } }
func WithInitTimeout(timeout time.Duration) Option { return func(o *options) { o.timeout = timeout } }
// Error exposes a safe initialization summary while preserving errors.Is/As.
type Error struct{ cause error }
func (e *Error) Error() string { return "gormx: initialization failed" }
func (e *Error) Unwrap() error { return e.cause }
// MySQL attaches GORM's MySQL dialect, including version negotiation, to the
// existing pool. Configure TLS, timeouts and connection limits on that pool.
func MySQL(ctx context.Context, pool *database.DB, opts ...Option) (*gorm.DB, error) {
return Open(ctx, pool, func(conn gorm.ConnPool) gorm.Dialector {
return gormmysql.New(gormmysql.Config{Conn: conn})
}, opts...)
}
// Open borrows pool. On failure (including dialect initialization failure),
// the original pool remains owned by the caller. No ORM Close is required.
// The returned ORM does not retain the startup context: each request should use
// WithContext(ctx). Never close the sql.DB returned by orm.DB() independently.
func Open(ctx context.Context, pool *database.DB, factory DialectorFactory, opts ...Option) (*gorm.DB, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, &Error{cause: err}
}
if pool == nil || pool.DB == nil || factory == nil {
return nil, errors.New("gormx: pool and dialector factory are required")
}
timeout := pool.PoolConfig().PingTimeout
if timeout == 0 {
timeout = 5 * time.Second
}
o := options{timeout: timeout, logConfig: DefaultLogConfig(), logger: logx.FromContext(ctx)}
for _, option := range opts {
if option != nil {
option(&o)
}
}
if o.timeout <= 0 {
return nil, errors.New("gormx: initialization timeout must be positive")
}
if o.config.ConnPool != nil || o.config.Dialector != nil {
return nil, errors.New("gormx: configure the existing pool through the dialector factory")
}
logger, err := NewLogger(o.logger, o.logConfig)
if err != nil {
return nil, err
}
config := o.config
config.Plugins = maps.Clone(config.Plugins)
config.ClauseBuilders = maps.Clone(config.ClauseBuilders)
if config.Logger == nil {
config.Logger = logger
}
config.DisableAutomaticPing = true
startup, cancel := context.WithTimeout(ctx, o.timeout)
defer cancel()
borrowed := &borrowedPool{db: pool.DB}
borrowed.startup.Store(&startupContext{startup})
config.ConnPool = borrowed
dialect := factory(borrowed)
if dialect == nil || (reflect.ValueOf(dialect).Kind() == reflect.Pointer && reflect.ValueOf(dialect).IsNil()) {
return nil, errors.New("gormx: dialector factory returned nil")
}
orm, err := gorm.Open(dialect, &config)
if err != nil {
return nil, &Error{cause: err}
}
if err = startup.Err(); err != nil {
return nil, &Error{cause: err}
}
// Prepared statements are GORM's own wrapper over the very same pool.
conn := orm.ConnPool
if prepared, ok := conn.(*gorm.PreparedStmtDB); ok {
conn = prepared.ConnPool
}
if actual, ok := conn.(*borrowedPool); !ok || actual != borrowed {
return nil, errors.New("gormx: dialector or plugin replaced the supplied pool")
}
borrowed.startup.Store(nil)
return orm, nil
}

173
database/gormx/gorm_test.go Normal file
View File

@@ -0,0 +1,173 @@
package gormx
import (
"context"
"database/sql/driver"
"errors"
"io"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
type testConnector struct {
closed atomic.Int32
query func(context.Context) error
}
func (c *testConnector) Connect(context.Context) (driver.Conn, error) {
return &testConn{owner: c}, nil
}
func (c *testConnector) Driver() driver.Driver { return testDriver{} }
func (c *testConnector) Close() error { c.closed.Add(1); return nil }
type testDriver struct{}
func (testDriver) Open(string) (driver.Conn, error) { return nil, errors.New("unexpected new pool") }
type testConn struct{ owner *testConnector }
func (c *testConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("unused") }
func (c *testConn) Close() error { return nil }
func (c *testConn) Begin() (driver.Tx, error) { return nil, errors.New("unused") }
func (c *testConn) Ping(context.Context) error { return nil }
func (c *testConn) QueryContext(ctx context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) {
if c.owner.query != nil {
if err := c.owner.query(ctx); err != nil {
return nil, err
}
}
return &testRows{}, nil
}
type testRows struct{ done bool }
func (r *testRows) Columns() []string { return []string{"version"} }
func (r *testRows) Close() error { return nil }
func (r *testRows) Next(values []driver.Value) error {
if r.done {
return io.EOF
}
r.done = true
values[0] = "8.4.0"
return nil
}
func poolFixture(t *testing.T) (*database.DB, *testConnector) {
t.Helper()
connector := &testConnector{}
pool, err := database.OpenConnector(context.Background(), connector, database.DefaultPoolConfig())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pool.Close() })
return pool, connector
}
func TestBorrowedPoolFailureAndStartupDeadline(t *testing.T) {
for _, mode := range []string{"driverError", "deadline", "canceled"} {
t.Run(mode, func(t *testing.T) {
pool, connector := poolFixture(t)
cause := errors.New("private-driver-error")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
connector.query = func(ctx context.Context) error {
if mode == "driverError" {
return cause
}
<-ctx.Done()
return ctx.Err()
}
if mode == "canceled" {
cancel()
cause = context.Canceled
} else if mode == "deadline" {
cause = context.DeadlineExceeded
}
start := time.Now()
orm, err := MySQL(ctx, pool, WithInitTimeout(20*time.Millisecond))
if orm != nil || !errors.Is(err, cause) {
t.Fatal("lost cause", err)
}
if time.Since(start) > time.Second {
t.Fatal("startup deadline ignored")
}
if connector.closed.Load() != 0 {
t.Fatal("borrowed pool closed after GORM failure")
}
if healthErr := pool.Health(context.Background()); healthErr != nil {
t.Fatal("owner pool unusable", healthErr)
}
if err.Error() == cause.Error() {
t.Fatal("unsafe initialization error")
}
})
}
}
func TestPoolIdentityNativeOptionsAndRequestContext(t *testing.T) {
pool, connector := poolFixture(t)
ctx, cancel := context.WithCancel(context.Background())
cfg := gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "business_"}, ClauseBuilders: map[string]clause.ClauseBuilder{}}
logger, _ := logx.New(logx.Config{Output: "discard"})
orm, err := MySQL(ctx, pool, WithConfig(cfg), WithLogger(logger))
if err != nil {
t.Fatal(err)
}
cancel() // The startup context must not poison future queries.
got, err := orm.DB()
if err != nil || got != pool.DB {
t.Fatal("pool identity changed", err)
}
if len(cfg.ClauseBuilders) != 0 || cfg.ConnPool != nil || cfg.Logger != nil {
t.Fatal("native input config mutated")
}
if orm.NamingStrategy.TableName("Item") != "business_items" {
t.Fatal("native option lost")
}
if orm.SkipDefaultTransaction || orm.AllowGlobalUpdate {
t.Fatal("transaction safety defaults changed")
}
ctx, cancel = context.WithCancel(context.Background())
cancel()
var v string
if err = orm.WithContext(ctx).Raw("SELECT VERSION()").Scan(&v).Error; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if err = orm.WithContext(context.Background()).Raw("SELECT VERSION()").Scan(&v).Error; err != nil || v != "8.4.0" {
t.Fatal(v, err)
}
if connector.closed.Load() != 0 {
t.Fatal("pool unexpectedly closed")
}
if err = pool.Close(); err != nil {
t.Fatal(err)
}
if err = orm.Raw("SELECT VERSION()").Scan(&v).Error; err == nil {
t.Fatal("owner close did not affect GORM")
}
}
func TestRejectedOptionsDoNotTouchPool(t *testing.T) {
for _, option := range []Option{WithInitTimeout(0), WithLogConfig(LogConfig{Level: "bad"}), WithConfig(gorm.Config{ConnPool: &borrowedPool{}})} {
pool, connector := poolFixture(t)
if _, err := MySQL(context.Background(), pool, option); err == nil {
t.Fatal("invalid option accepted")
}
if connector.closed.Load() != 0 {
t.Fatal("pool closed")
}
}
if _, err := MySQL(nil, nil); err == nil {
t.Fatal("nil pool accepted")
}
pool, _ := poolFixture(t)
if _, err := Open(nil, pool, func(gorm.ConnPool) gorm.Dialector { return nil }); err == nil {
t.Fatal("nil dialect accepted")
}
}

View File

@@ -0,0 +1,157 @@
package gormx_test
import (
"bytes"
"context"
"errors"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
adapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gorm.io/gorm"
)
type record struct {
ID int64 `gorm:"primaryKey;autoIncrement:false"`
Name string
}
func (record) TableName() string { return "gorm_adapter_records" }
func TestGORMMySQLTransactionsCancellationAndLogs(t *testing.T) {
if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" {
t.Skip("use scripts/verifyConnector.py --gorm with isolated MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cfg := adapter.DefaultConfig()
host, port, err := net.SplitHostPort(os.Getenv("GO_OMNI_CONNECTOR_ADDR"))
if err != nil {
t.Fatal(err)
}
cfg.Host = host
cfg.Port, _ = strconv.Atoi(port)
cfg.User = os.Getenv("GO_OMNI_CONNECTOR_USER")
cfg.Password = os.Getenv("GO_OMNI_CONNECTOR_PASSWORD")
cfg.Database = os.Getenv("GO_OMNI_CONNECTOR_DATABASE")
cfg.TLS.CAFile = os.Getenv("GO_OMNI_CONNECTOR_CA_FILE")
cfg.TLS.ServerName = "localhost"
cfg.Pool.MaxOpenConns = 2
cfg.Pool.MaxIdleConns = 2
pool, err := adapter.Open(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
var logs bytes.Buffer
logger, err := logx.New(logx.Config{Writer: &logs})
if err != nil {
t.Fatal(err)
}
defer logger.Close()
logConfig := gormx.DefaultLogConfig()
logConfig.Level = "info"
orm, err := gormx.MySQL(ctx, pool, gormx.WithLogger(logger), gormx.WithLogConfig(logConfig), gormx.WithConfig(gorm.Config{PrepareStmt: true, TranslateError: true}))
if err != nil {
t.Fatal(err)
}
sqlDB, err := orm.DB()
if err != nil || sqlDB != pool.DB {
t.Fatal("pool not shared", err)
}
if sqlDB.Stats().MaxOpenConnections != 2 {
t.Fatal("pool limits changed")
}
var tables int
if err = pool.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'gorm_adapter_records'").Scan(&tables); err != nil || tables != 0 {
t.Fatal("adapter must not migrate schema", err)
}
if _, err = pool.ExecContext(ctx, "CREATE TABLE gorm_adapter_records (id BIGINT PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL)"); err != nil {
t.Fatal(err)
}
defer pool.ExecContext(context.Background(), "DROP TABLE gorm_adapter_records")
requestCtx := logx.WithRequestID(ctx, "gorm-request")
orm = orm.WithContext(requestCtx)
secret := "private-bound-name"
if err = orm.Transaction(func(tx *gorm.DB) error { return tx.Create(&record{ID: 1, Name: secret}).Error }); err != nil {
t.Fatal(err)
}
wanted := errors.New("rollback sentinel")
if err = orm.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&record{ID: 2, Name: "rollback"}).Error; e != nil {
return e
}
return wanted
}); !errors.Is(err, wanted) {
t.Fatal(err)
}
func() {
defer func() {
if recover() != "rollback panic" {
t.Error("panic changed")
}
}()
_ = orm.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&record{ID: 3, Name: "panic"}).Error; e != nil {
t.Fatal(e)
}
panic("rollback panic")
})
}()
var count int64
if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 1 {
t.Fatal("commit/rollback differs", count, err)
}
if err = orm.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&record{ID: 4, Name: "outer"}).Error; e != nil {
return e
}
if e := tx.Transaction(func(inner *gorm.DB) error {
if e := inner.Create(&record{ID: 5, Name: "inner"}).Error; e != nil {
return e
}
return wanted
}); !errors.Is(e, wanted) {
return errors.New("nested rollback failed")
}
return nil
}); err != nil {
t.Fatal(err)
}
if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 2 {
t.Fatal(count, err)
}
if err = orm.Create(&record{ID: 6, Name: secret}).Error; !errors.Is(err, gorm.ErrDuplicatedKey) {
t.Fatal("native error translation lost", err)
}
var missing record
if err = orm.First(&missing, "id = ?", 999).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("not-found semantics changed", err)
}
deadline, stop := context.WithTimeout(requestCtx, 30*time.Millisecond)
start := time.Now()
err = orm.WithContext(deadline).Exec("SELECT SLEEP(3)").Error
stop()
if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second {
t.Fatal("query cancellation lost", err)
}
if err = pool.Health(ctx); err != nil {
t.Fatal("pool did not recover", err)
}
if strings.Contains(logs.String(), secret) || !strings.Contains(logs.String(), `"requestId":"gorm-request"`) || !strings.Contains(logs.String(), "gorm.queryFailed") {
t.Fatal("SQL log privacy/correlation differs")
}
var cipher struct {
VariableName string
Value string
}
if err = orm.Raw("SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&cipher).Error; err != nil || cipher.Value == "" {
t.Fatal("GORM bypassed TLS pool", err)
}
}

171
database/gormx/logger.go Normal file
View File

@@ -0,0 +1,171 @@
package gormx
import (
"context"
"errors"
"fmt"
"time"
"unicode/utf8"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gorm.io/gorm"
gormlog "gorm.io/gorm/logger"
)
type LogConfig struct {
Level string `json:"level"` // silent, error, warn (default), info
SlowThreshold time.Duration `json:"slowThreshold"` // zero disables slow-query events
IgnoreRecordNotFound bool `json:"ignoreRecordNotFound"`
IncludeParameters bool `json:"includeParameters"`
IncludeErrorDetails bool `json:"includeErrorDetails"`
MaxSQLBytes int `json:"maxSqlBytes"` // zero uses 4096; bounds emitted SQL text
}
func DefaultLogConfig() LogConfig {
return LogConfig{Level: "warn", SlowThreshold: 200 * time.Millisecond, IgnoreRecordNotFound: true, MaxSQLBytes: 4096}
}
func (c LogConfig) Validate() error {
switch c.Level {
case "", "silent", "error", "warn", "info":
default:
return errors.New("gormx: invalid SQL log level")
}
if c.SlowThreshold < 0 || c.MaxSQLBytes < 0 {
return errors.New("gormx: SQL log limits cannot be negative")
}
return nil
}
type sqlLogger struct {
logger *logx.Logger
config LogConfig
level gormlog.LogLevel
}
// NewLogger adapts logx without changing its level, globals or ownership.
// Bound SQL arguments and raw driver error text are omitted by default. SQL
// literals written directly into a query are not parsed or redacted.
func NewLogger(logger *logx.Logger, config LogConfig) (gormlog.Interface, error) {
if err := config.Validate(); err != nil {
return nil, err
}
if config.MaxSQLBytes == 0 {
config.MaxSQLBytes = 4096
}
level := gormlog.Warn
switch config.Level {
case "silent":
level = gormlog.Silent
case "error":
level = gormlog.Error
case "info":
level = gormlog.Info
}
return &sqlLogger{logger: logger, config: config, level: level}, nil
}
func (l *sqlLogger) LogMode(level gormlog.LogLevel) gormlog.Interface {
copy := *l
if level < gormlog.Silent || level > gormlog.Info {
level = gormlog.Silent
}
copy.level = level
return &copy
}
func (l *sqlLogger) output(ctx context.Context) *logx.Logger {
if l.logger != nil {
return l.logger
}
return logx.FromContext(ctx)
}
func (l *sqlLogger) message(msg string, args []any) string {
if !l.config.IncludeErrorDetails {
copy := append([]any(nil), args...)
for i, v := range copy {
if e, ok := v.(error); ok {
copy[i] = errorKind(e)
}
}
args = copy
}
return clip(fmt.Sprintf(msg, args...), l.config.MaxSQLBytes)
}
func (l *sqlLogger) Info(ctx context.Context, msg string, args ...any) {
if l.level >= gormlog.Info {
l.output(ctx).InfoContext(ctx, "gorm.message", "message", l.message(msg, args))
}
}
func (l *sqlLogger) Warn(ctx context.Context, msg string, args ...any) {
if l.level >= gormlog.Warn {
l.output(ctx).WarnContext(ctx, "gorm.message", "message", l.message(msg, args))
}
}
func (l *sqlLogger) Error(ctx context.Context, msg string, args ...any) {
if l.level >= gormlog.Error {
l.output(ctx).ErrorContext(ctx, "gorm.message", "message", l.message(msg, args))
}
}
func (l *sqlLogger) ParamsFilter(_ context.Context, query string, args ...any) (string, []any) {
if !l.config.IncludeParameters {
return query, nil
}
return query, args
}
func (l *sqlLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.level == gormlog.Silent {
return
}
elapsed := time.Since(begin)
failure := err != nil && !(l.config.IgnoreRecordNotFound && errors.Is(err, gorm.ErrRecordNotFound))
slow := l.config.SlowThreshold > 0 && elapsed > l.config.SlowThreshold
level := gormlog.Info
if failure && l.level >= gormlog.Error {
level = gormlog.Error
} else if slow && l.level >= gormlog.Warn {
level = gormlog.Warn
} else if l.level < gormlog.Info {
return
}
query, rows := fc()
attrs := []any{"durationMs", float64(elapsed) / float64(time.Millisecond), "rowsAffected", rows, "sql", clip(query, l.config.MaxSQLBytes)}
if level == gormlog.Error {
attrs = append(attrs, "errorKind", errorKind(err))
if l.config.IncludeErrorDetails {
attrs = append(attrs, "error", clip(err.Error(), l.config.MaxSQLBytes))
}
}
logger := l.output(ctx)
switch level {
case gormlog.Error:
logger.ErrorContext(ctx, "gorm.queryFailed", attrs...)
case gormlog.Warn:
logger.WarnContext(ctx, "gorm.slowQuery", attrs...)
default:
logger.InfoContext(ctx, "gorm.query", attrs...)
}
}
func errorKind(err error) string {
switch {
case errors.Is(err, context.Canceled):
return "canceled"
case errors.Is(err, context.DeadlineExceeded):
return "deadlineExceeded"
case errors.Is(err, gorm.ErrRecordNotFound):
return "recordNotFound"
default:
return fmt.Sprintf("%T", err)
}
}
func clip(value string, limit int) string {
if len(value) <= limit {
return value
}
value = value[:limit]
for !utf8.ValidString(value) && len(value) > 0 {
value = value[:len(value)-1]
}
return value + "…"
}
var _ gormlog.Interface = (*sqlLogger)(nil)
var _ gorm.ParamsFilter = (*sqlLogger)(nil)

View File

@@ -0,0 +1,92 @@
package gormx
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gorm.io/gorm"
gormlog "gorm.io/gorm/logger"
)
func TestSQLLoggerLevelsPrivacyAndCorrelation(t *testing.T) {
var out bytes.Buffer
base, _ := logx.New(logx.Config{Writer: &out})
l, _ := NewLogger(base, DefaultLogConfig())
ctx := logx.WithRequestID(logx.WithTraceID(context.Background(), "trace-1"), "request-1")
count := 0
fc := func() (string, int64) { count++; return "SELECT * FROM records WHERE token = ?", 1 }
l.Trace(ctx, time.Now(), fc, nil)
l.Trace(ctx, time.Now(), fc, fmt.Errorf("wrapped: %w", gorm.ErrRecordNotFound))
l.LogMode(gormlog.Silent).Trace(ctx, time.Now(), fc, errors.New("password-value"))
if count != 0 {
t.Fatal("ignored event evaluated SQL")
}
l.Trace(ctx, time.Now().Add(-time.Second), fc, nil)
l.Trace(ctx, time.Now(), fc, errors.New("password-value"))
l.Error(ctx, "failed: %v", errors.New("password-value"))
l.LogMode(gormlog.Info).Trace(ctx, time.Now(), fc, nil)
if count != 3 {
t.Fatal(count)
}
if strings.Contains(out.String(), "password-value") {
t.Fatal("driver detail leaked", out.String())
}
for _, text := range []string{"gorm.slowQuery", "gorm.queryFailed", "gorm.query", `"requestId":"request-1"`, `"traceId":"trace-1"`} {
if !strings.Contains(out.String(), text) {
t.Fatal(text, out.String())
}
}
q, args := l.(gorm.ParamsFilter).ParamsFilter(ctx, "SELECT ?", "private")
if q != "SELECT ?" || len(args) != 0 {
t.Fatal("default SQL arguments exposed")
}
// LogMode clones rather than mutating the shared instance.
l.Trace(ctx, time.Now(), fc, nil)
if count != 3 {
t.Fatal("session mode changed root logger")
}
}
func TestSQLLoggerExplicitDetailsAndConcurrentModes(t *testing.T) {
var out bytes.Buffer
base, _ := logx.New(logx.Config{Writer: &out})
cfg := DefaultLogConfig()
cfg.IncludeParameters = true
cfg.IncludeErrorDetails = true
cfg.Level = "info"
cfg.MaxSQLBytes = 8
l, err := NewLogger(base, cfg)
if err != nil {
t.Fatal(err)
}
_, args := l.(gorm.ParamsFilter).ParamsFilter(context.Background(), "SELECT ?", "value")
if len(args) != 1 {
t.Fatal("explicit arguments dropped")
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
l.LogMode(gormlog.Error).Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 中文标识符", -1 }, errors.New("failure"))
}()
}
wg.Wait()
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
var event map[string]any
if err = json.Unmarshal([]byte(line), &event); err != nil {
t.Fatal(err)
}
if event["error"] != "failure" || strings.Contains(event["sql"].(string), "<22>") {
t.Fatal(event)
}
}
}

52
database/gormx/pool.go Normal file
View File

@@ -0,0 +1,52 @@
package gormx
import (
"context"
"database/sql"
"sync/atomic"
"gorm.io/gorm"
)
// GORM can call DB().Close on failed initialization. Do not expose the owner's
// sql.DB until initialization has succeeded; SQL methods remain fully usable.
// Dialects using context.Background during version probing are bounded by the
// startup context, which stops affecting queries as soon as Open succeeds.
type borrowedPool struct {
db *sql.DB
startup atomic.Pointer[startupContext]
}
type startupContext struct{ context.Context }
func (p *borrowedPool) operationContext(ctx context.Context) context.Context {
if startup := p.startup.Load(); startup != nil {
return startup.Context
}
return ctx
}
func (p *borrowedPool) GetDBConn() (*sql.DB, error) {
if p.startup.Load() != nil {
return nil, gorm.ErrInvalidDB
}
return p.db, nil
}
func (p *borrowedPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
return p.db.PrepareContext(p.operationContext(ctx), query)
}
func (p *borrowedPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return p.db.ExecContext(p.operationContext(ctx), query, args...)
}
func (p *borrowedPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return p.db.QueryContext(p.operationContext(ctx), query, args...)
}
func (p *borrowedPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return p.db.QueryRowContext(p.operationContext(ctx), query, args...)
}
func (p *borrowedPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
return p.db.BeginTx(p.operationContext(ctx), opts)
}
var _ gorm.ConnPool = (*borrowedPool)(nil)
var _ gorm.TxBeginner = (*borrowedPool)(nil)
var _ gorm.GetDBConnector = (*borrowedPool)(nil)

View File

@@ -2,6 +2,8 @@
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。 本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
项目使用 GORM 时,在 svc.Init 中调用 [gormx.MySQL](../gormx/README.md) 即可复用这个池、TLS 和清理流程。
生成项目选择 `--database mysql` 即接入: 生成项目选择 `--database mysql` 即接入:
```sh ```sh

View File

@@ -0,0 +1,23 @@
syntax = "v1"
@app (defaultResponse: Common)
response Common (
codeType: int32
successCode: 0
errorCode: 9999
messageField: msg
dataMode: always
)
@server (group: arrays)
service arrays {
@handler strings
get /strings returns ([]string)
@handler integers
get /integers returns ([]int64)
@handler dynamic
get /dynamic returns ([]any)
@handler empty
get /empty returns ([]any)
@handler raw
@response raw
get /raw returns ([]bool)
}

View File

@@ -1,5 +1,5 @@
{ {
"templateVersion": "v0.14.0-preview", "templateVersion": "v0.16.0-preview",
"profile": { "profile": {
"database": "none", "database": "none",
"storage": "none", "storage": "none",
@@ -10,17 +10,17 @@
"module": "example.com/managed", "module": "example.com/managed",
"entry": "api/managed.api", "entry": "api/managed.api",
"generated": { "generated": {
"docs/openapi.json": "68c7e9e0294bf62937aa953a44732288c51571108c922a36a72faa72c4360b5f", "docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d", "internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375", "internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
"internal/handler/openapiGen.go": "ae9b794c63e0f3c8f9d200a5fc6977350139d4a6f00b42a26102c8630d907d03", "internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
"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/svc/runtimeGen.go": "78ffd0b2a196fbc29e29473919d1341e8a2f508f6594ad148c0b399fbcc40018", "internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344", "internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1", "internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
"main.go": "8a4051c0d51fc14ddf22c36d09de706f02499817accc937f0785e3d0fb5e2aa5" "main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
}, },
"contract": { "contract": {
"routes": { "routes": {

File diff suppressed because it is too large Load Diff

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.14.0-preview require git.yuntuo.hk/k2safe/go-omni v0.16.0-preview
require ( require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -4,7 +4,6 @@ import (
"git.yuntuo.hk/k2safe/go-omni/config" "git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"os" "os"
"strings"
"time" "time"
) )
@@ -38,16 +37,19 @@ func Load(path string) (Config, error) {
return Config{}, err return Config{}, err
} }
} }
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_"}) cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: config.ServerEnvAliases("GO_OMNI_", "server")})
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc). output, outputSet := os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT")
cfg.Server, err = config.ApplyEnv(cfg.Server, "") if !outputSet {
if err != nil { output = os.Getenv("GO_OMNI_LOG_OUTPUT")
return Config{}, err
} }
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" { file, fileSet := os.LookupEnv("GO_OMNI_SERVER_LOG_FILE")
if !fileSet {
file = os.Getenv("GO_OMNI_LOG_FILE")
}
if output != "" && file == "" {
cfg.Server.Log.File = "" cfg.Server.Log.File = ""
cfg.Server.Log.Rotation = logx.RotationConfig{} cfg.Server.Log.Rotation = logx.RotationConfig{}
} }

File diff suppressed because one or more lines are too long

View File

@@ -37,7 +37,11 @@ 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(), Runtime: lifecycle.NewRuntime(ctx)} runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Server.Lifecycle)
if err != nil {
return nil, err
}
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: runtime}
ctx = logx.WithLogger(c.Context(), logger) ctx = logx.WithLogger(c.Context(), logger)
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {

View File

@@ -8,6 +8,7 @@ import (
"example.com/managed/internal/handler" "example.com/managed/internal/handler"
"example.com/managed/internal/svc" "example.com/managed/internal/svc"
"flag" "flag"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"log" "log"
"os" "os"
@@ -33,7 +34,13 @@ func run(ctx context.Context, path string) (runErr error) {
if err != nil { if err != nil {
return err return err
} }
defer func() { runErr = errors.Join(runErr, logger.Close()) }() // Unjoined background work may still log. The owning process exits on this
// error; do not close its borrowed logger before that work has finished.
defer func() {
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
runErr = errors.Join(runErr, logger.Close())
}
}()
deps, err := svc.New(ctx, logger, cfg) deps, err := svc.New(ctx, logger, cfg)
if err != nil { if err != nil {
return err return err

4
go.mod
View File

@@ -19,6 +19,8 @@ require (
golang.org/x/net v0.33.0 golang.org/x/net v0.33.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
) )
require ( require (
@@ -36,6 +38,8 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect

12
go.sum
View File

@@ -67,6 +67,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -76,6 +80,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA= github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
@@ -134,3 +140,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -1,5 +1,67 @@
# lifecycle # lifecycle
## 后台组件分阶段关闭
从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。
在项目维护的 svc.Init 中登记即可,main/runtimeGen 由生成器更新。例如使用可选的内存 task.Runner:
```go
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128})
if err != nil { return err }
c.OnStop("jobs", jobs.StopAccepting)
c.OnDrain("jobs", jobs.Wait)
// 把 jobs 保存到项目的 svc 字段,供 Logic 使用。
// 已接受任务使用 c.WorkContext(),保留到 HTTP 和后台正常排空结束。
```
数据库、Redis 或第三方客户端仍用已有的 `c.OnClose(client.Close)`;框架创建的 DB 已登记,不要再次登记。这里的 jobs 不再额外登记 Close:StopAccepting 停止定时调度/提交,Wait 等待 worker 与回调全部退出,已完成其生命周期。
任何自定义组件都可以登记 `func(context.Context) error`,不必实现或依赖框架任务接口:
- OnStop 只停止接收新工作,不能关闭正在执行的工作所需的资源。每个 OnStop 必须有同名 OnDrain;缺失时拒绝释放共享资源。
- OnDrain 返回 nil 表示已经收尾,包含最终写入、确认和监控协程退出。返回错误不作为已完成证明;运行中的业务错误可以先通过 RequestShutdown(err) 上报,再等完成后由 Drain 返回 nil。
- 同一阶段的回调并发执行,不按登记顺序执行;框架等待整个阶段结束后才进入下一阶段。有组件之间的先后依赖时,组合为一个 Stop/Drain 登记项,由该项明确组织内部顺序。Stop 不能等待 Drain 才会完成的工作。
- OnClose 保留原来的同步逆序执行;普通项目无后台组件时无需登记新钩子。生命周期钩子不能再次调用 Runtime.Close/Finish,否则会等待自身;可以调用 RequestShutdown。
- 所有依赖初始化成功后立即登记,再启动使用它们的工作。构造器交付前失败的内部资源仍由构造器释放。
### 两种 context
`c.Context()` / `c.Lifecycle.Context()` 是早期退出通知,会随外部信号或后台故障取消。`c.WorkContext()` 保留初始化 context 的值但不继承其取消/截止时间,允许已接受工作在 HTTP 收尾期间继续使用依赖。
停止接收工作由 OnStop 负责,不能仅等待 WorkContext 取消。正常情况下 WorkContext 在后台排空完成后取消;阶段失败或超时会请求取消工作。框架不假设取消已经意味着 goroutine 结束,仍以 Drain 的完成为准。每个业务任务的执行时限继续由项目/任务组件设置。
### 时限与失败
```yaml
server:
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
```
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。可用 `GO_OMNI_LIFECYCLE_STOP_TIMEOUT`、`GO_OMNI_LIFECYCLE_DRAIN_TIMEOUT`、`GO_OMNI_LIFECYCLE_CANCEL_TIMEOUT` 覆盖;生成配置还支持完整的 `GO_OMNI_SERVER_LIFECYCLE_*` 路径,别名优先。配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;其他组件仍可继续收尾。此时共享资源始终保留,避免未退出的 Stop 和 Drain 同时操作同一个组件。
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
### 升级
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
要获得后台分阶段保证,项目需把原先 `OnClose(worker.Close)` 的后台组件改成对应 Stop/Drain,并使用适合的工作 context;框架不会猜测旧 Close 回调负责数据库还是 worker。仅在 OnClose 中登记的旧任务继续遵循旧的逆序清理规则。
## 退出通知
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。 Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
手写服务也可组合现有 App: 手写服务也可组合现有 App:

184
lifecycle/phases.go Normal file
View File

@@ -0,0 +1,184 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode"
)
// ErrResourcesRetained means background quiescence could not be established.
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
var ErrResourcesRetained = errors.New("lifecycle: background shutdown incomplete; shared resources retained")
type ShutdownConfig struct {
StopTimeout time.Duration `json:"stopTimeout"`
DrainTimeout time.Duration `json:"drainTimeout"`
CancelTimeout time.Duration `json:"cancelTimeout"`
}
func DefaultShutdownConfig() ShutdownConfig {
return ShutdownConfig{StopTimeout: 5 * time.Second, DrainTimeout: 10 * time.Second, CancelTimeout: 5 * time.Second}
}
func (c ShutdownConfig) Validate() error {
if c.StopTimeout < 0 || c.DrainTimeout < 0 || c.CancelTimeout < 0 {
return errors.New("lifecycle: shutdown timeouts must be nonnegative")
}
return nil
}
func (c ShutdownConfig) defaults() ShutdownConfig {
d := DefaultShutdownConfig()
if c.StopTimeout == 0 {
c.StopTimeout = d.StopTimeout
}
if c.DrainTimeout == 0 {
c.DrainTimeout = d.DrainTimeout
}
if c.CancelTimeout == 0 {
c.CancelTimeout = d.CancelTimeout
}
return c
}
// HookError identifies the failed phase/component and preserves the original error.
type HookError struct {
Phase, Name string
Err error
}
func (e *HookError) Error() string { return fmt.Sprintf("lifecycle %s %q: %v", e.Phase, e.Name, e.Err) }
func (e *HookError) Unwrap() error { return e.Err }
// phaseError keeps diagnostics separate from proof that every hook succeeded.
// A deadline is still reported when all callbacks finish during cancellation grace.
type phaseError struct {
err error
unsafe bool
unjoined map[string]bool
}
func (e *phaseError) Error() string { return e.err.Error() }
func (e *phaseError) Unwrap() error { return e.err }
func unsafePhase(err error) bool { var p *phaseError; return errors.As(err, &p) && p.unsafe }
type shutdownHook struct {
name string
fn func(context.Context) error
}
// OnStop registers a prompt, idempotent stop-admission callback. It must not
// close resources used by accepted work. All stop hooks precede all drain hooks.
// Hooks within a phase run concurrently: combine dependent components in one hook.
// Every OnStop requires a matching OnDrain with the same name.
func (r *Runtime) OnStop(name string, fn func(context.Context) error) { r.addHook(&r.stops, name, fn) }
// OnDrain registers proof of quiescence. Return nil only after the component can
// no longer use shared resources, including final acknowledgements/cleanup.
// Deadline cancellation asks the component to stop; returning ctx.Err alone does
// not prove completion. A callback may finish during the cancellation grace.
func (r *Runtime) OnDrain(name string, fn func(context.Context) error) {
r.addHook(&r.drains, name, fn)
}
func (r *Runtime) addHook(list *[]shutdownHook, name string, fn func(context.Context) error) {
if fn == nil || name == "" || len(name) > 128 || strings.TrimSpace(name) != name || strings.IndexFunc(name, unicode.IsControl) >= 0 {
panic("lifecycle: valid hook name and function required")
}
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
panic("lifecycle: register hook after Close")
}
for _, h := range *list {
if h.name == name {
panic("lifecycle: duplicate phase hook name")
}
}
*list = append(*list, shutdownHook{name, fn})
}
func callHook(ctx context.Context, h shutdownHook) (err error) {
defer func() {
if p := recover(); p != nil {
if cause, ok := p.(error); ok {
err = fmt.Errorf("hook panic: %w", cause)
} else {
err = fmt.Errorf("hook panic: %v", p)
}
}
}()
return h.fn(ctx)
}
func (r *Runtime) runPhase(phase string, hooks []shutdownHook, timeout time.Duration) error {
if len(hooks) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.work), timeout)
defer cancel()
type result struct {
index int
err error
}
results := make(chan result, len(hooks))
pending := make([]bool, len(hooks))
for i, h := range hooks {
pending[i] = true
go func(i int, h shutdownHook) { results <- result{i, callHook(ctx, h)} }(i, h)
}
remaining := len(hooks)
var errs error
unsafe := false
accept := func(v result) {
pending[v.index] = false
remaining--
if v.err != nil {
unsafe = true
errs = errors.Join(errs, &HookError{phase, hooks[v.index].name, v.err})
r.cancelWork()
}
}
for remaining > 0 {
select {
case v := <-results:
accept(v)
case <-ctx.Done():
r.cancelWork()
errs = errors.Join(errs, &HookError{phase, "deadline", ctx.Err()})
grace := time.NewTimer(r.shutdown.CancelTimeout)
defer grace.Stop()
for remaining > 0 {
select {
case v := <-results:
accept(v)
case <-grace.C:
// Collect callbacks already returned at the deadline before marking pending ones.
for {
select {
case v := <-results:
accept(v)
default:
goto collected
}
}
collected:
unjoined := make(map[string]bool)
for i, active := range pending {
if active {
unjoined[hooks[i].name] = true
errs = errors.Join(errs, &HookError{phase, hooks[i].name, context.DeadlineExceeded})
}
}
return &phaseError{err: errs, unsafe: unsafe || remaining > 0, unjoined: unjoined}
}
}
}
}
if errs != nil {
return &phaseError{err: errs, unsafe: unsafe}
}
return nil
}

234
lifecycle/phases_test.go Normal file
View File

@@ -0,0 +1,234 @@
package lifecycle
import (
"context"
"errors"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
func testRuntime(t *testing.T) *Runtime {
t.Helper()
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: time.Second, DrainTimeout: time.Second, CancelTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
return r
}
func TestPhasesBarrierAndWorkLifetime(t *testing.T) {
parent, cancel := context.WithCancel(context.WithValue(context.Background(), struct{}{}, "value"))
defer cancel()
r := NewRuntime(parent)
var stops, drains atomic.Int32
var mu sync.Mutex
var closed []int
started := make(chan struct{}, 2)
release := make(chan struct{})
// Deliberately interleave registration. Phases take precedence over order.
r.OnClose(func() error { mu.Lock(); defer mu.Unlock(); closed = append(closed, 1); return nil })
for _, name := range []string{"producer", "consumer"} {
r.OnStop(name, func(ctx context.Context) error {
if r.WorkContext().Err() != nil || ctx.Err() != nil {
return errors.New("work canceled before drain")
}
stops.Add(1)
started <- struct{}{}
<-release
return nil
})
r.OnDrain(name, func(ctx context.Context) error {
if stops.Load() != 2 || r.WorkContext().Err() != nil || ctx.Err() != nil {
return errors.New("stage barrier failed")
}
drains.Add(1)
return nil
})
}
r.OnClose(func() error {
if drains.Load() != 2 {
return errors.New("dependency closed early")
}
mu.Lock()
defer mu.Unlock()
closed = append(closed, 2)
return nil
})
cancel()
if r.WorkContext().Err() != nil || r.WorkContext().Value(struct{}{}) != "value" {
t.Fatal("work context inherited cancellation or lost values")
}
done := make(chan error, 1)
go func() { done <- r.Finish(nil) }()
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("stop callbacks did not run concurrently")
}
}
if drains.Load() != 0 {
t.Fatal("drain ran before stop barrier")
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(closed, []int{2, 1}) || r.WorkContext().Err() == nil {
t.Fatal(closed)
}
}
func TestPhasesFailuresRetainDependenciesAndCauses(t *testing.T) {
for _, phase := range []string{"stop", "drain"} {
for _, panics := range []bool{false, true} {
t.Run(phase+map[bool]string{false: "Error", true: "Panic"}[panics], func(t *testing.T) {
r := testRuntime(t)
cause := errors.New("component cause")
fatal := errors.New("fatal monitor")
cleanup := atomic.Int32{}
other := atomic.Int32{}
r.OnClose(func() error { cleanup.Add(1); return nil })
hook := func(context.Context) error {
r.RequestShutdown(fatal)
if panics {
panic(cause)
}
return cause
}
if phase == "stop" {
r.OnStop("broken", hook)
} else {
r.OnDrain("broken", hook)
}
r.OnDrain("other", func(context.Context) error { other.Add(1); return nil })
err := r.Finish(nil)
var detail *HookError
if !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, cause) || !errors.Is(err, fatal) || !errors.As(err, &detail) || detail.Phase != phase {
t.Fatal(err)
}
if cleanup.Load() != 0 || other.Load() != 1 || r.WorkContext().Err() == nil {
t.Fatal("unsafe cleanup or omitted drain")
}
if !errors.Is(r.Close(), ErrResourcesRetained) || other.Load() != 1 {
t.Fatal("close retried hooks")
}
})
}
}
}
func TestPhasesDeadlineCancelsWorkAndJoinsBeforeClose(t *testing.T) {
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{DrainTimeout: 20 * time.Millisecond, CancelTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
var joined atomic.Bool
var closed atomic.Bool
r.OnDrain("worker", func(ctx context.Context) error {
<-ctx.Done()
<-r.WorkContext().Done()
// Cleanup can still use shared resources after forced cancellation.
if closed.Load() {
return errors.New("dependency closed during cancellation")
}
joined.Store(true)
return nil
})
r.OnClose(func() error {
if !joined.Load() {
t.Error("worker not joined")
}
closed.Store(true)
return nil
})
err = r.Close()
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrResourcesRetained) || !closed.Load() {
t.Fatal(err)
}
}
func TestPhasesUncooperativeHookIsBoundedAndNeverClosesDependencies(t *testing.T) {
for _, phase := range []string{"stop", "drain"} {
t.Run(phase, func(t *testing.T) {
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: 20 * time.Millisecond, DrainTimeout: 20 * time.Millisecond, CancelTimeout: 20 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
release := make(chan struct{})
finished := make(chan struct{})
var closed atomic.Int32
hook := func(context.Context) error { defer close(finished); <-release; return nil }
if phase == "stop" {
r.OnStop("stuck", hook)
r.OnDrain("stuck", func(context.Context) error { t.Error("Drain overlapped unfinished Stop"); return nil })
} else {
r.OnDrain("stuck", hook)
}
r.OnClose(func() error { closed.Add(1); return nil })
began := time.Now()
err = r.Finish(errors.New("listen failed"))
close(release)
<-finished
if time.Since(began) > time.Second || !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) || closed.Load() != 0 {
t.Fatal(err)
}
_ = r.Close()
if closed.Load() != 0 {
t.Fatal("late cleanup raced work")
}
})
}
}
func TestPhasesConcurrentFinishAndValidation(t *testing.T) {
if _, err := NewRuntimeWithConfig(nil, ShutdownConfig{DrainTimeout: -1}); err == nil {
t.Fatal("negative timeout accepted")
}
r := testRuntime(t)
var called atomic.Int32
r.OnStop("worker", func(context.Context) error { called.Add(1); return nil })
r.OnDrain("worker", func(context.Context) error { called.Add(1); return nil })
r.OnClose(func() error { called.Add(1); return nil })
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := r.Finish(nil); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if called.Load() != 3 {
t.Fatal(called.Load())
}
for _, fn := range []func(){func() { r.OnStop("late", func(context.Context) error { return nil }) }, func() { NewRuntime(nil).OnDrain("bad\nname", func(context.Context) error { return nil }) }, func() {
x := NewRuntime(nil)
x.OnStop("duplicate", func(context.Context) error { return nil })
x.OnStop("duplicate", func(context.Context) error { return nil })
}} {
func() {
defer func() {
if recover() == nil {
t.Error("invalid registration accepted")
}
}()
fn()
}()
}
}
func TestMissingDrainRetainsResources(t *testing.T) {
r := testRuntime(t)
r.OnStop("worker", func(context.Context) error { return nil })
closed := false
r.OnClose(func() error { closed = true; return nil })
if err := r.Close(); !errors.Is(err, ErrResourcesRetained) || closed {
t.Fatal("missing completion proof accepted", err)
}
}

View File

@@ -11,17 +11,42 @@ import (
// must finish draining before calling Close. Embed *Runtime in the project's svc. // must finish draining before calling Close. Embed *Runtime in the project's svc.
// The logger and any other borrowed resources must not be registered here. // The logger and any other borrowed resources must not be registered here.
type Runtime struct { type Runtime struct {
Lifecycle *Controller Lifecycle *Controller
mu sync.Mutex mu sync.Mutex
cleanups []func() error cleanups []func() error
closed bool closed bool
once sync.Once once sync.Once
closeErr error closeErr error
stops, drains []shutdownHook
shutdown ShutdownConfig
work context.Context
cancelWork context.CancelFunc
} }
func NewRuntime(ctx context.Context) *Runtime { return &Runtime{Lifecycle: New(ctx)} } func NewRuntime(ctx context.Context) *Runtime {
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() } r, _ := NewRuntimeWithConfig(ctx, DefaultShutdownConfig())
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) } return r
}
// NewRuntimeWithConfig separates the stop notification from the lifetime of
// accepted background work. Zero timeout fields use documented defaults.
func NewRuntimeWithConfig(ctx context.Context, cfg ShutdownConfig) (*Runtime, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if ctx == nil {
ctx = context.Background()
}
work, cancel := context.WithCancel(context.WithoutCancel(ctx))
return &Runtime{Lifecycle: New(ctx), shutdown: cfg.defaults(), work: work, cancelWork: cancel}, nil
}
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
// WorkContext stays alive during HTTP draining and graceful background draining.
// It is canceled on a shutdown failure/deadline, or after successful draining.
// Context() remains the early stop notification; do not use it for accepted work.
func (r *Runtime) WorkContext() context.Context { return r.work }
// OnClose transfers cleanup ownership. Register immediately after creation, // OnClose transfers cleanup ownership. Register immediately after creation,
// before exposing resources to other goroutines. Registration after Close is a // before exposing resources to other goroutines. Registration after Close is a
@@ -38,8 +63,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
r.cleanups = append(r.cleanups, cleanup) r.cleanups = append(r.cleanups, cleanup)
} }
// Close releases all resources in reverse order, including after a failed // Close runs stop and drain barriers before releasing resources in reverse
// initialization. All callbacks are attempted even if one fails or panics. // order, including after failed initialization. If quiescence is unproven it
// skips resource callbacks and returns ErrResourcesRetained. Once release is
// safe, all OnClose callbacks are attempted even if one fails or panics.
func (r *Runtime) Close() error { func (r *Runtime) Close() error {
if r == nil { if r == nil {
return nil return nil
@@ -48,9 +75,43 @@ func (r *Runtime) Close() error {
r.RequestShutdown(nil) r.RequestShutdown(nil)
r.mu.Lock() r.mu.Lock()
r.closed = true r.closed = true
cleanups := r.cleanups cleanups, stops, drains := r.cleanups, r.stops, r.drains
r.cleanups = nil r.cleanups = nil
r.stops, r.drains = nil, nil
r.mu.Unlock() r.mu.Unlock()
stopErr := r.runPhase("stop", stops, r.shutdown.StopTimeout)
var stopped *phaseError
errors.As(stopErr, &stopped)
for _, stop := range stops {
found := false
for _, drain := range drains {
if drain.name == stop.name {
found = true
break
}
}
if !found {
stopErr = &phaseError{err: errors.Join(stopErr, &HookError{"drain", stop.name, errors.New("matching OnDrain required")}), unsafe: true}
}
}
if stopErr != nil {
r.cancelWork()
}
// A timed-out Stop can still be executing. Never overlap its own Drain;
// other components can still establish their completion independently.
eligible := make([]shutdownHook, 0, len(drains))
for _, drain := range drains {
if stopped == nil || !stopped.unjoined[drain.name] {
eligible = append(eligible, drain)
}
}
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
r.cancelWork()
r.closeErr = errors.Join(stopErr, drainErr)
if unsafePhase(stopErr) || unsafePhase(drainErr) {
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
return
}
for i := len(cleanups) - 1; i >= 0; i-- { for i := len(cleanups) - 1; i >= 0; i-- {
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i])) r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
} }

View File

@@ -44,7 +44,13 @@ python3 scripts/verifyConnector.py --local --report reports/businessDevelopmentC
## 远程发布验收 ## 远程发布验收
代码发布标签后,使用以下命令在空 module 缓存验收同版远程 CLI/框架,不使用本地 replace;结果另行归档,发布标签不移动: 远程验收已通过。代码标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告另行提交,标签没有移动。两个脚本分别使用空 module 缓存,同版远程 CLI/框架均无本地 replace:
- [生成服务远程报告](businessDevelopmentManagedRemote.json):30.00 秒,覆盖实际生成、运行/构建、错误目录和模板参数、根数组/null、严格请求、声明式上传、依赖中间件、退出与清理。
- [数据库远程报告](businessDevelopmentConnectorRemote.json):22.99 秒,真实 MySQL TLS/mTLS、Connector/池隔离、HTTP 限额与生成 MySQL 服务的配置和生命周期。
- 两者 module 校验值一致:`h1:qG0Ipjt3enc8/Enu8ihJ6h8c8U6FCDedoNSdzuC3rBc=`。临时容器、凭据、证书和消费者目录已清理。
复验命令:
```sh ```sh
python3 scripts/verifyManagedRelease.py --version v0.14.0-preview --report reports/businessDevelopmentManagedRemote.json python3 scripts/verifyManagedRelease.py --version v0.14.0-preview --report reports/businessDevelopmentManagedRemote.json

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.14.0-preview",
"localSource": false,
"frameworkSum": "h1:qG0Ipjt3enc8/Enu8ihJ6h8c8U6FCDedoNSdzuC3rBc=",
"freshModuleCache": true,
"noLocalReplace": true,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 22.99,
"tests": [
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestStructuredMySQLAdapterTLS",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits",
"TestGeneratedMySQLConfigurationAndLifecycle"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
"scripts/verifyConnector.py": "4d2b1da0a1fcf641e02600710789ced51a58cf6fa1726a00c194a01f8e557e1a",
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645"
}
}

View File

@@ -0,0 +1,68 @@
{
"passed": true,
"frameworkVersion": "v0.14.0-preview",
"frameworkSum": "h1:qG0Ipjt3enc8/Enu8ihJ6h8c8U6FCDedoNSdzuC3rBc=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 30.0,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"strictRequestPolicies",
"declarativeMultipartBinding"
],
"sourceHashes": {
"lifecycle/runtime.go": "be9d3a483adc322b9c2633efbc87609f4b4a7aeebc2bee062e89ec32368466a3",
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
"cmd/go-omni/apigen/generate.go": "e0290bc5b6fdd9404da7944f2697299548ca3ea3611cf0e4a3ad3db5be2f7554",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "ac2ef8f03d4c01fa793bc381c152d5d09f76227659cd6399ef8d43087785295c",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "2b6bdf62a06cf25b85cbf06d60a5284ba24256c6705611fad4c012f63661a03d",
"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",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
}
}

View File

@@ -0,0 +1,34 @@
# v0.16.0-preview 契约与生成体验验收
日期:2026-09-25。范围是通用数组响应、OpenAPI 引用、共享 Go 错误目录与环境覆盖;没有引入业务专属错误或外部服务依赖。
## 验证结果
- 字符串、布尔、各支持整数/浮点类型与 any 数组可以校验并生成 Go 类型;实际生成消费者验证中文字符串、int64 精度、混合对象/数组/null/标量、nil 根数组归一和 raw 数组。未知类型、byte/File/根嵌套数组明确拒绝。
- 重复响应先共享,再依据体积收益共享 schema/示例,避免组件表本身膨胀。引用全部可解析,编译与压缩可重复;示例业务数据中的字面 `$ref` 不被解释。HTTP 200 同时含成功/业务错误时,两种 schema、所有模板示例和 data:null 保留。
- 两个独立生成服务共享一次 Go Catalog 导出;服务/路由文档筛选不生成运行期目录。支持 internal 包,临时 Go overlay、模块文件及导出程序在工作结束后删除。check 完整文件快照不变;过期目录、编译错误、非法路径和 symlink 输出拒绝。
- 重新生成保留 Logic;生成 --check、独立消费者 race 与编译通过。根 go run/go build、信号退出、nethttp/native、下载/原始回调/multipart 和生命周期清理继续通过。
- 环境覆盖验证同一个 Lookup、allow/ignore/reject、完整名优先、显式空与未设置区别、false/0、JSON 集合、默认值不被修改、错误不带输入值。框架旧短名称入口兼容;存储/MySQL 生成组合验证了新版模板。
## 实际契约文档
| 服务 | 原字节数 | 新字节数 | 操作数 | 展开后内容 |
| --- | ---: | ---: | ---: | --- |
| eTax API | 20,751,149 | 847,171 | 67 | 完全一致 |
| eTax ID | 1,008,210 | 392,481 | 3 | 完全一致 |
[文档报告](contractFollowupDocuments.json) 保存原/新 SHA256 与生成源码摘要。通过只读 api export 写入临时目录,逐项比较完整 paths 操作;业务项目未做迁移或修改。
[全量回归报告](contractFollowupRegression.json) 与 [进程验收](contractFollowupLocal.json) 保存本次检查结果。
## 复验
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
go vet ./...
go build ./...
python3 scripts/verifyManagedProject.py --report reports/contractFollowupLocal.json
python3 scripts/verifyManagedRelease.py --version v0.16.0-preview --report reports/contractFollowupRemote.json
```
共享 Catalog 提供方是清单显式指定的可信代码。框架隔离自己的文件操作,但不能保证用户提供方自身没有副作用;提供方应保持纯粹。该版本仍为 preview,未承诺支持完整 Go/goctl 类型语法或替业务完成数据库、权限、事务迁移。

View File

@@ -0,0 +1,40 @@
{
"passed": true,
"frameworkVersion": "v0.16.0-preview",
"date": "2026-09-25",
"method": "Read-only api export against existing contracts; expand standard local references and compare every path operation to the existing documents. No business migration performed.",
"services": [
{
"service": "api",
"beforeBytes": 20751149,
"afterBytes": 847171,
"operations": 67,
"expandedOperationsIdentical": true,
"beforeSHA256": "e9e7fa5de073f1c1747d83e8144dead47080ad720f1c0daf9f0ef6af330eff02",
"afterSHA256": "b1276c7cec2ce7bcaba9a73c475dcc3f5b485306cc6b9a66a4ccf5c6077a13cb",
"components": {
"responses": 25,
"schemas": 1,
"securitySchemes": 1
}
},
{
"service": "id",
"beforeBytes": 1008210,
"afterBytes": 392481,
"operations": 3,
"expandedOperationsIdentical": true,
"beforeSHA256": "0ab7f09e18604e402ef372cfe71720c60d8e744b43c7fcbf83211ce1c1a5b04b",
"afterSHA256": "31c1b14754dc863431bf56ef3f6820b019428d8982011503ac91ea87aaecdf9d",
"components": {
"responses": 14,
"schemas": 1,
"securitySchemes": 0
}
}
],
"sourceHashes": {
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/contractMetadata.go": "392fca7c015d17e0799019f62c9ed82904a7531a9779eb28c12ec37f8ab93fd7"
}
}

View File

@@ -0,0 +1,59 @@
{
"passed": true,
"mode": "localSource",
"durationSeconds": 4.91,
"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": "9d81a3a4cd7905bd1829626e960512d86ac628d981be47c1d0cacc0648c0f33a",
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
"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-config.go.tmpl": "715d29a9be33ba232eac339547cc0a13ae09c8bcb38a65a69a4f32f09fea5346",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
"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

@@ -0,0 +1,59 @@
{
"passed": true,
"frameworkVersion": "v0.16.0-preview",
"date": "2026-09-25",
"command": "GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s",
"testedPackages": [
"git.yuntuo.hk/k2safe/go-omni",
"git.yuntuo.hk/k2safe/go-omni/app",
"git.yuntuo.hk/k2safe/go-omni/audit",
"git.yuntuo.hk/k2safe/go-omni/auth",
"git.yuntuo.hk/k2safe/go-omni/cache",
"git.yuntuo.hk/k2safe/go-omni/cache/redis",
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni",
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen",
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold",
"git.yuntuo.hk/k2safe/go-omni/config",
"git.yuntuo.hk/k2safe/go-omni/contract",
"git.yuntuo.hk/k2safe/go-omni/database",
"git.yuntuo.hk/k2safe/go-omni/database/gormx",
"git.yuntuo.hk/k2safe/go-omni/database/migrate",
"git.yuntuo.hk/k2safe/go-omni/database/mysql",
"git.yuntuo.hk/k2safe/go-omni/errors",
"git.yuntuo.hk/k2safe/go-omni/examples/modules",
"git.yuntuo.hk/k2safe/go-omni/examples/platform",
"git.yuntuo.hk/k2safe/go-omni/examples/reference",
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository",
"git.yuntuo.hk/k2safe/go-omni/health",
"git.yuntuo.hk/k2safe/go-omni/httpc",
"git.yuntuo.hk/k2safe/go-omni/httpx",
"git.yuntuo.hk/k2safe/go-omni/internal/perfcheck",
"git.yuntuo.hk/k2safe/go-omni/lifecycle",
"git.yuntuo.hk/k2safe/go-omni/logx",
"git.yuntuo.hk/k2safe/go-omni/metrics",
"git.yuntuo.hk/k2safe/go-omni/middleware",
"git.yuntuo.hk/k2safe/go-omni/middleware/redis",
"git.yuntuo.hk/k2safe/go-omni/openapi",
"git.yuntuo.hk/k2safe/go-omni/pagination",
"git.yuntuo.hk/k2safe/go-omni/queue/redis",
"git.yuntuo.hk/k2safe/go-omni/resilience",
"git.yuntuo.hk/k2safe/go-omni/response",
"git.yuntuo.hk/k2safe/go-omni/storage",
"git.yuntuo.hk/k2safe/go-omni/storage/local",
"git.yuntuo.hk/k2safe/go-omni/storage/oss",
"git.yuntuo.hk/k2safe/go-omni/storage/s3",
"git.yuntuo.hk/k2safe/go-omni/task",
"git.yuntuo.hk/k2safe/go-omni/telemetry",
"git.yuntuo.hk/k2safe/go-omni/transport",
"git.yuntuo.hk/k2safe/go-omni/transport/http2",
"git.yuntuo.hk/k2safe/go-omni/transport/native",
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
],
"logSHA256": "ea70707ea4c160e9ed56edab99d040d9959dfa402a3e8f6d090d2b30ccef1513",
"additionalChecks": [
"go vet ./...",
"go build ./...",
"example managedService generate --check",
"example managedService go test ./..."
]
}

42
reports/gormAcceptance.md Normal file
View File

@@ -0,0 +1,42 @@
# v0.14.1-preview GORM 可选适配验收
日期:2026-09-24。GORM v2 API,固定 `gorm.io/gorm v1.31.2`、`gorm.io/driver/mysql v1.6.0`。没有修改接入项目业务,也未使用业务数据库。
## 已通过的检查
- 原池身份与限额保持;初始化错误、已取消 context、版本查询超时都保留原因,且不会关闭借用池。
- 原生命名策略、默认事务保护、错误翻译、PreparedStmt 查询和嵌套事务保持可用。成功后启动 context 取消不会影响新请求。
- SQL 日志级别、慢查询、ErrRecordNotFound 忽略、占位符过滤、错误详情显式开关、requestId/traceId、并发 LogMode 副本与 UTF-8 截断。
- 真实 MySQL 8.4:TLS、同池连接、事务提交/错误回滚/panic 回滚、嵌套 savepoint 回滚、唯一约束、查询取消和后续恢复;参数与重复键错误中的值不进入默认日志。
- 实际生成 MySQL 项目,只扩展项目拥有的 svc 字段与 Init,main/运行期不改;生成检查通过,ORM 随项目池关闭。既有 MySQL TLS/mTLS 与 HTTP 限额回归一并执行。
- `go list -deps` 确认核心 App、database、database/mysql 的编译依赖不包含 GORM。
本地命令:
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
go vet ./...
go build ./...
python3 scripts/verifyConnector.py --local --gorm --report reports/gormLocal.json
```
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
发布后的远程验收也已通过:
- [远程 MySQL/GORM 报告](gormRemote.json):25.42 秒,实际下载固定版本,执行 TLS、事务、取消、日志、生成项目接入及已有数据库/HTTP 回归。
- [远程生成项目报告](gormManagedRemote.json):30.45 秒,验证根目录运行/构建、重新生成、错误目录与参数、上传下载、内置端点及初始化失败/监听失败/正常退出清理。
- 两份报告均使用空 module 缓存,无本地 replace,CLI 和消费者版本均为 `v0.14.1-preview`;module 校验值一致为 `h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=`。同版 CLI 的生成检查没有错误版本提示。
复验命令:
```sh
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
```
代码标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`。远程验收结果单独提交,标签保持不变;临时容器、证书、消费者与进程均已清理。
## 边界
本版真实数据库只验收 MySQL。自定义方言工厂必须使用给定连接池;额外连接池由其所有者管理,不自动纳入当前池生命周期。GORM 默认不启用 PrepareStmt;显式启用时保留第三方缓存语义,其清理接口及后台 goroutine 的限制已在 [适配文档](../database/gormx/README.md) 说明。默认 SQL 参数隐藏不等于自动脱敏原始 SQL 字面量。

82
reports/gormLocal.json Normal file
View File

@@ -0,0 +1,82 @@
{
"passed": true,
"frameworkVersion": "v0.14.1-preview",
"localSource": true,
"gormAdapter": true,
"frameworkSum": null,
"freshModuleCache": false,
"noLocalReplace": false,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 15.01,
"tests": [
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestGORMMySQLTransactionsCancellationAndLogs",
"TestStructuredMySQLAdapterTLS",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits",
"TestGeneratedOptionalGORM",
"TestGeneratedMySQLConfigurationAndLifecycle"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
}
}

View File

@@ -0,0 +1,68 @@
{
"passed": true,
"frameworkVersion": "v0.14.1-preview",
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 30.45,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"strictRequestPolicies",
"declarativeMultipartBinding"
],
"sourceHashes": {
"lifecycle/runtime.go": "be9d3a483adc322b9c2633efbc87609f4b4a7aeebc2bee062e89ec32368466a3",
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
"cmd/go-omni/apigen/generate.go": "138ea2809df839a5283c1f545d94e9a13d3476d149e7bfb62f73fb9c84ad1d92",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "ac2ef8f03d4c01fa793bc381c152d5d09f76227659cd6399ef8d43087785295c",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "2b6bdf62a06cf25b85cbf06d60a5284ba24256c6705611fad4c012f63661a03d",
"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",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
}
}

82
reports/gormRemote.json Normal file
View File

@@ -0,0 +1,82 @@
{
"passed": true,
"frameworkVersion": "v0.14.1-preview",
"localSource": false,
"gormAdapter": true,
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
"freshModuleCache": true,
"noLocalReplace": true,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 25.42,
"tests": [
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestStructuredMySQLAdapterTLS",
"TestGORMMySQLTransactionsCancellationAndLogs",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits",
"TestGeneratedOptionalGORM",
"TestGeneratedMySQLConfigurationAndLifecycle"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
}
}

View File

@@ -0,0 +1,48 @@
# v0.15.0-preview 后台分阶段关闭验收
日期:2026-09-25。范围为通用生命周期、task 可选接入、生成项目装配和升级;没有修改业务项目,也没有引入事务任务一致性实现。
## 已验证行为
- 早期退出通知不取消 WorkContext;父 context 的值保留,截止时间不传入后台工作生命周期。阶段钩子有独立时限。
- 所有 Stop 完成才正常进入 Drain,同一阶段并发;仅确认收尾完成后才逆序释放共享资源。OnStop 要求匹配 OnDrain。
- 阶段错误、panic、超时和未响应取消都会保留原因;不能证明结束时跳过资源关闭,返回 ErrResourcesRetained。超时后在取消窗口成功完成的组件允许释放资源,同时仍报告超时。
- 未返回的 Stop 不与同名 Drain 并发;其他组件继续尝试收尾。重复/并发 Finish 不重复调用钩子。
- 实际生成项目在 nethttp/native 上验证:HTTP 请求仍在处理时后台不提前停止,后台最终操作完成后才关闭客户端;后台致命原因贯穿返回错误。
- 初始化失败和监听失败仍等待已创建后台任务收尾。独立子进程验证未结束任务不会导致共享客户端提前关闭,进程退出码为 1,原始初始化错误保留。
- 配置 YAML/环境覆盖、非法时限拒绝;重新生成保留业务文件,v0.14 项目的定点升级只更新依赖/生成文件,保留自定义配置与 svc。
- 实际 MySQL 8.4 TLS:后台在退出通知后创建临时表、完成 INSERT/SELECT 并释放借用连接,再关闭池;无法证明完成时池仍通过健康检查。GORM 同池接入及既有 Connector/TLS/HTTP 限额回归同时通过。
本地进程记录:[生成项目](phasedManagedLocal.json)。数据库记录:[MySQL 与 GORM](phasedMySQLLocal.json)。容器限制为 1 CPU / 768 MiB / 128 PIDs,只使用本机临时数据库,已清理容器、凭据、证书和临时消费者。
全量 race(启用生成消费者)、go vet、go build、示例生成漂移检查和依赖隔离检查均通过。
## 复验
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
go vet ./...
go build ./...
python3 scripts/verifyManagedProject.py --report reports/phasedManagedLocal.json
python3 scripts/verifyConnector.py --local --phases --gorm --report reports/phasedMySQLLocal.json
```
发布后空缓存远程验收也已通过:
- [生成项目远程报告](phasedManagedRemote.json):31.93 秒,包含后台阶段顺序、初始化/监听失败、超时退出及既有接口功能。
- [真实 MySQL/GORM 远程报告](phasedMySQLRemote.json):25.14 秒,包含后台最终写入后关池、收尾失败保留池与 GORM 同池回归。
- 两份报告均为固定版本 v0.15.0-preview、空 module 缓存、无本地 replace;module 校验值一致为 `h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=`。
- 发布标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`,报告单独提交,标签不移动。
远程复验命令:
```sh
python3 scripts/verifyManagedRelease.py --version v0.15.0-preview --report reports/phasedManagedRemote.json
python3 scripts/verifyConnector.py --version v0.15.0-preview --phases --gorm --report reports/phasedMySQLRemote.json
```
## 使用边界
普通 OnClose 保留原有逆序语义;需要阶段保证的后台组件必须迁入对应钩子,框架不会猜测任意 Close 内部行为。钩子负责真实停止与等待,nil 是完成证明。未结束 goroutine 不能被 Go 强杀;资源被保留后由生成入口异常退出,手写宿主须自行终止服务。OnClose 的同步回调仍须能返回;HTTP 超时保留原传输层行为,业务必须响应请求取消。同一阶段有顺序依赖时由一个登记项组合管理。
lifecycle 本身仅依赖标准库;核心 App/database/task 的编译依赖不包含 Orbit/GORM/Redis。第三方可选适配依赖仍记录于同一 module,不声称已经拆成独立 module。

View File

@@ -0,0 +1,59 @@
{
"passed": true,
"mode": "localSource",
"durationSeconds": 4.31,
"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": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
"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-config.go.tmpl": "fa37bd0a71587e2f821ec1595eb1f1a371677c90428001ea75f61815243c3b7b",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
"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

@@ -0,0 +1,78 @@
{
"passed": true,
"frameworkVersion": "v0.15.0-preview",
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 31.93,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits"
],
"sourceHashes": {
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
"config/lifecycle_test.go": "8e5fccf0f7432c64d7fa8a52687002307d3ebb1fd039adb6ae09da0c9a2891bf",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
"cmd/go-omni/apigen/generate.go": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "8bc3ce425ca93cbea370fa94ff855922bdb60b2213330dec1cddd25d73d6f12b",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
"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",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
}
}

View File

@@ -0,0 +1,87 @@
{
"passed": true,
"frameworkVersion": "v0.15.0-preview",
"localSource": true,
"gormAdapter": true,
"phasedLifecycle": true,
"frameworkSum": null,
"freshModuleCache": false,
"noLocalReplace": false,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 12.44,
"tests": [
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestStructuredMySQLAdapterTLS",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits",
"TestGORMMySQLTransactionsCancellationAndLogs",
"TestGeneratedOptionalGORM",
"TestGeneratedMySQLConfigurationAndLifecycle",
"TestGeneratedMySQLBackgroundDrain"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
"scripts/verifyConnector.py": "fba3064dc4611eef5a7cc25369d4523b485e633448fa14a7a1fea0fbca3796fe",
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
}
}

View File

@@ -0,0 +1,87 @@
{
"passed": true,
"frameworkVersion": "v0.15.0-preview",
"localSource": false,
"gormAdapter": true,
"phasedLifecycle": true,
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
"freshModuleCache": true,
"noLocalReplace": true,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 25.14,
"tests": [
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestStructuredMySQLAdapterTLS",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits",
"TestGORMMySQLTransactionsCancellationAndLogs",
"TestGeneratedOptionalGORM",
"TestGeneratedMySQLConfigurationAndLifecycle",
"TestGeneratedMySQLBackgroundDrain"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
"scripts/verifyConnector.py": "fba3064dc4611eef5a7cc25369d4523b485e633448fa14a7a1fea0fbca3796fe",
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
}
}

48
scripts/testdata/gormGenerated_test.go vendored Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"context"
"io"
"testing"
"time"
"example.com/mysqlAcceptance/internal/config"
"example.com/mysqlAcceptance/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestGeneratedOptionalGORM(t *testing.T) {
cfg, err := config.Load("etc/config.yaml")
if err != nil {
t.Fatal(err)
}
logger, err := logx.New(logx.Config{Writer: io.Discard})
if err != nil {
t.Fatal(err)
}
defer logger.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c, err := svc.New(ctx, logger, cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if c.ORM == nil {
t.Fatal("svc.Init did not provide GORM")
}
pool, err := c.ORM.DB()
if err != nil || pool != c.DB.DB {
t.Fatal("GORM created another pool", err)
}
var value int
if err = c.ORM.WithContext(ctx).Raw("SELECT ?", 42).Scan(&value).Error; err != nil || value != 42 {
t.Fatal(value, err)
}
if err = c.Finish(nil); err != nil {
t.Fatal(err)
}
if err = c.ORM.WithContext(ctx).Raw("SELECT 1").Scan(&value).Error; err == nil {
t.Fatal("ORM did not follow owner shutdown")
}
}

85
scripts/testdata/phasedMySQL_test.go vendored Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"context"
"errors"
"example.com/mysqlAcceptance/internal/config"
"example.com/mysqlAcceptance/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"testing"
"time"
)
func TestGeneratedMySQLBackgroundDrain(t *testing.T) {
cfg, err := config.Load("etc/config.yaml")
if err != nil {
t.Fatal(err)
}
logger, err := logx.New(logx.Config{Writer: io.Discard})
if err != nil {
t.Fatal(err)
}
defer logger.Close()
parent, cancel := context.WithCancel(context.Background())
defer cancel()
c, err := svc.New(parent, logger, cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
stop := make(chan struct{})
finished := make(chan error, 1)
c.OnStop("writer", func(context.Context) error { close(stop); return nil })
c.OnDrain("writer", func(context.Context) error { return <-finished })
go func() {
<-stop
result := func() (err error) {
ctx, end := context.WithTimeout(c.WorkContext(), 3*time.Second)
defer end()
conn, err := c.DB.Conn(ctx)
if err != nil {
return err
}
defer func() { err = errors.Join(err, conn.Close()) }()
for _, query := range []string{"CREATE TEMPORARY TABLE omni_phase_records (id INT)", "INSERT INTO omni_phase_records VALUES (42)"} {
if _, err = conn.ExecContext(ctx, query); err != nil {
return err
}
}
var n int
if err = conn.QueryRowContext(ctx, "SELECT id FROM omni_phase_records").Scan(&n); err != nil {
return err
}
if n != 42 {
return errors.New("final background write missing")
}
_, err = conn.ExecContext(ctx, "DROP TEMPORARY TABLE omni_phase_records")
return err
}()
// Completion follows release of the borrowed connection, including failures.
finished <- result
}()
cancel()
if err = c.Finish(nil); err != nil {
t.Fatal(err)
}
if err = c.DB.PingContext(context.Background()); err == nil {
t.Fatal("pool not closed after worker finished")
}
held, err := svc.New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
defer held.DB.Close() // This test owns recovery and has no outstanding work.
cause := errors.New("worker completion unproven")
held.OnDrain("unjoined", func(context.Context) error { return cause })
if err = held.Finish(nil); !errors.Is(err, cause) || !errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal(err)
}
if err = held.DB.Health(context.Background()); err != nil {
t.Fatal("unjoined worker lost shared database", err)
}
}

View File

@@ -43,6 +43,8 @@ def main():
source.add_argument("--version") source.add_argument("--version")
parser.add_argument("--report", type=Path, required=True) parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--image", default="mysql:8.4") parser.add_argument("--image", default="mysql:8.4")
parser.add_argument("--gorm", action="store_true", help="also verify the optional GORM adapter and generated svc extension")
parser.add_argument("--phases", action="store_true", help="verify final background writes before generated pool cleanup (v0.15+)")
args = parser.parse_args() args = parser.parse_args()
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version): if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required") parser.error("a fixed release version is required")
@@ -70,13 +72,16 @@ def main():
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid. path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
project = temp / "consumer" project = temp / "consumer"
project.mkdir() project.mkdir()
version = args.version or "v0.14.0-preview" version = args.version or "v0.15.0-preview"
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n") (project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
if args.local: if args.local:
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env) run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
else: else:
env["GOMODCACHE"] = str(temp / "modcache") env["GOMODCACHE"] = str(temp / "modcache")
for name in SOURCES: sources = list(SOURCES)
if args.gorm:
sources.append("database/gormx/integration_test.go")
for name in sources:
target = project / name target = project / name
target.parent.mkdir(exist_ok=True, parents=True) target.parent.mkdir(exist_ok=True, parents=True)
shutil.copyfile(ROOT / name, target) shutil.copyfile(ROOT / name, target)
@@ -98,6 +103,20 @@ def main():
command += ["--framework-dir", ROOT] command += ["--framework-dir", ROOT]
run([*command, generated], temp, env) run([*command, generated], temp, env)
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go") shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go")
if args.phases:
shutil.copyfile(ROOT / "scripts/testdata/phasedMySQL_test.go", generated / "phases_test.go")
if args.gorm:
# Only the documented project-owned extension is customized.
service = generated / "internal/svc/serviceContext.go"
code = service.read_text()
assert '"context"' in code and 'type ServiceContext struct {' in code
code = code.replace('"context"', '"context"\n "gorm.io/gorm"\n "' + MODULE + '/database/gormx"', 1)
code = code.replace('type ServiceContext struct {', 'type ServiceContext struct {\n ORM *gorm.DB', 1)
assert 'return nil' in code
code = code.replace('return nil', 'var err error\n c.ORM, err = gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))\n return err', 1)
service.write_text(code)
run(["gofmt", "-w", service], generated, env)
shutil.copyfile(ROOT / "scripts/testdata/gormGenerated_test.go", generated / "gorm_test.go")
run(["go", "mod", "tidy"], generated, env) run(["go", "mod", "tidy"], generated, env)
generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env)) generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env))
if not args.local: if not args.local:
@@ -137,6 +156,8 @@ def main():
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e] passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert "TestMySQLConnectorTLSAndPoolIsolation" in passed assert "TestMySQLConnectorTLSAndPoolIsolation" in passed
assert "TestStructuredMySQLAdapterTLS" in passed assert "TestStructuredMySQLAdapterTLS" in passed
if args.gorm:
assert "TestGORMMySQLTransactionsCancellationAndLogs" in passed
assert not any(e.get("Action") in ("fail", "skip") for e in events), output assert not any(e.get("Action") in ("fail", "skip") for e in events), output
env.update(GO_OMNI_DB_HOST="127.0.0.1", GO_OMNI_DB_PORT=addr.rsplit(":", 1)[1], env.update(GO_OMNI_DB_HOST="127.0.0.1", GO_OMNI_DB_PORT=addr.rsplit(":", 1)[1],
GO_OMNI_DB_USER="omni_test", GO_OMNI_DB_PASSWORD=env["MYSQL_PASSWORD"], GO_OMNI_DB_USER="omni_test", GO_OMNI_DB_PASSWORD=env["MYSQL_PASSWORD"],
@@ -144,23 +165,34 @@ def main():
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2", GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2") GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
print("Testing generated MySQL service config, independent pools and cleanup", flush=True) print("Testing generated MySQL service config, independent pools and cleanup", flush=True)
run([cli, "api", "generate", "--check"], generated, env) check_output = run([cli, "api", "generate", "--check"], generated, env)
if not args.local:
assert "frameworkVersion" not in check_output, check_output
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=60s", "./..."], generated, env) output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=60s", "./..."], generated, env)
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e] generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
if args.phases:
assert "TestGeneratedMySQLBackgroundDrain" in generated_passed, output
if args.gorm:
assert "TestGeneratedOptionalGORM" in generated_passed, output
assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
passed += generated_passed passed += generated_passed
image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip() image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip()
finally: finally:
if created: if created:
run(["docker", "rm", "--force", "--volumes", name], temp, env) run(["docker", "rm", "--force", "--volumes", name], temp, env)
report = {"passed": True, "frameworkVersion": version, "localSource": args.local, report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
if args.phases:
report_sources += ["scripts/testdata/phasedMySQL_test.go", "lifecycle/runtime.go", "lifecycle/phases.go"]
if args.gorm:
report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"]
report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm, "phasedLifecycle": args.phases,
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local, "frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image, "noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128}, "mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
"durationSeconds": round(time.monotonic() - began, 2), "tests": passed, "durationSeconds": round(time.monotonic() - began, 2), "tests": passed,
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in (*SOURCES, "scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go")}} "sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in report_sources}}
args.report.parent.mkdir(parents=True, exist_ok=True) args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print("PASS; isolated container, credentials, certificates and consumer removed", flush=True) print("PASS; isolated container, credentials, certificates and consumer removed", flush=True)

View File

@@ -18,6 +18,55 @@ ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni" MODULE = "git.yuntuo.hk/k2safe/go-omni"
def verify_shared_catalog(cli, temp, env, version):
workspace = temp / "workspace"
(workspace / "internal/apierrors").mkdir(parents=True)
(workspace / "contracts").mkdir()
(workspace / "go.mod").write_text(f"module example.com/releaseWorkspace\n\ngo 1.24.0\nrequire {MODULE} {version}\n")
provider = workspace / "internal/apierrors/catalog.go"
provider.write_text('package apierrors\nimport errors "' + MODULE + '/errors"\n'
'func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{'
'"INVALID":{Code:"INVALID",Message:"invalid"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"}}}}\n')
run(["go", "mod", "tidy"], workspace, env)
for name in ("api", "id"):
service = workspace / "services" / name
run([cli, "new", "--module", "example.com/" + name, service], workspace, env)
(service / "api/omni.imports.json").write_text(json.dumps({
"roots": {"common": {"path": "../../../contracts"}},
"errors": {"source": "@common/errors.json", "include": ["INVALID"],
"routes": {"GET /api/greet/:name": ["MISSING"]}}}))
manifest = workspace / "omni.workspace.json"
manifest.write_text(json.dumps({"errorCatalogs": [{"dir": ".", "package": "example.com/releaseWorkspace/internal/apierrors",
"function": "Catalog", "output": "contracts/errors.json"}],
"services": [{"dir": "services/api"}, {"dir": "services/id"}]}))
for command in ("generate", "format", "generate", "check"):
run([cli, "workspace", command, "--file", manifest], workspace, env)
for name in ("api", "id"):
service = workspace / "services" / name
assert not (service / "api/omni.errors.json").exists()
assert not (service / "internal/apierrors/errorsGen.go").exists()
doc = json.loads((service / "docs/openapi.json").read_text())
responses = doc["paths"]["/api/greet/{name}"]["get"]["responses"]
assert "404" in responses
run(["go", "test", "-mod=mod", "./..."], service, env)
snapshot = lambda: {str(p.relative_to(workspace)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in workspace.rglob("*") if p.is_file()}
before = snapshot()
run([cli, "workspace", "check", "--file", manifest], workspace, env)
assert before == snapshot(), "workspace check changed project files"
provider.write_text(provider.read_text().replace('Message:"invalid"', 'Message:"new message"'))
before = snapshot()
try:
run([cli, "workspace", "check", "--file", manifest], workspace, env)
except RuntimeError as err:
assert "catalog drift" in str(err)
else:
raise AssertionError("stale catalog accepted")
assert before == snapshot(), "drift check changed project files"
run([cli, "workspace", "generate", "--file", manifest], workspace, env)
run([cli, "workspace", "check", "--file", manifest], workspace, env)
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True) parser.add_argument("--version", required=True)
@@ -71,8 +120,11 @@ def main():
serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp) serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp)
checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"] checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"]
verify_shared_catalog(cli, temp, env, args.version)
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject"] tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer"]
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True) print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env) output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
@@ -81,11 +133,13 @@ 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", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "strictRequestPolicies", "declarativeMultipartBinding"] checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
sources = ["lifecycle/runtime.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "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 = ["lifecycle/runtime.go", "lifecycle/phases.go", "config/lifecycle_test.go", "task/task.go", "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl", "cmd/go-omni/apigen/templates.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "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")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
sources += ["cmd/go-omni/apigen/responseShape.go", "cmd/go-omni/apigen/documentRefs.go", "cmd/go-omni/apigen/catalogDocument.go", "cmd/go-omni/apigen/workspaceCatalog.go", "config/env.go", "cmd/go-omni/apigen/arrayResponse_test.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").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,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks, "durationSeconds": round(time.monotonic() - started, 2), "checks": checks,

60
task/phases_test.go Normal file
View File

@@ -0,0 +1,60 @@
package task_test
import (
"context"
"errors"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/task"
"sync/atomic"
"testing"
"time"
)
func TestRunnerPhasedShutdown(t *testing.T) {
runtime := lifecycle.NewRuntime(context.Background())
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1})
if err != nil {
t.Fatal(err)
}
var resourceClosed atomic.Bool
runtime.OnClose(func() error { resourceClosed.Store(true); return nil })
started, release, draining := make(chan struct{}), make(chan struct{}), make(chan struct{})
runtime.OnStop("jobs", runner.StopAccepting)
runtime.OnDrain("jobs", func(ctx context.Context) error { close(draining); return runner.Wait(ctx) })
future, err := runner.Submit(runtime.WorkContext(), "write", func(ctx context.Context) error {
close(started)
<-release
if resourceClosed.Load() {
return errors.New("resource already closed")
}
return ctx.Err()
})
if err != nil {
t.Fatal(err)
}
<-started
runtime.RequestShutdown(nil)
done := make(chan error, 1)
go func() { done <- runtime.Close() }()
select {
case <-draining:
case <-time.After(time.Second):
t.Fatal("did not drain")
}
if _, err := runner.Submit(context.Background(), "late", func(context.Context) error { return nil }); !errors.Is(err, task.ErrClosed) {
t.Fatal(err)
}
if resourceClosed.Load() {
t.Fatal("closed resource before task")
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
if err := future.Wait(context.Background()); err != nil {
t.Fatal(err)
}
if !resourceClosed.Load() {
t.Fatal("resource leaked")
}
}

View File

@@ -227,10 +227,9 @@ func run(ctx context.Context, fn Func) (err error) {
return err return err
} }
// Shutdown stops schedules and submission, then drains accepted jobs. Deadline // StopAccepting stops schedules and submission without canceling accepted jobs.
// expiry cancels running/queued jobs and returns without waiting for code that // Pair it with Wait to establish completion before releasing dependencies.
// ignores cancellation. Such code cannot be forcibly stopped by Go. func (r *Runner) StopAccepting(ctx context.Context) error {
func (r *Runner) Shutdown(ctx context.Context) error {
if ctx == nil { if ctx == nil {
return errors.New("task: nil context") return errors.New("task: nil context")
} }
@@ -242,6 +241,20 @@ func (r *Runner) Shutdown(ctx context.Context) error {
go func() { r.wg.Wait(); r.cancel(); close(r.done) }() go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
} }
r.mu.Unlock() r.mu.Unlock()
return nil
}
// Wait joins all workers and schedules after StopAccepting. A canceled context
// cancels work, but callers can wait again to establish actual completion.
func (r *Runner) Wait(ctx context.Context) error {
if ctx == nil {
return errors.New("task: nil context")
}
select {
case <-r.done:
return nil
default:
}
select { select {
case <-r.done: case <-r.done:
return nil return nil
@@ -251,6 +264,14 @@ func (r *Runner) Shutdown(ctx context.Context) error {
} }
} }
// Shutdown stops admission and waits with the caller's deadline.
func (r *Runner) Shutdown(ctx context.Context) error {
if err := r.StopAccepting(ctx); err != nil {
return err
}
return r.Wait(ctx)
}
func (r *Runner) Close() error { func (r *Runner) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout) ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
defer cancel() defer cancel()