feat: add cache, outbound HTTP, request protection, tasks and module scaffolding

This commit is contained in:
2026-09-23 13:38:25 +08:00
parent 467c890363
commit fe66221a9c
34 changed files with 3100 additions and 1 deletions

View File

@@ -1,5 +1,12 @@
# go-omni CLI
main 分支还提供 `go-omni add orderItems --dir /path/to/project`,向已有 GoOmni 项目增加
Handler / Service / Repository / Model 与模块注册入口。模块名为 lowerCamelCase,
目标目录存在时拒绝覆盖,已有启动代码保持原样。生成模块带可替换的内存 CRUD Repository,
导入后使用 `orderItems.Register(application, repository, authMiddleware)` 注册。
该命令尚未包含在下方历史标签中;当前源码可用 `go build -o /tmp/go-omni ./cmd/go-omni` 构建。
边界和验证方式见 [业务基础模块](../../MODULES.md)。
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh

View File

@@ -17,6 +17,11 @@ func main() {
}
switch os.Args[1] {
case "add":
if err := runAdd(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "go-omni add:", err)
os.Exit(1)
}
case "new":
if err := runNew(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "go-omni new:", err)
@@ -104,7 +109,36 @@ 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, " go-omni add <name> [--dir <project>] Generate an isolated business module")
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")
fmt.Fprintln(os.Stderr, " --database <memory|mysql> Select generated storage (default memory)")
}
func runAdd(args []string) error {
directory, name := ".", ""
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "--dir":
if index+1 >= len(args) {
return errors.New("--dir requires a project directory")
}
index++
directory = args[index]
case strings.HasPrefix(arg, "--dir="):
directory = strings.TrimPrefix(arg, "--dir=")
case strings.HasPrefix(arg, "-"):
return fmt.Errorf("unknown option %q", arg)
case name == "":
name = arg
default:
return fmt.Errorf("unexpected argument %q", arg)
}
}
if err := scaffold.AddModule(directory, name); err != nil {
return err
}
fmt.Printf("created internal/%s; see its README.md for route registration\n", name)
return nil
}

View File

