Compare commits
6 Commits
v0.14.0-pr
...
v0.16.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e3bd6cfc | |||
| 1f2d8b6a6a | |||
| 16deead70e | |||
| adcb6f60b6 | |||
| b8ab6b3521 | |||
| da8356e18b |
@@ -13,7 +13,9 @@
|
||||
- [x] 文档工具:字段注释、example/枚举描述进入 DTO 与 OpenAPI;保注释、幂等格式化及 check;显式 Go 错误目录元数据导出与文档合并,不自动执行项目代码。
|
||||
- [x] 共享契约:受控 roots/摘要、循环/深度/文件数/路径保护;分源文件 DTO、多个独立 module 的工作区检查;版本提示与基础模板升级预览,保留业务文件。
|
||||
- [x] 集成:实际生成项目验证新能力、连续生成、业务文件保留、失败清理、nethttp/native 请求与退出;现有错误继承/参数/上传下载回归。
|
||||
- [ ] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
|
||||
- [x] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
|
||||
|
||||
已发布 `v0.14.0-preview`。逐项验收、可替换的业务适配点与远程报告见 [验收记录](reports/businessDevelopmentAcceptance.md);接入和升级见 [开发指南](DEVELOPMENT.md)。
|
||||
|
||||
## 核心约定
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 通用业务开发指南(v0.14.0-preview)
|
||||
# 通用业务开发指南(v0.16.0-preview)
|
||||
|
||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||
|
||||
@@ -21,12 +21,17 @@ delete /item
|
||||
| 契约 | Logic 返回 | 成功数据 |
|
||||
| --- | --- | --- |
|
||||
| `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 (null)` | `error` | HTTP 200 和空业务数据 |
|
||||
| 不写 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 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
|
||||
|
||||
### 一次覆盖完整配置与 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)` 中装配额外依赖:
|
||||
@@ -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:
|
||||
|
||||
@@ -185,21 +207,9 @@ go-omni api generate --check
|
||||
|
||||
格式化保留注释、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
|
||||
// 在项目自己的导出命令中;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 的文档由项目显式扩展。
|
||||
OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。
|
||||
|
||||
## 两个服务共享契约
|
||||
|
||||
@@ -241,9 +251,57 @@ go-omni workspace check --file omni.workspace.json
|
||||
|
||||
各服务保持独立 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 项目升级
|
||||
|
||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。
|
||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.16.0-preview(或显式引用当前源码)。
|
||||
|
||||
```sh
|
||||
go-omni api upgrade --dir . --dry-run
|
||||
|
||||
10
PROJECT.md
10
PROJECT.md
@@ -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。框架请求链按原有次序运行。
|
||||
|
||||
@@ -93,7 +93,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
|
||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 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 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
|
||||
`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`;不对旧入口强制覆盖。
|
||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||
|
||||
16
README.md
16
README.md
@@ -2,13 +2,19 @@
|
||||
|
||||
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 适配器。
|
||||
|
||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `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
|
||||
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"
|
||||
go-omni --help
|
||||
```
|
||||
@@ -186,7 +192,7 @@ go-omni new --api catalog.api \
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```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#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
@@ -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#契约变更提示)。
|
||||
|
||||
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)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
68
RELEASE.md
68
RELEASE.md
@@ -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 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
|
||||
|
||||
@@ -24,6 +88,8 @@ go-omni new --module example.com/billing ./billing
|
||||
|
||||
详见 [业务开发指南](DEVELOPMENT.md)、[数据库接入](database/mysql/README.md) 和 [验收记录](reports/businessDevelopmentAcceptance.md)。
|
||||
|
||||
全量 race(含生成项目)、vet、build 与本地进程验收通过。发布后的空缓存远程消费者和隔离 MySQL 验收也均通过;CLI/框架同版且无本地 replace。标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告单独提交。
|
||||
|
||||
仍需业务维护:契约、配置、svc 依赖创建/探针/清理登记、错误目录、Logic、Repository 和权限中间件。特殊协议可使用保留的扩展点。声明式上传的暂存磁盘和并发量需配置容量;共享契约版本标签由项目管理,框架校验摘要但不下载依赖。此版仍为 preview,不代表 eTax/ePay 已完成业务迁移。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# GoOmni 成熟度计划
|
||||
|
||||
当前进展:`v0.14.0-preview` 已完成契约驱动的业务开发能力、通用 Runtime/配置和官方 MySQL 适配器,并通过全量 race、实际生成服务及空缓存远程验收。新项目/升级以 [README](README.md) 和 [开发指南](DEVELOPMENT.md) 为准;下方为历史阶段记录,完整本轮验收见 [报告](reports/businessDevelopmentAcceptance.md)。具体 eTax/ePay 业务迁移与业务回归仍由接入项目完成。
|
||||
|
||||
## 1. 新项目启动与发布流程(已完成,v0.1.1)
|
||||
|
||||
- 修正脚手架固定版本引用,保留显式本地开发选项。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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)。
|
||||
|
||||
新项目采用根目录 `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
|
||||
# 仓库读取权限与 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
|
||||
|
||||
# 从自己的契约生成;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
|
||||
# 在 go-omni 源码仓库中执行
|
||||
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` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,41 +2,73 @@ package apigen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const catalogDocumentFile = "omni.errors.json"
|
||||
|
||||
func readDocumentCatalog(root string, spec *Spec) error {
|
||||
path := filepath.Join(root, catalogDocumentFile)
|
||||
stat, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
// DocumentErrors selects documentation only. Runtime App inheritance and
|
||||
// route-local @errors declarations are never changed by this metadata.
|
||||
// A nil Include lists all errors; [] lists none. Routes override Include.
|
||||
type DocumentErrors struct {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errors.New("api: error metadata must be a regular file")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||
if err != nil {
|
||||
return err
|
||||
raw, ok := overrides[path]
|
||||
if !ok {
|
||||
stat, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errors.New("api: error metadata must be a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
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 {
|
||||
return errors.New("api: error metadata exceeds 1 MiB")
|
||||
}
|
||||
var catalog apierrors.Catalog
|
||||
if err = apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
||||
if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
||||
return err
|
||||
}
|
||||
registry, err := apierrors.NewRegistry(catalog)
|
||||
@@ -45,10 +77,70 @@ func readDocumentCatalog(root string, spec *Spec) error {
|
||||
}
|
||||
normalized := registry.Catalog()
|
||||
spec.DocumentCatalog = &normalized
|
||||
spec.Sources[catalogDocumentFile] = raw
|
||||
spec.SourcePaths[catalogDocumentFile] = path
|
||||
spec.Sources[name] = raw
|
||||
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
|
||||
}
|
||||
|
||||
func validateDocumentCatalog(spec *Spec) error {
|
||||
if spec.DocumentCatalog == nil {
|
||||
return nil
|
||||
@@ -61,7 +153,7 @@ func validateDocumentCatalog(spec *Spec) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = policy.ValidateErrors(*spec.DocumentCatalog); err != nil {
|
||||
if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
resolving := map[string]bool{}
|
||||
var resolve func(string, 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) {
|
||||
if depth > 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 {
|
||||
return t, nil
|
||||
}
|
||||
if t, ok := primitives[e.Name]; ok {
|
||||
if t, ok := primitiveTypes[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return resolve(e.Name, depth+1)
|
||||
@@ -313,6 +313,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
return nil, err
|
||||
}
|
||||
decorateDocument(doc, spec, c.Bindings)
|
||||
compactDocument(doc)
|
||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||
return c, err
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contr
|
||||
if len(r.Errors) == 0 && documentRegistry != nil {
|
||||
registry = documentRegistry
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
86
cmd/go-omni/apigen/documentRefs.go
Normal file
86
cmd/go-omni/apigen/documentRefs.go
Normal 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)
|
||||
}
|
||||
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
107
cmd/go-omni/apigen/documentRefs_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,12 @@ import (
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
type Options struct {
|
||||
lockHeld bool
|
||||
documentCatalogs map[string][]byte
|
||||
Database, Storage string
|
||||
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
||||
Check bool
|
||||
@@ -48,7 +49,7 @@ func Generate(o Options) (Output, error) {
|
||||
if o.API == "" || o.Dir == "" {
|
||||
return Output{}, errors.New("--api and --dir are required")
|
||||
}
|
||||
spec, err := Parse(o.API)
|
||||
spec, err := parseWithCatalogs(o.API, o.documentCatalogs)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
@@ -145,13 +146,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
if !filepath.IsAbs(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
|
||||
}
|
||||
}
|
||||
}
|
||||
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 != "" {
|
||||
@@ -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)
|
||||
if requiredVersion != "" && requiredVersion != 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"})
|
||||
expectedVersion := minimumVersion
|
||||
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)
|
||||
}
|
||||
return out, err
|
||||
@@ -450,7 +455,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
continue
|
||||
}
|
||||
hasAutomatic = true
|
||||
needsTypes = needsTypes || r.Request != "" || responseGoType(r.Response) != ""
|
||||
needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response)
|
||||
needsBinder = needsBinder || !r.CustomBinding
|
||||
}
|
||||
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")
|
||||
if r.Request != "" || responseGoType(r.Response) != "" {
|
||||
if r.Request != "" || responseNeedsTypes(r.Response) {
|
||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
}
|
||||
if streamingBody || r.Produces != "" {
|
||||
|
||||
@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
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",
|
||||
"shutdown_test.go": "shutdown_test.go",
|
||||
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
|
||||
}
|
||||
for path, fixture := range files {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
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)
|
||||
|
||||
@@ -74,7 +74,7 @@ func document(t *testing.T, c *Compiled) map[string]any {
|
||||
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||
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 {
|
||||
t.Helper()
|
||||
@@ -303,3 +303,21 @@ func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
type Spec struct {
|
||||
DocumentCatalog *apierrors.Catalog
|
||||
DocumentErrors *DocumentErrors
|
||||
SharedRoots map[string]SharedRoot
|
||||
SourcePaths map[string]string
|
||||
RequestPolicy contract.RequestPolicy
|
||||
@@ -72,7 +73,9 @@ type parser struct {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -82,11 +85,12 @@ func Parse(path string) (*Spec, error) {
|
||||
return nil, err
|
||||
}
|
||||
root := filepath.Dir(entry)
|
||||
roots, err := readSharedRoots(root)
|
||||
shared, err := readSharedConfig(root)
|
||||
if err != nil {
|
||||
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{}
|
||||
total := 0
|
||||
var visit func(string, string, int) error
|
||||
@@ -169,7 +173,7 @@ func Parse(path string) (*Spec, error) {
|
||||
if err = visit(entry, "", 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = readDocumentCatalog(root, spec); err != nil {
|
||||
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = validateSharedPins(spec); err != nil {
|
||||
|
||||
@@ -73,6 +73,8 @@ func TestConfiguration(t *testing.T){
|
||||
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||
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_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")
|
||||
}`
|
||||
endpoint := ""
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Response roots deliberately allow named DTOs, []DTO, *DTO and null. Keeping
|
||||
// these four shapes explicit makes nil/empty behavior uniform across encoders.
|
||||
// Response roots allow named DTOs, []DTO, []primitive, *DTO and null.
|
||||
// A nil root slice is always encoded as an empty JSON array.
|
||||
func responseShape(value string) (kind, name string) {
|
||||
if value == "null" {
|
||||
return "null", ""
|
||||
@@ -27,6 +28,9 @@ func responseGoType(value string) string {
|
||||
return ""
|
||||
}
|
||||
if kind == "array" {
|
||||
if primitiveTypes[name] != nil {
|
||||
return "[]" + name
|
||||
}
|
||||
return "[]types." + name
|
||||
}
|
||||
return "*types." + name
|
||||
@@ -36,9 +40,25 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
||||
if kind == "null" {
|
||||
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]
|
||||
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()
|
||||
if err != nil {
|
||||
@@ -52,3 +72,7 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
||||
}
|
||||
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.") }
|
||||
|
||||
@@ -23,48 +23,49 @@ type SharedRoot struct {
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
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))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
return sharedConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
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))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
return sharedConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
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
|
||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
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 {
|
||||
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") != "") {
|
||||
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
|
||||
if !filepath.IsAbs(path) {
|
||||
@@ -72,22 +73,22 @@ func readSharedRoots(root string) (map[string]SharedRoot, error) {
|
||||
}
|
||||
r.Path, err = filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
r.Path, err = filepath.Abs(r.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
stat, err := os.Stat(r.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
return cfg.Roots, nil
|
||||
return cfg, nil
|
||||
}
|
||||
func sourceWithin(root, name string) (string, error) {
|
||||
real, err := filepath.EvalSymlinks(name)
|
||||
@@ -122,7 +123,7 @@ func sourceMetadata(spec *Spec) map[string]any {
|
||||
return map[string]any{"files": hashes, "shared": shared}
|
||||
}
|
||||
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 {
|
||||
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"MODULE/internal/config"
|
||||
"MODULE/internal/handler"
|
||||
"MODULE/internal/svc"
|
||||
@@ -27,7 +28,9 @@ func main(){
|
||||
func run(ctx context.Context,path string)(runErr error){
|
||||
cfg,err:=config.Load(path);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}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// 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
|
||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||
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
|
||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||
their own lifetime, canceled by OnClose after HTTP draining.
|
||||
|
||||
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal 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 }
|
||||
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal 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
|
||||
}
|
||||
@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
|
||||
Opened.Add(1)
|
||||
c.Client = &Client{}
|
||||
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 {
|
||||
return errors.New("client initialization failed")
|
||||
}
|
||||
|
||||
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,12 @@ func PlanUpgrade(dir string) (UpgradePlan, error) {
|
||||
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))
|
||||
for name := range names {
|
||||
sorted = append(sorted, name)
|
||||
|
||||
@@ -60,6 +60,49 @@ func TestUpgradeRejectsEscapingEntry(t *testing.T) {
|
||||
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) {
|
||||
dir := oldInfrastructureProject(t)
|
||||
before := snapshot(t, dir)
|
||||
|
||||
@@ -16,7 +16,8 @@ type WorkspaceService struct {
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
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.
|
||||
@@ -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})
|
||||
}
|
||||
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
|
||||
for _, o := range options {
|
||||
if command == "format" {
|
||||
@@ -101,6 +112,13 @@ func Workspace(path, command string, w io.Writer) error {
|
||||
if failures != nil {
|
||||
return failures
|
||||
}
|
||||
if command == "generate" {
|
||||
for path, data := range catalogs {
|
||||
if err = writeWorkspaceCatalog(path, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range options {
|
||||
switch command {
|
||||
case "format":
|
||||
|
||||
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal 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)
|
||||
}
|
||||
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
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")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package settings
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
{{if and (not .API) (ne .Storage "none")}}"strings"{{end}}
|
||||
"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
@@ -41,10 +41,10 @@ func Load(path string)(Config,error){
|
||||
cfg:=Default();var err error
|
||||
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}}
|
||||
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_"});if err!=nil{return Config{},err}
|
||||
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc).
|
||||
cfg.Server,err=config.ApplyEnv(cfg.Server,"");if err!=nil{return Config{},err}
|
||||
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{}}
|
||||
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_",Empty:"ignore",Aliases:config.ServerEnvAliases("GO_OMNI_","server")});if err!=nil{return Config{},err}
|
||||
output,outputSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT");if !outputSet {output=os.Getenv("GO_OMNI_LOG_OUTPUT")}
|
||||
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.Rotation=logx.RotationConfig{}}
|
||||
return cfg,cfg.Validate()
|
||||
}
|
||||
func(c Config)Validate()error{
|
||||
|
||||
@@ -28,6 +28,8 @@ type Context struct {
|
||||
{{end}}
|
||||
}
|
||||
// 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.
|
||||
func(c *Context)Init(ctx context.Context)error {return nil}
|
||||
|
||||
@@ -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]}
|
||||
if err=cfg.Validate();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)
|
||||
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
|
||||
@@ -4,7 +4,11 @@ server:
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
{{if .API}} endpoints:
|
||||
{{if .API}} lifecycle:
|
||||
stopTimeout: 5s
|
||||
drainTimeout: 10s
|
||||
cancelTimeout: 5s
|
||||
endpoints:
|
||||
liveness:
|
||||
enabled: true
|
||||
path: /health/live
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.14.0-preview"
|
||||
const defaultFrameworkVersion = "v0.16.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
66
config/aliases_test.go
Normal file
66
config/aliases_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"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/response"
|
||||
)
|
||||
@@ -27,22 +28,23 @@ type TLSConfig struct {
|
||||
|
||||
// Config controls the default runtime behavior of an App.
|
||||
type Config struct {
|
||||
Request contract.RequestPolicy `json:"request"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Endpoints EndpointsConfig `json:"endpoints"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
LogInstance *logx.Logger `json:"-"`
|
||||
Request contract.RequestPolicy `json:"request"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Endpoints EndpointsConfig `json:"endpoints"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
LogInstance *logx.Logger `json:"-"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -51,6 +53,7 @@ func Default() Config {
|
||||
Transport: TransportNetHTTP,
|
||||
RequestIDHeader: defaultRequestIDHeader,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
Lifecycle: lifecycle.DefaultShutdownConfig(),
|
||||
Endpoints: DefaultEndpoints(),
|
||||
Log: logx.Default(),
|
||||
}
|
||||
@@ -58,6 +61,9 @@ func Default() Config {
|
||||
|
||||
// Validate checks configuration values before an application starts.
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Lifecycle.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Request.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
168
config/env.go
168
config/env.go
@@ -1,135 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultEnvPrefix = "GO_OMNI_"
|
||||
|
||||
// LoadFromEnv loads application settings from GO_OMNI_* variables. The
|
||||
// database settings remain owned by database.ConfigFromEnv.
|
||||
func LoadFromEnv(prefix string) (Config, error) {
|
||||
return applyEnv(Default(), prefix)
|
||||
// ServerEnvAliases returns the documented short names for Config fields.
|
||||
// field is the JSON path of Config in a project (usually "server"), or ""
|
||||
// for Config itself. Canonical names win over aliases. Both use the same
|
||||
// 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
|
||||
// settings, then validates the result. Caller-owned slices are not retained.
|
||||
// LoadFromEnv loads defaults using the legacy short-name conventions.
|
||||
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) {
|
||||
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)
|
||||
if prefix == "" {
|
||||
prefix = defaultEnvPrefix
|
||||
}
|
||||
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
||||
cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport)
|
||||
cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", cfg.TLS.CertFile)
|
||||
cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", cfg.TLS.KeyFile)
|
||||
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
|
||||
cfg.TrustedProxies = strings.Split(value, ",")
|
||||
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)
|
||||
lookup := func(key string) (string, bool) {
|
||||
raw, ok := os.LookupEnv(key)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
|
||||
values := strings.Split(raw, ",")
|
||||
for i := range values {
|
||||
values[i] = strings.TrimSpace(values[i])
|
||||
}
|
||||
data, _ := json.Marshal(values)
|
||||
raw = string(data)
|
||||
}
|
||||
return raw, ok
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" {
|
||||
cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
|
||||
defaults.Errors = defaults.Errors.Clone()
|
||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
||||
cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); 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 {
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func LoadFile(path, prefix string) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return applyEnv(cfg, prefix)
|
||||
return ApplyEnv(cfg, prefix)
|
||||
}
|
||||
|
||||
// Reloader only accepts log.level changes. Static changes and invalid files
|
||||
|
||||
37
config/lifecycle_test.go
Normal file
37
config/lifecycle_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ type EnvOptions struct {
|
||||
Prefix string
|
||||
Empty string
|
||||
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.
|
||||
@@ -162,13 +165,22 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
|
||||
}
|
||||
seen[key] = true
|
||||
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 {
|
||||
raw, present = field.Tag.Lookup("envDefault")
|
||||
present = present && dst.IsZero()
|
||||
}
|
||||
if present && raw == "" && !defaults {
|
||||
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" {
|
||||
present = false
|
||||
@@ -177,7 +189,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
|
||||
if present {
|
||||
parsed := reflect.New(dst.Type()).Elem()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
|
||||
|
||||
使用 GORM 的项目可显式接入 [gormx 可选适配器](gormx/README.md),复用本包连接池,并将 SQL 日志接入 logx;普通 database 包不导入 GORM。
|
||||
|
||||
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
|
||||
104
database/gormx/README.md
Normal file
104
database/gormx/README.md
Normal 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
121
database/gormx/gorm.go
Normal 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
173
database/gormx/gorm_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
157
database/gormx/integration_test.go
Normal file
157
database/gormx/integration_test.go
Normal 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
171
database/gormx/logger.go
Normal 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 ©
|
||||
}
|
||||
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)
|
||||
92
database/gormx/logger_test.go
Normal file
92
database/gormx/logger_test.go
Normal 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
52
database/gormx/pool.go
Normal 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)
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
|
||||
|
||||
项目使用 GORM 时,在 svc.Init 中调用 [gormx.MySQL](../gormx/README.md) 即可复用这个池、TLS 和清理流程。
|
||||
|
||||
生成项目选择 `--database mysql` 即接入:
|
||||
|
||||
```sh
|
||||
|
||||
23
examples/contracts/arrays.api
Normal file
23
examples/contracts/arrays.api
Normal 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)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"templateVersion": "v0.14.0-preview",
|
||||
"templateVersion": "v0.16.0-preview",
|
||||
"profile": {
|
||||
"database": "none",
|
||||
"storage": "none",
|
||||
@@ -10,17 +10,17 @@
|
||||
"module": "example.com/managed",
|
||||
"entry": "api/managed.api",
|
||||
"generated": {
|
||||
"docs/openapi.json": "68c7e9e0294bf62937aa953a44732288c51571108c922a36a72faa72c4360b5f",
|
||||
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
|
||||
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
|
||||
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
|
||||
"internal/handler/openapiGen.go": "ae9b794c63e0f3c8f9d200a5fc6977350139d4a6f00b42a26102c8630d907d03",
|
||||
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
|
||||
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
||||
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
||||
"internal/svc/runtimeGen.go": "78ffd0b2a196fbc29e29473919d1341e8a2f508f6594ad148c0b399fbcc40018",
|
||||
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
||||
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
||||
"main.go": "8a4051c0d51fc14ddf22c36d09de706f02499817accc937f0785e3d0fb5e2aa5"
|
||||
"main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
|
||||
},
|
||||
"contract": {
|
||||
"routes": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ module example.com/managed
|
||||
|
||||
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 (
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -38,16 +37,19 @@ func Load(path string) (Config, error) {
|
||||
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 {
|
||||
return Config{}, err
|
||||
}
|
||||
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc).
|
||||
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
output, outputSet := os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT")
|
||||
if !outputSet {
|
||||
output = os.Getenv("GO_OMNI_LOG_OUTPUT")
|
||||
}
|
||||
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.Rotation = logx.RotationConfig{}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,7 +37,11 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
|
||||
if err != nil {
|
||||
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)
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
"flag"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"log"
|
||||
"os"
|
||||
@@ -33,7 +34,13 @@ func run(ctx context.Context, path string) (runErr error) {
|
||||
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
|
||||
|
||||
4
go.mod
4
go.mod
@@ -19,6 +19,8 @@ require (
|
||||
golang.org/x/net v0.33.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,6 +38,8 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // 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/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -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/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/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/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
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/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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=
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# 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#依赖主动申请退出)。
|
||||
|
||||
手写服务也可组合现有 App:
|
||||
|
||||
184
lifecycle/phases.go
Normal file
184
lifecycle/phases.go
Normal 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
234
lifecycle/phases_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,42 @@ import (
|
||||
// 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.
|
||||
type Runtime struct {
|
||||
Lifecycle *Controller
|
||||
mu sync.Mutex
|
||||
cleanups []func() error
|
||||
closed bool
|
||||
once sync.Once
|
||||
closeErr error
|
||||
Lifecycle *Controller
|
||||
mu sync.Mutex
|
||||
cleanups []func() error
|
||||
closed bool
|
||||
once sync.Once
|
||||
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 (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
||||
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
||||
func NewRuntime(ctx context.Context) *Runtime {
|
||||
r, _ := NewRuntimeWithConfig(ctx, DefaultShutdownConfig())
|
||||
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,
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Close releases all resources in reverse order, including after a failed
|
||||
// initialization. All callbacks are attempted even if one fails or panics.
|
||||
// Close runs stop and drain barriers before releasing resources in reverse
|
||||
// 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 {
|
||||
if r == nil {
|
||||
return nil
|
||||
@@ -48,9 +75,43 @@ func (r *Runtime) Close() error {
|
||||
r.RequestShutdown(nil)
|
||||
r.mu.Lock()
|
||||
r.closed = true
|
||||
cleanups := r.cleanups
|
||||
cleanups, stops, drains := r.cleanups, r.stops, r.drains
|
||||
r.cleanups = nil
|
||||
r.stops, r.drains = nil, nil
|
||||
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-- {
|
||||
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
python3 scripts/verifyManagedRelease.py --version v0.14.0-preview --report reports/businessDevelopmentManagedRemote.json
|
||||
|
||||
74
reports/businessDevelopmentConnectorRemote.json
Normal file
74
reports/businessDevelopmentConnectorRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
68
reports/businessDevelopmentManagedRemote.json
Normal file
68
reports/businessDevelopmentManagedRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
reports/contractFollowupAcceptance.md
Normal file
34
reports/contractFollowupAcceptance.md
Normal 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 类型语法或替业务完成数据库、权限、事务迁移。
|
||||
40
reports/contractFollowupDocuments.json
Normal file
40
reports/contractFollowupDocuments.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
reports/contractFollowupLocal.json
Normal file
59
reports/contractFollowupLocal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
reports/contractFollowupRegression.json
Normal file
59
reports/contractFollowupRegression.json
Normal 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
42
reports/gormAcceptance.md
Normal 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
82
reports/gormLocal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
68
reports/gormManagedRemote.json
Normal file
68
reports/gormManagedRemote.json
Normal 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
82
reports/gormRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
reports/phasedLifecycleAcceptance.md
Normal file
48
reports/phasedLifecycleAcceptance.md
Normal 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。
|
||||
59
reports/phasedManagedLocal.json
Normal file
59
reports/phasedManagedLocal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
78
reports/phasedManagedRemote.json
Normal file
78
reports/phasedManagedRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
87
reports/phasedMySQLLocal.json
Normal file
87
reports/phasedMySQLLocal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
87
reports/phasedMySQLRemote.json
Normal file
87
reports/phasedMySQLRemote.json
Normal 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
48
scripts/testdata/gormGenerated_test.go
vendored
Normal 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
85
scripts/testdata/phasedMySQL_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ def main():
|
||||
source.add_argument("--version")
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
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()
|
||||
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")
|
||||
@@ -70,13 +72,16 @@ def main():
|
||||
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
||||
project = temp / "consumer"
|
||||
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")
|
||||
if args.local:
|
||||
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
|
||||
else:
|
||||
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.parent.mkdir(exist_ok=True, parents=True)
|
||||
shutil.copyfile(ROOT / name, target)
|
||||
@@ -98,6 +103,20 @@ def main():
|
||||
command += ["--framework-dir", ROOT]
|
||||
run([*command, generated], temp, env)
|
||||
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)
|
||||
generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env))
|
||||
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]
|
||||
assert "TestMySQLConnectorTLSAndPoolIsolation" 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
|
||||
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"],
|
||||
@@ -144,23 +165,34 @@ def main():
|
||||
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
|
||||
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
|
||||
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)
|
||||
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]
|
||||
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
|
||||
passed += generated_passed
|
||||
image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip()
|
||||
finally:
|
||||
if created:
|
||||
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,
|
||||
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
|
||||
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
|
||||
"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.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print("PASS; isolated container, credentials, certificates and consumer removed", flush=True)
|
||||
|
||||
@@ -18,6 +18,55 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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("{")]
|
||||
@@ -81,11 +133,13 @@ def main():
|
||||
evidence = "".join(e.get("Output", "") for e in events)
|
||||
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"
|
||||
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/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"],
|
||||
"freshModuleCache": True, "noLocalReplace": True,
|
||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||
|
||||
60
task/phases_test.go
Normal file
60
task/phases_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
29
task/task.go
29
task/task.go
@@ -227,10 +227,9 @@ func run(ctx context.Context, fn Func) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown stops schedules and submission, then drains accepted jobs. Deadline
|
||||
// expiry cancels running/queued jobs and returns without waiting for code that
|
||||
// ignores cancellation. Such code cannot be forcibly stopped by Go.
|
||||
func (r *Runner) Shutdown(ctx context.Context) error {
|
||||
// StopAccepting stops schedules and submission without canceling accepted jobs.
|
||||
// Pair it with Wait to establish completion before releasing dependencies.
|
||||
func (r *Runner) StopAccepting(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
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) }()
|
||||
}
|
||||
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 {
|
||||
case <-r.done:
|
||||
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 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
Reference in New Issue
Block a user