feat: unify generated configuration loading and extensible decoders

This commit is contained in:
2026-09-26 14:47:33 +08:00
parent 91a6beb2f3
commit c60841f901
42 changed files with 2280 additions and 189 deletions

View File

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

View File

@@ -0,0 +1,5 @@
{
"client": {"endpoint": "https://example.com", "timeout": "2s"},
"maxItems": 100,
"allowed": ["json", "xml"]
}

View File

@@ -0,0 +1,8 @@
<config>
<client>
<endpoint>https://example.com</endpoint>
<timeout>2s</timeout>
</client>
<maxItems>100</maxItems>
<allowed type="array"><item>json</item><item>xml</item></allowed>
</config>

View File

@@ -0,0 +1,5 @@
client:
endpoint: https://example.com
timeout: 2s
maxItems: 100
allowed: [json, xml]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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_<JSON_PATH>, 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

View File

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

View File

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