fix: pin scaffold dependencies and handle graceful shutdown
This commit is contained in:
@@ -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 .
|
||||
```
|
||||
|
||||
@@ -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
33
cmd/go-omni/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
31
cmd/go-omni/scaffold/version.go
Normal file
31
cmd/go-omni/scaffold/version.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user