feat(ws): release generic WebSocket client and server v0.31.0-preview

This commit is contained in:
2026-10-29 19:20:12 +08:00
parent 19ee9746e9
commit 3272974bc1
58 changed files with 7654 additions and 21 deletions

View File

@@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -57,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.30.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.31.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
当前模板与依赖以 v0.30.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.31.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -0,0 +1,185 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// Exercise the generated consumer, rather than matching a template substring:
// the optional hook must compile without changes to existing ServiceContext,
// survive regeneration, serve requests, and roll back App-owned resources.
func TestGeneratedCustomRoutesConsumer(t *testing.T) {
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
releaseVersion := os.Getenv("GO_OMNI_RELEASE_VERSION")
if (releaseCLI == "") != (releaseVersion == "") {
t.Fatal("GO_OMNI_RELEASE_CLI and GO_OMNI_RELEASE_VERSION must be set together")
}
dir := filepath.Join(t.TempDir(), "customroutes")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service customroutes {\n @handler ping\n get /ping\n}\n")
opts := Options{Dir: dir, API: api, Module: "example.com/customroutes", FrameworkDir: frameworkRoot(t)}
if releaseCLI != "" {
// The installed CLI must select its own published framework version.
// Never supply a source replacement in remote acceptance mode.
opts.FrameworkDir = ""
}
if err := generateManagedTestProject(t, opts); err != nil {
t.Fatal(err)
}
consumer := filepath.Join(dir, "custom_routes_test.go")
writeFile(t, consumer, customRoutesConsumerImports+`
func TestExistingServiceWithoutCustomRoutes(t *testing.T) {
deps:=newDependencies(t)
if _,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{t.Fatal("baseline unexpectedly implements extension")}
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
response:=httptest.NewRecorder();app.ServeHTTP(response,httptest.NewRequest("GET","/ping",nil))
if response.Code!=http.StatusNotImplemented{t.Fatalf("generated route: %d %s",response.Code,response.Body.String())}
}
`)
// First compile and run the old project shape before adding any method.
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
hookPath := filepath.Join(dir, "internal/svc/customRoutes.go")
writeFile(t, hookPath, customRoutesHook)
writeFile(t, consumer, customRoutesConsumerImports+customRoutesConsumerTests)
serviceContext := filepath.Join(dir, "internal/svc/serviceContext.go")
projectConfig := filepath.Join(dir, "internal/config/config.go")
handwritten := map[string]string{}
for _, path := range []string{hookPath, consumer, serviceContext, projectConfig} {
handwritten[path] = readFile(t, path)
}
opts.API = filepath.Join(dir, "api/service.api")
for _, mode := range []string{"dry-run", "generate", "check"} {
opts.DryRun = mode == "dry-run"
opts.Check = mode == "check"
if err := generateManagedTestProject(t, opts); err != nil {
t.Fatalf("%s: %v", mode, err)
}
for path, before := range handwritten {
if readFile(t, path) != before {
t.Fatalf("%s changed project-owned file %s", mode, path)
}
}
}
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
runConsumerGo(t, dir, "build", "-mod=mod", ".")
if releaseCLI != "" {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != releaseVersion || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("custom routes consumer is not using the fixed remote framework without replace")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
const customRoutesConsumerImports = `package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"example.com/customroutes/internal/config"
"example.com/customroutes/internal/handler"
"example.com/customroutes/internal/svc"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func newDependencies(t *testing.T)*svc.ServiceContext{
t.Helper();cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};t.Cleanup(func(){_ = logger.Close()})
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};t.Cleanup(func(){if err:=deps.Close();err!=nil{t.Error(err)}})
return deps
}
`
const customRoutesHook = `package svc
import (
"context"
"errors"
"net/http"
"sync/atomic"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
var ErrCustomRegistration = errors.New("custom registration failed")
var ErrCustomCleanup = errors.New("custom cleanup failed")
var CustomCalls, CustomStopped, CustomDrained, CustomCleaned atomic.Int32
var CustomFailure bool
var CustomApp *omni.App
// This file belongs to the consuming project and must never be generated.
func(c *ServiceContext)RegisterCustomRoutes(app *omni.App)error{
CustomCalls.Add(1);CustomApp=app
app.OnStop("project-custom",func(context.Context)error{CustomStopped.Add(1);return nil})
app.OnDrain("project-custom",func(context.Context)error{CustomDrained.Add(1);return nil})
fail:=CustomFailure
app.OnShutdown(func()error{CustomCleaned.Add(1);if fail{return ErrCustomCleanup};return nil})
app.GET("/custom",httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){
w.Header().Set("Content-Type","text/plain");_,_ = w.Write([]byte("project-owned route"))
})))
if fail{return ErrCustomRegistration}
return nil
}
func ResetCustomRoutes(){CustomCalls.Store(0);CustomStopped.Store(0);CustomDrained.Store(0);CustomCleaned.Store(0);CustomFailure=false;CustomApp=nil}
func IsCustomFailure(err error)bool{return errors.Is(err,ErrCustomRegistration)&&errors.Is(err,ErrCustomCleanup)}
`
const customRoutesConsumerTests = `
func TestCustomRoutesServeAndClose(t *testing.T){
svc.ResetCustomRoutes();deps:=newDependencies(t)
var extension interface{RegisterCustomRoutes(*omni.App) error}=deps
if extension==nil{t.Fatal("missing extension")}
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
if svc.CustomCalls.Load()!=1{t.Fatal("hook called wrong number of times")}
server:=httptest.NewServer(app);defer server.Close()
for _,test:=range []struct{path string;status int;body string}{{"/custom",200,"project-owned route"},{"/ping",501,""},{"/health/ready",200,""}}{
response,err:=http.Get(server.URL+test.path);if err!=nil{t.Fatal(err)}
body,err:=io.ReadAll(response.Body);response.Body.Close();if err!=nil{t.Fatal(err)}
if response.StatusCode!=test.status||(test.body!=""&&string(body)!=test.body){t.Fatalf("%s: %d %s",test.path,response.StatusCode,body)}
}
if svc.CustomCleaned.Load()!=0{t.Fatal("resource closed during successful construction")}
if err:=app.Close();err!=nil{t.Fatal(err)}
if svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("custom lifecycle did not finish")}
}
func TestCustomRoutesFailureRollsBackOnlyAppResources(t *testing.T){
svc.ResetCustomRoutes();svc.CustomFailure=true;defer func(){svc.CustomFailure=false}()
deps:=newDependencies(t);dependencyClosed:=false;deps.OnClose(func()error{dependencyClosed=true;return nil})
app,err:=handler.NewApp(deps)
if app!=nil||!svc.IsCustomFailure(err){t.Fatalf("expected both hook and cleanup errors, got app=%v err=%v",app,err)}
if svc.CustomCalls.Load()!=1||svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("failed construction leaked App resources")}
if dependencyClosed{t.Fatal("NewApp closed caller-owned dependencies")}
response:=httptest.NewRecorder();svc.CustomApp.ServeHTTP(response,httptest.NewRequest("GET","/custom",nil))
if response.Code!=http.StatusServiceUnavailable{t.Fatalf("failed App still accepts requests: %d",response.Code)}
_=svc.CustomApp.Close();if svc.CustomCleaned.Load()!=1{t.Fatal("rollback was not idempotent")}
if err:=deps.Close();err!=nil{t.Fatal(err)};if !dependencyClosed{t.Fatal("caller could not close dependencies")}
}
`

View File

@@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Runtime-only releases need not raise the managed template compatibility floor.
const minimumVersion = "v0.22.0-preview"
const currentVersion = "v0.30.0-preview"
const currentVersion = "v0.31.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {

View File

@@ -63,7 +63,14 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){
for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))}
`
}
source += "return app,nil\n}\n"
source += `// Optional project-owned extension; no generated file needs hand edits.
if extension,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{
if err:=extension.RegisterCustomRoutes(app);err!=nil{return nil,errors.Join(err,app.Close())}
}
return app,nil
}
`
source = strings.Replace(source, "\"fmt\"", "\"fmt\";\"errors\"", 1)
if o.Storage != "local" {
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
}

View File

@@ -25,7 +25,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const currentVersion = "v0.30.0-preview"
const currentVersion = "v0.31.0-preview"
const minimumVersion = "v0.28.0-preview"
const manifestPath = ".goOmni/projectManifest.json"
const lockPath = ".goOmni/project.lock"

View File

@@ -10,7 +10,7 @@ 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.30.0-preview"
const defaultFrameworkVersion = "v0.31.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.