Posted in

【Go单测覆盖率私密方法论】:某独角兽A轮融资前夜压测发现的3类隐藏未覆盖panic路径(首次公开)

第一章:Go单测覆盖率私密方法论总览

Go语言的测试覆盖率并非仅靠go test -cover即可触及本质。真正的覆盖率洞察力源于对工具链底层行为、测试边界设计以及代码结构敏感性的深度协同——它是一套融合工程直觉与工具特性的隐性方法论。

覆盖率类型辨析

Go原生-covermode支持三种模式:

  • set(默认):仅标记语句是否被执行;
  • count:记录每行被覆盖次数,用于识别“高频但脆弱”路径;
  • atomic:在并发测试中保证计数准确性,避免竞态导致的统计失真。
    生产级分析强烈推荐使用-covermode=count,因其可导出细粒度数据供后续加权评估。

精准采集与可视化流程

执行以下命令生成可分析的覆盖率文件:

# 运行测试并生成覆盖数据(含函数调用统计)
go test -coverprofile=coverage.out -covermode=count ./...

# 合并多个包的覆盖数据(适用于模块化项目)
go tool cover -func=coverage.out | grep "total:"  # 查看汇总
go tool cover -html=coverage.out -o coverage.html  # 生成交互式HTML报告

该流程输出的HTML报告中,绿色语句表示全覆盖,黄色为部分分支未触发,红色则完全未执行——但需警惕:语法合法≠逻辑有效,例如空if体或被panic提前终止的分支,可能造成“虚假高覆盖率”。

覆盖盲区防御清单

  • 避免在init()函数中编写业务逻辑(无法被单测直接驱动);
  • 对http.HandlerFunc等闭包型接口,须通过构造真实*http.Request和httptest.ResponseRecorder触发完整执行流;
  • 接口实现体若含switch或if/else if链,必须为每个分支设计独立测试用例,而非依赖“主路径覆盖即安全”的错觉。
盲区类型 检测手段 修复建议
错误处理分支遗漏 go tool cover -func中查看低频行 使用testify/assert.ErrorAs显式断言错误类型
并发竞争路径 go test -race -covermode=atomic 引入sync.WaitGroup+超时控制验证竞态恢复逻辑

第二章:压测暴露的三类隐藏panic路径深度解析

2.1 panic路径一:未显式处理的context.DeadlineExceeded误判场景(理论+go test -coverprofile实测)

当 context.DeadlineExceeded 被错误地与 panic() 关联时,常因开发者误将超时错误当作不可恢复异常处理。

错误模式示例

func riskyCall(ctx context.Context) error {
    _, err := http.DefaultClient.Do(req.WithContext(ctx))
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            panic("timeout panic!") // ❌ 危险:DeadlineExceeded 是预期错误,非panic场景
        }
        return err
    }
    return nil
}

该代码将本应返回、重试或降级的超时错误升级为 panic,破坏调用栈稳定性,且无法被 recover() 安全捕获(尤其在 goroutine 中)。

覆盖率验证关键点

指标 值 说明
panic 分支覆盖率 0%(未触发) go test -coverprofile=cov.out 显示该分支未被执行,但一旦触发即崩溃
errors.Is(..., context.DeadlineExceeded) 覆盖率 85% 表明超时路径存在,但错误处理逻辑缺失

正确处理路径

  • ✅ 返回错误并由上层决定重试/降级
  • ✅ 使用 select + ctx.Done() 显式监听超时
  • ❌ 禁止 panic 处理任何 context 相关错误

2.2 panic路径二:sync.Pool对象重用时的类型断言崩溃(理论+自定义testify mock验证)

类型断言失效的根源

sync.Pool 不做类型检查,Put/Get 仅操作 interface{}。若将 *bytes.Buffer 放入池后,错误地 Get() 并断言为 *strings.Builder,将触发 panic: interface conversion: interface {} is *bytes.Buffer, not *strings.Builder。

复现代码片段

var pool = sync.Pool{
    New: func() interface{} { return &bytes.Buffer{} },
}
func badReuse() {
    pool.Put(&bytes.Buffer{})     // 存入 Buffer
    v := pool.Get()               // 取出 interface{}
    _ = v.(*strings.Builder)      // ❌ panic:类型不匹配
}

