Posted in

为什么你写的Go单元测试总被Merge拒绝?小厂CI流水线中未公开的3项test准入硬指标解析

第一章:为什么你写的Go单元测试总被Merge拒绝?小厂CI流水线中未公开的3项test准入硬指标解析

在小厂CI流水线中,go test 通过远不等于测试准入。大量PR因看似“绿色”的测试结果被拒,根源在于流水线背后隐含的三项强制性硬指标——它们通常不会写在文档里,却由预提交钩子(pre-commit hook)和CI脚本严格校验。

测试覆盖率阈值必须显式声明且达标

CI会运行 go test -coverprofile=coverage.out ./... 后解析 coverage.out,要求 整体覆盖率 ≥ 80%核心业务包(如 ./pkg/payment, ./internal/auth)单独 ≥ 85%。若未达阈值,直接失败。修复方式:在 Makefile 中加入显式校验步骤:

test-cover:
    go test -coverprofile=coverage.out -covermode=count ./...
    @echo "Checking coverage..."
    @awk 'NR > 1 {sum += $$2; count++} END {if (count > 0 && sum/count < 80) {print "ERROR: Overall coverage " sum/count "% < 80%"; exit 1} else {print "Coverage OK"}}' coverage.out

所有测试函数必须携带 // +build unit 构建标签

CI默认仅执行带 unit 标签的测试,避免集成测试污染单元测试阶段。若遗漏,go test -tags=unit 将跳过该文件,导致实际零覆盖。正确写法:

// +build unit

package user

import "testing"

func TestValidateEmail(t *testing.T) {
    // ...
}

禁止使用 time.Sleepnet/http 外部依赖

CI环境禁用网络与非确定性延时。检测脚本会扫描所有 _test.go 文件:

grep -r "time\.Sleep\|http\.Get\|http\.Post" ./ | grep "_test.go" && echo "FAIL: Found forbidden I/O in tests" && exit 1

三项指标联动生效:缺一即拒。建议在本地开发时同步启用:

检查项 本地验证命令
覆盖率 make test-cover
构建标签 go test -tags=unit -run=^$ ./...(应无输出)
禁用调用 sh -c 'grep -r "time\.Sleep\|http\." ./ --include="*_test.go" || true'

第二章:硬指标一:测试覆盖率必须≥85%——从go test -cover到精准提升策略

2.1 覆盖率统计原理与小厂CI中go tool cover的真实行为解析

go tool cover 并非黑盒采样器,其本质是在 go test -coverprofile 阶段对 AST 插入计数探针(counter-instrumentation),每个可执行语句块(如 if 分支、for 循环体、函数体首行)被赋予唯一 ID,并在运行时写入 count[] 数组。

探针注入示例

// 示例源码:math.go
func Abs(x int) int {
  if x < 0 { // ← 此行被注入探针 #1
    return -x // ← 此行被注入探针 #2
  }
  return x // ← 此行被注入探针 #3
}

该注入由 cmd/compile/internal/ssagen 在 SSA 生成前完成;-covermode=count 模式下,每次执行探针位置,对应计数器原子递增,最终序列化为 coverage.out

小厂CI常见误用陷阱

  • CI脚本直接 go test ./... -coverprofile=cover.out → 忽略子模块覆盖率合并逻辑
  • 未设置 -coverpkg=./... → 外部依赖包函数调用不计入覆盖统计
  • 使用 -covermode=set(布尔模式)→ 无法识别“部分执行分支”(如 if 条件为真但 else 未进)
模式 计数精度 支持分支判定 CI推荐
count ✅ 原子计数 ✔️
atomic ✅ 并发安全 ✔️(高并发测试)
set ❌ 仅标记 ✖️
graph TD
  A[go test -coverprofile] --> B[AST遍历+探针ID分配]
  B --> C[编译时插入runtime.SetFinalizer式计数调用]
  C --> D[运行时写入cover.CounterMap]
  D --> E[cover工具解析二进制profile]

2.2 识别“伪高覆盖”陷阱:mock滥用、空分支未测、HTTP handler未触发路径

常见伪覆盖场景

  • Mock过度隔离:替换了本应验证交互逻辑的依赖,导致行为断言失效
  • 空分支沉默通过if err != nil { return } 类分支无错误注入测试,覆盖率虚高
  • Handler路径未触达:路由注册存在,但测试未构造匹配 path/method 的请求

