- Add atomic EWMA Stats field to Target - Implement LeastTime balancer with header_time and last_byte metrics - Support Select and SelectExcluding with zero-lock design - Add ResponseTimeRecorder interface for proxy integration
638 lines
17 KiB
Go
638 lines
17 KiB
Go
// Package loadbalance 提供负载均衡算法实现。
|
||
//
|
||
// 该文件包含负载均衡相关的核心定义,包括:
|
||
// - Target 目标结构体定义
|
||
// - Balancer 接口定义
|
||
// - ValidAlgorithms 有效算法列表
|
||
//
|
||
// 主要用途:
|
||
//
|
||
// 用于定义负载均衡的标准接口和目标结构,支持多种负载均衡算法。
|
||
//
|
||
// 注意事项:
|
||
// - 所有实现必须并发安全
|
||
// - 目标的健康状态使用 atomic.Bool
|
||
//
|
||
// 作者:xfy
|
||
package loadbalance
|
||
|
||
import (
|
||
"net"
|
||
"net/url"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"rua.plus/lolly/internal/hash"
|
||
)
|
||
|
||
type filterContext struct {
|
||
available []*Target
|
||
backups []*Target
|
||
excludeSet map[string]bool
|
||
}
|
||
|
||
var filterContextPool = sync.Pool{
|
||
New: func() any {
|
||
return &filterContext{
|
||
available: make([]*Target, 0, 64),
|
||
backups: make([]*Target, 0, 64),
|
||
excludeSet: make(map[string]bool, 8),
|
||
}
|
||
},
|
||
}
|
||
|
||
func acquireFilterContext() *filterContext {
|
||
fc, _ := filterContextPool.Get().(*filterContext)
|
||
return fc
|
||
}
|
||
|
||
func releaseFilterContext(fc *filterContext) {
|
||
fc.available = fc.available[:0]
|
||
fc.backups = fc.backups[:0]
|
||
for k := range fc.excludeSet {
|
||
delete(fc.excludeSet, k)
|
||
}
|
||
filterContextPool.Put(fc)
|
||
}
|
||
|
||
func fnvHash64a(key string) uint64 {
|
||
return hash.FNV64a(key)
|
||
}
|
||
|
||
// Target 表示 HTTP 代理(L7 层)的负载均衡后端服务器目标。
|
||
//
|
||
// HTTP Target 特性(区别于 Stream Target):
|
||
// - URL 解析:支持完整 URL(如 http://backend:8080),包含协议、路径、查询参数
|
||
// - DNS 动态解析:resolvedIPs 和 lastResolved 字段支持 DNS TTL 缓存和动态重解析
|
||
// - Failover 支持:配合 Balancer.SelectExcluding 实现失败节点排除重试
|
||
// - 一致性哈希:VirtualHashes 支持一致性哈希算法的虚拟节点
|
||
//
|
||
// 语义差异说明:
|
||
// - HTTP 代理工作在应用层(L7),需要处理 URL 和 DNS 解析
|
||
// - Stream 代理工作在传输层(L4),只需简单 host:port,无需 DNS 缓存
|
||
// - 因此 HTTP Target 和 Stream Target 必须保持独立定义,不可合并
|
||
//
|
||
// 所有字段都设计为使用原子操作进行并发访问(如适用)。
|
||
type Target struct {
|
||
resolvedIPs atomic.Pointer[[]string]
|
||
URL string
|
||
hostname string
|
||
VirtualHashes []uint64
|
||
Weight int
|
||
Connections int64
|
||
lastResolved atomic.Int64
|
||
hostnameOnce sync.Once
|
||
Healthy atomic.Bool
|
||
|
||
// MaxConns 最大并发连接数,0 表示不限制
|
||
MaxConns int64
|
||
// MaxFails 最大失败次数,0 表示不检测
|
||
MaxFails int64
|
||
// FailTimeout 失败冷却时间
|
||
FailTimeout time.Duration
|
||
// Backup 备份服务器标记
|
||
Backup bool
|
||
// Down 永久不可用标记
|
||
Down bool
|
||
// ProxyURI 代理传递的 URI 路径
|
||
ProxyURI string
|
||
|
||
failCount atomic.Int64
|
||
failedUntil atomic.Int64
|
||
|
||
// 慢启动相关字段
|
||
// EffectiveWeight 当前有效权重(慢启动期间动态变化)
|
||
EffectiveWeight atomic.Int64
|
||
// SlowStart 慢启动时间(配置)
|
||
SlowStart time.Duration `yaml:"slow_start"`
|
||
|
||
// Stats 响应时间统计(用于 least_time 算法)
|
||
Stats *EWMAStats
|
||
}
|
||
|
||
// Balancer 是 HTTP 代理(L7 层)负载均衡算法的接口。
|
||
//
|
||
// HTTP Balancer 特性(区别于 Stream Balancer):
|
||
// - Select(): 标准选择方法,按算法策略选择健康目标
|
||
// - SelectExcluding(): 故障转移支持,排除失败节点后选择替代目标
|
||
//
|
||
// 语义差异说明:
|
||
// - HTTP 代理需要 failover 重试能力(next_upstream 配置),因此需要 SelectExcluding
|
||
// - Stream 代理工作在传输层(L4),无重试机制,仅需要 Select 方法
|
||
// - 因此 HTTP Balancer 和 Stream Balancer 接口签名不同,不可合并
|
||
//
|
||
// 实现必须是并发安全的。
|
||
type Balancer interface {
|
||
// Select 根据算法策略从提供的列表中选择一个目标。
|
||
// 如果没有健康目标可用,返回 nil。
|
||
Select(targets []*Target) *Target
|
||
|
||
// SelectExcluding 根据算法策略选择一个目标,排除指定的目标列表。
|
||
// 用于故障转移场景,避免选择已失败的目标。
|
||
// 如果除了排除列表外没有可用目标,返回 nil。
|
||
SelectExcluding(targets []*Target, excluded []*Target) *Target
|
||
}
|
||
|
||
// RoundRobin 实现简单的轮询负载均衡。
|
||
//
|
||
// 它按顺序将请求均匀分配到所有健康目标上,适合后端服务器性能相近的场景。
|
||
//
|
||
// 并发安全:counter 使用 atomic 操作,支持多 goroutine 并发调用。
|
||
type RoundRobin struct {
|
||
// counter 请求计数器,原子递增,用于确定轮询位置
|
||
counter atomic.Uint64
|
||
}
|
||
|
||
// NewRoundRobin 创建一个新的轮询负载均衡器。
|
||
//
|
||
// 该函数初始化一个无状态的 RoundRobin 实例,内部 counter 从零开始。
|
||
// 适合后端服务器性能相近、无需权重区分的场景。
|
||
//
|
||
// 返回值:
|
||
// - *RoundRobin: 初始化的轮询均衡器实例
|
||
func NewRoundRobin() *RoundRobin {
|
||
return &RoundRobin{}
|
||
}
|
||
|
||
// Select 选择轮询顺序中的下一个目标。
|
||
// 只考虑健康目标。如果没有健康目标则返回 nil。
|
||
func (r *RoundRobin) Select(targets []*Target) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
healthy := filterInto(fc, targets)
|
||
if len(healthy) == 0 {
|
||
return nil
|
||
}
|
||
idx := r.counter.Add(1) - 1
|
||
return healthy[idx%uint64(len(healthy))]
|
||
}
|
||
|
||
// WeightedRoundRobin 实现权重轮询负载均衡。
|
||
//
|
||
// 权重越高的目标接收成比例更多的请求,适合后端服务器性能差异较大的场景。
|
||
//
|
||
// 并发安全:counter 使用 atomic 操作,支持多 goroutine 并发调用。
|
||
type WeightedRoundRobin struct {
|
||
// counter 请求计数器,原子递增,用于确定权重分布位置
|
||
counter atomic.Uint64
|
||
}
|
||
|
||
// NewWeightedRoundRobin 创建一个新的权重轮询负载均衡器。
|
||
//
|
||
// 该函数初始化一个 WeightedRoundRobin 实例,内部 counter 从零开始。
|
||
// 权重越高的目标接收成比例更多的请求,适合后端服务器性能差异较大的场景。
|
||
//
|
||
// 返回值:
|
||
// - *WeightedRoundRobin: 初始化的权重轮询均衡器实例
|
||
func NewWeightedRoundRobin() *WeightedRoundRobin {
|
||
return &WeightedRoundRobin{}
|
||
}
|
||
|
||
// Select 基于权重分布选择目标。
|
||
// 只考虑健康目标。如果没有健康目标则返回 nil。
|
||
func (w *WeightedRoundRobin) Select(targets []*Target) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
healthy := filterInto(fc, targets)
|
||
if len(healthy) == 0 {
|
||
return nil
|
||
}
|
||
|
||
totalWeight := 0
|
||
for _, t := range healthy {
|
||
if t.Weight <= 0 {
|
||
totalWeight++
|
||
} else {
|
||
totalWeight += t.Weight
|
||
}
|
||
}
|
||
|
||
if totalWeight == 0 {
|
||
return nil
|
||
}
|
||
|
||
idx := w.counter.Add(1) - 1
|
||
pos := int(idx % uint64(totalWeight))
|
||
|
||
currentWeight := 0
|
||
for _, t := range healthy {
|
||
weight := t.Weight
|
||
if weight <= 0 {
|
||
weight = 1
|
||
}
|
||
currentWeight += weight
|
||
if pos < currentWeight {
|
||
return t
|
||
}
|
||
}
|
||
|
||
return healthy[len(healthy)-1]
|
||
}
|
||
|
||
// LeastConnections 实现最少连接负载均衡。
|
||
//
|
||
// 它选择活跃连接数最少的目标,适合请求处理时间差异较大的场景。
|
||
//
|
||
// 该算法无状态设计,不维护内部计数器,直接读取目标连接数。
|
||
type LeastConnections struct{}
|
||
|
||
// NewLeastConnections 创建一个新的最少连接负载均衡器。
|
||
//
|
||
// 返回值:
|
||
// - 初始化的 LeastConnections 实例
|
||
func NewLeastConnections() *LeastConnections {
|
||
return &LeastConnections{}
|
||
}
|
||
|
||
// Select 选择连接数最少的目标。
|
||
// 只考虑健康目标。如果没有健康目标则返回 nil。
|
||
func (l *LeastConnections) Select(targets []*Target) *Target {
|
||
var selected *Target
|
||
var minConns int64 = -1
|
||
|
||
for _, t := range targets {
|
||
if !t.Healthy.Load() {
|
||
continue
|
||
}
|
||
|
||
// 原子地读取连接计数
|
||
conns := atomic.LoadInt64(&t.Connections)
|
||
|
||
if selected == nil || conns < minConns {
|
||
selected = t
|
||
minConns = conns
|
||
}
|
||
}
|
||
|
||
return selected
|
||
}
|
||
|
||
// IPHash 实现基于 IP 哈希的负载均衡。
|
||
//
|
||
// 它将来自同一客户端 IP 的请求始终路由到同一目标,实现会话保持。
|
||
// 适合需要会话粘性的场景,如无状态后端的有状态请求处理。
|
||
type IPHash struct{}
|
||
|
||
// NewIPHash 创建一个新的 IP 哈希负载均衡器。
|
||
//
|
||
// 返回值:
|
||
// - 初始化的 IPHash 实例
|
||
func NewIPHash() *IPHash {
|
||
return &IPHash{}
|
||
}
|
||
|
||
// Select 基于客户端 IP 的哈希值选择目标。
|
||
// 只考虑健康目标。如果没有健康目标则返回 nil。
|
||
// clientIP 参数应该是客户端的 IP 地址字符串。
|
||
func (i *IPHash) Select(targets []*Target) *Target {
|
||
return i.SelectByIP(targets, "")
|
||
}
|
||
|
||
// SelectByIP 基于提供的 IP 地址的哈希值选择目标。
|
||
// 只考虑健康目标。如果没有健康目标则返回 nil。
|
||
func (i *IPHash) SelectByIP(targets []*Target, clientIP string) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
healthy := filterInto(fc, targets)
|
||
if len(healthy) == 0 {
|
||
return nil
|
||
}
|
||
hash := fnvHash64a(clientIP)
|
||
idx := hash % uint64(len(healthy))
|
||
return healthy[idx]
|
||
}
|
||
|
||
// GetEffectiveWeight 获取目标的有效权重。
|
||
//
|
||
// 如果未配置慢启动或不在慢启动状态,返回配置权重。
|
||
// 慢启动期间,权重从 1 逐渐增加到配置权重。
|
||
func (t *Target) GetEffectiveWeight() int {
|
||
ew := t.EffectiveWeight.Load()
|
||
if ew == 0 {
|
||
return t.Weight // 未配置慢启动时返回配置权重
|
||
}
|
||
return int(ew)
|
||
}
|
||
|
||
// IsAvailable 检查目标是否可用。
|
||
// 目标不可用的条件(优先级从高到低):
|
||
// - Healthy 为 false(硬性不可用,由健康检查器设置)
|
||
// - Down 为 true(配置标记永久不可用)
|
||
// - 超过 MaxConns 限制
|
||
// - 失败冷却期内(failCount >= MaxFails 且未超过 FailTimeout)
|
||
func (t *Target) IsAvailable() bool {
|
||
if !t.Healthy.Load() || t.Down {
|
||
return false
|
||
}
|
||
if t.MaxConns > 0 && atomic.LoadInt64(&t.Connections) >= t.MaxConns {
|
||
return false
|
||
}
|
||
if t.MaxFails > 0 {
|
||
failCount := t.failCount.Load()
|
||
if failCount >= t.MaxFails {
|
||
failedUntil := t.failedUntil.Load()
|
||
if time.Now().UnixNano() < failedUntil {
|
||
return false
|
||
}
|
||
if failedUntil > 0 {
|
||
t.failCount.Store(0)
|
||
t.failedUntil.Store(0)
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// RecordFailure 记录一次失败。
|
||
// 使用互斥锁保护 failCount/failedUntil 的协调更新。
|
||
// 返回当前失败计数。
|
||
func (t *Target) RecordFailure() int64 {
|
||
if t.MaxFails <= 0 {
|
||
return 0
|
||
}
|
||
count := t.failCount.Add(1)
|
||
if count >= t.MaxFails {
|
||
timeout := t.FailTimeout
|
||
if timeout <= 0 {
|
||
timeout = 10 * time.Second
|
||
}
|
||
t.failedUntil.Store(time.Now().Add(timeout).UnixNano())
|
||
}
|
||
return count
|
||
}
|
||
|
||
// RecordSuccess 记录一次成功,重置软失败状态。
|
||
// 仅重置 failCount 和 failedUntil,不修改 Healthy(健康检查器权威)。
|
||
func (t *Target) RecordSuccess() {
|
||
if t.MaxFails <= 0 {
|
||
return
|
||
}
|
||
t.failCount.Store(0)
|
||
t.failedUntil.Store(0)
|
||
}
|
||
|
||
// IsBackup 返回目标是否为备份服务器。
|
||
func (t *Target) IsBackup() bool {
|
||
return t.Backup
|
||
}
|
||
|
||
func filterInto(fc *filterContext, targets []*Target) []*Target {
|
||
for _, t := range targets {
|
||
if !t.IsAvailable() {
|
||
continue
|
||
}
|
||
if t.IsBackup() {
|
||
fc.backups = append(fc.backups, t)
|
||
} else {
|
||
fc.available = append(fc.available, t)
|
||
}
|
||
}
|
||
if len(fc.available) > 0 {
|
||
return fc.available
|
||
}
|
||
return fc.backups
|
||
}
|
||
|
||
// IncrementConnections 原子地增加目标的连接计数。
|
||
// 当新连接建立时应调用此函数。
|
||
func IncrementConnections(t *Target) {
|
||
atomic.AddInt64(&t.Connections, 1)
|
||
}
|
||
|
||
// DecrementConnections 原子地减少目标的连接计数。
|
||
// 当连接关闭时应调用此函数。
|
||
func DecrementConnections(t *Target) {
|
||
atomic.AddInt64(&t.Connections, -1)
|
||
}
|
||
|
||
func filterIntoExcluding(fc *filterContext, targets []*Target, excluded []*Target) []*Target {
|
||
for _, t := range excluded {
|
||
if t != nil {
|
||
fc.excludeSet[t.URL] = true
|
||
}
|
||
}
|
||
for _, t := range targets {
|
||
if !t.IsAvailable() || fc.excludeSet[t.URL] {
|
||
continue
|
||
}
|
||
if t.IsBackup() {
|
||
fc.backups = append(fc.backups, t)
|
||
} else {
|
||
fc.available = append(fc.available, t)
|
||
}
|
||
}
|
||
if len(fc.available) > 0 {
|
||
return fc.available
|
||
}
|
||
return fc.backups
|
||
}
|
||
|
||
// SelectExcluding 根据轮询策略选择一个目标,排除指定的目标列表。
|
||
// 只考虑健康且不在排除列表中的目标。
|
||
func (r *RoundRobin) SelectExcluding(targets []*Target, excluded []*Target) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
available := filterIntoExcluding(fc, targets, excluded)
|
||
if len(available) == 0 {
|
||
return nil
|
||
}
|
||
idx := r.counter.Add(1) - 1
|
||
return available[idx%uint64(len(available))]
|
||
}
|
||
|
||
// SelectExcluding 根据权重分布选择目标,排除指定的目标列表。
|
||
// 只考虑健康且不在排除列表中的目标。
|
||
func (w *WeightedRoundRobin) SelectExcluding(targets []*Target, excluded []*Target) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
available := filterIntoExcluding(fc, targets, excluded)
|
||
if len(available) == 0 {
|
||
return nil
|
||
}
|
||
|
||
totalWeight := 0
|
||
for _, t := range available {
|
||
if t.Weight <= 0 {
|
||
totalWeight++
|
||
} else {
|
||
totalWeight += t.Weight
|
||
}
|
||
}
|
||
|
||
if totalWeight == 0 {
|
||
return nil
|
||
}
|
||
|
||
idx := w.counter.Add(1) - 1
|
||
pos := int(idx % uint64(totalWeight))
|
||
|
||
currentWeight := 0
|
||
for _, t := range available {
|
||
weight := t.Weight
|
||
if weight <= 0 {
|
||
weight = 1
|
||
}
|
||
currentWeight += weight
|
||
if pos < currentWeight {
|
||
return t
|
||
}
|
||
}
|
||
|
||
return available[len(available)-1]
|
||
}
|
||
|
||
// SelectExcluding 选择连接数最少的目标,排除指定的目标列表。
|
||
// 优先选择非备份目标,仅当无可用非备份目标时选择备份目标。
|
||
func (l *LeastConnections) SelectExcluding(targets []*Target, excluded []*Target) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
for _, t := range excluded {
|
||
if t != nil {
|
||
fc.excludeSet[t.URL] = true
|
||
}
|
||
}
|
||
|
||
var selected *Target
|
||
var selectedBackup *Target
|
||
var minConns int64 = -1
|
||
var minBackupConns int64 = -1
|
||
|
||
for _, t := range targets {
|
||
if !t.IsAvailable() || fc.excludeSet[t.URL] {
|
||
continue
|
||
}
|
||
conns := atomic.LoadInt64(&t.Connections)
|
||
if t.IsBackup() {
|
||
if selectedBackup == nil || conns < minBackupConns {
|
||
selectedBackup = t
|
||
minBackupConns = conns
|
||
}
|
||
} else {
|
||
if selected == nil || conns < minConns {
|
||
selected = t
|
||
minConns = conns
|
||
}
|
||
}
|
||
}
|
||
|
||
if selected != nil {
|
||
return selected
|
||
}
|
||
return selectedBackup
|
||
}
|
||
|
||
// SelectExcluding 基于客户端 IP 的哈希值选择目标,排除指定的目标列表。
|
||
// 只考虑健康且不在排除列表中的目标。
|
||
func (i *IPHash) SelectExcluding(targets []*Target, excluded []*Target) *Target {
|
||
return i.SelectExcludingByIP(targets, excluded, "")
|
||
}
|
||
|
||
// SelectExcludingByIP 基于提供的 IP 地址的哈希值选择目标,排除指定的目标列表。
|
||
// 只考虑健康且不在排除列表中的目标。
|
||
func (i *IPHash) SelectExcludingByIP(targets []*Target, excluded []*Target, clientIP string) *Target {
|
||
fc := acquireFilterContext()
|
||
defer releaseFilterContext(fc)
|
||
available := filterIntoExcluding(fc, targets, excluded)
|
||
if len(available) == 0 {
|
||
return nil
|
||
}
|
||
hash := fnvHash64a(clientIP)
|
||
idx := hash % uint64(len(available))
|
||
return available[idx]
|
||
}
|
||
|
||
// Hostname 返回目标主机名(从 URL 提取)。
|
||
// 使用 sync.Once 确保线程安全,只初始化一次。
|
||
func (t *Target) Hostname() string {
|
||
t.hostnameOnce.Do(t.initHostname)
|
||
return t.hostname
|
||
}
|
||
|
||
// ResolvedIPs 返回解析后的 IP 列表。
|
||
// 如果未解析过,返回 nil。
|
||
func (t *Target) ResolvedIPs() []string {
|
||
ips := t.resolvedIPs.Load()
|
||
if ips == nil {
|
||
return nil
|
||
}
|
||
return *ips
|
||
}
|
||
|
||
// SetResolvedIPs 设置解析后的 IP 列表,并更新最后解析时间。
|
||
func (t *Target) SetResolvedIPs(ips []string) {
|
||
// 创建副本避免外部修改
|
||
ipsCopy := make([]string, len(ips))
|
||
copy(ipsCopy, ips)
|
||
t.resolvedIPs.Store(&ipsCopy)
|
||
t.lastResolved.Store(time.Now().UnixNano())
|
||
}
|
||
|
||
// NeedsResolve 检查是否需要重新解析。
|
||
// 如果 hostname 是 IP 地址,返回 false。
|
||
// 如果从未解析过或超过 TTL,返回 true。
|
||
func (t *Target) NeedsResolve(ttl time.Duration) bool {
|
||
host := t.Hostname()
|
||
|
||
// IP 类型的 URL 不需要解析
|
||
if net.ParseIP(host) != nil {
|
||
return false
|
||
}
|
||
|
||
last := t.lastResolved.Load()
|
||
if last == 0 {
|
||
return true // 首次解析
|
||
}
|
||
|
||
return time.Since(time.Unix(0, last)) > ttl
|
||
}
|
||
|
||
// initHostname 从 URL 中提取并缓存主机名。
|
||
// 必须在 Target 创建后调用一次。
|
||
func (t *Target) initHostname() {
|
||
u, err := url.Parse(t.URL)
|
||
if err != nil {
|
||
// 解析失败,使用整个 URL 作为 hostname
|
||
t.hostname = t.URL
|
||
return
|
||
}
|
||
|
||
// 提取主机名(去掉端口)
|
||
host := u.Host
|
||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||
t.hostname = h
|
||
} else {
|
||
t.hostname = host
|
||
}
|
||
}
|
||
|
||
// NewTargetFromConfig 从配置创建 Target(推荐入口)。
|
||
// 自动初始化 hostname 和 Healthy 状态,设置上游参数。
|
||
func NewTargetFromConfig(url string, weight int, maxConns int64, maxFails int64, failTimeout time.Duration, backup bool, down bool, proxyURI string) *Target {
|
||
t := &Target{
|
||
URL: url,
|
||
Weight: weight,
|
||
MaxConns: maxConns,
|
||
MaxFails: maxFails,
|
||
FailTimeout: failTimeout,
|
||
Backup: backup,
|
||
Down: down,
|
||
ProxyURI: proxyURI,
|
||
Stats: NewEWMAStats(),
|
||
}
|
||
t.initHostname()
|
||
if !down {
|
||
t.Healthy.Store(true)
|
||
}
|
||
return t
|
||
}
|
||
|
||
// LastResolved 返回最后解析时间。
|
||
func (t *Target) LastResolved() time.Time {
|
||
nano := t.lastResolved.Load()
|
||
if nano == 0 {
|
||
return time.Time{}
|
||
}
|
||
return time.Unix(0, nano)
|
||
}
|