Posted in

Go开发库CI/CD集成盲点:GitHub Actions中go install -v ./… 为何总失败?5个被忽略的模块缓存、GOBIN冲突、vendor校验绕过问题(附YAML修复模板)

第一章:Go开发库CI/CD集成盲点全景透视

Go生态中,开发者常将go testgo build简单封装进CI脚本即视为完成集成,却忽视了语言特性和工程实践间的深层断层。这些盲点不暴露于构建成功与否,而潜伏在可重复性、依赖一致性、模块兼容性与可观测性等维度,最终导致测试通过但生产环境行为异常、跨团队协作阻塞或安全漏洞逃逸。

模块校验缺失引发的隐性漂移

启用 Go Modules 后,go.sum 文件应作为可信依赖指纹被严格校验。CI中若仅执行 go build 而跳过 go mod verify,将无法捕获依赖篡改或代理污染风险。正确做法是在构建前强制验证:

# 验证模块完整性,失败时立即中断流水线
go mod verify
if [ $? -ne 0 ]; then
  echo "❌ go.sum mismatch detected — aborting CI"
  exit 1
fi

测试覆盖率统计失真

go test -cover 默认仅覆盖当前包,忽略嵌套子包与internal/目录。真实覆盖率需递归扫描并合并报告:

# 生成全项目覆盖率(含 internal 和子模块)
go test -covermode=count -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | grep "total"  # 查看汇总值

构建环境不可控导致二进制差异

Go 的 CGO_ENABLEDGOOS/GOARCHGOCACHE 状态未显式锁定时,同一代码在不同CI节点产出的二进制可能哈希不一致。应在CI步骤中明确声明:

环境变量 推荐值 作用
CGO_ENABLED 确保纯静态链接,规避libc差异
GOCACHE /tmp/go-cache 隔离缓存,避免跨作业污染
GO111MODULE on 强制启用模块模式

静态分析工具链割裂

golint 已归档,但许多CI仍沿用旧版;staticcheckgovet 规则未对齐,导致关键问题(如错误忽略、竞态隐患)漏检。建议统一使用 golangci-lint 并启用严格配置:

# .golangci.yml 片段(CI中加载)
linters-settings:
  staticcheck:
    checks: ["all", "-SA1019"]  # 启用全部检查,禁用过时API警告误报

这些盲点并非技术难点,而是工程规范断层——它们不会让CI红灯亮起,却持续侵蚀交付质量的根基。

第二章:go install -v ./… 失败的五大根源剖析与实证复现

2.1 模块缓存污染:GOPATH/GOPROXY混用导致依赖解析错乱(含cache clean+verify对比实验)

当 GOPATH 模式与 GOPROXY 同时启用时,Go 工具链可能在 $GOCACHE 中混存 vendor/ 路径解析结果与 proxy 下载的 module zip,引发 go list -m all 输出不一致。

复现场景

  • GO111MODULE=on + GOPATH=/legacy + GOPROXY=https://proxy.golang.org
  • 执行 go build 后,缓存中同时存在:
    • pkg/mod/cache/download/github.com/foo/bar/@v/v1.2.3.zip
    • pkg/mod/cache/download/github.com/foo/bar/@v/v1.2.3.ziphash

清理与验证对比

命令 清除范围 是否重置 checksum 验证
go clean -cache $GOCACHE 全量
go clean -modcache pkg/mod 及关联 download 缓存 ✅(触发重新 fetch + verify)
# 强制重建模块校验(含哈希重算)
go clean -modcache && \
  GOSUMDB=off go mod download -x  # -x 显示实际 fetch 路径

