Posted in

Go多语言支持配置全链路解析(从go.mod到runtime.GC的本地化真相)

第一章:Go多语言支持的全局认知与设计哲学

Go 语言自诞生起便将“简单性”与“可工程化”置于核心地位,其多语言支持并非以运行时动态加载或内置丰富国际化库为优先路径,而是通过工具链驱动、编译期确定、标准库轻量协同的设计哲学实现稳健的全球化能力。这种设计拒绝在语言运行时引入复杂的本地化状态管理,转而将翻译资源、区域格式、字符边界等交由外部工具(如 go:generate + golang.org/x/text)和约定式结构(如 .po 文件映射、embed.FS 静态嵌入)统一处理。

Go对Unicode的原生尊重

Go 源文件默认 UTF-8 编码,string 类型天然承载 Unicode 文本,rune 类型精确表示 Unicode 码点而非字节。这使得多语言文本处理无需额外解码层:

s := "你好, こんにちは, 안녕하세요"
for _, r := range s { // 遍历的是rune,非byte
    fmt.Printf("%U ", r) // 输出 U+4F60 U+597D U+002C ...
}

该循环按 Unicode 字符而非字节安全迭代,规避了 C/Java 中常见的乱码切片风险。

工具链驱动的本地化工作流

Go 不在 fmtlog 中内置 locale 切换机制,而是推荐使用 golang.org/x/text/message 包配合 gotext 命令行工具生成编译时绑定的翻译包:

  1. 在代码中标记待翻译字符串:msg := message.NewPrinter(language.English).Sprintf("Hello %s", name)
  2. 运行 gotext extract -out active.en.toml -lang en,ja,zh 提取键值
  3. 人工或机器翻译后生成 active.ja.tomlactive.zh.toml
  4. 执行 gotext generate 生成 locales_gen.go,供运行时按 language.Tag 动态选择

核心权衡原则

维度 Go 的选择 对比典型方案(如 Java ResourceBundle)
运行时开销 零反射、无动态类加载 依赖 ClassLoader 和 properties 文件 I/O
构建确定性 翻译内容编译进二进制 运行时读取外部资源,易遗漏或路径错误
可维护性 TOML/JSON 结构化+Git 友好 Properties 文件缺乏嵌套与注释支持

这种“编译期固化、运行时极简”的路径,使 Go 应用在全球部署时保持二进制一致性与启动瞬时性,也要求开发者将本地化视为构建流水线的一环,而非运行时配置问题。

第二章:go.mod与构建链路中的本地化配置机制

2.1 go.mod中replace、exclude与retract对区域化依赖的影响

Go 模块的 replaceexcluderetract 指令在多地域协作场景下直接影响依赖解析的确定性与合规性。

替换本地开发分支(replace)

replace github.com/example/lib => ./lib/internal

该指令强制将远程模块映射为本地路径,绕过 GOPROXY 缓存。适用于中国研发团队快速验证私有 fork 分支,但会破坏 go.sum 的跨环境一致性。

排除高危版本(exclude)

exclude github.com/bad/pkg v1.2.3

在区域合规审查中,可主动屏蔽含已知漏洞或受出口管制的版本,确保构建链不引入禁用依赖。

撤回已发布版本(retract)

retract [v1.0.0, v1.5.0)

当某版本因区域法律要求(如 GDPR 数据处理缺陷)被下架时,retract 向所有下游模块声明其不可用,触发 go list -m -u 自动降级。

指令 生效时机 区域化典型用途
replace go build 内网离线开发、国产化适配
exclude go mod tidy 安全合规拦截
retract go get 法律强制版本废止

2.2 GOOS/GOARCH与CGO_ENABLED协同下的跨平台本地化编译实践

Go 的跨平台编译能力高度依赖 GOOSGOARCHCGO_ENABLED 的协同策略。三者组合决定二进制是否包含 C 依赖、目标系统 ABI 及运行时行为。

CGO_ENABLED 是关键开关

当启用 CGO(CGO_ENABLED=1)时,Go 工具链会调用系统 C 编译器链接本地库(如 libc),此时 GOOS/GOARCH 仅指定目标平台,但实际构建仍需对应平台的交叉工具链或容器环境;禁用时(CGO_ENABLED=0)则纯 Go 运行时生效,可安全交叉编译。

