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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
.idea/
.vscode/
bin/
dist/
*.log
coverage.out

157
README.md Normal file
View File

@@ -0,0 +1,157 @@
# GoOmni
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gin 作为网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh
go get git.yuntuo.hk/k2safe/go-omni@main
```
## 目录结构
```text
go-omni/
├── app/ # 应用启动和生命周期
├── httpx/ # 与网络框架无关的 HTTP 抽象
├── response/ # 统一响应
├── errors/ # 统一错误
├── health/ # 存活和就绪检查
├── middleware/ # 请求 ID 等基础中间件
├── logx/ # 业务层结构化日志
├── config/ # 配置管理
├── validate/ # 参数校验
├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务
├── transport/gin/ # Gin 网络适配器,可替换
├── examples/ # 示例项目
└── cmd/ # 后续 CLI
```
## 快速开始
```go
package main
import omni "git.yuntuo.hk/k2safe/go-omni"
func main() {
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
app.Run(":8080")
}
```
成功响应默认使用统一结构:
```json
{
"code": "OK",
"message": "success",
"data": {"status": "ok"},
"requestId": "..."
}
```
业务错误可以直接返回:
```go
return omni.NotFound("USER_NOT_FOUND", "user was not found")
```
默认 Gin 适配器位于 `transport/gin`。业务层不需要导入 Gin;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
```go
application := omni.NewWithRouter(myRouter)
```
仓库还提供了只依赖标准库的 `transport/nethttp` 参考实现,可以直接验证这条替换路径。
`app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖 Gin:
```go
func (s *UserService) Create(ctx *httpx.Context) error {
logx.InfoContext(ctx.Context(), "user.create", "email", input.Email)
return nil
}
```
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
```go
settings, err := omni.LoadConfig("")
if err != nil {
log.Fatal(err)
}
logger, err := logx.New(settings.Log)
if err != nil {
log.Fatal(err)
}
application := omni.New(omni.WithConfig(settings))
application.OnShutdown(logger.Close)
```
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
`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`。
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
```go
application := omni.New()
application.OnShutdown(db.Close)
```
清理函数会按注册顺序的逆序执行,`application.Close()` 和 `RunContext` 都会触发,且重复调用安全。
服务可以把数据库、缓存等依赖注册为就绪探针:
```go
checker, err := health.New(health.Probe{
Name: "database",
Check: db.PingContext,
})
if err != nil {
log.Fatal(err)
}
application.GET("/health/live", checker.LivenessHandler())
application.GET("/health/ready", checker.ReadinessHandler())
```
存活检查只确认进程在线;就绪检查会执行全部探针,任意依赖失败时返回 HTTP 503。
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
```go
services := servicecontext.New(logger, users)
userService := service.NewUsers(services)
```
## 完整业务参考项目
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
在框架根目录运行:
```sh
go run ./examples/reference
```
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入 Gin。
## 当前边界
缓存、消息队列、定时任务和 CLI 暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。

166
app/app.go Normal file
View File

@@ -0,0 +1,166 @@
package app
import (
"context"
"errors"
"net/http"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin"
)
// App is the top-level GoOmni application. Its public behavior depends on
// httpx.Router, leaving the network implementation replaceable.
type App struct {
router httpx.Router
config config.Config
middlewares []httpx.Middleware
closers []func() error
closeOnce sync.Once
closeErr error
}
// New creates an application using the default Gin adapter.
func New(options ...config.Option) *App {
settings := configure(options...)
return newApp(ginrouter.New(settings.Mode), settings)
}
// NewWithRouter creates an application with a custom network layer. A custom
// router only needs to implement httpx.Router; handlers and business code stay
// unchanged when the transport changes.
func NewWithRouter(router httpx.Router, options ...config.Option) *App {
if router == nil {
panic("go-omni: nil router")
}
return newApp(router, configure(options...))
}
func configure(options ...config.Option) config.Config {
settings := config.Default()
for _, option := range options {
if option != nil {
option(&settings)
}
}
return settings
}
func newApp(router httpx.Router, settings config.Config) *App {
middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)}
if settings.EnableLogger {
middlewares = append(middlewares, middleware.AccessLog())
}
middlewares = append(middlewares, middleware.Recovery())
return &App{router: router, config: settings, middlewares: middlewares}
}
// Router returns the transport contract used by this application.
func (a *App) Router() httpx.Router { return a.router }
// Engine is kept as a compatibility name for the old API. It returns a
// standard http.Handler, so callers no longer need a Gin type.
func (a *App) Engine() http.Handler { return a.router }
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
a.router.ServeHTTP(writer, request)
}
// Config returns the effective application configuration.
func (a *App) Config() config.Config { return a.config }
// Use adds middleware to routes registered after this call.
func (a *App) Use(middleware ...httpx.Middleware) *App {
a.middlewares = append(a.middlewares, middleware...)
return a
}
// OnShutdown registers a cleanup function owned by the application. Cleanup
// functions run in reverse registration order when Close or RunContext ends.
// This keeps omni.New as the only public startup entry point while allowing
// projects to attach databases, loggers and other resources to its lifecycle.
func (a *App) OnShutdown(cleanup func() error) *App {
if cleanup != nil {
a.closers = append(a.closers, cleanup)
}
return a
}
// Close releases application resources. It is safe to call more than once.
func (a *App) Close() error {
a.closeOnce.Do(func() {
for index := len(a.closers) - 1; index >= 0; index-- {
if err := a.closers[index](); err != nil {
a.closeErr = errors.Join(a.closeErr, err)
}
}
})
return a.closeErr
}
// Run starts the HTTP server. The default address is :8080.
func (a *App) Run(address ...string) error {
return a.RunContext(context.Background(), address...)
}
// NewServer creates a standard HTTP server around the application.
func (a *App) NewServer(address string) *http.Server {
if address == "" {
address = a.config.Address
if address == "" {
address = ":8080"
}
}
return &http.Server{Addr: address, Handler: a.router}
}
// RunContext serves until the context is canceled, then performs a graceful shutdown.
func (a *App) RunContext(ctx context.Context, address ...string) error {
if ctx == nil {
ctx = context.Background()
}
listenOn := a.config.Address
if listenOn == "" {
listenOn = ":8080"
}
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
server := a.NewServer(listenOn)
serveErr := make(chan error, 1)
go func() { serveErr <- server.ListenAndServe() }()
select {
case err := <-serveErr:
closeErr := a.Close()
if err == http.ErrServerClosed {
return closeErr
}
return errors.Join(err, closeErr)
case <-ctx.Done():
shutdownTimeout := a.config.ShutdownTimeout
if shutdownTimeout <= 0 {
shutdownTimeout = 10 * time.Second
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
shutdownErr := server.Shutdown(shutdownCtx)
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
shutdownErr = errors.Join(shutdownErr, err)
}
return errors.Join(shutdownErr, a.Close())
}
}
func (a *App) writeError(ctx *httpx.Context, err error) {
if err == nil || ctx.Responded() {
return
}
ctx.WriteError(apierrors.From(err))
}

107
app/router.go Normal file
View File

@@ -0,0 +1,107 @@
package app
import (
"net/http"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
routes.Handle(method, path, a.adapt(handler, middleware...))
}
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
wrapped := handler
all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware))
all = append(all, a.middlewares...)
all = append(all, middleware...)
for index := len(all) - 1; index >= 0; index-- {
if all[index] != nil {
wrapped = all[index](wrapped)
}
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err)
}
})
}
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodGet, path, handler, middleware)
}
func (a *App) POST(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodPost, path, handler, middleware)
}
func (a *App) PUT(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodPut, path, handler, middleware)
}
func (a *App) PATCH(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodPatch, path, handler, middleware)
}
func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodDelete, path, handler, middleware)
}
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.router.Any(path, a.adapt(handler, middleware...))
}
// RouterGroup scopes a route prefix and optional middleware.
type RouterGroup struct {
group httpx.RouterGroup
app *App
middlewares []httpx.Middleware
}
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
}
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
g.middlewares = append(g.middlewares, middleware...)
return g
}
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
}
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.app.handle(g.group, method, path, handler, combined)
}
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodGet, path, handler, middleware)
}
func (g *RouterGroup) POST(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodPost, path, handler, middleware)
}
func (g *RouterGroup) PUT(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodPut, path, handler, middleware)
}
func (g *RouterGroup) PATCH(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodPatch, path, handler, middleware)
}
func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodDelete, path, handler, middleware)
}
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Any(path, g.app.adapt(handler, combined...))
}

132
app_test.go Normal file
View File

@@ -0,0 +1,132 @@
package omni
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestAppReturnsStandardResponse(t *testing.T) {
app := New(WithMode(gin.TestMode))
app.GET("/health", func(ctx *Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
app.Engine().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var response Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Code != "OK" || response.Message != "success" {
t.Fatalf("unexpected response: %+v", response)
}
if recorder.Header().Get("X-Request-ID") == "" || response.RequestID == "" {
t.Fatal("expected request ID in header and response")
}
if !strings.Contains(recorder.Body.String(), `"requestId"`) || strings.Contains(recorder.Body.String(), `"request_id"`) {
t.Fatalf("expected lowerCamelCase response fields: %s", recorder.Body.String())
}
}
func TestAppReturnsAPIError(t *testing.T) {
app := New(WithMode(gin.TestMode))
app.GET("/users/:id", func(ctx *Context) error {
return NotFound("USER_NOT_FOUND", "user was not found")
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/users/1", nil)
app.Engine().ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", recorder.Code)
}
var response Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Code != "USER_NOT_FOUND" || response.Message != "user was not found" {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestAppRecoversFromPanic(t *testing.T) {
app := New(WithMode(gin.TestMode))
app.GET("/panic", func(ctx *Context) error {
panic("boom")
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/panic", nil)
app.Engine().ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", recorder.Code)
}
var response Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Code != "INTERNAL_ERROR" {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestAppRunContextShutsDownGracefully(t *testing.T) {
app := New(WithMode(gin.TestMode), WithShutdownTimeout(time.Second))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- app.RunContext(ctx, "127.0.0.1:0") }()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("graceful shutdown returned an error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("server did not stop after context cancellation")
}
}
func TestAppClosesResourcesInReverseOrder(t *testing.T) {
app := New(WithMode(gin.TestMode))
var closed []string
app.OnShutdown(func() error {
closed = append(closed, "database")
return nil
}).OnShutdown(func() error {
closed = append(closed, "logger")
return nil
})
if err := app.Close(); err != nil {
t.Fatalf("close app: %v", err)
}
if err := app.Close(); err != nil {
t.Fatalf("close app twice: %v", err)
}
if got := strings.Join(closed, ","); got != "logger,database" {
t.Fatalf("unexpected close order: %s", got)
}
}
func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) {
settings := Config{Address: "127.0.0.1:18080", RequestIDHeader: "X-Request-ID", ShutdownTimeout: time.Second}
app := New(WithConfig(settings))
if got := app.NewServer("").Addr; got != settings.Address {
t.Fatalf("expected configured address %q, got %q", settings.Address, got)
}
}

36
auth/README.md Normal file
View File

@@ -0,0 +1,36 @@
# auth
`auth` 只定义身份和权限边界,不把 JWT、Session、OAuth 或某个用户表绑定到核心框架。
## 核心用法
认证器把外部凭据转换成通用身份:
```go
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
```
把认证放在路由组上:
```go
private := app.Group("/api", auth.RequireAuth(authenticator))
private.GET("/me", getMe)
admin := private.Group("/admin", auth.RequireRole("admin"))
admin.POST("/users", createUser)
```
Handler 可以读取身份,但不需要知道 Token 的格式:
```go
principal, ok := auth.FromContext(ctx)
if !ok {
return omni.Unauthorized("UNAUTHENTICATED", "authentication is required")
}
```
认证失败统一返回 `401 UNAUTHENTICATED`,身份存在但角色不满足统一返回 `403 FORBIDDEN`。无效 Token 的详细原因保留在错误链中,不出现在 HTTP 响应里。
`StaticAuthenticator` 只用于本地开发和测试,不能作为生产认证方案。生产项目实现 `Authenticator`,在实现内部校验 JWT、Session 或其他凭据,并返回 `Principal`。

145
auth/auth.go Normal file
View File

@@ -0,0 +1,145 @@
// Package auth defines authentication and authorization boundaries for GoOmni.
// It does not choose JWT, sessions, OAuth, or any other credential format.
package auth
import (
"context"
"errors"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
const principalKey = "go-omni.auth.principal"
var (
ErrMissingToken = errors.New("authorization token is missing")
ErrInvalidToken = errors.New("authorization token is invalid")
)
// Principal is the authenticated identity made available to business handlers.
// Claims are optional provider-specific values and should not be required by core business code.
type Principal struct {
UserID string
Roles []string
Claims map[string]any
}
func (p Principal) HasRole(role string) bool {
role = strings.TrimSpace(role)
if role == "" {
return false
}
for _, candidate := range p.Roles {
if candidate == role {
return true
}
}
return false
}
// Authenticator converts a credential into a provider-neutral Principal.
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
// StaticAuthenticator is intended only for local development and tests.
type StaticAuthenticator map[string]Principal
func (a StaticAuthenticator) Authenticate(_ context.Context, token string) (Principal, error) {
principal, ok := a[token]
if !ok {
return Principal{}, ErrInvalidToken
}
return clonePrincipal(principal), nil
}
// FromContext returns the authenticated identity attached by RequireAuth.
func FromContext(ctx *httpx.Context) (Principal, bool) {
value, ok := ctx.Get(principalKey)
if !ok {
return Principal{}, false
}
principal, ok := value.(Principal)
return principal, ok
}
// RequireAuth extracts a Bearer token and attaches its Principal to the request.
func RequireAuth(authenticator Authenticator) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
if authenticator == nil {
return apierrors.Internal(errors.New("authenticator is not configured"))
}
token, err := bearerToken(ctx.Header("Authorization"))
if err != nil {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err)
}
principal, err := authenticator.Authenticate(ctx.Request().Context(), token)
if err != nil || strings.TrimSpace(principal.UserID) == "" {
if err == nil {
err = ErrInvalidToken
}
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err)
}
ctx.Set(principalKey, clonePrincipal(principal))
return next(ctx)
}
}
}
// OptionalAuth attaches a Principal when a valid Bearer token is present.
// Missing credentials are allowed; malformed or invalid credentials are rejected.
func OptionalAuth(authenticator Authenticator) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
if strings.TrimSpace(ctx.Header("Authorization")) == "" {
return next(ctx)
}
return RequireAuth(authenticator)(next)(ctx)
}
}
}
// RequireRole checks a Principal attached by RequireAuth.
func RequireRole(roles ...string) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
principal, ok := FromContext(ctx)
if !ok {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required")
}
for _, role := range roles {
if principal.HasRole(role) {
return next(ctx)
}
}
return apierrors.Forbidden("FORBIDDEN", "permission denied")
}
}
}
func bearerToken(value string) (string, error) {
parts := strings.Fields(value)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return "", ErrMissingToken
}
return parts[1], nil
}
func clonePrincipal(principal Principal) Principal {
principal.Roles = append([]string(nil), principal.Roles...)
if principal.Claims != nil {
principal.Claims = mapsClone(principal.Claims)
}
return principal
}
func mapsClone(values map[string]any) map[string]any {
clone := make(map[string]any, len(values))
for key, value := range values {
clone[key] = value
}
return clone
}

