第一章:Go语言HTTP客户端GET请求失败的典型现象与根因定位
Go语言中http.Get()看似简洁,但实际运行时频繁出现无响应、超时、连接拒绝或返回空体等静默失败。这些现象往往掩盖了底层网络、TLS握手、DNS解析或服务端策略等多维度问题。
常见失败现象归类
- 连接建立阶段失败:
dial tcp: i/o timeout(DNS解析超时或目标不可达) - TLS握手失败:
x509: certificate signed by unknown authority(自签名证书未信任)或tls: handshake did not complete(协议版本不兼容) - 请求发送后无响应:
context deadline exceeded(未显式设置Timeout,默认无限等待) - 服务端主动拒绝:返回
403 Forbidden或429 Too Many Requests,但err == nil导致误判为成功
快速根因验证步骤
-
使用
curl -v复现请求,对比底层行为:curl -v "https://api.example.com/data" --connect-timeout 5 --max-time 10观察DNS解析时间、TCP连接耗时、TLS握手是否完成。
-
在Go代码中启用标准库调试日志:
import "net/http/httptrace" // 在client.Do前添加trace,记录DNS、连接、TLS各阶段耗时 trace := &httptrace.ClientTrace{ DNSStart: func(info httptrace.DNSStartInfo) { log.Printf("DNS start: %s", info.Host) }, TLSHandshakeStart: func() { log.Println("TLS handshake start") }, } req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) -
检查客户端配置完整性: 配置项 缺失后果 推荐设置 Timeout连接/读写无限阻塞 &http.Client{Timeout: 10 * time.Second}Transport无法复用连接、忽略代理/CA 自定义 &http.Transport{...}CheckRedirect重定向循环崩溃或跳过认证跳转 显式定义重定向策略
关键防御性编码实践
始终检查resp和err双返回值;对resp.Body务必defer resp.Body.Close();使用http.DefaultClient前确认其Timeout为零值(即无超时),切勿直接依赖。
第二章:超时控制参数深度解析与调优实践
2.1 DefaultTransport的DialTimeout默认值陷阱与连接建立超时修复
Go 标准库 http.DefaultTransport 的 DialTimeout(已弃用)及现代等效字段 DialContext 配合 net.Dialer.Timeout,其默认值为 0 —— 即无限等待 DNS 解析与 TCP 握手,极易引发 goroutine 泄漏。
默认行为风险
- DNS 查询卡顿(如上游 resolver 延迟)→ 整个请求阻塞
- 中间网络设备丢 SYN 包 → 等待长达数分钟(依赖 OS TCP retransmit)
修复方案:显式设限
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // ⚠️ 关键:控制 TCP 连接建立上限
KeepAlive: 30 * time.Second,
}).DialContext,
}
Timeout仅作用于底层connect()系统调用,不包含 TLS 握手或 HTTP 写入;若需端到端控制,须配合http.Client.Timeout或context.WithTimeout。
推荐超时分层策略
| 阶段 | 推荐值 | 说明 |
|---|---|---|
| DNS 解析 | ≤ 2s | 可通过 net.Resolver 自定义 |
| TCP 连接建立 | 3–5s | 平衡成功率与响应性 |
| TLS 握手 | ≤ 10s | 受证书链、OCSP 影响大 |
| 全请求(含读写) | 30s+ | 由 Client.Timeout 统一管控 |
graph TD
A[HTTP Client] --> B{DialContext}
B --> C[DNS Lookup]
C -->|Timeout| D[Fail fast]
B --> E[TCP connect]
E -->|Timeout| D
E --> F[Success]
F --> G[TLS Handshake]
2.2 ResponseHeaderTimeout在高延迟API场景下的误判与精准配置方案
当API平均响应时间达800ms以上时,ResponseHeaderTimeout 默认值(如30s)易被误判为“服务不可用”,实则为慢查询或外部依赖延迟。
常见误判根因
- 客户端过早终止连接,掩盖真实瓶颈
- 负载均衡器与客户端超时未对齐,引发级联中断
- 日志中仅记录
net/http: timeout awaiting response headers,缺失上下文指标
精准配置三原则
- ✅ 依据P95响应延迟 × 1.5设定(例:P95=1.2s → 设为2s)
- ✅ 同步调整反向代理(Nginx
proxy_read_timeout)与客户端超时 - ❌ 避免全局设为60s+——掩盖性能退化信号
Go HTTP Client 示例
client := &http.Client{
Timeout: 10 * time.Second, // 整体请求上限
Transport: &http.Transport{
ResponseHeaderTimeout: 2 * time.Second, // 仅约束header接收阶段
IdleConnTimeout: 30 * time.Second,
},
}
ResponseHeaderTimeout 仅控制从发起请求到收到首个字节(status line + headers)的等待时长。若后端需2s预热缓存或建立DB连接,此值须≥该开销;但过大会延迟故障发现。
| 场景 | 推荐值 | 说明 |
|---|---|---|
| 内部gRPC网关调用 | 800ms | 低延迟微服务间通信 |
| 跨AZ数据库同步API | 2.5s | 网络RTT+连接池冷启动耗时 |
| 第三方支付回调聚合 | 5s | 外部系统固有延迟波动大 |
graph TD
A[发起HTTP请求] --> B{ResponseHeaderTimeout计时开始}
B --> C[收到Status Line & Headers?]
C -->|Yes| D[进入Body读取阶段]
C -->|No & 超时| E[返回timeout错误]
E --> F[日志无body内容,无法区分网络/服务层问题]
2.3 ExpectContinueTimeout对服务端兼容性的影响及禁用时机分析
Expect: 100-continue 是 HTTP/1.1 协议中客户端向服务端发起大请求体前的预检机制,而 ExpectContinueTimeout 控制客户端等待 100 Continue 响应的最长时间。
兼容性痛点场景
- 老旧 HTTP 服务器(如早期 Nginx 100 Continue
- 中间代理(如某些企业级 WAF)静默丢弃
Expect头或超时后直接转发 - gRPC-Web 网关等非标准实现可能忽略该语义
禁用建议时机
- 服务端明确声明不支持
100-continue(通过417 Expectation Failed或无响应) - 客户端日志高频出现
HttpRequestException: Operation timed out且伴随Expect:头 - 请求体确定小于 1MB(避免预检开销),且服务端已通过
Connection: close显式告知无流水线能力
.NET 中禁用示例
var handler = new HttpClientHandler
{
// 禁用 Expect: 100-continue 行为
ExpectContinueTimeout = TimeSpan.Zero // ⚠️ 设为零即彻底禁用
};
var client = new HttpClient(handler);
TimeSpan.Zero 触发底层 WinHttp/curl 的 CURLOPT_HTTP_CONTENT_DECODING=0 等效行为,跳过等待阶段,直接发送完整请求体。此设置适用于已知服务端不兼容的灰度环境。
| 场景 | 推荐值 | 风险 |
|---|---|---|
| 标准 HTTP/1.1 服务端 | 1000ms(默认) |
低延迟下偶发误判 |
| IoT 设备网关 | 0ms |
提前暴露服务端协议缺陷 |
| CDN 回源链路 | 300ms |
平衡兼容性与首字节时间 |
graph TD
A[客户端发送 Expect: 100-continue] --> B{服务端是否响应100 Continue?}
B -->|是| C[发送请求体]
B -->|否/超时| D[自动重发含完整Body的请求]
D --> E[服务端重复解析/存储风险]
2.4 IdleConnTimeout与KeepAlive机制协同失效导致的连接池耗尽实战复现
当 IdleConnTimeout(如30s)早于 TCP 层 KeepAlive 探测周期(如默认7200s),空闲连接在被探测前已被 HTTP 连接池主动关闭,而服务端仍维持半开状态。客户端复用时触发 connection reset,net/http 将其标记为 broken 并丢弃,但未及时归还可用连接槽位。
失效链路示意
graph TD
A[客户端发起请求] --> B[连接空闲25s]
B --> C{IdleConnTimeout=30s?}
C -->|是| D[连接从pool中移除]
C -->|否| E[等待KeepAlive探测]
D --> F[服务端TCP状态:ESTABLISHED]
F --> G[下次复用→RST→标记broken]
关键配置对比
| 参数 | 典型值 | 作用域 | 协同风险 |
|---|---|---|---|
IdleConnTimeout |
30s | http.Transport |
连接池级回收 |
KeepAlive |
30s | net.ListenConfig |
OS TCP层保活 |
MaxIdleConnsPerHost |
100 | http.Transport |
槽位上限 |
修复代码片段
tr := &http.Transport{
IdleConnTimeout: 60 * time.Second, // ≥ KeepAlive周期
KeepAlive: 30 * time.Second, // 启用并显式设置
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
}
此处 IdleConnTimeout 必须 ≥ KeepAlive 周期,确保连接在被探测存活后才参与复用;否则连接池持续“假释放、真泄漏”,最终耗尽 MaxIdleConnsPerHost 槽位。
2.5 TLSHandshakeTimeout在混合CDN环境下引发的HTTPS请求随机中断诊断与加固
混合CDN架构中,边缘节点与源站间TLS握手超时配置不一致,常导致HTTPS连接在ClientHello至ServerHello阶段无声中断。
根因定位:超时值级联失配
- Cloudflare边缘默认
TLSHandshakeTimeout=10s - 自建Nginx源站未显式配置
ssl_handshake_timeout(内核级默认仅5s) - 中间WAF设备额外引入1–2s TLS处理延迟
关键配置比对表
| 组件 | 配置项 | 实际值 | 风险等级 |
|---|---|---|---|
| CDN边缘 | tls_handshake_timeout |
10s | 低 |
| Nginx源站 | ssl_handshake_timeout |
—(fallback=5s) | 高 |
| 四层负载均衡 | TCP idle timeout | 6s | 中 |
Nginx加固配置
# /etc/nginx/conf.d/ssl.conf
ssl_handshake_timeout 12s; # 必须 > CDN最大RTT + WAF处理时间
ssl_protocols TLSv1.2 TLSv1.3;
逻辑分析:ssl_handshake_timeout控制OpenSSL SSL_accept()阻塞上限;设为12s可覆盖99.9%跨洲际混合CDN握手毛刺(含证书OCSP stapling耗时)。
graph TD
A[客户端发起ClientHello] --> B{CDN边缘接收}
B --> C[转发至源站]
C --> D[源站SSL_accept阻塞]
D -->|超时5s返回RST| E[连接静默中断]
D -->|延长至12s| F[完成ServerHello+证书链]
第三章:连接复用与资源管理关键参数剖析
3.1 MaxIdleConnsPerHost配置不当引发的TIME_WAIT风暴与压测验证
当 http.DefaultTransport 的 MaxIdleConnsPerHost 设置过低(如默认值2),高并发短连接场景下,连接复用率骤降,大量连接快速进入 TIME_WAIT 状态。
复现关键配置
tr := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 2, // ⚠️ 瓶颈根源:每主机仅2个空闲连接
IdleConnTimeout: 30 * time.Second,
}
逻辑分析:MaxIdleConnsPerHost=2 强制限制单域名复用上限,即便全局有100空闲连接,api.example.com 下最多仅缓存2个;其余请求被迫新建TCP连接,触发四次挥手后堆积 TIME_WAIT。
压测对比数据(QPS=500,持续60s)
| 配置项 | TIME_WAIT 数量 | 平均延迟 |
|---|---|---|
MaxIdleConnsPerHost=2 |
8,421 | 142ms |
MaxIdleConnsPerHost=100 |
137 | 28ms |
连接复用路径
graph TD
A[HTTP Client] -->|请求发往 api.example.com| B{Idle pool 中有可用连接?}
B -->|是| C[复用现有连接]
B -->|否| D[新建TCP连接 → TIME_WAIT 风暴]
3.2 IdleConnTimeout与MaxIdleConns协同策略:平衡吞吐与内存占用的黄金公式
HTTP连接池的性能拐点往往藏于两个参数的耦合关系中:IdleConnTimeout(空闲连接存活时长)与MaxIdleConns(最大空闲连接数)。
协同失效场景
当 IdleConnTimeout = 30s 但 MaxIdleConns = 100 时,高并发下易堆积大量待回收连接,触发GC压力;反之若 MaxIdleConns = 5 而 IdleConnTimeout = 2s,则连接复用率骤降,频繁建连拖累RT。
黄金配比公式
// 推荐初始化逻辑(基于QPS=200、P95 RT=80ms场景)
http.DefaultTransport.(*http.Transport).MaxIdleConns = 50
http.DefaultTransport.(*http.Transport).IdleConnTimeout = 60 * time.Second
http.DefaultTransport.(*http.Transport).MaxIdleConnsPerHost = 50
逻辑分析:
MaxIdleConns=50保障突发流量缓冲;IdleConnTimeout=60s略高于平均请求间隔(≈500ms × 50 ≈ 25s),避免过早驱逐;MaxIdleConnsPerHost同步对齐,防止单域名独占连接池。
参数影响对照表
| 参数组合 | 平均内存占用 | 连接复用率 | 建连频率(/min) |
|---|---|---|---|
| (10, 5s) | 低 | 32% | 1840 |
| (50, 60s) | 中 | 89% | 210 |
| (200, 120s) | 高 | 94% | 172 |
自适应调节建议
- 监控
http.Transport.IdleConns实时长度与http.Transport.CloseIdleConns()调用频次; - 当空闲连接平均存活时长持续 IdleConnTimeout × 0.6,应下调该值;
- 若
MaxIdleConns长期处于饱和态(len(idleConnList) == MaxIdleConns),需扩容或优化下游响应延迟。
3.3 Transport.CloseIdleConnections()在长周期服务中的主动回收实践
长周期服务(如网关、数据同步代理)若不主动管理 HTTP 连接,易因 Keep-Alive 积累大量空闲连接,触发文件描述符耗尽或 TIME_WAIT 暴涨。
连接泄漏的典型场景
- 默认
http.Transport的IdleConnTimeout = 0(永不超时) - 后端服务偶发延迟导致连接长期挂起
- DNS 轮询下复用连接未及时感知节点下线
主动回收配置示例
transport := &http.Transport{
IdleConnTimeout: 30 * time.Second, // 空闲连接最大存活时间
MaxIdleConns: 100, // 全局最大空闲连接数
MaxIdleConnsPerHost: 50, // 每 Host 最大空闲连接数
ForceAttemptHTTP2: true,
}
// 定期触发强制清理(非阻塞)
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
transport.CloseIdleConnections() // 清理已超时/失效的空闲连接
}
}()
CloseIdleConnections() 是并发安全的非阻塞调用,它遍历所有空闲连接池,关闭满足 time.Since(idleAt) > IdleConnTimeout 或底层连接已断开的连接。注意:它不关闭正在使用的连接,仅作用于 idle 状态连接。
推荐参数组合(生产环境)
| 参数 | 建议值 | 说明 |
|---|---|---|
IdleConnTimeout |
30s |
平衡复用率与资源释放速度 |
MaxIdleConnsPerHost |
50 |
防止单点后端连接过载 |
CloseIdleConnections() 调度间隔 |
15s |
略小于超时值,确保及时清理 |
graph TD
A[启动定时器] --> B[每15s调用 CloseIdleConnections]
B --> C{遍历空闲连接池}
C --> D[检查 idleAt + 30s < now?]
D -->|是| E[关闭该连接]
D -->|否| F[保留复用]
第四章:TLS与代理相关默认行为风险挖掘
4.1 DefaultTransport.TLSClientConfig.InsecureSkipVerify=true隐式继承漏洞与证书校验绕过链分析
当 http.DefaultTransport 被复用且未显式配置 TLS,其底层 TLSClientConfig 可能继承前序设置——若某处曾设 InsecureSkipVerify = true,后续所有共用该 Transport 的请求将静默跳过证书验证。
漏洞触发路径
- 初始化自定义 Transport 时误设
InsecureSkipVerify = true - 未重置或隔离 Transport 实例
- 其他模块复用同一 Transport(如 Prometheus client、K8s Go client)
典型错误代码
// ❌ 危险:全局 DefaultTransport 被污染
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
http.DefaultTransport = tr // ← 后续所有 http.Get() 均绕过证书校验
逻辑分析:
http.DefaultTransport是包级变量,Clone()后赋值覆盖全局实例;InsecureSkipVerify=true使 TLS handshake 忽略证书签名、域名匹配、有效期等全部校验项,形成供应链级信任坍塌。
| 风险等级 | 影响范围 | 修复建议 |
|---|---|---|
| ⚠️ 高危 | 所有复用该 Transport 的 HTTP 客户端 | 使用独立 Transport + 显式 tls.Config |
graph TD
A[初始化 Transport] --> B[设置 InsecureSkipVerify=true]
B --> C[赋值给 http.DefaultTransport]
C --> D[第三方库调用 http.Get]
D --> E[发起 HTTPS 请求]
E --> F[跳过证书链验证 → MITM 可行]
4.2 Proxy字段未显式设为http.ProxyFromEnvironment导致内网请求意外走外网代理
当 Go 程序使用 http.Client{} 默认配置发起请求时,若未显式设置 Transport.Proxy 字段,其值为 nil —— 此时 不会自动启用环境变量代理,而是完全绕过代理逻辑。
默认行为陷阱
http.DefaultClient和零值http.Client的Transport.Proxy均为nil- 只有显式赋值
http.ProxyFromEnvironment才会读取HTTP_PROXY/NO_PROXY
正确初始化方式
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment, // ✅ 显式启用环境代理
},
}
该赋值触发
http.ProxyFromEnvironment函数,内部解析HTTP_PROXY、HTTPS_PROXY和NO_PROXY(支持 CIDR 和域名通配),例如NO_PROXY="10.0.0.0/8,localhost"可阻止内网地址走代理。
NO_PROXY 匹配规则对比
| 环境变量值 | 是否匹配 10.1.2.3 |
是否匹配 api.internal |
|---|---|---|
10.0.0.0/8 |
✅ | ❌ |
internal |
❌ | ✅(子域名匹配) |
graph TD
A[发起 HTTP 请求] --> B{Transport.Proxy == nil?}
B -->|是| C[直连,无视环境变量]
B -->|否| D[调用 ProxyFunc]
D --> E[解析 HTTP_PROXY/NO_PROXY]
E --> F[匹配目标 Host/IP]
F -->|命中 NO_PROXY| G[直连]
F -->|未命中| H[经代理转发]
4.3 TLSNextProto空映射引发HTTP/2降级失败的调试日志追踪与修复
现象复现与关键日志线索
在 Go net/http 服务启用 TLS 时,若 http.Server.TLSNextProto 为空 map[string]func(...),客户端发起 ALPN 协商后,服务端无法识别 h2 协议,强制回退至 HTTP/1.1 —— 但未触发预期降级逻辑,连接直接关闭。
核心代码路径分析
// src/net/http/server.go 中 TLS handshake 后的协议选择逻辑
if server.TLSNextProto != nil {
if fn := server.TLSNextProto[proto]; fn != nil {
return fn(conn, hc)
}
}
// 若 map 为 nil 或 key 不存在,此处静默跳过,不 fallback 到 h2 handler
⚠️ TLSNextProto 为 nil 时安全跳过;但空 map(make(map[string]func(...)))会导致 server.TLSNextProto["h2"] 返回零值函数,进而 panic 或丢弃连接。
修复方案对比
| 方案 | 是否需显式注册 h2 | 兼容性 | 风险 |
|---|---|---|---|
删除 TLSNextProto 字段 |
否(由 http2.ConfigureServer 自动注入) |
✅ Go 1.8+ | 无 |
初始化为 nil 而非 make(map...) |
否 | ✅ | 低 |
推荐修复代码
// ❌ 错误:空映射导致 h2 查找失败
srv.TLSNextProto = make(map[string]func(*tls.Conn, http.Handler))
// ✅ 正确:显式设为 nil,交由 http2 包接管
srv.TLSNextProto = nil
http2.ConfigureServer(srv, &http2.Server{})
http2.ConfigureServer 内部会安全地注册 h2 处理器,并确保 TLSNextProto 不被意外覆盖。
4.4 Response.Body未defer关闭+io.Copy未限流引发的goroutine泄漏与fd耗尽复现
根本诱因:HTTP响应体未释放
Go中http.Response.Body是io.ReadCloser,必须显式关闭,否则底层TCP连接无法复用,且文件描述符(fd)持续占用。
resp, err := http.Get("https://api.example.com/stream")
if err != nil {
return err
}
// ❌ 缺失 defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body) // 阻塞读取直至EOF
resp.Body底层绑定net.Conn,不调用Close()会导致连接保留在TIME_WAIT状态,fd泄漏;io.Copy无缓冲限流时,高并发下易堆积goroutine等待读取。
并发放大效应
| 场景 | goroutine数 | fd占用/请求 | 风险等级 |
|---|---|---|---|
| 正常关闭+限流 | ~1 | 1–2 | 低 |
| 未关闭+无限流 | 数百+ | 持续增长 | 危急 |
修复模式
- ✅
defer resp.Body.Close()确保资源释放 - ✅
io.CopyN(dst, src, limit)或io.LimitReader控制最大读取量 - ✅ 使用
context.WithTimeout约束整个HTTP生命周期
graph TD
A[发起HTTP请求] --> B{Body是否defer关闭?}
B -->|否| C[fd泄漏+连接堆积]
B -->|是| D[io.Copy是否限流?]
D -->|否| E[goroutine阻塞于Read]
D -->|是| F[安全完成]
第五章:构建健壮HTTP客户端的最佳实践清单
使用连接池与超时精细化控制
HTTP客户端必须显式配置连接池大小、空闲连接存活时间及连接获取超时。例如在Go的http.Client中,应设置Transport.MaxIdleConns(默认0即无限制)、MaxIdleConnsPerHost(建议设为100)和IdleConnTimeout(推荐30秒)。同时,务必为每次请求设置context.WithTimeout(ctx, 8*time.Second),避免因服务端hang住导致goroutine泄漏。生产环境曾因未设读取超时,某依赖API偶发5分钟无响应,引发上游服务线程池耗尽。
实施指数退避重试策略
对幂等性请求(如GET、HEAD、PUT),应集成带抖动的指数退避重试。以下为Python httpx.AsyncClient配合tenacity的典型配置:
from tenacity import retry, stop_after_attempt, wait_exponential_jitter
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(max=2.0)
)
async def fetch_user(client, user_id):
resp = await client.get(f"https://api.example.com/users/{user_id}")
resp.raise_for_status()
return resp.json()
该策略将重试间隔从1s→2s→4s随机偏移,有效缓解雪崩效应。
统一错误分类与可观测性埋点
建立标准化错误码映射表,区分网络层(ECONNREFUSED、timeout)、协议层(4xx/5xx)与业务语义层(如{"code": "USER_NOT_FOUND"}):
| 错误类型 | 触发场景 | 推荐动作 |
|---|---|---|
| NetworkError | DNS失败、TCP连接拒绝 | 立即重试(最多1次) |
| TimeoutError | 请求超时或响应体流式读取超时 | 指数退避重试 |
| HttpStatusError | 429、503、504 | 解析Retry-After头并休眠 |
所有请求需注入唯一trace_id,并记录request_id、status_code、duration_ms、error_type至日志与指标系统(如Prometheus http_client_requests_total{method="GET",status_code="503",error_type="HttpStatusError"})。
启用HTTP/2与TLS 1.3强制协商
现代客户端应禁用HTTP/1.1降级,强制协商HTTP/2。Java中通过HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)实现;Go需确保http.Transport.TLSClientConfig.MinVersion = tls.VersionTLS13,并验证服务端支持ALPN。某金融API接入后,QPS提升37%,因HTTP/2多路复用消除了队头阻塞。
构建可插拔的中间件链
采用责任链模式封装通用逻辑:认证注入 → 请求签名 → 响应解密 → 缓存决策。以Rust的reqwest为例,使用tower::Service组合多个Layer,使鉴权逻辑与业务代码完全解耦,灰度切换OAuth2/Bearer Token仅需替换中间件实例。
flowchart LR
A[发起请求] --> B[Auth Layer]
B --> C[Signature Layer]
C --> D[Retry Layer]
D --> E[Metrics Layer]
E --> F[实际HTTP传输] 