典型组合对照表

GOOS GOARCH CGO_ENABLED 适用场景
linux amd64 0 静态二进制,Docker 镜像部署
windows arm64 0 无 C 依赖的 CLI 工具
darwin arm64 1 调用 CoreFoundation 等系统 API

构建 macOS ARM64 原生二进制示例

# 启用 CGO 并指定 Apple Silicon 目标
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o myapp main.go

此命令要求宿主机为 macOS(因 CGO_ENABLED=1 依赖 Xcode Command Line Tools),GOOS/GOARCH 确保生成 Mach-O arm64 格式,Cgo 自动桥接 Darwin 系统调用。

协同失效路径(mermaid)

graph TD
    A[设定 GOOS=linux GOARCH=arm64] --> B{CGO_ENABLED=1?}
    B -->|是| C[失败:需 linux/arm64 libc 头文件与 cc]
    B -->|否| D[成功:纯 Go 静态二进制]

2.3 vendor机制与go.work在多语言项目中的隔离性验证

Go 的 vendor 目录与 go.work 文件在多模块协作中承担不同职责:前者锁定依赖版本(单模块内),后者跨仓库协调多个 go.mod 项目。

vendor 的作用边界

  • 仅影响 go build/go test 时的依赖解析路径
  • 对非 Go 子项目(如 Python/TypeScript)完全无感知
  • 不修改 $GOPATH 或环境变量,纯本地文件系统隔离

go.work 的跨语言局限性

# go.work 示例(仅声明 Go 工作区)
go 1.22

use (
    ./backend/go-service
    ./shared/go-utils
)

此配置仅引导 Go 工具链识别多模块结构;Python 的 poetry.lock、TS 的 pnpm-lock.yaml 仍各自独立解析依赖,无任何联动机制。

隔离性验证对比表

维度 vendor go.work
作用范围 单模块内 多 Go 模块间
影响其他语言
构建工具感知 go 命令直接读取 go 命令识别
graph TD
    A[多语言项目根目录] --> B[./backend/go-service]
    A --> C[./frontend/ts-app]
    A --> D[./shared/go-utils]
    B -->|go.mod + vendor| E[Go 依赖隔离]
    D -->|go.mod| E
    C -->|pnpm-lock.yaml| F[JS 依赖隔离]
    E -.->|无交互| F

2.4 Go模块校验和(sum.db)如何隐式承载区域编码元信息

Go 的 sum.db 并非单纯哈希数据库,其二进制格式中嵌入了模块路径的 UTF-8 编码字节序列——而该序列在不同区域设置(locale)下,对含非 ASCII 字符(如中文、日文包名)的模块路径,会因 Go 工具链调用 path.Cleanstrings.ToLower 时依赖底层 runtime.LowerASCII 实现,间接反映系统区域编码偏好。

校验和生成中的隐式编码路径

// go/src/cmd/go/internal/sumdb/format.go 中关键逻辑片段
func (e *Entry) WriteTo(w io.Writer) error {
    _, _ = w.Write([]byte(e.Path)) // 原始模块路径字节(未标准化为 NFC)
    _, _ = w.Write([]byte{0})
    _, _ = w.Write(e.Sum[:]) // sha256(sum(db entry))
    return nil
}

e.Path 直接写入原始 modulePath 字符串的 UTF-8 字节流,不执行 Unicode 归一化(NFC/NFD)。若开发者在 GBK 环境下 go mod init 你好.com/foo,路径字节序列为 E4xBD-A0E5-A5-BD2E636F6D2F666F6F;而在 UTF-8 终端下同名模块路径字节相同,但若经 locale-aware 文件系统重命名(如 macOS HFS+ 转换),可能触发隐式 NFD → NFC 转换,导致 sum.db 条目不一致。

区域敏感性验证示例

环境变量 go list -m -json 输出路径 sum.db 条目是否可复现
LANG=zh_CN.GBK "module": "你好.com/foo" ❌(路径字节非法 UTF-8)
LANG=en_US.UTF-8 "module": "你好.com/foo" ✅(标准 UTF-8 字节)
graph TD
    A[go get 你好.com/lib] --> B{Go 工具链解析 module path}
    B --> C[保留原始 UTF-8 字节]
    C --> D[写入 sum.db Entry.Path]
    D --> E[校验和绑定字节级路径]
    E --> F[跨区域环境校验失败 → 隐式暴露编码上下文]

