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

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