chore: release v0.4.0

- Update CHANGELOG.md for v0.4.0
- Update Makefile FALLBACK_VERSION to 0.4.0
- Fix lint warnings (godoc comments, goconst)
- Clean up code formatting
This commit is contained in:
xfy 2026-06-09 15:59:36 +08:00
parent 503daf65d3
commit f12ffd180f
8 changed files with 53 additions and 28 deletions

View File

@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.4.0] - 2026-06-09
### Added
#### 负载均衡
- **Least Time 负载均衡器**:基于 EWMA指数加权移动平均统计的最少响应时间算法自动选择响应最快上游
- **Session Sticky 负载均衡器**:基于 Cookie 的会话粘性,一致性哈希分片,支持 Cookie 过期、域名、路径、Secure/HttpOnly/SameSite 属性
- 对应 YAML 配置支持:`least_time``sticky` 策略及参数
#### 平台与构建
- FreeBSD 部署示例
### Fixed
- Least Time 响应时间记录修正
- Sticky Cookie 格式、分片键、过期检查修复
- Sticky 双重 Stop 防护和重启支持
- 配置验证:`least_time``default_time` 不允许负值
### Tests
- Least Time 和 Sticky 负载均衡器集成测试
- Least Time 和 Sticky 基准测试
## [0.3.0] - 2026-06-05
### Added

View File

@ -1,7 +1,7 @@
# Makefile - Lolly Build Commands
APP_NAME := lolly
FALLBACK_VERSION := 0.3.0
FALLBACK_VERSION := 0.4.0
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "$(FALLBACK_VERSION)")
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")

View File

