feat: bootstrap reusable GoOmni framework
This commit is contained in:
22
transport/README.md
Normal file
22
transport/README.md
Normal 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
70
transport/gin/router.go
Normal 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
218
transport/nethttp/router.go
Normal 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
|
||||
}
|
||||
57
transport/nethttp/router_test.go
Normal file
57
transport/nethttp/router_test.go
Normal 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user