122
auth/auth_test.go Normal file
View File

@@ -0,0 +1,122 @@
package auth_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/auth"
"github.com/gin-gonic/gin"
)
func TestRequireAuthAndRole(t *testing.T) {
app := omni.New(omni.WithMode(gin.TestMode))
authenticator := auth.StaticAuthenticator{"good-token": {UserID: "user-1", Roles: []string{"user"}}}
private := app.Group("/private", auth.RequireAuth(authenticator))
private.GET("/me", func(ctx *omni.Context) error {
principal, ok := auth.FromContext(ctx)
if !ok {
t.Fatal("principal was not attached")
}
return ctx.OK(principal)
})
private.GET("/admin", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"ok": "true"})
}, auth.RequireRole("admin"))
tests := []struct {
name, authorization, path, code string
status int
}{
{"missing", "", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized},
{"malformed", "Token good-token", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized},
{"invalid", "Bearer bad-token", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized},
{"valid", "Bearer good-token", "/private/me", "OK", http.StatusOK},
{"forbidden", "Bearer good-token", "/private/admin", "FORBIDDEN", http.StatusForbidden},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, test.path, nil)
if test.authorization != "" {
request.Header.Set("Authorization", test.authorization)
}
app.ServeHTTP(recorder, request)
if recorder.Code != test.status {
t.Fatalf("expected status %d, got %d: %s", test.status, recorder.Code, recorder.Body.String())
}
var body struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != test.code {
t.Fatalf("expected code %q, got %q", test.code, body.Code)
}
})
}
}
func TestOptionalAuth(t *testing.T) {
app := omni.New(omni.WithMode(gin.TestMode))
app.GET("/public", func(ctx *omni.Context) error {
principal, authenticated := auth.FromContext(ctx)
return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID})
}, auth.OptionalAuth(auth.StaticAuthenticator{"token": {UserID: "user-1"}}))
for _, test := range []struct {
name, token string
wantAuth bool
status int
}{
{"anonymous", "", false, http.StatusOK},
{"authenticated", "Bearer token", true, http.StatusOK},
{"invalid", "Bearer bad", false, http.StatusUnauthorized},
} {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/public", nil)
if test.token != "" {
request.Header.Set("Authorization", test.token)
}
app.ServeHTTP(recorder, request)
if recorder.Code != test.status {
t.Fatalf("expected status %d, got %d", test.status, recorder.Code)
}
if test.wantAuth {
var body struct {
Data struct {
Authenticated bool `json:"authenticated"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.Data.Authenticated {
t.Fatal("expected authenticated response")
}
}
})
}
}
func TestStaticAuthenticatorDoesNotExposeMutableSlices(t *testing.T) {
authenticator := auth.StaticAuthenticator{"token": {UserID: "user-1", Roles: []string{"admin"}, Claims: map[string]any{"tenant": "a"}}}
principal, err := authenticator.Authenticate(context.Background(), "token")
if err != nil {
t.Fatal(err)
}
principal.Roles[0] = "changed"
principal.Claims["tenant"] = "changed"
again, err := authenticator.Authenticate(context.Background(), "token")
if err != nil {
t.Fatal(err)
}
if !again.HasRole("admin") || again.Claims["tenant"] != "a" {
t.Fatalf("static authenticator returned mutable state: %+v", again)
}
}

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

93
config/config.go Normal file
View File

@@ -0,0 +1,93 @@
package config
import (
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
const defaultRequestIDHeader = "X-Request-ID"
// Config controls the default runtime behavior of an App.
type Config struct {
Mode string
Address string
RequestIDHeader string
EnableLogger bool
ShutdownTimeout time.Duration
Log logx.Config
}
func Default() Config {
return Config{
Address: ":8080",
RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second,
Log: logx.Default(),
}
}
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if strings.TrimSpace(c.Address) == "" {
return &ConfigError{Field: "address", Message: "is required"}
}
if strings.TrimSpace(c.RequestIDHeader) == "" {
return &ConfigError{Field: "request_id_header", Message: "is required"}
}
if c.ShutdownTimeout <= 0 {
return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
}
return nil
}
// ConfigError describes an invalid application configuration field.
type ConfigError struct {
Field string
Message string
}
func (e *ConfigError) Error() string { return "config " + e.Field + " " + e.Message }
// Option changes an App's configuration.
type Option func(*Config)
// WithMode sets the default transport's running mode.
func WithMode(mode string) Option {
return func(cfg *Config) { cfg.Mode = mode }
}
// WithRequestIDHeader changes the header used to read and return request IDs.
func WithRequestIDHeader(header string) Option {
return func(cfg *Config) {
if value := strings.TrimSpace(header); value != "" {
cfg.RequestIDHeader = value
}
}
}
// WithLogger enables GoOmni's structured access logger.
func WithLogger(enabled bool) Option {
return func(cfg *Config) { cfg.EnableLogger = enabled }
}
// WithConfig applies a complete configuration loaded from a file or
// environment. Later options can still override individual fields.
func WithConfig(settings Config) Option {
return func(cfg *Config) { *cfg = settings }
}
// WithLogConfig configures the logger used by the application runtime.
func WithLogConfig(settings logx.Config) Option {
return func(cfg *Config) { cfg.Log = settings }
}
// WithShutdownTimeout controls how long RunContext waits for active requests.
func WithShutdownTimeout(timeout time.Duration) Option {
return func(cfg *Config) {
if timeout > 0 {
cfg.ShutdownTimeout = timeout
}
}
}

95
config/config_test.go Normal file
View File

@@ -0,0 +1,95 @@
package config
import (
"strings"
"testing"
"time"
)
func TestLoadFromEnvUsesDefaults(t *testing.T) {
for _, key := range []string{
"GO_OMNI_MODE", "GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
} {
t.Setenv(key, "")
}
cfg, err := LoadFromEnv("")
if err != nil {
t.Fatal(err)
}
if cfg.Address != ":8080" || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
t.Fatalf("unexpected logger defaults: %+v", cfg.Log)
}
}
func TestLoadFromEnvOverridesSettings(t *testing.T) {
t.Setenv("GO_OMNI_MODE", "release")
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
t.Setenv("GO_OMNI_LOG_OUTPUT", "stdout")
t.Setenv("GO_OMNI_SERVICE", "billing")
t.Setenv("GO_OMNI_ENVIRONMENT", "test")
t.Setenv("GO_OMNI_LOG_ADD_SOURCE", "true")
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "3s")
cfg, err := LoadFromEnv("")
if err != nil {
t.Fatal(err)
}
if cfg.Mode != "release" || cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger {
t.Fatalf("unexpected application settings: %+v", cfg)
}
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
t.Fatalf("unexpected logger settings: %+v", cfg.Log)
}
}
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
t.Setenv("GO_OMNI_ENABLE_LOGGER", "sometimes")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "ENABLE_LOGGER") {
t.Fatalf("expected invalid logger flag error, got %v", err)
}
t.Setenv("GO_OMNI_ENABLE_LOGGER", "")
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "later")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "SHUTDOWN_TIMEOUT") {
t.Fatalf("expected invalid timeout error, got %v", err)
}
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
cfg := Default()
cfg.Address = ""
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
t.Fatalf("expected invalid address error, got %v", err)
}
cfg = Default()
cfg.RequestIDHeader = " "
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "request_id_header") {
t.Fatalf("expected invalid request header error, got %v", err)
}
}
func TestWithConfigAndWithLogConfig(t *testing.T) {
settings := Default()
settings.Mode = "release"
settings.Log.Service = "orders"
cfg := Default()
WithConfig(settings)(&cfg)
if cfg.Mode != "release" || cfg.Log.Service != "orders" {
t.Fatalf("complete config was not applied: %+v", cfg)
}
WithLogConfig(Default().Log)(&cfg)
if cfg.Log.Service != "" {
t.Fatalf("log config was not replaced: %+v", cfg.Log)
}
}

67
config/env.go Normal file
View File

@@ -0,0 +1,67 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
const defaultEnvPrefix = "GO_OMNI_"
// LoadFromEnv loads application settings from GO_OMNI_* variables. The
// database settings remain owned by database.ConfigFromEnv.
func LoadFromEnv(prefix string) (Config, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
prefix = defaultEnvPrefix
}
cfg := Default()
cfg.Mode = envString(prefix+"MODE", cfg.Mode)
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File)
cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service)
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
var err error
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
return Config{}, err
}
if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil {
return Config{}, err
}
if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" {
cfg.ShutdownTimeout, err = time.ParseDuration(value)
if err != nil {
return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err)
}
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func envString(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envBool(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
}
return parsed, nil
}

37
database/README.md Normal file
View File

@@ -0,0 +1,37 @@
# database
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
- `Health`:用于就绪检查。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
```go
import (
"context"
_ "github.com/go-sql-driver/mysql"
"git.yuntuo.hk/k2safe/go-omni/database"
)
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
db, err := database.Open(context.Background(), cfg)
defer db.Close()
```
连接池相关环境变量:
```text
GO_OMNI_DB_DRIVER=mysql
GO_OMNI_DB_DSN=user:password@tcp(127.0.0.1:3306)/app?parseTime=true
GO_OMNI_DB_MAX_OPEN_CONNS=10
GO_OMNI_DB_MAX_IDLE_CONNS=5
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
GO_OMNI_DB_PING_TIMEOUT=5s
```
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。

175
database/database.go Normal file
View File

@@ -0,0 +1,175 @@
// Package database provides the database/sql lifecycle used by GoOmni projects.
// It deliberately does not import a database driver; each application chooses one.
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Driver string
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
PingTimeout time.Duration
}
func DefaultConfig() Config {
return Config{
MaxOpenConns: 10,
MaxIdleConns: 5,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
PingTimeout: 5 * time.Second,
}
}
// ConfigFromEnv reads GO_OMNI_DB_* style settings. DSN is required.
func ConfigFromEnv(prefix string) (Config, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
prefix = "GO_OMNI_DB_"
}
cfg := DefaultConfig()
cfg.Driver = strings.TrimSpace(os.Getenv(prefix + "DRIVER"))
if cfg.Driver == "" {
cfg.Driver = "mysql"
}
cfg.DSN = strings.TrimSpace(os.Getenv(prefix + "DSN"))
if cfg.DSN == "" {
return Config{}, fmt.Errorf("%sDSN is required", prefix)
}
var err error
if cfg.MaxOpenConns, err = envInt(prefix+"MAX_OPEN_CONNS", cfg.MaxOpenConns); err != nil {
return Config{}, err
}
if cfg.MaxIdleConns, err = envInt(prefix+"MAX_IDLE_CONNS", cfg.MaxIdleConns); err != nil {
return Config{}, err
}
if cfg.ConnMaxLifetime, err = envDuration(prefix+"CONN_MAX_LIFETIME", cfg.ConnMaxLifetime); err != nil {
return Config{}, err
}
if cfg.ConnMaxIdleTime, err = envDuration(prefix+"CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime); err != nil {
return Config{}, err
}
if cfg.PingTimeout, err = envDuration(prefix+"PING_TIMEOUT", cfg.PingTimeout); err != nil {
return Config{}, err
}
return cfg, cfg.Validate()
}
func (c Config) Validate() error {
if strings.TrimSpace(c.Driver) == "" {
return errors.New("database driver is required")
}
if strings.TrimSpace(c.DSN) == "" {
return errors.New("database DSN is required")
}
if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 {
return errors.New("database pool limits cannot be negative")
}
if c.MaxIdleConns > c.MaxOpenConns && c.MaxOpenConns > 0 {
return errors.New("max idle connections cannot exceed max open connections")
}
if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 {
return errors.New("database timeouts are invalid")
}
return nil
}
// DB owns a connection pool and exposes the embedded database/sql methods.
type DB struct {
*sql.DB
config Config
}
func Open(ctx context.Context, cfg Config) (*DB, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
db, err := sql.Open(cfg.Driver, cfg.DSN)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if cfg.MaxOpenConns > 0 {
db.SetMaxOpenConns(cfg.MaxOpenConns)
}
if cfg.MaxIdleConns > 0 {
db.SetMaxIdleConns(cfg.MaxIdleConns)
}
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
defer cancel()
if err := db.PingContext(checkCtx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{DB: db, config: cfg}, nil
}
func (db *DB) Config() Config { return db.config }
func (db *DB) Health(ctx context.Context) error {
if db == nil || db.DB == nil {
return errors.New("database is not initialized")
}
return db.PingContext(ctx)
}
// InTx commits only when work returns nil. Any work error or panic rolls back.
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
if db == nil || db.DB == nil {
return errors.New("database is not initialized")
}
tx, err := db.BeginTx(ctx, options)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
rollbackErr := tx.Rollback()
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
}
}()
if err = work(tx); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func envInt(key string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
}
return parsed, nil
}
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
}
return parsed, nil
}

58
database/database_test.go Normal file
View File

@@ -0,0 +1,58 @@
package database
import (
"context"
"os"
"strings"
"testing"
"time"
)
func TestConfigFromEnv(t *testing.T) {
keys := []string{
"GO_OMNI_DB_DRIVER", "GO_OMNI_DB_DSN", "GO_OMNI_DB_MAX_OPEN_CONNS",
"GO_OMNI_DB_MAX_IDLE_CONNS", "GO_OMNI_DB_CONN_MAX_LIFETIME",
"GO_OMNI_DB_CONN_MAX_IDLE_TIME", "GO_OMNI_DB_PING_TIMEOUT",
}
for _, key := range keys {
t.Setenv(key, "")
_ = os.Unsetenv(key)
}
t.Setenv("GO_OMNI_DB_DSN", "root:secret@tcp(127.0.0.1:3306)/demo")
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "20")
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "8")
t.Setenv("GO_OMNI_DB_CONN_MAX_LIFETIME", "2m")
t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "750ms")
cfg, err := ConfigFromEnv("")
if err != nil {
t.Fatal(err)
}
if cfg.Driver != "mysql" || cfg.DSN == "" || cfg.MaxOpenConns != 20 || cfg.MaxIdleConns != 8 || cfg.ConnMaxLifetime != 2*time.Minute || cfg.PingTimeout != 750*time.Millisecond {
t.Fatalf("unexpected config: %+v", cfg)
}
}
func TestConfigFromEnvRejectsInvalidValues(t *testing.T) {
t.Setenv("GO_OMNI_DB_DSN", "demo")
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "bad")
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "MAX_OPEN_CONNS") {
t.Fatalf("expected invalid pool limit error, got %v", err)
}
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "2")
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "3")
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "max idle") {
t.Fatalf("expected pool relationship error, got %v", err)
}
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "1")
t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "0s")
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "timeouts") {
t.Fatalf("expected timeout error, got %v", err)
}
}
func TestOpenValidatesBeforeLoadingDriver(t *testing.T) {
_, err := Open(context.Background(), Config{Driver: "", DSN: "demo"})
if err == nil || !strings.Contains(err.Error(), "driver") {
t.Fatalf("expected driver validation error, got %v", err)
}
}

74
errors/errors.go Normal file
View File

@@ -0,0 +1,74 @@
package apierrors
import (
stdErrors "errors"
"net/http"
)
// APIError is the public error shape used by handlers and middleware.
// Cause is retained for logging and unwrapping, but is never serialized.
type APIError struct {
Status int
Code string
Message string
Cause error
}
func (e *APIError) Error() string {
if e == nil {
return ""
}
if e.Cause != nil {
return e.Code + ": " + e.Message + ": " + e.Cause.Error()
}
return e.Code + ": " + e.Message
}
func (e *APIError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// WithCause attaches an internal cause while keeping the public response stable.
func (e *APIError) WithCause(cause error) *APIError {
e.Cause = cause
return e
}
func New(status int, code, message string) *APIError {
return &APIError{Status: status, Code: code, Message: message}
}
func BadRequest(code, message string) *APIError {
return New(http.StatusBadRequest, code, message)
}
func Unauthorized(code, message string) *APIError {
return New(http.StatusUnauthorized, code, message)
}
func Forbidden(code, message string) *APIError {
return New(http.StatusForbidden, code, message)
}
func NotFound(code, message string) *APIError {
return New(http.StatusNotFound, code, message)
}
func Internal(cause error) *APIError {
return New(http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error").WithCause(cause)
}
// From converts any error into the public API error shape.
func From(err error) *APIError {
if err == nil {
return nil
}
var apiErr *APIError
if stdErrors.As(err, &apiErr) && apiErr != nil {
return apiErr
}
return Internal(err)
}

20
examples/basic/main.go Normal file
View File

@@ -0,0 +1,20 @@
package main
import "git.yuntuo.hk/k2safe/go-omni"
func main() {
app := omni.New(omni.WithLogger(true))
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
api := app.Group("/api")
api.GET("/users/:id", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{
"id": ctx.Param("id"),
})
})
_ = app.Run(":8080")
}

View File

@@ -0,0 +1,159 @@
# 用户 CRUD 参考项目
这个示例展示如何用 GoOmni 写一个完整业务模块:参数绑定、业务规则、存储接口、分页、认证授权、统一响应、错误转换和自定义中间件。
## 运行
在 `go-omni` 根目录执行(Go 1.22 或以上):
```sh
go run ./examples/reference
```
默认监听 `127.0.0.1:8080`,可通过环境变量调整:
```sh
GO_OMNI_ADDR=127.0.0.1:8081 go run ./examples/reference
```
如果终端提示找不到 `go`,需要先把 Go 的 `bin` 目录加入 `PATH`。不要与 `examples/basic` 同时占用同一个端口。
默认使用内存存储,启动时为空,重启后数据清空。写接口需要 `Authorization: Bearer dev-token`,删除接口还需要 admin 角色;这是本地演示 Token,不可用于生产。设置 `GO_OMNI_DB_DSN` 后会改用 MySQL 存储,启动时会连接并 Ping 数据库;请先执行 [`schema.sql`](schema.sql)。
## 分层
```text
examples/reference/
├── main.go # 组装存储、认证、业务服务、中间件和路由
├── handler/
│ ├── user.go # JSON / URI / Query 参数校验、HTTP 错误转换
│ └── middleware.go # 自定义结构化请求日志
├── service/
│ └── user.go # 业务规则、输入规范化、UserRepository 接口
├── repository/
│ ├── memory.go # 并发安全的内存实现,原子保证邮箱唯一
│ └── mysql.go # database/sql + MySQL 持久化实现
├── model/
│ ├── user.go # 用户、输入、分页结果
│ └── errors.go # 业务错误
└── main_test.go # 接口全流程与并发测试
```
调用路径:HTTP → Handler → Service → Repository。
- Handler 依赖 GoOmni,示例代码不导入 Gin,也不调用 `Engine()` / `Raw()`。
- Service、Repository、Model 不依赖 GoOmni 或 Gin,通过标准 `context.Context` 传递请求取消信号。
- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。
- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。
- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。
## 接口约定
| 方法 | 路径 | 成功结果 |
| --- | --- | --- |
| GET | `/health` | 200,健康检查 |
| POST | `/api/v1/users` | 201,创建用户 |
| GET | `/api/v1/users` | 200,分页列表 |
| GET | `/api/v1/users/:id` | 200,用户详情 |
| PUT | `/api/v1/users/:id` | 200,完整替换姓名和邮箱 |
| DELETE | `/api/v1/users/:id` | 204,无响应体 |
姓名和邮箱必填。姓名最长 100 个字符,去掉首尾空白后不能为空;邮箱最长 254 字节。本示例约定整个邮箱地址不区分大小写,按小写存储并唯一校验。
`PUT` 必须同时提交姓名和邮箱;ID 和时间戳由服务生成。删除后允许重新使用邮箱,新用户获得新的 ID。删除不存在的用户返回 404。
列表默认 `page=1&pageSize=20`,每页最多 100 条,按 ID 升序;超出最后一页返回空数组并保留总数。
## 试一遍完整流程
以下命令假定刚启动一个空实例,第一条创建的用户 ID 为 1:
```sh
# 健康检查
curl -i http://127.0.0.1:8080/health
# 创建:201(开发 Token)
curl -i -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer dev-token' \
-H 'X-Request-ID: demo-create' \
-d '{"name":"张三","email":"zhangsan@example.com"}'
# 查询详情和分页列表:200
curl -i http://127.0.0.1:8080/api/v1/users/1
curl -i 'http://127.0.0.1:8080/api/v1/users?page=1&pageSize=10'
# 相同邮箱(包括大小写变体):409 EMAIL_TAKEN
curl -i -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer dev-token' \
-d '{"name":"另一个人","email":"ZHANGSAN@example.com"}'
# 完整修改:200
curl -i -X PUT http://127.0.0.1:8080/api/v1/users/1 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer dev-token' \
-d '{"name":"张三(已更新)","email":"zhangsan.new@example.com"}'
# 删除:204;随后查询:404 USER_NOT_FOUND
curl -i -X DELETE http://127.0.0.1:8080/api/v1/users/1 \
-H 'Authorization: Bearer dev-token'
curl -i http://127.0.0.1:8080/api/v1/users/1
```
分页结果的 `data` 结构:
```json
{
"items": [],
"total": 0,
"page": 1,
"pageSize": 20
}
```
成功与失败都使用框架的 `code`、`message`、`requestId` 约定;删除成功只有 HTTP 204 和请求 ID 响应头。
| HTTP 状态 | code | 说明 |
| --- | --- | --- |
| 400 | `INVALID_BODY` | JSON 或必填字段、格式校验失败 |
| 400 | `INVALID_PATH` | ID 不是正整数 |
| 400 | `INVALID_QUERY` | 分页参数不合法 |
| 400 | `INVALID_USER` | 业务校验失败,例如姓名全为空白 |
| 404 | `USER_NOT_FOUND` | 用户不存在 |
| 409 | `EMAIL_TAKEN` | 邮箱已被其他用户使用 |
| 500 | `INTERNAL_ERROR` | 未预期的内部错误,响应不包含内部细节 |
## 使用数据库
参考项目已经提供 MySQL 的 `service.UserRepository` 实现;Handler 和 Service 不需要修改。自定义数据库实现也应遵循相同契约:
- 创建和修改时原子保证规范化后的邮箱唯一,例如使用数据库唯一索引;将唯一键冲突转换为 `model.ErrEmailTaken`。
- 未找到用户返回 `model.ErrUserNotFound`;修改失败不保留部分更新。
- 列表按 ID 排序、返回总数,空列表使用 `[]`。
- 使用传入的 `context.Context` 执行查询,保留创建时间,修改时更新时间。
MySQL 运行配置通过环境变量传入:
```sh
export GO_OMNI_DB_DRIVER=mysql
export GO_OMNI_DB_DSN='root:password@tcp(127.0.0.1:3306)/go_omni?parseTime=true&loc=UTC'
export GO_OMNI_DB_MAX_OPEN_CONNS=10
export GO_OMNI_DB_MAX_IDLE_CONNS=5
export GO_OMNI_DB_PING_TIMEOUT=5s
go run ./examples/reference
```
不设置 `GO_OMNI_DB_DSN` 就使用内存实现。可通过 `GO_OMNI_DEV_TOKEN` 替换本地演示 Token。数据库连接会在收到 `SIGINT` / `SIGTERM` 后随 HTTP 服务优雅退出而关闭;连接失败会阻止服务启动,不会静默回退到内存存储。
## 验证
在框架根目录执行:
```sh
go test -race ./...
go vet ./...
go build ./...
```
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。

View File

@@ -0,0 +1,31 @@
package handler
import (
"time"
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
// RequestLog demonstrates business middleware using only the GoOmni API.
func RequestLog(logger *logx.Logger) omni.Middleware {
return func(next omni.Handler) omni.Handler {
return func(ctx *omni.Context) error {
started := time.Now()
err := next(ctx)
attributes := []any{
"requestId", ctx.RequestID(),
"method", ctx.Request().Method,
"path", ctx.Request().URL.Path,
"duration", time.Since(started),
}
if err != nil {
attributes = append(attributes, "error", err)
logger.ErrorContext(ctx.Request().Context(), "request failed", attributes...)
} else {
logger.InfoContext(ctx.Request().Context(), "request completed", attributes...)
}
return err
}
}
}

View File

@@ -0,0 +1,121 @@
package handler
import (
"errors"
"net/http"
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/service"
)
type Users struct {
service *service.Users
}
func NewUsers(service *service.Users) *Users {
return &Users{service: service}
}
func (h *Users) RegisterRoutes(api *omni.RouterGroup, writeMiddleware, deleteMiddleware omni.Middleware) {
users := api.Group("/users")
users.POST("", h.Create, writeMiddleware)
users.GET("", h.List)
users.GET("/:id", h.Get)
users.PUT("/:id", h.Update, writeMiddleware)
users.DELETE("/:id", h.Delete, writeMiddleware, deleteMiddleware)
}
// HTTP-specific validation tags stay here, outside the business model.
type userBody struct {
Name string `json:"name" binding:"required,max=100"`
Email string `json:"email" binding:"required,email,max=254"`
}
type userPath struct {
ID int64 `uri:"id" binding:"gt=0"`
}
type listQuery struct {
Page int `form:"page,default=1" binding:"gte=1"`
PageSize int `form:"pageSize,default=20" binding:"gte=1,lte=100"`
}
func (h *Users) Create(ctx *omni.Context) error {
var body userBody
if err := ctx.BindJSON(&body); err != nil {
return err
}
user, err := h.service.Create(ctx.Request().Context(), model.UserInput(body))
if err != nil {
return userError(err)
}
return ctx.Created(user)
}
func (h *Users) Get(ctx *omni.Context) error {
var path userPath
if err := ctx.BindURI(&path); err != nil {
return err
}
user, err := h.service.Get(ctx.Request().Context(), path.ID)
if err != nil {
return userError(err)
}
return ctx.OK(user)
}
func (h *Users) List(ctx *omni.Context) error {
var query listQuery
if err := ctx.BindQuery(&query); err != nil {
return err
}
page, err := h.service.List(ctx.Request().Context(), query.Page, query.PageSize)
if err != nil {
return userError(err)
}
return ctx.OK(page)
}
func (h *Users) Update(ctx *omni.Context) error {
var path userPath
if err := ctx.BindURI(&path); err != nil {
return err
}
var body userBody
if err := ctx.BindJSON(&body); err != nil {
return err
}
user, err := h.service.Update(ctx.Request().Context(), path.ID, model.UserInput(body))
if err != nil {
return userError(err)
}
return ctx.OK(user)
}
func (h *Users) Delete(ctx *omni.Context) error {
var path userPath
if err := ctx.BindURI(&path); err != nil {
return err
}
if err := h.service.Delete(ctx.Request().Context(), path.ID); err != nil {
return userError(err)
}
return ctx.NoContent()
}
func userError(err error) error {
switch {
case errors.Is(err, model.ErrUserNotFound):
return omni.NotFound("USER_NOT_FOUND", "user was not found").WithCause(err)
case errors.Is(err, model.ErrEmailTaken):
return omni.NewError(http.StatusConflict, "EMAIL_TAKEN", "email is already in use").WithCause(err)
case errors.Is(err, model.ErrInvalidUser):
return omni.BadRequest("INVALID_USER", "name or email is invalid").WithCause(err)
case errors.Is(err, model.ErrInvalidPage):
return omni.BadRequest("INVALID_QUERY", "pagination is invalid").WithCause(err)
default:
// GoOmni masks unexpected errors; internal details stay in server logs.
return err
}
}

100
examples/reference/main.go Normal file
View File

@@ -0,0 +1,100 @@
package main
import (
"context"
"os"
"os/signal"
"strings"
"syscall"
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/auth"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/handler"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/service"
"git.yuntuo.hk/k2safe/go-omni/logx"
_ "github.com/go-sql-driver/mysql"
)
func newApp(users service.UserRepository, logger *logx.Logger, options ...omni.Option) *omni.App {
application := omni.New(options...)
application.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
api := application.Group("/api/v1", handler.RequestLog(logger))
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, nil, nil)
return application
}
func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, options ...omni.Option) *omni.App {
application := omni.New(options...)
application.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
api := application.Group("/api/v1", handler.RequestLog(logger))
write := auth.RequireAuth(authenticator)
delete := auth.RequireRole("admin")
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, write, delete)
return application
}
func main() {
logger, err := logx.New(logx.Config{
Service: "reference-api",
Environment: "local",
Format: "json",
Writer: os.Stdout,
})
if err != nil {
os.Exit(1)
}
defer logger.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
users, closeStorage, storage, err := openUsers(ctx, logger)
if err != nil {
logger.Error("storage initialization failed", "error", err)
os.Exit(1)
}
defer func() {
if err := closeStorage(); err != nil {
logger.Error("storage close failed", "error", err)
}
}()
devToken := strings.TrimSpace(os.Getenv("GO_OMNI_DEV_TOKEN"))
if devToken == "" {
devToken = "dev-token"
}
authenticator := auth.StaticAuthenticator{
devToken: {UserID: "dev-user", Roles: []string{"admin"}},
}
application := newProtectedApp(users, logger, authenticator)
address := os.Getenv("GO_OMNI_ADDR")
if address == "" {
address = "127.0.0.1:8080"
}
logger.Info("starting reference API", "address", address, "storage", storage)
if err := application.RunContext(ctx, address); err != nil {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
func openUsers(ctx context.Context, logger *logx.Logger) (service.UserRepository, func() error, string, error) {
if strings.TrimSpace(os.Getenv("GO_OMNI_DB_DSN")) == "" {
return repository.NewMemoryUsers(), func() error { return nil }, "memory", nil
}
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
if err != nil {
return nil, nil, "", err
}
db, err := database.Open(ctx, cfg)
if err != nil {
return nil, nil, "", err
}
return repository.NewMySQLUsers(db.DB), db.Close, "mysql", nil
}

View File

@@ -0,0 +1,277 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/auth"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
type envelope struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
RequestID string `json:"requestId"`
}
func testApp() *omni.App {
return newApp(repository.NewMemoryUsers(), testLogger(io.Discard), omni.WithMode("test"))
}
func testLogger(writer io.Writer) *logx.Logger {
logger, err := logx.New(logx.Config{Format: "json", Writer: writer})
if err != nil {
panic(err)
}
return logger
}
func request(t *testing.T, app http.Handler, method, path, body string, status int, code string) envelope {
return requestWithAuthorization(t, app, method, path, body, "", status, code)
}
func requestWithAuthorization(t *testing.T, app http.Handler, method, path, body, authorization string, status int, code string) envelope {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", "reference-test")
if authorization != "" {
req.Header.Set("Authorization", authorization)
}
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
if w.Code != status {
t.Fatalf("%s %s: want status %d, got %d: %s", method, path, status, w.Code, w.Body.String())
}
if w.Header().Get("X-Request-ID") != "reference-test" {
t.Fatalf("request ID header was not propagated: %v", w.Header())
}
if status == http.StatusNoContent {
if w.Body.Len() != 0 {
t.Fatalf("204 must have no body: %s", w.Body.String())
}
return envelope{}
}
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
t.Fatalf("expected JSON content type: %v", w.Header())
}
var result envelope
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON response: %v: %s", err, w.Body.String())
}
if result.Code != code || result.RequestID != "reference-test" {
t.Fatalf("unexpected envelope: %+v", result)
}
return result
}
func TestProtectedWriteRoutes(t *testing.T) {
app := newProtectedApp(
repository.NewMemoryUsers(),
testLogger(io.Discard),
auth.StaticAuthenticator{"user-token": {UserID: "user-1", Roles: []string{"user"}}},
omni.WithMode("test"),
)
request(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED")
created := dataAs[model.User](t, requestWithAuthorization(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, "Bearer user-token", 201, "CREATED"))
path := "/api/v1/users/" + strconv.FormatInt(created.ID, 10)
requestWithAuthorization(t, app, "DELETE", path, "", "Bearer user-token", 403, "FORBIDDEN")
requestWithAuthorization(t, app, "PUT", path, `{"name":"Alice Updated","email":"alice.updated@example.com"}`, "Bearer user-token", 200, "OK")
}
func dataAs[T any](t *testing.T, value envelope) T {
t.Helper()
var data T
if err := json.Unmarshal(value.Data, &data); err != nil {
t.Fatalf("decode data: %v", err)
}
return data
}
func TestUserCRUD(t *testing.T) {
app := testApp()
health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK"))
if health["status"] != "ok" {
t.Fatalf("unexpected health: %v", health)
}
empty := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK"))
if empty.Items == nil || len(empty.Items) != 0 || empty.Total != 0 || empty.Page != 1 || empty.PageSize != 20 {
t.Fatalf("unexpected empty page: %+v", empty)
}
alice := dataAs[model.User](t, request(t, app, "POST", "/api/v1/users", `{"name":" Alice ","email":"ALICE@example.com"}`, 201, "CREATED"))
if alice.ID <= 0 || alice.Name != "Alice" || alice.Email != "alice@example.com" || alice.CreatedAt.IsZero() || !alice.CreatedAt.Equal(alice.UpdatedAt) {
t.Fatalf("unexpected created user: %+v", alice)
}
path := "/api/v1/users/" + strconv.FormatInt(alice.ID, 10)
got := dataAs[model.User](t, request(t, app, "GET", path, "", 200, "OK"))
if got != alice {
t.Fatalf("get did not preserve user: %+v", got)
}
request(t, app, "POST", "/api/v1/users", `{"name":"Duplicate","email":"Alice@EXAMPLE.com"}`, 409, "EMAIL_TAKEN")
bob := dataAs[model.User](t, request(t, app, "POST", "/api/v1/users", `{"name":"Bob","email":"bob@example.com"}`, 201, "CREATED"))
page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users?page=2&pageSize=1", "", 200, "OK"))
if page.Total != 2 || page.Page != 2 || page.PageSize != 1 || len(page.Items) != 1 || page.Items[0] != bob {
t.Fatalf("unexpected page: %+v", page)
}
// An extreme page must return an empty list without an integer overflow.
hugePage := strconv.Itoa(int(^uint(0) >> 1))
outOfRange := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users?page="+hugePage+"&pageSize=100", "", 200, "OK"))
if outOfRange.Items == nil || len(outOfRange.Items) != 0 || outOfRange.Total != 2 {
t.Fatalf("unexpected out-of-range page: %+v", outOfRange)
}
// A conflicting update must not partially modify either user or email index.
request(t, app, "PUT", path, `{"name":"Changed","email":"BOB@example.com"}`, 409, "EMAIL_TAKEN")
unchanged := dataAs[model.User](t, request(t, app, "GET", path, "", 200, "OK"))
if unchanged != alice {
t.Fatalf("failed update changed data: %+v", unchanged)
}
updated := dataAs[model.User](t, request(t, app, "PUT", path, `{"name":"Alice Updated","email":"new@example.com"}`, 200, "OK"))
if updated.ID != alice.ID || updated.Name != "Alice Updated" || updated.Email != "new@example.com" || !updated.CreatedAt.Equal(alice.CreatedAt) || updated.UpdatedAt.Before(alice.UpdatedAt) {
t.Fatalf("unexpected update: %+v", updated)
}
// Retaining one's own email is allowed; the old email can be reused.
request(t, app, "PUT", path, `{"name":"Alice Updated","email":"new@example.com"}`, 200, "OK")
request(t, app, "POST", "/api/v1/users", `{"name":"Reused","email":"alice@example.com"}`, 201, "CREATED")
request(t, app, "DELETE", path, "", 204, "")
request(t, app, "GET", path, "", 404, "USER_NOT_FOUND")
request(t, app, "DELETE", path, "", 404, "USER_NOT_FOUND")
request(t, app, "POST", "/api/v1/users", `{"name":"Reused Again","email":"new@example.com"}`, 201, "CREATED")
}
func TestUserValidation(t *testing.T) {
app := testApp()
for _, test := range []struct {
name, method, path, body, code string
}{
{"malformed JSON", "POST", "/api/v1/users", `{"name":`, "INVALID_BODY"},
{"missing email", "POST", "/api/v1/users", `{"name":"Alice"}`, "INVALID_BODY"},
{"invalid email", "POST", "/api/v1/users", `{"name":"Alice","email":"invalid"}`, "INVALID_BODY"},
{"blank name", "POST", "/api/v1/users", `{"name":" ","email":"alice@example.com"}`, "INVALID_USER"},
{"negative ID", "GET", "/api/v1/users/-1", "", "INVALID_PATH"},
{"zero ID", "DELETE", "/api/v1/users/0", "", "INVALID_PATH"},
{"text ID", "PUT", "/api/v1/users/abc", `{}`, "INVALID_PATH"},
{"overflow ID", "GET", "/api/v1/users/99999999999999999999999", "", "INVALID_PATH"},
{"zero page", "GET", "/api/v1/users?page=0", "", "INVALID_QUERY"},
{"negative page", "GET", "/api/v1/users?page=-1", "", "INVALID_QUERY"},
{"text page", "GET", "/api/v1/users?page=abc", "", "INVALID_QUERY"},
{"zero page size", "GET", "/api/v1/users?pageSize=0", "", "INVALID_QUERY"},
{"large page size", "GET", "/api/v1/users?pageSize=101", "", "INVALID_QUERY"},
{"incomplete replacement", "PUT", "/api/v1/users/1", `{"name":"Alice"}`, "INVALID_BODY"},
} {
t.Run(test.name, func(t *testing.T) {
request(t, app, test.method, test.path, test.body, 400, test.code)
})
}
request(t, app, "GET", "/api/v1/users/123", "", 404, "USER_NOT_FOUND")
request(t, app, "PUT", "/api/v1/users/123", `{"name":"Alice","email":"alice@example.com"}`, 404, "USER_NOT_FOUND")
page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK"))
if page.Total != 0 {
t.Fatalf("invalid requests mutated storage: %+v", page)
}
}
type failingUsers struct {
*repository.MemoryUsers
err error
}
func (r failingUsers) Get(context.Context, int64) (model.User, error) {
return model.User{}, r.err
}
func TestErrorTranslationAndRequestLogging(t *testing.T) {
for _, test := range []struct {
name, code string
err error
status int
}{
{"internal failure", "INTERNAL_ERROR", errors.New("private database diagnostic"), 500},
{"wrapped domain error", "USER_NOT_FOUND", fmt.Errorf("lookup failed: %w", model.ErrUserNotFound), 404},
} {
t.Run(test.name, func(t *testing.T) {
var logs bytes.Buffer
app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs), omni.WithMode("test"))
result := request(t, app, "GET", "/api/v1/users/1", "", test.status, test.code)
if strings.Contains(result.Message, test.err.Error()) || len(result.Data) != 0 {
t.Fatalf("internal error details leaked: %+v", result)
}
var logEntry map[string]any
if err := json.Unmarshal(logs.Bytes(), &logEntry); err != nil {
t.Fatalf("missing or invalid request log: %v: %s", err, logs.String())
}
if logEntry["requestId"] != "reference-test" || logEntry["path"] != "/api/v1/users/1" || !strings.Contains(fmt.Sprint(logEntry["error"]), test.err.Error()) {
t.Fatalf("missing request/error context in log: %v", logEntry)
}
})
}
}
func TestConcurrentEmailUniqueness(t *testing.T) {
app := testApp()
server := httptest.NewServer(app)
defer server.Close()
const count = 24
results := make(chan int, count)
errors := make(chan error, count)
var workers sync.WaitGroup
for i := 0; i < count; i++ {
workers.Add(1)
go func(i int) {
defer workers.Done()
email := "same@example.com"
if i%2 == 0 {
email = "SAME@example.com"
}
body := fmt.Sprintf(`{"name":"User %d","email":%q}`, i, email)
response, err := server.Client().Post(server.URL+"/api/v1/users", "application/json", strings.NewReader(body))
if err != nil {
errors <- err
return
}
io.Copy(io.Discard, response.Body)
response.Body.Close()
results <- response.StatusCode
}(i)
}
workers.Wait()
close(results)
close(errors)
for err := range errors {
t.Errorf("request failed: %v", err)
}
created, conflicts := 0, 0
for status := range results {
switch status {
case 201:
created++
case 409:
conflicts++
default:
t.Errorf("unexpected status: %d", status)
}
}
if created != 1 || conflicts != count-1 {
t.Fatalf("want one create and %d conflicts, got %d creates and %d conflicts", count-1, created, conflicts)
}
page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK"))
if page.Total != 1 {
t.Fatalf("duplicate users persisted: %+v", page)
}
}

View File

@@ -0,0 +1,11 @@
package model
import "errors"
// Domain errors are translated into HTTP responses only by the handler layer.
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already in use")
ErrInvalidUser = errors.New("invalid user")
ErrInvalidPage = errors.New("invalid pagination")
)

View File

@@ -0,0 +1,25 @@
package model
import "time"
// User is the business entity; it has no dependency on HTTP or a database driver.
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"`
}
// UserInput is shared by creation and full replacement (PUT).
type UserInput struct {
Name string
Email string
}
type UserPage struct {
Items []User `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}

View File

@@ -0,0 +1,126 @@
package repository
import (
"context"
"sort"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
)
// MemoryUsers stores data only for this process. Replace it with a database
// implementation of service.UserRepository when persistence is required.
type MemoryUsers struct {
mu sync.RWMutex
users map[int64]model.User
emails map[string]int64
nextID int64
}
func NewMemoryUsers() *MemoryUsers {
return &MemoryUsers{
users: make(map[int64]model.User),
emails: make(map[string]int64),
nextID: 1,
}
}
func (r *MemoryUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err := ctx.Err(); err != nil {
return model.User{}, err
}
if _, exists := r.emails[input.Email]; exists {
return model.User{}, model.ErrEmailTaken
}
now := time.Now().UTC()
user := model.User{
ID: r.nextID, Name: input.Name, Email: input.Email,
CreatedAt: now, UpdatedAt: now,
}
r.nextID++
r.users[user.ID] = user
r.emails[user.Email] = user.ID
return user, nil
}
func (r *MemoryUsers) Get(ctx context.Context, id int64) (model.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if err := ctx.Err(); err != nil {
return model.User{}, err
}
user, exists := r.users[id]
if !exists {
return model.User{}, model.ErrUserNotFound
}
return user, nil
}
func (r *MemoryUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
if page < 1 || pageSize < 1 || pageSize > 100 {
return model.UserPage{}, model.ErrInvalidPage
}
r.mu.RLock()
if err := ctx.Err(); err != nil {
r.mu.RUnlock()
return model.UserPage{}, err
}
users := make([]model.User, 0, len(r.users))
for _, user := range r.users {
users = append(users, user)
}
r.mu.RUnlock()
// A stable order is essential: Go map iteration order changes between calls.
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
result := model.UserPage{
Items: []model.User{}, Total: len(users), Page: page, PageSize: pageSize,
}
// Check the page before multiplying so even a very large page cannot overflow.
if len(users) == 0 || page-1 > (len(users)-1)/pageSize {
return result, nil
}
start := (page - 1) * pageSize
count := min(pageSize, len(users)-start)
result.Items = users[start : start+count]
return result, nil
}
func (r *MemoryUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err := ctx.Err(); err != nil {
return model.User{}, err
}
user, exists := r.users[id]
if !exists {
return model.User{}, model.ErrUserNotFound
}
if owner, exists := r.emails[input.Email]; exists && owner != id {
return model.User{}, model.ErrEmailTaken
}
delete(r.emails, user.Email)
user.Name, user.Email = input.Name, input.Email
user.UpdatedAt = time.Now().UTC()
r.users[id] = user
r.emails[user.Email] = id
return user, nil
}
func (r *MemoryUsers) Delete(ctx context.Context, id int64) error {
r.mu.Lock()
defer r.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
user, exists := r.users[id]
if !exists {
return model.ErrUserNotFound
}
delete(r.users, id)
delete(r.emails, user.Email)
return nil
}

View File

@@ -0,0 +1,40 @@
package repository_test
import (
"context"
"errors"
"testing"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
)
func TestCanceledRequestsDoNotMutateStorage(t *testing.T) {
store := repository.NewMemoryUsers()
live := context.Background()
user, err := store.Create(live, model.UserInput{Name: "Alice", Email: "alice@example.com"})
if err != nil {
t.Fatal(err)
}
canceled, cancel := context.WithCancel(live)
cancel()
if _, err := store.Create(canceled, model.UserInput{Name: "Bob", Email: "bob@example.com"}); !errors.Is(err, context.Canceled) {
t.Fatalf("create: %v", err)
}
if _, err := store.Update(canceled, user.ID, model.UserInput{Name: "Changed", Email: "changed@example.com"}); !errors.Is(err, context.Canceled) {
t.Fatalf("update: %v", err)
}
if err := store.Delete(canceled, user.ID); !errors.Is(err, context.Canceled) {
t.Fatalf("delete: %v", err)
}
if _, err := store.Get(canceled, user.ID); !errors.Is(err, context.Canceled) {
t.Fatalf("get: %v", err)
}
if _, err := store.List(canceled, 1, 20); !errors.Is(err, context.Canceled) {
t.Fatalf("list: %v", err)
}
page, err := store.List(live, 1, 20)
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0] != user {
t.Fatalf("canceled requests changed stored data: %+v, %v", page, err)
}
}

View File

@@ -0,0 +1,122 @@
package repository
import (
"context"
"database/sql"
"errors"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
"github.com/go-sql-driver/mysql"
)
// MySQLUsers is the persistent implementation of service.UserRepository.
// The schema is documented in schema.sql; migrations stay outside the framework.
type MySQLUsers struct {
db *sql.DB
}
func NewMySQLUsers(db *sql.DB) *MySQLUsers {
return &MySQLUsers{db: db}
}
func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
result, err := r.db.ExecContext(ctx, `
INSERT INTO users (name, email, created_at, updated_at)
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email)
if err != nil {
return model.User{}, mapMySQLError(err)
}
id, err := result.LastInsertId()
if err != nil {
return model.User{}, err
}
return r.Get(ctx, id)
}
func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) {
var user model.User
err := r.db.QueryRowContext(ctx, `
SELECT id, name, email, created_at, updated_at
FROM users WHERE id = ?`, id).Scan(
&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return model.User{}, model.ErrUserNotFound
}
if err != nil {
return model.User{}, err
}
return user, nil
}
func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
var total int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
return model.UserPage{}, err
}
result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize}
if total == 0 || page-1 > (total-1)/pageSize {
return result, nil
}
offset := (page - 1) * pageSize
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, email, created_at, updated_at
FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset)
if err != nil {
return model.UserPage{}, err
}
defer rows.Close()
for rows.Next() {
var user model.User
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
return model.UserPage{}, err
}
result.Items = append(result.Items, user)
}
if err := rows.Err(); err != nil {
return model.UserPage{}, err
}
return result, nil
}
func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6)
WHERE id = ?`, input.Name, input.Email, id)
if err != nil {
return model.User{}, mapMySQLError(err)
}
changed, err := result.RowsAffected()
if err != nil {
return model.User{}, err
}
if changed == 0 {
// MySQL may report zero affected rows when the replacement is identical;
// distinguish that from an ID that does not exist.
return r.Get(ctx, id)
}
return r.Get(ctx, id)
}
func (r *MySQLUsers) Delete(ctx context.Context, id int64) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
deleted, err := result.RowsAffected()
if err != nil {
return err
}
if deleted == 0 {
return model.ErrUserNotFound
}
return nil
}
func mapMySQLError(err error) error {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return model.ErrEmailTaken
}
return err
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE users (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(254) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email)
);

View File

@@ -0,0 +1,71 @@
package service
import (
"context"
"net/mail"
"strings"
"unicode/utf8"
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
)
// UserRepository describes the storage operations the business needs.
// Implementations must atomically enforce unique, normalized email addresses.
type UserRepository interface {
Create(context.Context, model.UserInput) (model.User, error)
Get(context.Context, int64) (model.User, error)
List(context.Context, int, int) (model.UserPage, error)
Update(context.Context, int64, model.UserInput) (model.User, error)
Delete(context.Context, int64) error
}
type Users struct {
repository UserRepository
}
func NewUsers(repository UserRepository) *Users {
return &Users{repository: repository}
}
func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) {
input, err := normalize(input)
if err != nil {
return model.User{}, err
}
return s.repository.Create(ctx, input)
}
func (s *Users) Get(ctx context.Context, id int64) (model.User, error) {
return s.repository.Get(ctx, id)
}
func (s *Users) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
if page < 1 || pageSize < 1 || pageSize > 100 {
return model.UserPage{}, model.ErrInvalidPage
}
return s.repository.List(ctx, page, pageSize)
}
func (s *Users) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
input, err := normalize(input)
if err != nil {
return model.User{}, err
}
return s.repository.Update(ctx, id, input)
}
func (s *Users) Delete(ctx context.Context, id int64) error {
return s.repository.Delete(ctx, id)
}
func normalize(input model.UserInput) (model.UserInput, error) {
input.Name = strings.TrimSpace(input.Name)
// This example treats the entire email address as case-insensitive.
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
address, err := mail.ParseAddress(input.Email)
if input.Name == "" || utf8.RuneCountInString(input.Name) > 100 ||
len(input.Email) > 254 || err != nil || address.Address != input.Email {
return model.UserInput{}, model.ErrInvalidUser
}
return input, nil
}

38
go.mod Normal file
View File

@@ -0,0 +1,38 @@
module git.yuntuo.hk/k2safe/go-omni
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/go-sql-driver/mysql v1.9.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

93
go.sum Normal file
View File

@@ -0,0 +1,93 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

17
health/README.md Normal file
View File

@@ -0,0 +1,17 @@
# health
`health` 提供与网络层无关的存活和就绪 Handler。业务项目只需要注册依赖探针,路由仍通过 `omni.App` 或项目自己的 transport 注册:
```go
checker, err := health.New(health.Probe{
Name: "database",
Check: db.PingContext,
})
if err != nil {
return err
}
app.GET("/health/live", checker.LivenessHandler())
app.GET("/health/ready", checker.ReadinessHandler())
```
就绪探针会保留每项检查结果,并在至少一项失败时返回 `503` 和 `NOT_READY`。

153
health/health.go Normal file
View File

@@ -0,0 +1,153 @@
// Package health provides transport-neutral liveness and readiness handlers.
package health
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
const (
StatusReady = "ready"
StatusNotReady = "not_ready"
)
// Probe is one dependency check used by readiness. Timeout is optional; a
// zero value lets the request context control the probe duration.
type Probe struct {
Name string
Check func(context.Context) error
Timeout time.Duration
}
// CheckResult is the public result of one readiness probe.
type CheckResult struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
DurationMs float64 `json:"durationMs"`
}
// Result is returned by Checker.Check and the readiness endpoint.
type Result struct {
Status string `json:"status"`
Checks []CheckResult `json:"checks,omitempty"`
}
func (r Result) Ready() bool { return r.Status == StatusReady }
// Checker owns the probes registered by a service. It can be shared by all
// requests after startup; registration is safe before or during serving.
type Checker struct {
mu sync.RWMutex
probes []Probe
}
// New creates a checker and validates its initial probes.
func New(probes ...Probe) (*Checker, error) {
checker := &Checker{}
for _, probe := range probes {
if err := checker.Add(probe); err != nil {
return nil, err
}
}
return checker, nil
}
// Add registers a named readiness probe. Names must be unique and checks must
// be non-nil so configuration errors fail during startup.
func (c *Checker) Add(probe Probe) error {
if c == nil {
return fmt.Errorf("health: nil checker")
}
probe.Name = strings.TrimSpace(probe.Name)
if probe.Name == "" {
return fmt.Errorf("health: probe name is required")
}
if probe.Check == nil {
return fmt.Errorf("health: probe %q has no check", probe.Name)
}
if probe.Timeout < 0 {
return fmt.Errorf("health: probe %q timeout must not be negative", probe.Name)
}
c.mu.Lock()
defer c.mu.Unlock()
for _, current := range c.probes {
if current.Name == probe.Name {
return fmt.Errorf("health: duplicate probe %q", probe.Name)
}
}
c.probes = append(c.probes, probe)
return nil
}
// Check runs every registered probe in registration order. A failed probe is
// represented in the result; the other probes still run so operators see all
// unavailable dependencies in one response.
func (c *Checker) Check(ctx context.Context) Result {
if ctx == nil {
ctx = context.Background()
}
c.mu.RLock()
probes := append([]Probe(nil), c.probes...)
c.mu.RUnlock()
result := Result{Status: StatusReady, Checks: make([]CheckResult, 0, len(probes))}
for _, probe := range probes {
started := time.Now()
probeContext := ctx
cancel := func() {}
if probe.Timeout > 0 {
probeContext, cancel = context.WithTimeout(ctx, probe.Timeout)
}
err := probe.Check(probeContext)
cancel()
checkResult := CheckResult{
Name: probe.Name,
Status: StatusReady,
DurationMs: time.Since(started).Seconds() * 1000,
}
if err != nil {
result.Status = StatusNotReady
checkResult.Status = StatusNotReady
checkResult.Error = err.Error()
}
result.Checks = append(result.Checks, checkResult)
}
return result
}
// LivenessHandler reports that the process is running. It does not execute
// dependency probes and is safe to use for orchestrator liveness checks.
func (c *Checker) LivenessHandler() httpx.Handler {
return func(ctx *httpx.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
}
}
// ReadinessHandler reports whether all registered dependencies are ready.
// Failure uses HTTP 503 while preserving GoOmni's response envelope.
func (c *Checker) ReadinessHandler() httpx.Handler {
return func(ctx *httpx.Context) error {
result := c.Check(ctx.Context())
status := http.StatusOK
code, message := "READY", "ready"
if !result.Ready() {
status = http.StatusServiceUnavailable
code, message = "NOT_READY", "service is not ready"
}
return ctx.JSON(status, response.Response{
Code: code,
Message: message,
Data: result,
RequestID: ctx.RequestID(),
})
}
}

73
health/health_test.go Normal file
View File

@@ -0,0 +1,73 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
func TestCheckerReadinessReportsAllProbes(t *testing.T) {
checker, err := New(
Probe{Name: "database", Check: func(context.Context) error { return nil }},
Probe{Name: "cache", Check: func(context.Context) error { return errors.New("connection refused") }},
)
if err != nil {
t.Fatal(err)
}
app := omni.NewWithRouter(nethttp.New())
app.GET("/live", checker.LivenessHandler())
app.GET("/ready", checker.ReadinessHandler())
live := httptest.NewRecorder()
app.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/live", nil))
if live.Code != http.StatusOK {
t.Fatalf("expected liveness 200, got %d", live.Code)
}
ready := httptest.NewRecorder()
app.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/ready", nil))
if ready.Code != http.StatusServiceUnavailable {
t.Fatalf("expected readiness 503, got %d", ready.Code)
}
var body struct {
Code string `json:"code"`
Data Result `json:"data"`
}
if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "connection refused" {
t.Fatalf("unexpected readiness response: %+v", body)
}
}
func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) {
checker, err := New(Probe{
Name: "slow",
Timeout: 5 * time.Millisecond,
Check: func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
},
})
if err != nil {
t.Fatal(err)
}
result := checker.Check(context.Background())
if result.Ready() || result.Checks[0].Status != StatusNotReady {
t.Fatalf("expected timeout to fail readiness: %+v", result)
}
for _, probe := range []Probe{{Check: func(context.Context) error { return nil }}, {Name: "slow", Check: func(context.Context) error { return nil }}, {Name: "negative", Timeout: -time.Second, Check: func(context.Context) error { return nil }}} {
if err := checker.Add(probe); err == nil {
t.Fatalf("expected registration error for %+v", probe)
}
}
}

227
httpx/context.go Normal file
View File

@@ -0,0 +1,227 @@
package httpx
import (
"context"
"encoding/json"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
"git.yuntuo.hk/k2safe/go-omni/validate"
)
// Handler is the function signature used by GoOmni routes.
type Handler func(*Context) error
// Middleware wraps a Handler and may enforce cross-cutting behavior.
type Middleware func(Handler) Handler
// Router is the small contract the application needs from a network layer.
// The default implementation is backed by Gin, but another router or a
// future GoOmni transport can implement this interface without changing
// business handlers.
type Router interface {
http.Handler
Handle(method, path string, handler http.Handler)
Any(path string, handler http.Handler)
Group(path string) RouterGroup
}
// RouterGroup is the route-registration portion of Router.
type RouterGroup interface {
Handle(method, path string, handler http.Handler)
Any(path string, handler http.Handler)
Group(path string) RouterGroup
}
type requestContextKey uint8
const (
routeParamsKey requestContextKey = iota + 1
routePatternKey
)
// WithRouteParams lets a router adapter expose path parameters to the
// transport-neutral Context.
func WithRouteParams(ctx context.Context, params map[string]string) context.Context {
copyOfParams := make(map[string]string, len(params))
for key, value := range params {
copyOfParams[key] = value
}
return context.WithValue(ctx, routeParamsKey, copyOfParams)
}
// RouteParams returns path parameters installed by a router adapter.
func RouteParams(ctx context.Context) map[string]string {
if ctx == nil {
return nil
}
params, _ := ctx.Value(routeParamsKey).(map[string]string)
return params
}
// WithRoutePattern records the router's matched route pattern for access logs.
func WithRoutePattern(ctx context.Context, pattern string) context.Context {
return context.WithValue(ctx, routePatternKey, pattern)
}
// RoutePattern returns the matched route pattern, when the adapter provides it.
func RoutePattern(ctx context.Context) string {
if ctx == nil {
return ""
}
pattern, _ := ctx.Value(routePatternKey).(string)
return pattern
}
// Context provides the business-facing request and response API. It contains
// only standard-library HTTP types, so it remains usable when Gin is replaced.
type Context struct {
request *http.Request
writer http.ResponseWriter
params map[string]string
values map[string]any
responded bool
status int
}
// NewContext creates a transport-neutral GoOmni context.
func NewContext(request *http.Request, writer http.ResponseWriter, params map[string]string) *Context {
if request == nil {
request = &http.Request{}
}
return &Context{
request: request,
writer: writer,
params: params,
values: make(map[string]any),
}
}
func (c *Context) Request() *http.Request { return c.request }
// Context returns the standard request context. It carries GoOmni
// correlation values such as the request ID and can be passed to services.
func (c *Context) Context() context.Context {
if c.request == nil {
return context.Background()
}
return c.request.Context()
}
// Raw returns the response writer supplied by the active network adapter.
// Prefer the transport-neutral methods on Context for portable handlers.
func (c *Context) Raw() http.ResponseWriter { return c.writer }
func (c *Context) Param(name string) string { return c.params[name] }
func (c *Context) Query(name string) string {
if c.request == nil {
return ""
}
return c.request.URL.Query().Get(name)
}
func (c *Context) Header(name string) string {
if c.request == nil {
return ""
}
return c.request.Header.Get(name)
}
func (c *Context) RequestID() string { return logx.RequestID(c.Context()) }
func (c *Context) Set(key string, value any) { c.values[key] = value }
func (c *Context) Get(key string) (any, bool) {
value, ok := c.values[key]
return value, ok
}
// WithContext replaces the standard request context while preserving the
// request and its headers.
func (c *Context) WithContext(ctx context.Context) {
if c.request != nil {
c.request = c.request.WithContext(ctx)
}
}
// SetResponseHeader sets a header on the underlying response.
func (c *Context) SetResponseHeader(name, value string) {
if c.writer != nil {
c.writer.Header().Set(name, value)
}
}
func (c *Context) BindJSON(value any) error { return validate.JSON(c.request, value) }
func (c *Context) BindQuery(value any) error { return validate.Query(c.request, value) }
func (c *Context) BindURI(value any) error { return validate.URI(c.params, value) }
// JSON is an escape hatch for responses that do not use the standard envelope.
func (c *Context) JSON(status int, value any) error {
if c.writer == nil {
return apierrors.Internal(nil)
}
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.writer.WriteHeader(status)
if err := json.NewEncoder(c.writer).Encode(value); err != nil {
return apierrors.Internal(err)
}
c.responded = true
c.status = status
return nil
}
func (c *Context) OK(data any) error { return c.respond(http.StatusOK, "OK", "success", data) }
func (c *Context) Created(data any) error {
return c.respond(http.StatusCreated, "CREATED", "created", data)
}
func (c *Context) Accepted(data any) error {
return c.respond(http.StatusAccepted, "ACCEPTED", "accepted", data)
}
func (c *Context) NoContent() error {
c.writer.WriteHeader(http.StatusNoContent)
c.responded = true
c.status = http.StatusNoContent
return nil
}
func (c *Context) respond(status int, code, message string, data any) error {
return c.JSON(status, response.Response{
Code: code,
Message: message,
Data: data,
RequestID: c.RequestID(),
})
}
// Status returns the response status observed by Context.
func (c *Context) Status() int {
if c.status == 0 {
return http.StatusOK
}
return c.status
}
// Responded reports whether this handler has already written a response.
func (c *Context) Responded() bool { return c.responded }
// WriteError writes a stable public error response. Internal causes are not
// serialized and a partially written response is preserved.
func (c *Context) WriteError(err error) {
if c.responded {
return
}
apiErr := apierrors.From(err)
_ = c.JSON(apiErr.Status, response.Response{
Code: apiErr.Code,
Message: apiErr.Message,
RequestID: c.RequestID(),
})
}

31
logx/README.md Normal file
View File

@@ -0,0 +1,31 @@
# logx
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖 Gin:
```go
import "git.yuntuo.hk/k2safe/go-omni/logx"
func (s *UserService) Create(ctx context.Context, input CreateInput) error {
logx.InfoContext(ctx, "user.create.start", "email", input.Email)
// ...
return nil
}
```
应用启动时可以替换默认输出:
```go
closeLog, err := logx.Setup(logx.Config{
Service: "billing-api",
Environment: "production",
Level: "info",
Format: "json",
File: "./var/log/billing-api.log",
})
if err != nil {
return err
}
defer closeLog()
```
HTTP 请求的 `X-Request-ID` 会被放进标准 `context.Context`,之后的 `InfoContext`、`ErrorContext` 会自动带上 `requestId`。后续可以在不改变业务调用方式的前提下增加文件轮转、采样和 OpenTelemetry 导出。

327
logx/logx.go Normal file
View File

@@ -0,0 +1,327 @@
// Package logx provides the application logger used by GoOmni and business
// packages. Its public API is deliberately independent of Gin so the same
// logger can be used from HTTP handlers, jobs and command line programs.
package logx
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"sync"
"sync/atomic"
)
// Config controls a logger created by New or Setup.
//
// Writer is useful for tests and embedding. When it is nil, Output selects a
// standard stream. File takes precedence over Output and appends to the named
// file.
type Config struct {
Level string
Format string
Output string
File string
Service string
Environment string
AddSource bool
Writer io.Writer
}
// Default returns the default production-friendly logger configuration.
func Default() Config {
return Config{
Level: "info",
Format: "json",
Output: "stderr",
}
}
// Logger is a structured logger. Child loggers returned by With and WithGroup
// share the root logger's lifecycle and must not be closed independently.
type Logger struct {
logger *slog.Logger
closer *closeState
}
type closeState struct {
once sync.Once
fn func() error
err error
}
var (
defaultLogger atomic.Pointer[Logger]
setupMu sync.Mutex
)
func init() {
// Keep package-level logging useful without requiring every service to
// repeat setup. Applications can replace this logger with Setup.
defaultLogger.Store(&Logger{logger: slog.New(slog.NewJSONHandler(os.Stderr, nil))})
}
// New creates an independent logger. The caller owns its lifecycle and should
// call Close when the process is shutting down.
func New(cfg Config) (*Logger, error) {
cfg = normalizeConfig(cfg)
level, err := parseLevel(cfg.Level)
if err != nil {
return nil, err
}
writer, closeWriter, err := openWriter(cfg)
if err != nil {
return nil, err
}
opts := &slog.HandlerOptions{Level: level, AddSource: cfg.AddSource}
var handler slog.Handler
if cfg.Format == "text" {
handler = slog.NewTextHandler(writer, opts)
} else {
handler = slog.NewJSONHandler(writer, opts)
}
logger := slog.New(handler)
if cfg.Service != "" {
logger = logger.With("service", cfg.Service)
}
if cfg.Environment != "" {
logger = logger.With("environment", cfg.Environment)
}
var closer *closeState
if closeWriter != nil {
closer = &closeState{fn: closeWriter}
}
return &Logger{logger: logger, closer: closer}, nil
}
// Setup replaces the process-wide logger used by the package-level helpers.
// The returned function closes the newly configured logger. Calling Close is
// usually more convenient for application shutdown.
func Setup(cfg Config) (func() error, error) {
logger, err := New(cfg)
if err != nil {
return nil, err
}
setupMu.Lock()
previous := defaultLogger.Swap(logger)
setupMu.Unlock()
if previous != nil {
_ = previous.Close()
}
return logger.Close, nil
}
// SetDefault installs an existing logger as the package-wide logger.
func SetDefault(logger *Logger) {
if logger == nil {
logger = &Logger{logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}
}
setupMu.Lock()
previous := defaultLogger.Swap(logger)
setupMu.Unlock()
if previous != nil && previous != logger {
_ = previous.Close()
}
}
// Close closes the current process-wide logger and disables further output.
func Close() error {
discard := &Logger{logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}
setupMu.Lock()
previous := defaultLogger.Swap(discard)
setupMu.Unlock()
if previous == nil {
return nil
}
return previous.Close()
}
// With returns a child logger carrying stable attributes.
func With(args ...any) *Logger { return defaultLogger.Load().With(args...) }
// WithGroup returns a child logger writing attributes under a group.
func WithGroup(name string) *Logger { return defaultLogger.Load().WithGroup(name) }
func (l *Logger) With(args ...any) *Logger {
if l == nil || l.logger == nil {
return defaultLogger.Load().With(args...)
}
return &Logger{logger: l.logger.With(args...), closer: l.closer}
}
func (l *Logger) WithGroup(name string) *Logger {
if l == nil || l.logger == nil {
return defaultLogger.Load().WithGroup(name)
}
return &Logger{logger: l.logger.WithGroup(name), closer: l.closer}
}
// Close releases resources owned by the root logger. It is safe to call more
// than once and is a no-op for loggers writing to standard streams.
func (l *Logger) Close() error {
if l == nil || l.closer == nil {
return nil
}
l.closer.once.Do(func() { l.closer.err = l.closer.fn() })
return l.closer.err
}
func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) {
ctx = normalizeContext(ctx)
l.logger.Log(ctx, slog.LevelDebug, msg, contextAttrs(ctx, args)...)
}
func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) {
ctx = normalizeContext(ctx)
l.logger.Log(ctx, slog.LevelInfo, msg, contextAttrs(ctx, args)...)
}
func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) {
ctx = normalizeContext(ctx)
l.logger.Log(ctx, slog.LevelWarn, msg, contextAttrs(ctx, args)...)
}
func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) {
ctx = normalizeContext(ctx)
l.logger.Log(ctx, slog.LevelError, msg, contextAttrs(ctx, args)...)
}
func (l *Logger) Debug(msg string, args ...any) { l.DebugContext(context.Background(), msg, args...) }
func (l *Logger) Info(msg string, args ...any) { l.InfoContext(context.Background(), msg, args...) }
func (l *Logger) Warn(msg string, args ...any) { l.WarnContext(context.Background(), msg, args...) }
func (l *Logger) Error(msg string, args ...any) { l.ErrorContext(context.Background(), msg, args...) }
func DebugContext(ctx context.Context, msg string, args ...any) {
defaultLogger.Load().DebugContext(ctx, msg, args...)
}
func InfoContext(ctx context.Context, msg string, args ...any) {
defaultLogger.Load().InfoContext(ctx, msg, args...)
}
func WarnContext(ctx context.Context, msg string, args ...any) {
defaultLogger.Load().WarnContext(ctx, msg, args...)
}
func ErrorContext(ctx context.Context, msg string, args ...any) {
defaultLogger.Load().ErrorContext(ctx, msg, args...)
}
func Debug(msg string, args ...any) { DebugContext(context.Background(), msg, args...) }
func Info(msg string, args ...any) { InfoContext(context.Background(), msg, args...) }
func Warn(msg string, args ...any) { WarnContext(context.Background(), msg, args...) }
func Error(msg string, args ...any) { ErrorContext(context.Background(), msg, args...) }
type contextKey uint8
const (
requestIDKey contextKey = iota + 1
traceIDKey
)
// WithRequestID adds a request correlation ID to a context.
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
// RequestID returns the request correlation ID stored in ctx.
func RequestID(ctx context.Context) string {
if ctx == nil {
return ""
}
value, _ := ctx.Value(requestIDKey).(string)
return value
}
// WithTraceID adds a trace correlation ID to a context.
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceIDKey, traceID)
}
// TraceID returns the trace correlation ID stored in ctx.
func TraceID(ctx context.Context) string {
if ctx == nil {
return ""
}
value, _ := ctx.Value(traceIDKey).(string)
return value
}
func contextAttrs(ctx context.Context, args []any) []any {
ctx = normalizeContext(ctx)
attrs := make([]any, 0, len(args)+4)
if requestID := RequestID(ctx); requestID != "" {
attrs = append(attrs, "requestId", requestID)
}
if traceID := TraceID(ctx); traceID != "" {
attrs = append(attrs, "traceId", traceID)
}
return append(attrs, args...)
}
func normalizeContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func normalizeConfig(cfg Config) Config {
if strings.TrimSpace(cfg.Level) == "" {
cfg.Level = "info"
}
cfg.Level = strings.ToLower(strings.TrimSpace(cfg.Level))
if strings.TrimSpace(cfg.Format) == "" {
cfg.Format = "json"
}
cfg.Format = strings.ToLower(strings.TrimSpace(cfg.Format))
if strings.TrimSpace(cfg.Output) == "" {
cfg.Output = "stderr"
}
cfg.Output = strings.ToLower(strings.TrimSpace(cfg.Output))
return cfg
}
func parseLevel(value string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("logx: unsupported level %q", value)
}
}
func openWriter(cfg Config) (io.Writer, func() error, error) {
if cfg.Writer != nil {
return cfg.Writer, nil, nil
}
if cfg.File != "" {
file, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, nil, err
}
return file, file.Close, nil
}
switch cfg.Output {
case "stdout":
return os.Stdout, nil, nil
case "stderr":
return os.Stderr, nil, nil
case "discard", "none":
return io.Discard, nil, nil
default:
return nil, nil, errors.New("logx: output must be stdout, stderr, discard, or a file must be set")
}
}

37
logx/logx_test.go Normal file
View File

@@ -0,0 +1,37 @@
package logx_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestSetupAddsCorrelationFields(t *testing.T) {
var output bytes.Buffer
_, err := logx.Setup(logx.Config{Level: "info", Format: "json", Writer: &output})
if err != nil {
t.Fatalf("setup logger: %v", err)
}
t.Cleanup(func() { _ = logx.Close() })
ctx := logx.WithRequestID(context.Background(), "request-123")
ctx = logx.WithTraceID(ctx, "trace-456")
logx.InfoContext(ctx, "user.created", "userId", 42)
var record map[string]any
if err := json.Unmarshal(output.Bytes(), &record); err != nil {
t.Fatalf("decode log record: %v; output=%q", err, output.String())
}
if record["msg"] != "user.created" || record["requestId"] != "request-123" || record["traceId"] != "trace-456" {
t.Fatalf("unexpected log record: %#v", record)
}
}
func TestNewRejectsUnknownLevel(t *testing.T) {
if _, err := logx.New(logx.Config{Level: "verbose"}); err == nil {
t.Fatal("expected unsupported level error")
}
}

82
middleware/middleware.go Normal file
View File

@@ -0,0 +1,82 @@
package middleware
import (
"fmt"
"net/http"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
// RequestIDKey is the value key used by the request ID middleware.
const RequestIDKey = "go-omni.requestId"
// RequestID reads or creates a request ID and returns it in the response
// header. It is a transport-neutral GoOmni middleware.
func RequestID(header string) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
requestID := ctx.Header(header)
if requestID == "" {
requestID = newRequestID()
}
ctx.Set(RequestIDKey, requestID)
ctx.SetResponseHeader(header, requestID)
ctx.WithContext(logx.WithRequestID(ctx.Context(), requestID))
return next(ctx)
}
}
}
// Recovery converts panics from handlers and middleware into the standard
// internal error. The network adapter does not need a Gin-specific recovery
// hook for this behavior.
func Recovery() httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = apierrors.Internal(fmt.Errorf("panic recovered: %v", recovered))
}
}()
return next(ctx)
}
}
}
// AccessLog records one structured event after each request. It deliberately
// lives in GoOmni instead of using a router-specific logger so business
// applications get the same log format with every transport adapter.
func AccessLog() httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
started := time.Now()
err := next(ctx)
status := ctx.Status()
if err != nil {
status = apierrors.From(err).Status
}
logx.InfoContext(ctx.Context(), "http.request",
"method", ctx.Request().Method,
"path", ctx.Request().URL.Path,
"route", httpx.RoutePattern(ctx.Context()),
"status", status,
"durationMs", time.Since(started).Seconds()*1000,
"clientIp", clientIP(ctx.Request()),
)
return err
}
}
}
func clientIP(request *http.Request) string {
if request == nil {
return ""
}
if value := request.Header.Get("X-Forwarded-For"); value != "" {
return value
}
return request.RemoteAddr
}

16
middleware/request_id.go Normal file
View File

@@ -0,0 +1,16 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
func newRequestID() string {
value := make([]byte, 16)
if _, err := rand.Read(value); err == nil {
return hex.EncodeToString(value)
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}

67
omni.go Normal file
View File

@@ -0,0 +1,67 @@
package omni
import (
"time"
"git.yuntuo.hk/k2safe/go-omni/app"
"git.yuntuo.hk/k2safe/go-omni/auth"
"git.yuntuo.hk/k2safe/go-omni/config"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
type App = app.App
type RouterGroup = app.RouterGroup
type Principal = auth.Principal
type Authenticator = auth.Authenticator
type Context = httpx.Context
type Handler = httpx.Handler
type Middleware = httpx.Middleware
type Router = httpx.Router
type HTTPRouterGroup = httpx.RouterGroup
type APIError = apierrors.APIError
type Config = config.Config
type Option = config.Option
type Response = response.Response
type LogConfig = logx.Config
type Logger = logx.Logger
func New(options ...Option) *App { return app.New(options...) }
func NewWithRouter(router Router, options ...Option) *App {
return app.NewWithRouter(router, options...)
}
func WithMode(mode string) Option { return config.WithMode(mode) }
func WithRequestIDHeader(header string) Option { return config.WithRequestIDHeader(header) }
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithConfig(settings Config) Option { return config.WithConfig(settings) }
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }
func WithShutdownTimeout(timeout time.Duration) Option { return config.WithShutdownTimeout(timeout) }
func LoadConfig(prefix string) (Config, error) { return config.LoadFromEnv(prefix) }
func SetupLogger(cfg LogConfig) (func() error, error) { return logx.Setup(cfg) }
func NewError(status int, code, message string) *APIError {
return apierrors.New(status, code, message)
}
func BadRequest(code, message string) *APIError { return apierrors.BadRequest(code, message) }
func Unauthorized(code, message string) *APIError { return apierrors.Unauthorized(code, message) }
func Forbidden(code, message string) *APIError { return apierrors.Forbidden(code, message) }
func NotFound(code, message string) *APIError { return apierrors.NotFound(code, message) }
func Internal(cause error) *APIError { return apierrors.Internal(cause) }
func PrincipalFromContext(ctx *Context) (Principal, bool) { return auth.FromContext(ctx) }

9
response/response.go Normal file
View File

@@ -0,0 +1,9 @@
package response
// Response is the standard JSON envelope returned by GoOmni helpers.
type Response struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
RequestID string `json:"requestId,omitempty"`
}

69
router_test.go Normal file
View File

@@ -0,0 +1,69 @@
package omni
import (
"net/http"
"net/http/httptest"
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
func TestNewWithRouterKeepsHandlersTransportNeutral(t *testing.T) {
router := &testRouter{}
app := NewWithRouter(router)
app.GET("/health", func(ctx *Context) error {
if ctx.RequestID() == "" {
t.Fatal("expected request ID from core middleware")
}
return ctx.OK(map[string]string{"status": "ok"})
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
app.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
}
type testRouter struct {
handlers map[string]http.Handler
}
func (r *testRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if handler := r.handlers[request.Method+" "+request.URL.Path]; handler != nil {
handler.ServeHTTP(writer, request)
return
}
http.NotFound(writer, request)
}
func (r *testRouter) Handle(method, path string, handler http.Handler) {
if r.handlers == nil {
r.handlers = make(map[string]http.Handler)
}
r.handlers[method+" "+path] = handler
}
func (r *testRouter) Any(path string, handler http.Handler) { r.Handle(http.MethodGet, path, handler) }
func (r *testRouter) Group(path string) httpx.RouterGroup {
return &testRouterGroup{router: r, prefix: path}
}
type testRouterGroup struct {
router *testRouter
prefix string
}
func (g *testRouterGroup) Handle(method, path string, handler http.Handler) {
g.router.Handle(method, g.prefix+path, handler)
}
func (g *testRouterGroup) Any(path string, handler http.Handler) {
g.Handle(http.MethodGet, path, handler)
}
func (g *testRouterGroup) Group(path string) httpx.RouterGroup {
return &testRouterGroup{router: g.router, prefix: g.prefix + path}
}

22
transport/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Transport adapters
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
使用标准库适配器:
```go
import (
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
application := omni.NewWithRouter(netrouter.New())
```
后续自研网络层时,实现以下能力即可接入现有 `app`、中间件和业务 Handler:
- `httpx.Router`:注册路由、分组并实现 `http.Handler`。
- `httpx.RouterGroup`:注册分组内路由。
- 适配器为请求 context 注入 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern`。
业务层不应该导入具体 transport 包;它们只是网络层实现。

70
transport/gin/router.go Normal file
View File

@@ -0,0 +1,70 @@
// Package gin adapts Gin to GoOmni's transport-neutral router contract.
// Business packages should import httpx and app, not this package.
package gin
import (
"net/http"
"git.yuntuo.hk/k2safe/go-omni/httpx"
ginlib "github.com/gin-gonic/gin"
)
// Router is the default Gin-backed GoOmni router.
type Router struct {
engine *ginlib.Engine
}
// New creates a Gin router without Gin's default logger or recovery middleware.
func New(mode string) *Router {
if mode != "" {
ginlib.SetMode(mode)
}
return &Router{engine: ginlib.New()}
}
// NewWithEngine wraps an existing Gin engine for incremental migration.
func NewWithEngine(engine *ginlib.Engine) *Router { return &Router{engine: engine} }
// Engine exposes the underlying Gin engine to adapter-specific integrations.
func (r *Router) Engine() *ginlib.Engine { return r.engine }
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
r.engine.ServeHTTP(writer, request)
}
func (r *Router) Handle(method, path string, handler http.Handler) {
r.engine.Handle(method, path, r.adapt(handler))
}
func (r *Router) Any(path string, handler http.Handler) { r.engine.Any(path, r.adapt(handler)) }
func (r *Router) Group(path string) httpx.RouterGroup {
return &group{group: r.engine.Group(path)}
}
type group struct{ group *ginlib.RouterGroup }
func (g *group) Handle(method, path string, handler http.Handler) {
g.group.Handle(method, path, adapt(handler))
}
func (g *group) Any(path string, handler http.Handler) { g.group.Any(path, adapt(handler)) }
func (g *group) Group(path string) httpx.RouterGroup {
return &group{group: g.group.Group(path)}
}
func (r *Router) adapt(handler http.Handler) ginlib.HandlerFunc { return adapt(handler) }
func adapt(handler http.Handler) ginlib.HandlerFunc {
return func(ctx *ginlib.Context) {
params := make(map[string]string, len(ctx.Params))
for _, param := range ctx.Params {
params[param.Key] = param.Value
}
requestContext := httpx.WithRouteParams(ctx.Request.Context(), params)
requestContext = httpx.WithRoutePattern(requestContext, ctx.FullPath())
request := ctx.Request.WithContext(requestContext)
handler.ServeHTTP(ctx.Writer, request)
}
}

