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

14
CHANGELOG.md Normal file
View File

@@ -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 与标准库路由适配器、统一响应与错误。
- 提供配置、日志、校验、认证接口、数据库基础能力、健康探针和项目脚手架。

View File

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

27
ROADMAP.md Normal file
View File

@@ -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,再评估稳定版范围。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段,数据库生产验收、自研网络内核与实际业务接入尚未完成。

View File

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

163
app/lifecycle_test.go Normal file
View File

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

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
}

158
scripts/verifyScaffold.py Normal file
View File

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