示例:被掩盖的空错误分支

func HandleUser(w http.ResponseWriter, r *http.Request) {
    user, err := db.Find(r.URL.Query().Get("id"))
    if err != nil {
        return // ← 此分支从未被 err 注入测试!
    }
    json.NewEncoder(w).Encode(user)
}

逻辑分析:return 分支无日志、无状态变更、无响应写入,但 go test -cover 仍计为已覆盖;需用 db.Find = func(id string) (*User, error) { return nil, errors.New("not found") } 显式触发。

三类陷阱对比

陷阱类型 覆盖率表现 真实风险 检测手段
Mock滥用 协议/时序逻辑缺失 删除 mock,观察集成失败
空分支未测 故障静默丢弃 错误注入 + 响应断言
Handler未触发 中低 路由配置与实现脱节 httptest.NewRequest 全路径遍历
graph TD
    A[测试启动] --> B{是否注入边界错误?}
    B -->|否| C[报告100%覆盖]
    B -->|是| D[暴露空分支panic/空响应]
    D --> E[修复错误处理逻辑]

2.3 基于ast分析的覆盖率盲区定位:用gocovgui+custom report快速定位缺口

Go 原生 go test -coverprofile 仅统计执行路径,无法识别 AST 层面未覆盖的语法结构(如空分支、未触发的 case、无逻辑体的 if)。gocovgui 结合自定义 reporter 可桥接此鸿沟。

核心增强机制

  • 解析 .cov 文件与源码 AST 同步比对
  • 注入 ast.Inspect 遍历器标记所有 ast.IfStmt/ast.CaseClause 节点
  • 将未命中节点映射到行号并高亮至 HTML 报告

自定义报告关键代码

// custom_reporter.go:注入 AST 覆盖元数据
func (r *CoverageReporter) AnnotateAST(fset *token.FileSet, f *ast.File) {
    ast.Inspect(f, func(n ast.Node) bool {
        if stmt, ok := n.(*ast.IfStmt); ok {
            line := fset.Position(stmt.Pos()).Line
            r.astCoverage[line] = "uncovered-if" // 标记未执行的 if 结构
        }
        return true
    })
}

该函数遍历 AST 获取所有 if 语句位置,通过 token.FileSet 精确定位行号,并存入 astCoverage 映射供后续 HTML 渲染使用。

覆盖类型 检测方式 盲区示例
行覆盖 go test -cover if x > 0 { }(空体)
AST 结构覆盖 gocovgui + custom case nil:(永不触发)
graph TD
    A[go test -coverprofile] --> B[coverage.dat]
    B --> C[gocovgui parser]
    C --> D[AST traversal via go/ast]
    D --> E[Uncovered node mapping]
    E --> F[Enhanced HTML report]

2.4 针对DTO/validator等高频低价值模块的覆盖豁免机制与PR注释规范

豁免范围界定

