From 6697240a8e48cc34704fce8540aaae1a773e53a0 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 19 Sep 2026 15:52:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/config.default.yaml | 6 +- config.go => internal/config/config.go | 57 +++- internal/config/config_test.go | 206 ++++++++++++++ internal/config/upgrade.go | 267 ++++++++++++++++++ main.go | 73 ++++- 5 files changed, 592 insertions(+), 17 deletions(-) rename config.yaml => internal/config/config.default.yaml (83%) rename config.go => internal/config/config.go (77%) create mode 100644 internal/config/config_test.go create mode 100644 internal/config/upgrade.go diff --git a/config.yaml b/internal/config/config.default.yaml similarity index 83% rename from config.yaml rename to internal/config/config.default.yaml index f4b0858..32c694d 100644 --- a/config.yaml +++ b/internal/config/config.default.yaml @@ -1,9 +1,11 @@ # rill 服务端配置 +version: 1 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 + server: host: "0.0.0.0" # 监听地址,0.0.0.0 表示所有网卡 - port: 8080 + port: 8080 #web 服务端口,为 0 则不使用 tcp html sock: "web.sock" # unix socket 文件路径,留空表示不启用 - mode: debug # gin 运行模式: debug / release / test + mode: release # gin 运行模式: debug / release / test log: diff --git a/config.go b/internal/config/config.go similarity index 77% rename from config.go rename to internal/config/config.go index 479dcf4..3617040 100644 --- a/config.go +++ b/internal/config/config.go @@ -1,18 +1,24 @@ -package main +package config import ( + _ "embed" "errors" "fmt" "io/fs" "log/slog" "os" + "path/filepath" "strings" "time" "github.com/goccy/go-yaml" ) +//go:embed config.default.yaml +var defaultConfigYAML []byte + type Config struct { + Version int `yaml:"version"` Server ServerConfig `yaml:"server"` Log LogConfig `yaml:"log"` Static StaticConfig `yaml:"static"` @@ -23,9 +29,20 @@ type Config struct { type ServerConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` + Sock string `yaml:"sock"` Mode string `yaml:"mode"` } +// TCPEnabled 是否启用 TCP 监听(port 为 0 表示不启用)。 +func (s ServerConfig) TCPEnabled() bool { + return s.Port > 0 +} + +// SockEnabled 是否启用 unix socket 监听(sock 留空表示不启用)。 +func (s ServerConfig) SockEnabled() bool { + return strings.TrimSpace(s.Sock) != "" +} + type LogConfig struct { Level string `yaml:"level"` AccessLog bool `yaml:"access_log"` @@ -77,7 +94,8 @@ func defaultConfig() *Config { Server: ServerConfig{ Host: "0.0.0.0", Port: 8080, - Mode: "debug", + Sock: "web.sock", + Mode: "release", }, Log: LogConfig{ Level: "info", @@ -118,36 +136,57 @@ func defaultConfig() *Config { } } -// LoadConfig 读取 YAML 配置文件,未指定的配置项使用默认值;文件不存在时回退到默认配置。 +// LoadConfig 读取 YAML 配置文件,未指定的配置项使用默认值;文件不存在时自动生成默认配置文件。 func LoadConfig(path string) (*Config, error) { cfg := defaultConfig() data, err := os.ReadFile(path) switch { case err == nil: - if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err) - } + data = applyConfigUpgrade(path, data) case errors.Is(err, fs.ErrNotExist): - slog.Warn("配置文件不存在,使用默认配置", "path", path) + if err := generateConfig(path); err != nil { + return nil, err + } + data = defaultConfigYAML default: return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err) } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err) + } + if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } +func generateConfig(path string) error { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建配置目录 %s 失败: %w", dir, err) + } + } + if err := os.WriteFile(path, defaultConfigYAML, 0o644); err != nil { + return fmt.Errorf("生成默认配置文件 %s 失败: %w", path, err) + } + slog.Info("配置文件不存在,已生成默认配置", "path", path) + return nil +} + func (c *Config) validate() error { switch c.Server.Mode { case "debug", "release", "test": default: return fmt.Errorf("server.mode 无效: %q(可选: debug/release/test)", c.Server.Mode) } - if c.Server.Port <= 0 || c.Server.Port > 65535 { - return fmt.Errorf("server.port 无效: %d", c.Server.Port) + if c.Server.Port < 0 || c.Server.Port > 65535 { + return fmt.Errorf("server.port 无效: %d(0 表示不启用 TCP)", c.Server.Port) + } + if !c.Server.TCPEnabled() && !c.Server.SockEnabled() { + return fmt.Errorf("server.port 与 server.sock 至少需要启用一个") } if _, err := parseLogLevel(c.Log.Level); err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..da30875 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,206 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goccy/go-yaml" +) + +func TestUpgradeConfigFillsMissing(t *testing.T) { + input := `server: + host: "127.0.0.1" # 自定义监听地址 + port: 9000 +custom: + keep: true +` + result, err := upgradeConfig([]byte(input)) + if err != nil { + t.Fatalf("upgradeConfig 失败: %v", err) + } + if result == nil { + t.Fatal("期望产生补全结果") + } + if result.Version != 0 { + t.Fatalf("补全前版本 = %d, 期望 0", result.Version) + } + + out := string(result.Data) + for _, want := range []string{ + "version: 1", + `host: "127.0.0.1"`, + "# 自定义监听地址", + "sock:", + `"web.sock"`, + "# unix socket 文件路径", + "mode: release", + "custom:", + "keep: true", + "api:", + `prefix: "/api"`, + } { + if !strings.Contains(out, want) { + t.Errorf("补全结果缺少 %q\n---\n%s", want, out) + } + } + + var cfg Config + if err := yaml.Unmarshal(result.Data, &cfg); err != nil { + t.Fatalf("补全结果无法解析: %v\n%s", err, out) + } + if cfg.Version != ConfigVersion { + t.Errorf("version = %d, 期望 %d", cfg.Version, ConfigVersion) + } + if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 9000 { + t.Errorf("用户已有值被覆盖: %+v", cfg.Server) + } + if cfg.Server.Sock != "web.sock" || cfg.Server.Mode != "release" { + t.Errorf("缺失项未按默认值补全: %+v", cfg.Server) + } + if len(result.Added) == 0 { + t.Error("期望记录新增配置项路径") + } +} + +func TestUpgradeConfigIdempotent(t *testing.T) { + first, err := upgradeConfig([]byte("server:\n port: 9000\n")) + if err != nil || first == nil { + t.Fatalf("首次补全失败: result=%v err=%v", first, err) + } + second, err := upgradeConfig(first.Data) + if err != nil { + t.Fatalf("二次检查失败: %v", err) + } + if second != nil { + t.Errorf("版本已是最新,不应再次变更:\n%s", second.Data) + } +} + +func TestUpgradeConfigSkipsCurrentVersion(t *testing.T) { + input := "version: 1\nserver:\n host: \"0.0.0.0\"\n" + result, err := upgradeConfig([]byte(input)) + if err != nil { + t.Fatalf("upgradeConfig 失败: %v", err) + } + if result != nil { + t.Errorf("版本一致时不应扫描补全:\n%s", result.Data) + } +} + +func TestUpgradeConfigNewerVersion(t *testing.T) { + input := "version: 99\nserver:\n port: 9000\n" + result, err := upgradeConfig([]byte(input)) + if err != nil { + t.Fatalf("upgradeConfig 失败: %v", err) + } + if result != nil { + t.Errorf("高版本配置不应被改写:\n%s", result.Data) + } +} + +func TestUpgradeConfigNullSection(t *testing.T) { + input := "version: 0\napi:\nserver:\n host: \"0.0.0.0\"\n" + result, err := upgradeConfig([]byte(input)) + if err != nil || result == nil { + t.Fatalf("补全失败: result=%v err=%v", result, err) + } + + out := string(result.Data) + for _, want := range []string{`prefix: "/api"`, `max_age: "12h"`, "allow_origins"} { + if !strings.Contains(out, want) { + t.Errorf("空节未按默认子树补全,缺少 %q\n---\n%s", want, out) + } + } + + var cfg Config + if err := yaml.Unmarshal(result.Data, &cfg); err != nil { + t.Fatalf("补全结果无法解析: %v", err) + } + if cfg.API.CORS.MaxAge != "12h" { + t.Errorf("api.cors.max_age = %q, 期望 12h", cfg.API.CORS.MaxAge) + } +} + +func TestLoadConfigUpgradeWritesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + original := "server:\n host: \"127.0.0.1\"\n port: 9000\n" + if err := os.WriteFile(path, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig 失败: %v", err) + } + if cfg.Server.Port != 9000 || cfg.Server.Sock != "web.sock" { + t.Errorf("加载结果不符合预期: %+v", cfg.Server) + } + + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(updated), "version: 1") { + t.Errorf("磁盘配置未补全 version:\n%s", updated) + } + + bak, err := os.ReadFile(path + ".bak") + if err != nil { + t.Fatalf("未生成备份文件: %v", err) + } + if string(bak) != original { + t.Errorf("备份内容与升级前不一致:\n%s", bak) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("文件权限 = %o, 期望 600", info.Mode().Perm()) + } + + if _, err := LoadConfig(path); err != nil { + t.Fatalf("二次加载失败: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(updated, after) { + t.Errorf("版本已是最新,二次加载不应改写文件:\n--- before\n%s\n--- after\n%s", updated, after) + } +} + +func TestLoadConfigGeneratesDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), "data", "config.yaml") + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig 失败: %v", err) + } + if cfg.Version != ConfigVersion || cfg.Server.Port != 8080 || cfg.Server.Sock != "web.sock" { + t.Errorf("默认配置不符合预期: version=%d server=%+v", cfg.Version, cfg.Server) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("默认配置文件未生成: %v", err) + } + if !strings.Contains(string(data), "version: 1") { + t.Errorf("默认配置文件缺少 version:\n%s", data) + } +} + +func TestDefaultTemplateVersionMatchesConst(t *testing.T) { + m, err := defaultMapping() + if err != nil { + t.Fatalf("解析默认模板失败: %v", err) + } + if version := versionOf(m); version != ConfigVersion { + t.Fatalf("默认模板 version = %d, ConfigVersion = %d,请同步更新", version, ConfigVersion) + } +} diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go new file mode 100644 index 0000000..9b95a40 --- /dev/null +++ b/internal/config/upgrade.go @@ -0,0 +1,267 @@ +package config + +import ( + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/goccy/go-yaml" + "github.com/goccy/go-yaml/ast" + "github.com/goccy/go-yaml/parser" +) + +// ConfigVersion 当前配置结构版本,新增配置项时递增。 +const ConfigVersion = 1 + +// upgradeResult 描述一次配置自动补全的结果。 +type upgradeResult struct { + Data []byte + Version int + Added []string +} + +// upgradeConfig 比较配置版本,版本落后时按默认模板递归补全缺失项。 +// 返回 nil 表示无需变更。 +func upgradeConfig(data []byte) (*upgradeResult, error) { + file, err := parser.ParseBytes(data, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("解析配置失败: %w", err) + } + body, err := rootMapping(file) + if err != nil { + return nil, err + } + + version := versionOf(body) + if version > ConfigVersion { + slog.Warn("配置文件版本高于当前程序,可能存在未知配置项,请升级程序", + "version", version, "supported", ConfigVersion) + return nil, nil + } + if version == ConfigVersion { + return nil, nil + } + + defaults, err := defaultMapping() + if err != nil { + return nil, err + } + + added := mergeMissing(body, defaults, "") + setVersion(body, ConfigVersion) + return &upgradeResult{Data: []byte(file.String()), Version: version, Added: added}, nil +} + +// mergeMissing 以用户配置为基底递归补入默认模板中缺失的键,已有值保持不变。 +// 返回新增配置项的路径列表。 +func mergeMissing(user, defaults *ast.MappingNode, prefix string) []string { + var added []string + + index := make(map[string]int, len(user.Values)) + for i, v := range user.Values { + index[v.Key.String()] = i + } + delta := mappingKeyColumn(user) - mappingKeyColumn(defaults) + insertAt := 0 + + for _, dv := range defaults.Values { + key := dv.Key.String() + full := joinPath(prefix, key) + + i, exists := index[key] + if !exists { + node := dv + node.AddColumn(delta) + user.Values = slices.Insert(user.Values, insertAt, node) + shiftIndex(index, insertAt) + index[key] = insertAt + insertAt++ + added = append(added, full) + continue + } + if i+1 > insertAt { + insertAt = i + 1 + } + + uv := user.Values[i] + switch { + case isMapping(uv.Value) && isMapping(dv.Value): + added = append(added, mergeMissing( + uv.Value.(*ast.MappingNode), dv.Value.(*ast.MappingNode), full)...) + case isNull(uv.Value) && isMapping(dv.Value): + dm := dv.Value.(*ast.MappingNode) + dm.AddColumn(uv.Key.GetToken().Position.Column - dv.Key.GetToken().Position.Column) + uv.Value = dm + added = append(added, leafPaths(dm, full)...) + } + } + return added +} + +// setVersion 将配置中的 version 更新为当前版本(保留原行内注释)。 +func setVersion(body *ast.MappingNode, version int) { + for _, v := range body.Values { + if v.Key.String() != "version" { + continue + } + node, err := yaml.ValueToNode(version) + if err != nil { + return + } + if comment := v.Value.GetComment(); comment != nil { + _ = node.SetComment(comment) + } + v.Value = node + return + } +} + +// versionOf 读取配置中的 version,缺失或无效时视为 0。 +func versionOf(body *ast.MappingNode) int { + for _, v := range body.Values { + if v.Key.String() != "version" { + continue + } + var version int + if err := yaml.NodeToValue(v.Value, &version); err != nil { + return 0 + } + return version + } + return 0 +} + +func defaultMapping() (*ast.MappingNode, error) { + file, err := parser.ParseBytes(defaultConfigYAML, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("解析默认配置模板失败: %w", err) + } + return rootMapping(file) +} + +func rootMapping(file *ast.File) (*ast.MappingNode, error) { + if len(file.Docs) == 0 || file.Docs[0].Body == nil { + return nil, fmt.Errorf("配置内容为空") + } + body, ok := file.Docs[0].Body.(*ast.MappingNode) + if !ok { + return nil, fmt.Errorf("配置根节点必须是映射") + } + return body, nil +} + +func mappingKeyColumn(m *ast.MappingNode) int { + if len(m.Values) > 0 && m.Values[0].Key != nil { + return m.Values[0].Key.GetToken().Position.Column + } + if m.Start != nil { + return m.Start.Position.Column + } + return 1 +} + +func joinPath(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +func leafPaths(m *ast.MappingNode, prefix string) []string { + var paths []string + for _, v := range m.Values { + key := joinPath(prefix, v.Key.String()) + if child, ok := v.Value.(*ast.MappingNode); ok && len(child.Values) > 0 { + paths = append(paths, leafPaths(child, key)...) + continue + } + paths = append(paths, key) + } + return paths +} + +func isMapping(node ast.Node) bool { + _, ok := node.(*ast.MappingNode) + return ok +} + +func isNull(node ast.Node) bool { + if node == nil { + return true + } + _, ok := node.(*ast.NullNode) + return ok +} + +func shiftIndex(index map[string]int, from int) { + for key, i := range index { + if i >= from { + index[key] = i + 1 + } + } +} + +// applyConfigUpgrade 执行配置检查与补全;失败时仅告警并使用原内容,不阻塞启动。 +func applyConfigUpgrade(path string, data []byte) []byte { + result, err := upgradeConfig(data) + if err != nil { + slog.Warn("配置自动补全检查失败,将使用默认值补齐缺失项", "path", path, "err", err) + return data + } + if result == nil { + return data + } + + if err := backupConfig(path); err != nil { + slog.Warn("备份配置文件失败", "path", path, "err", err) + } + if err := writeFileAtomic(path, result.Data); err != nil { + slog.Warn("配置自动补全写回失败,将使用默认值补齐缺失项", "path", path, "err", err) + return data + } + slog.Info("配置已自动补全", + "path", path, + "version", fmt.Sprintf("%d -> %d", result.Version, ConfigVersion), + "added", strings.Join(result.Added, ", ")) + return result.Data +} + +func backupConfig(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(path+".bak", data, fileMode(path)) +} + +func writeFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".rill-config-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, fileMode(path)); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +func fileMode(path string) fs.FileMode { + if info, err := os.Stat(path); err == nil { + return info.Mode().Perm() + } + return 0o644 +} diff --git a/main.go b/main.go index f1d4eaa..05d4407 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,32 @@ package main import ( + "context" + "errors" "flag" + "fmt" + "io/fs" "log/slog" "net" "net/http" "os" + "os/signal" "strconv" "strings" + "syscall" + "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + + "rill/internal/config" ) func main() { - configPath := flag.String("c", "config.yaml", "配置文件路径") + configPath := flag.String("c", "data/config.yaml", "配置文件路径(不存在时自动生成)") flag.Parse() - cfg, err := LoadConfig(*configPath) + cfg, err := config.LoadConfig(*configPath) if err != nil { slog.Error("加载配置失败", "err", err) os.Exit(1) @@ -63,10 +72,62 @@ func main() { c.Abort() }) - addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)) - slog.Info("服务启动", "addr", addr, "static", cfg.Static.Dir, "mode", cfg.Server.Mode) - if err := r.Run(addr); err != nil { - slog.Error("服务启动失败", "err", err) + if err := serve(cfg, r); err != nil { + slog.Error("服务运行失败", "err", err) os.Exit(1) } } + +// serve 依据配置监听 TCP 与 unix socket(可同时启用),并在收到退出信号后优雅关闭。 +func serve(cfg *config.Config, handler http.Handler) error { + srv := &http.Server{Handler: handler} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 2) + listen := func(network, addr string) error { + ln, err := net.Listen(network, addr) + if err != nil { + return fmt.Errorf("监听 %s %s 失败: %w", network, addr, err) + } + slog.Info("服务监听中", "network", network, "addr", addr, "static", cfg.Static.Dir, "mode", cfg.Server.Mode) + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + return nil + } + + if cfg.Server.TCPEnabled() { + addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)) + if err := listen("tcp", addr); err != nil { + return err + } + } + if cfg.Server.SockEnabled() { + sock := strings.TrimSpace(cfg.Server.Sock) + if err := os.Remove(sock); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("清理残留 socket %s 失败: %w", sock, err) + } + if err := listen("unix", sock); err != nil { + return err + } + defer os.Remove(sock) + } + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + stop() + slog.Info("收到退出信号,正在关闭服务") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("关闭服务失败: %w", err) + } + } + return nil +}