@ -19,7 +19,7 @@ import (
// 注意事项:
// - Path 使用前缀匹配,较长路径优先匹配
// - 至少配置一个 Target 才能正常工作
// - 负载均衡算法支持round_robin、weighted_round_robin、least_conn、ip_hash、consistent_hash、random、least_time、sticky
// - 负载均衡算法支持round_robin、weighted_round_robin、least_conn、ip_hash、consistent_hash、random、least_time、sticky
// - 一致性哈希需要配置 HashKey
//
// 使用示例:

View File

@ -11,10 +11,11 @@ import (
)
const (
gzipType = "gzip"
offValue = "off"
redirectType = "redirect"
staticType = "static"
gzipType = "gzip"
offValue = "off"
redirectType = "redirect"
staticType = "static"
returnDirective = "return"
)
// Warning represents a conversion warning for unsupported or partially supported directives.
@ -70,7 +71,7 @@ var unsupportedDirectives = map[string]string{
"split_clients": "the 'split_clients' directive is not supported",
"geo": "the 'geo' directive is not supported; use access.geoip config instead",
"range": "the 'range' directive is not supported",
"return": "the 'return' directive is not supported for non-redirect status codes; only 301/302 are supported",
returnDirective: "the 'return' directive is not supported for non-redirect status codes; only 301/302 are supported",
}
// Convert converts a parsed nginx configuration to a lolly configuration.
@ -273,7 +274,7 @@ func convertServerBlock(d *Directive, upstreams map[string]*upstreamInfo, result
parseAccessLog(bd, result)
case "error_log":
parseErrorLog(bd, result)
case "return":
case returnDirective:
parseServerReturn(bd, &baseServer, result)
case "rewrite":
parseRewrite(bd, &baseServer)
@ -457,7 +458,7 @@ func parseServerReturn(d *Directive, server *config.ServerConfig, result *Conver
})
default:
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Directive: returnDirective,
Line: d.Line,
File: d.File,
Message: fmt.Sprintf("return %d is not a redirect; only 301/302 are supported at server level", code),
@ -554,7 +555,7 @@ func classifyLocation(d *Directive, serverRoot string, result *ConvertResult) lo
hasRootOrAlias = true
case "try_files":
hasTryFiles = true
case "return":
case returnDirective:
if len(d.Block[i].Args) > 0 {
code, err := strconv.Atoi(d.Block[i].Args[0])
if err == nil && (code == 301 || code == 302) {
@ -876,7 +877,7 @@ func convertRedirectDirectives(directives []Directive, locPath string, server *c
for i := range directives {
d := &directives[i]
if d.Name != "return" {
if d.Name != returnDirective {
continue
}
@ -900,7 +901,7 @@ func convertRedirectDirectives(directives []Directive, locPath string, server *c
Flag: "permanent",
})
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Directive: returnDirective,
Line: d.Line,
File: d.File,
Message: "return 301 converted to rewrite rule with permanent flag",
@ -912,14 +913,14 @@ func convertRedirectDirectives(directives []Directive, locPath string, server *c
Flag: "redirect",
})
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Directive: returnDirective,
Line: d.Line,
File: d.File,
Message: "return 302 converted to rewrite rule with redirect flag",
})
default:
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Directive: returnDirective,
Line: d.Line,
File: d.File,
Message: fmt.Sprintf("return %d in location is not a redirect; only 301/302 are supported", code),

View File

@ -71,7 +71,6 @@ func TestProxyRequestHeaders(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证代理配置已设置
assert.NotNil(t, cfg.Headers.SetRequest)
assert.Equal(t, "custom-value", cfg.Headers.SetRequest["X-Custom-Header"])
@ -101,7 +100,6 @@ func TestProxyResponseHeaders(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证响应头配置
assert.Equal(t, "lolly", cfg.Headers.SetResponse["X-Server"])
assert.Contains(t, cfg.Headers.Remove, "X-Powered-By")
@ -125,7 +123,6 @@ func TestProxyTimeout(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证超时配置
assert.Equal(t, 1*time.Second, cfg.Timeout.Connect)
assert.Equal(t, 50*time.Millisecond, cfg.Timeout.Read)
@ -150,7 +147,6 @@ func TestProxyLoadBalanceRoundRobin(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证负载均衡器类型
assert.Equal(t, "round_robin", cfg.LoadBalance)
assert.Len(t, targets, 2)
@ -174,7 +170,6 @@ func TestProxyWeightedRoundRobin(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证权重配置
assert.Equal(t, 3, targets[0].Weight)
assert.Equal(t, 1, targets[1].Weight)
@ -198,7 +193,6 @@ func TestProxyLeastConn(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
assert.Equal(t, "least_conn", cfg.LoadBalance)
}
@ -220,7 +214,6 @@ func TestProxyIPHash(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
assert.Equal(t, "ip_hash", cfg.LoadBalance)
}
@ -240,7 +233,6 @@ func TestProxyConsistentHash(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
assert.Equal(t, "consistent_hash", cfg.LoadBalance)
assert.Equal(t, "uri", cfg.HashKey)
assert.Equal(t, 150, cfg.VirtualNodes)
@ -268,7 +260,6 @@ func TestProxyErrorHandling(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证 MaxFails 配置 (int64 类型)
assert.Equal(t, int64(3), targets[0].MaxFails)
assert.Equal(t, 10*time.Second, targets[0].FailTimeout)
@ -300,7 +291,6 @@ func TestProxyCacheConfig(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证缓存配置
assert.True(t, cfg.Cache.Enabled)
assert.Equal(t, 60*time.Second, cfg.Cache.MaxAge)
@ -330,7 +320,6 @@ func TestProxyNextUpstream(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证故障转移配置
assert.Equal(t, 3, cfg.NextUpstream.Tries)
assert.Contains(t, cfg.NextUpstream.HTTPCodes, 502)
@ -360,7 +349,6 @@ func TestProxyHealthCheck(t *testing.T) {
_, err := proxy.NewProxy(cfg, targets, nil, nil)
require.NoError(t, err)
// 验证健康检查配置
assert.Equal(t, 10*time.Second, cfg.HealthCheck.Interval)
assert.Equal(t, "/health", cfg.HealthCheck.Path)

View File

@ -14,10 +14,12 @@ type EWMAStats struct {
const defaultAlphaScale = 300 // alpha = 0.3
// NewEWMAStats 创建新的 EWMA 统计器。
func NewEWMAStats() *EWMAStats {
return &EWMAStats{}
}
// Record 记录一次响应时间样本。
func (e *EWMAStats) Record(headerTime, lastByteTime time.Duration) {
e.recordAtomic(&e.headerTime, headerTime)
e.recordAtomic(&e.lastByteTime, lastByteTime)
@ -41,18 +43,22 @@ func (e *EWMAStats) recordAtomic(ptr *atomic.Int64, newValue time.Duration) {
}
}
// HeaderTime 返回首字节时间的 EWMA 值。
func (e *EWMAStats) HeaderTime() time.Duration {
return time.Duration(e.headerTime.Load())
}
// LastByteTime 返回完整响应时间的 EWMA 值。
func (e *EWMAStats) LastByteTime() time.Duration {
return time.Duration(e.lastByteTime.Load())
}
// SampleCount 返回已记录的样本数量。
func (e *EWMAStats) SampleCount() int64 {
return e.sampleCount.Load()
}
// Reset 重置所有统计数据。
func (e *EWMAStats) Reset() {
e.headerTime.Store(0)
e.lastByteTime.Store(0)

View File

@ -97,5 +97,7 @@ func (l *LeastTime) GetMetric() string {
return l.metric
}
var _ Balancer = (*LeastTime)(nil)
var _ ResponseTimeRecorder = (*LeastTime)(nil)
var (
_ Balancer = (*LeastTime)(nil)
_ ResponseTimeRecorder = (*LeastTime)(nil)
)

View File

@ -2,6 +2,7 @@ package loadbalance
import "time"
// StickyConfig 配置 Sticky 负载均衡的 Cookie 参数。
type StickyConfig struct {
Enabled bool `yaml:"enabled"`
Name string `yaml:"name"`
@ -13,6 +14,7 @@ type StickyConfig struct {
SameSite string `yaml:"same_site"`
}
// DefaultStickyConfig 返回 Sticky 负载均衡的默认配置。
func DefaultStickyConfig() StickyConfig {
return StickyConfig{
Name: "lolly_route",