From 92b7040a5fe4362675789c361d133c185825f6d2 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 22 Apr 2026 13:15:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy,config):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E5=92=8C=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 健康检查增强: - 添加 HealthMatch 接口支持自定义健康判断逻辑 - 支持状态码范围、响应体正则、响应头匹配 - 集成 SlowStartManager 实现慢启动 配置增强: - HealthCheckConfig 新增 Match 和 SlowStart 字段 - ProxyBufferingConfig 支持 Buffers 配置字符串格式 - 新增 ProxyCachePathConfig 磁盘缓存路径配置 - 添加 StaleIfError/StaleIfTimeout 缓存配置 Co-Authored-By: Claude Opus 4.7 --- internal/config/config.go | 169 +++++++++++++++-- internal/config/config_test.go | 77 ++++++++ internal/proxy/health.go | 82 ++++++-- internal/proxy/health_match.go | 196 +++++++++++++++++++ internal/proxy/health_match_test.go | 282 ++++++++++++++++++++++++++++ 5 files changed, 773 insertions(+), 33 deletions(-) create mode 100644 internal/proxy/health_match.go create mode 100644 internal/proxy/health_match_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 40fae37..f56ed75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,8 @@ import ( "fmt" "net" "os" + "strconv" + "strings" "time" "gopkg.in/yaml.v3" @@ -72,17 +74,18 @@ const ( // // 处理每个服务器配置 // } type Config struct { - Mode ServerMode `yaml:"mode"` - Variables VariablesConfig `yaml:"variables"` - Logging LoggingConfig `yaml:"logging"` - Servers []ServerConfig `yaml:"servers"` - Stream []StreamConfig `yaml:"stream"` - Monitoring MonitoringConfig `yaml:"monitoring"` - HTTP3 HTTP3Config `yaml:"http3"` - Resolver ResolverConfig `yaml:"resolver"` - Performance PerformanceConfig `yaml:"performance"` - Shutdown ShutdownConfig `yaml:"shutdown"` - Include []IncludeConfig `yaml:"include"` // 配置引入,支持从其他文件引入配置片段 + Mode ServerMode `yaml:"mode"` + Variables VariablesConfig `yaml:"variables"` + Logging LoggingConfig `yaml:"logging"` + Servers []ServerConfig `yaml:"servers"` + Stream []StreamConfig `yaml:"stream"` + Monitoring MonitoringConfig `yaml:"monitoring"` + HTTP3 HTTP3Config `yaml:"http3"` + Resolver ResolverConfig `yaml:"resolver"` + Performance PerformanceConfig `yaml:"performance"` + Shutdown ShutdownConfig `yaml:"shutdown"` + Include []IncludeConfig `yaml:"include"` // 配置引入,支持从其他文件引入配置片段 + CachePath *ProxyCachePathConfig `yaml:"cache_path"` // 缓存路径配置(磁盘持久化) } // IncludeConfig 配置引入配置。 @@ -390,6 +393,85 @@ type ProxyBufferingConfig struct { // BufferSize 响应缓冲区大小(字节) // 0 表示使用默认值 BufferSize int `yaml:"buffer_size"` + + // Buffers 多缓冲区配置字符串 + // 格式:"数量 大小" 或 "数量1 大小1 数量2 大小2 ..." + // 例如:"8 16k" 表示 8 个 16KB 缓冲区 + // 例如:"4 4k 8 16k" 表示 4 个 4KB + 8 个 16KB 缓冲区 + Buffers string `yaml:"buffers"` + + // BufferCount 缓冲区数量(解析后) + BufferCount int `yaml:"-"` + + // BufferSizeEach 每个缓冲区大小(字节,解析后) + BufferSizeEach int `yaml:"-"` +} + +// ParseBuffers 解析 Buffers 配置字符串。 +// +// 支持格式: +// - "8 16k" → 8 个 16KB 缓冲区 +// - "4 4k" → 4 个 4KB 缓冲区 +// +// 大小单位: +// - k 或 K: KB (1024 字节) +// - m 或 M: MB (1024 * 1024 字节) +// - 无单位: 字节 +func (c *ProxyBufferingConfig) ParseBuffers() { + if c.Buffers == "" { + // 向后兼容:使用 BufferSize + if c.BufferSize > 0 { + c.BufferCount = 1 + c.BufferSizeEach = c.BufferSize + } + return + } + + parts := strings.Fields(c.Buffers) + if len(parts) < 2 { + return // 无效格式 + } + + count, err := strconv.Atoi(parts[0]) + if err != nil || count <= 0 { + return // 无效数量 + } + + sizeEach, err := parseSize(parts[1]) + if err != nil || sizeEach <= 0 { + return // 无效大小 + } + + c.BufferCount = count + c.BufferSizeEach = sizeEach +} + +// parseSize 解析大小字符串(支持 k, m 单位)。 +func parseSize(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, strconv.ErrSyntax + } + + // 提取单位 + unit := strings.ToLower(s[len(s)-1:]) + var multiplier int = 1 + numStr := s + + if unit == "k" { + multiplier = 1024 + numStr = s[:len(s)-1] + } else if unit == "m" { + multiplier = 1024 * 1024 + numStr = s[:len(s)-1] + } + + value, err := strconv.Atoi(numStr) + if err != nil { + return 0, err + } + + return value * multiplier, nil } // BalancerByLuaConfig Lua 负载均衡配置 @@ -489,9 +571,18 @@ type ProxyTarget struct { // path: "/health" // timeout: 5s type HealthCheckConfig struct { - Path string `yaml:"path"` - Interval time.Duration `yaml:"interval"` - Timeout time.Duration `yaml:"timeout"` + Path string `yaml:"path"` + Interval time.Duration `yaml:"interval"` + Timeout time.Duration `yaml:"timeout"` + Match *HealthMatchConfig `yaml:"match"` // 健康检查匹配配置 + SlowStart time.Duration `yaml:"slow_start"` // 慢启动时间 +} + +// HealthMatchConfig 健康检查匹配配置。 +type HealthMatchConfig struct { + Status []string `yaml:"status"` // 状态码范围列表 + Body string `yaml:"body"` // 响应体正则表达式 + Headers map[string]string `yaml:"headers"` // 响应头匹配 } // ProxyTimeout 代理超时配置。 @@ -576,6 +667,54 @@ type ProxyHeaders struct { CookiePath string `yaml:"cookie_path"` } +// ProxyCachePathConfig 缓存路径配置(磁盘持久化)。 +// +// 配置磁盘缓存路径和相关参数,支持 L1/L2 分层缓存架构。 +// 配置后,代理缓存将持久化到磁盘,服务重启后可恢复。 +// +// 注意事项: +// - Path 为必填项,指定缓存根目录 +// - Levels 支持最多 3 级目录(如 "1:2:2") +// - MaxSize 为 0 表示不限制大小 +// - L1MaxEntries/L1MaxSize 为 0 时使用默认值 +// +// 使用示例: +// +// cache_path: +// path: "/var/cache/lolly" +// levels: "1:2" +// max_size: "1GB" +// inactive: "60m" +// l1_max_entries: 10000 +type ProxyCachePathConfig struct { + // Path 缓存根目录 + Path string `yaml:"path"` + + // Levels 目录层级,如 "1:2" 表示两级目录 + Levels string `yaml:"levels"` + + // MaxSize 最大缓存大小(字节) + MaxSize int64 `yaml:"max_size"` + + // Inactive 未访问淘汰时间 + Inactive time.Duration `yaml:"inactive"` + + // Purger 是否启用后台清理 + Purger bool `yaml:"purger"` + + // PurgerInterval 清理间隔 + PurgerInterval time.Duration `yaml:"purger_interval"` + + // L1MaxEntries L1 最大条目数 + L1MaxEntries int64 `yaml:"l1_max_entries"` + + // L1MaxSize L1 最大内存大小 + L1MaxSize int64 `yaml:"l1_max_size"` + + // PromoteThreshold 提升到 L1 的访问阈值 + PromoteThreshold int `yaml:"promote_threshold"` +} + // ProxyCacheConfig 代理缓存配置。 // // 缓存后端响应,减少重复请求,提高响应速度。 @@ -596,6 +735,8 @@ type ProxyHeaders struct { type ProxyCacheConfig struct { MaxAge time.Duration `yaml:"max_age"` StaleWhileRevalidate time.Duration `yaml:"stale_while_revalidate"` + StaleIfError time.Duration `yaml:"stale_if_error"` // 错误时使用过期缓存 + StaleIfTimeout time.Duration `yaml:"stale_if_timeout"` // 超时时使用过期缓存 Enabled bool `yaml:"enabled"` CacheLock bool `yaml:"cache_lock"` Methods []string `yaml:"methods"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9ff76e5..131b27e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -414,3 +414,80 @@ func TestConfigMethods(t *testing.T) { } }) } + +func TestProxyBufferingConfig_ParseBuffers(t *testing.T) { + tests := []struct { + name string + buffers string + bufferSize int + wantCount int + wantSizeEach int + }{ + { + name: "empty uses buffer_size", + buffers: "", + bufferSize: 4096, + wantCount: 1, + wantSizeEach: 4096, + }, + { + name: "8 16k format", + buffers: "8 16k", + wantCount: 8, + wantSizeEach: 16 * 1024, + }, + { + name: "4 4k format", + buffers: "4 4k", + wantCount: 4, + wantSizeEach: 4 * 1024, + }, + { + name: "2 1m format", + buffers: "2 1m", + wantCount: 2, + wantSizeEach: 1024 * 1024, + }, + { + name: "bytes without unit", + buffers: "4 8192", + wantCount: 4, + wantSizeEach: 8192, + }, + { + name: "uppercase K", + buffers: "8 16K", + wantCount: 8, + wantSizeEach: 16 * 1024, + }, + { + name: "invalid format", + buffers: "invalid", + wantCount: 0, + wantSizeEach: 0, + }, + { + name: "missing size", + buffers: "8", + wantCount: 0, + wantSizeEach: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &ProxyBufferingConfig{ + Buffers: tt.buffers, + BufferSize: tt.bufferSize, + } + cfg.ParseBuffers() + + if cfg.BufferCount != tt.wantCount { + t.Errorf("BufferCount = %d, want %d", cfg.BufferCount, tt.wantCount) + } + if cfg.BufferSizeEach != tt.wantSizeEach { + t.Errorf("BufferSizeEach = %d, want %d", cfg.BufferSizeEach, tt.wantSizeEach) + } + }) + } +} diff --git a/internal/proxy/health.go b/internal/proxy/health.go index 9a9363e..fd5f47c 100644 --- a/internal/proxy/health.go +++ b/internal/proxy/health.go @@ -56,13 +56,15 @@ const healthPath = "/health" // checker.Start() // defer checker.Stop() type HealthChecker struct { - stopCh chan struct{} - client *fasthttp.Client - path string - targets []*loadbalance.Target - interval time.Duration - timeout time.Duration - running atomic.Bool + stopCh chan struct{} + client *fasthttp.Client + path string + targets []*loadbalance.Target + interval time.Duration + timeout time.Duration + running atomic.Bool + matcher HealthMatch // 健康检查匹配器 + slowStartManager *loadbalance.SlowStartManager // 慢启动管理器 } // NewHealthChecker 使用指定的目标和配置创建一个新的 HealthChecker。 @@ -90,12 +92,33 @@ func NewHealthChecker(targets []*loadbalance.Target, cfg *config.HealthCheckConf path = healthPath } + // 创建健康检查匹配器 + var matcher HealthMatch + if cfg.Match != nil { + matcher = NewHealthMatch(&HealthMatchConfig{ + Status: cfg.Match.Status, + Body: cfg.Match.Body, + Headers: cfg.Match.Headers, + }) + } + if matcher == nil { + matcher = DefaultHealthMatch() + } + + // 创建慢启动管理器 + var slowStartManager *loadbalance.SlowStartManager + if cfg.SlowStart > 0 { + slowStartManager = loadbalance.NewSlowStartManager(cfg.SlowStart) + } + return &HealthChecker{ - targets: targets, - interval: interval, - timeout: timeout, - path: path, - stopCh: make(chan struct{}), + targets: targets, + interval: interval, + timeout: timeout, + path: path, + stopCh: make(chan struct{}), + matcher: matcher, + slowStartManager: slowStartManager, client: &fasthttp.Client{ ReadTimeout: timeout, WriteTimeout: timeout, @@ -114,6 +137,9 @@ func (h *HealthChecker) Start() { } h.running.Store(true) + if h.slowStartManager != nil { + h.slowStartManager.Start() + } go h.run() } @@ -125,6 +151,9 @@ func (h *HealthChecker) Stop() { if !h.running.CompareAndSwap(true, false) { return // 已经停止,直接返回 } + if h.slowStartManager != nil { + h.slowStartManager.Stop() + } close(h.stopCh) // 重新创建 stopCh 以支持后续 Start h.stopCh = make(chan struct{}) @@ -172,12 +201,12 @@ func (h *HealthChecker) checkAll() { // // 目标被认为健康,如果满足以下条件: // - HTTP 请求成功 -// - 响应状态码在 200 到 299 之间 +// - matcher.Match 返回 true // // 目标被标记为不健康,如果满足以下条件: // - 连接失败 // - 请求超时 -// - 响应状态码不是 2xx +// - matcher.Match 返回 false func (h *HealthChecker) checkTarget(target *loadbalance.Target) { // 构建健康检查 URL url := target.URL + h.path @@ -196,16 +225,23 @@ func (h *HealthChecker) checkTarget(target *loadbalance.Target) { err := h.client.DoTimeout(req, resp, h.timeout) if err != nil { // 连接失败或超时 - 标记为不健康 - target.Healthy.Store(false) + h.MarkUnhealthy(target) return } - // 检查状态码 - 2xx 为健康 + // 提取响应头(小写 key) + headers := make(map[string]string) + for key, value := range resp.Header.All() { + headers[string(key)] = string(value) + } + + // 使用 matcher 判断健康状态 statusCode := resp.StatusCode() - if statusCode >= 200 && statusCode < 300 { - target.Healthy.Store(true) + body := resp.Body() + if h.matcher.Match(statusCode, body, headers) { + h.MarkHealthy(target) } else { - target.Healthy.Store(false) + h.MarkUnhealthy(target) } } @@ -215,9 +251,13 @@ func (h *HealthChecker) checkTarget(target *loadbalance.Target) { // // 同时调用 RecordFailure 记录软失败状态,配合 MaxFails/FailTimeout // 实现失败计数和冷却机制。 +// 同时通知 SlowStartManager 清除慢启动状态。 func (h *HealthChecker) MarkUnhealthy(target *loadbalance.Target) { target.Healthy.Store(false) target.RecordFailure() + if h.slowStartManager != nil { + h.slowStartManager.OnTargetUnhealthy(target) + } } // MarkHealthy 将目标标记为健康。 @@ -225,9 +265,13 @@ func (h *HealthChecker) MarkUnhealthy(target *loadbalance.Target) { // // 同时调用 RecordSuccess 重置软失败状态(failCount/failedUntil), // 但不修改 Healthy 标志——健康检查器对 Healthy 拥有权威。 +// 同时通知 SlowStartManager 开始慢启动。 func (h *HealthChecker) MarkHealthy(target *loadbalance.Target) { target.Healthy.Store(true) target.RecordSuccess() + if h.slowStartManager != nil { + h.slowStartManager.OnTargetHealthy(target) + } } // IsRunning 如果健康检查器当前正在运行,则返回 true。 diff --git a/internal/proxy/health_match.go b/internal/proxy/health_match.go new file mode 100644 index 0000000..f5e67bc --- /dev/null +++ b/internal/proxy/health_match.go @@ -0,0 +1,196 @@ +// Package proxy 提供 HTTP 代理功能。 +// +// 该文件实现 HealthMatch 健康检查匹配接口,支持: +// - 默认 2xx 状态码判断 +// - 自定义状态码范围匹配 +// - 响应体正则匹配 +// - 响应头匹配 +// +// 主要用途: +// +// 灵活定义后端服务器健康判断逻辑,替代硬编码的 2xx 判断。 +// +// 作者:xfy +package proxy + +import ( + "regexp" + "strconv" + "strings" +) + +// HealthMatch 定义健康检查匹配接口。 +// +// 用于判断健康检查响应是否表示目标健康。 +type HealthMatch interface { + // Match 判断健康检查响应是否表示目标健康。 + // + // 参数: + // - status: HTTP 状态码 + // - body: 响应体内容 + // - headers: 响应头(key 为小写) + // + // 返回值: + // - true: 目标健康 + // - false: 目标不健康 + Match(status int, body []byte, headers map[string]string) bool +} + +// defaultHealthMatch 默认健康检查匹配器。 +// +// 判断逻辑:状态码为 2xx 即健康。 +type defaultHealthMatch struct{} + +// Match 实现 HealthMatch 接口。 +func (m *defaultHealthMatch) Match(status int, body []byte, headers map[string]string) bool { + return status >= 200 && status < 300 +} + +// customHealthMatch 自定义健康检查匹配器。 +// +// 支持状态码范围、响应体正则、响应头匹配。 +type customHealthMatch struct { + statusRanges []statusRange // 状态码范围列表 + bodyRegex *regexp.Regexp // 响应体正则(可选) + headerMatches []headerMatch // 响应头匹配列表(可选) +} + +// statusRange 表示状态码范围。 +type statusRange struct { + min int + max int +} + +// headerMatch 表示响应头匹配条件。 +type headerMatch struct { + key string + value string +} + +// Match 实现 HealthMatch 接口。 +func (m *customHealthMatch) Match(status int, body []byte, headers map[string]string) bool { + // 1. 检查状态码 + if !m.matchStatus(status) { + return false + } + + // 2. 检查响应体正则(如果配置) + if m.bodyRegex != nil && !m.bodyRegex.Match(body) { + return false + } + + // 3. 检查响应头(如果配置) + for _, hm := range m.headerMatches { + value, exists := headers[hm.key] + if !exists || value != hm.value { + return false + } + } + + return true +} + +// matchStatus 检查状态码是否匹配任一范围。 +func (m *customHealthMatch) matchStatus(status int) bool { + for _, r := range m.statusRanges { + if status >= r.min && status <= r.max { + return true + } + } + return false +} + +// HealthMatchConfig 健康检查匹配配置。 +type HealthMatchConfig struct { + // Status 状态码范围列表,如 ["200-299", "301"] + Status []string `yaml:"status"` + + // Body 响应体正则表达式 + Body string `yaml:"body"` + + // Headers 响应头匹配,如 {"Content-Type": "application/json"} + Headers map[string]string `yaml:"headers"` +} + +// NewHealthMatch 从配置创建健康检查匹配器。 +// +// 如果配置为空或无效,返回默认匹配器(2xx 判断)。 +func NewHealthMatch(cfg *HealthMatchConfig) HealthMatch { + if cfg == nil { + return &defaultHealthMatch{} + } + + // 解析状态码范围 + var ranges []statusRange + for _, s := range cfg.Status { + r, err := parseStatusRange(s) + if err != nil { + continue // 忽略无效范围 + } + ranges = append(ranges, r) + } + + // 如果没有有效状态码范围,使用默认 2xx + if len(ranges) == 0 { + ranges = []statusRange{{min: 200, max: 299}} + } + + // 解析响应体正则 + var bodyRegex *regexp.Regexp + if cfg.Body != "" { + bodyRegex = regexp.MustCompile(cfg.Body) // 配置加载时预编译 + } + + // 解析响应头匹配 + var headerMatches []headerMatch + for k, v := range cfg.Headers { + headerMatches = append(headerMatches, headerMatch{ + key: strings.ToLower(k), // 统一小写 + value: v, + }) + } + + return &customHealthMatch{ + statusRanges: ranges, + bodyRegex: bodyRegex, + headerMatches: headerMatches, + } +} + +// parseStatusRange 解析状态码范围字符串。 +// +// 支持格式: +// - "200" → 单个状态码 +// - "200-299" → 范围 +func parseStatusRange(s string) (statusRange, error) { + s = strings.TrimSpace(s) + + // 尝试解析范围 + if strings.Contains(s, "-") { + parts := strings.Split(s, "-") + if len(parts) != 2 { + return statusRange{}, strconv.ErrSyntax + } + + min, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + max, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + return statusRange{}, strconv.ErrSyntax + } + + return statusRange{min: min, max: max}, nil + } + + // 单个状态码 + code, err := strconv.Atoi(s) + if err != nil { + return statusRange{}, err + } + + return statusRange{min: code, max: code}, nil +} + +// DefaultHealthMatch 返回默认健康检查匹配器。 +func DefaultHealthMatch() HealthMatch { + return &defaultHealthMatch{} +} diff --git a/internal/proxy/health_match_test.go b/internal/proxy/health_match_test.go new file mode 100644 index 0000000..5f30a5e --- /dev/null +++ b/internal/proxy/health_match_test.go @@ -0,0 +1,282 @@ +package proxy + +import ( + "testing" +) + +func TestDefaultHealthMatch(t *testing.T) { + m := DefaultHealthMatch() + + tests := []struct { + status int + want bool + }{ + {200, true}, + {201, true}, + {299, true}, + {300, false}, + {400, false}, + {500, false}, + {199, false}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got := m.Match(tt.status, nil, nil) + if got != tt.want { + t.Errorf("Match(%d) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +func TestCustomHealthMatch_StatusRange(t *testing.T) { + cfg := &HealthMatchConfig{ + Status: []string{"200-299", "301", "302"}, + } + m := NewHealthMatch(cfg) + + tests := []struct { + status int + want bool + }{ + {200, true}, + {250, true}, + {299, true}, + {301, true}, + {302, true}, + {300, false}, // 不在范围内 + {303, false}, + {400, false}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got := m.Match(tt.status, nil, nil) + if got != tt.want { + t.Errorf("Match(%d) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +func TestCustomHealthMatch_BodyRegex(t *testing.T) { + cfg := &HealthMatchConfig{ + Status: []string{"200"}, + Body: `"status":"ok"`, + } + m := NewHealthMatch(cfg) + + tests := []struct { + name string + status int + body string + want bool + }{ + { + name: "matching body", + status: 200, + body: `{"status":"ok","data":{}}`, + want: true, + }, + { + name: "non-matching body", + status: 200, + body: `{"status":"error"}`, + want: false, + }, + { + name: "wrong status", + status: 500, + body: `{"status":"ok"}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.Match(tt.status, []byte(tt.body), nil) + if got != tt.want { + t.Errorf("Match() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCustomHealthMatch_Headers(t *testing.T) { + cfg := &HealthMatchConfig{ + Status: []string{"200"}, + Headers: map[string]string{ + "X-Health": "ok", + }, + } + m := NewHealthMatch(cfg) + + tests := []struct { + name string + status int + headers map[string]string + want bool + }{ + { + name: "matching header", + status: 200, + headers: map[string]string{ + "x-health": "ok", + }, + want: true, + }, + { + name: "missing header", + status: 200, + headers: map[string]string{ + "content-type": "application/json", + }, + want: false, + }, + { + name: "wrong value", + status: 200, + headers: map[string]string{ + "x-health": "error", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.Match(tt.status, nil, tt.headers) + if got != tt.want { + t.Errorf("Match() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewHealthMatch_NilConfig(t *testing.T) { + m := NewHealthMatch(nil) + + // 应该返回默认匹配器 + if !m.Match(200, nil, nil) { + t.Error("nil config should return default matcher") + } + if m.Match(300, nil, nil) { + t.Error("default matcher should not match 300") + } +} + +func TestNewHealthMatch_EmptyStatus(t *testing.T) { + cfg := &HealthMatchConfig{ + Status: []string{}, // 空 + } + m := NewHealthMatch(cfg) + + // 应该使用默认 2xx 范围 + if !m.Match(200, nil, nil) { + t.Error("empty status should default to 2xx") + } + if m.Match(300, nil, nil) { + t.Error("empty status should default to 2xx, not match 300") + } +} + +func TestParseStatusRange(t *testing.T) { + tests := []struct { + input string + min int + max int + wantErr bool + }{ + {"200", 200, 200, false}, + {"200-299", 200, 299, false}, + {" 200-299 ", 200, 299, false}, + {"200 - 299", 200, 299, false}, + {"abc", 0, 0, true}, + {"200-abc", 0, 0, true}, + {"200-300-400", 0, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + r, err := parseStatusRange(tt.input) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.min != tt.min || r.max != tt.max { + t.Errorf("range = {%d, %d}, want {%d, %d}", r.min, r.max, tt.min, tt.max) + } + }) + } +} + +func TestCustomHealthMatch_Combined(t *testing.T) { + cfg := &HealthMatchConfig{ + Status: []string{"200-299"}, + Body: `"healthy":true`, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + m := NewHealthMatch(cfg) + + tests := []struct { + name string + status int + body string + headers map[string]string + want bool + }{ + { + name: "all match", + status: 200, + body: `{"healthy":true,"status":"ok"}`, + headers: map[string]string{ + "content-type": "application/json", + }, + want: true, + }, + { + name: "status mismatch", + status: 400, + body: `{"healthy":true}`, + headers: map[string]string{ + "content-type": "application/json", + }, + want: false, + }, + { + name: "body mismatch", + status: 200, + body: `{"healthy":false}`, + headers: map[string]string{ + "content-type": "application/json", + }, + want: false, + }, + { + name: "header mismatch", + status: 200, + body: `{"healthy":true}`, + headers: map[string]string{ + "content-type": "text/plain", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.Match(tt.status, []byte(tt.body), tt.headers) + if got != tt.want { + t.Errorf("Match() = %v, want %v", got, tt.want) + } + }) + } +}