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.git


The following commit(s) were added to refs/heads/develop by this push:
     new 707124f71 feat(router): add static router configuration injection 
(#3252)
707124f71 is described below

commit 707124f719536a926a3171a5932785fb86028321
Author: Aether <[email protected]>
AuthorDate: Sun Apr 12 20:34:53 2026 +0800

    feat(router): add static router configuration injection (#3252)
    
    * feat(router): add static router configuration injection
    
    - Add StaticConfigSetter interface for static config injection
    - Add global static router registry
    - Add router chain injection mechanism
    - Implement static config setters for condition and tag routers
    
    * add unit tests
    
    * fix(client): make setRouters use override semantics
    
    * docs(client): clarify router option append/replace semantics
    
    * fix(router): clone static router config before injecting setters
    
    * fix(tag-router): guard nil router flags in tag routing
    
    * docs(router): clarify static injection and dynamic override semantics
    
    * docs(client): document WithRouter append behavior
    
    * docs(router): clarify dynamic Process overrides static router config
    
    * remove unnecessary outer clone in injectRouterConfig
    
    * fix: ignore service-scope static tag configs
    
    * refactor: remove redundant nil check when appending routers
    
    * fix(router): remove chain-level static router scope matching
    
    * fix(router): log static router config type assertion failures as errors
    
    ---------
    
    Co-authored-by: Aetherance <[email protected]>
---
 client/action.go                           |   1 +
 client/client.go                           |   1 +
 client/options.go                          |  44 ++++++
 client/options_test.go                     |  70 ++++++++++
 cluster/router/chain/chain.go              |  53 ++++++++
 cluster/router/chain/chain_test.go         | 206 +++++++++++++++++++++++++++++
 cluster/router/condition/dynamic_router.go |  58 ++++++++
 cluster/router/condition/router_test.go    |  88 ++++++++++++
 cluster/router/router.go                   |  18 +++
 cluster/router/tag/match.go                |   2 +-
 cluster/router/tag/router.go               |  28 +++-
 cluster/router/tag/router_test.go          | 127 ++++++++++++++++++
 common/constant/key.go                     |   3 +
 dubbo.go                                   |   4 +
 14 files changed, 701 insertions(+), 2 deletions(-)

diff --git a/client/action.go b/client/action.go
index 2b9aca347..643126208 100644
--- a/client/action.go
+++ b/client/action.go
@@ -155,6 +155,7 @@ func (refOpts *ReferenceOptions) refer(srv 
common.RPCService, info *ClientInfo)
                common.WithAttribute(constant.ConsumerConfigKey, 
refOpts.Consumer),
                common.WithAttribute(constant.ProtocolConfigKey, ref.Protocol),
                common.WithAttribute(constant.RegistriesConfigKey, 
refOpts.Registries),
+               common.WithAttribute(constant.RoutersConfigKey, 
refOpts.Routers),
        )
 
        // for new triple IDL mode
diff --git a/client/client.go b/client/client.go
index e443e5da6..794da976d 100644
--- a/client/client.go
+++ b/client/client.go
@@ -199,6 +199,7 @@ func (cli *Client) dial(interfaceName string, info 
*ClientInfo, srv any, opts ..
                setOtel(cli.cliOpts.Otel),
                setTLS(cli.cliOpts.TLS),
                setProtocols(cli.cliOpts.Protocols),
+               setRouters(cli.cliOpts.Routers),
                // this config must be set after Reference initialized
                setInterfaceName(interfaceName),
        }
diff --git a/client/options.go b/client/options.go
index 273deeb69..2be018441 100644
--- a/client/options.go
+++ b/client/options.go
@@ -50,6 +50,7 @@ type ReferenceOptions struct {
        TLS         *global.TLSConfig
        Protocols   map[string]*global.ProtocolConfig
        Registries  map[string]*global.RegistryConfig
+       Routers     []*global.RouterConfig
 
        pxy          *proxy.Proxy
        id           string
@@ -445,6 +446,17 @@ func WithParam(k, v string) ReferenceOption {
        }
 }
 
+// WithRouter appends router configurations to the reference options.
+// This is a user-facing option for incrementally adding routers.
+// It appends to the current router config slice instead of replacing it.
+func WithRouter(routers ...*global.RouterConfig) ReferenceOption {
+       return func(opts *ReferenceOptions) {
+               if len(routers) > 0 {
+                       opts.Routers = append(opts.Routers, routers...)
+               }
+       }
+}
+
 // ---------- For framework ----------
 // These functions should not be invoked by users
 
@@ -512,6 +524,15 @@ func setRegistries(regs map[string]*global.RegistryConfig) 
ReferenceOption {
        }
 }
 
