fix: pin scaffold dependencies and handle graceful shutdown

This commit is contained in:
2026-09-20 22:25:53 +08:00
parent afde676ea2
commit 0bd71b17b4
15 changed files with 643 additions and 26 deletions

View File

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

View File

@@ -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 <module>] [--force] <directory>")
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
}

33
cmd/go-omni/main_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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