218
transport/nethttp/router.go Normal file
View File

@@ -0,0 +1,218 @@
// Package nethttp provides a small standard-library router for GoOmni.
// It is intentionally independent of Gin and serves as the reference for a
// future project-owned network layer.
package nethttp
import (
"net/http"
"sort"
"strings"
"sync"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
// Router implements httpx.Router with net/http only. Register routes before
// serving requests.
type Router struct {
mu sync.RWMutex
routes []route
}
type route struct {
method string
pattern string
segments []segment
handler http.Handler
score int
}
type segment struct {
literal string
name string
wildcard bool
}
// New creates an empty standard-library router.
func New() *Router { return &Router{} }
// ServeHTTP dispatches a request and injects route params and the matched
// pattern into its context for the transport-neutral handler chain.
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
r.mu.RLock()
routes := append([]route(nil), r.routes...)
r.mu.RUnlock()
var selected *route
var selectedParams map[string]string
pathMatched := false
allow := make(map[string]struct{})
for index := range routes {
candidate := &routes[index]
params, matched := match(candidate.segments, request.URL.Path)
if !matched {
continue
}
pathMatched = true
if candidate.method != "*" && candidate.method != request.Method {
allow[candidate.method] = struct{}{}
continue
}
if selected == nil || candidate.score > selected.score {
selected = candidate
selectedParams = params
}
}
if selected == nil {
if pathMatched {
methods := make([]string, 0, len(allow))
for method := range allow {
methods = append(methods, method)
}
sort.Strings(methods)
writer.Header().Set("Allow", strings.Join(methods, ", "))
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
http.NotFound(writer, request)
return
}
requestContext := httpx.WithRouteParams(request.Context(), selectedParams)
requestContext = httpx.WithRoutePattern(requestContext, selected.pattern)
selected.handler.ServeHTTP(writer, request.WithContext(requestContext))
}
func (r *Router) Handle(method, path string, handler http.Handler) {
if handler == nil {
panic("go-omni/nethttp: nil route handler")
}
segments := parsePattern(path)
r.mu.Lock()
r.routes = append(r.routes, route{
method: method,
pattern: cleanPattern(path),
segments: segments,
handler: handler,
score: routeScore(method, segments),
})
r.mu.Unlock()
}
func (r *Router) Any(path string, handler http.Handler) { r.Handle("*", path, handler) }
func (r *Router) Group(path string) httpx.RouterGroup {
return &group{router: r, prefix: cleanPattern(path)}
}
type group struct {
router *Router
prefix string
}
func (g *group) Handle(method, path string, handler http.Handler) {
g.router.Handle(method, joinPattern(g.prefix, path), handler)
}
func (g *group) Any(path string, handler http.Handler) { g.Handle("*", path, handler) }
func (g *group) Group(path string) httpx.RouterGroup {
return &group{router: g.router, prefix: joinPattern(g.prefix, path)}
}
func cleanPattern(pattern string) string {
if pattern == "" {
return "/"
}
if !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
pattern = strings.TrimRight(pattern, "/")
if pattern == "" {
return "/"
}
return pattern
}
func joinPattern(prefix, path string) string {
if prefix == "" || prefix == "/" {
return cleanPattern(path)
}
if path == "" || path == "/" {
return cleanPattern(prefix)
}
return cleanPattern(strings.TrimRight(prefix, "/") + "/" + strings.Trim(path, "/"))
}
func parsePattern(pattern string) []segment {
pattern = cleanPattern(pattern)
if pattern == "/" {
return nil
}
parts := strings.Split(strings.Trim(pattern, "/"), "/")
segments := make([]segment, 0, len(parts))
for _, part := range parts {
switch {
case strings.HasPrefix(part, ":") && len(part) > 1:
segments = append(segments, segment{name: part[1:]})
case strings.HasPrefix(part, "*") && len(part) > 1:
segments = append(segments, segment{name: part[1:], wildcard: true})
default:
segments = append(segments, segment{literal: part})
}
}
return segments
}
func match(segments []segment, path string) (map[string]string, bool) {
parts := splitPath(path)
params := make(map[string]string)
partIndex := 0
for segmentIndex, current := range segments {
if current.wildcard {
params[current.name] = strings.Join(parts[partIndex:], "/")
return params, true
}
if partIndex >= len(parts) {
return nil, false
}
if current.name != "" {
params[current.name] = parts[partIndex]
} else if current.literal != parts[partIndex] {
return nil, false
}
partIndex++
if segmentIndex == len(segments)-1 && partIndex != len(parts) {
return nil, false
}
}
if partIndex != len(parts) {
return nil, false
}
return params, true
}
func splitPath(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
return nil
}
return strings.Split(path, "/")
}
func routeScore(method string, segments []segment) int {
score := len(segments)
if method != "*" {
score += 1000
}
for _, current := range segments {
if current.literal != "" {
score += 10
}
if current.wildcard {
score -= 5
}
}
return score
}