以下模块默认豁免单元测试覆盖率检查:

  • *DTO.java*VO.java*Query.java(仅含字段+Lombok @Data
  • @Valid 注解驱动的 ConstraintValidator 实现类(无业务逻辑分支)
  • 纯 Jackson @JsonCreator/@JsonProperty 构造器

PR注释规范

提交时需在 PR 描述首行添加:

#COVERAGE_EXEMPT: DTO/Validator — UserCreateDTO lacks business logic; validated via @Valid + Hibernate Validator

逻辑分析:CI 流程通过正则 #COVERAGE_EXEMPT:\s*(\w+) 提取豁免类型,并校验文件路径是否匹配白名单模式。参数 UserCreateDTO 触发静态扫描,确认其仅含 @NotBlank 等声明式约束,无自定义 isValid() 分支。

豁免审批流

角色 权限 触发条件
TL 强制审批 单次豁免 >3 个类
CI 自动放行 匹配白名单 + 注释格式合法
graph TD
    A[PR提交] --> B{含#COVERAGE_EXEMPT?}
    B -->|否| C[执行全量覆盖率检查]
    B -->|是| D[解析豁免类型+路径]
    D --> E[白名单匹配?]
    E -->|否| F[拒绝合并]
    E -->|是| G[自动跳过对应类覆盖率统计]

2.5 实战:为一个含3个error path的service层函数补全边界测试并拉升覆盖率至87.2%

场景还原

待测函数 syncUserProfile(userId, payload) 存在三个 error path:

  • userId 为空或非数字字符串 → 抛 InvalidUserIdError
  • payload 缺失 email 字段 → 抛 MissingFieldError
  • 外部 HTTP 调用超时(mocked)→ 抛 RemoteServiceTimeoutError

关键测试用例覆盖策略

  • 补充 3 个负向 case(各触发唯一 error path)
  • 增加 1 个边界值:userId = 0(合法但易被忽略)
  • 使用 jest.mock('axios') 精准控制 reject 分支
// 测试 payload 缺失 email 的分支
test('throws MissingFieldError when email is absent', () => {
  const invalidPayload = { name: 'Alice' }; // 故意省略 email
  expect(() => syncUserProfile('123', invalidPayload))
    .toThrow(MissingFieldError);
});

✅ 此用例精准激活第 2 条 error path;invalidPayload 模拟真实数据缺失场景,避免 undefined/null 误判。

覆盖率提升验证

测试类型 行覆盖增量 error path 覆盖
新增负向 case +12 lines ✅ ✅ ✅(全部3条)
边界值 userId=0 +2 lines ✅(激活空值校验分支)
graph TD
  A[syncUserProfile] --> B{userId valid?}
  B -- No --> C[InvalidUserIdError]
  B -- Yes --> D{email in payload?}
  D -- No --> E[MissingFieldError]
  D -- Yes --> F[HTTP call]
  F -- Timeout --> G[RemoteServiceTimeoutError]

第三章:硬指标二:零time.Sleep() + 严格超时控制——阻塞式测试的静默淘汰逻辑

3.1 小厂CI容器资源受限下time.Sleep导致Job超时的底层调度归因

在低配CI容器(如 1C2G、CPU shares=1024)中,time.Sleep 并非“精准休眠”,而是依赖内核定时器 + Go runtime 的抢占式调度协同。

Go 调度器与 OS 时间片的错位

  • Linux CFS 调度器对低配容器分配时间片极短(常
  • time.Sleep(10 * time.Millisecond) 实际可能被延迟至 15–40ms(受 GC STW、sysmon 抢占延迟放大)

典型误用代码

// ❌ 在资源紧张的CI Job中高危
for i := 0; i < 5; i++ {
    if checkReady() {
        break
    }
    time.Sleep(500 * time.Millisecond) // 累计2.5s,但实际耗时可能翻倍
}

逻辑分析:time.Sleep 仅注册唤醒事件,不保证准时返回;当 P(Processor)被 OS 强制切出、或 M 被阻塞于 syscall 时,唤醒回调延迟触发。参数 500ms 是期望值,真实延迟服从长尾分布(P95 > 820ms,实测于 Kubernetes LimitRange=500m CPU)。

资源受限下的延迟放大链

阶段 正常环境延迟 小厂CI容器(1C限频)
timerfd_settime ~10μs ~300μs
G 唤醒入 runqueue ~50μs ~12ms(队列积压)
P 获取并执行 G ~20μs ~8ms(CPU throttling)
graph TD
    A[time.Sleep] --> B{runtime.timerAdd}
    B --> C[内核timerfd]
    C --> D[OS CFS调度延迟]
    D --> E[Go scheduler runqueue排队]
    E --> F[实际唤醒时刻偏移]

3.2 用testify/suite + context.WithTimeout重构异步测试的标准化模板

异步测试易因 goroutine 泄漏或无限等待导致 CI 不稳定。testify/suite 提供结构化生命周期管理,配合 context.WithTimeout 可精准控制超时边界。

标准化测试结构

  • 每个测试用例在 SetupTest() 中初始化共享资源
  • TearDownTest() 确保 goroutine 和 channel 清理
  • 所有异步操作必须接受 context.Context 参数

超时控制实践

func (s *AsyncSuite) TestDataSync() {
    ctx, cancel := context.WithTimeout(s.ctx, 2*time.Second)
    defer cancel()

    done := make(chan error, 1)
    go func() { done <- s.service.Sync(ctx) }()

    select {
    case err := <-done:
        s.Require().NoError(err)
    case <-ctx.Done():
        s.T().Fatalf("sync timed out: %v", ctx.Err())
    }
}

逻辑分析s.ctx 来自 suite.SetUpSuite() 初始化的根 context;context.WithTimeout 生成带截止时间的子 context;defer cancel() 防止 context 泄漏;channel 缓冲为 1 避免 goroutine 阻塞。

关键参数说明

参数 作用 推荐值
2*time.Second 业务合理超时阈值 ≤3×P95 响应时间
chan error, 1 非阻塞结果传递 避免 goroutine 挂起
graph TD
    A[启动测试] --> B[SetupTest]
    B --> C[WithTimeout 创建 ctx]
    C --> D[启动异步任务]
    D --> E{完成 or 超时?}
    E -->|完成| F[校验结果]
    E -->|超时| G[Fatalf 并终止]

3.3 替代方案实战:Ginkgo BeforeSuite预热+channel同步+t.Parallel()安全边界验证

数据同步机制

使用 sync.WaitGroup + chan struct{} 双保险确保预热完成后再启动并行测试:

var wg sync.WaitGroup
ready := make(chan struct{})

BeforeSuite(func() {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // 模拟DB连接池预热、缓存加载等耗时操作
        time.Sleep(500 * time.Millisecond)
        close(ready) // 通知就绪
    }()
    wg.Wait() // 阻塞至预热goroutine结束
})

逻辑分析:wg.Wait() 保证 BeforeSuite 主协程不提前退出;close(ready) 作为轻量信号,避免 channel 泄漏。t.Parallel()It 中调用时,仅当 ready 已关闭才安全执行。

并行安全校验策略

校验项 方式 触发时机
预热完成 <-ready 阻塞等待 BeforeEach 开头
并发资源隔离 t.Parallel() + 原子变量 It 内部
状态一致性 runtime.NumGoroutine() 断言 AfterEach

执行时序保障

graph TD
    A[BeforeSuite] --> B[启动预热goroutine]
    B --> C[WaitGroup.Wait]
    C --> D[ready closed]
    D --> E[BeforeEach: <-ready]
    E --> F[It: t.Parallel]

第四章:硬指标三:所有test必须可重复执行且无状态污染——全局变量、单例、临时文件的清除契约

4.1 Go test生命周期中init()与TestMain的执行顺序陷阱与重置时机图解

Go 测试启动时,init()TestMainTestXxx 的执行顺序极易引发隐式状态污染。

执行时序关键点

  • 全局 init() 函数在包加载时执行(仅一次,早于 TestMain
  • TestMain(m *testing.M)init() 后、所有 TestXxx 前执行
  • m.Run() 内部才真正调度各测试函数,但不会重置包级变量
// 示例:易被忽略的状态残留
var counter int

func init() { counter = 10 } // ✅ 首次测试前执行

func TestMain(m *testing.M) {
    fmt.Println("TestMain start, counter =", counter) // 输出: 10
    code := m.Run()
    fmt.Println("TestMain end, counter =", counter)   // 若测试中修改了 counter,此处可见残留
    os.Exit(code)
}

func TestA(t *testing.T) { counter++ } // 修改 counter
func TestB(t *testing.T) { t.Log(counter) } // 输出 11 —— 状态已污染!

逻辑分析counter 是包级变量,init() 初始化后,TestA 修改其值;因 Go test 默认复用同一进程运行所有测试,counter 不会自动重置。TestB 读取的是 TestA 留下的脏值。

执行顺序图解(mermaid)

graph TD
    A[init()] --> B[TestMain]
    B --> C[m.Run()]
    C --> D[TestA]
    C --> E[TestB]
    D --> F[共享包变量状态]
    E --> F

安全实践建议

  • 避免在 init() 中初始化可变全局状态
  • TestMaindefer func(){ ... }() 清理,或使用 t.Cleanup()
  • 单元测试应具备幂等性,不依赖前序测试副作用

4.2 数据库/Redis mock隔离:wire注入+testcontainer启动+defer cleanup三段式实践

在集成测试中,真实依赖易导致环境污染与非确定性。我们采用三段式隔离策略:

  • Wire 依赖注入:编译期解耦,避免 new() 硬编码
  • Testcontainer 启动:运行轻量级 PostgreSQL/Redis 容器,保障环境一致性
  • Defer cleanupdefer container.Terminate() 确保资源释放
func TestUserService_Create(t *testing.T) {
    // 启动 Redis 容器(自动拉取镜像、暴露端口)
    ctx := context.Background()
    redisCt, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "redis:7-alpine",
            ExposedPorts: []string{"6379/tcp"},
        },
        Started: true,
    })
    require.NoError(t, err)
    defer redisCt.Terminate(ctx) // ✅ 延迟清理,无论测试成功或 panic 均执行

    // 获取动态映射端口并构造连接 URL
    port, _ := redisCt.MappedPort(ctx, "6379")
    redisURL := fmt.Sprintf("redis://localhost:%s/0", port.Port())

    // Wire 构建带真实 Redis 的 UserService 实例
    app := wireApp(redisURL) // wireApp 由 wire.Build 自动生成
    // ... 执行业务断言
}

