diff --git a/pkg/common/constant/http.go b/pkg/common/constant/http.go index bbb98dd43..a94f2dab4 100644 --- a/pkg/common/constant/http.go +++ b/pkg/common/constant/http.go @@ -51,7 +51,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 33080f381..69cb44587 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/mock/router.go b/pkg/common/router/mock/router.go new file mode 100644 index 000000000..77f414843 --- /dev/null +++ b/pkg/common/router/mock/router.go @@ -0,0 +1,172 @@ +/* + * 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 mock + +import ( + stdHttp "net/http" + "strings" + "sync" +) + +import ( + "github.com/pkg/errors" +) + +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" + "github.com/apache/dubbo-go-pixiu/pkg/logger" + "github.com/apache/dubbo-go-pixiu/pkg/model" + "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 + } +) + +// CreateRouterCoordinator create coordinator for http connection manager +func CreateRouterCoordinator(routeConfig *model.RouteConfiguration) *RouterCoordinator { + rc := &RouterCoordinator{activeConfig: routeConfig} + if routeConfig.Dynamic { + server.GetRouterManager().AddRouterListener(rc) + } + rc.initTrie() + rc.initRegex() + 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) +} + +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 { + continue + } + if route.Match.MatchHeader(req) { + matched = append(matched, route) + } + } + + // 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) +} + +func getTrieKey(method string, path string, isPrefix bool) string { + if isPrefix { + if !strings.HasSuffix(path, constant.PathSlash) { + path = path + constant.PathSlash + } + path = path + "**" + } + return stringutil.GetTrieKey(method, path) +} + +func (rm *RouterCoordinator) initTrie() { + if rm.activeConfig.RouteTrie.IsEmpty() { + rm.activeConfig.RouteTrie = trie.NewTrie() + } + for _, router := range rm.activeConfig.Routes { + rm.OnAddRouter(router) + } +} + +func (rm *RouterCoordinator) initRegex() { + for _, router := range rm.activeConfig.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) + } + } + } + } +} + +// OnAddRouter add router +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.activeConfig.RouteTrie.Put(key, r.Route) + } +} + +// OnDeleteRouter delete router +func (rm *RouterCoordinator) OnDeleteRouter(r *model.Router) { + rm.rw.Lock() + defer rm.rw.Unlock() + + if r.Match.Methods == nil { + r.Match.Methods = []string{constant.Get, constant.Put, constant.Delete, constant.Post} + } + 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.activeConfig.RouteTrie.Remove(key) + } +} diff --git a/pkg/common/router/router.go b/pkg/common/router/router.go index cd175eb2a..fe78ea7df 100644 --- a/pkg/common/router/router.go +++ b/pkg/common/router/router.go @@ -19,8 +19,9 @@ package router import ( stdHttp "net/http" - "strings" "sync" + "sync/atomic" + "time" ) import ( @@ -28,7 +29,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,94 +37,148 @@ 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 { + active snapshotHolder // atomic snapshot + mu sync.Mutex + store map[string]*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{ + store: make(map[string]*model.Router), + debounce: 50 * time.Millisecond, // merge window + } if routeConfig.Dynamic { server.GetRouterManager().AddRouterListener(rc) } - rc.initTrie() - rc.initRegex() + // build initial config and store snapshot + first := buildConfig(routeConfig.Routes) + rc.active.store(model.ToSnapshot(first)) + // copy initial routes to store + for _, r := range routeConfig.Routes { + rc.store[r.ID] = r + } 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.active.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 := node.GetBizInfo().(model.RouteAction) + 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.active.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) { + 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 := node.GetBizInfo().(model.RouteAction) + 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() +// 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, 0, len(rm.store)) + for _, r := range rm.store { + next = append(next, r) } - for _, router := range rm.activeConfig.Routes { - rm.OnAddRouter(router) + // 2) build new config + cfg := buildConfig(next) + // 3) atomic switch + rm.active.store(model.ToSnapshot(cfg)) +} + +func buildConfig(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) } } } @@ -133,40 +187,54 @@ func (rm *RouterCoordinator) initRegex() { // OnAddRouter add router 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.store[r.ID] = 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"} + } + isPrefix := r.Match.Prefix != "" + for _, m := range methods { + key := stringutil.GetTrieKeyWithPrefix(m, r.Match.Path, r.Match.Prefix, isPrefix) + _, _ = 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() - - if r.Match.Methods == nil { - r.Match.Methods = []string{constant.Get, constant.Put, constant.Delete, constant.Post} - } - 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() + delete(rm.store, r.ID) + rm.schedulePublishLocked() +} + +type snapshotHolder struct { + ptr atomic.Pointer[model.RouteSnapshot] +} + +func (h *snapshotHolder) load() *model.RouteSnapshot { return h.ptr.Load() } +func (h *snapshotHolder) store(s *model.RouteSnapshot) { h.ptr.Store(s) } + +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 { + return ch.Regex.MatchString(val) + } + + for _, src := range ch.Values { + if src == val { + return true + } + } } - _, _ = rm.activeConfig.RouteTrie.Remove(key) } + return false } diff --git a/pkg/common/router/router_bench_test.go b/pkg/common/router/router_bench_test.go new file mode 100644 index 000000000..c8598cd44 --- /dev/null +++ b/pkg/common/router/router_bench_test.go @@ -0,0 +1,429 @@ +/* + * 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" + "strconv" + "testing" +) + +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 +} + +// ============= 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) + + 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) + + 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"}} + oldBase := genRoutes(shape) + newBase := genRoutes(shape) + + oldc := buildOldCoordinator(oldBase) + newc := buildNewCoordinator(newBase) + + 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(oldBase, 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(newBase, 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)) + + 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)) + + 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"}, + } + oldBase := genRoutes(shape) + newBase := genRoutes(shape) + + oldc := buildOldCoordinator(genRoutes(shape)) + newc := buildNewCoordinator(genRoutes(shape)) + + 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(oldBase, 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(newBase, 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" + + 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 464415149..0fce87355 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,8 +61,8 @@ func TestCreateRouterCoordinator(t *testing.T) { }, } - r.OnAddRouter(router) - r.OnDeleteRouter(router) + coordinator.OnAddRouter(router) + coordinator.OnDeleteRouter(router) } func TestRoute(t *testing.T) { @@ -144,7 +134,7 @@ func TestRoute(t *testing.T) { Header: map[string]string{ "A": "1", }, - Expect: "test-cluster-1", + Expect: Cluster1, }, { Name: "one header matched", @@ -205,7 +195,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 +214,402 @@ 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 +} diff --git a/pkg/common/util/stringutil/stringutil.go b/pkg/common/util/stringutil/stringutil.go index 23ba5eafd..d564ddbe3 100644 --- a/pkg/common/util/stringutil/stringutil.go +++ b/pkg/common/util/stringutil/stringutil.go @@ -67,24 +67,43 @@ func IsMatchAll(key string) bool { } 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 d3e7fadb5..ff4f6a9e6 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 000000000..10112ef0b --- /dev/null +++ b/pkg/model/router_snapshot.go @@ -0,0 +1,184 @@ +/* + * 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" + "sync/atomic" +) + +import ( + "github.com/apache/dubbo-go-pixiu/pkg/common/router/trie" + "github.com/apache/dubbo-go-pixiu/pkg/common/util/stringutil" +) + +// 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 +} + +// SnapshotHolder holds current active snapshot +type SnapshotHolder struct { + ptr atomic.Pointer[RouteSnapshot] +} + +func (h *SnapshotHolder) Load() *RouteSnapshot { return h.ptr.Load() } +func (h *SnapshotHolder) Store(s *RouteSnapshot) { h.ptr.Store(s) } + +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 getCachedRegexp(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(cfg *RouteConfiguration) *RouteSnapshot { + // pre-scan header-only routes count + headerOnlyCount := 0 + for _, r := range cfg.Routes { + if r.Match.Path == "" && r.Match.Prefix == "" && len(r.Match.Headers) > 0 { + headerOnlyCount++ + } + } + + s := &RouteSnapshot{ + MethodTries: make(map[string]*trie.Trie, 8), + } + if headerOnlyCount > 0 { + s.HeaderOnly = make([]HeaderRoute, 0, headerOnlyCount) + } + + constMethods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"} + + // 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 cfg.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 := getCachedRegexp(h.Values[0]); re != nil { + c.Regex = re + } + } + } 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 + isPrefix := r.Match.Prefix != "" + methods := r.Match.Methods + if len(methods) == 0 { + methods = constMethods // use constant slice to avoid allocation + } + for _, m := range methods { + t := getTrie(m) + key := stringutil.GetTrieKeyWithPrefix(m, r.Match.Path, r.Match.Prefix, isPrefix) + _, _ = t.Put(key, r.Route) + } + } + return s +}