2.5 go build -tags与构建约束在语言变体开关中的工程化应用

Go 的构建约束(Build Constraints)与 -tags 标志共同构成轻量级、编译期确定的多语言变体控制机制,无需运行时分支或配置加载。

构建标签驱动的本地化实现

main.go 中使用 //go:build en || zh 声明约束,配合文件后缀(如 app_en.go / app_zh.go)实现零 runtime 开销的语言切片。

// app_zh.go
//go:build zh
package main

func GetWelcome() string { return "欢迎使用" }

此文件仅在 go build -tags=zh 时参与编译;-tags 支持逗号分隔(-tags=prod,debug)和逻辑表达式(-tags="linux && amd64"),优先级高于 +build 注释。

多语言构建矩阵

环境变量 构建命令 输出二进制行为
LANG=zh go build -tags=zh 加载中文文案
LANG=en go build -tags=en 加载英文文案
CI=true go build -tags="en prod" 同时启用英文+生产优化

构建流程示意

graph TD
    A[源码含多个 _en.go/_zh.go] --> B{go build -tags=zh?}
    B -->|是| C[仅编译 zh 文件]
    B -->|否| D[默认 fallback 到 en]

第三章:运行时层的语言感知体系解析

3.1 runtime.GC触发时机与本地化内存分配器的耦合关系

Go 运行时中,GC 触发并非仅由堆大小阈值(GOGC)单向驱动,而是深度耦合于 P(Processor)本地分配器(mcache) 的状态。