逻辑分析testcontainers.GenericContainer 启动容器后返回可管理句柄;MappedPort 获取宿主机映射端口(避免硬编码 6379);defer Terminate() 在函数退出时销毁容器,防止残留。Wire 则将 redisURL 注入 RedisClient 构造链,实现测试与生产一致的初始化路径。

阶段 关键能力 风险规避点
Wire 注入 编译期依赖图校验 运行时 nil panic
Testcontainer 跨平台 Dockerized 环境 本地 Redis 版本不一致
Defer cleanup 函数作用域生命周期绑定 忘记 Close() 导致泄漏
graph TD
    A[测试函数开始] --> B[启动 Redis 容器]
    B --> C[获取映射端口]
    C --> D[Wire 注入 URL 构建服务]
    D --> E[执行业务逻辑]
    E --> F[defer Terminate 容器]

4.3 文件系统污染防控:t.TempDir()统一根目录 + filepath.Walk清除残留 + .gitignore自动化校验

测试中临时文件若散落于项目各处,极易污染工作区、干扰 Git 跟踪或引发 CI 偶发失败。根本解法在于隔离、清理、校验三重闭环。

统一临时根目录:t.TempDir() 的确定性优势

func TestProcessing(t *testing.T) {
    root := t.TempDir() // 自动注册 cleanup,路径唯一且隔离
    dataDir := filepath.Join(root, "input")
    os.MkdirAll(dataDir, 0755)
}