+// setRouters sets the routers configuration for the service reference.
+// This is an internal framework function for applying router settings to
+// reference options. It replaces the current router slice.
+func setRouters(routers []*global.RouterConfig) ReferenceOption {
+       return func(opts *ReferenceOptions) {
+               opts.Routers = routers
+       }
+}
+
 type ClientOptions struct {
        Consumer    *global.ConsumerConfig
        Application *global.ApplicationConfig
@@ -521,6 +542,7 @@ type ClientOptions struct {
        Otel        *global.OtelConfig
        TLS         *global.TLSConfig
        Protocols   map[string]*global.ProtocolConfig
+       Routers     []*global.RouterConfig
 
        overallReference *global.ReferenceConfig
 }
@@ -835,6 +857,17 @@ func WithClientParam(k, v string) ClientOption {
        }
 }
 
+// WithClientRouter appends router configurations to the client options.
+// This is a user-facing option for incrementally adding routers.
+// It appends to the current router slice instead of replacing it.
+func WithClientRouter(routers ...*global.RouterConfig) ClientOption {
+       return func(opts *ClientOptions) {
+               if len(routers) > 0 {
+                       opts.Routers = append(opts.Routers, routers...)
+               }
+       }
+}
+
 // todo(DMwangnima): implement this functionality
 // func WithClientGeneric(generic bool) ClientOption {
 //     return func(opts *ClientOptions) {
@@ -953,6 +986,17 @@ func SetClientProtocols(protocols 
map[string]*global.ProtocolConfig) ClientOptio
        }
 }
 