此命令组合会绕过 sumdb 校验并显式输出每个 module 的下载源(如 https://proxy.golang.org/... 或本地 file:///...),暴露 GOPROXY 是否被 GOPATH 中 vendor 覆盖。

缓存污染路径示意

graph TD
  A[go build] --> B{GO111MODULE=on?}
  B -->|Yes| C[解析 go.mod]
  C --> D[查 GOPROXY]
  C --> E[ fallback to GOPATH/src?]
  D --> F[写入 pkg/mod/cache/download/...zip]
  E --> G[写入 $GOPATH/pkg/mod/cache/download/...zip]
  F & G --> H[哈希冲突 → verify 失败]

2.2 GOBIN路径冲突:多版本Go并存下二进制覆盖与PATH优先级陷阱(附GOBIN隔离验证脚本)

当系统中同时安装 Go 1.21 和 Go 1.22,并分别设置 GOBIN=/usr/local/go121/binGOBIN=/usr/local/go122/bin,若未显式隔离 PATHgo install 生成的二进制会按 PATH 中首个匹配目录写入——而非当前 GOROOT 对应的 GOBIN

PATH 优先级决定实际落盘位置

  • PATH 中靠前的 GOBIN 目录将接收所有 go install 输出
  • GOROOTGOBIN 无强制绑定关系,仅由环境变量独立控制

验证脚本核心逻辑

# 隔离验证:临时切换GOBIN并检查bin文件归属
export GOROOT="/usr/local/go1.22"  
export GOBIN="/tmp/go122-bin"  
export PATH="/tmp/go122-bin:$PATH"  
go install golang.org/x/tools/cmd/goimports@latest  
ls -l "$GOBIN/goimports"  # 确认文件确实写入预期路径

该脚本通过动态重置 GOBIN + PATH 前置,强制 go install 落盘到指定沙箱目录;若省略 PATH 更新,系统仍可能调用旧版 goimports(因旧 GOBINPATH 中更靠前)。

变量 作用 风险点
GOBIN 指定 go install 输出目录 单一值,无法自动适配多版本
PATH 决定可执行文件搜索顺序 优先级高于 GOBIN 实际落盘位置
graph TD
    A[go install cmd] --> B{GOBIN exists?}
    B -->|Yes| C[Write to $GOBIN]
    B -->|No| D[Write to $GOROOT/bin]
    C --> E[But: PATH lookup uses FIRST match]
    E --> F[可能执行旧版二进制]

2.3 vendor目录校验绕过失效:GO111MODULE=on时vendor未被尊重的底层机制与go mod vendor精准触发条件

GO111MODULE=on 时,Go 工具链默认忽略 vendor/ 目录,除非显式启用 -mod=vendor。其底层逻辑在于 loadPackage 阶段对 modLoadModFile 的调用路径中,vendorEnabled 标志仅在 modFlag == modVendor 时置为 true

vendor 启用判定关键条件

  • GO111MODULE=on(强制模块模式)
  • 命令行显式指定 -mod=vendor
  • vendor/modules.txt 存在且格式合法(非空、含有效 checksum)
# 正确触发 vendor 模式
go build -mod=vendor
go test -mod=vendor ./...

⚠️ 注意:GOFLAGS="-mod=vendor" 无效——该标志不被 go build 解析,仅 go env -w GOFLAGS=... 设置后部分子命令生效,但 go mod vendor 本身不受影响。

go mod vendor 精准触发条件表

条件 是否必需 说明
go.mod 存在 模块根目录必须有合法 go.mod
GOPATH 外执行 若在 $GOPATH/src 内,可能降级为 GOPATH 模式
无未提交 Git 更改 ❌(推荐) 否则 modules.txt checksum 可能失准
// vendor/modules.txt 示例片段(go mod vendor 自动生成)
# github.com/gorilla/mux v1.8.0 h1:123...abc
github.com/gorilla/mux v1.8.0 h1:KQ+VzZqF9yXqL7aJfYtBkDcWzTn5pQrRqZ6vP7xYzU=

modules.txt 是 vendor 的权威清单,go build -mod=vendor 严格校验其哈希;缺失或篡改将导致 loading module requirements 错误。

graph TD A[go build] –> B{modFlag == modVendor?} B –>|Yes| C[Read vendor/modules.txt] B –>|No| D[Fetch from proxy/cache] C –> E[Verify checksums] E –>|Match| F[Use vendor/] E –>|Mismatch| G[Fail with ‘checksum mismatch’]

2.4 主模块路径歧义:非标准项目根目录(如子模块嵌套、workspace多模块)引发的./…匹配越界问题(含go list -m all诊断实践)

当项目采用 go work 多模块工作区或深层嵌套子模块(如 ./services/auth 下独立 go.mod),go build ./... 会递归扫描所有子目录,包括非当前主模块的 vendor/testdata/ 或其他 workspace 成员模块,导致构建污染或版本冲突。

诊断先行:识别真实模块边界

# 列出当前 workspace 所有激活模块及其路径
go list -m all | grep -v '^\.'

输出示例:
example.com/app v0.1.0
example.com/lib v0.3.2
example.com/services/auth v0.2.0
该命令仅返回 go.mod 显式声明的模块(含 replace),排除未纳入管理的路径,是判断“哪些路径应被 ./... 包含”的黄金基准。

安全替代方案对比

方案 范围控制 适用场景 风险
go build ./... 全递归(含无关子模块) 单模块扁平结构 ✅ 高风险越界
go build $(go list -f '{{.Dir}}' ./...) 仅当前模块内 go list 可见路径 workspace 中精准构建主模块 ✅ 推荐
go build ./cmd/... 显式限定子目录 微服务入口明确时 ⚠️ 需手动维护

根本规避:显式路径裁剪

# 仅构建当前模块下非 vendor/testdata 的源码树
find . -name 'go.mod' -prune -o -path './vendor/*' -prune -o -path './testdata/*' -prune -o -name '*.go' -print0 \
  | xargs -0 dirname | sort -u | xargs go build

此命令通过 find 排除 vendortestdata,再用 dirname 提取唯一包路径,彻底规避跨模块污染。-prune 确保不进入被排除目录的子树,逻辑严谨性远超通配符匹配。

2.5 构建约束(build tags)与环境变量耦合:CI中CGO_ENABLED、GOOS/GOARCH隐式缺失导致install静默跳过或失败(带tag覆盖率检测YAML片段)

在 CI 环境中,go install 常因缺失显式构建约束而静默跳过含 // +build cgo//go:build cgo 的包——尤其当 CGO_ENABLED=0(默认值)且未设 GOOS/GOARCH 时,Go 工具链会直接忽略该文件,不报错、不警告。

静默失效的典型路径

# .github/workflows/build.yml
- name: Install with build tags
  run: go install -tags "sqlite" ./cmd/app
  env:
    # ❌ 缺失 CGO_ENABLED=1 → sqlite binding 被跳过
    # ❌ GOOS/GOARCH 未设 → 交叉编译目标模糊

关键环境变量影响表

变量 默认值 影响 必须显式设置场景
CGO_ENABLED (CI 中常见) 禁用 cgo → 所有 cgo tag 包被忽略 使用 SQLite、libgit2、OpenSSL 等原生绑定
GOOS / GOARCH 主机平台 go install 推导目标平台 → 多平台构建失败 跨平台发布、Docker 构建

tag 覆盖率检测逻辑(CI 安全网)

# 检测当前环境是否覆盖所需 build tag
go list -f '{{.ImportPath}} {{.BuildTags}}' ./... | \
  grep -q 'sqlite' || echo "⚠️  sqlite tag not satisfied under CGO_ENABLED=$CGO_ENABLED"

✅ 正确实践:始终在 CI step 中显式声明 CGO_ENABLED=1GOOS=linux GOARCH=amd64,并用 go list -tags 验证 tag 解析结果。

第三章:GitHub Actions运行时Go环境深度治理

3.1 actions/setup-go行为解构:version、check-latest、cache参数对模块缓存与toolchain一致性的真实影响

参数协同机制

version 指定 Go 版本语义(如 1.22, 1.22.x, 1.22.5),直接影响 toolchain 二进制下载与 $GOROOT 初始化;check-latest: true 会绕过本地缓存校验,强制查询 GitHub Releases 获取最新 patch 版本(如将 1.22 解析为 1.22.6),破坏可重现性cache: true 启用模块依赖缓存(基于 go mod download 输出哈希),但仅作用于 $GOMODCACHE,与 Go toolchain 缓存正交。

缓存分层影响对比

参数组合 Toolchain 可重现性 模块下载复用率 是否触发网络重解析
version: '1.22' ❌(隐式漂移) ✅(check-latest 默认 true)
version: '1.22.5', check-latest: false
- uses: actions/setup-go@v4
  with:
    version: '1.22.5'
    check-latest: false  # 关键:禁用自动升级,锁定 toolchain
    cache: true          # 启用 go.sum/gomodcache 哈希缓存

此配置确保 GOROOTGOMODCACHE 双层锁定:version 精确控制编译器,cache 复用模块字节码,check-latest: false 阻断语义化版本的 runtime 解析跃迁。

3.2 runner环境隔离边界:Docker容器层、job workspace、home目录三重缓存域的协同与冲突

CI/CD中,runner通过三重缓存域实现资源隔离与复用:Docker容器层(只读镜像层+可写容器层)、job workspace(每次job独占的临时工作目录)、以及runner user的$HOME(持久化但跨job共享)。

数据同步机制

GitLab Runner默认不自动同步$HOME与workspace间的数据。例如:

# .gitlab-ci.yml 片段
before_script:
  - echo "cache_key=$(date +%s)" > ~/.build-meta  # 写入HOME
  - cat ~/.build-meta | tee $CI_PROJECT_DIR/meta.log  # 显式复制到workspace

此操作显式桥接HOME与workspace边界;若省略,则~/.build-meta对后续job不可见——因workspace在job结束时被清理,而HOME保留但不被自动挂载进新容器。

缓存域优先级与冲突场景

域类型 生命周期 可写性 共享粒度 冲突典型表现
Docker容器层 job启动时创建 容器层可写 单job内全局 多job并发写同一路径导致覆盖
Job workspace job开始→结束 全写入 仅当前job可见 cache:策略未配置时丢失构建产物
$HOME runner进程级 持久可写 所有job共享(同user) 私钥/配置残留引发权限或行为漂移

隔离边界协同模型

graph TD
  A[Docker容器层] -->|overlayfs mount| B[Job Workspace]
  C[$HOME] -->|bind mount -ro| A
  B -->|explicit cp/mv| C

bind mount -ro确保HOME只读挂载进容器,避免意外污染;所有跨域数据流动必须显式触发,杜绝隐式依赖。

3.3 Go toolchain可重现性保障:go version + go env + go list -m all三位一体基线快照策略

构建可重现的Go构建环境,核心在于捕获工具链版本、构建上下文与依赖拓扑三要素。

三位一体快照生成命令

# 一次性采集完整基线快照
{
  echo "=== go version ==="; go version;
  echo -e "\n=== go env ==="; go env GOOS GOARCH GOCACHE GOPATH GOROOT GOPROXY;
  echo -e "\n=== module dependencies ==="; go list -m -f '{{.Path}} {{.Version}} {{.Replace}}' all;
} > baseline.snapshot

该命令按序输出:运行时Go版本(含编译器标识)、关键环境变量(排除敏感路径,聚焦可复现维度)、以及每个module的路径、语义化版本与replace重定向——-f模板确保结构化输出,避免go list -m all默认格式中隐含的本地路径干扰。

快照要素对比表

维度 命令 捕获内容 是否影响构建结果
工具链一致性 go version Go编译器版本与构建主机标识 ✅ 关键(影响内联、逃逸分析等)
构建上下文 go env GOOS/GARCH/GOPROXY 目标平台与代理策略 ✅ 决定交叉编译与模块解析路径
依赖精确性 go list -m all lockfile级module版本+replace映射 ✅ 唯一确定依赖图谱

验证流程

graph TD
  A[采集baseline.snapshot] --> B[CI中执行go version校验]
  B --> C[比对GOOS/GOARCH/GOPROXY]
  C --> D[用go mod download -json验证module存在性]
  D --> E[diff -u baseline.snapshot <new>]

第四章:健壮型Go CI流水线YAML工程化设计

4.1 分阶段缓存策略:go mod download缓存键设计(含GOSUMDB、checksum校验开关联动)

Go 模块下载缓存键并非仅依赖模块路径与版本,而是分三阶段构造:

缓存键生成逻辑

  • 阶段一:基础标识 → module@version
  • 阶段二:校验上下文 → 是否启用 GOSUMDB=off 或自定义 sum.golang.org 替换
  • 阶段三:校验结果固化 → go.sum 中该模块对应 checksum 行哈希(非内容哈希,而是行字符串规范化后 SHA256)

校验开关联动示意

# GOSUMDB=off 时跳过远程校验,但缓存键仍含 "sumdb:off" 后缀以隔离缓存
GOSUMDB=off go mod download golang.org/x/net@v0.23.0

此命令生成缓存键为 golang.org/x/net@v0.23.0-sumdb:off,避免与启用校验的缓存混淆。若后续开启 GOSUMDB,则触发重新校验并写入新键。

缓存键结构对照表

维度 启用 GOSUMDB(默认) GOSUMDB=off
缓存键后缀 -sum:sha256:abc123... -sumdb:off
校验时机 下载后立即比对 go.sum 跳过 checksum 验证
缓存复用性 严格绑定校验结果 独立缓存域,不可混用
graph TD
    A[go mod download] --> B{GOSUMDB set?}
    B -->|yes| C[Fetch sum from GOSUMDB]
    B -->|no| D[Skip checksum check]
    C --> E[Compute line-hash of go.sum entry]
    D --> F[Use 'sumdb:off' as cache suffix]
    E & F --> G[Write to $GOCACHE/download/...]

4.2 install指令安全替代方案:go build -o ${GOBIN}/xxx ./cmd/… + 符号链接管理(规避./…语义风险)

go install 的隐式模块解析与 ./... 通配易引入非预期包(如测试文件、内部工具),导致二进制污染或构建失败。

安全构建范式

# 显式限定 cmd/ 下主程序,避免递归扫描
go build -o "${GOBIN}/myapp" ./cmd/myapp
  • -o 指定输出路径,强制写入 $GOBIN(需已配置)
  • ./cmd/myapp 精确指向入口包,杜绝 ./... 的语义漂移风险

符号链接统一管理

场景 命令 说明
首次部署 ln -sf myapp /usr/local/bin/myapp 绑定可执行文件到 PATH
版本切换 ln -sf myapp-v1.2.0 /usr/local/bin/myapp 无停机更新

构建流程示意

graph TD
    A[显式指定 cmd/ 子目录] --> B[go build -o]
    B --> C[输出至 GOBIN]
    C --> D[符号链接暴露 CLI]

4.3 vendor完整性守护:go mod verify + diff -u vendor/modules.txt

Go 项目启用 vendor/ 后,依赖一致性面临双重风险:vendor/ 目录可能被手动篡改,而 go.modvendor/modules.txt 可能不同步。

校验原理双轨并行

  • go mod verify:校验所有模块的 checksum 是否匹配 go.sum
  • diff -u vendor/modules.txt <(go list -m -mod=readonly all):比对 vendor 快照与当前模块图快照
# 完整校验命令(含错误提示)
diff -u \
  <(sort vendor/modules.txt) \
  <(go list -m -mod=readonly all | sort) \
  || echo "❌ vendor/modules.txt 与模块图不一致!"

sort 确保行序一致;-mod=readonly 禁止修改 go.mod,仅读取当前解析结果;<( … ) 是进程替换,避免临时文件。

常见不一致场景

场景 原因 修复方式
新增依赖未 go mod vendor go.mod 已更新但 vendor 未同步 运行 go mod vendor
手动删除 vendor/ 中某包 vendor/modules.txt 仍记录该模块 清理后重执行 go mod vendor
graph TD
  A[执行校验] --> B{go mod verify 成功?}
  B -->|否| C[checksum 不匹配 → 检查 go.sum 或源码篡改]
  B -->|是| D{diff 输出为空?}
  D -->|否| E[vendor/modules.txt 与实际模块图偏差 → 需同步 vendor]
  D -->|是| F[✅ vendor 完整性通过]

4.4 多平台交叉构建防护:GOOS/GOARCH显式声明 + 构建矩阵(matrix)中build tags动态注入机制

在 CI/CD 流水线中,仅依赖 GOOS/GOARCH 环境变量易导致构建产物错位。需显式声明并协同 build tags 实现精准裁剪。

显式构建指令示例

# 在 GitHub Actions matrix 中动态注入
go build -o bin/app-linux-amd64 -ldflags="-s -w" \
  -tags "linux,amd64,prod" \
  -gcflags="-trimpath=${PWD}" \
  .

GOOS=linux GOARCH=amd64 需与 -tagslinux,amd64 语义对齐;prod tag 控制条件编译逻辑(如跳过调试日志)。

构建矩阵与 tags 映射关系

GOOS GOARCH 注入 tags
linux amd64 linux,amd64,ci
windows arm64 windows,arm64,ci
darwin arm64 darwin,arm64,ci

构建流程控制逻辑

graph TD
  A[Matrix job starts] --> B{Read GOOS/GOARCH}
  B --> C[Inject matching build tags]
  C --> D[go build -tags ...]
  D --> E[产出平台专属二进制]
  • 所有 //go:build 指令须与 tags 严格匹配,否则文件被忽略
  • tags 注入必须早于 go build 执行,避免隐式 fallback

第五章:从修复模板到SRE级Go库交付标准

模板修复的起点:一个真实告警事件

2023年Q4,某支付网关核心服务因http.Client超时未显式配置,导致下游依赖抖动时出现级联雪崩。SRE团队回溯发现,问题根源是内部Go SDK模板中DefaultClient被直接复用,且无熔断与重试机制。我们立即冻结所有新服务模板发布,并启动模板治理专项。

标准化交付清单

每个SRE级Go库必须包含以下交付物(强制检查项):

项目 要求 验证方式
go.mod 最小版本 go 1.21 或更高 go version -m
健康检查端点 /healthz 返回 200 OK + JSON结构体 curl + HTTP status code
指标暴露 至少暴露 http_request_duration_secondshttp_requests_total Prometheus target discovery
上下文传播 所有HTTP调用必须携带 context.WithTimeout 静态扫描(gosec规则 G109

自动化流水线强制门禁

CI阶段集成三项不可绕过的检查:

  • make verify:运行 gofmt -s -w .go vet ./...staticcheck --checks=all ./...
  • make test-cover:单元测试覆盖率 ≥85%,且关键路径(如错误处理分支)100%覆盖
  • make security-scan:调用 trivy fs --security-check vuln,config,secret .
// 示例:SRE级客户端构造器(已内置于组织模板)
func NewPaymentClient(baseURL string, timeout time.Duration) *PaymentClient {
    return &PaymentClient{
        client: &http.Client{
            Timeout: timeout,
            Transport: &http.Transport{
                IdleConnTimeout: 30 * time.Second,
                TLSHandshakeTimeout: 10 * time.Second,
            },
        },
        baseURL: baseURL,
        metrics: newClientMetrics(), // 自动注册Prometheus指标
    }
}

可观测性嵌入规范

所有库必须预置结构化日志与指标采集点。例如,在HTTP请求拦截器中注入request_idspan_id,并通过OpenTelemetry SDK自动上报至Jaeger+Prometheus统一平台。日志字段遵循JSON Schema:{"level":"info","ts":"2024-06-15T08:22:11.123Z","service":"payment-sdk","op":"doRequest","status":"success","duration_ms":12.4,"http_code":200}

SLO驱动的版本生命周期管理

主干分支(main)仅接受语义化版本发布:v1.2.0 表示向后兼容的功能增强;v1.3.0 若引入Context参数变更,则必须同步更新CHANGELOG.md中的SLO影响评估——例如:“新增WithDeadline选项,将P99延迟从120ms降至85ms,不影响可用性SLO(99.95%)”。

生产环境灰度验证流程

新版本SDK在发布前需通过三级灰度验证:

  1. 内部工具链(如CLI、监控脚本)先行升级并运行72小时
  2. 选取3个低流量业务服务部署,持续观测错误率与延迟毛刺(阈值:错误率增幅 ≤0.01%,P99延迟增幅 ≤5ms)
  3. 全量推送前执行混沌工程注入:随机延迟500ms、模拟DNS解析失败,验证熔断器是否在2秒内生效

模板即代码(Template-as-Code)演进

我们将原始go mod init模板重构为基于gomod+ytt的声明式模板引擎,支持动态注入组织合规配置(如默认CA证书路径、审计日志开关)。每次git push触发template-validator服务校验YAML Schema,并生成对应go.sum哈希白名单。

运维反哺开发的闭环机制

SRE团队每周提取生产环境Top 5 Go相关告警模式(如net/http: request canceled高频出现),反向推动模板升级:2024年Q2已将context.WithCancel自动注入逻辑集成至go generate指令,开发者只需标注//go:generate contextify即可生成带上下文取消链路的HTTP handler。

合规性审计自动化

每季度执行一次全量Go库合规扫描,使用自研go-audit工具比对CNCF安全白皮书v2.1条款,输出PDF报告并自动创建Jira整改任务。最近一次扫描覆盖147个内部库,共修复23处unsafe包误用及11个硬编码密钥问题。

用实验精神探索 Go 语言边界,分享压测与优化心得。

发表回复

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