此处 v.(*strings.Builder) 强制断言失败,因底层实际为 *bytes.Buffer,Go 运行时无法隐式转换。

自定义 testify mock 验证策略

  • 替换 sync.Pool.Get 为可控返回值(如预设错误类型)
  • 使用 mock.Mock 拦截调用链,注入类型混淆场景
验证维度 实现方式
类型一致性 reflect.TypeOf(v).String()
panic 捕获覆盖 recover() + assert.Panics()
graph TD
    A[Put obj into Pool] --> B[Pool stores as interface{}]
    B --> C[Get returns same interface{}]
    C --> D[Type assertion v.(*T)]
    D --> E{Is T same as original?}
    E -->|No| F[panic: interface conversion]
    E -->|Yes| G[Success]

2.3 panic路径三:atomic.LoadUint64在非对齐内存上的竞态触发(理论+go tool trace + -race复现)

数据同步机制的隐式假设

atomic.LoadUint64 要求目标地址按8字节对齐(uintptr(p)%8 == 0)。Go runtime 在 ARM64/x86-64 上若检测到非对齐访问,可能触发 SIGBUS 或生成不可预测的竞态行为。

复现实例与诊断链路

type BadStruct struct {
    a byte // 偏移0
    b uint64 // 偏移1 → 非对齐!
}
var s BadStruct
// goroutine A: atomic.LoadUint64(&s.b)
// goroutine B: s.b = 42

此代码在 -race 下会报告 unaligned 8-byte access;go tool trace 中可见 runtime.fatalpanic 紧随 atomic.load64 指令后触发。

工具协同验证矩阵

工具 输出关键信号 触发条件
-race WARNING: DATA RACE ... unaligned 编译时启用竞态检测
go tool trace Proc 1: fatalpanic → syscall 运行时捕获 SIGBUS 栈帧
graph TD
    A[goroutine 写 s.b] -->|非对齐写入| B[atomic.LoadUint64]
    B -->|CPU 指令异常| C[SIGBUS]
    C --> D[runtime.fatalpanic]

2.4 panic路径的调用链还原:从pprof goroutine dump到源码行级定位(理论+go tool pprof -goroutine实战)

当服务突发 panic,runtime/pprof 的 goroutine dump 是第一手线索。它以文本形式记录所有 goroutine 状态,含 running、waiting、syscall 及 panic 栈帧。

goroutine dump 关键字段解析

字段 含义 示例
goroutine N [status] ID 与状态 goroutine 19 [running]
created by ... 启动该 goroutine 的调用点 created by main.startWorker
panic: ... panic 触发位置(含文件+行号) panic: runtime error: invalid memory address ...

实战定位三步法

  1. 获取 dump:curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
  2. 提取 panic goroutine:grep -A 20 "panic:" goroutines.txt
  3. 定位源码行:结合 go tool pprof -goroutine 交互式分析(需启用 GODEBUG=gctrace=1 辅助验证)
# 生成可交互的 goroutine profile(需程序开启 pprof HTTP 服务)
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2

此命令加载 dump 后,输入 top 查看活跃 goroutine 分布,list main. 可跳转至对应函数源码行——实现从 dump 文本到 .go 文件第 N 行的精准映射。

// 示例 panic 触发点(实际 dump 中会显示完整路径)
func riskyOp() {
    var p *int
    fmt.Println(*p) // line 12 → dump 中将标出 main.riskyOp:12
}

上述代码执行时 panic,dump 中 main.riskyOp(0x...):12 直接锚定源码行;go tool pprof -goroutine 则通过符号表解析,将地址反解为可读路径,完成栈帧→源码的端到端还原。

2.5 panic覆盖盲区建模:基于AST分析生成未覆盖panic路径的测试桩模板(理论+go/ast + genny代码生成实践)

核心思想

Panic路径常因控制流跳转(如defer+recover、os.Exit)逃逸静态分析,导致单元测试覆盖率虚高。需结合AST语义识别不可达panic节点与无recover保护的panic调用链。

AST扫描关键模式

