第一章:Go语言转大写不等于.ToUpper():核心认知误区与CNCF实践启示
在Go生态中,strings.ToUpper() 常被开发者默认为“安全、通用、符合预期”的大小写转换方案,但这一认知在国际化场景下极易引发严重缺陷——它仅基于Unicode 13.0之前的简单映射规则,且完全忽略语言环境(locale)与上下文敏感性。例如土耳其语中,小写字母 'i' 的大写形式是 'İ'(带点大写I),而非 'I';而 strings.ToUpper("i") 在所有环境中均返回 "I",导致CNCF项目如Prometheus和Envoy在土耳其区域部署时出现标签匹配失败、配置解析异常等静默错误。
Unicode规范与Go标准库的边界
Go标准库明确声明:strings.ToUpper 和 strings.ToLower “perform case mapping according to Unicode version X.Y.Z”,其行为由编译时绑定的Unicode数据表决定,不支持运行时切换区域设置(如en-US或tr-TR)。这意味着:
- 无法通过环境变量或
runtime.GC()等机制动态调整; - 所有字符串操作均以“无locale”模式执行;
unicode包中的CaseRange映射表是静态只读的。
替代方案:使用golang.org/x/text/cases
当需符合ISO/IEC 15897或CLDR标准时,应引入官方扩展库:
go get golang.org/x/text/cases
go get golang.org/x/text/language
package main
import (
"fmt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func main() {
// ✅ 正确:按土耳其语规则转换
tr := cases.Title(language.MustParse("tr"))
fmt.Println(tr.String("istanbul")) // 输出 "İstanbul"
// ❌ 错误:标准库忽略语言上下文
fmt.Println(strings.ToUpper("istanbul")) // 输出 "ISTANBUL"(丢失点符号)
}
CNCF项目的真实取舍表
| 项目 | 使用方案 | 理由 |
|---|---|---|
| Kubernetes | strings.ToUpper |
标签键强制ASCII,规避locale依赖 |
| Istio | cases.Title(language.Und) |
支持多语言服务名显示 |
| Thanos | 自定义映射表 | 避免x/text依赖,减小二进制体积 |
真正的工程决策从来不是“该用哪个函数”,而是“在可维护性、合规性与交付约束间如何权衡”。
第二章:标准库底层原理剖析与安全边界识别
2.1 Unicode规范下大小写映射的复杂性与Go实现细节
Unicode 大小写映射远非简单的 ASCII a↔A 一对一关系:存在条件映射(如土耳其语 i→İ)、多对一映射(ß→SS)、上下文敏感映射(希腊词尾 σ vs 中置 σ),以及语言特定规则(如德语、立陶宛语例外)。
Go 的 strings.ToUpper() 和 unicode.ToUpper() 底层调用 unicode.SpecialCase 表,该表由 Unicode 标准数据文件 CaseFolding.txt 自动生成:
// 示例:获取 'ß' 的大写形式(需指定语言环境)
import "golang.org/x/text/cases"
import "golang.org/x/text/language"
caser := cases.Upper(language.German)
result := caser.String("straße") // → "STRASSE"
逻辑分析:
cases.Upper使用预编译的折叠规则表,支持语言感知;参数language.German触发ß→SS的特殊转换,而默认strings.ToUpper("ß")仅返回"ß"(无变化),因标准 Unicode 大写映射未定义此转换。
关键差异对比
| 场景 | strings.ToUpper |
cases.Upper(lang) |
|---|---|---|
| ASCII 字母 | ✅ 正确 | ✅ 正确 |
德语 ß |
❌ 保持原样 | ✅ 转为 "SS" |
土耳其 i/I |
❌ 错误映射 | ✅ 按 tr 语言正确 |
graph TD A[输入字符] –> B{是否在 Unicode CaseFolding.txt 中?} B –>|是| C[查 SpecialCase 表] B –>|否| D[使用简单映射表] C –> E[应用语言/上下文规则] D –> F[基础 Unicode 大写映射]
2.2 strings.ToUpper()在多语言场景中的隐式截断与丢失风险实测
Go 标准库 strings.ToUpper() 基于 Unicode 大小写映射,但不支持上下文敏感转换(如土耳其语 i → İ),且对组合字符(如带重音的 é)仅转换基础码点,忽略修饰符。
隐式截断案例
s := "café" // U+00E9 (é) = LATIN SMALL LETTER E WITH ACUTE
fmt.Println(strings.ToUpper(s)) // 输出 "CAFÉ" — 表面正常
⚠️ 逻辑分析:é 是单个 Unicode 码点(U+00E9),ToUpper() 正确映射为 É(U+00C9)。但若输入为分解形式 "cafe\u0301"(e + U+0301 COMBINING ACUTE ACCENT),则 ToUpper() 仅大写 e → E,保留独立重音符号,导致 E\u0301(视觉正确但语义异常)。
多语言失效对照表
| 语言 | 输入 | strings.ToUpper() 输出 | 问题类型 |
|---|---|---|---|
| 土耳其语 | "i" |
"I" |
应为 "İ"(带点大写 I) |
| 德语 | "ß" |
"ß" |
应为 "SS"(Unicode 13.0+ 支持,但 Go 1.22 仍返回原值) |
安全替代方案
- 使用
golang.org/x/text/cases包(支持语言感知、组合字符归一化); - 预处理字符串:
unicode.NFC.String(s)归一化后再转换。
2.3 rune vs byte视角下的大小写转换内存布局与性能开销分析
Go 中 byte(即 uint8)仅覆盖 ASCII 范围,而 rune(int32)完整表示 Unicode 码点。大小写转换时,底层行为截然不同:
内存布局差异
[]byte:连续单字节存储,'A' → 0x41,零拷贝可读写[]rune:每个元素占 4 字节,需 UTF-8 解码/编码转换,隐含内存膨胀
性能关键路径
s := "Hello, 世界"
b := []byte(s) // 13 bytes: ASCII + UTF-8 multi-byte seq
r := []rune(s) // 9 runes × 4 = 36 bytes; alloc + decode overhead
逻辑分析:
[]byte(s)直接复制原始字节(O(n) 时间,无解码);[]rune(s)触发utf8.DecodeRuneInString循环,对"世界"(各占 3 字节)需 3 次变长解析,额外分配 36 字节堆内存。
| 维度 | []byte 转换(ASCII-only) |
[]rune 转换(Unicode) |
|---|---|---|
| 内存占用 | 1× 原始字节数 | ≈4× rune 数 + 解码缓冲 |
| CPU 开销 | O(n),查表位运算 | O(n),含多字节状态机解析 |
graph TD
A[输入字符串] --> B{是否纯 ASCII?}
B -->|是| C[byte slice + toUpper/toLower 查表]
B -->|否| D[UTF-8 decode → rune slice → unicode.ToUpper → encode]
C --> E[零分配,纳秒级]
D --> F[多次堆分配,微秒级]
2.4 Go 1.22+ text/cases 包的标准化演进路径与兼容性验证
Go 1.22 将原 golang.org/x/text/cases 移入标准库 text/cases,实现 Unicode 大小写转换逻辑的统一收敛。
核心能力升级
- 支持 CLDR v43+ 的区域敏感大小写规则(如土耳其语
i→İ) - 新增
cases.Context类型,显式控制语言环境与折叠策略 - 废弃
cases.Fold中隐式 locale 推断,强制传入language.Tag
兼容性关键变更
| 行为 | Go 1.21(x/text) | Go 1.22+(std) |
|---|---|---|
| 默认 locale | und(未指定) |
language.Und(显式) |
cases.Title 空格处理 |
仅断字符 | 扩展至 Unicode Segmentation |
import "text/cases"
import "golang.org/x/text/language"
// Go 1.22+ 标准用法:显式指定上下文
c := cases.Title(language.Turkish) // 避免隐式 und 推断
result := c.String("istanbul") // → "İstanbul"
此代码强制绑定土耳其语规则,
İ(带点大写 I)符合 CLDR 规范;若省略language.Turkish,将回退至und,导致错误结果"Istanbul"。参数language.Turkish触发 ICU 的特殊映射表加载,确保大小写对称性。
演进验证路径
graph TD
A[Go 1.21 x/text/cases] -->|迁移适配| B[Go 1.22 text/cases]
B --> C[CI 中并行运行旧/新包对比测试]
C --> D[Unicode 15.1 边界用例覆盖率 ≥99.7%]
2.5 CNCF项目中因.ToUpper()误用导致的国际化故障复盘(含Kubernetes/Envoy日志片段)
故障现象
Kubernetes Ingress Controller 在土耳其语环境(LC_ALL=tr_TR.UTF-8)下无法匹配 Host: api.example.com 路由规则,Envoy 日志持续输出 no matching route found。
根本原因
Go 语言中 strings.ToUpper() 在非C locale下对拉丁字母 i 的转换不符合预期:
// 错误用法:依赖默认locale的大小写转换
host := strings.ToUpper(req.Host) // 在tr_TR下 → "API.EXAMPLE.COM" → "API.EXAMPLE.COM"(看似正常)
// 但实际:'i' → 'İ'(带点大写I),后续字符串比较失效
该逻辑被用于 Envoy xDS 动态路由匹配键生成,导致哈希不一致。
关键修复对比
| 场景 | strings.ToUpper("api") (tr_TR) |
strings.ToUpper("api") (C) |
|---|---|---|
| 实际结果 | "API"(含 İ) |
"API"(标准ASCII) |
| 匹配行为 | ❌ 路由键不匹配 | ✅ 正常转发 |
修复方案
使用 strings.ToTitle 或显式指定 Unicode 大小写映射(如 golang.org/x/text/cases)。
第三章:生产级替代方案一——text/cases 的工程化落地
3.1 cases.Title与cases.Upper在不同语言区域设置(Locale)下的行为差异实验
实验环境准备
使用 Go 标准库 strings 和 golang.org/x/text/cases,对比 cases.Title 与 cases.Upper 在 en-US、tr-TR、el-GR 下的大小写转换表现。
关键代码示例
import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
tr := cases.Title(language.MustParse("tr-TR"))
up := cases.Upper(language.MustParse("tr-TR"))
fmt.Println(tr.String("istanbul")) // → "İstanbul"(带点大写 İ)
fmt.Println(up.String("istanbul")) // → "İSTANBUL"
逻辑分析:cases.Title 遵循 Unicode TR-29 标题化规则,对土耳其语中 'i'→'İ'(带点大写 I)特殊处理;cases.Upper 仅执行纯大写映射,但同样尊重 locale 的大小写折叠规则(如 ß→SS 在德语中不适用,但在土耳其语中无影响)。
行为对比表
| Locale | 输入 | cases.Title |
cases.Upper |
|---|---|---|---|
tr-TR |
"python" |
"Python" |
"PYTHON" |
tr-TR |
"istanbul" |
"İstanbul" |
"İSTANBUL" |
Unicode 特性依赖
cases.Title内部调用unicode.TitleCase并结合 locale 的casing数据;cases.Upper直接映射unicode.ToUpperSpecial,依赖 CLDR casing 规则。
3.2 零分配字符串转换与sync.Pool协同优化的实战封装
核心问题:频繁 string(bytes) 触发堆分配
Go 中 string(b []byte) 默认复制底层数组,每次调用产生一次堆分配,高并发场景下加剧 GC 压力。
优化路径:unsafe.String + sync.Pool 复用
利用 unsafe.String 绕过复制(需确保字节切片生命周期可控),配合 sync.Pool 管理底层 []byte 缓冲区。
var bufPool = sync.Pool{
New: func() interface{} { return make([]byte, 0, 128) },
}
func BytesToStringUnsafe(b []byte) string {
buf := bufPool.Get().([]byte)
buf = append(buf[:0], b...) // 安全复用,避免逃逸
s := unsafe.String(&buf[0], len(buf))
bufPool.Put(buf) // 归还缓冲区
return s
}
逻辑分析:
append(buf[:0], b...)复用底层数组内存;unsafe.String构造零拷贝字符串;bufPool.Put确保缓冲区可重用。参数b必须在调用期间保持有效,否则导致悬垂指针。
性能对比(10K 次转换)
| 方式 | 分配次数 | 耗时(ns/op) |
|---|---|---|
string(b) |
10,000 | 1240 |
BytesToStringUnsafe |
0 | 86 |
graph TD
A[输入 []byte] --> B{长度 ≤ 128?}
B -->|是| C[从 Pool 获取预分配 buffer]
B -->|否| D[临时分配并释放]
C --> E[unsafe.String 零拷贝]
E --> F[归还 buffer 到 Pool]
3.3 在gRPC网关与OpenAPI生成器中集成cases的CI/CD校验策略
为保障 gRPC 接口变更与 OpenAPI 文档的一致性,需在 CI 流水线中嵌入自动化校验环节。
校验核心流程
# 在 CI job 中执行:验证 proto → gateway → OpenAPI 三者语义一致性
protoc \
--openapiv2_out=. \
--openapiv2_opt=logtostderr=true,allow_merge=true,generate_unbound_methods=false \
-I . api/v1/service.proto
该命令将 service.proto 编译为 api/v1/service.swagger.json;关键参数 allow_merge=true 支持多文件合并,generate_unbound_methods=false 防止生成未绑定 HTTP 路由的方法,避免 OpenAPI 中出现“幽灵端点”。
校验项清单
- ✅ OpenAPI
paths是否覆盖所有google.api.http注解路由 - ✅ 所有
message字段是否在 Swagger schema 中存在对应定义 - ❌ 检测到
rpc GetOrder(...)无http.get注解 → 构建失败
差异检测流程
graph TD
A[Pull Request] --> B[生成 OpenAPI v3 JSON]
B --> C[调用 openapi-diff CLI]
C --> D{schema/paths diff > 0?}
D -->|Yes| E[阻断合并,输出差异报告]
D -->|No| F[允许进入下一阶段]
| 工具 | 用途 | 必选 |
|---|---|---|
protoc-gen-openapiv2 |
从 proto 生成 OpenAPI | ✓ |
openapi-diff |
对比前后版本 schema 差异 | ✓ |
spectral |
检查 OpenAPI 规范合规性 | △ |
第四章:生产级替代方案二与三——定制化与零依赖方案
4.1 基于Unicode Case Mapping Table的轻量级映射表构建与AOT预编译
为降低运行时开销,我们从 Unicode 15.1 的 CaseFolding.txt 提取双向映射关系,剔除语言敏感(C/S 类型)条目,仅保留 F(full)和 T(turkic)中确定性、无上下文依赖的映射。
映射表精简策略
- 过滤掉含代理对(surrogate pairs)及非 BMP 字符
- 合并连续码点区间(如
0041..005A → 0061..007A)以提升空间局部性 - 生成紧凑的
uint16_t索引+偏移结构,支持 O(1) 查找
AOT预编译流程
// build_map.rs:离线生成静态查找表
let mut table = [0u16; 65536];
for (upper, lower) in parse_case_folding("CaseFolding.txt") {
if upper <= 0xFFFF && lower <= 0xFFFF {
table[upper as usize] = lower as u16; // 单向小写映射
}
}
std::fs::write("case_map.bin", table.to_le_bytes()).unwrap();
逻辑分析:仅处理 BMP 范围(
0x0000–0xFFFF),避免动态内存分配;to_le_bytes()保证跨平台二进制兼容;预编译后嵌入固件或静态库,零运行时解析成本。
| 映射类型 | 条目数 | 占比 | 特点 |
|---|---|---|---|
Full (F) |
1,284 | 92.3% | 稳定、无语言依赖 |
Turkic (T) |
4 | 0.3% | 仅影响 I/İ 等少数字符 |
Special (S) |
102 | 7.4% | 被剔除(需上下文) |
graph TD
A[Unicode CaseFolding.txt] --> B[过滤 C/S 类型]
B --> C[区间合并 & BMP 截断]
C --> D[生成 uint16_t[65536]]
D --> E[AOT 编译为 .rodata 段]
4.2 针对ASCII子集的无GC、无反射、常数时间转换汇编优化(amd64/arm64双平台)
ASCII子集(0x00–0x7F)的字节→rune转换可完全规避UTF-8解码开销,实现真正O(1)、零堆分配、零反射调用。
核心约束与收益
- 输入严格限定为
[]byte中每个字节 ≤ 0x7F - 输出
[]rune直接复用输入底层数组(仅类型重解释,无拷贝) - amd64 使用
movq+punpcklbw批量扩展;arm64 使用uxtb+ins向量广播
关键内联汇编片段(amd64)
// 将 8 字节 ASCII 批量转为 8 个 uint32(rune)
MOVQ src+0(FP), AX // 加载源地址
MOVD (AX), BX // 读取低8字节
PUNPCKLBW X0, BX // 零扩展:0x41 → 0x0041
MOVOU BX, ret+8(FP) // 存入返回 slice.data
逻辑:
PUNPCKLBW将每个字节高位补零成 word,再经两次扩展得 32 位 rune;全程寄存器操作,无内存分配,无分支预测失败。
性能对比(1KB 输入)
| 实现方式 | 耗时(ns) | GC 次数 | 内存分配 |
|---|---|---|---|
标准 bytes.Runes |
1240 | 1 | 4KB |
| 本优化方案 | 89 | 0 | 0B |
graph TD
A[输入 []byte] --> B{全字节 ≤ 0x7F?}
B -->|是| C[reinterpret as []rune]
B -->|否| D[回退标准 UTF-8 解码]
C --> E[零开销返回]
4.3 兼容旧Go版本(
Go 1.18 引入 //go:build 指令替代旧式 +build 注释,但大量生产项目仍需支持 Go ≤1.17。为此需双轨并行约束管理。
双约束共存策略
- 同时保留
//go:build和// +build行(顺序不可颠倒) - 构建工具自动识别并优先使用
go:build(≥1.18),降级回退至+build
//go:build go1.18
// +build go1.18
package compat
// 使用泛型特性(仅1.18+)
func Map[T, U any](s []T, f func(T) U) []U { /* ... */ }
逻辑分析:
//go:build go1.18是语义化约束,// +build go1.18是向后兼容占位;Go 工具链在 go:build 行,仅解析+build。
约束组合对照表
| 场景 | go:build 表达式 | +build 表达式 |
|---|---|---|
| ≥1.18 且 Linux | go1.18 && linux |
go1.18 linux |
!go1.18 || windows |
!go1.18 windows |
graph TD
A[源码含双约束] --> B{Go版本 ≥1.18?}
B -->|是| C[解析 //go:build]
B -->|否| D[解析 // +build]
C --> E[启用泛型/切片操作等新特性]
D --> F[调用反射或接口fallback实现]
4.4 在Prometheus Exporter与Terraform Provider中三方案的Benchmark横向对比(allocs/op, ns/op, GC cycles)
测试环境统一配置
- Go 1.22.5,
GOMAXPROCS=8,禁用 CPU 频率缩放 - 基准测试运行
go test -bench=.+-benchmem -count=5
性能指标核心差异
| 方案 | allocs/op | ns/op | GC cycles/op |
|---|---|---|---|
| 直接 HTTP 拉取(Exporter) | 12.4 ±0.3 | 892 ±21 | 0.08 |
| Terraform Provider(SDK v2) | 217 ±14 | 14,356 ±421 | 1.92 |
| Terraform Provider(Plugin Framework) | 89 ±5 | 5,103 ±137 | 0.71 |
内存分配关键路径分析
// Exporter 中指标采集(零拷贝优化)
func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
// 直接复用预分配的 metricDesc 和 float64Value
ch <- prometheus.MustNewConstMetric(
e.upDesc, prometheus.GaugeValue, float64(e.state), // 无字符串拼接
)
}
该实现避免 runtime.convT2E 接口转换开销,allocs/op 降低至 12.4;而 SDK v2 因深度嵌套 schema 转换及 terraform.ResourceData.Get() 的反射调用,触发大量临时对象分配。
GC 压力来源对比
graph TD
A[Provider Read] --> B[Schema.DecodeJSON]
B --> C[reflect.Value.SetMapIndex]
C --> D[alloc map[string]interface{}]
D --> E[GC cycle ↑↑]
- Plugin Framework 通过静态类型绑定和
tfsdk类型系统,跳过 73% 的反射路径; - Exporter 完全规避状态同步逻辑,天然零 GC 周期波动。
第五章:面向云原生未来的大小写处理范式升级建议
统一采用 kebab-case 作为跨服务 API 路径与配置键命名规范
在 Kubernetes Operator 开发实践中,某金融客户将 PaymentServiceConfig 的环境变量名从 PAYMENT_SERVICE_TIMEOUT_MS 迁移至 payment-service-timeout-ms,配合 Helm Chart 中的 {{ .Values.payment-service-timeout-ms }} 引用,彻底规避了 Go 客户端(如 client-go)因 os.Getenv("PAYMENT_SERVICE_TIMEOUT_MS") 与 YAML 中 paymentServiceTimeoutMs 字段映射不一致导致的空值注入故障。该变更使 Istio VirtualService 的 host 和 subset 标签、Prometheus 指标名(如 payment_service_request_duration_seconds)实现全链路命名对齐。
在 CRD Schema 中强制启用 OpenAPI v3 大小写校验
以下为生产级 CRD 片段,通过 x-kubernetes-validations 实现字段名大小写策略控制:
spec:
versions:
- name: v1
schema:
openAPIV3Schema:
properties:
spec:
properties:
replicaCount:
type: integer
x-kubernetes-validations:
- rule: 'self == oldSelf' # 阻止 runtime 修改时大小写混用
storageClassName:
type: string
# 显式禁止 camelCase 变体:storageclassname / StorageClassName
构建 CI/CD 级别的大小写合规流水线
某电商中台团队在 Argo CD 同步前插入校验步骤,使用自定义 admission webhook 拦截非法命名:
| 检查项 | 正则模式 | 违规示例 | 自动修复 |
|---|---|---|---|
| ConfigMap key | ^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$ |
DB_URL, dbUrl |
db-url |
| Service port name | ^[a-z][a-z0-9\-]*$ |
HTTPPort, http_port |
http-port |
基于 eBPF 的运行时大小写异常检测
在容器网络层部署 Cilium eBPF 程序,实时捕获 Envoy 代理转发中 Header 名大小写违规行为(如 X-Request-ID 与 x-request-id 并存),生成告警事件并注入 OpenTelemetry trace tag:
flowchart LR
A[Envoy Inbound] --> B{eBPF Hook}
B -->|Header name mismatch| C[OTel Collector]
C --> D[(Jaeger UI)]
B -->|Valid kebab-case| E[Application Pod]
多语言 SDK 的大小写转换中间件标准化
Spring Cloud Gateway 与 Rust-based Linkerd2-proxy 均集成统一的 case-normalizer filter,其核心逻辑基于 Unicode 15.1 标准化算法:
// linkerd2-proxy/src/case_normalizer.rs
pub fn normalize_header_name(name: &str) -> String {
name.to_lowercase()
.replace('_', "-")
.replace(" ", "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()
.trim_matches('-')
.to_string()
}
云原生配置中心的大小写感知同步机制
当 HashiCorp Consul KV 存储中存在 database/timeout-ms 与 database/TimeoutMs 两个 key 时,采用优先级仲裁策略:以 consul kv get -recurse database/ 返回结果中路径深度最浅且符合 kebab-case 的 key 为准,自动 tombstone 冲突项并记录 audit log。
基于 OPA 的动态大小写策略引擎
在 Kubernetes Admission Controller 中嵌入 Rego 策略,根据资源类型动态启用不同规则:
package kubernetes.admission
deny[msg] {
input.request.kind.kind == "ConfigMap"
input.request.object.data[_]
not re_match("^[a-z0-9]([a-z0-9\\-]*[a-z0-9])?$", key)
msg := sprintf("ConfigMap data key %q violates kebab-case policy", [key])
}
服务网格侧车容器的大小写兼容性降级方案
当上游服务仍使用 PascalCase 响应头(如 X-RateLimit-Limit),Istio EnvoyFilter 插入 Lua filter 进行无损转换:
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: header-case-normalizer
spec:
configPatches:
- applyTo: HTTP_FILTER
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inlineCode: |
function envoy_on_response(response_handle)
local headers = response_handle:headers()
for key, _ in pairs(headers) do
if key:match("^X%-[A-Z]") then
local normalized = key:gsub("%-", ""):gsub("([A-Z])", "-%1"):lower():gsub("^%-", "")
response_handle:headers():replace(key, normalized)
end
end
end 