Skip to content

Commit 6327c52

Browse files
Add ParseURL function for cluster mode (#1924)
* feat: add ParseClusterURL to allow for parsing of redis cluster urls into cluster options
1 parent a65f5ed commit 6327c52

File tree

6 files changed

+295
-60
lines changed

6 files changed

+295
-60
lines changed

cluster.go

+119-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"fmt"
77
"math"
88
"net"
9+
"net/url"
910
"runtime"
1011
"sort"
12+
"strings"
1113
"sync"
1214
"sync/atomic"
1315
"time"
@@ -131,6 +133,123 @@ func (opt *ClusterOptions) init() {
131133
}
132134
}
133135

136+
// ParseClusterURL parses a URL into ClusterOptions that can be used to connect to Redis.
137+
// The URL must be in the form:
138+
// redis://<user>:<password>@<host>:<port>
139+
// or
140+
// rediss://<user>:<password>@<host>:<port>
141+
// To add additional addresses, specify the query parameter, "addr" one or more times. e.g:
142+
// redis://<user>:<password>@<host>:<port>?addr=<host2>:<port2>&addr=<host3>:<port3>
143+
// or
144+
// rediss://<user>:<password>@<host>:<port>?addr=<host2>:<port2>&addr=<host3>:<port3>
145+
//
146+
// Most Option fields can be set using query parameters, with the following restrictions:
147+
// - field names are mapped using snake-case conversion: to set MaxRetries, use max_retries
148+
// - only scalar type fields are supported (bool, int, time.Duration)
149+
// - for time.Duration fields, values must be a valid input for time.ParseDuration();
150+
// additionally a plain integer as value (i.e. without unit) is intepreted as seconds
151+
// - to disable a duration field, use value less than or equal to 0; to use the default
152+
// value, leave the value blank or remove the parameter
153+
// - only the last value is interpreted if a parameter is given multiple times
154+
// - fields "network", "addr", "username" and "password" can only be set using other
155+
// URL attributes (scheme, host, userinfo, resp.), query paremeters using these
156+
// names will be treated as unknown parameters
157+
// - unknown parameter names will result in an error
158+
// Example:
159+
// redis://user:password@localhost:6789?dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791
160+
// is equivalent to:
161+
// &ClusterOptions{
162+
// Addr: ["localhost:6789", "localhost:6790", "localhost:6791"]
163+
// DialTimeout: 3 * time.Second, // no time unit = seconds
164+
// ReadTimeout: 6 * time.Second,
165+
// }
166+
func ParseClusterURL(redisURL string) (*ClusterOptions, error) {
167+
o := &ClusterOptions{}
168+
169+
u, err := url.Parse(redisURL)
170+
if err != nil {
171+
return nil, err
172+
}
173+
174+
// add base URL to the array of addresses
175+
// more addresses may be added through the URL params
176+
h, p := getHostPortWithDefaults(u)
177+
o.Addrs = append(o.Addrs, net.JoinHostPort(h, p))
178+
179+
// setup username, password, and other configurations
180+
o, err = setupClusterConn(u, h, o)
181+
if err != nil {
182+
return nil, err
183+
}
184+
185+
return o, nil
186+
}
187+
188+
// setupClusterConn gets the username and password from the URL and the query parameters.
189+
func setupClusterConn(u *url.URL, host string, o *ClusterOptions) (*ClusterOptions, error) {
190+
switch u.Scheme {
191+
case "rediss":
192+
o.TLSConfig = &tls.Config{ServerName: host}
193+
fallthrough
194+
case "redis":
195+
o.Username, o.Password = getUserPassword(u)
196+
default:
197+
return nil, fmt.Errorf("redis: invalid URL scheme: %s", u.Scheme)
198+
}
199+
200+
// retrieve the configuration from the query parameters
201+
o, err := setupClusterQueryParams(u, o)
202+
if err != nil {
203+
return nil, err
204+
}
205+
206+
return o, nil
207+
}
208+
209+
// setupClusterQueryParams converts query parameters in u to option value in o.
210+
func setupClusterQueryParams(u *url.URL, o *ClusterOptions) (*ClusterOptions, error) {
211+
q := queryOptions{q: u.Query()}
212+
213+
o.MaxRedirects = q.int("max_redirects")
214+
o.ReadOnly = q.bool("read_only")
215+
o.RouteByLatency = q.bool("route_by_latency")
216+
o.RouteByLatency = q.bool("route_randomly")
217+
o.MaxRetries = q.int("max_retries")
218+
o.MinRetryBackoff = q.duration("min_retry_backoff")
219+
o.MaxRetryBackoff = q.duration("max_retry_backoff")
220+
o.DialTimeout = q.duration("dial_timeout")
221+
o.ReadTimeout = q.duration("read_timeout")
222+
o.WriteTimeout = q.duration("write_timeout")
223+
o.PoolFIFO = q.bool("pool_fifo")
224+
o.PoolSize = q.int("pool_size")
225+
o.MinIdleConns = q.int("min_idle_conns")
226+
o.PoolTimeout = q.duration("pool_timeout")
227+
o.ConnMaxLifetime = q.duration("conn_max_lifetime")
228+
o.ConnMaxIdleTime = q.duration("conn_max_idle_time")
229+
230+
if q.err != nil {
231+
return nil, q.err
232+
}
233+
234+
// addr can be specified as many times as needed
235+
addrs := q.strings("addr")
236+
for _, addr := range addrs {
237+
h, p, err := net.SplitHostPort(addr)
238+
if err != nil || h == "" || p == "" {
239+
return nil, fmt.Errorf("redis: unable to parse addr param: %s", addr)
240+
}
241+
242+
o.Addrs = append(o.Addrs, net.JoinHostPort(h, p))
243+
}
244+
245+
// any parameters left?
246+
if r := q.remaining(); len(r) > 0 {
247+
return nil, fmt.Errorf("redis: unexpected option: %s", strings.Join(r, ", "))
248+
}
249+
250+
return o, nil
251+
}
252+
134253
func (opt *ClusterOptions) clientOptions() *Options {
135254
return &Options{
136255
Dialer: opt.Dialer,
@@ -1537,7 +1656,6 @@ func (c *ClusterClient) SSubscribe(ctx context.Context, channels ...string) *Pub
15371656
return pubsub
15381657
}
15391658

1540-
15411659
func (c *ClusterClient) retryBackoff(attempt int) time.Duration {
15421660
return internal.RetryBackoff(attempt, c.opt.MinRetryBackoff, c.opt.MaxRetryBackoff)
15431661
}

cluster_test.go

+139
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ package redis_test
22

33
import (
44
"context"
5+
"crypto/tls"
6+
"errors"
57
"fmt"
68
"net"
79
"strconv"
810
"strings"
911
"sync"
12+
"testing"
1013
"time"
1114

1215
. "github.com/onsi/ginkgo"
1316
. "github.com/onsi/gomega"
17+
"github.com/stretchr/testify/assert"
1418

1519
"github.com/go-redis/redis/v9"
1620
"github.com/go-redis/redis/v9/internal/hashtag"
@@ -1296,3 +1300,138 @@ var _ = Describe("ClusterClient timeout", func() {
12961300
testTimeout()
12971301
})
12981302
})
1303+
1304+
func TestParseClusterURL(t *testing.T) {
1305+
cases := []struct {
1306+
test string
1307+
url string
1308+
o *redis.ClusterOptions // expected value
1309+
err error
1310+
}{
1311+
{
1312+
test: "ParseRedisURL",
1313+
url: "redis://localhost:123",
1314+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}},
1315+
}, {
1316+
test: "ParseRedissURL",
1317+
url: "rediss://localhost:123",
1318+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, TLSConfig: &tls.Config{ServerName: "localhost"}},
1319+
}, {
1320+
test: "MissingRedisPort",
1321+
url: "redis://localhost",
1322+
o: &redis.ClusterOptions{Addrs: []string{"localhost:6379"}},
1323+
}, {
1324+
test: "MissingRedissPort",
1325+
url: "rediss://localhost",
1326+
o: &redis.ClusterOptions{Addrs: []string{"localhost:6379"}, TLSConfig: &tls.Config{ServerName: "localhost"}},
1327+
}, {
1328+
test: "MultipleRedisURLs",
1329+
url: "redis://localhost:123?addr=localhost:1234&addr=localhost:12345",
1330+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234", "localhost:12345"}},
1331+
}, {
1332+
test: "MultipleRedissURLs",
1333+
url: "rediss://localhost:123?addr=localhost:1234&addr=localhost:12345",
1334+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234", "localhost:12345"}, TLSConfig: &tls.Config{ServerName: "localhost"}},
1335+
}, {
1336+
test: "OnlyPassword",
1337+
url: "redis://:bar@localhost:123",
1338+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, Password: "bar"},
1339+
}, {
1340+
test: "OnlyUser",
1341+
url: "redis://foo@localhost:123",
1342+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, Username: "foo"},
1343+
}, {
1344+
test: "RedisUsernamePassword",
1345+
url: "redis://foo:bar@localhost:123",
1346+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, Username: "foo", Password: "bar"},
1347+
}, {
1348+
test: "RedissUsernamePassword",
1349+
url: "rediss://foo:bar@localhost:123?addr=localhost:1234",
1350+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234"}, Username: "foo", Password: "bar", TLSConfig: &tls.Config{ServerName: "localhost"}},
1351+
}, {
1352+
test: "QueryParameters",
1353+
url: "redis://localhost:123?read_timeout=2&pool_fifo=true&addr=localhost:1234",
1354+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234"}, ReadTimeout: 2 * time.Second, PoolFIFO: true},
1355+
}, {
1356+
test: "DisabledTimeout",
1357+
url: "redis://localhost:123?conn_max_idle_time=0",
1358+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, ConnMaxIdleTime: -1},
1359+
}, {
1360+
test: "DisabledTimeoutNeg",
1361+
url: "redis://localhost:123?conn_max_idle_time=-1",
1362+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, ConnMaxIdleTime: -1},
1363+
}, {
1364+
test: "UseDefault",
1365+
url: "redis://localhost:123?conn_max_idle_time=",
1366+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, ConnMaxIdleTime: 0},
1367+
}, {
1368+
test: "UseDefaultMissing=",
1369+
url: "redis://localhost:123?conn_max_idle_time",
1370+
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, ConnMaxIdleTime: 0},
1371+
}, {
1372+
test: "InvalidQueryAddr",
1373+
url: "rediss://foo:bar@localhost:123?addr=rediss://foo:barr@localhost:1234",
1374+
err: errors.New(`redis: unable to parse addr param: rediss://foo:barr@localhost:1234`),
1375+
}, {
1376+
test: "InvalidInt",
1377+
url: "redis://localhost?pool_size=five",
1378+
err: errors.New(`redis: invalid pool_size number: strconv.Atoi: parsing "five": invalid syntax`),
1379+
}, {
1380+
test: "InvalidBool",
1381+
url: "redis://localhost?pool_fifo=yes",
1382+
err: errors.New(`redis: invalid pool_fifo boolean: expected true/false/1/0 or an empty string, got "yes"`),
1383+
}, {
1384+
test: "UnknownParam",
1385+
url: "redis://localhost?abc=123",
1386+
err: errors.New("redis: unexpected option: abc"),
1387+
}, {
1388+
test: "InvalidScheme",
1389+
url: "https://google.com",
1390+
err: errors.New("redis: invalid URL scheme: https"),
1391+
},
1392+
}
1393+
1394+
for i := range cases {
1395+
tc := cases[i]
1396+
t.Run(tc.test, func(t *testing.T) {
1397+
t.Parallel()
1398+
1399+
actual, err := redis.ParseClusterURL(tc.url)
1400+
if tc.err == nil && err != nil {
1401+
t.Fatalf("unexpected error: %q", err)
1402+
return
1403+
}
1404+
if tc.err != nil && err == nil {
1405+
t.Fatalf("expected error: got %+v", actual)
1406+
return
1407+
}
1408+
if tc.err != nil && err != nil {
1409+
if tc.err.Error() != err.Error() {
1410+
t.Fatalf("got %q, expected %q", err, tc.err)
1411+
}
1412+
return
1413+
}
1414+
comprareOptions(t, actual, tc.o)
1415+
})
1416+
}
1417+
}
1418+
1419+
func comprareOptions(t *testing.T, actual, expected *redis.ClusterOptions) {
1420+
t.Helper()
1421+
assert.Equal(t, expected.Addrs, actual.Addrs)
1422+
assert.Equal(t, expected.TLSConfig, actual.TLSConfig)
1423+
assert.Equal(t, expected.Username, actual.Username)
1424+
assert.Equal(t, expected.Password, actual.Password)
1425+
assert.Equal(t, expected.MaxRetries, actual.MaxRetries)
1426+
assert.Equal(t, expected.MinRetryBackoff, actual.MinRetryBackoff)
1427+
assert.Equal(t, expected.MaxRetryBackoff, actual.MaxRetryBackoff)
1428+
assert.Equal(t, expected.DialTimeout, actual.DialTimeout)
1429+
assert.Equal(t, expected.ReadTimeout, actual.ReadTimeout)
1430+
assert.Equal(t, expected.WriteTimeout, actual.WriteTimeout)
1431+
assert.Equal(t, expected.PoolFIFO, actual.PoolFIFO)
1432+
assert.Equal(t, expected.PoolSize, actual.PoolSize)
1433+
assert.Equal(t, expected.MinIdleConns, actual.MinIdleConns)
1434+
assert.Equal(t, expected.ConnMaxLifetime, actual.ConnMaxLifetime)
1435+
assert.Equal(t, expected.ConnMaxIdleTime, actual.ConnMaxIdleTime)
1436+
assert.Equal(t, expected.PoolTimeout, actual.PoolTimeout)
1437+
}

go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ require (
77
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
88
github.com/onsi/ginkgo v1.16.5
99
github.com/onsi/gomega v1.20.2
10+
github.com/stretchr/testify v1.5.1
1011
)
1112

1213
require (
14+
github.com/davecgh/go-spew v1.1.1 // indirect
1315
github.com/fsnotify/fsnotify v1.4.9 // indirect
1416
github.com/google/go-cmp v0.5.8 // indirect
1517
github.com/nxadm/tail v1.4.8 // indirect
18+
github.com/pmezard/go-difflib v1.0.0 // indirect
1619
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
1720
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
1821
golang.org/x/text v0.3.7 // indirect
1922
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
23+
gopkg.in/yaml.v2 v2.3.0 // indirect
2024
gopkg.in/yaml.v3 v3.0.1 // indirect
2125
)

0 commit comments

Comments
 (0)