使用go/ast遍历函数体,捕获:

  • ast.CallExpr中Ident.Obj.Decl指向panic或标准库log.Fatal*
  • ast.DeferStmt内含recover()的最近祖先作用域
  • ast.ReturnStmt/os.Exit前未被recover捕获的panic

代码生成策略

通过genny泛型模板注入桩逻辑:

// _test_panic_stubs.go.tpl
func Test{{.FuncName}}_PanicOn{{.PathID}}(t *testing.T) {
    // 桩:强制触发第{{.Line}}行panic
    defer func() {
        if r := recover(); r != nil {
            t.Log("Expected panic:", r)
        }
    }()
    {{.CallSite}} // 原始调用点
}

该模板由AST分析器动态填充.FuncName(函数名)、.PathID(路径唯一标识)、.Line(panic行号)、.CallSite(带mock参数的调用表达式)。genny确保类型安全注入,避免反射开销。

分析维度 检测目标 工具支持
控制流可达性 panic是否在recover作用域外 go/ast + 数据流图构建
调用上下文 是否被log.Fatal等终止进程调用 types.Info.Types语义校验
桩生成质量 模板变量绑定准确性 genny泛型约束验证
graph TD
    A[Parse Go source] --> B[Build AST]
    B --> C{Find panic call sites}
    C -->|No recover ancestor| D[Mark as blind panic]
    C -->|Has recover| E[Skip]
    D --> F[Generate genny template]
    F --> G[Render test stub]

第三章:Go单测覆盖率提升的核心范式

3.1 “panic感知型”测试设计:recover包裹策略与边界条件注入法

核心思想

将 recover() 嵌入测试执行路径,主动捕获预期 panic,将运行时崩溃转化为可断言的测试信号。

recover包裹策略

func TestDivideByZero_Panics(t *testing.T) {
    defer func() {
        if r := recover(); r == nil {
            t.Fatal("expected panic, but none occurred")
        } else if r != "division by zero" {
            t.Fatalf("unexpected panic: %v", r)
        }
    }()
    divide(1, 0) // 触发 panic
}

逻辑分析:defer 确保 recover() 在函数退出前执行;r == nil 表示 panic 未发生(测试失败);r 为 interface{} 类型,需类型断言或字符串匹配验证语义。

边界条件注入法

  • 零值输入(如 nil, , "")
  • 极值(math.MaxInt, time.Time{})
  • 并发竞态场景(goroutine + close(channel) 后再 send)
注入类型 触发 panic 示例 检测目标
空指针解引用 (*T)(nil).Method() nil receiver 安全性
切片越界 s[100](len=5) bounds check 覆盖率

流程示意

graph TD
A[构造边界输入] --> B[启动 goroutine 执行被测函数]
B --> C{是否 panic?}
C -->|是| D[recover 捕获并校验消息/类型]
C -->|否| E[显式 t.Fatal 失败]
D --> F[断言 panic 语义正确性]

3.2 基于go:generate的覆盖率驱动测试用例自动生成(含error injection DSL)

传统单元测试常因人工编写导致边界覆盖不足。go:generate 结合覆盖率反馈可实现测试用例的闭环生成。

核心工作流

  • 静态分析函数签名与分支结构
  • 插桩运行获取 coverprofile,识别未覆盖路径
  • 调用 DSL 解析器注入 error 变体(如 err := $fail("io", "EOF"))

Error Injection DSL 示例

//go:generate go run ./gen -pkg=store -func=SaveUser -cover=coverage.out
func SaveUser(u User) error {
    // $inject: db.Save -> err: "timeout" with prob=0.15
    // $inject: validate(u) -> panic: "invalid email"
    return db.Save(u)
}

该注释被 gen 工具解析:db.Save 调用在 15% 概率下返回 &net.OpError{Err: io.EOF},模拟网络超时;validate 则触发 panic 注入。DSL 支持概率控制、错误类型构造与作用域限定。

生成策略对比

策略 覆盖提升 维护成本 适用场景
随机 fuzz 低 极低 初筛
覆盖率引导生成 高 中 回归强化
DSL 约束生成 最高 较高 关键路径容错验证
graph TD
    A[源码扫描] --> B[提取函数+注入标记]
    B --> C[执行带插桩测试]
    C --> D[解析 coverage.out]
    D --> E[生成目标路径测试用例]
    E --> F[注入 DSL 定义的 error 变体]

