This is an automated email from the ASF dual-hosted git repository.
xuetaoli pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new c618a5ac feat: update route mechanism (#777)
c618a5ac is described below
commit c618a5acdbad09397d342e7f95f1188865bc951f
Author: Xuetao Li <[email protected]>
AuthorDate: Sun Dec 7 20:39:58 2025 +0800
feat: update route mechanism (#777)
* feat(router): enhance routing capabilities with snapshot management and
header matching
---
pkg/common/constant/http.go | 3 +-
pkg/common/http/manager_test.go | 52 +-
pkg/common/router/{ => mock}/router.go | 7 +-
pkg/common/router/router.go | 241 +++++++---
pkg/common/router/router_bench_test.go | 504 +++++++++++++++++++
pkg/common/router/router_test.go | 802 +++++++++++++++++++++++++++++--
pkg/common/util/stringutil/stringutil.go | 27 +-
pkg/model/router.go | 9 +-
pkg/model/router_snapshot.go | 181 +++++++
9 files changed, 1693 insertions(+), 133 deletions(-)
diff --git a/pkg/common/constant/http.go b/pkg/common/constant/http.go
index 2156ab90..1ec456b8 100644
--- a/pkg/common/constant/http.go
+++ b/pkg/common/constant/http.go
@@ -57,7 +57,8 @@ const (
HeaderValueAuthorization = "Authorization"
- HeaderValueAll = "*"
+ HeaderValueAll = "*"
+ HeaderValueAllLevels = "**"
PathSlash = "/"
ProtocolSlash = "://"
diff --git a/pkg/common/http/manager_test.go b/pkg/common/http/manager_test.go
index 33080f38..69cb4458 100644
--- a/pkg/common/http/manager_test.go
+++ b/pkg/common/http/manager_test.go
@@ -39,7 +39,6 @@ import (
"github.com/apache/dubbo-go-pixiu/pkg/common/constant"
"github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
commonmock "github.com/apache/dubbo-go-pixiu/pkg/common/mock"
- "github.com/apache/dubbo-go-pixiu/pkg/common/router/trie"
contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
"github.com/apache/dubbo-go-pixiu/pkg/context/mock"
"github.com/apache/dubbo-go-pixiu/pkg/logger"
@@ -121,10 +120,19 @@ func TestCreateHttpConnectionManager(t *testing.T) {
hcmc := model.HttpConnectionManagerConfig{
RouteConfig: model.RouteConfiguration{
- RouteTrie: trie.NewTrieWithDefault("POST/api/v1/**",
model.RouteAction{
- Cluster: "test_dubbo",
- ClusterNotFoundResponseCode: 505,
- }),
+ Routes: []*model.Router{
+ {
+ ID: "1",
+ Match: model.RouterMatch{
+ Path: "/api/v1/**",
+ Methods: []string{"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
"test_dubbo",
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ },
Dynamic: false,
},
HTTPFilters: []*model.HTTPFilter{
@@ -157,9 +165,20 @@ func TestCreateHttpConnectionManager(t *testing.T) {
func TestStreamingResponse(t *testing.T) {
hcmc := model.HttpConnectionManagerConfig{
RouteConfig: model.RouteConfiguration{
- RouteTrie: trie.NewTrieWithDefault("GET/api/sse",
model.RouteAction{
- Cluster: "mock_stream_cluster",
- }),
+ Routes: []*model.Router{
+ {
+ ID: "1",
+ Match: model.RouterMatch{
+ Path: "/api/sse",
+ Methods: []string{"GET"},
+ },
+ Route: model.RouteAction{
+ Cluster:
"mock_stream_cluster",
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ },
+ Dynamic: false,
},
HTTPFilters: []*model.HTTPFilter{
{
@@ -360,9 +379,20 @@ func TestStreamableHTTPResponse(t *testing.T) {
func testStreamableResponse(t *testing.T, contentType string) {
hcmc := model.HttpConnectionManagerConfig{
RouteConfig: model.RouteConfiguration{
- RouteTrie: trie.NewTrieWithDefault("GET/api/stream",
model.RouteAction{
- Cluster: "mock_stream_cluster",
- }),
+ Routes: []*model.Router{
+ {
+ ID: "1",
+ Match: model.RouterMatch{
+ Path: "/api/stream",
+ Methods: []string{"GET"},
+ },
+ Route: model.RouteAction{
+ Cluster:
"mock_stream_cluster",
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ },
+ Dynamic: false,
},
HTTPFilters: []*model.HTTPFilter{
{
diff --git a/pkg/common/router/router.go b/pkg/common/router/mock/router.go
similarity index 94%
copy from pkg/common/router/router.go
copy to pkg/common/router/mock/router.go
index cd175eb2..317f37e4 100644
--- a/pkg/common/router/router.go
+++ b/pkg/common/router/mock/router.go
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-package router
+package mock
import (
stdHttp "net/http"
@@ -37,6 +37,11 @@ import (
"github.com/apache/dubbo-go-pixiu/pkg/server"
)
+// * =================================
+// This is a old implementation before updating to the new router framework
(#777)
+// It is a reserved for function verification only
+// DO NOT USE IT IN ANYWHERE BUT TEST IN pkg/common/router/router_test.go
+// * =================================
type (
// RouterCoordinator the router coordinator for http connection manager
RouterCoordinator struct {
diff --git a/pkg/common/router/router.go b/pkg/common/router/router.go
index cd175eb2..6a8393c5 100644
--- a/pkg/common/router/router.go
+++ b/pkg/common/router/router.go
@@ -19,8 +19,10 @@ package router
import (
stdHttp "net/http"
- "strings"
+ "slices"
"sync"
+ "sync/atomic"
+ "time"
)
import (
@@ -28,7 +30,6 @@ import (
)
import (
- "github.com/apache/dubbo-go-pixiu/pkg/common/constant"
"github.com/apache/dubbo-go-pixiu/pkg/common/router/trie"
"github.com/apache/dubbo-go-pixiu/pkg/common/util/stringutil"
"github.com/apache/dubbo-go-pixiu/pkg/context/http"
@@ -37,136 +38,216 @@ import (
"github.com/apache/dubbo-go-pixiu/pkg/server"
)
-type (
- // RouterCoordinator the router coordinator for http connection manager
- RouterCoordinator struct {
- activeConfig *model.RouteConfiguration
- rw sync.RWMutex
- }
-)
+// RouterCoordinator the router coordinator for http connection manager
+type RouterCoordinator struct {
+ mainSnapshot atomic.Pointer[model.RouteSnapshot] // atomic snapshot
+ mu sync.Mutex
+
+ nextSnapshot []*model.Router // temp store for dynamic update, DO NOT
read directly
+
+ timer *time.Timer // debounce timer
+ debounce time.Duration // merge window, default 50ms
+}
// CreateRouterCoordinator create coordinator for http connection manager
func CreateRouterCoordinator(routeConfig *model.RouteConfiguration)
*RouterCoordinator {
- rc := &RouterCoordinator{activeConfig: routeConfig}
+ rc := &RouterCoordinator{
+ nextSnapshot: make([]*model.Router, 0, len(routeConfig.Routes)),
+ debounce: 50 * time.Millisecond, // merge window
+ }
if routeConfig.Dynamic {
server.GetRouterManager().AddRouterListener(rc)
}
- rc.initTrie()
- rc.initRegex()
+ // build initial config and store snapshot
+
rc.mainSnapshot.Store(model.ToSnapshot(buildRouteConfiguration(routeConfig.Routes).Routes))
+ // copy initial routes to store, keep origin order
+ rc.nextSnapshot = append(rc.nextSnapshot, routeConfig.Routes...)
return rc
}
-// Route find routeAction for request
func (rm *RouterCoordinator) Route(hc *http.HttpContext) (*model.RouteAction,
error) {
- rm.rw.RLock()
- defer rm.rw.RUnlock()
-
return rm.route(hc.Request)
}
func (rm *RouterCoordinator) RouteByPathAndName(path, method string)
(*model.RouteAction, error) {
- rm.rw.RLock()
- defer rm.rw.RUnlock()
-
- return rm.activeConfig.RouteByPathAndMethod(path, method)
+ s := rm.mainSnapshot.Load()
+ if s == nil {
+ return nil, errors.New("router configuration is empty")
+ }
+ t := s.MethodTries[method]
+ if t == nil {
+ return nil, errors.Errorf("route failed for %s, no rules
matched", stringutil.GetTrieKey(method, path))
+ }
+ node, _, ok := t.Match(stringutil.GetTrieKey(method, path))
+ if !ok || node == nil || node.GetBizInfo() == nil {
+ return nil, errors.Errorf("route failed for %s, no rules
matched", stringutil.GetTrieKey(method, path))
+ }
+ act, ok := node.GetBizInfo().(model.RouteAction)
+ if !ok {
+ return nil, errors.Errorf("route failed for %s, invalid route
action type", stringutil.GetTrieKey(method, path))
+ }
+ return &act, nil
}
func (rm *RouterCoordinator) route(req *stdHttp.Request) (*model.RouteAction,
error) {
- // match those route that only contains headers first
- var matched []*model.Router
- for _, route := range rm.activeConfig.Routes {
- if len(route.Match.Prefix) > 0 {
+ s := rm.mainSnapshot.Load()
+ if s == nil {
+ return nil, errors.New("router configuration is empty")
+ }
+
+ // header-only first
+ for _, hr := range s.HeaderOnly {
+ if !model.MethodAllowed(hr.Methods, req.Method) {
continue
}
- if route.Match.MatchHeader(req) {
- matched = append(matched, route)
+ if matchHeaders(hr.Headers, req) {
+ if len(hr.Action.Cluster) == 0 {
+ return nil, errors.New("action is nil. please
check your configuration.")
+ }
+ return &hr.Action, nil
}
}
+ // Trie
+ t := s.MethodTries[req.Method]
+ if t == nil {
+ return nil, errors.Errorf("route failed for %s, no rules
matched", stringutil.GetTrieKey(req.Method, req.URL.Path))
- // always return the first match of header if got any
- if len(matched) > 0 {
- if len(matched[0].Route.Cluster) == 0 {
- return nil, errors.New("action is nil. please check
your configuration.")
- }
- return &matched[0].Route, nil
}
- // match those route that only contains prefix
- // TODO: may consider implementing both prefix and header in the future
- return rm.activeConfig.Route(req)
+ node, _, ok := t.Match(stringutil.GetTrieKey(req.Method, req.URL.Path))
+ if !ok || node == nil || node.GetBizInfo() == nil {
+ return nil, errors.Errorf("route failed for %s, no rules
matched", stringutil.GetTrieKey(req.Method, req.URL.Path))
+ }
+ act, ok := node.GetBizInfo().(model.RouteAction)
+ if !ok {
+ return nil, errors.Errorf("route failed for %s, invalid route
action type", stringutil.GetTrieKey(req.Method, req.URL.Path))
+ }
+ return &act, nil
}
-func getTrieKey(method string, path string, isPrefix bool) string {
- if isPrefix {
- if !strings.HasSuffix(path, constant.PathSlash) {
- path = path + constant.PathSlash
+// reset timer or publish directly
+func (rm *RouterCoordinator) schedulePublishLocked() {
+ if rm.debounce <= 0 {
+ // fallback: immediate
+ rm.publishLocked()
+ return
+ }
+ if rm.timer == nil {
+ rm.timer = time.NewTimer(rm.debounce)
+ go rm.awaitAndPublish()
+ return
+ }
+ // clear timer channel
+ if !rm.timer.Stop() {
+ select {
+ case <-rm.timer.C:
+ default:
}
- path = path + "**"
}
- return stringutil.GetTrieKey(method, path)
+ rm.timer.Reset(rm.debounce)
}
-func (rm *RouterCoordinator) initTrie() {
- if rm.activeConfig.RouteTrie.IsEmpty() {
- rm.activeConfig.RouteTrie = trie.NewTrie()
- }
- for _, router := range rm.activeConfig.Routes {
- rm.OnAddRouter(router)
+// wait for timer and publish
+func (rm *RouterCoordinator) awaitAndPublish() {
+ <-rm.timer.C
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+ rm.publishLocked()
+ rm.timer = nil
+}
+
+// publish: clone from store -> build new config -> atomic switch
+func (rm *RouterCoordinator) publishLocked() {
+ // 1) clone routes
+ next := make([]*model.Router, len(rm.nextSnapshot))
+ copy(next, rm.nextSnapshot)
+ // 2) build new config
+ cfg := buildRouteConfiguration(next)
+ // 3) atomic switch
+ rm.mainSnapshot.Store(model.ToSnapshot(cfg.Routes))
+}
+
+func buildRouteConfiguration(routes []*model.Router) *model.RouteConfiguration
{
+ cfg := &model.RouteConfiguration{
+ RouteTrie: trie.NewTrie(),
+ Routes: make([]*model.Router, 0, len(routes)),
+ Dynamic: false,
}
+ cfg.Routes = append(cfg.Routes, routes...)
+ initRegex(cfg)
+ fillTrieFromRoutes(cfg)
+ return cfg
}
-func (rm *RouterCoordinator) initRegex() {
- for _, router := range rm.activeConfig.Routes {
+func initRegex(cfg *model.RouteConfiguration) {
+ for _, router := range cfg.Routes {
headers := router.Match.Headers
for i := range headers {
if headers[i].Regex && len(headers[i].Values) > 0 {
- // regexp always use first value of header
- err :=
headers[i].SetValueRegex(headers[i].Values[0])
- if err != nil {
- logger.Errorf("invalid regexp in
headers[%d]: %v", i, err)
- panic(err)
+ if err :=
headers[i].SetValueRegex(headers[i].Values[0]); err != nil {
+ logger.Warnf("invalid regexp in
headers[%d]: %v", i, err)
}
}
}
}
}
-// OnAddRouter add router
+// OnAddRouter add router, every call of OnAdd will ADD a new rule, instead of
OVERWRITE the same rule
func (rm *RouterCoordinator) OnAddRouter(r *model.Router) {
- //TODO: lock move to trie node
- rm.rw.Lock()
- defer rm.rw.Unlock()
- if r.Match.Methods == nil {
- r.Match.Methods = []string{constant.Get, constant.Put,
constant.Delete, constant.Post, constant.Options}
- }
- isPrefix := r.Match.Prefix != ""
- for _, method := range r.Match.Methods {
- var key string
- if isPrefix {
- key = getTrieKey(method, r.Match.Prefix, isPrefix)
- } else {
- key = getTrieKey(method, r.Match.Path, isPrefix)
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+ rm.nextSnapshot = append(rm.nextSnapshot, r)
+ rm.schedulePublishLocked()
+}
+
+func fillTrieFromRoutes(cfg *model.RouteConfiguration) {
+ for _, r := range cfg.Routes {
+ methods := r.Match.Methods
+ if len(methods) == 0 {
+ methods = []string{"GET", "POST", "PUT", "DELETE",
"PATCH", "OPTIONS", "HEAD"}
+ }
+ for _, m := range methods {
+ key := stringutil.GetTrieKeyWithPrefix(m, r.Match.Path,
r.Match.Prefix, r.Match.Prefix != "")
+ _, _ = cfg.RouteTrie.Put(key, r.Route)
}
- _, _ = rm.activeConfig.RouteTrie.Put(key, r.Route)
}
}
// OnDeleteRouter delete router
func (rm *RouterCoordinator) OnDeleteRouter(r *model.Router) {
- rm.rw.Lock()
- defer rm.rw.Unlock()
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
- if r.Match.Methods == nil {
- r.Match.Methods = []string{constant.Get, constant.Put,
constant.Delete, constant.Post}
+ if len(rm.nextSnapshot) == 0 {
+ return
}
- isPrefix := r.Match.Prefix != ""
- for _, method := range r.Match.Methods {
- var key string
- if isPrefix {
- key = getTrieKey(method, r.Match.Prefix, isPrefix)
+ out := rm.nextSnapshot[:0]
+ for _, rr := range rm.nextSnapshot {
+ if rr.ID == r.ID {
+ continue
+ }
+ out = append(out, rr)
+ }
+ rm.nextSnapshot = out
+ rm.schedulePublishLocked()
+}
+
+func matchHeaders(chs []model.CompiledHeader, r *stdHttp.Request) bool {
+ for _, ch := range chs {
+ if val := r.Header.Get(ch.Name); len(val) > 0 {
+ if ch.Regex != nil {
+ if ok := ch.Regex.MatchString(val); !ok {
+ return false
+ }
+ continue
+ }
+
+ if !slices.Contains(ch.Values, val) {
+ return false
+ }
} else {
- key = getTrieKey(method, r.Match.Path, isPrefix)
+ return false
}
- _, _ = rm.activeConfig.RouteTrie.Remove(key)
}
+ return true
}
diff --git a/pkg/common/router/router_bench_test.go
b/pkg/common/router/router_bench_test.go
new file mode 100644
index 00000000..e44599d8
--- /dev/null
+++ b/pkg/common/router/router_bench_test.go
@@ -0,0 +1,504 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package router
+
+import (
+ "math/rand"
+ stdHttp "net/http"
+ "reflect"
+ "strconv"
+ "testing"
+ "time"
+)
+
+import (
+ oldrouter "github.com/apache/dubbo-go-pixiu/pkg/common/router/mock"
+ "github.com/apache/dubbo-go-pixiu/pkg/context/http"
+ "github.com/apache/dubbo-go-pixiu/pkg/model"
+)
+
+/*
+==============================
+this is the benchmark for router
+contrast oldrouter and newrouter
+oldrouter: "github.com/apache/dubbo-go-pixiu/pkg/common/router/mock"
+newrouter: "github.com/apache/dubbo-go-pixiu/pkg/common/router"
+==============================
+*/
+type benchShape struct {
+ NRoutes int // router number
+ PrefixRatio float64 // prefix router ratio(others are accurate path)
+ HeaderOnlyRatio float64 // header only router ratio(without path/prefix)
+ Methods []string
+}
+
+func buildOldCoordinator(routes []*model.Router) *oldrouter.RouterCoordinator {
+ cfg := &model.RouteConfiguration{
+ Routes: routes,
+ Dynamic: false,
+ }
+ return oldrouter.CreateRouterCoordinator(cfg)
+}
+
+func genRoutes(sh benchShape) []*model.Router {
+ routes := make([]*model.Router, 0, sh.NRoutes)
+ if len(sh.Methods) == 0 {
+ sh.Methods = []string{"GET", "POST"}
+ }
+ nHeader := int(float64(sh.NRoutes) * sh.HeaderOnlyRatio)
+ nPrefix := int(float64(sh.NRoutes-nHeader) * sh.PrefixRatio)
+ nPath := sh.NRoutes - nHeader - nPrefix
+
+ // 1) Header-only
+ for i := 0; i < nHeader; i++ {
+ id := "hdr-" + strconv.Itoa(i)
+ r := &model.Router{
+ ID: id,
+ Match: model.RouterMatch{
+ Methods: sh.Methods,
+ Headers: []model.HeaderMatcher{
+ {Name: "X-Env", Values:
[]string{"prod"}, Regex: false},
+ },
+ },
+ Route: model.RouteAction{Cluster: "c-h-" + id},
+ }
+ routes = append(routes, r)
+ }
+ // 2) Prefix routes
+ for i := 0; i < nPrefix; i++ {
+ id := "pre-" + strconv.Itoa(i)
+ p := "/api/v1/service" + strconv.Itoa(i%50) + "/"
+ r := &model.Router{
+ ID: id,
+ Match: model.RouterMatch{
+ Methods: sh.Methods,
+ Prefix: p,
+ },
+ Route: model.RouteAction{Cluster: "c-p-" + id},
+ }
+ routes = append(routes, r)
+ }
+ // 3) Exact path
+ for i := 0; i < nPath; i++ {
+ id := "pth-" + strconv.Itoa(i)
+ pp := "/api/v1/item/" + strconv.Itoa(i)
+ r := &model.Router{
+ ID: id,
+ Match: model.RouterMatch{
+ Methods: sh.Methods,
+ Path: pp,
+ },
+ Route: model.RouteAction{Cluster: "c-x-" + id},
+ }
+ routes = append(routes, r)
+ }
+ return routes
+}
+
+func buildNewCoordinator(routes []*model.Router) *RouterCoordinator {
+ cfg := &model.RouteConfiguration{
+ Routes: routes,
+ Dynamic: false,
+ }
+ return CreateRouterCoordinator(cfg)
+}
+
+func buildDelta(base []*model.Router, seed int64) []*model.Router {
+ cp := make([]*model.Router, len(base))
+ copy(cp, base)
+ rnd := rand.New(rand.NewSource(seed))
+ k := len(cp) / 100 // 1%
+ out := make([]*model.Router, 0, k)
+ for i := 0; i < k; i++ {
+ idx := rnd.Intn(len(cp)) // NOSONAR
+ old := cp[idx]
+ newPath := "/api/v1/item/" + strconv.Itoa(rnd.Intn(100000)) //
NOSONAR
+ nr := &model.Router{
+ ID: old.ID,
+ Match: model.RouterMatch{
+ Methods: old.Match.Methods,
+ Path: newPath,
+ Headers: old.Match.Headers,
+ },
+ Route: old.Route,
+ }
+ out = append(out, nr)
+ }
+ return out
+}
+
+func genRequests(n int) []*stdHttp.Request {
+ reqs := make([]*stdHttp.Request, 0, n)
+ methods := []string{"GET", "POST"}
+ for i := 0; i < n; i++ {
+ var path string
+ switch i % 3 {
+ case 0:
+ path = "/api/v1/item/" + strconv.Itoa(i%10000)
+ case 1:
+ path = "/api/v1/service" + strconv.Itoa(i%50) +
"/foo/bar"
+ default:
+ path = "/unknown/" + strconv.Itoa(i)
+ }
+ req, _ := stdHttp.NewRequest(methods[i%len(methods)], path, nil)
+ if i%5 == 0 { // trigger header-only route
+ req.Header.Set("X-Env", "prod")
+ }
+ reqs = append(reqs, req)
+ }
+ return reqs
+}
+
+// helper: assert Route behavior of old/new is the same on a set of requests
+func assertRouteSame(b testing.TB, oldc *oldrouter.RouterCoordinator, newc
*RouterCoordinator, reqs []*stdHttp.Request) {
+ b.Helper()
+ for i, r := range reqs {
+ ctxOld := http.HttpContext{Request: r}
+ ctxNew := http.HttpContext{Request: r}
+
+ oldRes, oldErr := oldc.Route(&ctxOld)
+ newRes, newErr := newc.Route(&ctxNew)
+
+ if (oldErr != nil && newErr == nil) || (oldErr == nil && newErr
!= nil) {
+ b.Fatalf("route error text mismatch on #%d path=%s
method=%s: oldErr=%v newErr=%v",
+ i, r.URL.Path, r.Method, oldErr, newErr)
+ }
+ if !reflect.DeepEqual(oldRes, newRes) {
+ b.Fatalf("route result mismatch on #%d path=%s
method=%s: old=%#v new=%#v",
+ i, r.URL.Path, r.Method, oldRes, newRes)
+ }
+ }
+}
+
+// helper: assert RouteByPathAndName behavior is the same
+func assertRouteByPathAndNameSame(b testing.TB, oldc
*oldrouter.RouterCoordinator, newc *RouterCoordinator, paths []string, method
string) {
+ b.Helper()
+ for i, p := range paths {
+ oldRes, oldErr := oldc.RouteByPathAndName(p, method)
+ newRes, newErr := newc.RouteByPathAndName(p, method)
+
+ if (oldErr != nil && newErr == nil) || (oldErr == nil && newErr
!= nil) {
+ b.Fatalf("route error text mismatch on #%d path=%s
method=%s: oldRes=%v oldErr=%v newRes=%v newErr=%v",
+ i, p, method, oldRes, oldErr, newRes, newErr)
+ }
+ if !reflect.DeepEqual(oldRes, newRes) {
+ b.Fatalf("RouteByPathAndName result mismatch on #%d
path=%s method=%s: old=%#v new=%#v",
+ i, p, method, oldRes, newRes)
+ }
+ }
+}
+
+// ============= Bench 1:read throughput (one goroutine) =============
+
+func BenchmarkRouteReadThroughput(b *testing.B) {
+ shape := benchShape{NRoutes: 30000, PrefixRatio: 0.4, HeaderOnlyRatio:
0.1, Methods: []string{"GET", "POST"}}
+
+ oldRoutes := genRoutes(shape)
+ newRoutes := genRoutes(shape)
+ reqs := genRequests(4096)
+
+ oldc := buildOldCoordinator(oldRoutes)
+ newc := buildNewCoordinator(newRoutes)
+
+ assertRouteSame(b, oldc, newc, reqs)
+
+ b.Run("old/locked-read-30k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ r := reqs[i%len(reqs)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = oldc.Route(&httpContext)
+ }
+ })
+
+ b.Run("new/rcu-read-30k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ r := reqs[i%len(reqs)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = newc.Route(&httpContext)
+ }
+ })
+}
+
+// ============= Bench 2:read throughput (parallel) =============
+
+func BenchmarkRouteReadParallel(b *testing.B) {
+ shape := benchShape{NRoutes: 30000, PrefixRatio: 0.4, HeaderOnlyRatio:
0.1, Methods: []string{"GET", "POST"}}
+
+ oldRoutes := genRoutes(shape)
+ newRoutes := genRoutes(shape)
+ reqs := genRequests(8192)
+
+ oldc := buildOldCoordinator(oldRoutes)
+ newc := buildNewCoordinator(newRoutes)
+
+ assertRouteSame(b, oldc, newc, reqs)
+
+ b.Run("old/parallel-30k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.SetParallelism(40)
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ i := rand.Int() // NOSONAR
+ for pb.Next() {
+ r := reqs[i%len(reqs)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = oldc.Route(&httpContext)
+ i++
+ }
+ })
+ })
+
+ b.Run("new/parallel-30k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.SetParallelism(40)
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ i := rand.Int() // NOSONAR
+ for pb.Next() {
+ r := reqs[i%len(reqs)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = newc.Route(&httpContext)
+ i++
+ }
+ })
+ })
+}
+
+// ============= Bench 3:read and write(1% write) =============
+
+func BenchmarkReloadLatency(b *testing.B) {
+ shape := benchShape{NRoutes: 30000, PrefixRatio: 0.4, HeaderOnlyRatio:
0.1, Methods: []string{"GET", "POST"}}
+ base := genRoutes(shape)
+
+ oldc := buildOldCoordinator(base)
+ newc := buildNewCoordinator(base)
+
+ {
+ checkOld := buildOldCoordinator(base)
+ checkNew := buildNewCoordinator(base)
+ deltaOld := buildDelta(base, 1)
+ for i := range deltaOld {
+ checkOld.OnAddRouter(deltaOld[i])
+ checkNew.OnAddRouter(deltaOld[i])
+ }
+ reqs := genRequests(1024)
+ time.Sleep(100 * time.Millisecond)
+ assertRouteSame(b, checkOld, checkNew, reqs)
+ }
+
+ b.Run("old/reload-1percent-30k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, r := range buildDelta(base, int64(i)) {
+ oldc.OnAddRouter(r)
+ }
+ }
+ })
+
+ b.Run("new/reload-1percent-30k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, r := range buildDelta(base, int64(i)) {
+ newc.OnAddRouter(r)
+ }
+ }
+ })
+}
+
+func BenchmarkRoute100kReadThroughput(b *testing.B) {
+ shape := benchShape{
+ NRoutes: 100_000,
+ PrefixRatio: 0.4,
+ HeaderOnlyRatio: 0.1,
+ Methods: []string{"GET", "POST"},
+ }
+ reqs := genRequests(16_384)
+
+ oldc := buildOldCoordinator(genRoutes(shape))
+ newc := buildNewCoordinator(genRoutes(shape))
+
+ assertRouteSame(b, oldc, newc, reqs)
+
+ b.Run("old/locked-read-100k", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ r := reqs[i%len(reqs)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = oldc.Route(&httpContext)
+ }
+ })
+ b.Run("new/rcu-read-100k", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ r := reqs[i%len(reqs)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = newc.Route(&httpContext)
+ }
+ })
+}
+
+func BenchmarkRoute100kReadParallel(b *testing.B) {
+ shape := benchShape{
+ NRoutes: 100_000,
+ PrefixRatio: 0.4,
+ HeaderOnlyRatio: 0.1,
+ Methods: []string{"GET", "POST"},
+ }
+ reqs := genRequests(32_768)
+
+ oldc := buildOldCoordinator(genRoutes(shape))
+ newc := buildNewCoordinator(genRoutes(shape))
+
+ assertRouteSame(b, oldc, newc, reqs)
+
+ b.Run("old/parallel-100k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.SetParallelism(4)
+ b.RunParallel(func(pb *testing.PB) {
+ i := 0
+ for pb.Next() {
+ r := reqs[i&(len(reqs)-1)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = oldc.Route(&httpContext)
+ i++
+ }
+ })
+ })
+ b.Run("new/parallel-100k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.SetParallelism(4)
+ b.RunParallel(func(pb *testing.PB) {
+ i := 0
+ for pb.Next() {
+ r := reqs[i&(len(reqs)-1)]
+ httpContext := http.HttpContext{
+ Request: r,
+ }
+ _, _ = newc.Route(&httpContext)
+ i++
+ }
+ })
+ })
+}
+
+func BenchmarkReload100kLatency1Percent(b *testing.B) {
+ shape := benchShape{
+ NRoutes: 100_000,
+ PrefixRatio: 0.4,
+ HeaderOnlyRatio: 0.1,
+ Methods: []string{"GET", "POST"},
+ }
+ base := genRoutes(shape)
+
+ oldc := buildOldCoordinator(genRoutes(shape))
+ newc := buildNewCoordinator(genRoutes(shape))
+
+ {
+ checkOld := buildOldCoordinator(base)
+ checkNew := buildNewCoordinator(base)
+ deltaOld := buildDelta(base, 1)
+ for i := range deltaOld {
+ checkOld.OnAddRouter(deltaOld[i])
+ checkNew.OnAddRouter(deltaOld[i])
+ }
+ reqs := genRequests(2048)
+ time.Sleep(100 * time.Millisecond)
+ assertRouteSame(b, checkOld, checkNew, reqs)
+ }
+
+ b.Run("old/reload-1percent-100k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, r := range buildDelta(base, int64(i)) {
+ oldc.OnAddRouter(r)
+ }
+ }
+ })
+
+ b.Run("new/reload-1percent-100k", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, r := range buildDelta(base, int64(i)) {
+ newc.OnAddRouter(r)
+ }
+ }
+ })
+}
+
+// ============= Bench 4:RouteByPathAndName(API behavior must same)
=============
+
+func BenchmarkRouteByPathAndName(b *testing.B) {
+ shape := benchShape{
+ NRoutes: 20000,
+ PrefixRatio: 0.5,
+ HeaderOnlyRatio: 0.0,
+ Methods: []string{"GET"},
+ }
+
+ oldc := buildOldCoordinator(genRoutes(shape))
+ newc := buildNewCoordinator(genRoutes(shape))
+
+ paths := []string{
+ "/api/v1/item/12345",
+ "/api/v1/service7/xxx/yyy",
+ "/no/match/path",
+ }
+ method := "GET"
+
+ assertRouteByPathAndNameSame(b, oldc, newc, paths, method)
+
+ b.Run("old/RouteByPathAndName", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ path := paths[i%len(paths)]
+ _, _ = oldc.RouteByPathAndName(path, method)
+ }
+ })
+
+ b.Run("new/RouteByPathAndName", func(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ path := paths[i%len(paths)]
+ _, _ = newc.RouteByPathAndName(path, method)
+ }
+ })
+}
diff --git a/pkg/common/router/router_test.go b/pkg/common/router/router_test.go
index 46441514..f3063a00 100644
--- a/pkg/common/router/router_test.go
+++ b/pkg/common/router/router_test.go
@@ -19,7 +19,9 @@ package router
import (
"bytes"
- "net/http"
+ "math/rand"
+ stdHttp "net/http"
+ "strconv"
"testing"
)
@@ -28,36 +30,24 @@ import (
)
import (
- "github.com/apache/dubbo-go-pixiu/pkg/common/router/trie"
+ oldrouter "github.com/apache/dubbo-go-pixiu/pkg/common/router/mock"
+ "github.com/apache/dubbo-go-pixiu/pkg/context/http"
"github.com/apache/dubbo-go-pixiu/pkg/context/mock"
"github.com/apache/dubbo-go-pixiu/pkg/model"
)
func TestCreateRouterCoordinator(t *testing.T) {
- hcmc := model.HttpConnectionManagerConfig{
- RouteConfig: model.RouteConfiguration{
- RouteTrie: trie.NewTrieWithDefault("POST/api/v1/**",
model.RouteAction{
- Cluster: "test_dubbo",
- ClusterNotFoundResponseCode: 505,
- }),
- Dynamic: false,
- },
- HTTPFilters: []*model.HTTPFilter{
- {
- Name: "test",
- Config: nil,
- },
- },
- ServerName: "test_http_dubbo",
- GenerateRequestID: false,
- IdleTimeoutStr: "100",
+ specs := []RouteSpec{
+ // exact
+ {ID: "test", Methods: []string{"POST"}, Path: "/api/v1/**",
Cluster: "test_dubbo"},
}
- r := CreateRouterCoordinator(&hcmc.RouteConfig)
- request, err := http.NewRequest("POST",
"http://www.dubbogopixiu.com/api/v1?name=tc",
bytes.NewReader([]byte("{\"id\":\"12345\"}")))
+ coordinator := BuildNew(specs)
+
+ request, err := stdHttp.NewRequest("POST",
"http://www.dubbogopixiu.com/api/v1?name=tc",
bytes.NewReader([]byte("{\"id\":\"12345\"}")))
assert.NoError(t, err)
c := mock.GetMockHTTPContext(request)
- a, err := r.Route(c)
+ a, err := coordinator.Route(c)
assert.NoError(t, err)
assert.Equal(t, a.Cluster, "test_dubbo")
@@ -71,13 +61,19 @@ func TestCreateRouterCoordinator(t *testing.T) {
},
}
- r.OnAddRouter(router)
- r.OnDeleteRouter(router)
+ coordinator.OnAddRouter(router)
+ coordinator.OnDeleteRouter(router)
}
func TestRoute(t *testing.T) {
const (
Cluster1 = "test-cluster-1"
+ Cluster2 = "test-cluster-2"
+ Cluster3 = "test-cluster-3"
+ Cluster4 = "test-cluster-4"
+ Cluster5 = "test-cluster-5"
+ Cluster6 = "test-cluster-6"
+ Cluster7 = "test-cluster-7"
)
hcmc := model.HttpConnectionManagerConfig{
@@ -89,16 +85,52 @@ func TestRoute(t *testing.T) {
Headers: []model.HeaderMatcher{
{
Name: "A",
- Values:
[]string{"1", "2", "3"},
+ Values:
[]string{"1", "2", "0"},
},
+ },
+ Methods: []string{"GET",
"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
Cluster1,
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ {
+ ID: "2",
+ Match: model.RouterMatch{
+ Headers: []model.HeaderMatcher{
{
Name: "A",
Values:
[]string{"3", "4", "5"},
},
+ },
+ Methods: []string{"GET",
"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
Cluster2,
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ {
+ ID: "3",
+ Match: model.RouterMatch{
+ Headers: []model.HeaderMatcher{
{
Name: "B",
Values:
[]string{"1"},
},
+ },
+ Methods: []string{"GET",
"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
Cluster3,
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ {
+ ID: "4",
+ Match: model.RouterMatch{
+ Headers: []model.HeaderMatcher{
{
Name:
"normal-regex",
Values:
[]string{"(k){2}"},
@@ -113,7 +145,65 @@ func TestRoute(t *testing.T) {
Methods: []string{"GET",
"POST"},
},
Route: model.RouteAction{
- Cluster:
Cluster1,
+ Cluster:
Cluster4,
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ {
+ ID: "5",
+ Match: model.RouterMatch{
+ Headers: []model.HeaderMatcher{
+ {
+ Name:
"broken-regex",
+ Values:
[]string{"(t){2]]"},
+ Regex: true,
+ },
+ },
+ Methods: []string{"GET",
"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
Cluster5,
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ {
+ ID: "6",
+ Match: model.RouterMatch{
+ Headers: []model.HeaderMatcher{
+ {
+ Name: "C",
+ Values:
[]string{"1", "2", "0"},
+ },
+ {
+ Name: "D",
+ Values:
[]string{"3", "4", "5"},
+ },
+ },
+ Methods: []string{"GET",
"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
Cluster6,
+ ClusterNotFoundResponseCode:
505,
+ },
+ },
+ {
+ ID: "7",
+ Match: model.RouterMatch{
+ Headers: []model.HeaderMatcher{
+ {
+ Name: "E",
+ Values:
[]string{"1", "2", "0"},
+ },
+ {
+ Name:
"normal-regex",
+ Values:
[]string{"(k){2}"},
+ Regex: true,
+ },
+ },
+ Methods: []string{"GET",
"POST"},
+ },
+ Route: model.RouteAction{
+ Cluster:
Cluster7,
ClusterNotFoundResponseCode:
505,
},
},
@@ -144,7 +234,7 @@ func TestRoute(t *testing.T) {
Header: map[string]string{
"A": "1",
},
- Expect: "test-cluster-1",
+ Expect: Cluster1,
},
{
Name: "one header matched",
@@ -152,7 +242,7 @@ func TestRoute(t *testing.T) {
Header: map[string]string{
"A": "3",
},
- Expect: Cluster1,
+ Expect: Cluster2,
},
{
Name: "more header with one regex matched",
@@ -161,16 +251,16 @@ func TestRoute(t *testing.T) {
"A": "5",
"normal-regex": "kkkk",
},
- Expect: Cluster1,
+ Expect: Cluster2,
},
{
Name: "one header but wrong method",
URL: "/user",
Method: "PUT",
Header: map[string]string{
- "A": "3",
+ "A": "0",
},
- Expect: "route failed for PUT/user, no rules matched.",
+ Expect: "route failed for PUT/user, no rules matched",
},
{
Name: "one broken regex header",
@@ -178,14 +268,22 @@ func TestRoute(t *testing.T) {
Header: map[string]string{
"broken-regex": "tt",
},
- Expect: "route failed for GET/user, no rules matched.",
+ Expect: "route failed for GET/user, no rules matched",
},
{
Name: "one matched header 2",
Header: map[string]string{
"B": "1",
},
- Expect: Cluster1,
+ Expect: Cluster3,
+ },
+ {
+ Name: "only header but wrong method",
+ Method: "DELETE",
+ Header: map[string]string{
+ "B": "1",
+ },
+ Expect: "route failed for DELETE, no rules matched",
},
{
Name: "only header but wrong method",
@@ -193,7 +291,25 @@ func TestRoute(t *testing.T) {
Header: map[string]string{
"B": "1",
},
- Expect: "route failed for DELETE, no rules matched.",
+ Expect: "route failed for DELETE, no rules matched",
+ },
+ {
+ Name: "regex AND normal",
+ URL: "/user",
+ Header: map[string]string{
+ "E": "0",
+ "normal-regex": "kk",
+ },
+ Expect: Cluster7,
+ },
+ {
+ Name: "normal AND normal",
+ URL: "/user",
+ Header: map[string]string{
+ "C": "1",
+ "D": "3",
+ },
+ Expect: Cluster6,
},
}
@@ -205,7 +321,7 @@ func TestRoute(t *testing.T) {
if len(tc.Method) > 0 {
method = tc.Method
}
- request, err := http.NewRequest(method, tc.URL, nil)
+ request, err := stdHttp.NewRequest(method, tc.URL, nil)
assert.NoError(t, err)
if tc.Header != nil {
@@ -224,3 +340,619 @@ func TestRoute(t *testing.T) {
})
}
}
+
+/* ==============================
+ below are parity test between old and new router
+ ============================== */
+
+type HeaderSpec struct {
+ Name string
+ Values []string
+ Regex bool
+}
+
+type RouteSpec struct {
+ ID string
+ Methods []string
+ Path string
+ Prefix string
+ Headers []HeaderSpec
+ Cluster string
+}
+
+func (s RouteSpec) toRouter() *model.Router {
+ h := make([]model.HeaderMatcher, 0, len(s.Headers))
+ for _, x := range s.Headers {
+ h = append(h, model.HeaderMatcher{Name: x.Name, Values:
append([]string(nil), x.Values...), Regex: x.Regex})
+ }
+ return &model.Router{
+ ID: s.ID,
+ Match: model.RouterMatch{
+ Methods: append([]string(nil), s.Methods...),
+ Path: s.Path,
+ Prefix: s.Prefix,
+ Headers: h,
+ },
+ Route: model.RouteAction{Cluster: s.Cluster},
+ }
+}
+
+func buildOld(specs []RouteSpec) *oldrouter.RouterCoordinator {
+ rs := make([]*model.Router, 0, len(specs))
+ for _, s := range specs {
+ rs = append(rs, s.toRouter())
+ }
+ cfg := &model.RouteConfiguration{Routes: rs, Dynamic: false}
+ return oldrouter.CreateRouterCoordinator(cfg)
+}
+
+func BuildNew(specs []RouteSpec) *RouterCoordinator {
+ rs := make([]*model.Router, 0, len(specs))
+ for _, s := range specs {
+ rs = append(rs, s.toRouter())
+ }
+ cfg := &model.RouteConfiguration{Routes: rs, Dynamic: false}
+ return CreateRouterCoordinator(cfg)
+}
+
+type res struct {
+ ok bool
+ cluster string
+ err string
+}
+
+func call(cOld *oldrouter.RouterCoordinator, cNew *RouterCoordinator, method,
path string, hdr map[string]string) (res, res) {
+ req, _ := stdHttp.NewRequest(method, path, nil)
+ httpContext := http.HttpContext{
+ Request: req,
+ }
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ oa, oe := cOld.Route(&httpContext)
+ na, ne := cNew.Route(&httpContext)
+
+ or := res{}
+ if oe != nil || oa == nil {
+ if oe != nil {
+ or.err = oe.Error()
+ }
+ } else {
+ or.ok = true
+ or.cluster = oa.Cluster
+ }
+
+ nr := res{}
+ if ne != nil || na == nil {
+ if ne != nil {
+ nr.err = ne.Error()
+ }
+ } else {
+ nr.ok = true
+ nr.cluster = na.Cluster
+ }
+ return or, nr
+}
+
+func assertSame(t *testing.T, oldc *oldrouter.RouterCoordinator, newc
*RouterCoordinator,
+ method, path string, hdr map[string]string, wantOK bool, wantCluster
string) {
+
+ ro, rn := call(oldc, newc, method, path, hdr)
+ if ro.ok != rn.ok || ro.cluster != rn.cluster {
+ t.Fatalf("mismatch: %s %s hdr=%v\n old={ok:%v cluster:%q
err:%q}\n new={ok:%v cluster:%q err:%q}",
+ method, path, hdr, ro.ok, ro.cluster, ro.err, rn.ok,
rn.cluster, rn.err)
+ }
+ if ro.ok != wantOK || rn.ok != wantOK {
+ t.Fatalf("ok mismatch: %s %s hdr=%v wantOK=%v oldOK=%v
newOK=%v", method, path, hdr, wantOK, ro.ok, rn.ok)
+ }
+ if wantOK && wantCluster != "" && (ro.cluster != wantCluster ||
rn.cluster != wantCluster) {
+ t.Fatalf("cluster mismatch: %s %s hdr=%v want=%q old=%q
new=%q", method, path, hdr, wantCluster, ro.cluster, rn.cluster)
+ }
+}
+
+type varSyntax struct {
+ simplePattern func(seg string) string // /users/:id
+ digitsPattern func(seg string) (string, bool) // /users/:id(\d+)
+ multiPattern func(a, b string) string // /shops/:a/orders/:b
+}
+
+func colonSyntax() varSyntax {
+ return varSyntax{
+ simplePattern: func(seg string) string {
+ return "/users/:" + seg
+ },
+ digitsPattern: func(seg string) (string, bool) {
+ return "/users/:" + seg + "(\\d+)", true
+ },
+ multiPattern: func(a, b string) string {
+ return "/shops/:" + a + "/orders/:" + b
+ },
+ }
+}
+
+/* ==============================
+ test cases (var/regex/priority/header/)
+ ============================== */
+
+func TestParitySimpleCases(t *testing.T) {
+ syntax = colonSyntax()
+
+ specs := []RouteSpec{
+ // exact
+ {ID: "exact", Methods: []string{"GET"}, Path:
"/api/v1/item/100", Cluster: "c-exact"},
+ // prefix(/**)
+ {ID: "pre", Methods: []string{"GET"}, Prefix: "/api/v1/svc/",
Cluster: "c-pre"},
+ // var
+ {ID: "var", Methods: []string{"GET"}, Path:
syntax.simplePattern("id"), Cluster: "c-var"},
+ // multi
+ {ID: "multi", Methods: []string{"GET", "POST"}, Path: "/multi",
Cluster: "c-multi"},
+ // Header regex
+ {ID: "hdr", Methods: []string{"GET"}, Headers:
[]HeaderSpec{{Name: "X-Env", Values: []string{"^prod|staging$"}, Regex: true}},
Cluster: "c-hdr"},
+ }
+
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ cases := []struct {
+ name string
+ method string
+ path string
+ hdr map[string]string
+ ok bool
+ cluster string
+ }{
+ {"exact", "GET", "/api/v1/item/100", nil, true, "c-exact"},
+ {"prefix.deep", "GET", "/api/v1/svc/a/b", nil, true, "c-pre"},
+ {"var.hit", "GET", "/users/42", nil, true, "c-var"},
+ {"var.not_deeper", "GET", "/users/42/extra", nil, false, ""},
+ {"multi.get", "GET", "/multi", nil, true, "c-multi"},
+ {"multi.post", "POST", "/multi", nil, true, "c-multi"},
+ {"hdr.regex", "GET", "/whatever", map[string]string{"X-Env":
"prod"}, true, "c-hdr"},
+ {"miss", "GET", "/no/match", nil, false, ""},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assertSame(t, oldc, newc, tc.method, tc.path, tc.hdr,
tc.ok, tc.cluster)
+ })
+ }
+}
+
+func TestPrioritySpecificOverWildcard(t *testing.T) {
+ syntax = colonSyntax()
+
+ specs := []RouteSpec{
+ {ID: "wild", Methods: []string{"GET"}, Prefix: "/api/v1/**",
Cluster: "c-wild"},
+ {ID: "spec", Methods: []string{"GET"}, Path:
"/api/v1/test-dubbo/user/name/" +
syntax.simplePattern("name")[len("/users/"):], Cluster: "c-spec"},
+ // equals to /api/v1/test-dubbo/user/name/:name
+ }
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ assertSame(t, oldc, newc, "GET",
+ "/api/v1/test-dubbo/user/name/yqxu", nil, true, "c-spec")
+}
+
+func TestPriorityDeeperWins(t *testing.T) {
+ specs := []RouteSpec{
+ {ID: "shallow", Methods: []string{"GET"}, Prefix: "/api/v1/",
Cluster: "c-shallow"},
+ {ID: "deeper", Methods: []string{"GET"}, Prefix:
"/api/v1/test-dubbo/", Cluster: "c-deeper"},
+ }
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ assertSame(t, oldc, newc, "GET",
+ "/api/v1/test-dubbo/user/name/abc", nil, true, "c-deeper")
+}
+
+func TestPrioritySingleStarOverDoubleStar(t *testing.T) {
+ // use var to express "/*"
+ syntax = colonSyntax()
+ specs := []RouteSpec{
+ {ID: "multi", Methods: []string{"GET"}, Prefix: "/api/",
Cluster: "c-**"},
+ {ID: "single", Methods: []string{"GET"}, Path: "/api/" +
syntax.simplePattern("seg")[len("/users/"):] + "/users", Cluster: "c-*"},
+ // equals to /api/:seg/users
+ }
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ assertSame(t, oldc, newc, "GET", "/api/v1/users", nil, true, "c-*")
+ assertSame(t, oldc, newc, "GET", "/api/v1/x/users", nil, true, "c-**")
+}
+
+func TestVariablesSingleAndMulti(t *testing.T) {
+ syntax = colonSyntax()
+ specs := []RouteSpec{
+ {ID: "one", Methods: []string{"GET"}, Path:
syntax.simplePattern("id"), Cluster: "c-one"},
+ {ID: "two", Methods: []string{"GET"}, Path:
syntax.multiPattern("shopId", "orderId"), Cluster: "c-two"},
+ {ID: "pre", Methods: []string{"GET"}, Prefix: "/shops/",
Cluster: "c-pre"},
+ }
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ assertSame(t, oldc, newc, "GET", "/users/777", nil, true, "c-one")
+ assertSame(t, oldc, newc, "GET", syntax.multiPattern("12", "34"), nil,
true, "c-two")
+ assertSame(t, oldc, newc, "GET", syntax.multiPattern("12",
"34")+"/extra", nil, true, "c-pre")
+}
+
+func TestHeaderRegexWithRoutes(t *testing.T) {
+ specs := []RouteSpec{
+ {ID: "hdr", Methods: []string{"GET"}, Headers:
[]HeaderSpec{{Name: "X-Env", Values: []string{"^prod|staging$"}, Regex: true}},
Cluster: "c-hdr"},
+ {ID: "pre", Methods: []string{"GET"}, Prefix: "/api/", Cluster:
"c-pre"},
+ }
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ assertSame(t, oldc, newc, "GET", "/whatever",
map[string]string{"X-Env": "prod"}, true, "c-hdr")
+ assertSame(t, oldc, newc, "GET", "/api/foo", map[string]string{"X-Env":
"dev"}, true, "c-pre")
+}
+
+/* ==============================
+ random data fuzz test
+ ============================== */
+
+func TestParityRandomized(t *testing.T) {
+ syntax = colonSyntax()
+ const (
+ nRoutes = 20000
+ nRequests = 10000
+ prefixRatio = 0.40
+ headerRatio = 0.10
+ seed int64 = 20250929
+ )
+
+ specs := genRandomSpecsWithVars(syntax, nRoutes, prefixRatio,
headerRatio, seed)
+ oldc := buildOld(specs)
+ newc := BuildNew(specs)
+
+ reqs := genRandomRequests(nRequests, seed+1)
+ for i, req := range reqs {
+ ro, rn := call(oldc, newc, req.Method, req.URL.Path,
headerFromReq(req))
+ if ro.ok != rn.ok || ro.cluster != rn.cluster {
+ t.Fatalf("Randomized mismatch at #%d: %s %s old={ok:%v
cluster:%q err:%q} new={ok:%v cluster:%q err:%q}",
+ i, req.Method, req.URL.Path, ro.ok, ro.cluster,
ro.err, rn.ok, rn.cluster, rn.err)
+ }
+ }
+}
+
+func headerFromReq(r *stdHttp.Request) map[string]string {
+ if len(r.Header) == 0 {
+ return nil
+ }
+ out := make(map[string]string)
+ for k, vs := range r.Header {
+ if len(vs) > 0 {
+ out[k] = vs[0]
+ }
+ }
+ return out
+}
+
+/* ==============================
+ random data generation tools
+ ============================== */
+
+var syntax varSyntax
+
+func genRandomSpecsWithVars(s varSyntax, n int, prefixRatio, headerOnlyRatio
float64, seed int64) []RouteSpec {
+ rnd := rand.New(rand.NewSource(seed))
+ out := make([]RouteSpec, 0, n)
+
+ nHeader := int(float64(n) * headerOnlyRatio)
+ nPrefix := int(float64(n-nHeader) * prefixRatio)
+ // preserve 20% for "variable path", the rest for exact path
+ nVars := int(float64(n) * 0.20)
+ nPath := n - nHeader - nPrefix - nVars
+ if nPath < 0 {
+ nPath = 0
+ }
+
+ // Header-only (regex + normal)
+ for i := 0; i < nHeader; i++ {
+ if i%5 == 0 {
+ out = append(out, RouteSpec{
+ ID: "hdrx-" + strconv.Itoa(i),
+ Methods: []string{"GET", "POST"},
+ Headers: []HeaderSpec{{Name: "X-Trace", Values:
[]string{"^pixiu-[0-9a-f]{8}$"}, Regex: true}},
+ Cluster: "c-hx-" + strconv.Itoa(i),
+ })
+ } else {
+ out = append(out, RouteSpec{
+ ID: "hdr-" + strconv.Itoa(i),
+ Methods: []string{"GET", "POST"},
+ Headers: []HeaderSpec{{Name: "X-Env", Values:
[]string{"prod"}, Regex: false}},
+ Cluster: "c-h-" + strconv.Itoa(i),
+ })
+ }
+ }
+
+ // Prefix
+ for i := 0; i < nPrefix; i++ {
+ base := "/api/v" + strconv.Itoa(1+rnd.Intn(3)) + "/svc" +
strconv.Itoa(rnd.Intn(50)) + "/" // NOSONAR
+ out = append(out, RouteSpec{
+ ID: "pre-" + strconv.Itoa(i),
+ Methods: []string{"GET", "POST"},
+ Prefix: base,
+ Cluster: "c-p-" + strconv.Itoa(i),
+ })
+ }
+
+ // Variables
+ for i := 0; i < nVars; i++ {
+ if i%3 == 0 {
+ out = append(out, RouteSpec{
+ ID: "var-" + strconv.Itoa(i),
+ Methods: []string{"GET"},
+ Path: s.simplePattern("id"),
+ Cluster: "c-v-" + strconv.Itoa(i),
+ })
+ } else {
+ out = append(out, RouteSpec{
+ ID: "var2-" + strconv.Itoa(i),
+ Methods: []string{"GET"},
+ Path: s.multiPattern("a", "b"),
+ Cluster: "c-v2-" + strconv.Itoa(i),
+ })
+ }
+ }
+
+ // Exact Path
+ for i := 0; i < nPath; i++ {
+ out = append(out, RouteSpec{
+ ID: "pth-" + strconv.Itoa(i),
+ Methods: []string{"GET"},
+ Path: "/api/v1/item/" + strconv.Itoa(i),
+ Cluster: "c-x-" + strconv.Itoa(i),
+ })
+ }
+ return out
+}
+
+func genRandomRequests(n int, seed int64) []*stdHttp.Request {
+ rnd := rand.New(rand.NewSource(seed))
+ reqs := make([]*stdHttp.Request, 0, n)
+ methods := []string{"GET", "POST"}
+
+ for i := 0; i < n; i++ {
+ var path string
+ switch rnd.Intn(5) { // NOSONAR
+ case 0: // exact style
+ path = "/api/v1/item/" + strconv.Itoa(rnd.Intn(50000))
// NOSONAR
+ case 1: // prefix style
+ path = "/api/v" + strconv.Itoa(1+rnd.Intn(3)) + "/svc"
+ strconv.Itoa(rnd.Intn(50)) + "/foo/bar" // NOSONAR
+ case 2: // var
+ path = "/users/" + strconv.Itoa(1000+rnd.Intn(9000)) //
NOSONAR
+ case 3: // var
+ path = "/shops/" + strconv.Itoa(rnd.Intn(100)) +
"/orders/" + strconv.Itoa(rnd.Intn(1000)) // NOSONAR
+ default:
+ path = "/unknown/" + strconv.Itoa(rnd.Intn(100000)) //
NOSONAR
+ }
+ req, _ := stdHttp.NewRequest(methods[rnd.Intn(len(methods))],
path, nil) // NOSONAR
+ // header-only
+ switch rnd.Intn(7) { // NOSONAR
+ case 0:
+ req.Header.Set("X-Env", "prod")
+ case 1:
+ req.Header.Set("X-Trace",
"pixiu-"+strconv.FormatInt(rnd.Int63()&0xffffffff, 16)) // NOSONAR
+ }
+ reqs = append(reqs, req)
+ }
+ return reqs
+}
+
+func newTestRouter(id, method, path, cluster string) *model.Router {
+ return &model.Router{
+ ID: id,
+ Match: model.RouterMatch{
+ Methods: []string{method},
+ Path: path,
+ },
+ Route: model.RouteAction{
+ Cluster: cluster,
+ },
+ }
+}
+
+func mustRouteCluster(t *testing.T, rm *RouterCoordinator, method, path
string, want string) {
+ t.Helper()
+ req, err := stdHttp.NewRequest(method, path, nil)
+ if err != nil {
+ t.Fatalf("failed to build request: %v", err)
+ }
+ ctx := http.HttpContext{Request: req}
+ act, err := rm.Route(&ctx)
+ if err != nil {
+ t.Fatalf("unexpected route error for %s %s: %v", method, path,
err)
+ }
+ if act == nil {
+ t.Fatalf("route action is nil for %s %s", method, path)
+ }
+ if act.Cluster != want {
+ t.Fatalf("unexpected cluster for %s %s, want %q, got %q",
method, path, want, act.Cluster)
+ }
+}
+
+func mustRouteNotFound(t *testing.T, rm *RouterCoordinator, method, path
string) {
+ t.Helper()
+ req, err := stdHttp.NewRequest(method, path, nil)
+ if err != nil {
+ t.Fatalf("failed to build request: %v", err)
+ }
+ ctx := http.HttpContext{Request: req}
+ act, err := rm.Route(&ctx)
+ if err == nil {
+ t.Fatalf("expected error for %s %s, got nil (action=%#v)",
method, path, act)
+ }
+}
+
+func mustRouteByPathAndNameCluster(t *testing.T, rm *RouterCoordinator,
method, path string, want string) {
+ t.Helper()
+ act, err := rm.RouteByPathAndName(path, method)
+ if err != nil {
+ t.Fatalf("unexpected RouteByPathAndName error for %s %s: %v",
method, path, err)
+ }
+ if act == nil {
+ t.Fatalf("route action is nil for %s %s", method, path)
+ }
+ if act.Cluster != want {
+ t.Fatalf("unexpected cluster for %s %s, want %q, got %q",
method, path, want, act.Cluster)
+ }
+}
+
+func mustRouteByPathAndNameNotFound(t *testing.T, rm *RouterCoordinator,
method, path string) {
+ t.Helper()
+ act, err := rm.RouteByPathAndName(path, method)
+ if err == nil {
+ t.Fatalf("expected RouteByPathAndName error for %s %s, got nil
(action=%#v)", method, path, act)
+ }
+}
+
+func TestCreateRouterCoordinatorInitialSnapshot(t *testing.T) {
+ r1 := newTestRouter("r1", "GET", "/foo", "cluster-foo")
+ r2 := newTestRouter("r2", "GET", "/bar", "cluster-bar")
+
+ cfg := &model.RouteConfiguration{
+ Routes: []*model.Router{r1, r2},
+ Dynamic: false,
+ }
+
+ rm := CreateRouterCoordinator(cfg)
+ rm.debounce = 0
+
+ mustRouteCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteCluster(t, rm, "GET", "/bar", "cluster-bar")
+
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/bar", "cluster-bar")
+}
+
+// TestRouterCoordinatorOnAddRouterUpdatesSnapshot 验证 Add 后新路由生效,旧路由保持
+func TestRouterCoordinatorOnAddRouterUpdatesSnapshot(t *testing.T) {
+ r1 := newTestRouter("r1", "GET", "/foo", "cluster-foo")
+
+ cfg := &model.RouteConfiguration{
+ Routes: []*model.Router{r1},
+ Dynamic: false,
+ }
+
+ rm := CreateRouterCoordinator(cfg)
+ rm.debounce = 0
+
+ // only /foo
+ mustRouteCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteNotFound(t, rm, "GET", "/bar")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteByPathAndNameNotFound(t, rm, "GET", "/bar")
+
+ // Add /bar
+ r2 := newTestRouter("r2", "GET", "/bar", "cluster-bar")
+ rm.OnAddRouter(r2)
+
+ // /foo available
+ mustRouteCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/foo", "cluster-foo")
+
+ // /bar available
+ mustRouteCluster(t, rm, "GET", "/bar", "cluster-bar")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/bar", "cluster-bar")
+}
+
+// TestRouterCoordinatorOnDeleteRouterUpdatesSnapshot 验证 Delete 后路由失效
+func TestRouterCoordinatorOnDeleteRouterUpdatesSnapshot(t *testing.T) {
+ r1 := newTestRouter("r1", "GET", "/foo", "cluster-foo")
+ r2 := newTestRouter("r2", "GET", "/bar", "cluster-bar")
+
+ cfg := &model.RouteConfiguration{
+ Routes: []*model.Router{r1, r2},
+ Dynamic: false,
+ }
+
+ rm := CreateRouterCoordinator(cfg)
+ rm.debounce = 0
+
+ // /foo、/bar available
+ mustRouteCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteCluster(t, rm, "GET", "/bar", "cluster-bar")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/bar", "cluster-bar")
+
+ // delete /bar
+ rm.OnDeleteRouter(r2)
+
+ // /foo available
+ mustRouteCluster(t, rm, "GET", "/foo", "cluster-foo")
+ mustRouteByPathAndNameCluster(t, rm, "GET", "/foo", "cluster-foo")
+
+ // /bar fail
+ mustRouteNotFound(t, rm, "GET", "/bar")
+ mustRouteByPathAndNameNotFound(t, rm, "GET", "/bar")
+}
+
+func TestOldVsNew_OnAddRouterWithSameID(t *testing.T) {
+ base := []*model.Router{
+ {
+ ID: "r1",
+ Match: model.RouterMatch{
+ Methods: []string{"POST"},
+ Path: "/api/v1/item/711",
+ },
+ Route: model.RouteAction{Cluster: "c-x-r1"},
+ },
+ }
+
+ oldc := buildOldCoordinator(base)
+ newc := buildNewCoordinator(base)
+ newc.debounce = 0
+
+ {
+ req, _ := stdHttp.NewRequest("POST", "/api/v1/item/711", nil)
+ ctxOld := http.HttpContext{Request: req}
+ ctxNew := http.HttpContext{Request: req}
+
+ oldRes, oldErr := oldc.Route(&ctxOld)
+ newRes, newErr := newc.Route(&ctxNew)
+
+ if oldErr != nil || newErr != nil {
+ t.Fatalf("initial route error: oldErr=%v newErr=%v",
oldErr, newErr)
+ }
+ if oldRes.Cluster != "c-x-r1" || newRes.Cluster != "c-x-r1" {
+ t.Fatalf("initial cluster mismatch: old=%v new=%v",
oldRes, newRes)
+ }
+ }
+
+ delta := &model.Router{
+ ID: "r1",
+ Match: model.RouterMatch{
+ Methods: []string{"POST"},
+ Path: "/api/v1/item/999999",
+ },
+ Route: model.RouteAction{Cluster: "c-x-r1"},
+ }
+
+ oldc.OnAddRouter(delta)
+ newc.OnAddRouter(delta)
+
+ {
+ req, _ := stdHttp.NewRequest("POST", "/api/v1/item/711", nil)
+ ctxOld := http.HttpContext{Request: req}
+ ctxNew := http.HttpContext{Request: req}
+
+ oldRes, oldErr := oldc.Route(&ctxOld)
+ newRes, newErr := newc.Route(&ctxNew)
+
+ assert.Equal(t, oldRes, newRes)
+ assert.Equal(t, oldErr, newErr)
+ }
+
+ {
+ req, _ := stdHttp.NewRequest("POST", "/api/v1/item/999999", nil)
+ ctxOld := http.HttpContext{Request: req}
+ ctxNew := http.HttpContext{Request: req}
+
+ oldRes, oldErr := oldc.Route(&ctxOld)
+ newRes, newErr := newc.Route(&ctxNew)
+
+ assert.Equal(t, oldRes, newRes)
+ assert.Equal(t, oldErr, newErr)
+ }
+}
diff --git a/pkg/common/util/stringutil/stringutil.go
b/pkg/common/util/stringutil/stringutil.go
index 23ba5eaf..405d0dec 100644
--- a/pkg/common/util/stringutil/stringutil.go
+++ b/pkg/common/util/stringutil/stringutil.go
@@ -59,32 +59,53 @@ func IsPathVariableOrWildcard(key string) bool {
// IsWildcard return if is *
func IsWildcard(key string) bool {
- return key == "*"
+ return key == constant.HeaderValueAll
}
+// IsMatchAll return if is **
func IsMatchAll(key string) bool {
- return key == "**"
+ return key == constant.HeaderValueAllLevels
}
func GetTrieKey(method string, path string) string {
+ // "http://localhost:8882/api/v1/test-dubbo/user?name=tc/"
ret := ""
- //"http://localhost:8882/api/v1/test-dubbo/user?name=tc"
+
if strings.Contains(path, constant.ProtocolSlash) {
path = path[strings.Index(path,
constant.ProtocolSlash)+len(constant.ProtocolSlash):]
path = path[strings.Index(path, constant.PathSlash)+1:]
}
+ // "api/v1/test-dubbo/user?name=tc/"
+
if strings.HasPrefix(path, constant.PathSlash) {
ret = method + path
} else {
ret = method + constant.PathSlash + path
}
+ // "METHOD/api/v1/test-dubbo/user?name=tc/"
+
if strings.HasSuffix(ret, constant.PathSlash) {
ret = ret[0 : len(ret)-1]
}
+ // "METHOD/api/v1/test-dubbo/user?name=tc"
+
ret = strings.Split(ret, "?")[0]
+ // "METHOD/api/v1/test-dubbo/user"
+
return ret
}
+func GetTrieKeyWithPrefix(method, path, prefix string, isPrefix bool) string {
+ if isPrefix {
+ if prefix != "" && prefix[len(prefix)-1] != '/' {
+ prefix += constant.PathSlash
+ }
+ prefix += constant.HeaderValueAllLevels
+ return GetTrieKey(method, prefix)
+ }
+ return GetTrieKey(method, path)
+}
+
func GetIPAndPort(address string) ([]*net.TCPAddr, error) {
if len(address) <= 0 {
return nil, errors.Errorf("invalid address, %s", address)
diff --git a/pkg/model/router.go b/pkg/model/router.go
index d3e7fadb..ff4f6a9e 100644
--- a/pkg/model/router.go
+++ b/pkg/model/router.go
@@ -97,7 +97,7 @@ func (rc *RouteConfiguration) Route(req *stdHttp.Request)
(*RouteAction, error)
return rc.RouteByPathAndMethod(req.URL.Path, req.Method)
}
-// MatchHeader used when there's only headers to match
+// MatchHeader used when there are only headers to match
func (rm *RouterMatch) MatchHeader(req *stdHttp.Request) bool {
if len(rm.Methods) > 0 {
for _, method := range rm.Methods {
@@ -146,7 +146,12 @@ func (hm *HeaderMatcher) SetValueRegex(regex string) error
{
func (r *Router) String() string {
var builder strings.Builder
builder.WriteString("[" + strings.Join(r.Match.Methods, ",") + "] ")
- if r.Match.Prefix != "" {
+ if r.Match.Path == "" && r.Match.Prefix == "" && len(r.Match.Headers) >
0 {
+ builder.WriteString("headers ")
+ for _, h := range r.Match.Headers {
+ builder.WriteString(h.Name + "=" +
strings.Join(h.Values, "|"))
+ }
+ } else if r.Match.Prefix != "" {
builder.WriteString("prefix " + r.Match.Prefix)
} else {
builder.WriteString("path " + r.Match.Path)
diff --git a/pkg/model/router_snapshot.go b/pkg/model/router_snapshot.go
new file mode 100644
index 00000000..bae890cd
--- /dev/null
+++ b/pkg/model/router_snapshot.go
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package model
+
+import (
+ "regexp"
+ "sync"
+)
+
+import (
+ "github.com/apache/dubbo-go-pixiu/pkg/common/router/trie"
+ "github.com/apache/dubbo-go-pixiu/pkg/common/util/stringutil"
+ "github.com/apache/dubbo-go-pixiu/pkg/logger"
+)
+
+var (
+ constMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH",
"OPTIONS", "HEAD"}
+)
+
+// RouteSnapshot Read-only snapshot for routing
+type RouteSnapshot struct {
+ // multi-trie for each method, built once and read-only
+ MethodTries map[string]*trie.Trie
+
+ // precompiled regex for header-only routes
+ HeaderOnly []HeaderRoute
+}
+
+type HeaderRoute struct {
+ Methods []string
+ Headers []CompiledHeader
+ Action RouteAction
+}
+
+type CompiledHeader struct {
+ Name string
+ Regex *regexp.Regexp
+ Values []string
+}
+
+func MethodAllowed(methods []string, m string) bool {
+ if len(methods) == 0 {
+ return true
+ }
+ for _, x := range methods {
+ if x == m {
+ return true
+ }
+ }
+ return false
+}
+
+var regexCache sync.Map // map[string]*regexp.Regexp
+
+func getRegexpWithCache(pat string) *regexp.Regexp {
+ if v, ok := regexCache.Load(pat); ok {
+ return v.(*regexp.Regexp)
+ }
+ // Compile fail return nil (caller will ignore this regex)
+ re, err := regexp.Compile(pat)
+ if err != nil {
+ return nil
+ }
+ if v, ok := regexCache.LoadOrStore(pat, re); ok {
+ return v.(*regexp.Regexp)
+ }
+ return re
+}
+
+// compiledHeaderSlicePool is a pool for temporary []CompiledHeader slices
during snapshot building
+var compiledHeaderSlicePool = sync.Pool{
+ New: func() any {
+ s := make([]CompiledHeader, 0, 4) // start with small capacity,
grow as needed
+ return &s
+ },
+}
+
+func ToSnapshot(routes []*Router) *RouteSnapshot {
+ s := &RouteSnapshot{
+ MethodTries: make(map[string]*trie.Trie, 8),
+ }
+
+ // pre-scan header-only routes count
+ headerOnlyCount := 0
+ for _, r := range routes {
+ if r.Match.Path == "" && r.Match.Prefix == "" &&
len(r.Match.Headers) > 0 {
+ headerOnlyCount++
+ }
+ }
+
+ if headerOnlyCount > 0 {
+ s.HeaderOnly = make([]HeaderRoute, 0, headerOnlyCount)
+ }
+
+ // part to get or create trie for a method
+ getTrie := func(m string) *trie.Trie {
+ if t := s.MethodTries[m]; t != nil {
+ return t
+ }
+ nt := trie.NewTrie()
+ s.MethodTries[m] = &nt
+ return &nt
+ }
+
+ for _, r := range routes {
+ // A) header-only:with Headers, without Path / Prefix
+ if r.Match.Path == "" && r.Match.Prefix == "" &&
len(r.Match.Headers) > 0 {
+ hr := HeaderRoute{
+ Methods: r.Match.Methods,
+ Action: r.Route,
+ }
+
+ // use temporary slice from pool to build compiled
headers
+ chPtr :=
compiledHeaderSlicePool.Get().(*[]CompiledHeader)
+ ch := (*chPtr)[:0] // reset
+
+ for _, h := range r.Match.Headers {
+ c := CompiledHeader{Name: h.Name}
+ if h.Regex {
+ // 1) the model already has compiled
regex (if any) → use it directly
+ if h.valueRE != nil {
+ c.Regex = h.valueRE
+ } else if len(h.Values) > 0 &&
h.Values[0] != "" {
+ // 2) else use global
cache/compile (cross-snapshot reuse)
+ if re :=
getRegexpWithCache(h.Values[0]); re != nil {
+ c.Regex = re
+ } else {
+ // invalid regex → skip
this header matcher
+ logger.Errorf("Header
regex compiled fail for %v", h.Values[0])
+ continue
+ }
+ }
+ } else {
+ // not regex → copy values directly (if
any)
+ if len(h.Values) > 0 {
+ // direct assignment is ok here
(string slice)
+ c.Values = append(c.Values,
h.Values...)
+ }
+ }
+ ch = append(ch, c)
+ }
+
+ // move the temporary slice content to snapshot
(ownership transferred)
+ hr.Headers = make([]CompiledHeader, len(ch))
+ copy(hr.Headers, ch)
+
+ // reset and put back the temporary slice to pool
+ *chPtr = (*chPtr)[:0]
+ compiledHeaderSlicePool.Put(chPtr)
+
+ s.HeaderOnly = append(s.HeaderOnly, hr)
+ continue
+ }
+
+ // B) Trie
+ methods := r.Match.Methods
+ if len(methods) == 0 {
+ methods = constMethods
+ }
+ for _, m := range methods {
+ t := getTrie(m)
+ t.Put(stringutil.GetTrieKeyWithPrefix(m, r.Match.Path,
r.Match.Prefix, r.Match.Prefix != ""), r.Route)
+ }
+ }
+ return s
+}