View File

@@ -0,0 +1,57 @@
package nethttp_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
func TestRouterWorksWithoutGin(t *testing.T) {
router := netrouter.New()
app := omni.NewWithRouter(router)
app.GET("/users/:id", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"id": ctx.Param("id")})
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/users/42", nil)
app.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
}
var body struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data["id"] != "42" {
t.Fatalf("unexpected route parameter: %#v", body.Data)
}
}
func TestRouterPrefersStaticRoutesAndReturnsMethodNotAllowed(t *testing.T) {
router := netrouter.New()
router.Handle(http.MethodGet, "/users/:id", http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusAccepted)
}))
router.Handle(http.MethodGet, "/users/me", http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusCreated)
}))
static := httptest.NewRecorder()
router.ServeHTTP(static, httptest.NewRequest(http.MethodGet, "/users/me", nil))
if static.Code != http.StatusCreated {
t.Fatalf("static route did not win, got %d", static.Code)
}
method := httptest.NewRecorder()
router.ServeHTTP(method, httptest.NewRequest(http.MethodPost, "/users/me", nil))
if method.Code != http.StatusMethodNotAllowed || method.Header().Get("Allow") != http.MethodGet {
t.Fatalf("unexpected method response: status=%d allow=%q", method.Code, method.Header().Get("Allow"))
}
}