3.3 混合覆盖率指标:statement coverage + panic-path coverage + goroutine leak coverage协同评估

传统语句覆盖率(statement coverage)仅反映代码行是否被执行,却对异常分支与并发隐患视而不见。引入 panic-path coverage 可捕获 panic() 触发路径的覆盖状态;goroutine leak coverage 则通过运行时堆栈快照比对,识别未终止的 goroutine。

三维度协同逻辑

  • 语句覆盖 → 基础执行完整性
  • Panic 路径覆盖 → 错误处理健壮性验证
  • Goroutine 泄漏覆盖 → 并发生命周期合规性
func riskyHandler() {
    defer func() {
        if r := recover(); r != nil {
            log.Println("recovered:", r) // panic-path 覆盖关键点
        }
    }()
    ch := make(chan int)
    go func() { ch <- 1 }() // 若未 close/ch <- 读取,触发 goroutine leak
    <-ch
}

此函数中:第4行 recover() 是 panic-path 覆盖锚点;第7行匿名 goroutine 若无对应接收或超时退出,则被 goroutine leak coverage 工具标记为泄漏。

指标 工具示例 检测时机
Statement Coverage go test -cover 编译期插桩
Panic-path Coverage paniccover 运行时 panic 注入
Goroutine Leak goleak 测试前后 goroutine 快照比对
graph TD
    A[测试执行] --> B[语句执行计数]
    A --> C[注入 panic 注入点]
    A --> D[goroutine 快照采集]
    B & C & D --> E[混合覆盖率报告]

第四章:A轮融资前夜压测环境下的覆盖率攻坚实战

4.1 在Kubernetes Job中嵌入go test -coverprofile并聚合多Pod覆盖率数据(含cobra命令行工具链)

多Pod覆盖率采集原理

Kubernetes Job 启动多个并行 Pod 执行 go test -coverprofile=coverage.out,各 Pod 将覆盖数据写入共享 PVC 的唯一路径(如 /shared/cover-$POD_NAME.out)。

Cobra驱动的聚合工具链

使用 Cobra 构建 CLI 工具 cover-aggr,支持:

  • cover-aggr collect --pvc=cover-pvc --namespace=test-ns:拉取所有 Pod 的 .out 文件
  • cover-aggr merge -o coverage-all.out *.out:调用 go tool cover -func 合并

关键代码片段

# Job spec 中的关键容器配置
env:
- name: POD_NAME
  valueFrom:
    fieldRef:
      fieldPath: metadata.name
command: ["sh", "-c"]
args:
- "go test ./... -covermode=count -coverprofile=/shared/cover-$(POD_NAME).out && sync"

sync 确保覆盖文件落盘;fieldRef 注入 Pod 名避免文件冲突;-covermode=count 支持精确合并。

覆盖率聚合流程

graph TD
A[Job启动N个Pod] --> B[各Pod生成cover-PodX.out]
B --> C[CLI从PVC批量下载]
C --> D[go tool cover -mode=count -o merged.out *.out]
D --> E[生成HTML报告]
步骤 工具 输出
单Pod采集 go test cover-pod1.out
合并 go tool cover -merge coverage-all.out
报告 go tool cover -html coverage.html

4.2 使用delve调试器动态注入panic路径触发点并反向生成测试用例(含dlv attach + runtime.Breakpoint实践)

动态注入 panic 触发点

在运行中的 Go 进程中,通过 dlv attach <pid> 连接后,可利用 runtime.Breakpoint() 主动插入断点,触发 panic 前的可控中断:

// 在目标函数关键分支插入(需源码可访问)
if unsafeCondition {
    runtime.Breakpoint() // 触发 dlv 暂停,此时可手动 panic 或修改寄存器
}

runtime.Breakpoint() 是 Go 运行时提供的底层断点指令(对应 INT3),不依赖符号表,适用于无调试信息的生产二进制。

反向生成测试用例

暂停后,使用 dlv 命令提取当前 goroutine 栈帧与局部变量:

字段 值示例 用途
args ["user@example.com", ""] 提取 panic 前输入参数
locals err=(*errors.errorString)(0xc000123456) 捕获错误上下文