t.TempDir() 为每个测试生成独立、自动清理的临时根目录(如 /tmp/TestProcessing123abc),避免跨测试污染;其生命周期与 *testing.T 绑定,无需手动 os.RemoveAll

残留扫描:filepath.Walk 驱动的深度清理

err := filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
    if err != nil { return err }
    if info.IsDir() && strings.HasSuffix(info.Name(), "_test") {
        return os.RemoveAll(path) // 清理命名特征残留
    }
    return nil
})

filepath.Walk 递归遍历根下所有路径,结合业务规则(如后缀 _test)精准识别并清除未被 t.TempDir() 自动回收的嵌套残留目录。

自动化校验:.gitignore 与测试联动

检查项 工具 触发时机
临时目录是否被忽略 git check-ignore CI 测试前
.gitignore 是否覆盖 **/test-* grep -q "test-" .gitignore PR 提交时
graph TD
    A[测试启动] --> B[t.TempDir() 创建隔离根]
    B --> C[执行业务逻辑]
    C --> D[filepath.Walk 扫描残留]
    D --> E[CI 校验 .gitignore 覆盖率]
    E --> F[阻断未忽略路径的提交]

4.4 并发测试状态污染复现与修复:sync.Once误用、global logger reset、net/http/httptest.Server泄漏检测

常见污染源分析

  • sync.Once 在测试中被多个 TestXxx 共享,导致初始化逻辑仅执行一次,掩盖并发竞态;
  • 全局 logger(如 log.SetOutput)被单个测试修改,影响后续测试日志行为;
  • httptest.NewUnstartedServer 启动后未调用 Close()CloseClientConnections(),造成端口/连接泄漏。

复现场景代码示例

var once sync.Once
var globalDB *sql.DB

func initDB() {
    once.Do(func() {
        globalDB, _ = sql.Open("sqlite3", ":memory:") // ❌ 测试间共享
    })
}