+// SetClientRouters sets the routers configuration for the client.
+// This is an internal framework function for applying router settings to
+// client options.
+// End users should not use this function for configuration.
+// It replaces the current router slice instead of appending to it.
+func SetClientRouters(routers []*global.RouterConfig) ClientOption {
+       return func(opts *ClientOptions) {
+               opts.Routers = routers
+       }
+}
+
 // todo: need to be consistent with MethodConfig
 type CallOptions struct {
        RequestTimeout string
diff --git a/client/options_test.go b/client/options_test.go
index 7d7141023..9b8bf4505 100644
--- a/client/options_test.go
+++ b/client/options_test.go
@@ -1063,6 +1063,76 @@ func TestWithParams(t *testing.T) {
        processReferenceOptionsInitCases(t, cases)
 }
 
+func TestWithRouter(t *testing.T) {
+       router1 := &global.RouterConfig{Key: "svc-a"}
+       router2 := &global.RouterConfig{Key: "svc-b"}
+
+       cases := []referenceOptionsInitCase{
+               {
+                       desc: "append router configs",
+                       opts: []ReferenceOption{
+                               WithRouter(router1, router2),
+                       },
+                       verify: func(t *testing.T, refOpts *ReferenceOptions, 
err error) {
+                               require.NoError(t, err)
+                               assert.Equal(t, []*global.RouterConfig{router1, 
router2}, refOpts.Routers)
+                       },
+               },
+               {
+                       desc: "ignore empty router configs",
+                       opts: []ReferenceOption{
+                               WithRouter(),
+                       },
+                       verify: func(t *testing.T, refOpts *ReferenceOptions, 
err error) {
+                               require.NoError(t, err)
+                               assert.Nil(t, refOpts.Routers)
+                       },
+               },
+               {
+                       desc: "framework setter replaces router configs",
+                       opts: []ReferenceOption{
+                               setRouters([]*global.RouterConfig{router1}),
+                               setRouters([]*global.RouterConfig{router2}),
+                       },
+                       verify: func(t *testing.T, refOpts *ReferenceOptions, 
err error) {
+                               require.NoError(t, err)
+                               assert.Equal(t, 
[]*global.RouterConfig{router2}, refOpts.Routers)
+                       },
+               },
+       }
+       processReferenceOptionsInitCases(t, cases)
+}
+
+func TestClientRouterOptions(t *testing.T) {
+       router1 := &global.RouterConfig{Key: "app-a"}
+       router2 := &global.RouterConfig{Key: "app-b"}
+
+       cases := []newClientCase{
+               {
+                       desc: "append client router configs",
+                       opts: []ClientOption{
+                               WithClientRouter(router1),
+                               WithClientRouter(router2),
+                       },
+                       verify: func(t *testing.T, cli *Client, err error) {
+                               require.NoError(t, err)
+                               assert.Equal(t, []*global.RouterConfig{router1, 
router2}, cli.cliOpts.Routers)
+                       },
+               },
+               {
+                       desc: "framework setter replaces client router configs",
+                       opts: []ClientOption{
+                               
SetClientRouters([]*global.RouterConfig{router1, router2}),
+                       },
+                       verify: func(t *testing.T, cli *Client, err error) {
+                               require.NoError(t, err)
+                               assert.Equal(t, []*global.RouterConfig{router1, 
router2}, cli.cliOpts.Routers)
+                       },
+               },
+       }
+       processNewClientCases(t, cases)
+}
+
 //func TestWithClientParam(t *testing.T) {
 //     cases := []newClientCase{
 //             {
diff --git a/cluster/router/chain/chain.go b/cluster/router/chain/chain.go
index d849c5804..c64968be7 100644
--- a/cluster/router/chain/chain.go
+++ b/cluster/router/chain/chain.go
@@ -35,6 +35,7 @@ import (
        "dubbo.apache.org/dubbo-go/v3/common"
        "dubbo.apache.org/dubbo-go/v3/common/constant"
        "dubbo.apache.org/dubbo-go/v3/common/extension"
+       "dubbo.apache.org/dubbo-go/v3/global"
        "dubbo.apache.org/dubbo-go/v3/protocol/base"
 )
 
@@ -110,6 +111,53 @@ func (c *RouterChain) copyRouters() 
[]router.PriorityRouter {
        return ret
 }
 
+// injectStaticRouters injects static router configurations into the router 
chain.
+// Called after all routers are created to ensure they exist.
+// The injected static configs act as bootstrap state only during 
initialization. For the shared
+// static and dynamic lifecycle semantics, see router.StaticConfigSetter.
+func (c *RouterChain) injectStaticRouters(url *common.URL) {
+       staticRoutersAttrAny, ok := url.GetAttribute(constant.RoutersConfigKey)
+       if !ok && url.SubURL != nil {
+               staticRoutersAttrAny, ok = 
url.SubURL.GetAttribute(constant.RoutersConfigKey)
+       }
+       if !ok {
+               return
+       }
+       staticRoutersAttr, ok := staticRoutersAttrAny.([]*global.RouterConfig)
+       if !ok {
+               logger.Errorf("failed to type assert routers config: expected 
[]*global.RouterConfig, got %T", staticRoutersAttrAny)
+               return
+       }
+       if len(staticRoutersAttr) == 0 {
+               return
+       }
+
+       for _, routerCfg := range staticRoutersAttr {
+               if routerCfg == nil {
+                       continue
+               }
+               if routerCfg.Enabled != nil && !*routerCfg.Enabled {
+                       continue
+               }
+               if routerCfg.Valid != nil && !*routerCfg.Valid {
+                       continue
+               }
+               c.injectRouterConfig(routerCfg)
+       }
+}
+
+// injectRouterConfig injects router configuration into routers that implement 
StaticConfigSetter.
+// Each router decides whether the config applies to it and no-ops if not.
+func (c *RouterChain) injectRouterConfig(routerCfg *global.RouterConfig) {
+       c.mutex.RLock()
+       defer c.mutex.RUnlock()
+       for _, r := range c.routers {
+               if setter, ok := r.(router.StaticConfigSetter); ok {
+                       setter.SetStaticConfig(routerCfg.Clone())
+               }
+       }
+}
+
 // NewRouterChain init router chain
 // Loop routerFactories and call NewRouter method
 func NewRouterChain(url *common.URL) (*RouterChain, error) {
@@ -150,6 +198,11 @@ func NewRouterChain(url *common.URL) (*RouterChain, error) 
{
                builtinRouters: routers,
        }
 
+       // Inject static router configurations after all routers are created.
+       // This happens before the first registry notification triggers dynamic 
Notify/Process
+       // updates on builtin routers.
+       chain.injectStaticRouters(url)
+
        return chain, nil
 }
 
diff --git a/cluster/router/chain/chain_test.go 
b/cluster/router/chain/chain_test.go
index afdc39420..96557c608 100644
--- a/cluster/router/chain/chain_test.go
+++ b/cluster/router/chain/chain_test.go
@@ -29,10 +29,216 @@ import (
 import (
        "dubbo.apache.org/dubbo-go/v3/cluster/router"
        "dubbo.apache.org/dubbo-go/v3/common"
+       "dubbo.apache.org/dubbo-go/v3/common/constant"
+       "dubbo.apache.org/dubbo-go/v3/global"
        "dubbo.apache.org/dubbo-go/v3/protocol/base"
        "dubbo.apache.org/dubbo-go/v3/protocol/invocation"
 )
 
+type mockStaticRouter struct {
+       configs []*global.RouterConfig
+}
+
+func (m *mockStaticRouter) Route(invokers []base.Invoker, _ *common.URL, _ 
base.Invocation) []base.Invoker {
+       return invokers
+}
+
+func (m *mockStaticRouter) URL() *common.URL {
+       return nil
+}
+
+func (m *mockStaticRouter) Priority() int64 {
+       return 0
+}
+
+func (m *mockStaticRouter) Notify(_ []base.Invoker) {
+}
+
+func (m *mockStaticRouter) SetStaticConfig(cfg *global.RouterConfig) {
+       m.configs = append(m.configs, cfg)
+}
+
+type mutatingStaticRouter struct {
+       configs []*global.RouterConfig
+}
+
+func (m *mutatingStaticRouter) Route(invokers []base.Invoker, _ *common.URL, _ 
base.Invocation) []base.Invoker {
+       return invokers
+}
+
+func (m *mutatingStaticRouter) URL() *common.URL {
+       return nil
+}
+
+func (m *mutatingStaticRouter) Priority() int64 {
+       return 0
+}
+
+func (m *mutatingStaticRouter) Notify(_ []base.Invoker) {
+}
+
+func (m *mutatingStaticRouter) SetStaticConfig(cfg *global.RouterConfig) {
+       if cfg != nil {
+               if cfg.Force != nil {
+                       *cfg.Force = !*cfg.Force // NOSONAR
+               }
+               if len(cfg.Conditions) > 0 {
+                       cfg.Conditions[0] = "mutated" // NOSONAR
+               }
+       }
+       m.configs = append(m.configs, cfg)
+}
+
+type mockRouter struct{}
+
+func (m *mockRouter) Route(invokers []base.Invoker, _ *common.URL, _ 
base.Invocation) []base.Invoker {
+       return invokers
+}
+
+func (m *mockRouter) URL() *common.URL {
+       return nil
+}
+
+func (m *mockRouter) Priority() int64 {
+       return 0
+}
+
+func (m *mockRouter) Notify(_ []base.Invoker) {
+}
+
+func TestInjectStaticRouters(t *testing.T) {
+       trueValue := true
+       falseValue := false
+
+       staticRouter := &mockStaticRouter{}
+       chain := &RouterChain{
+               routers: []router.PriorityRouter{
+                       staticRouter,
+                       &mockRouter{},
+               },
+       }
+
+       url := common.NewURLWithOptions(
+               common.WithProtocol("consumer"),
+               common.WithPath("consumer.path"),
+       )
+       url.SubURL = common.NewURLWithOptions(
+               common.WithProtocol("dubbo"),
+               common.WithPath("svc.test"),
+       )
+       url.SubURL.SetParam(constant.ApplicationKey, "app.test")
+       url.SubURL.SetAttribute(constant.RoutersConfigKey, 
[]*global.RouterConfig{
+               nil,
+               {
+                       Scope:   constant.RouterScopeService,
+                       Key:     "svc.test",
+                       Enabled: &trueValue,
+                       Valid:   &trueValue,
+               },
+               {
+                       Scope: constant.RouterScopeApplication,
+                       Key:   "app.test",
+               },
+               {
+                       Scope:   constant.RouterScopeApplication,
+                       Key:     "disabled",
+                       Enabled: &falseValue,
+               },
+               {
+                       Scope: constant.RouterScopeApplication,
+                       Key:   "invalid",
+                       Valid: &falseValue,
+               },
+               {
+                       Scope: constant.RouterScopeApplication,
+                       Key:   "other-app",
+               },
+       })
+
+       chain.injectStaticRouters(url)
+
+       if assert.Len(t, staticRouter.configs, 3) {
+               assert.Equal(t, "svc.test", staticRouter.configs[0].Key)
+               assert.Equal(t, "app.test", staticRouter.configs[1].Key)
+               assert.Equal(t, "other-app", staticRouter.configs[2].Key)
+       }
+}
+
+func TestInjectStaticRouters_InvalidAttributeType(t *testing.T) {
+       staticRouter := &mockStaticRouter{}
+       chain := &RouterChain{
+               routers: []router.PriorityRouter{staticRouter},
+       }
+
+       url := common.NewURLWithOptions(
+               common.WithProtocol("consumer"),
+               common.WithPath("svc.test"),
+       )
+       url.SetAttribute(constant.RoutersConfigKey, "invalid")
+
+       chain.injectStaticRouters(url)
+
+       assert.Empty(t, staticRouter.configs)
+}
+
+func TestInjectStaticRouters_RegistryURLUsesSubURLConfig(t *testing.T) {
+       staticRouter := &mockStaticRouter{}
+       chain := &RouterChain{
+               routers: []router.PriorityRouter{staticRouter},
+       }
+
+       registryURL := common.NewURLWithOptions(
+               common.WithProtocol(constant.RegistryProtocol),
+               common.WithPath("registry"),
+       )
+       registryURL.SubURL = common.NewURLWithOptions(
+               common.WithProtocol("consumer"),
+               common.WithPath("consumer.path"),
+       )
+       registryURL.SubURL.SetAttribute(constant.RoutersConfigKey, 
[]*global.RouterConfig{{
+               Scope: constant.RouterScopeApplication,
+               Key:   "provider-app",
+       }})
+
+       chain.injectStaticRouters(registryURL)
+
+       if assert.Len(t, staticRouter.configs, 1) {
+               assert.Equal(t, "provider-app", staticRouter.configs[0].Key)
+       }
+}
+
+func TestInjectRouterConfig_ClonePerSetter(t *testing.T) {
+       trueValue := true
+       mutatingRouter := &mutatingStaticRouter{}
+       observingRouter := &mockStaticRouter{}
+       chain := &RouterChain{
+               routers: []router.PriorityRouter{
+                       mutatingRouter,
+                       observingRouter,
+               },
+       }
+
+       routerCfg := &global.RouterConfig{
+               Force:      &trueValue,
+               Conditions: []string{"original"}, // NOSONAR
+       }
+
+       chain.injectRouterConfig(routerCfg)
+
+       if assert.Len(t, mutatingRouter.configs, 1) && assert.Len(t, 
observingRouter.configs, 1) {
+               assert.NotSame(t, routerCfg, mutatingRouter.configs[0])
+               assert.NotSame(t, routerCfg, observingRouter.configs[0])
+               assert.NotSame(t, mutatingRouter.configs[0], 
observingRouter.configs[0])
+               assert.False(t, *mutatingRouter.configs[0].Force)
+               assert.Equal(t, "mutated", 
mutatingRouter.configs[0].Conditions[0]) // NOSONAR
+               assert.True(t, *observingRouter.configs[0].Force)
+               assert.Equal(t, "original", 
observingRouter.configs[0].Conditions[0]) // NOSONAR
+       }
+
+       assert.True(t, *routerCfg.Force)
+       assert.Equal(t, "original", routerCfg.Conditions[0]) // NOSONAR
+}
+
 const testConsumerServiceURL = "consumer://127.0.0.1/com.demo.Service"
 
 type testPriorityRouter struct {
diff --git a/cluster/router/condition/dynamic_router.go 
b/cluster/router/condition/dynamic_router.go
index 4bdc34c3e..7b61a686b 100644
--- a/cluster/router/condition/dynamic_router.go
+++ b/cluster/router/condition/dynamic_router.go
@@ -134,6 +134,48 @@ func (d *DynamicRouter) URL() *common.URL {
        return nil
 }
 
+// SetStaticConfig applies a RouterConfig with string conditions directly, 
bypassing YAML parsing.
+// This is the correct entry point for static (code-configured) rules;
+// Process is designed for dynamic config-center updates that arrive as YAML 
text.
+// Static and dynamic rules are not merged: later Process updates replace the 
current state built here.
+func (d *DynamicRouter) SetStaticConfig(cfg *global.RouterConfig) {
+       if cfg == nil || len(cfg.Conditions) == 0 {
+               return
+       }
+
+       d.mu.Lock()
+       defer d.mu.Unlock()
+
+       force := cfg.Force != nil && *cfg.Force
+       enable := cfg.Enabled == nil || *cfg.Enabled
+
+       if !enable {
+               d.force, d.enable, d.conditionRouter = force, false, nil
+               return
+       }
+
+       conditionRouters := make([]*StateRouter, 0, len(cfg.Conditions))
+       for _, conditionRule := range cfg.Conditions {
+               url, err := common.NewURL("condition://")
+               if err != nil {
+                       logger.Warnf("[condition router] failed to create 
condition URL: %v", err)
+                       continue
+               }
+               url.AddParam(constant.RuleKey, conditionRule)
+               url.AddParam(constant.ForceKey, strconv.FormatBool(force))
+               conditionRoute, err := NewConditionStateRouter(url)
+               if err != nil {
+                       logger.Warnf("[condition router] failed to parse 
condition rule %q: %v", conditionRule, err)
+                       continue
+               }
+               conditionRouters = append(conditionRouters, conditionRoute)
+       }
+       d.force, d.enable, d.conditionRouter = force, enable, 
stateRouters(conditionRouters)
+}
+
+// Process applies config-center updates as the authoritative rule source at 
runtime.
+// It does not merge with static rules bootstrapped via SetStaticConfig; any 
later
+// dynamic update replaces the current static-derived state.
 func (d *DynamicRouter) Process(event *config_center.ConfigChangeEvent) {
        d.mu.Lock()
        defer d.mu.Unlock()
@@ -282,6 +324,14 @@ func NewServiceRouter() *ServiceRouter {
        return &ServiceRouter{}
 }
 
+// SetStaticConfig applies config only when scope is service and Conditions 
are present.
+func (s *ServiceRouter) SetStaticConfig(cfg *global.RouterConfig) {
+       if cfg == nil || cfg.Scope != constant.RouterScopeService || 
len(cfg.Conditions) == 0 {
+               return
+       }
+       s.DynamicRouter.SetStaticConfig(cfg)
+}
+
 func (s *ServiceRouter) Priority() int64 {
        return 140
 }
@@ -340,6 +390,14 @@ func NewApplicationRouter(url *common.URL) 
*ApplicationRouter {
        return a
 }
 
+// SetStaticConfig applies config only when scope is application and 
Conditions are present.
+func (a *ApplicationRouter) SetStaticConfig(cfg *global.RouterConfig) {
+       if cfg == nil || cfg.Scope != constant.RouterScopeApplication || 
len(cfg.Conditions) == 0 {
+               return
+       }
+       a.DynamicRouter.SetStaticConfig(cfg)
+}
+
 func (a *ApplicationRouter) Priority() int64 {
        return 145
 }
diff --git a/cluster/router/condition/router_test.go 
b/cluster/router/condition/router_test.go
index 5c6da3b2d..c9b852168 100644
--- a/cluster/router/condition/router_test.go
+++ b/cluster/router/condition/router_test.go
@@ -137,6 +137,94 @@ func TestRouteMatchWhen(t *testing.T) {
        }
 }
 
+func TestDynamicRouterSetStaticConfig(t *testing.T) {
+       t.Run("enabled config builds condition routers", func(t *testing.T) {
+               d := &DynamicRouter{}
+               force := true
+               enabled := true
+
+               d.SetStaticConfig(&global.RouterConfig{
+                       Force:      &force,
+                       Enabled:    &enabled,
+                       Conditions: []string{"host = 127.0.0.1 => host = 
dubbo.apache.org"},
+               })
+
+               assert.True(t, d.force)
+               assert.True(t, d.enable)
+               routers, ok := d.conditionRouter.(stateRouters)
+               require.True(t, ok)
+               assert.Len(t, routers, 1)
+       })
+
+       t.Run("disabled config clears router", func(t *testing.T) {
+               d := &DynamicRouter{}
+               force := true
+               enabled := false
+
+               d.SetStaticConfig(&global.RouterConfig{
+                       Force:      &force,
+                       Enabled:    &enabled,
+                       Conditions: []string{"host = 127.0.0.1 => host = 
dubbo.apache.org"},
+               })
+
+               assert.True(t, d.force)
+               assert.False(t, d.enable)
+               assert.Nil(t, d.conditionRouter)
+       })
+
+       t.Run("empty condition is ignored without corrupting state", func(t 
*testing.T) {
+               d := &DynamicRouter{}
+
+               d.SetStaticConfig(&global.RouterConfig{
+                       Conditions: []string{""},
+               })
+
+               assert.False(t, d.force)
+               assert.True(t, d.enable)
+               routers, ok := d.conditionRouter.(stateRouters)
+               require.True(t, ok)
+               assert.Empty(t, routers)
+       })
+}
+
+func TestScopedStaticConfigSetters(t *testing.T) {
+       t.Run("service router only accepts service scope", func(t *testing.T) {
+               router := NewServiceRouter()
+
+               router.SetStaticConfig(&global.RouterConfig{
+                       Scope:      constant.RouterScopeApplication,
+                       Conditions: []string{"host = 127.0.0.1 => host = 
dubbo.apache.org"},
+               })
+               assert.Nil(t, router.conditionRouter)
+
+               router.SetStaticConfig(&global.RouterConfig{
+                       Scope:      constant.RouterScopeService,
+                       Conditions: []string{"host = 127.0.0.1 => host = 
dubbo.apache.org"},
+               })
+               assert.NotNil(t, router.conditionRouter)
+       })
+
+       t.Run("application router only accepts application scope", func(t 
*testing.T) {
+               url := common.NewURLWithOptions(
+                       common.WithProtocol("consumer"),
+                       common.WithPath("com.foo.BarService"),
+               )
+               router := NewApplicationRouter(url)
+
+               router.SetStaticConfig(&global.RouterConfig{
+                       Scope:      constant.RouterScopeService,
+                       Conditions: []string{"host = 127.0.0.1 => host = 
dubbo.apache.org"},
+               })
+               assert.Nil(t, router.conditionRouter)
+
+               router.SetStaticConfig(&global.RouterConfig{
+                       Scope:      constant.RouterScopeApplication,
+                       Conditions: []string{"host = 127.0.0.1 => host = 
dubbo.apache.org"},
+               })
+               assert.NotNil(t, router.conditionRouter)
+       })
+}
+
 // TestRouteMatchFilter also tests pattern_value.WildcardValuePattern's Match 
method
 func TestRouteMatchFilter(t *testing.T) {
 
diff --git a/cluster/router/router.go b/cluster/router/router.go
index 93df2c0ee..8c5f1b226 100644
--- a/cluster/router/router.go
+++ b/cluster/router/router.go
@@ -23,6 +23,7 @@ import (
 
 import (
        "dubbo.apache.org/dubbo-go/v3/common"
+       "dubbo.apache.org/dubbo-go/v3/global"
        "dubbo.apache.org/dubbo-go/v3/protocol/base"
 )
 
@@ -48,6 +49,23 @@ type PriorityRouter interface {
        Notify(invokers []base.Invoker)
 }
 
+// StaticConfigSetter is implemented by routers that accept static router 
config injection.
+//
+// The chain passes every static config to every router that implements this 
interface, so that
+// the chain does not need to know which config type 
(condition/tag/script/...) belongs to which
+// router. Each router must approve only configs that apply to it: at the 
start of SetStaticConfig,
+// if cfg is nil or does not match this router's type (e.g. no Conditions for 
condition router,
+// no Tags for tag router), return immediately without modifying state. Only 
apply when the config
+// is intended for this router.
+//
+// Static config injection happens once during RouterChain initialization and 
serves as bootstrap
+// state for builtin routers. The chain does not merge static and dynamic 
rules. Later dynamic
+// updates still go through each router's own Notify/Process path and override 
the router's
+// current rule.
+type StaticConfigSetter interface {
+       SetStaticConfig(cfg *global.RouterConfig)
+}
+
 // Poolable caches address pool and address metadata for a router instance 
which will be used later in Router's Route.
 type Poolable interface {
        // Pool created address pool and address metadata from the invokers.
diff --git a/cluster/router/tag/match.go b/cluster/router/tag/match.go
index e8208848c..21254584c 100644
--- a/cluster/router/tag/match.go
+++ b/cluster/router/tag/match.go
@@ -133,7 +133,7 @@ func requestTag(invokers []base.Invoker, url *common.URL, 
invocation base.Invoca
                }
        }
        // returns the result directly
-       if *cfg.Force || requestIsForce(url, invocation) {
+       if (cfg.Force != nil && *cfg.Force) || requestIsForce(url, invocation) {
                return result
        }
        if len(result) != 0 {
diff --git a/cluster/router/tag/router.go b/cluster/router/tag/router.go
index 8fde2d38a..df8d36299 100644
--- a/cluster/router/tag/router.go
+++ b/cluster/router/tag/router.go
@@ -60,7 +60,9 @@ func (p *PriorityRouter) Route(invokers []base.Invoker, url 
*common.URL, invocat
                return staticTag(invokers, url, invocation)
        }
        routerCfg := value.(global.RouterConfig)
-       if !*routerCfg.Enabled || !*routerCfg.Valid {
+       enabled := routerCfg.Enabled == nil || *routerCfg.Enabled
+       valid := (routerCfg.Valid != nil && *routerCfg.Valid) || 
(routerCfg.Valid == nil && len(routerCfg.Tags) > 0)
+       if !enabled || !valid {
                return staticTag(invokers, url, invocation)
        }
        return dynamicTag(invokers, url, invocation, routerCfg)
@@ -102,6 +104,30 @@ func (p *PriorityRouter) Notify(invokers []base.Invoker) {
        p.Process(&config_center.ConfigChangeEvent{Key: key, Value: value, 
ConfigType: remoting.EventTypeAdd})
 }
 
+// SetStaticConfig applies a RouterConfig directly, bypassing YAML parsing.
+// This is the correct entry point for static (code-configured) rules;
+// Process is designed for dynamic config-center updates that arrive as YAML 
text.
+// Static and dynamic rules are not merged: later Process updates replace the 
current state built here.
+func (p *PriorityRouter) SetStaticConfig(cfg *global.RouterConfig) {
+       if cfg == nil || cfg.Scope != constant.RouterScopeApplication || 
len(cfg.Tags) == 0 {
+               return
+       }
+       cfgCopy := cfg.Clone()
+       cfgCopy.Valid = new(bool)
+       *cfgCopy.Valid = len(cfgCopy.Tags) > 0
+       if cfgCopy.Enabled == nil {
+               cfgCopy.Enabled = new(bool)
+               *cfgCopy.Enabled = true
+       }
+       // Derive storage key the same way Notify() does: application + suffix
+       key := strings.Join([]string{cfg.Key, constant.TagRouterRuleSuffix}, "")
+       p.routerConfigs.Store(key, *cfgCopy)
+       logger.Infof("[tag router] Applied static tag router config: key=%s", 
key)
+}
+
+// Process applies config-center updates as the authoritative rule source at 
runtime.
+// It does not merge with static rules bootstrapped via SetStaticConfig; any 
later
+// dynamic update replaces the current static-derived state.
 func (p *PriorityRouter) Process(event *config_center.ConfigChangeEvent) {
        if event.ConfigType == remoting.EventTypeDel {
                p.routerConfigs.Delete(event.Key)
diff --git a/cluster/router/tag/router_test.go 
b/cluster/router/tag/router_test.go
index 7b51aec91..630d573a9 100644
--- a/cluster/router/tag/router_test.go
+++ b/cluster/router/tag/router_test.go
@@ -419,3 +419,130 @@ tags:
                assert.Nil(t, value)
        })
 }
+
+func TestSetStaticConfig(t *testing.T) {
+       t.Run("empty tag config is ignored", func(t *testing.T) {
+               p, err := NewTagPriorityRouter()
+               require.NoError(t, err)
+
+               p.SetStaticConfig(&global.RouterConfig{
+                       Scope: constant.RouterScopeApplication,
+                       Key:   "test-app",
+               })
+
+               value, ok := p.routerConfigs.Load("test-app" + 
constant.TagRouterRuleSuffix)
+               assert.False(t, ok)
+               assert.Nil(t, value)
+       })
+
+       t.Run("service-scope tag config is ignored", func(t *testing.T) {
+               p, err := NewTagPriorityRouter()
+               require.NoError(t, err)
+
+               p.SetStaticConfig(&global.RouterConfig{
+                       Scope: constant.RouterScopeService,
+                       Key:   "svc.test",
+                       Tags: []global.Tag{{
+                               Name: "gray",
+                       }},
+               })
+
+               value, ok := p.routerConfigs.Load("svc.test" + 
constant.TagRouterRuleSuffix)
+               assert.False(t, ok)
+               assert.Nil(t, value)
+       })
+
+       t.Run("static tag config is cloned and stored with default enabled", 
func(t *testing.T) { // NOSONAR
+               p, err := NewTagPriorityRouter()
+               require.NoError(t, err)
+
+               cfg := &global.RouterConfig{
+                       Scope: constant.RouterScopeApplication,
+                       Key:   "test-app",
+                       Tags: []global.Tag{{
+                               Name:      "gray",
+                               Addresses: []string{"192.168.0.1:20000"}, // 
NOSONAR
+                       }},
+               }
+
+               p.SetStaticConfig(cfg)
+               cfg.Tags[0].Addresses[0] = "192.168.0.9:20000" // NOSONAR
+
+               value, ok := p.routerConfigs.Load("test-app" + 
constant.TagRouterRuleSuffix)
+               require.True(t, ok)
+               routerCfg := value.(global.RouterConfig)
+               assert.True(t, *routerCfg.Enabled)
+               assert.True(t, *routerCfg.Valid)
+               assert.Equal(t, "192.168.0.1:20000", 
routerCfg.Tags[0].Addresses[0]) // NOSONAR
+       })
+}
+
+func TestParseRoute(t *testing.T) {
+       t.Run("route with tags is valid", func(t *testing.T) {
+               cfg, err := parseRoute(`
+key: test-app
+tags:
+ - name: gray
+   addresses: [192.168.0.1:20000]
+`)
+               require.NoError(t, err)
+               require.NotNil(t, cfg.Valid)
+               assert.True(t, *cfg.Valid)
+       })
+
+       t.Run("route without tags is invalid", func(t *testing.T) {
+               cfg, err := parseRoute("key: test-app")
+               require.NoError(t, err)
+               require.NotNil(t, cfg.Valid)
+               assert.False(t, *cfg.Valid)
+       })
+}
+
+func TestRequestTagNilForceFallback(t *testing.T) {
+       initUrl()
+
+       ivk := base.NewBaseInvoker(url1)
+       ivk1 := base.NewBaseInvoker(url2)
+       ivk2 := base.NewBaseInvoker(url3)
+       invokerList := []base.Invoker{ivk, ivk1, ivk2}
+
+       result := requestTag(
+               invokerList,
+               consumerUrl,
+               invocation.NewRPCInvocation("GetUser", nil, 
map[string]any{constant.Tagkey: "gray"}),
+               global.RouterConfig{
+                       Tags: []global.Tag{{
+                               Name: "gray",
+                       }},
+               },
+               "gray",
+       )
+
+       assert.Len(t, result, 3)
+}
+
+func TestRouteNilDefaults(t *testing.T) {
+       initUrl()
+
+       p, err := NewTagPriorityRouter()
+       require.NoError(t, err)
+
+       ivk := base.NewBaseInvoker(url1)
+       ivk1 := base.NewBaseInvoker(url2)
+       ivk2 := base.NewBaseInvoker(url3)
+       invokerList := []base.Invoker{ivk, ivk1, ivk2}
+
+       p.routerConfigs.Store(constant.TagRouterRuleSuffix, global.RouterConfig{
+               Tags: []global.Tag{{
+                       Name: "gray",
+               }},
+       })
+
+       result := p.Route(
+               invokerList,
+               consumerUrl,
+               invocation.NewRPCInvocation("GetUser", nil, 
map[string]any{constant.Tagkey: "gray"}),
+       )
+
+       assert.Len(t, result, 3)
+}
diff --git a/common/constant/key.go b/common/constant/key.go
index 98aab2c65..2be7ffc88 100644
--- a/common/constant/key.go
+++ b/common/constant/key.go
@@ -73,6 +73,7 @@ const (
        TripleConfigKey     = "triple-config"
        ConsumerConfigKey   = "consumer-config"
        RegistriesConfigKey = "registries-config"
+       RoutersConfigKey    = "routers-config"
 )
 
 // TODO: remove this after old triple removed
@@ -349,6 +350,8 @@ const (
        ConditionAppRouterFactoryKey      = "provider.condition"
        ConditionServiceRouterFactoryKey  = "service.condition"
        ScriptRouterFactoryKey            = "consumer.script"
+       RouterScopeService                = "service"
+       RouterScopeApplication            = "application"
        ForceKey                          = "force"
        TrafficDisableKey                 = "trafficDisable"
        Arguments                         = "arguments"
diff --git a/dubbo.go b/dubbo.go
index 125af0385..4fedea24b 100644
--- a/dubbo.go
+++ b/dubbo.go
@@ -77,6 +77,7 @@ func (ins *Instance) NewClient(opts ...client.ClientOption) 
(*client.Client, err
        otelCfg := ins.insOpts.CloneOtel()
        tlsCfg := ins.insOpts.CloneTLSConfig()
        protocolsCfg := ins.insOpts.CloneProtocols()
+       routersCfg := ins.insOpts.CloneRouter()
 
        if conCfg != nil {
                if !conCfg.Check {
@@ -113,6 +114,9 @@ func (ins *Instance) NewClient(opts ...client.ClientOption) 
(*client.Client, err
        if protocolsCfg != nil {
                cliOpts = append(cliOpts, 
client.SetClientProtocols(protocolsCfg))
        }
+       if routersCfg != nil {
+               cliOpts = append(cliOpts, client.SetClientRouters(routersCfg))
+       }
 
        // options passed by users has higher priority
        cliOpts = append(cliOpts, opts...)

Reply via email to