- Add 256-shard lock map for concurrent session routing - Cookie-based session persistence with base64 encoding - TTL expiration with background cleanup goroutine - Support Secure, HttpOnly, SameSite cookie attributes - Fallback to configured balancer when session target unavailable
252 lines
6.6 KiB
Go
252 lines
6.6 KiB
Go
package loadbalance
|
||
|
||
import (
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/valyala/fasthttp"
|
||
)
|
||
|
||
// TestStickySession_BasicRoute 测试基本的会话粘性路由。
|
||
// 第一次请求应设置 cookie,第二次携带相同 cookie 应路由到同一目标。
|
||
func TestStickySession_BasicRoute(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("首次请求设置cookie并路由", func(_ *testing.T) {
|
||
config := DefaultStickyConfig()
|
||
config.Enabled = true
|
||
fallback := NewRoundRobin()
|
||
sticky := NewStickySession(config, fallback)
|
||
defer sticky.Stop()
|
||
|
||
targets := []*Target{
|
||
createHealthyTarget("http://backend1:8080", true),
|
||
createHealthyTarget("http://backend2:8080", true),
|
||
}
|
||
|
||
ctx := &fasthttp.RequestCtx{}
|
||
got := sticky.Select(ctx, targets)
|
||
if got == nil {
|
||
t.Fatal("Select() = nil, want non-nil")
|
||
}
|
||
|
||
// 验证设置了 cookie
|
||
cookieValue := ctx.Response.Header.PeekCookie(config.Name)
|
||
if len(cookieValue) == 0 {
|
||
t.Error("首次请求未设置 cookie")
|
||
}
|
||
})
|
||
|
||
t.Run("相同cookie路由到同一目标", func(_ *testing.T) {
|
||
config := DefaultStickyConfig()
|
||
config.Enabled = true
|
||
fallback := NewRoundRobin()
|
||
sticky := NewStickySession(config, fallback)
|
||
defer sticky.Stop()
|
||
|
||
targets := []*Target{
|
||
createHealthyTarget("http://backend1:8080", true),
|
||
createHealthyTarget("http://backend2:8080", true),
|
||
}
|
||
|
||
// 第一次请求
|
||
ctx1 := &fasthttp.RequestCtx{}
|
||
got1 := sticky.Select(ctx1, targets)
|
||
if got1 == nil {
|
||
t.Fatal("第一次 Select() = nil")
|
||
}
|
||
|
||
// 提取 cookie
|
||
cookie := &fasthttp.Cookie{}
|
||
cookie.SetKey(config.Name)
|
||
if err := cookie.ParseBytes(ctx1.Response.Header.PeekCookie(config.Name)); err != nil {
|
||
t.Fatalf("解析 cookie 失败: %v", err)
|
||
}
|
||
|
||
// 第二次请求携带相同 cookie
|
||
ctx2 := &fasthttp.RequestCtx{}
|
||
ctx2.Request.Header.SetCookie(config.Name, string(cookie.Value()))
|
||
got2 := sticky.Select(ctx2, targets)
|
||
if got2 == nil {
|
||
t.Fatal("第二次 Select() = nil")
|
||
}
|
||
|
||
if got2.URL != got1.URL {
|
||
t.Errorf("相同 cookie 路由到不同目标: 第一次=%q, 第二次=%q", got1.URL, got2.URL)
|
||
}
|
||
})
|
||
|
||
t.Run("禁用时不设置cookie", func(_ *testing.T) {
|
||
config := DefaultStickyConfig()
|
||
config.Enabled = false
|
||
fallback := NewRoundRobin()
|
||
sticky := NewStickySession(config, fallback)
|
||
defer sticky.Stop()
|
||
|
||
targets := []*Target{
|
||
createHealthyTarget("http://backend1:8080", true),
|
||
}
|
||
|
||
ctx := &fasthttp.RequestCtx{}
|
||
got := sticky.Select(ctx, targets)
|
||
if got == nil {
|
||
t.Fatal("Select() = nil")
|
||
}
|
||
|
||
cookieValue := ctx.Response.Header.PeekCookie(config.Name)
|
||
if len(cookieValue) > 0 {
|
||
t.Error("禁用时不应设置 cookie")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestStickySession_TargetUnavailable 测试目标不可用时回退到 fallback。
|
||
func TestStickySession_TargetUnavailable(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("目标不健康时回退", func(_ *testing.T) {
|
||
config := DefaultStickyConfig()
|
||
config.Enabled = true
|
||
fallback := NewRoundRobin()
|
||
sticky := NewStickySession(config, fallback)
|
||
defer sticky.Stop()
|
||
|
||
targets := []*Target{
|
||
createHealthyTarget("http://backend1:8080", true),
|
||
createHealthyTarget("http://backend2:8080", true),
|
||
}
|
||
|
||
// 第一次请求,记录会话
|
||
ctx1 := &fasthttp.RequestCtx{}
|
||
got1 := sticky.Select(ctx1, targets)
|
||
if got1 == nil {
|
||
t.Fatal("第一次 Select() = nil")
|
||
}
|
||
|
||
// 提取 cookie
|
||
cookie := &fasthttp.Cookie{}
|
||
cookie.SetKey(config.Name)
|
||
if err := cookie.ParseBytes(ctx1.Response.Header.PeekCookie(config.Name)); err != nil {
|
||
t.Fatalf("解析 cookie 失败: %v", err)
|
||
}
|
||
|
||
// 使之前选中的目标不健康
|
||
for _, target := range targets {
|
||
if target.URL == got1.URL {
|
||
target.Healthy.Store(false)
|
||
break
|
||
}
|
||
}
|
||
|
||
// 第二次请求,应回退到其他目标
|
||
ctx2 := &fasthttp.RequestCtx{}
|
||
ctx2.Request.Header.SetCookie(config.Name, string(cookie.Value()))
|
||
got2 := sticky.Select(ctx2, targets)
|
||
if got2 == nil {
|
||
t.Fatal("第二次 Select() = nil")
|
||
}
|
||
|
||
if got2.URL == got1.URL {
|
||
t.Errorf("不健康目标未回退: %q", got2.URL)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestStickySession_CookieEncodeDecode 测试 cookie 编解码。
|
||
func TestStickySession_CookieEncodeDecode(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("编码解码round-trip", func(_ *testing.T) {
|
||
url := "http://backend1:8080"
|
||
encoded := encodeStickyCookie(url)
|
||
if encoded == "" {
|
||
t.Fatal("encodeStickyCookie() 返回空字符串")
|
||
}
|
||
|
||
decoded, err := decodeStickyCookie(encoded)
|
||
if err != nil {
|
||
t.Fatalf("decodeStickyCookie() 错误: %v", err)
|
||
}
|
||
|
||
if decoded != url {
|
||
t.Errorf("解码后 URL = %q, want %q", decoded, url)
|
||
}
|
||
})
|
||
|
||
t.Run("空URL编码解码", func(_ *testing.T) {
|
||
encoded := encodeStickyCookie("")
|
||
decoded, err := decodeStickyCookie(encoded)
|
||
if err != nil {
|
||
t.Fatalf("decodeStickyCookie() 错误: %v", err)
|
||
}
|
||
if decoded != "" {
|
||
t.Errorf("解码后 URL = %q, want 空字符串", decoded)
|
||
}
|
||
})
|
||
|
||
t.Run("无效编码", func(_ *testing.T) {
|
||
_, err := decodeStickyCookie("invalid-base64!!!")
|
||
if err == nil {
|
||
t.Error("decodeStickyCookie() 应返回错误")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestStickySession_Concurrent 测试并发安全。
|
||
// 100 个 goroutine 同时访问会话存储。
|
||
func TestStickySession_Concurrent(t *testing.T) {
|
||
t.Parallel()
|
||
config := DefaultStickyConfig()
|
||
config.Enabled = true
|
||
fallback := NewRoundRobin()
|
||
sticky := NewStickySession(config, fallback)
|
||
defer sticky.Stop()
|
||
|
||
targets := []*Target{
|
||
createHealthyTarget("http://backend1:8080", true),
|
||
createHealthyTarget("http://backend2:8080", true),
|
||
createHealthyTarget("http://backend3:8080", true),
|
||
}
|
||
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 100; i++ {
|
||
wg.Add(1)
|
||
go func(idx int) {
|
||
defer wg.Done()
|
||
ctx := &fasthttp.RequestCtx{}
|
||
// 交替使用有 cookie 和没有 cookie 的请求
|
||
if idx%2 == 0 {
|
||
ctx.Request.Header.SetCookie(config.Name, encodeStickyCookie("http://backend1:8080"))
|
||
}
|
||
got := sticky.Select(ctx, targets)
|
||
if got == nil {
|
||
t.Error("并发 Select() = nil")
|
||
}
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// TestStickySession_SelectExcluding 测试排除选择委托给 fallback。
|
||
func TestStickySession_SelectExcluding(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("SelectExcluding委托给fallback", func(_ *testing.T) {
|
||
config := DefaultStickyConfig()
|
||
config.Enabled = true
|
||
fallback := NewRoundRobin()
|
||
sticky := NewStickySession(config, fallback)
|
||
defer sticky.Stop()
|
||
|
||
targets := []*Target{
|
||
createHealthyTarget("http://backend1:8080", true),
|
||
createHealthyTarget("http://backend2:8080", true),
|
||
}
|
||
|
||
excluded := []*Target{targets[0]}
|
||
got := sticky.SelectExcluding(targets, excluded)
|
||
if got == nil {
|
||
t.Fatal("SelectExcluding() = nil")
|
||
}
|
||
if got.URL != "http://backend2:8080" {
|
||
t.Errorf("SelectExcluding() = %q, want %q", got.URL, "http://backend2:8080")
|
||
}
|
||
})
|
||
}
|