feat: bootstrap reusable GoOmni framework

This commit is contained in:
2026-09-20 22:06:24 +08:00
commit 8b19aa79ee
60 changed files with 4923 additions and 0 deletions

3
cmd/README.md Normal file
View File

@@ -0,0 +1,3 @@
# cmd
命令行工具入口。目前提供 `go-omni new`,用于生成 Handler / Service / Repository / Model 分层的服务骨架。

11
cmd/go-omni/README.md Normal file
View File

@@ -0,0 +1,11 @@
# go-omni CLI
生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh
go run ./cmd/go-omni new --module example.com/billing ./billing
```
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
`--force` 才会覆盖脚手架文件。

85
cmd/go-omni/main.go Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "new":
if err := runNew(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "go-omni new:", err)
os.Exit(1)
}
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
usage()
os.Exit(2)
}
}
func runNew(args []string) error {
options := scaffold.Options{}
var target string
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "--force":
options.Force = true
case arg == "--module":
if index+1 >= len(args) {
return errors.New("--module requires a value")
}
index++
options.Module = args[index]
case strings.HasPrefix(arg, "--module="):
options.Module = strings.TrimPrefix(arg, "--module=")
case arg == "--framework-dir":
if index+1 >= len(args) {
return errors.New("--framework-dir requires a value")
}
index++
options.FrameworkDir = args[index]
case strings.HasPrefix(arg, "--framework-dir="):
options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=")
case strings.HasPrefix(arg, "-"):
return fmt.Errorf("unknown option %q", arg)
case target == "":
target = arg
default:
return fmt.Errorf("unexpected argument %q", arg)
}
}
if target == "" {
return errors.New("project directory is required")
}
if options.Module == "" {
options.Module = filepath.Base(filepath.Clean(target))
}
options.Dir = target
if err := scaffold.Generate(options); err != nil {
return err
}
fmt.Printf("created GoOmni project %s\n", filepath.Clean(target))
return nil
}
func usage() {
fmt.Fprintln(os.Stderr, "GoOmni project tool")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
}

View File

@@ -0,0 +1,184 @@
package scaffold
import (
"embed"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
)
//go:embed templates
var templateFiles embed.FS
// Options controls project generation.
type Options struct {
Dir string
Module string
FrameworkDir string
Force bool
}
var files = map[string]string{
"go.mod": "templates/go.mod.tmpl",
"README.md": "templates/README.md.tmpl",
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
"internal/repository/memory.go": "templates/internal-repository-memory.go.tmpl",
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
"internal/handler/user.go": "templates/internal-handler-user.go.tmpl",
}
// Generate creates a GoOmni service skeleton.
func Generate(options Options) error {
if strings.TrimSpace(options.Dir) == "" {
return errors.New("project directory is required")
}
if strings.TrimSpace(options.Module) == "" {
return errors.New("module path is required")
}
directory, err := filepath.Abs(options.Dir)
if err != nil {
return fmt.Errorf("resolve project directory: %w", err)
}
if err := checkTarget(directory, options.Force); err != nil {
return err
}
if err := os.MkdirAll(directory, 0o755); err != nil {
return fmt.Errorf("create project directory: %w", err)
}
data := templateData{
Module: options.Module,
FrameworkReplace: frameworkReplace(directory, options.FrameworkDir),
}
paths := make([]string, 0, len(files))
for path := range files {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
content, err := render(files[path], data)
if err != nil {
return fmt.Errorf("render %s: %w", path, err)
}
target := filepath.Join(directory, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
}
if !options.Force {
if _, err := os.Stat(target); err == nil {
return fmt.Errorf("file already exists: %s", target)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("check %s: %w", target, err)
}
}
if err := os.WriteFile(target, []byte(content), 0o644); err != nil {
return fmt.Errorf("write %s: %w", target, err)
}
}
return nil
}
type templateData struct {
Module string
FrameworkReplace string
}
func render(name string, data templateData) (string, error) {
content, err := fs.ReadFile(templateFiles, name)
if err != nil {
return "", err
}
tmpl, err := newTemplate(name, string(content))
if err != nil {
return "", err
}
var output strings.Builder
if err := tmpl.Execute(&output, data); err != nil {
return "", err
}
return output.String(), nil
}
func newTemplate(name, content string) (*template.Template, error) {
return template.New(name).Parse(content)
}
func checkTarget(directory string, force bool) error {
info, err := os.Stat(directory)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("check project directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("project path is not a directory: %s", directory)
}
if force {
return nil
}
entries, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("read project directory: %w", err)
}
if len(entries) > 0 {
return fmt.Errorf("project directory is not empty: %s (use --force to overwrite generated files)", directory)
}
return nil
}
func frameworkReplace(projectDir, configured string) string {
root := strings.TrimSpace(configured)
if root == "" {
root = findFrameworkRoot()
}
if root == "" {
return ""
}
projectDir = canonicalPath(projectDir)
absoluteRoot := canonicalPath(root)
relative, err := filepath.Rel(projectDir, absoluteRoot)
if err != nil {
return ""
}
return filepath.ToSlash(relative)
}
func canonicalPath(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
resolved, err := filepath.EvalSymlinks(abs)
if err == nil {
return resolved
}
return abs
}
func findFrameworkRoot() string {
directory, err := os.Getwd()
if err != nil {
return ""
}
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
}
parent := filepath.Dir(directory)
if parent == directory {
return ""
}
directory = parent
}
}

View File

@@ -0,0 +1,97 @@
package scaffold
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateCreatesServiceLayout(t *testing.T) {
directory := t.TempDir()
project := filepath.Join(directory, "billing")
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatalf("generate project: %v", err)
}
for _, path := range []string{
"go.mod",
"cmd/server/main.go",
"internal/model/user.go",
"internal/repository/memory.go",
"internal/servicecontext/context.go",
"internal/service/user.go",
"internal/handler/user.go",
} {
if _, err := os.Stat(filepath.Join(project, filepath.FromSlash(path))); err != nil {
t.Fatalf("expected generated file %s: %v", path, err)
}
}
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
if err != nil {
t.Fatalf("read generated go.mod: %v", err)
}
if !strings.Contains(string(content), "replace git.yuntuo.hk/k2safe/go-omni =>") {
t.Fatalf("expected local framework replace directive: %s", content)
}
mainContent, err := os.ReadFile(filepath.Join(project, "cmd/server/main.go"))
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)"} {
if !strings.Contains(string(mainContent), expected) {
t.Fatalf("generated server missing %q: %s", expected, mainContent)
}
}
}
func TestGenerateRejectsNonEmptyDirectory(t *testing.T) {
directory := t.TempDir()
if err := os.WriteFile(filepath.Join(directory, "existing.txt"), []byte("keep"), 0o644); err != nil {
t.Fatalf("seed directory: %v", err)
}
if err := Generate(Options{Dir: directory, Module: "example.com/demo"}); err == nil {
t.Fatal("expected non-empty directory error")
}
}
func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
root := frameworkRoot(t)
project := filepath.Join(t.TempDir(), "generated")
if err := os.MkdirAll(project, 0o755); err != nil {
t.Fatal(err)
}
replace := frameworkReplace(project, root)
resolvedProject, err := filepath.EvalSymlinks(project)
if err != nil {
t.Fatal(err)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
expected, err := filepath.Rel(resolvedProject, resolvedRoot)
if err != nil {
t.Fatal(err)
}
if replace != filepath.ToSlash(expected) {
t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace)
}
}
func frameworkRoot(t *testing.T) string {
t.Helper()
directory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil {
return directory
}
parent := filepath.Dir(directory)
if parent == directory {
t.Fatal("framework root not found")
}
directory = parent
}
}

View File

@@ -0,0 +1,22 @@
# GoOmni service
This service was generated with `go-omni new`.
```sh
go mod tidy
go run ./cmd/server
```
运行时配置从 `GO_OMNI_*` 环境变量加载,例如:
```sh
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`。
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
persistence, then add it to `internal/servicecontext`.

View File

@@ -0,0 +1,49 @@
package main
import (
"log"
"strings"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/logx"
"{{.Module}}/internal/handler"
"{{.Module}}/internal/repository"
"{{.Module}}/internal/service"
"{{.Module}}/internal/servicecontext"
)
func main() {
settings, err := config.LoadFromEnv("")
if err != nil {
log.Fatal(err)
}
if strings.TrimSpace(settings.Log.Service) == "" {
settings.Log.Service = "{{.Module}}"
}
logger, err := logx.New(settings.Log)
if err != nil {
log.Fatal(err)
}
// Access logs and business logs share the same configured sink.
logx.SetDefault(logger)
users := repository.NewMemoryUsers()
services := servicecontext.New(logger, users)
userService := service.NewUsers(services)
userHandler := handler.NewUsers(userService)
checker, err := health.New()
if err != nil {
log.Fatal(err)
}
application := omni.New(omni.WithConfig(settings))
application.OnShutdown(logger.Close)
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)
}
}

View File

@@ -0,0 +1,8 @@
module {{.Module}}
go 1.22
require git.yuntuo.hk/k2safe/go-omni v0.0.0
{{- if .FrameworkReplace}}
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
{{- end}}

View File

@@ -0,0 +1,31 @@
package handler
import (
"net/http"
omni "git.yuntuo.hk/k2safe/go-omni"
"{{.Module}}/internal/model"
"{{.Module}}/internal/service"
)
type Users struct {
service *service.Users
}
func NewUsers(service *service.Users) *Users { return &Users{service: service} }
func (h *Users) RegisterRoutes(application *omni.App) {
application.POST("/users", h.Create)
}
func (h *Users) Create(ctx *omni.Context) error {
var input model.UserInput
if err := ctx.BindJSON(&input); err != nil {
return err
}
user, err := h.service.Create(ctx.Context(), input)
if err != nil {
return err
}
return ctx.JSON(http.StatusCreated, user)
}

View File

@@ -0,0 +1,16 @@
package model
import "time"
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserInput struct {
Name string `json:"name"`
Email string `json:"email"`
}

View File

@@ -0,0 +1,31 @@
package repository
import (
"context"
"sync"
"time"
"{{.Module}}/internal/model"
)
type UserRepository interface {
Create(context.Context, model.UserInput) (model.User, error)
}
type MemoryUsers struct {
mu sync.Mutex
nextID int64
users []model.User
}
func NewMemoryUsers() *MemoryUsers { return &MemoryUsers{nextID: 1} }
func (r *MemoryUsers) Create(_ context.Context, input model.UserInput) (model.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
user := model.User{ID: r.nextID, Name: input.Name, Email: input.Email, CreatedAt: now, UpdatedAt: now}
r.nextID++
r.users = append(r.users, user)
return user, nil
}

View File

@@ -0,0 +1,21 @@
package service
import (
"context"
"{{.Module}}/internal/model"
"{{.Module}}/internal/servicecontext"
)
type Users struct {
context *servicecontext.Context
}
func NewUsers(context *servicecontext.Context) *Users {
return &Users{context: context}
}
func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) {
// Put business normalization and rules here.
return s.context.Users.Create(ctx, input)
}

View File

@@ -0,0 +1,17 @@
package servicecontext
import (
"git.yuntuo.hk/k2safe/go-omni/logx"
"{{.Module}}/internal/repository"
)
// Context owns infrastructure dependencies shared by the service layer.
// Add database, cache and external clients here as the project grows.
type Context struct {
Logger *logx.Logger
Users repository.UserRepository
}
func New(logger *logx.Logger, users repository.UserRepository) *Context {
return &Context{Logger: logger, Users: users}
}