diff --git a/CHANGELOG.md b/CHANGELOG.md
index b928528..5c55223 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## v0.20.0-preview(2026-09-26)
+
+- 生成项目配置加载入口持续由生成器维护;保留业务字段、默认值、校验与 NewLoader 扩展文件。
+- config.LoadProject 统一 JSON/YAML/XML、环境覆盖和校验;提供实例级 Decoder 注册。
+- 文件/字段/环境/行列的安全诊断,Invalid/AtField 与原始错误链。
+- 增加格式等价、精度、null/空数组、并发、资源限制、默认值隔离、真实生成项目与性能验收。
+- 旧手写 Load 必须显式迁移;低层 API 保留。固定数组严格长度、YAML alias/merge/custom tag 拒绝。用法及限制见 CONFIG.md。
+
+
## v0.19.0-preview(2026-09-25)
- 新增通用可选 copyx:构造器、浅/深拷贝、精确类型转换、字段规则、nil/零值策略、并发复用。
diff --git a/CONFIG.md b/CONFIG.md
new file mode 100644
index 0000000..634148d
--- /dev/null
+++ b/CONFIG.md
@@ -0,0 +1,178 @@
+# 统一配置加载
+
+v0.20 起,新生成服务的 `main.go` 调用生成器维护的 `config.Load`。项目只需要定义业务字段、默认值和校验;日志、HTTP、数据库等配置使用同一流程,配置加载时不打开数据库、监听端口或创建日志文件。
+
+```text
+代码默认值 → envDefault(仅填零值)→ 可选配置文件
+→ 完整结构体环境覆盖(含别名)→ 框架规范化 → 项目 Validate
+→ 初始化日志及项目依赖 → 启动服务
+```
+
+## 生成项目要维护什么
+
+| 文件 | 维护方 | 用途 |
+| --- | --- | --- |
+| `main.go` | 生成器 | 启动、信号、请求排空、依赖关闭 |
+| `internal/config/loadGen.go` | 生成器 | 调用统一加载入口;纳入 `generate --check` |
+| `internal/config/config.go` | 项目 | `Config`、`Default()`、`Validate()` |
+| `internal/config/loader.go` | 项目 | `NewLoader()`,只有增加文件格式时才需修改 |
+| `etc/config.yaml` | 项目 | 部署配置;可改用 JSON/XML 并传 `-config` |
+| `internal/svc` | 项目与生成器分文件 | 初始化依赖、登记清理、AppOptions |
+
+比如增加业务的批次数量:在 Config 增加一个字段,在 Validate 加范围校验;需要部署值时改配置文件或环境变量。不修改 main、Load 或 Handler。
+
+```go
+// 添加到项目 Config。
+BatchSize int `json:"batchSize" envDefault:"100"`
+
+// 添加到项目 Validate。
+if c.BatchSize < 1 || c.BatchSize > 1000 {
+ return omniConfig.Invalid("batchSize", "must be between 1 and 1000")
+}
+```
+
+自动支持 `GO_OMNI_BATCH_SIZE`。生成服务的空环境值策略为 `ignore`,0 和 false 是有效覆盖;范围由 Validate 决定。
+重新生成保留 config.go、loader.go 和 svc 的手写扩展。
+
+## 手写项目用法
+
+```go
+package main
+
+import (
+ "time"
+ "git.yuntuo.hk/k2safe/go-omni/config"
+)
+
+type Config struct {
+ Timeout time.Duration `json:"timeout" envDefault:"3s"`
+ Limit int `json:"limit" envDefault:"100"`
+ Token string `json:"-" env:"APP_TOKEN,secret"`
+}
+func (c Config) Validate() error {
+ if c.Timeout <= 0 {
+ return config.Invalid("timeout", "must be positive")
+ }
+ return nil
+}
+
+// cfg, err := config.LoadProject("etc/config.yaml", Config{},
+// config.EnvOptions{Prefix: "APP_", Empty: "reject"})
+```
+
+`LoadProject` 接收结构体默认值,可实现值接收器或指针接收器的 `Validate() error`。空路径表示不读文件。
+`DecodeFile` 只做文件覆盖,不做 envDefault、环境覆盖或业务 Validate;旧的两参数调用仍有效,可用结构体或结构体指针。低层 `ApplyDefaults`、`OverlayEnv`、`LoadFile` 继续存在。
+
+嵌套校验用 `config.AtField("client", err)` 加路径前缀。公共原因通过 `config.Invalid` 提供,原因中不要包含密码、DSN、Token 或输入原值。
+
+## JSON / YAML / XML
+
+按文件扩展名选择解析器:`.json`、`.yaml`、`.yml`、`.xml`,大小写不敏感。所有格式使用 Go 结构体的 **json 字段名**,无需为 XML 复制一份结构或标签。
+以下配置等价:
+
+```json
+{"client":{"timeout":"2s"},"enabled":false,"ports":[8080,8081],"optional":null}
+```
+
+```yaml
+client:
+ timeout: 2s
+enabled: false
+ports: [8080, 8081]
+optional: null
+```
+
+```xml
+
+ 2s
+ false
+ - 8080
- 8081
+
+
+```
+
+XML 必须使用一个 `` 根;普通叶节点按目标 Go 类型转换。数组显式 `type="array"` 加 `- `,不根据重复元素猜测类型。`type="array"` 空元素表示空数组,`type="object"` 表示空对象,`type="null"` 表示 null。`any` 中的 XML 叶节点默认是字符串,需要动态数字/布尔时标注 `type="number"` / `type="bool"`;也可用 `type="string"`。
+
+类型和覆盖规则:
+
+- 缺失字段保留默认值;显式 0、false、空字符串正常覆盖。
+- null 清空指针、map、slice、interface;普通值字段保持默认值,沿用 Go JSON 解码语义。需要区分 null 时用指针。
+- 数组整体替换;空 slice 保持非 nil;固定长度 Go 数组要求长度一致,避免丢弃多余配置。
+- map 合并键,已提供的键整体覆盖对应值;map 中未提供的键保留。
+- duration 支持 `250ms`、`3s`;整数保持精度,动态 JSON 数字使用 `json.Number`,不先转 float64。
+- 自定义 `json.Unmarshaler` / `encoding.TextUnmarshaler` 仍可接入。这些回调是可信项目代码,必须自己保证并发和副作用安全;不要修改共享外部状态。
+- JSON `,string` 字段、嵌入字段遵循 JSON 字段选择;未知字段、重复绑定、冲突均拒绝。
+
+内置解析器限制:单文件最多 1 MiB,解析最多 64 层、100000 个节点;绑定和默认值复制也有深度限制。只接受普通文件,不读取设备或命名管道。不支持多文档、重复字段、无效 UTF-8;YAML 拒绝 alias/merge/custom tag,XML 拒绝 DTD/外部实体、命名空间、业务属性和混合内容。XML 仅支持配置所需的子集,不是通用 XML 文档映射器。
+
+这些限制针对启动配置,避免意外超大文件、别名扩展和模糊覆盖。自定义值解码器的内部字段校验由该解码器负责;例如现有错误目录使用其专有解码规则。
+
+## 环境覆盖
+
+统一入口的 EnvOptions 控制前缀、空值策略、别名和 Lookup。完整 JSON 路径自动转换为 UPPER_SNAKE_CASE:`client.timeout` → `APP_CLIENT_TIMEOUT`。
+
+```go
+config.EnvOptions{
+ Prefix: "GO_OMNI_", Empty: "ignore",
+ Aliases: config.ServerEnvAliases("GO_OMNI_", "server"),
+}
+```
+
+生成项目同时接受完整的 `GO_OMNI_SERVER_HTTP_READ_TIMEOUT` 与短别名 `GO_OMNI_HTTP_READ_TIMEOUT`。完整名存在时优先,即便其空值被 ignore,也不回退别名。不会再二次调用 ApplyEnv 覆盖业务字段。
+
+显式 `env:"CUSTOM_NAME"` 是绝对名称;`env:"-"` 跳过子树。`json:"-"` 只在声明显式 env 名时参与,可让密钥只能从环境加载。`secret` 标签表达用途,解析错误对所有字段都不打印值。slice/map 的环境值采用 JSON。`required` 检查字符串非空、指针/集合非 nil;复杂范围由 Validate 负责。详见 [完整业务环境覆盖](DEVELOPMENT.md#完整业务配置的环境覆盖)。
+
+显式环境选择日志 stdout/stderr 会清除文件 profile 的 File/Rotation,除非同时提供非空 File 覆盖。该兼容规则由框架处理。
+
+## 扩展解析器
+
+内置格式直接可用。新增格式只在 `internal/config/loader.go` 的 `NewLoader` 注册:
+
+```go
+func NewLoader() (*omniConfig.Loader, error) {
+ return omniConfig.NewLoader(
+ omniConfig.WithDecoder("custom", omniConfig.DecoderFunc(
+ func(data []byte) (omniConfig.Document, error) {
+ // 演示注册扩展名;实际可在这里调用项目选定的 TOML 等解析库。
+ return (omniConfig.JSONDecoder{}).Decode(data)
+ },
+ )),
+ )
+}
+```
+
+Decoder 返回 Document:Value 是 map[string]any 根,子值为 string/bool/json.Number/nil/[]any/map[string]any;无自身类型的叶节点可返回 config.Text。Positions 可选,用 `client.timeout` 或 `servers[0].timeout` 标记 1-based 行列。不提供位置时仍有文件、字段、原因。
+
+自己的语法错误可返回 `&config.Error{Field: "client.timeout", Line: 3, Column: 5, Reason: "expected a duration"}`。Reason 必须安全;普通第三方错误仅公开 `invalid configuration syntax`,内部 cause 通过 errors.Is/As 保留。
+
+注册表属于 Loader 实例,无全局 init 注册;不能覆盖已有扩展名。Loader 可共享,但 Decoder 必须支持并发且不保留/修改输入。解析插件是可信 Go 代码,框架不是插件沙箱。新格式依赖由选择它的项目显式引入,核心不强制引入 TOML 等库。
+
+## 定位配置错误
+
+```text
+config "etc/config.yaml":4:12 field "client.timeout": expected a duration such as 5s
+config env "APP_CLIENT_TIMEOUT" field "client.timeout": expected a valid time.Duration
+```
+
+`errors.As(err, &diagnostic)` 可获取 `*config.Error` 的 File/Field/Env/Line/Column/Reason;原 `DecodeError` 名称保留为别名。`errors.Is` 可判断文件不存在等 I/O 原因。
+
+解析失败显示文件和可获取的行列;Validate 使用 Invalid/AtField 才能定位业务字段。环境覆盖导致的错误优先标环境变量,不错误地归因到文件;数组元素验证也会追溯到覆盖它的集合环境变量。默认值未出现在文件里时没有行列。YAML 语法错误通常只有行号,列显示 1。
+
+不打印底层错误原文或配置值;任意普通 `errors.New(...)` 校验失败的公开原因是 `validation failed`,内部原因仍在链中。项目应改用 Invalid 给出安全的原因。第三方子配置校验若没有字段元数据,只定位到对应子树,框架不会从任意错误文案猜字段。
+
+不要把 Unwrap 后的原始错误、整个配置对象写入公开日志/HTTP;自定义解析器与校验器传入的 Reason 由项目负责脱敏。
+
+## v0.19 及更早项目迁移
+
+先提交现有代码,再升级匹配的 CLI 与框架。旧项目的 `internal/config/config.go` 含手写 Load,新生成器会明确阻止 generate/check/dry-run,不会直接覆盖它。
+
+1. 保留 Config、Default、Validate、业务配置结构及辅助函数。
+2. 删除旧 Load。普通的 defaults/file/env/Validate 流程已由 LoadProject 接管;移除由此不再使用的 import。将自定义默认值和校验分别迁入 Default / Validate,自定义文件格式迁入 NewLoader。
+3. 框架只在 `loader.go` 不存在时创建 NewLoader;已有项目文件同名时需要保证它提供同签名函数。无需手写 loadGen.go。
+4. 重新生成,执行 `generate --check`、`go test ./...`、`go build .`,再用真实配置验证启动。
+
+保留业务需要的显式 env 标签/规则;如果原 Load 有解密或其他项目特殊步骤,不要直接删除,应先明确其归属,必要时在自定义 Decoder 中处理。生成器不自动搬动或猜测手写行为。`api upgrade` 仍可预览较早版本的基础设施差异,但已修改的配置必须按上述步骤迁移。
+
+不会移除低层配置 API;本次移除的是**新项目里重复手写的加载流程**,不删除旧项目的业务文件。
+
+完整可运行例子:[examples/configuration](examples/configuration/README.md)。
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index f4419e4..fcc2b27 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -1,4 +1,4 @@
-# 通用业务开发指南(v0.19.0-preview)
+# 通用业务开发指南(v0.20.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
@@ -102,7 +102,9 @@ defer reader.Close()
## 完整业务配置的环境覆盖
-生成项目默认执行:代码与 `envDefault` 默认值 → JSON/YAML 文件 → 全结构体环境覆盖 → 既有框架环境别名 → `Validate`。增加业务字段不需要另外写 `os.Getenv`。
+生成器维护 `internal/config/loadGen.go`;项目维护 Config/Default/Validate 和可选 NewLoader。三种格式、可扩展解析器、字段错误定位及旧 Load 迁移见 [统一配置指南](CONFIG.md)。
+
+生成项目默认执行:代码与 `envDefault` 默认值 → JSON/YAML/XML 文件 → 全结构体环境覆盖(含别名)→ 规范化 → `Validate`。增加业务字段不需要另外写 `os.Getenv`。
```go
type ClientConfig struct {
@@ -278,7 +280,7 @@ go-omni workspace check --file omni.workspace.json
## v0.13 项目升级
-先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.19.0-preview(或显式引用当前源码)。
+先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.20.0-preview(或显式引用当前源码)。
```sh
go-omni api upgrade --dir . --dry-run
diff --git a/PROJECT.md b/PROJECT.md
index b8958b8..342dc85 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -1,6 +1,6 @@
# 生成项目与文件维护边界
-v0.19.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
+v0.20.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -38,7 +38,7 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
-本版新增生成选项、公共字段组合和映射,详见 [生成能力指南](GENERATION.md)。
+生成选项、公共字段组合和可选 Go 拷贝工具,详见 [生成能力指南](GENERATION.md)。
## 哪些文件由用户维护
@@ -46,7 +46,8 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
| --- | --- |
| `api/*.api` | 路由、DTO、具名中间件、响应模板、请求/响应模式 |
| `etc/config.yaml` | 运行参数;密钥通过环境变量提供 |
-| `internal/config/config.go` | 自定义配置字段、加载、默认值与校验 |
+| `internal/config/config.go` | 自定义配置字段、默认值与校验 |
+| `internal/config/loader.go` | 需要额外文件格式时注册 Decoder |
| `internal/svc/serviceContext.go` | 依赖字段和初始化,持有加载后的 Config |
| `internal/svc/options.go` | AppOptions、GlobalMiddleware;注册 Go 错误目录和全局扩展 |
| `internal/svc/databaseOptions.go` | 可选 MySQL 驱动与项目启动检查扩展 |
@@ -62,7 +63,7 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
-生成器持续管理:`internal/svc/runtimeGen.go`、`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
+生成器持续管理:`internal/config/loadGen.go`、`internal/svc/runtimeGen.go`、`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
@@ -114,6 +115,8 @@ c.RequestShutdown(err)
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
+配置加载入口 `internal/config/loadGen.go` 也由生成器维护。业务扩展 Config/Default/Validate;只有新格式才需要修改 NewLoader。JSON/YAML/XML、错误定位和旧 Load 迁移见 [配置指南](CONFIG.md)。
+
## 配置内置接口
生成项目在 `etc/config.yaml` 明确列出以下默认配置;可以独立关闭、修改路径,或让健康接口返回原始 JSON:
@@ -212,7 +215,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
-每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.19.0-preview:
+每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.20.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 和提前返回的中间件一起验收。
diff --git a/README.md b/README.md
index 4f67f4c..0f84260 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
-**从这份 README 开始接入。** 当前版本 **`v0.19.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
+**从这份 README 开始接入。** 当前版本 **`v0.20.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
@@ -38,7 +38,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
-| 文件配置与环境覆盖 | 默认值→JSON/YAML→环境;完整业务结构用 `config.LoadProject[T]` / 环境别名 | [配置详解与例子](DEVELOPMENT.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
+| 文件配置与环境覆盖 | 默认值→JSON/YAML/XML→环境→校验;完整业务结构用 `config.LoadProject[T]` / 环境别名 | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
@@ -54,6 +54,29 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
+## 统一配置加载
+
+新生成项目的加载流程由框架管理。业务在 `internal/config/config.go` 增加字段、默认值和 `Validate`,无需改 main 或复制环境变量解析。JSON、YAML、XML 共用字段和校验;扩展格式在 `loader.go` 注册。
+
+```go
+// 项目 Config 中增加字段,自动支持 GO_OMNI_BATCH_SIZE:
+BatchSize int `json:"batchSize" envDefault:"100"`
+
+// 项目 Validate 中增加业务范围检查:
+if c.BatchSize < 1 {
+ return omniConfig.Invalid("batchSize", "must be positive")
+}
+```
+
+错误包含文件、字段、可用的行列或环境变量名;输入原值不自动写入错误。
+
+```sh
+go run ./examples/configuration -format xml
+DEMO_CLIENT_TIMEOUT=500ms go run ./examples/configuration
+```
+
+详见 [统一加载、解析器、迁移](CONFIG.md) 和 [完整示例](examples/configuration/README.md)。**旧项目须先迁移手写 Load,再重新生成**;生成器会保留业务配置并拒绝冲突,不静默覆盖。
+
## 选择接入方式
| 你的项目 | 从哪里开始 |
@@ -74,7 +97,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.19.0-preview
+go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -231,7 +254,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
-go get git.yuntuo.hk/k2safe/go-omni@v0.19.0-preview
+go get git.yuntuo.hk/k2safe/go-omni@v0.20.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -533,7 +556,7 @@ go test ./...
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
-CLI 与业务 module 固定使用 `v0.19.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
+CLI 与业务 module 固定使用 `v0.20.0-preview`。v0.14–v0.19 项目先按 [配置入口迁移](CONFIG.md#v019-及更早项目迁移) 整理旧 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入
diff --git a/RELEASE.md b/RELEASE.md
index fb60975..e826058 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,4 +1,28 @@
-# v0.19.0-preview
+# v0.20.0-preview
+
+统一配置加载,减少生成项目重复维护的启动代码。
+
+- 生成器维护 `internal/config/loadGen.go`,调用 `config.LoadProject`;项目只扩展 Config/Default/Validate,可选在 loader.go/NewLoader 注册新格式。入口、环境覆盖、日志输出规范化无需业务重复实现。
+- JSON/YAML/XML 使用同一个加载与绑定流程;Decoder 通过实例注册,无全局 init 或额外强制依赖。内置格式、字段、文件大小与层数均有明确限制。
+- `config.Error` 携带文件、字段、环境变量、可用行列及安全原因;`Invalid` / `AtField` 支持业务验证。保留内部错误链,不自动输出原值;大整数、空集合、null、默认值隔离和自定义反序列化有回归覆盖。
+- 配置解析在日志、依赖和监听初始化之前;生成/check/dry-run 使用一致入口和文件所有权规则。
+- **迁移要求:旧项目先整理手写 Load,再重新生成。** 不静默覆盖业务文件。低层配置 API 保留;新项目不再复制手写加载流程。固定 Go 数组严格检查长度,YAML alias/merge/custom tag 不再接受,避免模糊覆盖和扩展行为。
+
+```sh
+go get git.yuntuo.hk/k2safe/go-omni@v0.20.0-preview
+go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
+# 先按 CONFIG.md 迁移旧 Load,再执行:
+go-omni api generate
+go-omni api generate --check
+go test ./...
+go build .
+```
+
+完整用法、格式、脱敏边界和迁移见 [统一配置指南](CONFIG.md),可运行例子见 [examples/configuration](examples/configuration/README.md)。回归、模糊测试与启动加载性能见 [验收记录](reports/configurationAcceptance.md)。仍为 preview;项目的实际配置和业务依赖应自行验收。
+
+---
+
+# v0.19.0-preview(历史版本)
将可选结构转换改为直接 Go 调用,减少业务项目需要维护的配置。普通接口继续使用 `.api` 生成 DTO,不要求增加独立模型或任何拷贝规则。
diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md
index ea60944..2cfeaaf 100644
--- a/cmd/go-omni/README.md
+++ b/cmd/go-omni/README.md
@@ -14,7 +14,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.19.0-preview
+go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -49,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
-CLI 与框架依赖固定使用 v0.19.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
+CLI 与框架依赖固定使用 v0.20.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.19.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
+当前模板与依赖以 v0.20.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令
@@ -69,3 +69,7 @@ go-omni migrate --dir migrations --apply
```
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
+
+### v0.20 配置入口
+
+新生成 `internal/config/loadGen.go` 调用统一加载,纳入生成检查;项目 config.go 只维护字段、默认值和校验,loader.go 用于注册额外格式。JSON/YAML/XML 内置。旧手写 Load 会阻止生成,请先按 [迁移步骤](../../CONFIG.md#v019-及更早项目迁移) 整理,不会自动覆盖业务代码。
diff --git a/cmd/go-omni/apigen/configLoader_test.go b/cmd/go-omni/apigen/configLoader_test.go
new file mode 100644
index 0000000..dc8e467
--- /dev/null
+++ b/cmd/go-omni/apigen/configLoader_test.go
@@ -0,0 +1,113 @@
+package apigen
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestGeneratedConfigurationConsumer(t *testing.T) {
+ if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
+ t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
+ }
+ dir := filepath.Join(t.TempDir(), "app")
+ api := filepath.Join(t.TempDir(), "service.api")
+ writeFile(t, api, "service configuration {\n@handler ping\nget /ping\n}\n")
+ o := Options{API: api, Dir: dir, Module: "example.com/configuration", FrameworkDir: frameworkRoot(t)}
+ if err := generateManagedTestProject(t, o); err != nil {
+ t.Fatal(err)
+ }
+ main := readFile(t, filepath.Join(dir, "main.go"))
+ // The business only changes fields/defaults/validation, not the loading flow.
+ writeFile(t, filepath.Join(dir, "internal/config/config.go"), `package config
+ import("git.yuntuo.hk/k2safe/go-omni/config";"git.yuntuo.hk/k2safe/go-omni/logx")
+ type Config struct { Server config.Config `+"`json:\"server\"`"+`;Limit int `+"`json:\"limit\" envDefault:\"10\"`"+` }
+ func Default()Config{ c:=config.Default();c.Log.File="./var/log/app.log";c.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:10};return Config{Server:c} }
+ func(c Config)Validate()error{if err:=c.Server.Validate();err!=nil{return config.AtField("server",err)};if c.Limit<0{return config.Invalid("limit","must not be negative")};return nil}
+ `)
+ writeFile(t, filepath.Join(dir, "internal/config/loader.go"), `package config
+ import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
+ func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader(omniConfig.WithDecoder("custom",omniConfig.JSONDecoder{}))}
+ `)
+ svc := filepath.Join(dir, "internal/svc/serviceContext.go")
+ writeFile(t, svc, strings.Replace(readFile(t, svc), "return nil", "panic(\"dependencies must not initialize on invalid config\")", 1))
+ writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/configuration/consumer_test.go"))
+ runConsumerGo(t, dir, "mod", "tidy")
+ runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
+ runConsumerGo(t, dir, "build", ".")
+ o.API = filepath.Join(dir, "api/service.api")
+ before := snapshot(t, dir)
+ for _, mode := range []string{"dry", "generate", "check"} {
+ o.DryRun = mode == "dry"
+ o.Check = mode == "check"
+ if err := generateManagedTestProject(t, o); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !sameFiles(before, snapshot(t, dir)) {
+ t.Fatal("business files overwritten")
+ }
+ if main != readFile(t, filepath.Join(dir, "main.go")) {
+ t.Fatal("main changed")
+ }
+ load := filepath.Join(dir, "internal/config/loadGen.go")
+ original := readFile(t, load)
+ writeFile(t, load, original+"\n// drift\n")
+ if err := generateManagedTestProject(t, o); err == nil {
+ t.Fatal("loadGen drift accepted")
+ }
+ writeFile(t, load, original)
+ if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOWORK=off")
+ data, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatal(err, string(data))
+ }
+ var dependency struct {
+ Version, Sum string
+ Replace any
+ }
+ if err = json.Unmarshal(data, &dependency); err != nil {
+ t.Fatal(err)
+ }
+ if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
+ t.Fatal("not a remote consumer")
+ }
+ t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
+ }
+}
+func TestConfigLoadMigrationGuard(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "app")
+ o := Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}
+ if _, err := New(o); err != nil {
+ t.Fatal(err)
+ }
+ o.API = filepath.Join(dir, "api/service.api")
+ matches, err := filepath.Glob(filepath.Join(dir, "api/*.api"))
+ if err != nil || len(matches) != 1 {
+ t.Fatal(matches, err)
+ }
+ o.API = matches[0]
+ writeFile(t, filepath.Join(dir, "internal/config/legacy.go"), "package config\nfunc Load(path string)(Config,error){return Default(),nil}\n")
+ before := snapshot(t, dir)
+ for _, mode := range []string{"dry", "generate", "check"} {
+ o.DryRun = mode == "dry"
+ o.Check = mode == "check"
+ _, err := Generate(o)
+ if err == nil || !strings.Contains(err.Error(), "CONFIG.md") {
+ t.Fatal("missing migration diagnostic", err)
+ }
+ if !sameFiles(before, snapshot(t, dir)) {
+ t.Fatal("migration guard changed files")
+ }
+ }
+}
diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go
index 8509872..d154939 100644
--- a/cmd/go-omni/apigen/generate.go
+++ b/cmd/go-omni/apigen/generate.go
@@ -19,7 +19,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
-const minimumVersion = "v0.19.0-preview"
+const minimumVersion = "v0.20.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -106,6 +106,9 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if err = checkRuntimeExtension(directory); err != nil {
return Output{}, err
}
+ if err = checkConfigLoadExtension(directory); err != nil {
+ return Output{}, err
+ }
for _, r := range c.Spec.Routes {
if !r.Manual {
path := filepath.Join(directory, "internal/handler", r.Group, lower(r.Handler)+"Manual.go")
@@ -152,7 +155,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(directory, path)
}
- if source, err := os.ReadFile(filepath.Join(path, "config", "env.go")); err == nil && strings.Contains(string(source), "func ServerEnvAliases(") {
+ if source, err := os.ReadFile(filepath.Join(path, "config", "loader.go")); err == nil && strings.Contains(string(source), "func NewLoader(") {
localCurrent = true
}
}
diff --git a/cmd/go-omni/apigen/templates.go b/cmd/go-omni/apigen/templates.go
index 1c53758..c047f19 100644
--- a/cmd/go-omni/apigen/templates.go
+++ b/cmd/go-omni/apigen/templates.go
@@ -58,6 +58,8 @@ Unimplemented/nil factories fail startup and clean registered dependencies.
Existing projects retain middleware: handlers until explicitly migrated.
Ordinary Logic uses the generated Request/Response types directly. Where an
independent business type is needed, use ordinary Go or the optional copyx package.
+Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use one pipeline. See the framework CONFIG.md.
+
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
See COPY.md in the framework repository for copy policies and migration steps,
and GENERATION.md for field composition and middleware factories.
diff --git a/cmd/go-omni/apigen/testdata/configuration/consumer_test.go b/cmd/go-omni/apigen/testdata/configuration/consumer_test.go
new file mode 100644
index 0000000..ebfd12a
--- /dev/null
+++ b/cmd/go-omni/apigen/testdata/configuration/consumer_test.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "example.com/configuration/internal/config"
+ omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestGeneratedConfiguration(t *testing.T) {
+ for ext, source := range map[string]string{
+ "json": `{"limit":7}`, "yaml": "limit: 7\n", "xml": "7", "custom": `{"limit":7}`,
+ } {
+ t.Run(ext, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "app."+ext)
+ if err := os.WriteFile(path, []byte(source), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := config.Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Limit != 7 {
+ t.Fatal(cfg.Limit)
+ }
+ t.Setenv("GO_OMNI_LIMIT", "0")
+ cfg, err = config.Load(path)
+ if err != nil || cfg.Limit != 0 {
+ t.Fatal(cfg.Limit, err)
+ }
+ })
+ }
+ t.Setenv("GO_OMNI_LOG_OUTPUT", "stderr")
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Limit != 10 || cfg.Server.Log.File != "" || cfg.Server.Log.Rotation.Enabled {
+ t.Fatal("defaults/log normalization", cfg, err)
+ }
+ path := filepath.Join(t.TempDir(), "bad.yaml")
+ os.WriteFile(path, []byte("limit: -1\n"), 0600)
+ // Validation must run before svc.Initialize; the project initializer panics
+ // below if reached, so this also detects accidentally starting dependencies.
+ err = run(context.Background(), path)
+ var diagnostic *omniConfig.Error
+ if !errors.As(err, &diagnostic) || diagnostic.Field != "limit" || diagnostic.File != path || diagnostic.Line != 1 {
+ t.Fatal(err)
+ }
+ t.Setenv("GO_OMNI_LIMIT", "private-not-a-number")
+ _, err = config.Load(path)
+ if !errors.As(err, &diagnostic) || diagnostic.Env != "GO_OMNI_LIMIT" || strings.Contains(err.Error(), "private-not-a-number") {
+ t.Fatal(err)
+ }
+}
diff --git a/cmd/go-omni/apigen/upgrade.go b/cmd/go-omni/apigen/upgrade.go
index b97588d..6c81c1e 100644
--- a/cmd/go-omni/apigen/upgrade.go
+++ b/cmd/go-omni/apigen/upgrade.go
@@ -30,6 +30,39 @@ type UpgradePlan struct {
Module, Entry string
}
+// Handwritten old loaders must be migrated explicitly, never silently replaced
+// or left competing with the generated entry point.
+func checkConfigLoadExtension(root string) error {
+ dir := filepath.Join(root, "internal/config")
+ entries, err := os.ReadDir(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "loadGen.go" {
+ continue
+ }
+ rel := "internal/config/" + name
+ if err := safePath(root, rel); err != nil {
+ return err
+ }
+ f, err := goparser.ParseFile(gotoken.NewFileSet(), filepath.Join(root, rel), nil, 0)
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", rel, err)
+ }
+ for _, decl := range f.Decls {
+ if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && fn.Name.Name == "Load" {
+ return fmt.Errorf("%s: migrate the project-owned Load function before generating internal/config/loadGen.go; retain Config/Default/Validate and move decoder options to NewLoader; see CONFIG.md", rel)
+ }
+ }
+ }
+ return nil
+}
+
// PlanUpgrade compares infrastructure against the exact previous release seeds.
// A custom file is never classified as safe to replace. Logic, middleware,
// options and repositories outside the changed templates are left untouched.
diff --git a/cmd/go-omni/apigen/write.go b/cmd/go-omni/apigen/write.go
index 1d80e7d..0ecc9bb 100644
--- a/cmd/go-omni/apigen/write.go
+++ b/cmd/go-omni/apigen/write.go
@@ -284,6 +284,9 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
}
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func ownedPath(name string) bool {
+ if name == "internal/config/loadGen.go" {
+ return true
+ }
if strings.HasPrefix(name, "internal/types/dto") && strings.HasSuffix(name, "Gen.go") && goName.MatchString(strings.TrimSuffix(filepath.Base(name), ".go")) {
return true
}
diff --git a/cmd/go-omni/main_test.go b/cmd/go-omni/main_test.go
index 89848b9..ab0ff58 100644
--- a/cmd/go-omni/main_test.go
+++ b/cmd/go-omni/main_test.go
@@ -8,7 +8,7 @@ import (
)
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
- for _, flag := range [][]string{{"--framework-version", "v0.19.0-preview"}, {"--framework-version=v0.19.0-preview"}} {
+ for _, flag := range [][]string{{"--framework-version", "v0.20.0-preview"}, {"--framework-version=v0.20.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.19.0-preview") || strings.Contains(string(content), "replace ") {
+ if !strings.Contains(string(content), "go-omni v0.20.0-preview") || strings.Contains(string(content), "replace ") {
t.Fatalf("CLI did not pin remote dependency: %s", content)
}
}
diff --git a/cmd/go-omni/scaffold/apiInfrastructure.go b/cmd/go-omni/scaffold/apiInfrastructure.go
index 95af63c..339d215 100644
--- a/cmd/go-omni/scaffold/apiInfrastructure.go
+++ b/cmd/go-omni/scaffold/apiInfrastructure.go
@@ -21,6 +21,8 @@ func apiInfrastructure(module, database, storage, logOutput, logFormat string, l
"internal/svc/runtimeGen.go": "templates/api-runtime.go.tmpl",
"internal/svc/options.go": "templates/api-options.go.tmpl",
"internal/config/config.go": "templates/api-config.go.tmpl",
+ "internal/config/loadGen.go": "templates/api-load.go.tmpl",
+ "internal/config/loader.go": "templates/api-loader.go.tmpl",
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
"internal/svc/serviceContext.go": "templates/api-context.go.tmpl",
}
@@ -31,6 +33,8 @@ func apiInfrastructure(module, database, storage, logOutput, logFormat string, l
files["internal/svc/storage.go"] = "templates/modules-storage.go.tmpl"
}
if legacy {
+ delete(files, "internal/config/loadGen.go")
+ delete(files, "internal/config/loader.go")
delete(files, "internal/svc/runtimeGen.go")
delete(files, "internal/svc/databaseOptions.go")
files["internal/svc/close.go"] = "templates/api-close.go.tmpl"
diff --git a/cmd/go-omni/scaffold/templates/api-config.go.tmpl b/cmd/go-omni/scaffold/templates/api-config.go.tmpl
index 8471d4f..8b47982 100644
--- a/cmd/go-omni/scaffold/templates/api-config.go.tmpl
+++ b/cmd/go-omni/scaffold/templates/api-config.go.tmpl
@@ -1,10 +1,9 @@
package settings
import (
- "os"
{{if and (not .API) (ne .Storage "none")}}"strings"{{end}}
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
- "git.yuntuo.hk/k2safe/go-omni/logx"
+ {{if eq .LogOutput "file"}}"git.yuntuo.hk/k2safe/go-omni/logx"{{end}}
{{if eq .Database "mysql"}}mysqladapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"{{end}}
{{if ne .Storage "none"}}"errors"
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
@@ -35,22 +34,10 @@ func Default() Config {
}{{end}}}
}
-// Load applies defaults -> file -> typed environment -> validation.
-// New fields automatically use GO_OMNI_, or an explicit env tag.
-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_",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{
- if err:=c.Server.Validate();err!=nil{return err}
- {{if eq .Database "mysql"}}if err:=c.Database.Validate();err!=nil{return err}{{end}}
- {{if ne .Storage "none"}}if err:=c.Storage.Validate();err!=nil{return err}{{end}}
+ if err:=c.Server.Validate();err!=nil{return config.AtField("server",err)}
+ {{if eq .Database "mysql"}}if err:=c.Database.Validate();err!=nil{return config.AtField("database",err)}{{end}}
+ {{if ne .Storage "none"}}if err:=c.Storage.Validate();err!=nil{return config.AtField("storage",err)}{{end}}
return nil
}
{{if ne .Storage "none"}}
diff --git a/cmd/go-omni/scaffold/templates/api-load.go.tmpl b/cmd/go-omni/scaffold/templates/api-load.go.tmpl
new file mode 100644
index 0000000..51b82fe
--- /dev/null
+++ b/cmd/go-omni/scaffold/templates/api-load.go.tmpl
@@ -0,0 +1,13 @@
+package config
+
+import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
+
+// Load is maintained by the generator. Extend Config/Default/Validate in
+// config.go, or NewLoader in loader.go; do not duplicate the loading pipeline.
+func Load(path string) (Config, error) {
+ loader,err:=NewLoader()
+ if err!=nil{return Config{},err}
+ return omniConfig.LoadProject(path,Default(),omniConfig.EnvOptions{
+ Prefix:"GO_OMNI_",Empty:"ignore",Aliases:omniConfig.ServerEnvAliases("GO_OMNI_","server"),
+ },loader)
+}
diff --git a/cmd/go-omni/scaffold/templates/api-loader.go.tmpl b/cmd/go-omni/scaffold/templates/api-loader.go.tmpl
new file mode 100644
index 0000000..b5bbfad
--- /dev/null
+++ b/cmd/go-omni/scaffold/templates/api-loader.go.tmpl
@@ -0,0 +1,7 @@
+package config
+
+import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
+
+// NewLoader is project-owned. JSON/YAML/XML work without changes. Register
+// additional format decoders here only when the project needs them.
+func NewLoader() (*omniConfig.Loader,error) { return omniConfig.NewLoader() }
diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go
index 45b5802..599f4f7 100644
--- a/cmd/go-omni/scaffold/version.go
+++ b/cmd/go-omni/scaffold/version.go
@@ -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.19.0-preview"
+const defaultFrameworkVersion = "v0.20.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.
diff --git a/config/bind.go b/config/bind.go
new file mode 100644
index 0000000..a0aa212
--- /dev/null
+++ b/config/bind.go
@@ -0,0 +1,320 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "sort"
+ "strings"
+)
+
+var jsonUnmarshalerType = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
+
+type documentBinder struct {
+ doc Document
+ nodes int
+}
+
+func (b *documentBinder) step(path string, depth int) error {
+ b.nodes++
+ if depth > maxConfigDepth || b.nodes > maxConfigNodes {
+ return b.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
+ }
+ return nil
+}
+
+func bindDocument[T any](doc Document, defaults T) (T, error) {
+ var zero T
+ base := reflect.ValueOf(&defaults).Elem()
+ if base.Kind() != reflect.Struct && (base.Kind() != reflect.Pointer || base.Type().Elem().Kind() != reflect.Struct) {
+ return zero, &Error{Reason: "project configuration must be a struct"}
+ }
+ result, err := cloneConfiguration(base, 0)
+ if err != nil {
+ return zero, &Error{Reason: "cannot clone configuration defaults", cause: err}
+ }
+ b := documentBinder{doc: doc}
+ if err = b.bind(doc.Value, result, "", 0, false); err != nil {
+ return zero, err
+ }
+ return result.Interface().(T), nil
+}
+
+func (b *documentBinder) decode(v any, dst reflect.Value, path string) (err error) {
+ defer func() {
+ if recover() != nil {
+ err = b.doc.diagnostic(path, "custom value decoder panicked", nil)
+ }
+ }()
+ raw, err := json.Marshal(v)
+ if err != nil {
+ return b.doc.diagnostic(path, "unsupported configuration value", err)
+ }
+ if err = json.Unmarshal(raw, dst.Addr().Interface()); err != nil {
+ return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
+ }
+ return nil
+}
+func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int, quoted bool) error {
+ if err := b.step(path, depth); err != nil {
+ return err
+ }
+ if v == nil {
+ return b.decode(nil, dst, path)
+ } // Preserve encoding/json null semantics.
+ if dst.Kind() == reflect.Pointer {
+ if dst.IsNil() {
+ dst.Set(reflect.New(dst.Type().Elem()))
+ }
+ return b.bind(v, dst.Elem(), path, depth+1, quoted)
+ }
+ if dst.Type() == durationType {
+ var raw string
+ switch x := v.(type) {
+ case string:
+ raw = x
+ case Text:
+ raw = string(x)
+ default:
+ return b.decode(v, dst, path)
+ }
+ if err := parseEnvValue(dst, strings.TrimSpace(raw)); err != nil {
+ return b.doc.diagnostic(path, "expected a duration such as 5s", err)
+ }
+ return nil
+ }
+ if dst.CanAddr() && dst.Addr().Type().Implements(jsonUnmarshalerType) {
+ normalized, err := b.dynamic(v, path, depth+1)
+ if err != nil {
+ return err
+ }
+ return b.decode(normalized, dst, path)
+ }
+ if dst.CanAddr() && dst.Addr().Type().Implements(textType) {
+ if text, ok := v.(Text); ok {
+ v = string(text)
+ }
+ return b.decode(v, dst, path)
+ }
+ if text, ok := v.(Text); ok {
+ if dst.Kind() == reflect.String || dst.Kind() == reflect.Interface || (dst.Kind() == reflect.Slice && dst.Type().Elem().Kind() == reflect.Uint8) {
+ return b.decode(string(text), dst, path)
+ }
+ switch dst.Kind() {
+ case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
+ if err := parseEnvValue(dst, strings.TrimSpace(string(text))); err != nil {
+ return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
+ }
+ return nil
+ default:
+ return b.doc.diagnostic(path, "expected an object or array, not scalar text", nil)
+ }
+ }
+ if quoted {
+ s, ok := v.(string)
+ if !ok {
+ return b.doc.diagnostic(path, "expected a JSON-encoded string", nil)
+ }
+ if err := json.Unmarshal([]byte(s), dst.Addr().Interface()); err != nil {
+ return b.doc.diagnostic(path, "invalid string-encoded value", err)
+ }
+ return nil
+ }
+ switch dst.Kind() {
+ case reflect.Struct:
+ m, ok := v.(map[string]any)
+ if !ok {
+ return b.doc.diagnostic(path, "expected an object", nil)
+ }
+ fields := configurationFields(dst.Type())
+ used := map[string]bool{}
+ for _, name := range sortedKeys(m) {
+ child := joinField(path, name)
+ f, ok := fields[name]
+ canonical := name
+ if !ok {
+ for n, field := range fields {
+ if strings.EqualFold(n, name) {
+ if ok {
+ return b.doc.diagnostic(child, "ambiguous field name", nil)
+ }
+ f, ok, canonical = field, true, n
+ }
+ }
+ }
+ if !ok {
+ return b.doc.diagnostic(child, "unknown field", nil)
+ }
+ if used[canonical] {
+ return b.doc.diagnostic(child, "duplicate field binding", nil)
+ }
+ used[canonical] = true
+ target := dst
+ for _, index := range f.Index {
+ if target.Kind() == reflect.Pointer {
+ if target.IsNil() {
+ if !target.CanSet() {
+ return b.doc.diagnostic(child, "field cannot be assigned", nil)
+ }
+ target.Set(reflect.New(target.Type().Elem()))
+ }
+ target = target.Elem()
+ }
+ target = target.Field(index)
+ }
+ if !target.CanSet() {
+ return b.doc.diagnostic(child, "field cannot be assigned", nil)
+ }
+ if err := b.bind(m[name], target, child, depth+1, strings.Contains(","+f.Tag.Get("json")+",", ",string,")); err != nil {
+ return err
+ }
+ }
+ return nil
+ case reflect.Map:
+ m, ok := v.(map[string]any)
+ if !ok || dst.Type().Key().Kind() != reflect.String {
+ return b.doc.diagnostic(path, "expected an object with string keys", nil)
+ }
+ if dst.IsNil() {
+ dst.Set(reflect.MakeMap(dst.Type()))
+ }
+ for _, key := range sortedKeys(m) {
+ value := reflect.New(dst.Type().Elem()).Elem()
+ if err := b.bind(m[key], value, joinField(path, key), depth+1, false); err != nil {
+ return err
+ }
+ k := reflect.New(dst.Type().Key()).Elem()
+ k.SetString(key)
+ dst.SetMapIndex(k, value)
+ }
+ return nil
+ case reflect.Slice, reflect.Array:
+ if _, ok := v.(string); ok && dst.Kind() == reflect.Slice && dst.Type().Elem().Kind() == reflect.Uint8 {
+ return b.decode(v, dst, path)
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return b.doc.diagnostic(path, "expected an array", nil)
+ }
+ if dst.Kind() == reflect.Array && len(items) != dst.Len() {
+ return b.doc.diagnostic(path, "array length does not match its declared type", nil)
+ }
+ if dst.Kind() == reflect.Slice {
+ dst.Set(reflect.MakeSlice(dst.Type(), len(items), len(items)))
+ }
+ for i, value := range items {
+ if err := b.bind(value, dst.Index(i), fmt.Sprintf("%s[%d]", path, i), depth+1, false); err != nil {
+ return err
+ }
+ }
+ return nil
+ case reflect.Interface:
+ v, err := b.dynamic(v, path, depth+1)
+ if err != nil {
+ return err
+ }
+ if dst.Type().NumMethod() == 0 {
+ dst.Set(reflect.ValueOf(v))
+ return nil
+ }
+ return b.decode(v, dst, path)
+ default:
+ return b.decode(v, dst, path)
+ }
+}
+func sortedKeys(m map[string]any) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+func (b *documentBinder) dynamic(v any, path string, depth int) (any, error) {
+ if err := b.step(path, depth); err != nil {
+ return nil, err
+ }
+ switch x := v.(type) {
+ case Text:
+ return string(x), nil
+ case map[string]any:
+ out := make(map[string]any, len(x))
+ for _, k := range sortedKeys(x) {
+ c, err := b.dynamic(x[k], joinField(path, k), depth+1)
+ if err != nil {
+ return nil, err
+ }
+ out[k] = c
+ }
+ return out, nil
+ case []any:
+ out := make([]any, len(x))
+ for i, v := range x {
+ c, err := b.dynamic(v, fmt.Sprintf("%s[%d]", path, i), depth+1)
+ if err != nil {
+ return nil, err
+ }
+ out[i] = c
+ }
+ return out, nil
+ default:
+ return v, nil
+ }
+}
+
+// Match encoding/json's field promotion: the shallowest declaration wins;
+// explicit tags win ties, and ambiguous fields remain unavailable.
+func configurationFields(t reflect.Type) map[string]reflect.StructField {
+ type candidate struct {
+ field reflect.StructField
+ tagged bool
+ }
+ candidates := map[string][]candidate{}
+ var visit func(reflect.Type, []int, map[reflect.Type]bool)
+ visit = func(current reflect.Type, prefix []int, visiting map[reflect.Type]bool) {
+ if visiting[current] {
+ return
+ }
+ visiting[current] = true
+ defer delete(visiting, current)
+ for i := 0; i < current.NumField(); i++ {
+ f := current.Field(i)
+ name := strings.Split(f.Tag.Get("json"), ",")[0]
+ if name == "-" {
+ continue
+ }
+ child := f.Type
+ if child.Kind() == reflect.Pointer {
+ child = child.Elem()
+ }
+ if f.PkgPath != "" && (!f.Anonymous || child.Kind() != reflect.Struct) {
+ continue
+ }
+ f.Index = append(append([]int(nil), prefix...), i)
+ if name == "" && f.Anonymous && child.Kind() == reflect.Struct {
+ visit(child, f.Index, visiting)
+ continue
+ }
+ tagged := name != ""
+ if name == "" {
+ name = f.Name
+ }
+ candidates[name] = append(candidates[name], candidate{f, tagged})
+ }
+ }
+ visit(t, nil, map[reflect.Type]bool{})
+ fields := map[string]reflect.StructField{}
+ for name, list := range candidates {
+ sort.Slice(list, func(i, j int) bool {
+ if len(list[i].field.Index) != len(list[j].field.Index) {
+ return len(list[i].field.Index) < len(list[j].field.Index)
+ }
+ return list[i].tagged && !list[j].tagged
+ })
+ if len(list) > 1 && len(list[0].field.Index) == len(list[1].field.Index) && list[0].tagged == list[1].tagged {
+ continue
+ }
+ fields[name] = list[0].field
+ }
+ return fields
+}
diff --git a/config/config.go b/config/config.go
index 960348e..839e96d 100644
--- a/config/config.go
+++ b/config/config.go
@@ -62,19 +62,19 @@ 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
+ return AtField("lifecycle", err)
}
if err := c.Request.Validate(); err != nil {
- return err
+ return AtField("request", err)
}
if err := c.Log.Validate(); err != nil {
- return err
+ return AtField("log", err)
}
if err := c.Endpoints.Validate(); err != nil {
return err
}
if err := c.Errors.Validate(); err != nil {
- return err
+ return AtField("errors", err)
}
encoder := c.ResponseEncoder
if encoder == nil {
@@ -90,7 +90,7 @@ func (c Config) Validate() error {
return err
}
if err := c.Response.Validate(); err != nil {
- return err
+ return AtField("response", err)
}
if c.SlowRequestThreshold < 0 {
return &ConfigError{Field: "slowRequestThreshold", Message: "must be nonnegative"}
@@ -99,13 +99,13 @@ func (c Config) Validate() error {
return &ConfigError{Field: "address", Message: "is required"}
}
if strings.TrimSpace(c.RequestIDHeader) == "" {
- return &ConfigError{Field: "request_id_header", Message: "is required"}
+ return &ConfigError{Field: "requestIdHeader", Message: "is required"}
}
if c.ShutdownTimeout <= 0 {
- return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
+ return &ConfigError{Field: "shutdownTimeout", Message: "must be positive"}
}
if _, err := httpx.NewClientIPResolver(c.TrustedProxies...); err != nil {
- return &ConfigError{Field: "trusted_proxies", Message: err.Error()}
+ return &Error{Field: "trustedProxies", Reason: "expected IP addresses or CIDRs", cause: err}
}
return c.ValidateTransport()
}
diff --git a/config/config_test.go b/config/config_test.go
index c7a2e85..c09e6a1 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -112,7 +112,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
- if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trusted_proxies") {
+ if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trustedProxies") {
t.Fatalf("expected invalid trusted proxy error, got %v", err)
}
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
@@ -123,7 +123,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
}
cfg = Default()
cfg.RequestIDHeader = " "
- if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "request_id_header") {
+ if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "requestIdHeader") {
t.Fatalf("expected invalid request header error, got %v", err)
}
}
diff --git a/config/decoders.go b/config/decoders.go
new file mode 100644
index 0000000..44c19b8
--- /dev/null
+++ b/config/decoders.go
@@ -0,0 +1,398 @@
+package config
+
+import (
+ "bytes"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+)
+
+type JSONDecoder struct{}
+type YAMLDecoder struct{}
+type XMLDecoder struct{}
+
+type parseState struct {
+ doc Document
+ nodes int
+}
+
+func newParseState() *parseState { return &parseState{doc: Document{Positions: map[string]Position{}}} }
+func (p *parseState) enter(path string, pos Position, depth int) error {
+ p.nodes++
+ p.doc.Positions[path] = pos
+ if depth > maxConfigDepth || p.nodes > maxConfigNodes {
+ return p.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
+ }
+ return nil
+}
+
+type sourcePositions struct{ starts []int }
+
+func newSourcePositions(data []byte) sourcePositions {
+ p := sourcePositions{starts: []int{0}}
+ for i, c := range data {
+ if c == '\n' {
+ p.starts = append(p.starts, i+1)
+ }
+ }
+ return p
+}
+func (p sourcePositions) at(offset int64) Position {
+ if offset < 0 {
+ offset = 0
+ }
+ i := sort.Search(len(p.starts), func(i int) bool { return int64(p.starts[i]) > offset }) - 1
+ return Position{Line: i + 1, Column: int(offset) - p.starts[i] + 1}
+}
+
+var yamlLine = regexp.MustCompile(`line ([0-9]+)`)
+
+func yamlSyntax(err error) *Error {
+ d := &Error{Reason: "invalid YAML syntax", cause: err}
+ if m := yamlLine.FindStringSubmatch(err.Error()); len(m) == 2 {
+ d.Line, _ = strconv.Atoi(m[1])
+ d.Column = 1
+ }
+ return d
+}
+func (JSONDecoder) Decode(data []byte) (Document, error) {
+ p := newParseState()
+ if len(data) > maxConfigBytes {
+ return p.doc, &Error{Reason: "file exceeds 1 MiB"}
+ }
+ if !utf8.Valid(data) {
+ return p.doc, &Error{Reason: "configuration must be UTF-8"}
+ }
+ positions := newSourcePositions(data)
+ d := json.NewDecoder(bytes.NewReader(data))
+ d.UseNumber()
+ var parse func(string, int) (any, error)
+ parse = func(path string, depth int) (any, error) {
+ offset := d.InputOffset()
+ for offset < int64(len(data)) && strings.ContainsRune(" \t\r\n:,", rune(data[offset])) {
+ offset++
+ }
+ if err := p.enter(path, positions.at(offset), depth); err != nil {
+ return nil, err
+ }
+ token, err := d.Token()
+ if err != nil {
+ pos := positions.at(d.InputOffset())
+ var syntax *json.SyntaxError
+ if errors.As(err, &syntax) {
+ pos = positions.at(syntax.Offset - 1)
+ }
+ return nil, &Error{Field: path, Line: pos.Line, Column: pos.Column, Reason: "invalid JSON syntax", cause: err}
+ }
+ if delim, ok := token.(json.Delim); ok {
+ switch delim {
+ case '{':
+ out := map[string]any{}
+ for d.More() {
+ k, e := d.Token()
+ if e != nil {
+ return nil, p.doc.diagnostic(path, "invalid object key", e)
+ }
+ key, ok := k.(string)
+ if !ok {
+ return nil, p.doc.diagnostic(path, "object key must be a string", nil)
+ }
+ child := joinField(path, key)
+ if _, exists := out[key]; exists {
+ return nil, p.doc.diagnostic(child, "duplicate field", nil)
+ }
+ out[key], e = parse(child, depth+1)
+ if e != nil {
+ return nil, e
+ }
+ }
+ if _, err = d.Token(); err != nil {
+ return nil, p.doc.diagnostic(path, "unterminated object", err)
+ }
+ return out, nil
+ case '[':
+ out := []any{}
+ for d.More() {
+ v, e := parse(fmt.Sprintf("%s[%d]", path, len(out)), depth+1)
+ if e != nil {
+ return nil, e
+ }
+ out = append(out, v)
+ }
+ if _, err = d.Token(); err != nil {
+ return nil, p.doc.diagnostic(path, "unterminated array", err)
+ }
+ return out, nil
+ default:
+ return nil, p.doc.diagnostic(path, "unexpected JSON delimiter", nil)
+ }
+ }
+ return token, nil
+ }
+ var err error
+ p.doc.Value, err = parse("", 0)
+ if err == nil {
+ if _, e := d.Token(); e != io.EOF {
+ err = p.doc.diagnostic("", "exactly one JSON document required", e)
+ }
+ }
+ return p.doc, err
+}
+
+func (YAMLDecoder) Decode(data []byte) (Document, error) {
+ p := newParseState()
+ if len(data) > maxConfigBytes {
+ return p.doc, &Error{Reason: "file exceeds 1 MiB"}
+ }
+ if !utf8.Valid(data) {
+ return p.doc, &Error{Reason: "configuration must be UTF-8"}
+ }
+ d := yaml.NewDecoder(bytes.NewReader(data))
+ var root yaml.Node
+ if err := d.Decode(&root); err != nil {
+ return p.doc, yamlSyntax(err)
+ }
+ var extra yaml.Node
+ if err := d.Decode(&extra); err != io.EOF {
+ return p.doc, &Error{Reason: "exactly one YAML document required", cause: err}
+ }
+ var walk func(*yaml.Node, string, int) (any, error)
+ walk = func(n *yaml.Node, path string, depth int) (any, error) {
+ if err := p.enter(path, Position{n.Line, n.Column}, depth); err != nil {
+ return nil, err
+ }
+ switch n.Kind {
+ case yaml.MappingNode:
+ out := map[string]any{}
+ for i := 0; i < len(n.Content); i += 2 {
+ k, v := n.Content[i], n.Content[i+1]
+ if k.Tag != "!!str" {
+ return nil, p.doc.diagnostic(path, "mapping keys must be strings; merge keys are not supported", nil)
+ }
+ child := joinField(path, k.Value)
+ if _, ok := out[k.Value]; ok {
+ return nil, &Error{Field: child, Line: k.Line, Column: k.Column, Reason: "duplicate field"}
+ }
+ x, err := walk(v, child, depth+1)
+ if err != nil {
+ return nil, err
+ }
+ out[k.Value] = x
+ }
+ return out, nil
+ case yaml.SequenceNode:
+ out := make([]any, len(n.Content))
+ for i, c := range n.Content {
+ var err error
+ out[i], err = walk(c, fmt.Sprintf("%s[%d]", path, i), depth+1)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return out, nil
+ case yaml.AliasNode:
+ return nil, p.doc.diagnostic(path, "YAML aliases are not supported", nil)
+ case yaml.ScalarNode:
+ switch n.Tag {
+ case "!!str", "!!timestamp":
+ return n.Value, nil
+ case "!!null":
+ return nil, nil
+ case "!!bool", "!!int", "!!float":
+ var v any
+ if err := n.Decode(&v); err != nil {
+ return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
+ }
+ raw, err := json.Marshal(v)
+ if err != nil {
+ return nil, p.doc.diagnostic(path, "number must be finite", err)
+ }
+ var out any
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.UseNumber()
+ if err = dec.Decode(&out); err != nil {
+ return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
+ }
+ return out, nil
+ default:
+ return nil, p.doc.diagnostic(path, "unsupported YAML tag", nil)
+ }
+ }
+ return nil, p.doc.diagnostic(path, "unsupported YAML node", nil)
+ }
+ if len(root.Content) != 1 {
+ return p.doc, &Error{Reason: "one nonempty YAML document required"}
+ }
+ var err error
+ p.doc.Value, err = walk(root.Content[0], "", 0)
+ return p.doc, err
+}
+
+// XML uses a config root, element field names, type="array" with item children,
+// type="object" for empty objects, and type="null" for explicit null. Scalar
+// text is bound using the destination type; optional number/bool/string types
+// let dynamic any values preserve their intended JSON type.
+func (XMLDecoder) Decode(data []byte) (Document, error) {
+ p := newParseState()
+ if len(data) > maxConfigBytes {
+ return p.doc, &Error{Reason: "file exceeds 1 MiB"}
+ }
+ if !utf8.Valid(data) {
+ return p.doc, &Error{Reason: "configuration must be UTF-8"}
+ }
+ d := xml.NewDecoder(bytes.NewReader(data))
+ var parse func(xml.StartElement, string, int, Position) (any, error)
+ parse = func(start xml.StartElement, path string, depth int, pos Position) (any, error) {
+ if err := p.enter(path, pos, depth); err != nil {
+ return nil, err
+ }
+ if start.Name.Space != "" {
+ return nil, p.doc.diagnostic(path, "XML namespaces are not supported", nil)
+ }
+ kind := ""
+ attrSeen := false
+ for _, a := range start.Attr {
+ if a.Name.Space != "" || a.Name.Local != "type" || attrSeen {
+ return nil, p.doc.diagnostic(path, "only one XML type attribute is supported", nil)
+ }
+ kind = a.Value
+ attrSeen = true
+ }
+ if depth == 0 && kind == "" {
+ kind = "object"
+ }
+ switch kind {
+ case "", "object", "array", "null", "string", "number", "bool":
+ default:
+ return nil, p.doc.diagnostic(path, "unsupported XML type", nil)
+ }
+ children := map[string]any{}
+ array := []any{}
+ var text strings.Builder
+ for {
+ line, column := d.InputPos()
+ token, err := d.Token()
+ if err != nil {
+ return nil, &Error{Field: path, Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
+ }
+ switch x := token.(type) {
+ case xml.StartElement:
+ if kind != "" && kind != "object" && kind != "array" {
+ return nil, p.doc.diagnostic(path, "scalar XML element cannot contain children", nil)
+ }
+ child := joinField(path, x.Name.Local)
+ if kind == "array" {
+ if x.Name.Local != "item" {
+ return nil, p.doc.diagnostic(path, "XML array children must be item elements", nil)
+ }
+ child = fmt.Sprintf("%s[%d]", path, len(array))
+ } else if _, ok := children[x.Name.Local]; ok {
+ return nil, p.doc.diagnostic(child, "duplicate field; use type=\"array\" for lists", nil)
+ }
+ v, e := parse(x, child, depth+1, Position{line, column})
+ if e != nil {
+ return nil, e
+ }
+ if kind == "array" {
+ array = append(array, v)
+ } else {
+ children[x.Name.Local] = v
+ }
+ case xml.CharData:
+ text.Write([]byte(x))
+ case xml.Comment:
+ case xml.EndElement:
+ raw := text.String()
+ if len(children) > 0 || kind == "array" || kind == "object" {
+ if strings.TrimSpace(raw) != "" {
+ return nil, p.doc.diagnostic(path, "mixed XML text and elements are not supported", nil)
+ }
+ if kind == "array" {
+ return array, nil
+ }
+ return children, nil
+ }
+ switch kind {
+ case "null":
+ if strings.TrimSpace(raw) != "" {
+ return nil, p.doc.diagnostic(path, "null element must be empty", nil)
+ }
+ return nil, nil
+ case "string":
+ return raw, nil
+ case "number":
+ var n any
+ dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(raw)))
+ dec.UseNumber()
+ if err := dec.Decode(&n); err != nil {
+ return nil, p.doc.diagnostic(path, "invalid number", err)
+ }
+ if _, ok := n.(json.Number); !ok {
+ return nil, p.doc.diagnostic(path, "expected number", nil)
+ }
+ if _, e := dec.Token(); e != io.EOF {
+ return nil, p.doc.diagnostic(path, "invalid number", e)
+ }
+ return n, nil
+ case "bool":
+ b, e := strconv.ParseBool(strings.TrimSpace(raw))
+ if e != nil {
+ return nil, p.doc.diagnostic(path, "expected boolean", e)
+ }
+ return b, nil
+ }
+ return Text(raw), nil
+ default:
+ return nil, p.doc.diagnostic(path, "XML directives and processing instructions are not supported", nil)
+ }
+ }
+ }
+ found := false
+ prolog := false
+ for {
+ line, column := d.InputPos()
+ token, err := d.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return p.doc, &Error{Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
+ }
+ switch x := token.(type) {
+ case xml.StartElement:
+ if found || x.Name.Local != "config" {
+ return p.doc, &Error{Line: line, Column: column, Reason: "exactly one config root required"}
+ }
+ found = true
+ p.doc.Value, err = parse(x, "", 0, Position{line, column})
+ if err != nil {
+ return p.doc, err
+ }
+ case xml.CharData:
+ if strings.TrimSpace(string(x)) != "" {
+ return p.doc, &Error{Reason: "unexpected text outside XML root"}
+ }
+ case xml.Comment:
+ case xml.ProcInst:
+ if found || prolog || x.Target != "xml" {
+ return p.doc, &Error{Reason: "XML processing instructions are not supported"}
+ }
+ prolog = true
+ default:
+ return p.doc, &Error{Reason: "XML directives are not supported"}
+ }
+ }
+ if !found {
+ return p.doc, &Error{Reason: "config root required"}
+ }
+ return p.doc, nil
+}
diff --git a/config/diagnostic.go b/config/diagnostic.go
new file mode 100644
index 0000000..6028ccd
--- /dev/null
+++ b/config/diagnostic.go
@@ -0,0 +1,94 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// Error is a value-free diagnostic. Cause may contain private parser details;
+// errors.Is/As remain available, but Error never formats Cause automatically.
+type Error struct {
+ File, Field, Env, Reason string
+ Line, Column int
+ cause error
+}
+
+func (e *Error) Error() string {
+ where := "config"
+ if e.File != "" {
+ where = fmt.Sprintf("config %q", e.File)
+ }
+ if e.Line > 0 {
+ where += fmt.Sprintf(":%d:%d", e.Line, e.Column)
+ }
+ if e.Env != "" {
+ where += fmt.Sprintf(" env %q", e.Env)
+ }
+ if e.Field != "" {
+ where += fmt.Sprintf(" field %q", e.Field)
+ }
+ return where + ": " + e.Reason
+}
+func (e *Error) Unwrap() error { return e.cause }
+
+// DecodeError is retained for callers of errors.As from earlier releases.
+type DecodeError = Error
+
+// Invalid reports a public, value-free validation reason for a configuration field.
+func Invalid(field, reason string) error { return &ConfigError{Field: field, Message: reason} }
+
+// AtField prefixes a nested validation error without losing its original cause.
+// Unstructured validation errors are deliberately not echoed; use Invalid when
+// a custom validator needs a public reason rather than a private error message.
+func AtField(field string, err error) error {
+ if err == nil {
+ return nil
+ }
+ d := validationError(err)
+ d.Field = joinField(field, d.Field)
+ return d
+}
+func validationError(err error) *Error {
+ var d *Error
+ if errors.As(err, &d) {
+ out := *d
+ out.cause = err
+ return &out
+ }
+ var c *ConfigError
+ if errors.As(err, &c) {
+ return &Error{Field: c.Field, Reason: c.Message, cause: err}
+ }
+ return &Error{Reason: "validation failed", cause: err}
+}
+func joinField(parent, child string) string {
+ if parent == "" {
+ return child
+ }
+ if child == "" {
+ return parent
+ }
+ if strings.HasPrefix(child, "[") {
+ return parent + child
+ }
+ return parent + "." + child
+}
+
+// Position uses one-based source line/column numbers. Zero means unavailable.
+type Position struct{ Line, Column int }
+
+// Document is the common decoder result. Values use JSON-compatible data:
+// string, bool, json.Number, nil, []any, map[string]any. Text is available for
+// formats such as XML whose scalar tokens have no inherent JSON type.
+type Document struct {
+ Value any
+ Positions map[string]Position
+}
+
+type Text string
+
+func (d Document) diagnostic(field, reason string, cause error) *Error {
+ p := d.Positions[field]
+ return &Error{Field: field, Reason: reason, Line: p.Line, Column: p.Column, cause: cause}
+}
diff --git a/config/file.go b/config/file.go
index 160e4e6..d5c4836 100644
--- a/config/file.go
+++ b/config/file.go
@@ -1,100 +1,16 @@
package config
import (
- "bytes"
"context"
- "encoding/json"
"errors"
- "io"
- "os"
- "path/filepath"
"reflect"
"strings"
"sync"
"time"
- apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
- "gopkg.in/yaml.v3"
)
-// DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON
-// field names for both formats, rejects unknown fields and accepts durations
-// such as "3s". Application-specific validation remains with the caller.
-func DecodeFile[T any](path string, defaults T) (T, error) {
- result, err := decodeFile(path, defaults)
- if err != nil {
- var zero T
- return zero, &DecodeError{cause: err}
- }
- return result, nil
-}
-
-// DecodeError keeps input values out of public diagnostics while preserving
-// errors.Is/As for explicitly handled underlying I/O and syntax errors.
-type DecodeError struct{ cause error }
-
-func (e *DecodeError) Error() string {
- return "config: cannot decode file; check its path, format, field names and value types"
-}
-func (e *DecodeError) Unwrap() error { return e.cause }
-func decodeFile[T any](path string, defaults T) (T, error) {
- var zero T
- f, err := os.Open(path)
- if err != nil {
- return zero, err
- }
- defer f.Close()
- data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
- if err != nil {
- return zero, err
- }
- if len(data) > 1<<20 {
- return zero, errors.New("config: file exceeds 1 MiB")
- }
- var value any
- switch strings.ToLower(filepath.Ext(path)) {
- case ".yaml", ".yml":
- dec := yaml.NewDecoder(bytes.NewReader(data))
- if err := dec.Decode(&value); err != nil {
- return zero, err
- }
- var extra any
- if err := dec.Decode(&extra); err != io.EOF {
- return zero, errors.New("config: one YAML document required")
- }
- case ".json":
- if err := apierrors.DecodeCatalogJSON(data, &value); err != nil {
- return zero, err
- }
- default:
- return zero, errors.New("config: expected .json, .yaml or .yml")
- }
- if _, ok := value.(map[string]any); !ok {
- return zero, errors.New("config: root must be an object")
- }
- value, err = durations(value, reflect.TypeOf(defaults))
- if err != nil {
- return zero, err
- }
- encoded, err := json.Marshal(value)
- if err != nil {
- return zero, err
- }
- // Clone configuration without serializing borrowed json:"-" capabilities.
- var result T
- original, err := cloneConfiguration(reflect.ValueOf(&defaults).Elem(), 0)
- if err != nil {
- return zero, err
- }
- reflect.ValueOf(&result).Elem().Set(original)
- dec := json.NewDecoder(bytes.NewReader(encoded))
- dec.DisallowUnknownFields()
- if err = dec.Decode(&result); err != nil {
- return zero, err
- }
- return result, nil
-}
func durations(v any, t reflect.Type) (any, error) {
if t == nil {
return v, nil
diff --git a/config/loader.go b/config/loader.go
new file mode 100644
index 0000000..0675be0
--- /dev/null
+++ b/config/loader.go
@@ -0,0 +1,175 @@
+package config
+
+import (
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+)
+
+const maxConfigBytes = 1 << 20
+const maxConfigDepth = 64
+const maxConfigNodes = 100000
+
+// Decoder must not retain or mutate input and must support concurrent calls
+// when its Loader is shared. It is trusted application code, not a sandbox.
+type Decoder interface {
+ Decode([]byte) (Document, error)
+}
+type DecoderFunc func([]byte) (Document, error)
+
+func (f DecoderFunc) Decode(data []byte) (Document, error) { return f(data) }
+
+// Loader owns an immutable per-instance decoder registry. No global registration.
+type Loader struct {
+ decoders map[string]Decoder
+ sealed bool
+}
+type LoaderOption func(*Loader) error
+
+// WithDecoder adds a new extension; builtin/duplicate extensions cannot be replaced.
+func WithDecoder(extension string, decoder Decoder) LoaderOption {
+ return func(l *Loader) error {
+ if l == nil || l.decoders == nil || l.sealed {
+ return errors.New("config: decoder registry cannot be changed after construction")
+ }
+ ext := strings.ToLower(strings.TrimPrefix(extension, "."))
+ if ext == "" || strings.ContainsAny(ext, "./\\ \t\r\n") {
+ return errors.New("config: invalid decoder extension")
+ }
+ if decoder == nil {
+ return errors.New("config: nil decoder")
+ }
+ v := reflect.ValueOf(decoder)
+ if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Func || v.Kind() == reflect.Map || v.Kind() == reflect.Slice || v.Kind() == reflect.Interface) && v.IsNil() {
+ return errors.New("config: nil decoder")
+ }
+ if _, exists := l.decoders[ext]; exists {
+ return errors.New("config: decoder extension already registered")
+ }
+ l.decoders[ext] = decoder
+ return nil
+ }
+}
+
+func NewLoader(options ...LoaderOption) (*Loader, error) {
+ l := &Loader{decoders: map[string]Decoder{"json": JSONDecoder{}, "yaml": YAMLDecoder{}, "yml": YAMLDecoder{}, "xml": XMLDecoder{}}}
+ for _, o := range options {
+ if o == nil {
+ return nil, errors.New("config: nil loader option")
+ }
+ if err := o(l); err != nil {
+ return nil, err
+ }
+ }
+ l.sealed = true
+ return l, nil
+}
+
+var defaultLoader, _ = NewLoader()
+
+func selectLoader(loaders []*Loader) (*Loader, error) {
+ if len(loaders) == 0 {
+ return defaultLoader, nil
+ }
+ if len(loaders) != 1 || loaders[0] == nil {
+ return nil, errors.New("config: expected one non-nil loader")
+ }
+ return loaders[0], nil
+}
+func (l *Loader) read(path string) (doc Document, err error) {
+ defer func() {
+ if err != nil {
+ var d *Error
+ if errors.As(err, &d) {
+ c := *d
+ c.File = path
+ err = &c
+ } else {
+ reason := "cannot read configuration file"
+ if errors.Is(err, os.ErrNotExist) {
+ reason = "configuration file does not exist"
+ } else if errors.Is(err, os.ErrPermission) {
+ reason = "permission denied reading configuration file"
+ }
+ err = &Error{File: path, Reason: reason, cause: err}
+ }
+ }
+ }()
+ decoder := l.decoders[strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")]
+ if decoder == nil {
+ return doc, &Error{Reason: "unsupported configuration format"}
+ }
+ info, e := os.Stat(path)
+ if e != nil {
+ return doc, e
+ }
+ if !info.Mode().IsRegular() {
+ return doc, &Error{Reason: "configuration must be a regular file"}
+ }
+ f, e := os.Open(path)
+ if e != nil {
+ return doc, e
+ }
+ defer f.Close()
+ info, e = f.Stat()
+ if e != nil {
+ return doc, e
+ }
+ if !info.Mode().IsRegular() {
+ return doc, &Error{Reason: "configuration must be a regular file"}
+ }
+ data, e := io.ReadAll(io.LimitReader(f, maxConfigBytes+1))
+ if e != nil {
+ return doc, e
+ }
+ if len(data) > maxConfigBytes {
+ return doc, &Error{Reason: "file exceeds 1 MiB"}
+ }
+ doc, err = decodeSafely(decoder, data)
+ if err != nil {
+ return doc, err
+ }
+ if _, ok := doc.Value.(map[string]any); !ok {
+ return doc, doc.diagnostic("", "root must be an object", nil)
+ }
+ return doc, nil
+}
+func decodeSafely(decoder Decoder, data []byte) (doc Document, err error) {
+ defer func() {
+ if recover() != nil {
+ err = &Error{Reason: "decoder panicked"}
+ }
+ }()
+ doc, err = decoder.Decode(data)
+ if err != nil {
+ var d *Error
+ if !errors.As(err, &d) {
+ err = &Error{Reason: "invalid configuration syntax", cause: err}
+ }
+ }
+ return
+}
+
+// DecodeFile overlays a bounded document onto independent defaults. With no
+// loader argument it accepts JSON/YAML/XML. It never opens dependencies.
+func DecodeFile[T any](path string, defaults T, loaders ...*Loader) (T, error) {
+ var zero T
+ l, err := selectLoader(loaders)
+ if err != nil {
+ return zero, err
+ }
+ doc, err := l.read(path)
+ if err != nil {
+ return zero, err
+ }
+ result, err := bindDocument(doc, defaults)
+ if err != nil {
+ d := validationError(err)
+ d.File = path
+ return zero, d
+ }
+ return result, nil
+}
diff --git a/config/loader_test.go b/config/loader_test.go
new file mode 100644
index 0000000..32519c2
--- /dev/null
+++ b/config/loader_test.go
@@ -0,0 +1,397 @@
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type loaderExample struct {
+ Server Config `json:"server"`
+ Upload struct {
+ MaxBytes int64 `json:"maxBytes" envDefault:"1024"`
+ } `json:"upload"`
+ Values []int64 `json:"values"`
+ Optional *string `json:"optional"`
+ Enabled bool `json:"enabled"`
+ Dynamic any `json:"dynamic"`
+}
+
+func (c loaderExample) Validate() error {
+ if err := c.Server.Validate(); err != nil {
+ return AtField("server", err)
+ }
+ if c.Upload.MaxBytes < 0 {
+ return Invalid("upload.maxBytes", "must be nonnegative")
+ }
+ return nil
+}
+func noEnvironment(string) (string, bool) { return "", false }
+func configFile(t *testing.T, ext, raw string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "service."+ext)
+ if err := os.WriteFile(path, []byte(raw), 0600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestUnifiedFormatAndPrecedence(t *testing.T) {
+ formats := map[string]string{
+ "json": `{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":7},"values":[9007199254740993],"optional":null,"enabled":true,"dynamic":{"id":9007199254740993}}`,
+ "yaml": "server:\n http:\n readTimeout: 2s\nupload:\n maxBytes: 7\nvalues: [9007199254740993]\noptional: null\nenabled: true\ndynamic:\n id: 9007199254740993\n",
+ "xml": `2s7
- 9007199254740993
true9007199254740993`,
+ }
+ var reference loaderExample
+ for _, ext := range []string{"json", "yaml", "xml"} {
+ t.Run(ext, func(t *testing.T) {
+ old := "old"
+ base := loaderExample{Server: Default(), Optional: &old}
+ cfg, err := LoadProject(configFile(t, ext, formats[ext]), base, EnvOptions{Prefix: "APP_", Lookup: func(key string) (string, bool) {
+ v, ok := map[string]string{"APP_UPLOAD_MAX_BYTES": "0", "APP_ENABLED": "false"}[key]
+ return v, ok
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Upload.MaxBytes != 0 || cfg.Enabled || cfg.Optional != nil || cfg.Values[0] != 9007199254740993 || cfg.Server.HTTP.ReadTimeout != 2*time.Second {
+ t.Fatal("values changed", cfg)
+ }
+ if cfg.Dynamic.(map[string]any)["id"] != json.Number("9007199254740993") {
+ t.Fatal("dynamic integer precision lost")
+ }
+ if ext == "json" {
+ reference = cfg
+ } else if !reflect.DeepEqual(reference, cfg) {
+ t.Fatal("format-dependent semantics")
+ }
+ if base.Optional == nil || *base.Optional != "old" {
+ t.Fatal("defaults mutated")
+ }
+ })
+ }
+ cfg, err := LoadProject("", loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
+ if err != nil || cfg.Upload.MaxBytes != 1024 {
+ t.Fatal(cfg, err)
+ }
+}
+
+func TestConfigDiagnosticsAndCauses(t *testing.T) {
+ for _, tc := range []struct {
+ ext, body, field string
+ line bool
+ }{
+ {"json", "{\n\"upload\": {\"maxBytes\":\"private-value\"}}", "upload.maxBytes", true},
+ {"yaml", "server:\n http:\n readTimeout: private-value", "server.http.readTimeout", true},
+ {"xml", "\nprivate-value", "upload.maxBytes", true},
+ {"json", `{"upload":{"maxBytes":-1}}`, "upload.maxBytes", true},
+ {"yaml", "server:\n http:\n readTimeout: -1s", "server.http.readTimeout", true},
+ {"json", `{"upload":{"typo":3}}`, "upload.typo", true},
+ } {
+ t.Run(tc.ext+tc.field, func(t *testing.T) {
+ path := configFile(t, tc.ext, tc.body)
+ _, err := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
+ var d *Error
+ if !errors.As(err, &d) || d.File != path || d.Field != tc.field || d.Reason == "" || tc.line && d.Line < 1 {
+ t.Fatalf("missing location: %#v %v", d, err)
+ }
+ if strings.Contains(err.Error(), "private-value") {
+ t.Fatal("secret leaked", err)
+ }
+ })
+ }
+ path := filepath.Join(t.TempDir(), "missing.json")
+ _, err := LoadProject(path, loaderExample{}, EnvOptions{})
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("I/O cause discarded", err)
+ }
+ _, err = DecodeFile(configFile(t, "json", `{"count":"private-value"}`), struct {
+ Count int `json:"count"`
+ }{})
+ var typed *json.UnmarshalTypeError
+ if !errors.As(err, &typed) {
+ t.Fatal("type cause discarded", err)
+ }
+ _, err = LoadProject(configFile(t, "yaml", "upload:\n maxBytes: 7"), loaderExample{Server: Default()}, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { return "-1", k == "APP_UPLOAD_MAX_BYTES" }})
+ var d *Error
+ if !errors.As(err, &d) || d.Env != "APP_UPLOAD_MAX_BYTES" || d.Field != "upload.maxBytes" || d.File != "" || d.Line != 0 {
+ t.Fatal("environment validation wrongly blamed file", err)
+ }
+}
+
+func TestMalformedFormatsAndLimits(t *testing.T) {
+ for ext, bodies := range map[string][]string{
+ "json": {`{"enabled":true,"enabled":false}`, `{} {}`, `{"upload":`, `{"enabled":true,"Enabled":false}`, strings.Repeat(`{"a":`, 70) + `0` + strings.Repeat(`}`, 70)},
+ "yaml": {"enabled: true\nenabled: false", "---\n{}\n---\n{}", "a: &x [1]\nb: *x", "a:\n <<: {}", "upload: [\n"},
+ "xml": {``, `&private-value;`, ``, ``, ``, `truefalse`, `texttrue`, `1`},
+ } {
+ for i, raw := range bodies {
+ t.Run(fmt.Sprintf("%s%d", ext, i), func(t *testing.T) {
+ _, err := LoadProject(configFile(t, ext, raw), loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
+ if err == nil || strings.Contains(err.Error(), "private-value") {
+ t.Fatal(err)
+ }
+ })
+ }
+ }
+ if _, err := DecodeFile(configFile(t, "json", strings.Repeat(" ", maxConfigBytes+1)), loaderExample{}); err == nil {
+ t.Fatal("unbounded file")
+ }
+ if _, err := DecodeFile(configFile(t, "json", string([]byte{'{', '"', 0xff, '"', ':', '0', '}'})), loaderExample{}); err == nil {
+ t.Fatal("invalid UTF-8")
+ }
+}
+
+func TestDecoderRegistryAndConcurrentUse(t *testing.T) {
+ decoder := DecoderFunc(func([]byte) (Document, error) {
+ return Document{Value: map[string]any{"upload": map[string]any{"maxBytes": json.Number("42")}}}, nil
+ })
+ l, err := NewLoader(WithDecoder("custom", decoder))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := configFile(t, "custom", "ignored")
+ var wg sync.WaitGroup
+ for i := 0; i < 24; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ cfg, e := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment}, l)
+ if e != nil || cfg.Upload.MaxBytes != 42 {
+ t.Error(e)
+ }
+ }()
+ }
+ wg.Wait()
+ if _, err := DecodeFile(path, loaderExample{}); err == nil {
+ t.Fatal("decoder registration leaked globally")
+ }
+ for _, options := range [][]LoaderOption{{WithDecoder("json", decoder)}, {WithDecoder("x", decoder), WithDecoder("x", decoder)}, {WithDecoder("x", DecoderFunc(nil))}} {
+ if _, err = NewLoader(options...); err == nil {
+ t.Fatal("invalid registration")
+ }
+ }
+ l, _ = NewLoader(WithDecoder("panic", DecoderFunc(func([]byte) (Document, error) { panic("private-value") })))
+ _, err = DecodeFile(configFile(t, "panic", ""), loaderExample{}, l)
+ if err == nil || strings.Contains(err.Error(), "private-value") {
+ t.Fatal(err)
+ }
+ cycle := map[string]any{}
+ cycle["self"] = cycle
+ l, _ = NewLoader(WithDecoder("cycle", DecoderFunc(func([]byte) (Document, error) { return Document{Value: map[string]any{"dynamic": cycle}}, nil })))
+ if _, err = DecodeFile(configFile(t, "cycle", ""), loaderExample{}, l); err == nil {
+ t.Fatal("decoder bypassed traversal limit")
+ }
+}
+
+func TestUnifiedLoggerNormalization(t *testing.T) {
+ type project struct {
+ Server Config `json:"server"`
+ }
+ base := project{Server: Default()}
+ base.Server.Log.File = "old.log"
+ base.Server.Log.Rotation.Enabled = true
+ for _, full := range []bool{false, true} {
+ name := "GO_OMNI_LOG_OUTPUT"
+ if full {
+ name = "GO_OMNI_SERVER_LOG_OUTPUT"
+ }
+ cfg, err := LoadProject("", base, EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: ServerEnvAliases("GO_OMNI_", "server"), Lookup: func(k string) (string, bool) { return "stdout", k == name }})
+ if err != nil || cfg.Server.Log.File != "" || cfg.Server.Log.Rotation.Enabled || base.Server.Log.File != "old.log" {
+ t.Fatal(cfg, err)
+ }
+ }
+}
+
+func FuzzConfigurationDecoders(f *testing.F) {
+ f.Add(uint8(0), []byte(`{"count":1}`))
+ f.Add(uint8(1), []byte("count: 1"))
+ f.Add(uint8(2), []byte("1"))
+ f.Fuzz(func(t *testing.T, format uint8, data []byte) {
+ if len(data) > 8192 {
+ data = data[:8192]
+ }
+ ds := []Decoder{JSONDecoder{}, YAMLDecoder{}, XMLDecoder{}}
+ doc, err := ds[int(format)%len(ds)].Decode(data)
+ if err == nil {
+ _, _ = bindDocument(doc, struct {
+ Count int `json:"count"`
+ }{})
+ }
+ })
+}
+
+func BenchmarkConfiguration(b *testing.B) {
+ for name, tc := range map[string]struct {
+ d Decoder
+ raw string
+ }{"json": {JSONDecoder{}, `{"count":9007199254740993,"timeout":"5s","values":[1,2,3]}`}, "yaml": {YAMLDecoder{}, "count: 9007199254740993\ntimeout: 5s\nvalues: [1,2,3]"}, "xml": {XMLDecoder{}, `90071992547409935s- 1
- 2
- 3
`}} {
+ b.Run(name, func(b *testing.B) {
+ b.ReportAllocs()
+ raw := []byte(tc.raw)
+ for i := 0; i < b.N; i++ {
+ doc, err := tc.d.Decode(raw)
+ if err != nil {
+ b.Fatal(err)
+ }
+ _, err = bindDocument(doc, struct {
+ Count int64 `json:"count"`
+ Timeout time.Duration `json:"timeout"`
+ Values []int `json:"values"`
+ }{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ }
+}
+
+// Text-only scalars and embedded config fields use the same behavior in each format.
+type testText struct{ Value string }
+
+func (v *testText) UnmarshalText(data []byte) error { v.Value = string(data); return nil }
+func TestConfigScalarAndEmbedding(t *testing.T) {
+ type Fields struct {
+ Name string `json:"name"`
+ }
+ type C struct {
+ Fields
+ Nested Fields `json:"nested"`
+ Scalar testText `json:"scalar"`
+ Bytes []byte `json:"bytes"`
+ }
+ cases := map[string]string{
+ "json": `{"name":"outer","nested":{"name":"inner"},"scalar":"text","bytes":"aGk="}`,
+ "yaml": "name: outer\nnested:\n name: inner\nscalar: text\nbytes: aGk=\n",
+ "xml": `outerinnertextaGk=`,
+ }
+ for ext, data := range cases {
+ t.Run(ext, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config."+ext)
+ os.WriteFile(path, []byte(data), 0600)
+ got, err := DecodeFile(path, &C{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Name != "outer" || got.Nested.Name != "inner" || got.Scalar.Value != "text" || string(got.Bytes) != "hi" {
+ t.Fatal(got)
+ }
+ })
+ }
+ type Tagged struct {
+ Fields `json:"nested"`
+ }
+ doc, _ := (JSONDecoder{}).Decode([]byte(`{"name":"wrong"}`))
+ if _, err := bindDocument(doc, Tagged{}); err == nil {
+ t.Fatal("tagged embed incorrectly flattened")
+ }
+}
+func TestConfigDefaultsIsolationAndEmptyValues(t *testing.T) {
+ type Child struct {
+ Name string `json:"name"`
+ }
+ type C struct {
+ Child *Child `json:"child"`
+ Items []int `json:"items"`
+ Labels map[string]string `json:"labels"`
+ Enabled bool `json:"enabled"`
+ }
+ base := C{Child: &Child{Name: "old"}, Items: []int{1}, Labels: map[string]string{"old": "yes"}, Enabled: true}
+ doc, _ := (JSONDecoder{}).Decode([]byte(`{"child":{"name":"new"},"labels":{"new":"yes"},"items":[],"enabled":false}`))
+ got, err := bindDocument(doc, base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Child.Name != "new" || got.Items == nil || len(got.Items) != 0 || got.Enabled {
+ t.Fatal(got)
+ }
+ if base.Child.Name != "old" || len(base.Items) != 1 || len(base.Labels) != 1 {
+ t.Fatal("mutated defaults")
+ }
+ bad, _ := (JSONDecoder{}).Decode([]byte(`{"child":{"name":"changed"},"items":"bad"}`))
+ if _, err := bindDocument(bad, base); err == nil {
+ t.Fatal("invalid input accepted")
+ }
+ if base.Child.Name != "old" {
+ t.Fatal("failed load changed default pointer")
+ }
+ null, _ := (JSONDecoder{}).Decode([]byte(`{"child":null,"items":null,"labels":null,"enabled":null}`))
+ got, err = bindDocument(null, base)
+ if err != nil || got.Child != nil || got.Items != nil || got.Labels != nil || !got.Enabled {
+ t.Fatal(got, err)
+ }
+}
+func TestDefaultStageDoesNotReadEnvironment(t *testing.T) {
+ reads := 0
+ type C struct {
+ Value int `json:"value" envDefault:"3"`
+ }
+ got, err := LoadProject("", C{}, EnvOptions{Lookup: func(string) (string, bool) { reads++; return "", false }})
+ if err != nil || got.Value != 3 || reads != 1 {
+ t.Fatal(got, err, reads)
+ }
+ d := locateError(Invalid("servers[0].timeout", "must be positive"), "a.json", Document{}, map[string]string{"servers": "APP_SERVERS"})
+ if d.Env != "APP_SERVERS" || d.File != "" {
+ t.Fatal(d)
+ }
+}
+
+func TestDecoderRegistryFrozen(t *testing.T) {
+ l, err := NewLoader()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = WithDecoder("extra", JSONDecoder{})(l); err == nil {
+ t.Fatal("mutable registry")
+ }
+}
+func BenchmarkConfigurationStartup(b *testing.B) {
+ path := filepath.Join(b.TempDir(), "app.json")
+ if err := os.WriteFile(path, []byte(`{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":4096}}`), 0600); err != nil {
+ b.Fatal(err)
+ }
+ options := EnvOptions{Prefix: "BENCH_", Lookup: func(name string) (string, bool) {
+ if name == "BENCH_UPLOAD_MAX_BYTES" {
+ return "8192", true
+ }
+ return "", false
+ }}
+ defaults := loaderExample{Server: Default()}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := LoadProject(path, defaults, options); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+func BenchmarkConfigurationLarge(b *testing.B) {
+ type Entry struct {
+ Timeout time.Duration `json:"timeout"`
+ Count int `json:"count"`
+ }
+ type Large struct {
+ Entries []Entry `json:"entries"`
+ }
+ raw := []byte(`{"entries":[` + strings.TrimSuffix(strings.Repeat(`{"timeout":"3s","count":10},`, 1000), ",") + `]}`)
+ b.SetBytes(int64(len(raw)))
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ doc, err := (JSONDecoder{}).Decode(raw)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if _, err = bindDocument(doc, Large{}); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/config/project.go b/config/project.go
index 2a4fbdd..85ec00e 100644
--- a/config/project.go
+++ b/config/project.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
+ "git.yuntuo.hk/k2safe/go-omni/logx"
"math"
"os"
"reflect"
@@ -34,30 +35,114 @@ func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOp
// LoadProject applies code/tag defaults, a strict optional file, environment,
// then the project's Validate method, before any logger or dependency is opened.
// envDefault is only applied to zero-valued defaults before reading the file.
-func LoadProject[T any](path string, defaults T, options EnvOptions) (T, error) {
+func LoadProject[T any](path string, defaults T, options EnvOptions, loaders ...*Loader) (T, error) {
var zero T
+ loader, err := selectLoader(loaders)
+ if err != nil {
+ return zero, err
+ }
cfg, err := projectOverlay(defaults, options, true)
if err != nil {
return zero, err
}
+ var doc Document
if path != "" {
- cfg, err = DecodeFile(path, cfg)
+ doc, err = loader.read(path)
if err != nil {
- return zero, errors.New("config: cannot decode file; check its path, format, field names and value types")
+ return zero, err
}
+ cfg, err = bindDocument(doc, cfg)
+ if err != nil {
+ return zero, locateError(err, path, doc, nil)
+ }
+ } else {
+ value, e := cloneConfiguration(reflect.ValueOf(&cfg).Elem(), 0)
+ if e != nil {
+ return zero, &Error{Reason: "cannot clone defaults", cause: e}
+ }
+ cfg = value.Interface().(T)
}
- cfg, err = OverlayEnv(cfg, options)
+ origins := map[string]string{}
+ cfg, err = projectOverlay(cfg, options, false, origins)
if err != nil {
return zero, err
}
+ normalizeProject(reflect.ValueOf(&cfg).Elem(), "", origins, 0)
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
- return zero, err
+ return zero, locateError(err, path, doc, origins)
}
}
return cfg, nil
}
+func locateError(err error, path string, doc Document, origins map[string]string) *Error {
+ d := validationError(err)
+ field := d.Field
+ for field != "" && origins[field] == "" {
+ index := strings.LastIndexAny(field, ".[")
+ if index < 0 {
+ field = ""
+ } else {
+ field = field[:index]
+ }
+ }
+ if env := origins[field]; env != "" {
+ d.Env = env
+ d.File = ""
+ d.Line = 0
+ d.Column = 0
+ return d
+ }
+ d.File = path
+ if d.Line == 0 {
+ p := doc.Positions[d.Field]
+ d.Line = p.Line
+ d.Column = p.Column
+ }
+ return d
+}
+
+// Framework-owned normalization stays inside the common pipeline. Explicit
+// environment stream selection clears a file profile unless a file override
+// was also provided; project code never needs to repeat this rule.
+func normalizeProject(v reflect.Value, path string, origins map[string]string, depth int) {
+ if depth > 32 {
+ return
+ }
+ if v.Kind() == reflect.Pointer {
+ if v.IsNil() {
+ return
+ }
+ v = v.Elem()
+ }
+ if v.Kind() != reflect.Struct {
+ return
+ }
+ if v.Type() == reflect.TypeOf(Config{}) && v.CanAddr() {
+ c := v.Addr().Interface().(*Config)
+ if origins[joinField(path, "log.output")] != "" && c.Log.Output != "" && (origins[joinField(path, "log.file")] == "" || c.Log.File == "") {
+ c.Log.File = ""
+ c.Log.Rotation = logx.RotationConfig{}
+ }
+ return
+ }
+ for i := 0; i < v.NumField(); i++ {
+ f := v.Type().Field(i)
+ if f.PkgPath != "" {
+ continue
+ }
+ name := strings.Split(f.Tag.Get("json"), ",")[0]
+ if name == "-" {
+ continue
+ }
+ if name == "" {
+ name = f.Name
+ }
+ normalizeProject(v.Field(i), joinField(path, name), origins, depth+1)
+ }
+}
+
// OverlayEnv overlays the entire project struct without mutating the input.
// Tags: env:"CUSTOM_NAME,required,secret,empty=reject"; env:"-" excludes a
// subtree. required means nonempty string/non-nil pointer or collection; false
@@ -70,7 +155,7 @@ func OverlayEnv[T any](base T, options EnvOptions) (T, error) {
var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
var durationType = reflect.TypeOf(time.Duration(0))
-func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) {
+func projectOverlay[T any](base T, options EnvOptions, defaults bool, sourceMaps ...map[string]string) (T, error) {
var zero T
if options.Lookup == nil {
options.Lookup = os.LookupEnv
@@ -88,8 +173,8 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
result := reflect.New(v.Type()).Elem()
result.Set(v)
seen := map[string]bool{}
- var walk func(reflect.Value, string, int) error
- walk = func(value reflect.Value, prefix string, depth int) error {
+ var walk func(reflect.Value, string, string, int) error
+ walk = func(value reflect.Value, prefix, path string, depth int) error {
if depth > 32 {
return errors.New("config: nesting exceeds 32")
}
@@ -110,6 +195,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
if jsonName == "" {
jsonName = field.Name
}
+ fieldPath := joinField(path, jsonName)
key := prefix + envName(jsonName)
if tag[0] != "" {
key = tag[0]
@@ -131,7 +217,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
}
}
if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) {
- if err := walk(dst, key+"_", depth+1); err != nil {
+ if err := walk(dst, key+"_", fieldPath, depth+1); err != nil {
return err
}
continue
@@ -142,14 +228,14 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
if !dst.IsNil() {
child.Elem().Set(dst.Elem())
}
- if err := walk(child.Elem(), key+"_", depth+1); err != nil {
+ if err := walk(child.Elem(), key+"_", fieldPath, depth+1); err != nil {
return err
}
if !dst.IsNil() || !child.Elem().IsZero() {
dst.Set(child)
}
if required && !defaults && dst.IsNil() {
- return fmt.Errorf("config: %s is required", key)
+ return &Error{Env: key, Field: fieldPath, Reason: "is required"}
}
continue
}
@@ -164,7 +250,11 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
return fmt.Errorf("config: duplicate environment key %s", key)
}
seen[key] = true
- raw, present := options.Lookup(key)
+ var raw string
+ var present bool
+ if !defaults {
+ raw, present = options.Lookup(key)
+ }
selectedKey := key
if !present && !defaults {
for _, alias := range options.Aliases[key] {
@@ -180,7 +270,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
}
if present && raw == "" && !defaults {
if empty == "reject" {
- return fmt.Errorf("config: %s must not be empty", selectedKey)
+ return &Error{Env: selectedKey, Field: fieldPath, Reason: "must not be empty"}
}
if empty == "ignore" {
present = false
@@ -189,17 +279,24 @@ 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", selectedKey, dst.Type())
+ origin := selectedKey
+ if defaults {
+ origin = ""
+ }
+ return &Error{Env: origin, Field: fieldPath, Reason: "expected a valid " + dst.Type().String(), cause: err}
}
dst.Set(parsed)
+ if !defaults && len(sourceMaps) > 0 {
+ sourceMaps[0][fieldPath] = selectedKey
+ }
}
if required && !defaults && emptyRequired(dst) {
- return fmt.Errorf("config: %s is required", key)
+ return &Error{Env: key, Field: fieldPath, Reason: "is required"}
}
}
return nil
}
- if err := walk(result, options.Prefix, 0); err != nil {
+ if err := walk(result, options.Prefix, "", 0); err != nil {
return zero, err
}
return result.Interface().(T), nil
diff --git a/examples/configuration/README.md b/examples/configuration/README.md
new file mode 100644
index 0000000..093d223
--- /dev/null
+++ b/examples/configuration/README.md
@@ -0,0 +1,21 @@
+# 统一配置加载示例
+
+从框架根目录运行,无需数据库或外部服务:
+
+```sh
+go run ./examples/configuration
+go run ./examples/configuration -format json
+go run ./examples/configuration -format xml
+DEMO_CLIENT_TIMEOUT=500ms DEMO_MAX_ITEMS=0 go run ./examples/configuration
+DEMO_MAX_ITEMS=wrong go run ./examples/configuration
+```
+
+前三次都输出 `timeout=2s maxItems=100 allowed=[json xml]`。环境覆盖允许显式零值。
+最后一条输出定位 `maxItems` / `DEMO_MAX_ITEMS` 的安全错误,不打印输入原值。
+也可 `-config /path/to/app.yaml` 读取自己的文件。示例没有建立网络连接。
+
+`Config` 定义字段,`envDefault` 定义缺省值,`Validate` 用 `config.Invalid` 返回字段与公开原因。
+`newLoader` 额外注册 `.custom`(演示复用 JSON decoder);同一个 `load` 可读取所有格式。
+JSON/YAML/XML 直接可用,无需注册。新增格式只修改解析器注册,不改业务结构或校验。
+
+只打印选定非敏感字段,不输出整个配置。完整格式、错误定位和旧项目迁移见 [CONFIG.md](../../CONFIG.md)。
diff --git a/examples/configuration/config.json b/examples/configuration/config.json
new file mode 100644
index 0000000..9cf71b9
--- /dev/null
+++ b/examples/configuration/config.json
@@ -0,0 +1,5 @@
+{
+ "client": {"endpoint": "https://example.com", "timeout": "2s"},
+ "maxItems": 100,
+ "allowed": ["json", "xml"]
+}
diff --git a/examples/configuration/config.xml b/examples/configuration/config.xml
new file mode 100644
index 0000000..ea4492d
--- /dev/null
+++ b/examples/configuration/config.xml
@@ -0,0 +1,8 @@
+
+
+ https://example.com
+ 2s
+
+ 100
+ - json
- xml
+
diff --git a/examples/configuration/config.yaml b/examples/configuration/config.yaml
new file mode 100644
index 0000000..41a32e3
--- /dev/null
+++ b/examples/configuration/config.yaml
@@ -0,0 +1,5 @@
+client:
+ endpoint: https://example.com
+ timeout: 2s
+maxItems: 100
+allowed: [json, xml]
diff --git a/examples/configuration/main.go b/examples/configuration/main.go
new file mode 100644
index 0000000..785ca4b
--- /dev/null
+++ b/examples/configuration/main.go
@@ -0,0 +1,85 @@
+// Run with go run ./examples/configuration -format xml, or -config /path/to/app.yaml.
+package main
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "git.yuntuo.hk/k2safe/go-omni/config"
+)
+
+//go:embed config.json config.yaml config.xml
+var examples embed.FS
+
+type ClientConfig struct {
+ Endpoint string `json:"endpoint"`
+ Timeout time.Duration `json:"timeout" envDefault:"3s"`
+ // This field is deliberately unavailable to configuration files.
+ Token string `json:"-" env:"DEMO_CLIENT_TOKEN,secret"`
+}
+type Config struct {
+ Client ClientConfig `json:"client"`
+ MaxItems int `json:"maxItems" envDefault:"20"`
+ Allowed []string `json:"allowed"`
+}
+
+func (c Config) Validate() error {
+ if c.Client.Timeout <= 0 {
+ return config.Invalid("client.timeout", "must be positive")
+ }
+ if c.MaxItems < 0 {
+ return config.Invalid("maxItems", "must not be negative")
+ }
+ return nil
+}
+func newLoader() (*config.Loader, error) {
+ // Example of an instance-local extension. A TOML/etc. adapter can implement
+ // Decoder and return Document using that parser instead; no global registry.
+ return config.NewLoader(config.WithDecoder("custom", config.DecoderFunc(func(data []byte) (config.Document, error) {
+ return (config.JSONDecoder{}).Decode(data)
+ })))
+}
+func load(path string) (Config, error) {
+ loader, err := newLoader()
+ if err != nil {
+ return Config{}, err
+ }
+ return config.LoadProject(path, Config{}, config.EnvOptions{Prefix: "DEMO_", Empty: "reject"}, loader)
+}
+func run() error {
+ path := flag.String("config", "", "external configuration file")
+ format := flag.String("format", "yaml", "embedded example: json, yaml, xml")
+ flag.Parse()
+ if *path == "" {
+ data, err := examples.ReadFile("config." + *format)
+ if err != nil {
+ return fmt.Errorf("choose json, yaml or xml")
+ }
+ dir, err := os.MkdirTemp("", "goOmniConfiguration-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+ *path = filepath.Join(dir, "config."+*format)
+ if err = os.WriteFile(*path, data, 0600); err != nil {
+ return err
+ }
+ }
+ cfg, err := load(*path)
+ if err != nil {
+ return err
+ }
+ // Print selected non-sensitive fields, never the whole configuration.
+ fmt.Printf("timeout=%s maxItems=%d allowed=%v\n", cfg.Client.Timeout, cfg.MaxItems, cfg.Allowed)
+ return nil
+}
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/examples/configuration/main_test.go b/examples/configuration/main_test.go
new file mode 100644
index 0000000..62de65f
--- /dev/null
+++ b/examples/configuration/main_test.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestExamples(t *testing.T) {
+ var previous Config
+ for i, ext := range []string{"json", "yaml", "xml", "custom"} {
+ source := ext
+ if source == "custom" {
+ source = "json"
+ }
+ data, err := examples.ReadFile("config." + source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "app."+ext)
+ if err = os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if i > 0 && !reflect.DeepEqual(previous, got) {
+ t.Fatal("format difference")
+ }
+ previous = got
+ }
+}
diff --git a/examples/managedService/.goOmni/apiManifest.json b/examples/managedService/.goOmni/apiManifest.json
index 9812898..319ab01 100644
--- a/examples/managedService/.goOmni/apiManifest.json
+++ b/examples/managedService/.goOmni/apiManifest.json
@@ -1,6 +1,6 @@
{
"generatorConfig": true,
- "templateVersion": "v0.19.0-preview",
+ "templateVersion": "v0.20.0-preview",
"profile": {
"database": "none",
"storage": "none",
@@ -12,6 +12,7 @@
"entry": "api/managed.api",
"generated": {
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
+ "internal/config/loadGen.go": "5d630b894abdbbd66ddfa7d7d30f7573fc4ff995020a32a9f9157cd4af3a5655",
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod
index edc3717..e875e86 100644
--- a/examples/managedService/go.mod
+++ b/examples/managedService/go.mod
@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0
-require git.yuntuo.hk/k2safe/go-omni v0.19.0-preview
+require git.yuntuo.hk/k2safe/go-omni v0.20.0-preview
require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
diff --git a/examples/managedService/internal/config/config.go b/examples/managedService/internal/config/config.go
index 9d37b18..0062d72 100644
--- a/examples/managedService/internal/config/config.go
+++ b/examples/managedService/internal/config/config.go
@@ -1,10 +1,9 @@
package config
import (
- "git.yuntuo.hk/k2safe/go-omni/config"
- "git.yuntuo.hk/k2safe/go-omni/logx"
- "os"
"time"
+
+ "git.yuntuo.hk/k2safe/go-omni/config"
)
type Config struct {
@@ -22,42 +21,10 @@ func Default() Config {
return Config{Server: server}
}
-// Load applies defaults -> file -> typed environment -> validation.
-// New fields automatically use GO_OMNI_, or an explicit env tag.
-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_", 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()
-}
+// Validate checks configuration before logs or dependencies open.
func (c Config) Validate() error {
if err := c.Server.Validate(); err != nil {
- return err
+ return config.AtField("server", err)
}
return nil
diff --git a/examples/managedService/internal/config/loadGen.go b/examples/managedService/internal/config/loadGen.go
new file mode 100644
index 0000000..eb7f776
--- /dev/null
+++ b/examples/managedService/internal/config/loadGen.go
@@ -0,0 +1,16 @@
+// Code generated by go-omni api. DO NOT EDIT.
+package config
+
+import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
+
+// Load is maintained by the generator. Extend Config/Default/Validate in
+// config.go, or NewLoader in loader.go; do not duplicate the loading pipeline.
+func Load(path string) (Config, error) {
+ loader, err := NewLoader()
+ if err != nil {
+ return Config{}, err
+ }
+ return omniConfig.LoadProject(path, Default(), omniConfig.EnvOptions{
+ Prefix: "GO_OMNI_", Empty: "ignore", Aliases: omniConfig.ServerEnvAliases("GO_OMNI_", "server"),
+ }, loader)
+}
diff --git a/examples/managedService/internal/config/loader.go b/examples/managedService/internal/config/loader.go
new file mode 100644
index 0000000..64ec950
--- /dev/null
+++ b/examples/managedService/internal/config/loader.go
@@ -0,0 +1,7 @@
+package config
+
+import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
+
+// NewLoader is project-owned. JSON/YAML/XML work without changes. Register
+// additional format decoders here only when the project needs them.
+func NewLoader() (*omniConfig.Loader, error) { return omniConfig.NewLoader() }
diff --git a/reports/configurationAcceptance.md b/reports/configurationAcceptance.md
new file mode 100644
index 0000000..82e9ac5
--- /dev/null
+++ b/reports/configurationAcceptance.md
@@ -0,0 +1,53 @@
+# v0.20 统一配置加载验收(2026-09-26)
+
+本轮修改 go-omni 配置包、生成模板与文档。没有修改接入项目的业务配置/数据库,也没有使用线上服务器做压力测试。
+
+## 回归结果
+
+- 全量 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:47 个有测试的包通过,包含实际生成项目、生命周期失败清理、错误目录/响应继承、上传和拷贝工具回归。
+- `go vet ./...`、`go build ./...` 通过。
+- `GOTOOLCHAIN=go1.22.12 go test ./config ./examples/configuration` 通过。
+- 新生成消费者的 `go test -race ./...`、`go build .`、generate/check/dry-run 通过。
+- checked-in managedService 已显式迁移旧 Load,生成检查、race 和构建通过;三种格式示例与环境覆盖命令实际运行通过。
+
+针对性验证:
+
+| 场景 | 结果 |
+| --- | --- |
+| JSON / YAML / XML 的 defaults→file→env→Validate | 同值,0/false 可覆盖,envDefault 不覆盖文件 |
+| 大于 2^53 的整数、动态 json.Number | 精度保留 |
+| null、空数组、嵌套指针、map、duration、TextUnmarshaler、JSON tag / 嵌入字段 | 语义回归通过 |
+| 文件字段类型错、未知字段、重复字段、业务 Validate 失败 | 文件/字段/可用行列与安全原因 |
+| 环境类型或校验失败、集合子字段校验 | 定位环境来源,不错误归因于文件 |
+| 内部错误链和默认值隔离 | errors.Is/As 可用,失败不提交半成品/修改默认值 |
+| 24 个并发加载、自定义 Decoder、禁止事后注册 | race 通过,实例间不泄漏 |
+| 超大文件、超深输入、重复 XML 根、DTD/entity、YAML alias/merge、UTF-8 | 拒绝;解析器 panic 不公开 panic 内容 |
+| 业务新增字段与自定义格式 | 不改 main/loadGen,生成保留手写文件 |
+| 配置校验失败 | 依赖初始化之前返回;未触发测试中的 Init panic |
+| 旧手写 Load、loadGen 漂移 | generate/check/dry-run 拒绝冲突,不改业务文件 |
+
+30 秒、4 worker 的 `FuzzConfigurationDecoders`:383586 次执行,无失败(总运行 31.309 秒,包含停止收尾)。模糊输入上限 8 KiB;另有确定性 1 MiB/深度上限和恶意格式回归,不把 fuzz 次数当作形式证明。
+
+## 性能
+
+Go 1.27.1、darwin/arm64、Apple M2 Max;无 race 的本地基准,每个场景三轮。原始数据:[configurationBenchmark.txt](configurationBenchmark.txt)。
+
+| 场景 | 中位数耗时 | 典型分配 |
+| --- | ---: | ---: |
+| 小配置 JSON 解析+绑定 | 5.068 µs | 3781 B / 75 allocs |
+| 小配置 YAML 解析+绑定 | 14.855 µs | 15602 B / 181 allocs |
+| 小配置 XML 解析+绑定 | 7.137 µs | 5605 B / 122 allocs |
+| 含完整 Server 默认值:文件读取+环境覆盖+校验 | 107.660 µs | 约 67.4 KB / 1288 allocs |
+| 1000 个条目,约 28 KB JSON 解析+绑定 | 2.548 ms | 约 2.40 MB / 31585 allocs |
+
+配置在启动阶段加载,不经过业务请求路径。这些是暖文件缓存、本机测试结果,不能等同冷磁盘启动耗时、请求 QPS 或跨机器性能承诺。没有为此引入全局反射缓存、解析器池或后台线程。
+
+## 边界与迁移
+
+- 旧手写 Load 必须先明确迁移,生成器不会猜测其解密/其他特殊行为。
+- 内置 XML 是明确的配置子集;YAML alias/merge/custom tag 拒绝;固定 Go 数组严格长度。
+- 自定义 Decoder/反序列化/Validate 是可信 Go 回调,其并发、副作用与公开 Reason 的脱敏由项目负责。
+- 第三方非结构化验证错误仅定位子树,并保留内部原因;业务用 Invalid/AtField 提供准确的公开字段与原因。
+- 未进行业务数据库/第三方真实依赖验收,未修改或替代各接入项目的业务回归。
+
+完整使用与迁移:[CONFIG.md](../CONFIG.md)。远程固定版本安装验收在发布后追加。
diff --git a/reports/configurationBenchmark.txt b/reports/configurationBenchmark.txt
new file mode 100644
index 0000000..a5cb34c
--- /dev/null
+++ b/reports/configurationBenchmark.txt
@@ -0,0 +1,21 @@
+goos: darwin
+goarch: arm64
+pkg: git.yuntuo.hk/k2safe/go-omni/config
+cpu: Apple M2 Max
+BenchmarkConfiguration/json-12 239197 5068 ns/op 3781 B/op 75 allocs/op
+BenchmarkConfiguration/json-12 234421 5058 ns/op 3781 B/op 75 allocs/op
+BenchmarkConfiguration/json-12 233330 5564 ns/op 3781 B/op 75 allocs/op
+BenchmarkConfiguration/yaml-12 83022 14827 ns/op 15602 B/op 181 allocs/op
+BenchmarkConfiguration/yaml-12 83197 14855 ns/op 15604 B/op 181 allocs/op
+BenchmarkConfiguration/yaml-12 74828 16070 ns/op 15602 B/op 181 allocs/op
+BenchmarkConfiguration/xml-12 167509 7144 ns/op 5606 B/op 122 allocs/op
+BenchmarkConfiguration/xml-12 171202 7086 ns/op 5605 B/op 122 allocs/op
+BenchmarkConfiguration/xml-12 167727 7137 ns/op 5605 B/op 122 allocs/op
+BenchmarkConfigurationStartup-12 10000 107660 ns/op 67429 B/op 1288 allocs/op
+BenchmarkConfigurationStartup-12 10000 107420 ns/op 67427 B/op 1288 allocs/op
+BenchmarkConfigurationStartup-12 10000 107828 ns/op 67430 B/op 1288 allocs/op
+BenchmarkConfigurationLarge-12 481 2548066 ns/op 10.99 MB/s 2403822 B/op 31585 allocs/op
+BenchmarkConfigurationLarge-12 471 2532248 ns/op 11.06 MB/s 2403851 B/op 31586 allocs/op
+BenchmarkConfigurationLarge-12 477 2641417 ns/op 10.61 MB/s 2403402 B/op 31583 allocs/op
+PASS
+ok git.yuntuo.hk/k2safe/go-omni/config 20.682s
diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py
index 658954b..2f3e413 100644
--- a/scripts/verifyManagedRelease.py
+++ b/scripts/verifyManagedRelease.py
@@ -124,7 +124,7 @@ def main():
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", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer"]
+ tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer"]
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("{")]
@@ -137,6 +137,8 @@ def main():
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
+ checks += ["generatedUnifiedConfig", "jsonYamlXmlAndCustomDecoder", "configValidationBeforeDependencies", "configEnvProvenance", "configExtensionPreserved", "configLoaderDriftCheck"]
+
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")]
@@ -145,6 +147,8 @@ def main():
sources += ["cmd/go-omni/apigen/" + name for name in ("composition.go", "generatorConfig.go", "copyConsumer_test.go", "generatorConfig_test.go", "middlewareFactory.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/composition").iterdir() if p.is_file()]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "copyx").glob("*.go")]
+ sources += [str(p.relative_to(ROOT)) for p in (ROOT / "config").glob("*.go")]
+ sources += ["cmd/go-omni/apigen/configLoader_test.go", "cmd/go-omni/apigen/testdata/configuration/consumer_test.go", "cmd/go-omni/scaffold/templates/api-load.go.tmpl", "cmd/go-omni/scaffold/templates/api-loader.go.tmpl"]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,