GC 触发的双重信号源

  • 全局堆增长速率(heap_live > heap_trigger
  • 本地 mcache 中小对象缓存耗尽频次(mcache.next_sample 被频繁重置)

mcache 对 GC 延迟的影响

当大量 P 高频分配小对象(如 runtime.mallocgc 调用)但未及时归还至 mcentral 时:

  • mcache.alloc[8] 等 span 缓存持续占用
  • 实际 heap_live 统计滞后于真实内存压力
// src/runtime/malloc.go: mallocgc 中的关键路径
if shouldTriggerGC() { // 检查全局 heap_live 与 trigger
    gcStart(gcBackgroundMode, false)
}
// 但 mcache.free() 不立即更新 heap_live —— 需经 mcentral→mheap 归还链路

逻辑分析shouldTriggerGC() 仅读取原子变量 memstats.heap_live,而该值仅在 mheap.freeSpan()scavenge 时更新。mcache 中的未归还内存形成“观测盲区”,导致 GC 触发延迟或过早。

触发条件 是否实时反映 mcache 状态 延迟典型值
heap_live > heap_trigger 否(需 span 归还) 1–3 GC 周期
forcegc 信号 是(绕过统计) 即时
graph TD
    A[mcache.alloc] -->|分配小对象| B[span refcount++]
    B --> C{mcache.freeSpan?}
    C -->|否| D[内存暂驻 mcache]
    C -->|是| E[mcentral.put]
    E --> F[mheap.freeSpan → heap_live -= size]

3.2 GOMAXPROCS与P本地队列在非英语环境下的调度偏差实测

在中文、日文等宽字符区域启动 Go 程序时,os.Getenv("LANG") 返回 "zh_CN.UTF-8" 等值,触发 runtime 初始化阶段对 GOMAXPROCS 的隐式重估逻辑。

环境变量干扰链路

  • runtime.goenvs() 解析 GOMAXPROCS 前先调用 sysctl("hw.ncpu")
  • 某些 BSD 衍生系统在 locale 非 C 时,sysctl 返回值被 libc 缓存污染
  • 导致 schedinit()gomaxprocs 初始值比预期少 1–2 个 P

实测对比(Linux vs FreeBSD)

系统 LANG=zh_CN.UTF-8 GOMAXPROCS 显式设为 8 实际 P 数
Linux 6.5 8
FreeBSD 14 ❌(sysctl 异常) 6
// runtime/proc.go: schedinit() 截断片段
func schedinit() {
    // 此处 getenv 在非C locale下可能触发libc内部锁竞争
    n := atoi(goenvs("GOMAXPROCS")) 
    if n < 1 { n = 1 }
    // 若 sysctl("hw.ncpu") 失败,fallback 到 runtime.numcpu()
    // 而 numcpu() 在 FreeBSD 上依赖 /dev/cpuctl —— 权限受 locale 影响
    if n > int32(runtime.NumCPU()) {
        n = int32(runtime.NumCPU())
    }
}

该逻辑在 runtime.NumCPU() 内部调用 sysctlbyname("hw.ncpu") 时,因 libc 的 setlocale() 残留状态导致 sysctl 返回 -1,最终 runtime.NumCPU() 降级为 1,引发 P 本地队列饥饿。

graph TD A[启动进程] –> B{LANG != C} B –>|是| C[libc setlocale 留下 sysctl 上下文污染] B –>|否| D[正常读取 hw.ncpu] C –> E[sysctlbyname 返回 -1] E –> F[runtime.NumCPU → fallback 到 1] F –> G[P 数量严重不足]

3.3 Go runtime内部字符串处理对UTF-8/BOM/GB18030的底层兼容逻辑

Go runtime 将 string 视为不可变的 UTF-8 字节序列,不内建 BOM 或 GB18030 支持,所有编码转换需显式依赖 golang.org/x/text/encoding

UTF-8 是唯一原生语义

  • 字符串长度 len(s) 返回字节数,非 Unicode 码点数;
  • range s 自动按 UTF-8 码点解码(含代理对校验);
  • BOM(U+FEFF)被当作普通有效 UTF-8 字符处理,无自动剥离逻辑。

GB18030 需外部编码器

import "golang.org/x/text/encoding/simplifiedchinese"

dec := simplifiedchinese.GB18030.NewDecoder()
s, _ := dec.String("你好") // 显式转 UTF-8 string

此代码调用 DecodeString,内部通过查表匹配 GB18030 四字节变长编码(如 0x81 0x30 0x89 0x38U+4F60),失败时返回 UnicodeError

编码兼容性对比

编码 runtime 原生支持 BOM 处理 多字节映射
UTF-8 ❌(保留) ✅(RFC 3629)
GB18030 ✅(需 x/text)
UTF-8+BOM ❌(视为 "\xef\xbb\xbf"
graph TD
    A[byte[] input] --> B{Is valid UTF-8?}
    B -->|Yes| C[string = unsafe.String()]
    B -->|No| D[panic: invalid UTF-8 in literals]
    C --> E[range: decode codepoints on-the-fly]

第四章:标准库与生态工具链的本地化适配实践

4.1 net/http中Accept-Language解析与Content-Negotiation的Go原生实现剖析

Go 标准库 net/http 将语言协商完全交由开发者手动处理,未提供高层抽象接口。

Accept-Language 解析逻辑

Request.Header.Get("Accept-Language") 返回原始字符串(如 "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7"),需自行切分、解析权重(q 参数)并排序:

func parseAcceptLanguage(s string) []struct{ tag string; q float64 } {
    parts := strings.Split(s, ",")
    var langs []struct{ tag string; q float64 }
    for _, p := range parts {
        fields := strings.Split(strings.TrimSpace(p), ";")
        tag := fields[0]
        q := 1.0
        if len(fields) > 1 {
            if strings.HasPrefix(fields[1], "q=") {
                if v, err := strconv.ParseFloat(strings.TrimPrefix(fields[1], "q="), 64); err == nil {
                    q = v
                }
            }
        }
        langs = append(langs, struct{ tag string; q float64 }{tag, q})
    }
    sort.Slice(langs, func(i, j int) bool { return langs[i].q > langs[j].q })
    return langs
}

该函数完成三步:分割语言项 → 提取 q 值(默认 1.0)→ 按质量权重降序排列。

内容协商核心流程

graph TD
A[收到 HTTP 请求] --> B[读取 Accept-Language 头]
B --> C[解析为带权语言列表]
C --> D[匹配可用语言资源]
D --> E[返回最高优先级匹配项或默认值]
语言标签 示例值 语义说明
zh-CN 简体中文 明确地域变体
zh 中文泛型 无地域限定
* 通配符 匹配任意未指定语言

标准库不强制执行匹配策略,赋予开发者完整控制权。

4.2 time.LoadLocation与IANA时区数据库在多语言系统中的加载路径追踪

Go 标准库 time.LoadLocation 并非直接解析时区字符串,而是依赖底层 IANA 时区数据库(如 tzdata)的二进制索引文件(zoneinfo.zip 或文件系统路径下的 zoneinfo 目录)。

加载优先级路径

  • 首先检查环境变量 ZONEINFO 指向的自定义路径;
  • 其次尝试 $GOROOT/lib/time/zoneinfo.zip(编译时嵌入);
  • 最后回退至系统路径:/usr/share/zoneinfo(Linux/macOS)或 C:\Windows\System32\drivers\etc\timezone(Windows,仅限部分发行版)。

多语言环境适配关键点

loc, err := time.LoadLocation("Asia/Shanghai") // 严格匹配IANA名称,不接受"中国标准时间"等本地化别名
if err != nil {
    log.Fatal(err) // 错误含路径尝试详情,如 "open /usr/share/zoneinfo/Asia/Shanghai: no such file"
}

该调用实际触发 zoneinfo.ReadZoneInfozoneinfo.zipAsia/Shanghai 的二进制序列化数据解包,不进行任何语言翻译;本地化显示需由 loc.DisplayName() 或外部 i18n 库配合完成。

路径类型 示例值 是否支持多语言键
IANA 官方名称 Europe/Berlin 否(强制英文)
系统时区符号链 /etc/localtime → /usr/share/zoneinfo/Europe/Berlin 是(但Go不读取该符号链)
自定义映射表 "zh-CN": "Asia/Shanghai" 是(需应用层维护)
graph TD
    A[time.LoadLocation<br>"Asia/Shanghai"] --> B{查找 zoneinfo.zip?}
    B -->|存在| C[解压并反序列化<br>binary zoneinfo data]
    B -->|不存在| D[遍历系统路径列表]
    D --> E[/usr/share/zoneinfo/Asia/Shanghai]
    E -->|found| C
    E -->|not found| F[返回 error]

4.3 fmt.Printf与text/template在不同locale下的动词行为差异实验

locale敏感性初探

fmt.Printf%f%d 等动词默认遵循 Go 运行时 locale(实际由 LC_NUMERIC 影响),而 text/template.Float.Int 完全不感知系统 locale,始终使用 C locale 格式。

实验代码对比

// 设置环境变量:LC_NUMERIC=de_DE.UTF-8
os.Setenv("LC_NUMERIC", "de_DE.UTF-8")
fmt.Printf("fmt: %.2f\n", 1234.56) // 输出:1234,56(逗号为小数点)

fmt.Printf 调用底层 C.strtod/C.sprintf,受 setlocale(LC_NUMERIC, ...) 影响;Go 1.22+ 仍不支持 locale-aware 格式化开关,属隐式依赖。

t := template.Must(template.New("").Parse("tmpl: {{printf \"%.2f\" .}}"))
t.Execute(os.Stdout, 1234.56) // 恒输出:1234.56(英文格式)

text/template 内部使用 strconv.FormatFloat,硬编码 . 为小数分隔符,与系统 locale 无关。

行为差异总结

动词 fmt.Printf text/template 是否 locale 敏感
%f / %.2f
%d ❌(整数无分隔)
{{. | printf "%f"}} ✅(继承 fmt 规则) 是(仅当调用 fmt 函数时)

关键结论

  • fmt.Printf 是唯一受 locale 影响的格式化入口;
  • text/template 保持可预测性,但需手动适配多语言数字格式(如通过 message.Format 或自定义函数)。

4.4 go test -v输出、pprof报告及go doc生成内容的本地化注入点控制

Go 工具链在测试、性能分析与文档生成阶段均暴露可干预的输出注入点,用于注入本地化元数据(如语言标记、区域上下文、翻译键前缀)。

测试输出本地化控制

go test -v 的日志流可通过 testing.T.Log 配合 localize.Msg() 封装实现语境感知输出:

func TestLogin(t *testing.T) {
    t.Log(localize.Msg("test_login_start", localize.WithLang("zh-CN"))) // 注入语言上下文
}

此处 localize.Msg 在运行时查表替换模板字符串,并将 lang=zh-CN 作为结构化字段注入 t.Log 输出流,供后续日志处理器识别。

pprof 与 go doc 的注入机制

工具 注入点 控制方式
go tool pprof runtime/pprof.SetLabel 动态绑定 region=shanghai 标签
go doc //go:generate go run gen_doc.go -lang=ja 通过 build tag + 生成器注入语言元数据
graph TD
    A[go test -v] -->|Log hook| B[localize.InjectContext]
    C[pprof.StartCPUProfile] -->|SetLabel| D[region=shanghai]
    E[go doc] -->|go:generate| F[gen_doc.go -lang=ja]

第五章:从理论到生产——Go本地化支持的终极边界与演进方向

真实场景下的多语言资源热更新挑战

在某跨境电商SaaS平台中,运营团队需在不重启服务的前提下动态上线西班牙语(es-ES)和巴西葡萄牙语(pt-BR)的促销文案。团队采用自研的i18n-loader结合etcd实现配置中心驱动的本地化资源热加载:当/locales/es-ES/messages.yaml变更时,watcher触发Bundle.Reload(),并通过原子指针切换确保localizer.Get("checkout.discount")调用零中断。但实测发现,在高并发(QPS > 8k)下存在短暂的nil pointer dereference,根源在于Bundle结构体中未对mu.RLock()mu.Lock()进行读写分离优化。

嵌套消息格式的生产级陷阱

Go标准库golang.org/x/text/messageplural.Select在处理阿拉伯语(ar)复数规则时暴露局限性。某中东金融App要求按数字显示“1 رسالة”、“2 رسائل”、“3 رسائل”等不同形态,但message.Printer无法原生支持阿拉伯语复杂的12类复数范畴。最终采用github.com/nicksnyder/go-i18n/v2/i18n并手动注册ar语言的PluralRule函数,覆盖One/Two/Other三态映射逻辑:

func arabicPlural(n float64) string {
    if n == 1 { return "one" }
    if n == 2 { return "two" }
    return "other"
}

混合脚本界面的渲染一致性保障

某全球化协作工具需同时渲染中文(zh-Hans)、日文(ja-JP)和英文(en-US)的富文本评论。测试发现golang.org/x/text/languageMatcherAccept-Language: zh-CN,zh;q=0.9,en;q=0.8头解析时,将zh-CN错误匹配为zh-Hant(繁体),导致简体用户看到繁体文案。解决方案是构建定制Matcher,显式排除zh-Hant候选集,并强制回退链为[zh-Hans, zh, en]

Accept-Language Header Default Matcher Result Custom Matcher Result
zh-CN,zh;q=0.9 zh-Hant zh-Hans
ja-JP,ja;q=0.8 ja-JP ja-JP
fr-FR,fr;q=0.9,en;q=0.8 en-US en-US

WebAssembly环境中的本地化性能瓶颈

将Go编译为Wasm模块嵌入前端时,golang.org/x/text/languageParse函数在初始化阶段耗时达120ms(Chrome 120)。通过//go:wasmimport注入浏览器Intl.Locale API,绕过Go侧语言解析,直接复用V8内置能力:

flowchart LR
    A[Go Wasm Module] -->|Call| B[JS Bridge]
    B --> C[window.Intl.Locale\n'zh-CN']
    C -->|Return| D[ISO 639-1 Code]
    D --> E[Load zh-Hans Bundle]

静态分析驱动的本地化完整性校验

在CI流水线中集成go-i18n-checker工具,扫描所有localizer.MustGet(...)调用点,对比messages.en.yamlmessages.zh-Hans.yaml的键集合差异。当检测到messages.zh-Hans.yaml缺失"payment.failed"键时,自动阻断PR合并,并生成补全建议:

$ go-i18n-checker --base=en --target=zh-Hans locales/
❌ Missing keys in zh-Hans:
   - payment.failed: "支付失败,请重试"
   - cart.empty: "购物车为空"

跨平台字体fallback策略

iOS端使用SF Pro字体渲染阿拉伯语时出现方块字,Android端则正常。通过runtime.GOOS条件编译,在darwin/amd64平台注入系统字体映射表,强制ar语言使用"Hiragino Sans GB"作为fallback:

if runtime.GOOS == "darwin" && lang == "ar" {
    fontFamily = "Hiragino Sans GB, Arial, sans-serif"
}

关注系统设计与高可用架构,思考技术的长期演进。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注