diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..930ae3f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## v0.1.1 + +- 修复脚手架生成项目固定依赖不存在的 `v0.0.0`;远程安装的 CLI 默认使用自身版本,支持 `--framework-version` 指定完整版本号。 +- 显式远程版本禁用自动本地替换;本地替换路径支持空格,并按精确 module path 识别框架目录。 +- 生成项目通过 `signal.NotifyContext` 接入优雅退出,启动过程中的错误会先执行 defer 清理再报告。 +- `App.Run` 自动处理 `SIGINT` / `SIGTERM`;`RunContext` 仍由调用方控制,退出超时会关闭活跃连接。 +- 增加请求排空、超时取消、启动失败清理测试,以及远程脚手架和真实进程信号验收脚本。 + +## v0.1.0 + +- 首个可试用版本:应用生命周期、HTTP 抽象、Gin 与标准库路由适配器、统一响应与错误。 +- 提供配置、日志、校验、认证接口、数据库基础能力、健康探针和项目脚手架。 diff --git a/README.md b/README.md index 93cd9ee..e58b28c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,22 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gi 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.1.0 +go get git.yuntuo.hk/k2safe/go-omni@v0.1.1 ``` +创建新业务项目: + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.1.1 +go-omni new --module example.com/billing ./billing +cd billing +go mod tidy +go run ./cmd/server +``` + +远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。 +具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。 + ## 目录结构 ```text @@ -25,7 +38,7 @@ go-omni/ ├── database/ # database/sql 生命周期和事务 ├── transport/gin/ # Gin 网络适配器,可替换 ├── examples/ # 示例项目 -└── cmd/ # 后续 CLI +└── cmd/ # go-omni 项目脚手架 ``` ## 快速开始 @@ -104,7 +117,8 @@ application.OnShutdown(logger.Close) `GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由 `database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。 -`App` 同时实现标准 `http.Handler`,并提供 `RunContext`:收到 `SIGINT` / `SIGTERM` 后可以先停止接收新请求,再在超时时间内等待活跃请求完成。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。 +`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。 +退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。 应用资源可以直接挂到 `omni.New` 返回的生命周期上: @@ -154,4 +168,6 @@ go run ./examples/reference ## 当前边界 -缓存、消息队列、定时任务和 CLI 暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。 +缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。 + +当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..81b4e86 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,27 @@ +# GoOmni 成熟度计划 + +## 1. 新项目启动与发布流程(v0.1.1) + +- 修正脚手架固定版本引用,保留显式本地开发选项。 +- 生成服务处理退出信号,等待请求完成后清理资源。 +- 验收:源码目录外安装远程 CLI → 生成无 replace 的项目 → 编译 → 请求健康接口和业务接口 → SIGINT/SIGTERM 正常退出。 +- 自动验收入口:`python3 scripts/verifyScaffold.py --version v0.1.1`。 + +## 2. 业务数据接入(待完成) + +- 数据库初始化、Repository 和 ServiceContext 组合成完整业务启动流程。 +- Readiness 注册数据库探针,启动失败与服务退出时清理连接池。 +- 验收:真实数据库 CRUD、事务回滚、数据库不可用时 readiness 返回 503。 + +## 3. 网络适配一致性(待完成) + +- Gin 与标准库适配器运行同一套契约测试。 +- 覆盖路由参数、分组、中间件、错误响应和取消传播。 +- 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。 + +## 4. 实际业务试用(待完成) + +- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。 +- 根据真实接入问题收敛公共 API,再评估稳定版范围。 + +以上阶段以验收结果判断完成。当前仍是基础框架试用阶段,数据库生产验收、自研网络内核与实际业务接入尚未完成。 diff --git a/app/app.go b/app/app.go index 8049f13..e9b2944 100644 --- a/app/app.go +++ b/app/app.go @@ -3,8 +3,12 @@ package app import ( "context" "errors" + "net" "net/http" + "os" + "os/signal" "sync" + "syscall" "time" "git.yuntuo.hk/k2safe/go-omni/config" @@ -104,9 +108,12 @@ func (a *App) Close() error { return a.closeErr } -// Run starts the HTTP server. The default address is :8080. +// Run starts the HTTP server and handles SIGINT/SIGTERM. Use RunContext when +// the caller owns cancellation. The default address is :8080. func (a *App) Run(address ...string) error { - return a.RunContext(context.Background(), address...) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return a.RunContext(ctx, address...) } // NewServer creates a standard HTTP server around the application. @@ -125,6 +132,9 @@ func (a *App) RunContext(ctx context.Context, address ...string) error { if ctx == nil { ctx = context.Background() } + if ctx.Err() != nil { + return a.Close() + } listenOn := a.config.Address if listenOn == "" { listenOn = ":8080" @@ -133,8 +143,16 @@ func (a *App) RunContext(ctx context.Context, address ...string) error { listenOn = address[0] } server := a.NewServer(listenOn) + listener, err := net.Listen("tcp", server.Addr) + if err != nil { + return errors.Join(err, a.Close()) + } + return a.serveContext(ctx, server, listener) +} + +func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error { serveErr := make(chan error, 1) - go func() { serveErr <- server.ListenAndServe() }() + go func() { serveErr <- server.Serve(listener) }() select { case err := <-serveErr: @@ -151,6 +169,11 @@ func (a *App) RunContext(ctx context.Context, address ...string) error { shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() shutdownErr := server.Shutdown(shutdownCtx) + if shutdownErr != nil { + // Shutdown leaves active connections open on timeout. Close them so + // request contexts are canceled before releasing shared resources. + shutdownErr = errors.Join(shutdownErr, server.Close()) + } if err := <-serveErr; err != nil && err != http.ErrServerClosed { shutdownErr = errors.Join(shutdownErr, err) } diff --git a/app/lifecycle_test.go b/app/lifecycle_test.go new file mode 100644 index 0000000..d4da66b --- /dev/null +++ b/app/lifecycle_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "sync" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" +) + +type closingListener struct { + net.Listener + closed chan struct{} + once sync.Once +} + +func (l *closingListener) Close() error { + err := l.Listener.Close() + l.once.Do(func() { close(l.closed) }) + return err +} + +func startTestServer(t *testing.T, application *App) (*closingListener, context.CancelFunc, <-chan error) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + wrapped := &closingListener{Listener: listener, closed: make(chan struct{})} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + t.Cleanup(func() { _ = listener.Close() }) + done := make(chan error, 1) + go func() { done <- application.serveContext(ctx, application.NewServer(""), wrapped) }() + return wrapped, cancel, done +} + +func await(t *testing.T, event <-chan struct{}) { + t.Helper() + select { + case <-event: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for server event") + } +} + +func TestShutdownDrainsRequestBeforeCleanup(t *testing.T) { + application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(3*time.Second)) + started, release, cleaned := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + application.GET("/slow", func(ctx *httpx.Context) error { + close(started) + <-release + return ctx.OK("finished") + }) + application.OnShutdown(func() error { close(cleaned); return nil }) + listener, cancel, done := startTestServer(t, application) + requestDone := make(chan error, 1) + go func() { + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get("http://" + listener.Addr().String() + "/slow") + if err == nil { + defer response.Body.Close() + _, err = io.ReadAll(response.Body) + if response.StatusCode != http.StatusOK { + err = errors.New("request was not completed successfully") + } + } + requestDone <- err + }() + await(t, started) + cancel() + await(t, listener.closed) + select { + case <-cleaned: + t.Fatal("resources closed while a request was still running") + default: + } + unblock() + if err := <-requestDone; err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not finish shutdown") + } + await(t, cleaned) +} + +func TestShutdownTimeoutClosesActiveConnection(t *testing.T) { + application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(25*time.Millisecond)) + started, canceled := make(chan struct{}), make(chan struct{}) + application.GET("/slow", func(ctx *httpx.Context) error { + close(started) + <-ctx.Context().Done() + close(canceled) + return nil + }) + listener, cancel, done := startTestServer(t, application) + requestDone := make(chan struct{}) + go func() { + defer close(requestDone) + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get("http://" + listener.Addr().String() + "/slow") + if err == nil { + response.Body.Close() + } + }() + await(t, started) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected shutdown deadline error, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("shutdown ignored timeout") + } + await(t, canceled) + await(t, requestDone) +} + +func TestRunContextStartupFailureAndCanceledContextCloseResources(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + for _, canceled := range []bool{false, true} { + application := NewWithRouter(nethttp.New()) + closeCount := 0 + application.OnShutdown(func() error { closeCount++; return nil }) + ctx, cancel := context.WithCancel(context.Background()) + if canceled { + cancel() + } + err := application.RunContext(ctx, listener.Addr().String()) + cancel() + if !canceled && err == nil { + t.Fatal("expected occupied address error") + } + if canceled && err != nil { + t.Fatalf("canceled start: %v", err) + } + _ = application.Close() + if closeCount != 1 { + t.Fatalf("cleanup ran %d times", closeCount) + } + } +} diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index af64a07..49decf9 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -1,11 +1,53 @@ # go-omni CLI -生成一个带 Handler / Service / Repository / Model 分层的服务骨架: +从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: ```sh -go run ./cmd/go-omni new --module example.com/billing ./billing +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.1.1 +go-omni new --module example.com/billing ./billing +cd billing +go mod tidy +go run ./cmd/server ``` +通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的 +`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。 + +需要选择其他已发布版本时,使用完整版本号: + +```sh +go-omni new --framework-version v0.1.1 --module example.com/billing ./billing +``` + +`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。 +源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。 + 在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地 `replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入 `--force` 才会覆盖脚手架文件。 + +也可以在任意目录显式指定源码路径: + +```sh +go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing +``` + +生成服务默认包含 `/health/live`、`/health/ready` 和示例 `POST /users`。 +退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。 + +发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖: + +```sh +python3 scripts/verifyScaffold.py --version v0.1.1 +``` + +脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、 +编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的 +信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。 + +发布前验证当前源码: + +```sh +go build -o /tmp/go-omni ./cmd/go-omni +python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir . +``` diff --git a/cmd/go-omni/main.go b/cmd/go-omni/main.go index 31f2643..a0914f6 100644 --- a/cmd/go-omni/main.go +++ b/cmd/go-omni/main.go @@ -55,6 +55,17 @@ func runNew(args []string) error { options.FrameworkDir = args[index] case strings.HasPrefix(arg, "--framework-dir="): options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=") + case arg == "--framework-version": + if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" { + return errors.New("--framework-version requires a value") + } + index++ + options.FrameworkVersion = args[index] + case strings.HasPrefix(arg, "--framework-version="): + options.FrameworkVersion = strings.TrimPrefix(arg, "--framework-version=") + if strings.TrimSpace(options.FrameworkVersion) == "" { + return errors.New("--framework-version requires a value") + } case strings.HasPrefix(arg, "-"): return fmt.Errorf("unknown option %q", arg) case target == "": @@ -82,4 +93,6 @@ func usage() { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Usage:") fmt.Fprintln(os.Stderr, " go-omni new [--module ] [--force] ") + fmt.Fprintln(os.Stderr, " --framework-version Pin a remote framework version (defaults to CLI version)") + fmt.Fprintln(os.Stderr, " --framework-dir Use a local framework checkout") } diff --git a/cmd/go-omni/main_test.go b/cmd/go-omni/main_test.go new file mode 100644 index 0000000..37e9087 --- /dev/null +++ b/cmd/go-omni/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewWithRemoteFrameworkVersion(t *testing.T) { + for _, flag := range [][]string{{"--framework-version", "v0.1.0"}, {"--framework-version=v0.1.0"}} { + project := filepath.Join(t.TempDir(), "billing") + args := append([]string{"--module", "example.com/billing"}, flag...) + if err := runNew(append(args, project)); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(project, "go.mod")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "go-omni v0.1.0") || strings.Contains(string(content), "replace ") { + t.Fatalf("CLI did not pin remote dependency: %s", content) + } + } +} + +func TestNewRejectsEmptyFrameworkVersion(t *testing.T) { + for _, args := range [][]string{{"--framework-version"}, {"--framework-version="}, {"--framework-version", " "}} { + if err := runNew(args); err == nil { + t.Fatalf("accepted missing framework version: %v", args) + } + } +} diff --git a/cmd/go-omni/scaffold/scaffold.go b/cmd/go-omni/scaffold/scaffold.go index 1c4405a..4023b7f 100644 --- a/cmd/go-omni/scaffold/scaffold.go +++ b/cmd/go-omni/scaffold/scaffold.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "text/template" ) @@ -17,10 +18,11 @@ var templateFiles embed.FS // Options controls project generation. type Options struct { - Dir string - Module string - FrameworkDir string - Force bool + Dir string + Module string + FrameworkDir string + FrameworkVersion string + Force bool } var files = map[string]string{ @@ -42,6 +44,13 @@ func Generate(options Options) error { if strings.TrimSpace(options.Module) == "" { return errors.New("module path is required") } + version := strings.TrimSpace(options.FrameworkVersion) + if version == "" { + version = currentFrameworkVersion() + } + if !validFrameworkVersion(version) { + return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version) + } directory, err := filepath.Abs(options.Dir) if err != nil { @@ -54,9 +63,15 @@ func Generate(options Options) error { return fmt.Errorf("create project directory: %w", err) } + // An explicit remote version opts out of automatic local development mode. + replace := "" + if options.FrameworkDir != "" || options.FrameworkVersion == "" { + replace = frameworkReplace(directory, options.FrameworkDir) + } data := templateData{ Module: options.Module, - FrameworkReplace: frameworkReplace(directory, options.FrameworkDir), + FrameworkVersion: version, + FrameworkReplace: replace, } paths := make([]string, 0, len(files)) for path := range files { @@ -88,6 +103,7 @@ func Generate(options Options) error { type templateData struct { Module string + FrameworkVersion string FrameworkReplace string } @@ -149,7 +165,7 @@ func frameworkReplace(projectDir, configured string) string { if err != nil { return "" } - return filepath.ToSlash(relative) + return strconv.Quote(filepath.ToSlash(relative)) } func canonicalPath(path string) string { @@ -172,8 +188,14 @@ func findFrameworkRoot() string { for { moduleFile := filepath.Join(directory, "go.mod") content, readErr := os.ReadFile(moduleFile) - if readErr == nil && strings.Contains(string(content), "module git.yuntuo.hk/k2safe/go-omni") { - return directory + if readErr == nil { + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[0] == "module" && strings.Trim(fields[1], "\"") == frameworkModule { + return directory + } + } + return "" } parent := filepath.Dir(directory) if parent == directory { diff --git a/cmd/go-omni/scaffold/scaffold_test.go b/cmd/go-omni/scaffold/scaffold_test.go index b1506db..0c61fb0 100644 --- a/cmd/go-omni/scaffold/scaffold_test.go +++ b/cmd/go-omni/scaffold/scaffold_test.go @@ -3,6 +3,8 @@ package scaffold import ( "os" "path/filepath" + "runtime/debug" + "strconv" "strings" "testing" ) @@ -37,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) { if err != nil { t.Fatalf("read generated server: %v", err) } - for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.Run(settings.Address)"} { + for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.RunContext(ctx, settings.Address)"} { if !strings.Contains(string(mainContent), expected) { t.Fatalf("generated server missing %q: %s", expected, mainContent) } @@ -73,11 +75,59 @@ func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) { if err != nil { t.Fatal(err) } - if replace != filepath.ToSlash(expected) { + if replace != strconv.Quote(filepath.ToSlash(expected)) { t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace) } } +func TestGenerateRemoteProjectPinsVersion(t *testing.T) { + for _, version := range []string{"v0.1.0", "v1.2.3-rc.1", "v0.0.0-20260920140624-8b19aa79eea0"} { + t.Run(version, func(t *testing.T) { + project := filepath.Join(t.TempDir(), "billing") + if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(project, "go.mod")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "require "+frameworkModule+" "+version+"\n") || strings.Contains(string(content), "replace ") { + t.Fatalf("expected pinned remote dependency without replace: %s", content) + } + }) + } +} + +func TestGenerateRejectsInvalidVersionBeforeWriting(t *testing.T) { + for _, version := range []string{"main", "latest", "v1.2", "v01.2.3", "v2.0.0", "v0.1.0-01", "v0.1.0\nreplace bad => ./bad"} { + project := filepath.Join(t.TempDir(), "billing") + if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err == nil { + t.Fatalf("accepted invalid version %q", version) + } + if _, err := os.Stat(project); !os.IsNotExist(err) { + t.Fatalf("invalid version %q created a project: %v", version, err) + } + } +} + +func TestFrameworkVersionUsesInstalledModuleMetadata(t *testing.T) { + for _, test := range []struct { + info *debug.BuildInfo + want string + }{ + {nil, defaultFrameworkVersion}, + {&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "(devel)"}}, defaultFrameworkVersion}, + {&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.1.0+dirty"}}, defaultFrameworkVersion}, + {&debug.BuildInfo{Main: debug.Module{Path: "example.com/unrelated", Version: "v1.2.3"}}, defaultFrameworkVersion}, + {&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.9.2", Sum: "h1:test"}}, "v0.9.2"}, + {&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.0.0-20260920140624-8b19aa79eea0", Sum: "h1:test"}}, "v0.0.0-20260920140624-8b19aa79eea0"}, + } { + if got := frameworkVersion(test.info); got != test.want { + t.Fatalf("version = %q, want %q", got, test.want) + } + } +} + func frameworkRoot(t *testing.T) string { t.Helper() directory, err := os.Getwd() diff --git a/cmd/go-omni/scaffold/templates/README.md.tmpl b/cmd/go-omni/scaffold/templates/README.md.tmpl index a3423ce..83c7e53 100644 --- a/cmd/go-omni/scaffold/templates/README.md.tmpl +++ b/cmd/go-omni/scaffold/templates/README.md.tmpl @@ -2,6 +2,8 @@ This service was generated with `go-omni new`. +Framework dependency: `git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}`. + ```sh go mod tidy go run ./cmd/server @@ -16,6 +18,17 @@ GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server `GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和 `/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。 +服务使用 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`。部署时构建并运行服务二进制, +使信号直接到达服务进程: + +```sh +go build -o server ./cmd/server +./server +``` + +`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。健康探针目前没有 +数据库依赖检查;接入数据库后需要向 Checker 注册探针。 + The generated layout keeps HTTP handlers, business services, repositories, models and a project-owned ServiceContext separate. Replace the memory repository with a database implementation when the service is ready for diff --git a/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl b/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl index 93082c2..20feffc 100644 --- a/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl +++ b/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl @@ -1,8 +1,12 @@ package main import ( + "context" "log" + "os" + "os/signal" "strings" + "syscall" omni "git.yuntuo.hk/k2safe/go-omni" "git.yuntuo.hk/k2safe/go-omni/config" @@ -15,17 +19,27 @@ import ( ) func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + settings, err := config.LoadFromEnv("") if err != nil { - log.Fatal(err) + return err } if strings.TrimSpace(settings.Log.Service) == "" { settings.Log.Service = "{{.Module}}" } logger, err := logx.New(settings.Log) if err != nil { - log.Fatal(err) + return err } + defer logger.Close() // Access logs and business logs share the same configured sink. logx.SetDefault(logger) @@ -35,7 +49,7 @@ func main() { userHandler := handler.NewUsers(userService) checker, err := health.New() if err != nil { - log.Fatal(err) + return err } application := omni.New(omni.WithConfig(settings)) @@ -43,7 +57,5 @@ func main() { application.GET("/health/live", checker.LivenessHandler()) application.GET("/health/ready", checker.ReadinessHandler()) userHandler.RegisterRoutes(application) - if err := application.Run(settings.Address); err != nil { - log.Fatal(err) - } + return application.RunContext(ctx, settings.Address) } diff --git a/cmd/go-omni/scaffold/templates/go.mod.tmpl b/cmd/go-omni/scaffold/templates/go.mod.tmpl index 6d110ca..603118b 100644 --- a/cmd/go-omni/scaffold/templates/go.mod.tmpl +++ b/cmd/go-omni/scaffold/templates/go.mod.tmpl @@ -2,7 +2,7 @@ module {{.Module}} go 1.22 -require git.yuntuo.hk/k2safe/go-omni v0.0.0 +require git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}} {{- if .FrameworkReplace}} replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}} {{- end}} diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go new file mode 100644 index 0000000..1c64700 --- /dev/null +++ b/cmd/go-omni/scaffold/version.go @@ -0,0 +1,31 @@ +package scaffold + +import ( + "regexp" + "runtime/debug" +) + +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.1.1" + +var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + +func validFrameworkVersion(version string) bool { + return frameworkVersionPattern.MatchString(version) +} + +func currentFrameworkVersion() string { + info, _ := debug.ReadBuildInfo() + return frameworkVersion(info) +} + +func frameworkVersion(info *debug.BuildInfo) string { + if info != nil && info.Main.Path == frameworkModule && info.Main.Sum != "" && validFrameworkVersion(info.Main.Version) { + return info.Main.Version + } + return defaultFrameworkVersion +} diff --git a/scripts/verifyScaffold.py b/scripts/verifyScaffold.py new file mode 100644 index 0000000..7939669 --- /dev/null +++ b/scripts/verifyScaffold.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Verify a generated service outside the source tree using a real server process.""" + +import argparse +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +MODULE = "git.yuntuo.hk/k2safe/go-omni" + + +def run(args, cwd, env): + result = subprocess.run(args, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180) + if result.returncode: + raise RuntimeError(f"{args[0]} {args[1:]} failed:\n{result.stdout}") + return result.stdout + + +def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + address = f"127.0.0.1:{listener.getsockname()[1]}" + serverEnv = {key: value for key, value in env.items() if not key.startswith("GO_OMNI_")} + serverEnv.update(GO_OMNI_HTTP_ADDR=address, GO_OMNI_MODE="release", + GO_OMNI_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s") + if cleanupMarker: + serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker) + logFile = directory / f"{binary.name}-{stopSignal.name}.log" + client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + def request(path, data=None): + req = urllib.request.Request(f"http://{address}{path}", data=data, + headers={"Content-Type": "application/json", "X-Request-ID": "smokeRequest"}) + with client.open(req, timeout=2) as response: + if response.headers.get("X-Request-ID") != "smokeRequest": + raise AssertionError("request ID was not preserved") + return response.status, json.load(response) + + with logFile.open("w") as logs: + process = subprocess.Popen([str(binary)], cwd=directory, env=serverEnv, + stdout=logs, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 15 + while True: + if process.poll() is not None: + raise RuntimeError(f"server exited during startup: {logFile.read_text()}") + try: + status, body = request("/health/live") + break + except (OSError, urllib.error.URLError): + if time.monotonic() >= deadline: + raise RuntimeError(f"server did not start: {logFile.read_text()}") + time.sleep(0.05) + if status != 200 or body["data"]["status"] != "ok": + raise AssertionError(f"unexpected liveness: {body}") + if not cleanupMarker: + status, body = request("/health/ready") + if status != 200 or body["code"] != "READY": + raise AssertionError(f"unexpected readiness: {body}") + status, user = request("/users", json.dumps({"name": "Smoke", "email": "smoke@example.com"}).encode()) + if status != 201 or user["name"] != "Smoke" or "createdAt" not in user: + raise AssertionError(f"unexpected created user: {user}") + process.send_signal(stopSignal) + exitCode = process.wait(timeout=8) + if exitCode != 0: + raise AssertionError(f"{stopSignal.name} exit code {exitCode}: {logFile.read_text()}") + if cleanupMarker and cleanupMarker.read_text() != "closed": + raise AssertionError("shutdown callback was not executed") + # A built binary, rather than `go run`, lets us verify the actual + # service exited without leaving its child listening in the background. + with socket.socket() as connection: + connection.settimeout(1) + if connection.connect_ex(("127.0.0.1", int(address.rsplit(":", 1)[1]))) == 0: + raise AssertionError("server still accepts connections after exit") + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + print(f"PASS {binary.name}: HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--go", default="go", help="Go executable") + parser.add_argument("--version", default="v0.1.1", help="Released CLI version to install") + parser.add_argument("--cli", help="Use a locally built CLI instead of installing") + parser.add_argument("--framework-version", help="Explicit version passed to the CLI") + parser.add_argument("--framework-dir", help="Local checkout for pre-release verification") + parser.add_argument("--skip-run-check", action="store_true", help="Skip core Run signal checks when testing an older framework") + args = parser.parse_args() + env = dict(os.environ, GOWORK="off") + env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"])) + with tempfile.TemporaryDirectory(prefix="go omni smoke ") as temporary: + directory = Path(temporary).resolve() + if args.cli: + cli = Path(args.cli).resolve() + else: + env["GOBIN"] = str(directory / "bin") + print(f"Installing remote CLI {args.version}", flush=True) + run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], directory, env) + cli = directory / "bin" / "go-omni" + command = [str(cli), "new", "--module", "example.com/scaffoldSmoke"] + if args.framework_version: + command += ["--framework-version", args.framework_version] + if args.framework_dir: + command += ["--framework-dir", str(Path(args.framework_dir).resolve())] + project = directory / "billing" + run(command + [str(project)], directory, env) + module = json.loads(run([args.go, "mod", "edit", "-json"], project, env)) + requirements = {entry["Path"]: entry["Version"] for entry in module["Require"]} + expectedVersion = args.framework_version or args.version + if requirements.get(MODULE) != expectedVersion: + raise AssertionError(f"expected framework {expectedVersion}, got {requirements}") + if not args.framework_dir and module.get("Replace"): + raise AssertionError("remote generation must not contain local replace directives") + print(f"PASS generated module pins {expectedVersion}; local replace: {bool(module.get('Replace'))}", flush=True) + run([args.go, "mod", "tidy"], project, env) + run([args.go, "test", "./..."], project, env) + binary = directory / "server" + run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env) + for stopSignal in (signal.SIGINT, signal.SIGTERM): + exerciseServer(binary, project, env, stopSignal) + + if not args.skip_run_check: + # Verify the framework's convenient Run API independently from the + # generated main's caller-owned signal context. + helper = project / "cmd" / "runCheck" + helper.mkdir() + helper.joinpath("main.go").write_text('''package main +import ( + "log" + "os" + omni "git.yuntuo.hk/k2safe/go-omni" +) +func main() { + app := omni.New(omni.WithMode("release")) + app.GET("/health/live", func(ctx *omni.Context) error { return ctx.OK(map[string]string{"status": "ok"}) }) + app.OnShutdown(func() error { return os.WriteFile(os.Getenv("GO_OMNI_CLEANUP_MARKER"), []byte("closed"), 0600) }) + if err := app.Run(os.Getenv("GO_OMNI_HTTP_ADDR")); err != nil { log.Fatal(err) } +} +''') + runBinary = directory / "runCheck" + run([args.go, "build", "-o", str(runBinary), "./cmd/runCheck"], project, env) + for stopSignal in (signal.SIGINT, signal.SIGTERM): + marker = project / f"cleanup-{stopSignal.name}" + exerciseServer(runBinary, project, env, stopSignal, marker) + + +if __name__ == "__main__": + main()