feat: bootstrap reusable GoOmni framework
This commit is contained in:
3
cmd/README.md
Normal file
3
cmd/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# cmd
|
||||
|
||||
命令行工具入口。目前提供 `go-omni new`,用于生成 Handler / Service / Repository / Model 分层的服务骨架。
|
||||
11
cmd/go-omni/README.md
Normal file
11
cmd/go-omni/README.md
Normal 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
85
cmd/go-omni/main.go
Normal 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>")
|
||||
}
|
||||
184
cmd/go-omni/scaffold/scaffold.go
Normal file
184
cmd/go-omni/scaffold/scaffold.go
Normal 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
|
||||
}
|
||||
}
|
||||
97
cmd/go-omni/scaffold/scaffold_test.go
Normal file
97
cmd/go-omni/scaffold/scaffold_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
22
cmd/go-omni/scaffold/templates/README.md.tmpl
Normal file
22
cmd/go-omni/scaffold/templates/README.md.tmpl
Normal 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`.
|
||||
49
cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl
Normal file
49
cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl
Normal 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)
|
||||
}
|
||||
}
|
||||
8
cmd/go-omni/scaffold/templates/go.mod.tmpl
Normal file
8
cmd/go-omni/scaffold/templates/go.mod.tmpl
Normal 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}}
|
||||
31
cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl
Normal file
31
cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl
Normal 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)
|
||||
}
|
||||
16
cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl
Normal file
16
cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
21
cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl
Normal file
21
cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl
Normal 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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user