该代码在 TestATestB 中均调用 initDB(),但 once.Do 仅执行一次——若 TestA 修改了 globalDB 的配置(如 SetMaxOpenConns),TestB 将继承污染状态。修复方式:改用 t.Cleanup() 管理每个测试的独立资源。

泄漏检测建议

检测项 推荐工具 触发条件
HTTP server 未关闭 go test -race + 自定义 defer srv := httptest.NewServer(...); defer srv.Close() 缺失
Logger 全局覆盖 测试前/后快照 log.Writer() log.SetOutput(ioutil.Discard) 未恢复
graph TD
    A[启动测试] --> B{是否新建 httptest.Server?}
    B -->|是| C[注册 t.Cleanup(srv.Close)]
    B -->|否| D[跳过]
    C --> E[执行测试逻辑]
    E --> F[自动清理资源]

第五章:结语:从“能跑通”到“可交付”的测试心智升级

测试价值的临界点在哪里?

某金融SaaS团队曾用3周时间将核心交易链路的单元测试覆盖率从42%提升至89%,但上线后仍因一个未覆盖的BigDecimal精度截断逻辑导致批量退款失败。根本原因并非覆盖率不足,而是测试用例全部基于new BigDecimal("100.00")构造,却从未验证new BigDecimal("100.0000000001")在四舍五入策略下的边界行为。这揭示了一个关键事实:能跑通 ≠ 可交付——当测试仅服务于“让CI不红”,而非“让业务不崩”,技术债就会以生产事故的形式复利增长。

从PR检查清单到质量契约

我们推动某电商中台团队重构测试准入机制,将原先的“必须有单元测试”升级为结构化质量契约:

维度 “能跑通”实践 “可交付”契约要求
数据覆盖 覆盖主流程正向路径 必须包含3类异常数据:空值、超长字符串、时区错位时间戳
环境模拟 使用内存H2数据库 必须通过Docker Compose启动真实MySQL 5.7+实例并验证事务回滚
性能基线 单测执行时间 关键接口压测(JMeter 200并发)P95响应≤350ms且无连接泄漏

该契约写入GitLab MR模板,未达标PR自动拒绝合并,6个月内线上P0故障下降73%。

测试资产即产品文档

在迁移支付网关至云原生架构时,团队不再编写独立的API文档,而是将契约化测试用例直接生成交互式文档:

@Test
@ContractScenario(
  title = "跨境支付汇率锁定失败时自动降级为本币结算",
  businessImpact = "避免因汇率服务不可用导致订单流失"
)
void test_exchange_rate_fallback() {
  // 模拟汇率服务HTTP 503
  wiremock.stubFor(get(urlEqualTo("/v1/rates/USD-CNY"))
    .willReturn(aResponse().withStatus(503)));

  // 验证降级逻辑触发且日志含"fallback_to_cny"
  assertThat(paymentService.process(orderUSD)).hasFieldOrProperty("currency").isEqualTo("CNY");
}

质量门禁的演进路线图

flowchart LR
A[CI阶段:编译+基础单元测试] --> B[CD预检:契约测试+DB Schema校验]
B --> C[灰度发布:流量镜像比对+业务指标监控]
C --> D[全量发布:自动回滚触发条件<br/>• 支付成功率<99.5%持续2分钟<br/>• 异常日志突增300%]

某物流平台实施此门禁后,新运单路由算法上线首日即捕获因时区转换导致的15%运单延迟问题,自动回滚耗时47秒。

工程师的认知折叠

当测试工程师开始追问“这个断言保护了哪条业务SLA?”、开发工程师在写@Test前先画出状态转换图、产品经理参与评审测试场景卡片时,组织就完成了心智折叠——测试不再是下游环节的补救动作,而是需求拆解时的天然组成部分。某医疗IoT项目要求所有设备通信协议测试必须使用真实硬件模组(非Mock),迫使开发在设计阶段就考虑弱网重传、电量阈值等物理约束,最终FDA认证一次性通过。

这种折叠不是靠流程强制,而是当每个commit都携带可验证的业务承诺时,交付质量便成为代码的自然属性。

记录 Golang 学习修行之路,每一步都算数。

发表回复

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