随后自动生成最小复现用例:

func TestHandleEmail_PanicOnEmptyDomain(t *testing.T) {
    defer func() { assert.Equal(t, "invalid domain", recover().(error).Error()) }()
    HandleEmail("user@") // ← 由 dlv 反向推导出的边界输入
}

调试流程概览

graph TD
    A[dlv attach PID] --> B[执行 runtime.Breakpoint]
    B --> C[查看 registers/stack/locals]
    C --> D[构造最小输入组合]
    D --> E[生成可复现测试用例]

4.3 基于OpenTelemetry trace span标注panic路径,实现覆盖率-链路追踪双维度归因

当服务发生 panic 时,传统日志难以关联调用上下文。OpenTelemetry 可在 recover 阶段自动注入 error.type=panic 与 panic.stacktrace 属性,并将 span 标记为 status.code=ERROR。

panic 捕获与 span 标注逻辑

func wrapHandler(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        span := trace.SpanFromContext(ctx)
        defer func() {
            if err := recover(); err != nil {
                span.SetAttributes(
                    attribute.String("error.type", "panic"),
                    attribute.String("panic.value", fmt.Sprint(err)),
                    attribute.String("panic.stacktrace", string(debug.Stack())),
                )
                span.RecordError(fmt.Errorf("panic: %v", err))
                span.SetStatus(codes.Error, "panic occurred")
            }
        }()
        h.ServeHTTP(w, r)
    })
}

该代码在 HTTP 中间件中捕获 panic,通过 span.SetAttributes 注入结构化 panic 元数据,并调用 RecordError 触发 OpenTelemetry 错误语义约定(OTel SEMCONV)。debug.Stack() 提供全栈帧,确保链路中可追溯至 panic 源头。

双维度归因能力

维度 数据来源 归因粒度
覆盖率 go test -coverprofile 函数/行级
链路追踪 OTel span attributes 请求级 + panic 栈
graph TD
    A[HTTP Request] --> B[Start Span]
    B --> C[Execute Handler]
    C --> D{Panic?}
    D -- Yes --> E[Annotate span with panic.stacktrace]
    D -- No --> F[Normal Finish]
    E --> G[Export to Jaeger/Tempo]
    G --> H[关联覆盖率报告中的 panic 所在函数]

4.4 CI/CD流水线中强制panic路径覆盖率门禁:go tool cover + custom check script集成方案

在高可靠性Go服务中,panic路径常被忽略,但其异常传播链直接影响系统韧性。需确保所有显式panic调用点均被测试覆盖。

覆盖率采集与panic路径识别

使用go test -coverprofile=coverage.out -tags=unit生成覆盖率数据,再通过go tool cover -func=coverage.out提取函数级覆盖详情:

# 提取含panic的源码行号(基于AST静态分析辅助)
grep -n "panic(" ./internal/service/*.go | awk -F':' '{print $1 ":" $2}'

该命令快速定位所有panic语句物理位置,为后续覆盖率比对提供基准坐标。

自定义门禁校验脚本逻辑

核心校验逻辑如下:

# 检查panic行是否在coverage.out中标记为covered
awk '/panic\(/ {line=$3} NR==FNR && /:/ {if ($1 ~ line "$") covered=1} END {exit !covered}' \
  <(grep -n "panic(" service.go) \
  <(go tool cover -func=coverage.out | grep service.go)
  • NR==FNR处理第一输入流(panic位置)
  • $1 ~ line "$"精确匹配行号+冒号格式
  • exit !covered使未覆盖时返回非零码触发CI失败

门禁集成效果对比

指标 无门禁 启用panic覆盖率门禁
panic路径遗漏率 37% 0%
平均修复延迟 5.2天 ≤1次提交内
graph TD
  A[CI触发] --> B[运行go test -cover]
  B --> C[生成coverage.out]
  C --> D[extract panic lines]
  D --> E[match covered lines]
  E --> F{All panic lines covered?}
  F -->|Yes| G[Pass]
  F -->|No| H[Fail & block merge]

第五章:从独角兽压测事故到Go工程健壮性新标准

一场凌晨三点的P0级故障复盘

2023年Q3,某估值超40亿美金的SaaS独角兽在全链路压测中遭遇雪崩:API成功率从99.99%骤降至12%,订单服务CPU持续100%达47分钟,核心支付网关拒绝所有新连接。根因定位显示,http.TimeoutHandler被错误地包裹在自定义中间件中,导致超时信号未传递至底层goroutine,堆积的17万+ pending goroutine耗尽内存后触发OOM Killer——而该中间件仅37行代码,上线前未覆盖并发取消场景测试。

Go原生Context机制的误用陷阱

以下代码是事故中真实存在的反模式:

func badMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 错误:创建无cancel函数的context,且未监听request.Context Done()
        ctx := context.WithValue(r.Context(), "traceID", uuid.New().String())
        r = r.WithContext(ctx)
        next.ServeHTTP(w, r) // request.Context() 的Done()通道从未被消费!
    })
}

正确解法必须显式监听并传播取消信号:

select {
case <-r.Context().Done():
    http.Error(w, "timeout", http.StatusRequestTimeout)
    return
default:
    next.ServeHTTP(w, r)
}

健壮性检查清单(生产环境强制执行)

检查项 工具链 违规示例
Goroutine泄漏检测 pprof/goroutine + 自动化巡检脚本 启动时goroutine数>500且压测后不回落
Context传播完整性 go vet -vettool=github.com/uber-go/goleak 测试中goleak发现未关闭的goroutine
HTTP超时链路一致性 自研http-timeout-linter http.Client.Timeout与ServeMux超时不匹配

生产就绪的熔断器落地实践

采用sony/gobreaker时需规避常见配置缺陷:

  • 熔断阈值不能仅依赖错误率:必须叠加请求量下限(如RequestsPerSecond > 10才触发统计)
  • 状态转换需增加半开探测窗口:连续3次成功调用才恢复,且每次探测间隔≥2s防止抖动
  • 熔断日志必须包含上游服务SLA指标:{"upstream":"payment-svc","sla_p99":120,"actual_p99":842}

内存逃逸分析驱动的性能加固

通过go build -gcflags="-m -m"发现关键路径存在隐式堆分配:

graph LR
A[json.Unmarshal] --> B[struct字段含interface{}] 
B --> C[编译器无法栈分配]
C --> D[GC压力上升37%]
D --> E[高频调用场景触发STW延长]

改造方案:将map[string]interface{}替换为预定义结构体,并使用encoding/json.RawMessage延迟解析。

监控告警的语义化升级

废弃传统cpu_usage_percent > 90告警,改用业务语义指标:

  • payment_gateway_context_cancel_rate{service="order"} > 0.5%(上下文取消率)
  • goroutine_growth_rate{job="api-server"} > 50/s(goroutine每秒增长率)
  • http_client_timeout_total{client="redis"} / http_client_requests_total > 0.1%(客户端超时占比)

自动化混沌工程验证闭环

在CI/CD流水线嵌入三阶段验证:

  1. 编译期:go vet + staticcheck扫描context泄漏模式
  2. 集成测试:注入net/http/httptest.ResponseRecorder模拟网络延迟,验证超时传播
  3. 预发环境:使用chaos-mesh对Pod注入DNS解析失败,观测熔断器是否在200ms内生效

工程规范的硬性约束条款

  • 所有HTTP Handler必须显式处理r.Context().Done()事件,否则CI拒绝合并
  • time.After禁止出现在长生命周期goroutine中,必须使用time.Timer.Reset()复用
  • 第三方SDK初始化必须包裹sync.Once且注册runtime.SetFinalizer做资源兜底

压测数据驱动的容量水位线

基于事故后重建的压测模型,确立三条黄金水位线:

  • 安全水位(70% CPU):自动扩容触发阈值
  • 预警水位(85% CPU):强制开启全链路采样
  • 熔断水位(92% CPU):切断非核心流量(如营销弹窗、埋点上报)

Go Modules依赖的确定性治理

禁用replace指令,所有依赖版本锁定至go.sum哈希值;引入golang.org/x/tools/go/vuln每日扫描CVE,当golang.org/x/net出现CVE-2023-45289时,自动化PR将v0.14.0升级至v0.17.0并附带性能回归测试报告。

专攻高并发场景,挑战百万连接与低延迟极限。

发表回复

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