159
validate/validate.go Normal file
View File

@@ -0,0 +1,159 @@
package validate
import (
"encoding/json"
"net/http"
"reflect"
"strconv"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"github.com/go-playground/validator/v10"
)
var structValidator = newValidator()
func newValidator() *validator.Validate {
instance := validator.New()
instance.SetTagName("binding")
return instance
}
// JSON binds and validates a JSON request body using standard-library HTTP
// types. It intentionally does not depend on Gin.
func JSON(request *http.Request, value any) error {
if request == nil || request.Body == nil {
return apierrors.BadRequest("INVALID_BODY", "request body is invalid")
}
if err := json.NewDecoder(request.Body).Decode(value); err != nil {
return apierrors.BadRequest("INVALID_BODY", "request body is invalid").WithCause(err)
}
return validateValue(value, "INVALID_BODY", "request body is invalid")
}
// Query binds query parameters using form tags and validates the result.
func Query(request *http.Request, value any) error {
if request == nil {
return apierrors.BadRequest("INVALID_QUERY", "query parameters are invalid")
}
if err := bindValues(request.URL.Query(), value, "form"); err != nil {
return apierrors.BadRequest("INVALID_QUERY", "query parameters are invalid").WithCause(err)
}
return validateValue(value, "INVALID_QUERY", "query parameters are invalid")
}
// URI binds path parameters supplied by a router adapter and validates them.
func URI(params map[string]string, value any) error {
if err := bindValues(singleValues(params), value, "uri"); err != nil {
return apierrors.BadRequest("INVALID_PATH", "path parameters are invalid").WithCause(err)
}
return validateValue(value, "INVALID_PATH", "path parameters are invalid")
}
func validateValue(value any, code, message string) error {
if err := structValidator.Struct(value); err != nil {
return apierrors.BadRequest(code, message).WithCause(err)
}
return nil
}
func bindValues(values map[string][]string, target any, tagName string) error {
value := reflect.ValueOf(target)
if value.Kind() != reflect.Pointer || value.IsNil() || value.Elem().Kind() != reflect.Struct {
return &bindError{"target must be a non-nil pointer to a struct"}
}
return bindStruct(values, value.Elem(), tagName)
}
func bindStruct(values map[string][]string, target reflect.Value, tagName string) error {
typeOfTarget := target.Type()
for index := 0; index < target.NumField(); index++ {
field := target.Field(index)
if !field.CanSet() {
continue
}
fieldInfo := typeOfTarget.Field(index)
tag := fieldInfo.Tag.Get(tagName)
if tag == "-" {
continue
}
name, defaultValue := parseTag(tag, fieldInfo.Name)
items, ok := values[name]
if !ok || len(items) == 0 || items[0] == "" {
if defaultValue != "" {
items = []string{defaultValue}
} else {
continue
}
}
if err := setValue(field, items[0]); err != nil {
return &bindError{fieldInfo.Name + ": " + err.Error()}
}
}
return nil
}
func parseTag(tag, fallback string) (string, string) {
if tag == "" {
return fallback, ""
}
parts := strings.Split(tag, ",")
name := parts[0]
if name == "" {
name = fallback
}
var defaultValue string
for _, part := range parts[1:] {
if strings.HasPrefix(part, "default=") {
defaultValue = strings.TrimPrefix(part, "default=")
}
}
return name, defaultValue
}
func setValue(target reflect.Value, value string) error {
switch target.Kind() {
case reflect.String:
target.SetString(value)
case reflect.Bool:
parsed, err := strconv.ParseBool(value)
if err != nil {
return err
}
target.SetBool(parsed)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
parsed, err := strconv.ParseInt(value, 10, target.Type().Bits())
if err != nil {
return err
}
target.SetInt(parsed)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
parsed, err := strconv.ParseUint(value, 10, target.Type().Bits())
if err != nil {
return err
}
target.SetUint(parsed)
case reflect.Float32, reflect.Float64:
parsed, err := strconv.ParseFloat(value, target.Type().Bits())
if err != nil {
return err
}
target.SetFloat(parsed)
default:
return &bindError{"unsupported field type " + target.Type().String()}
}
return nil
}
func singleValues(values map[string]string) map[string][]string {
result := make(map[string][]string, len(values))
for key, value := range values {
result[key] = []string{value}
}
return result
}
type bindError struct{ message string }
func (e *bindError) Error() string { return e.message }