diff --git a/zrpc/resolver/internal/discovbuilder.go b/zrpc/resolver/internal/discovbuilder.go index 1aa5d8f3a..a7506f720 100644 --- a/zrpc/resolver/internal/discovbuilder.go +++ b/zrpc/resolver/internal/discovbuilder.go @@ -13,10 +13,10 @@ type discovBuilder struct{} func (b *discovBuilder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) ( resolver.Resolver, error) { - hosts := strings.FieldsFunc(targets.GetAuthority(target), func(r rune) bool { + hosts := strings.FieldsFunc(targets.GetHosts(target), func(r rune) bool { return r == EndpointSepChar }) - sub, err := discov.NewSubscriber(hosts, targets.GetEndpoints(target)) + sub, err := discov.NewSubscriber(hosts, targets.GetKey(target)) if err != nil { return nil, err } diff --git a/zrpc/resolver/internal/discovbuilder_test.go b/zrpc/resolver/internal/discovbuilder_test.go index 334e35632..937707c04 100644 --- a/zrpc/resolver/internal/discovbuilder_test.go +++ b/zrpc/resolver/internal/discovbuilder_test.go @@ -28,7 +28,7 @@ func TestDiscovBuilder_Build(t *testing.T) { for _, server := range servers.Servers { addrs = append(addrs, server.Address) } - u, err := url.Parse(fmt.Sprintf("%s://%s", DiscovScheme, strings.Join(addrs, ","))) + u, err := url.Parse(fmt.Sprintf("%s:///%s?key=test", DiscovScheme, strings.Join(addrs, ","))) assert.NoError(t, err) var b discovBuilder diff --git a/zrpc/resolver/internal/targets/endpoints.go b/zrpc/resolver/internal/targets/endpoints.go index 2685f6d2b..3cd02c5e2 100644 --- a/zrpc/resolver/internal/targets/endpoints.go +++ b/zrpc/resolver/internal/targets/endpoints.go @@ -17,3 +17,29 @@ func GetAuthority(target resolver.Target) string { func GetEndpoints(target resolver.Target) string { return strings.Trim(target.URL.Path, slashSeparator) } + +// GetHosts returns the comma-separated etcd hosts from the target URL. +// It supports two formats: +// - New format (etcd:///h1:port,h2:port?key=k): hosts are in the URL path (empty authority) +// - Legacy format (etcd://h1:port/key): host is in the URL authority +func GetHosts(target resolver.Target) string { + if target.URL.Host == "" { + // New format: hosts encoded in URL path to avoid RFC 3986 authority issues + return GetEndpoints(target) + } + // Legacy format: single host in authority + return target.URL.Host +} + +// GetKey returns the etcd key from the target URL. +// It supports two formats: +// - New format (etcd:///h1:port,h2:port?key=k): key is in the "key" query parameter +// - Legacy format (etcd://h1:port/key): key is in the URL path +func GetKey(target resolver.Target) string { + if target.URL.Host == "" { + // New format: key is in the query parameter + return target.URL.Query().Get("key") + } + // Legacy format: key is in the path + return strings.Trim(target.URL.Path, slashSeparator) +} diff --git a/zrpc/resolver/internal/targets/endpoints_test.go b/zrpc/resolver/internal/targets/endpoints_test.go index a22e2d394..93c930a25 100644 --- a/zrpc/resolver/internal/targets/endpoints_test.go +++ b/zrpc/resolver/internal/targets/endpoints_test.go @@ -87,3 +87,83 @@ func TestGetEndpoints(t *testing.T) { }) } } + +func TestGetHosts(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "single host", + url: "etcd:///localhost:2379?key=foo", + want: "localhost:2379", + }, + { + name: "multiple hosts", + url: "etcd:///host1:2379,host2:2379,host3:2379?key=foo", + want: "host1:2379,host2:2379,host3:2379", + }, + { + name: "legacy single host in authority", + url: "etcd://localhost:2379/my-service", + want: "localhost:2379", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + uri, err := url.Parse(test.url) + assert.Nil(t, err) + target := resolver.Target{ + URL: *uri, + } + assert.Equal(t, test.want, GetHosts(target)) + }) + } +} + +func TestGetKey(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "simple key", + url: "etcd:///localhost:2379?key=my-service", + want: "my-service", + }, + { + name: "key with slashes", + url: "etcd:///localhost:2379?key=%2Fgrpc%2Fmy-service", + want: "/grpc/my-service", + }, + { + name: "no key", + url: "etcd:///localhost:2379", + want: "", + }, + { + name: "legacy key in path", + url: "etcd://localhost:2379/my-service", + want: "my-service", + }, + { + name: "legacy key with leading slash", + url: "etcd://localhost:2379/grpc/my-service", + want: "grpc/my-service", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + uri, err := url.Parse(test.url) + assert.Nil(t, err) + target := resolver.Target{ + URL: *uri, + } + assert.Equal(t, test.want, GetKey(target)) + }) + } +} diff --git a/zrpc/resolver/target.go b/zrpc/resolver/target.go index deb2b8aa8..34ce0cabf 100644 --- a/zrpc/resolver/target.go +++ b/zrpc/resolver/target.go @@ -2,6 +2,7 @@ package resolver import ( "fmt" + "net/url" "strings" "github.com/zeromicro/go-zero/zrpc/resolver/internal" @@ -14,7 +15,9 @@ func BuildDirectTarget(endpoints []string) string { } // BuildDiscovTarget returns a string that represents the given endpoints with discov schema. +// The format is etcd:///host1:port,host2:port?key= to avoid placing comma-separated +// hosts in the URI authority, which Go 1.26+ rejects per RFC 3986. func BuildDiscovTarget(endpoints []string, key string) string { - return fmt.Sprintf("%s://%s/%s", internal.EtcdScheme, - strings.Join(endpoints, internal.EndpointSep), key) + return fmt.Sprintf("%s:///%s?key=%s", internal.EtcdScheme, + strings.Join(endpoints, internal.EndpointSep), url.QueryEscape(key)) } diff --git a/zrpc/resolver/target_test.go b/zrpc/resolver/target_test.go index 49af6e167..29ce00467 100644 --- a/zrpc/resolver/target_test.go +++ b/zrpc/resolver/target_test.go @@ -13,5 +13,10 @@ func TestBuildDirectTarget(t *testing.T) { func TestBuildDiscovTarget(t *testing.T) { target := BuildDiscovTarget([]string{"localhost:123", "localhost:456"}, "foo") - assert.Equal(t, "etcd://localhost:123,localhost:456/foo", target) + assert.Equal(t, "etcd:///localhost:123,localhost:456?key=foo", target) +} + +func TestBuildDiscovTargetWithSlashKey(t *testing.T) { + target := BuildDiscovTarget([]string{"localhost:2379"}, "/grpc/my-service") + assert.Equal(t, "etcd:///localhost:2379?key=%2Fgrpc%2Fmy-service", target) }