@@ -32,6 +32,30 @@ func TestNewRejectsEmptyFrameworkVersion(t *testing.T) {
}
}
func TestAddModuleCLI(t *testing.T) {
for _, equals := range []bool{false, true} {
project := filepath.Join(t.TempDir(), "billing")
if err := runNew([]string{"--module", "example.com/billing", project}); err != nil {
t.Fatal(err)
}
args := []string{"orders", "--dir", project}
if equals {
args = []string{"--dir=" + project, "orders"}
}
if err := runAdd(args); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(project, "internal/orders/module.go")); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{nil, {"orders", "--dir"}, {"orders", "--dir="}, {"orders", "extra"}, {"orders", "--unknown"}} {
if err := runAdd(args); err == nil {
t.Fatalf("accepted invalid arguments %v", args)
}
}
}
func TestNewMySQLDatabaseOptions(t *testing.T) {
for _, flag := range [][]string{{"--database", "mysql"}, {"--database=mysql"}} {
project := filepath.Join(t.TempDir(), "billing")

View File

@@ -0,0 +1,109 @@
package scaffold
import (
"bytes"
"errors"
"fmt"
"go/format"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
"golang.org/x/mod/modfile"
)
var moduleName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]{0,47}$`)
// AddModule creates a complete, injectable memory-backed CRUD module. It never
// edits existing routes, go.mod, migrations or files in another module.
func AddModule(directory, name string) (err error) {
if strings.TrimSpace(directory) == "" || !moduleName.MatchString(name) || token.Lookup(name).IsKeyword() || name == "main" || name == "init" || name == "internal" {
return errors.New("module name must be a non-keyword lowerCamelCase Go identifier (1..48 characters)")
}
root, err := filepath.Abs(directory)
if err != nil {
return err
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
return err
}
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
if err != nil {
return fmt.Errorf("read project go.mod: %w", err)
}
manifest, err := modfile.Parse("go.mod", data, nil)
if err != nil || manifest.Module == nil {
return errors.New("project go.mod must declare a valid module")
}
found := false
for _, dependency := range manifest.Require {
if dependency.Mod.Path == frameworkModule {
found = true
}
}
if !found {
return errors.New("project must already require go-omni; create it with go-omni new")
}
module := manifest.Module.Mod.Path
if strings.ContainsAny(module, "\"\\\r\n") {
return errors.New("invalid project module path")
}
paths := []string{"module.go", "model/model.go", "repository/repository.go", "service/service.go", "handler/handler.go", "README.md"}
rendered := make(map[string][]byte, len(paths))
for _, path := range paths {
source, readErr := templateFiles.ReadFile("moduleTemplates/" + strings.ReplaceAll(path, "/", "-") + ".tmpl")
if readErr != nil {
return readErr
}
tmpl, parseErr := template.New(path).Parse(string(source))
if parseErr != nil {
return parseErr
}
var output bytes.Buffer
if err := tmpl.Execute(&output, struct{ Module, Name string }{module, name}); err != nil {
return err
}
content := output.Bytes()
if strings.HasSuffix(path, ".go") {
content, err = format.Source(content)
if err != nil {
return err
}
}
rendered[path] = content
}
parent := filepath.Join(root, "internal")
if info, statErr := os.Lstat(parent); statErr == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("internal must be a real directory, not a symlink")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return statErr
} else if err := os.Mkdir(parent, 0755); err != nil {
return err
}
target := filepath.Join(parent, name)
// Exclusive creation protects existing files, including empty directories.
if err := os.Mkdir(target, 0755); err != nil {
return fmt.Errorf("create module (existing modules are never overwritten): %w", err)
}
defer func() {
if err != nil {
_ = os.RemoveAll(target)
}
}()
for _, path := range paths {
file := filepath.Join(target, filepath.FromSlash(path))
if err = os.MkdirAll(filepath.Dir(file), 0755); err != nil {
return err
}
if err = os.WriteFile(file, rendered[path], 0644); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,137 @@
package scaffold
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func moduleProject(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "billing")
if err := Generate(Options{Dir: root, Module: "example.com/billing", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
return root
}
func TestAddModulePreservesProjectAndRejectsOverwrite(t *testing.T) {
root := moduleProject(t)
manifest, _ := os.ReadFile(filepath.Join(root, "go.mod"))
main, _ := os.ReadFile(filepath.Join(root, "cmd/server/main.go"))
if err := AddModule(root, "orderItems"); err != nil {
t.Fatal(err)
}
for _, path := range []string{"module.go", "model/model.go", "handler/handler.go", "service/service.go", "repository/repository.go", "README.md"} {
if _, err := os.Stat(filepath.Join(root, "internal/orderItems", path)); err != nil {
t.Fatal(err)
}
}
marker := filepath.Join(root, "internal/orderItems/keep.txt")
if err := os.WriteFile(marker, []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
if err := AddModule(root, "orderItems"); err == nil {
t.Fatal("overwrote existing module")
}
if got, _ := os.ReadFile(marker); string(got) != "keep" {
t.Fatal("existing file changed")
}
if got, _ := os.ReadFile(filepath.Join(root, "go.mod")); string(got) != string(manifest) {
t.Fatal("go.mod changed")
}
if got, _ := os.ReadFile(filepath.Join(root, "cmd/server/main.go")); string(got) != string(main) {
t.Fatal("main changed")
}
for _, name := range []string{"../escape", "Order", "order_items", "func", "main", "init", "internal", "", strings.Repeat("a", 49)} {
if err := AddModule(root, name); err == nil {
t.Fatalf("accepted %q", name)
}
}
}
func TestAddModuleRejectsForeignProjectAndSymlink(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/foreign\ngo 1.22\n"), 0600); err != nil {
t.Fatal(err)
}
if err := AddModule(root, "orders"); err == nil {
t.Fatal("accepted foreign project")
}
if _, err := os.Stat(filepath.Join(root, "internal")); !os.IsNotExist(err) {
t.Fatal("created files before validation")
}
root = moduleProject(t)
if err := os.Rename(filepath.Join(root, "internal"), filepath.Join(root, "original")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "original"), filepath.Join(root, "internal")); err != nil {
t.Fatal(err)
}
if err := AddModule(root, "orders"); err == nil {
t.Fatal("accepted symlink parent")
}
}
// Opt-in because this compiles a separate consuming module and may need module
// downloads. The Linux feature acceptance runner enables it explicitly.
func TestAddModuleIntegration(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1 for generated-project CRUD")
}
root := moduleProject(t)
if err := AddModule(root, "orderItems"); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "internal/orderItems/module_test.go"), []byte(generatedModuleTest), 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "./...")
cmd.Dir = root
cmd.Env = append(os.Environ(), "GOWORK=off")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generated project: %v\n%s", err, output)
}
}
const generatedModuleTest = `package orderItems_test
import (
"context"
"net/http/httptest"
"strings"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
"example.com/billing/internal/orderItems"
)
func TestGeneratedCRUD(t *testing.T) {
app:=omni.New()
repo:=orderItems.NewMemory()
orderItems.Register(app,repo,func(next omni.Handler)omni.Handler{return func(c *omni.Context)error{
if c.Header("Authorization")!="Bearer demo" {return omni.Unauthorized("DENIED","denied")};return next(c)
}})
request:=func(method,path,body string,authorized bool)*httptest.ResponseRecorder{
r:=httptest.NewRequest(method,path,strings.NewReader(body));r.Header.Set("Content-Type","application/json")
if authorized {r.Header.Set("Authorization","Bearer demo")}
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,r);return w
}
if w:=request("POST","/orderItems","{\"name\":\"first\"}",false);w.Code!=401 {t.Fatal(w.Code)}
cases:=[]struct{method,path,body string;status int;contains string}{
{"POST","/orderItems","{\"name\":\" first \"}",201,"createdAt"},
{"GET","/orderItems/1","",200,"first"},
{"PUT","/orderItems/1","{\"name\":\"changed\"}",200,"changed"},
{"GET","/orderItems","",200,"changed"},
{"POST","/orderItems","{\"name\":\" \"}",400,"INVALID_NAME"},
{"DELETE","/orderItems/1","",204,""},
{"GET","/orderItems/1","",404,"NOT_FOUND"},
}
for _,tc:=range cases{w:=request(tc.method,tc.path,tc.body,true);if w.Code!=tc.status||!strings.Contains(w.Body.String(),tc.contains){t.Fatalf("%s %s: %d %s",tc.method,tc.path,w.Code,w.Body.String())}}
ctx,cancel:=context.WithCancel(context.Background());cancel()
if _,err:=repo.List(ctx);err!=context.Canceled {t.Fatal(err)}
}
`

View File

@@ -0,0 +1,15 @@
# {{.Name}}
在业务启动文件导入并注册:
```go
import "{{.Module}}/internal/{{.Name}}"
{{.Name}}.Register(application, {{.Name}}.NewMemory())
```
提供 `POST /{{.Name}}`、`GET /{{.Name}}`、`GET /{{.Name}}/:id`、`PUT /{{.Name}}/:id`、
`DELETE /{{.Name}}/:id`。写入 JSON 为 `{"name":"example"}`;输出字段为 id、name、createdAt。
代码按 Handler / Service / Repository / Model 分层,`Register` 可注入自己的 Repository 和认证中间件。
内存示例不持久化、不提供分页;列表适合小规模演示。接实际业务时定义领域字段、分页、鉴权和数据库实现。
生成器不会自动修改主入口、已有模块或数据库结构,也不会覆盖已有文件。

View File

@@ -0,0 +1,52 @@
package handler
import (
"errors"
omni "git.yuntuo.hk/k2safe/go-omni"
"{{.Module}}/internal/{{.Name}}/model"
"{{.Module}}/internal/{{.Name}}/service"
)
type Handler struct { service *service.Service }
type WriteRequest struct { Name string `json:"name" binding:"required,max=200"` }
func New(service *service.Service) *Handler {return &Handler{service:service}}
func (h *Handler) Register(routes *omni.RouterGroup) {
routes.POST("",h.Create)
routes.GET("",h.List)
routes.GET("/:id",h.Get)
routes.PUT("/:id",h.Update)
routes.DELETE("/:id",h.Delete)
}
func (h *Handler) Create(c *omni.Context) error {
var request WriteRequest
if err:=c.BindJSON(&request); err!=nil {return err}
item,err:=h.service.Create(c.Context(),request.Name)
if err!=nil {return publicError(err)}
return c.Created(item)
}
func (h *Handler) Get(c *omni.Context) error {
item,err:=h.service.Get(c.Context(),c.Param("id"))
if err!=nil {return publicError(err)}
return c.OK(item)
}
func (h *Handler) List(c *omni.Context) error {
items,err:=h.service.List(c.Context())
if err!=nil {return publicError(err)}
return c.OK(items)
}
func (h *Handler) Update(c *omni.Context) error {
var request WriteRequest
if err:=c.BindJSON(&request); err!=nil {return err}
item,err:=h.service.Update(c.Context(),c.Param("id"),request.Name)
if err!=nil {return publicError(err)}
return c.OK(item)
}
func (h *Handler) Delete(c *omni.Context) error {
if err:=h.service.Delete(c.Context(),c.Param("id")); err!=nil {return publicError(err)}
return c.NoContent()
}
func publicError(err error) error {
if errors.Is(err,model.ErrNotFound) {return omni.NotFound("NOT_FOUND","record not found").WithCause(err)}
if errors.Is(err,model.ErrInvalidName) {return omni.BadRequest("INVALID_NAME","name must contain 1..200 bytes").WithCause(err)}
return err
}

View File

@@ -0,0 +1,15 @@
package model
import (
"errors"
"time"
)
var ErrNotFound = errors.New("{{.Name}}: record not found")
var ErrInvalidName = errors.New("{{.Name}}: name must contain 1..200 bytes")
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,19 @@
package {{.Name}}
import (
omni "git.yuntuo.hk/k2safe/go-omni"
"{{.Module}}/internal/{{.Name}}/handler"
"{{.Module}}/internal/{{.Name}}/repository"
"{{.Module}}/internal/{{.Name}}/service"
)
type Repository = repository.Repository
func NewMemory() Repository { return repository.NewMemory() }
// Register wires this module with caller-owned storage. Pass auth middleware
// here to apply it to every route before module-specific middleware.
func Register(app *omni.App, repo Repository, middleware ...omni.Middleware) {
if repo == nil { panic("{{.Name}}: repository is required") }
handler.New(service.New(repo)).Register(app.Group("/{{.Name}}", middleware...))
}

View File

@@ -0,0 +1,60 @@
package repository
import (
"context"
"sort"
"strconv"
"sync"
"time"
"{{.Module}}/internal/{{.Name}}/model"
)
type Repository interface {
Create(context.Context,string) (model.Item,error)
Get(context.Context,string) (model.Item,error)
List(context.Context) ([]model.Item,error)
Update(context.Context,string,string) (model.Item,error)
Delete(context.Context,string) error
}
// Memory is demonstration storage; data and IDs reset with the process.
type Memory struct { mu sync.Mutex; next uint64; items map[string]model.Item }
func NewMemory() *Memory { return &Memory{items:make(map[string]model.Item)} }
func (m *Memory) Create(ctx context.Context, name string) (model.Item,error) {
m.mu.Lock(); defer m.mu.Unlock()
if err:=ctx.Err(); err!=nil {return model.Item{},err}
m.next++
item:=model.Item{ID:strconv.FormatUint(m.next,10),Name:name,CreatedAt:time.Now().UTC()}
m.items[item.ID]=item
return item,nil
}
func (m *Memory) Get(ctx context.Context,id string) (model.Item,error) {
m.mu.Lock(); defer m.mu.Unlock()
if err:=ctx.Err(); err!=nil {return model.Item{},err}
item,ok:=m.items[id]
if !ok {return model.Item{},model.ErrNotFound}
return item,nil
}
func (m *Memory) List(ctx context.Context) ([]model.Item,error) {
m.mu.Lock(); defer m.mu.Unlock()
if err:=ctx.Err(); err!=nil {return nil,err}
items:=make([]model.Item,0,len(m.items))
for _,item:=range m.items {items=append(items,item)}
sort.Slice(items,func(i,j int)bool{return items[i].ID<items[j].ID})
return items,nil
}
func (m *Memory) Update(ctx context.Context,id,name string) (model.Item,error) {
m.mu.Lock(); defer m.mu.Unlock()
if err:=ctx.Err(); err!=nil {return model.Item{},err}
item,ok:=m.items[id]
if !ok {return model.Item{},model.ErrNotFound}
item.Name=name; m.items[id]=item
return item,nil
}
func (m *Memory) Delete(ctx context.Context,id string) error {
m.mu.Lock(); defer m.mu.Unlock()
if err:=ctx.Err(); err!=nil {return err}
if _,ok:=m.items[id]; !ok {return model.ErrNotFound}
delete(m.items,id)
return nil
}

View File

@@ -0,0 +1,27 @@
package service
import (
"context"
"strings"
"{{.Module}}/internal/{{.Name}}/model"
"{{.Module}}/internal/{{.Name}}/repository"
)
type Service struct { repo repository.Repository }
func New(repo repository.Repository) *Service { return &Service{repo:repo} }
func name(value string) (string,error) {
value=strings.TrimSpace(value)
if len(value)==0 || len(value)>200 {return "",model.ErrInvalidName}
return value,nil
}
func (s *Service) Create(ctx context.Context,value string) (model.Item,error) {
value,err:=name(value); if err!=nil {return model.Item{},err}
return s.repo.Create(ctx,value)
}
func (s *Service) Get(ctx context.Context,id string) (model.Item,error) { return s.repo.Get(ctx,id) }
func (s *Service) List(ctx context.Context) ([]model.Item,error) { return s.repo.List(ctx) }
func (s *Service) Update(ctx context.Context,id,value string) (model.Item,error) {
value,err:=name(value); if err!=nil {return model.Item{},err}
return s.repo.Update(ctx,id,value)
}
func (s *Service) Delete(ctx context.Context,id string) error {return s.repo.Delete(ctx,id)}

View File

@@ -14,7 +14,7 @@ import (
"text/template"
)
//go:embed templates
//go:embed templates moduleTemplates
var templateFiles embed.FS
// Options controls project generation.