Posted in

Golang岗位“云原生适配度”测评(免费版):输入你的项目架构图,输出云迁移风险评分与改造成本预估

第一章:Golang岗位“云原生适配度”测评方法论总览

云原生技术栈的快速演进正重塑Golang工程师的能力边界——Kubernetes编排能力、服务网格理解深度、声明式API设计直觉,已不再是“加分项”,而是岗位胜任力的核心标尺。本方法论摒弃传统语言语法熟练度单维评估,构建覆盖基础设施交互、可观测性集成、弹性架构实践三大维度的动态适配度模型。

测评维度定义

  • 基础设施协同力:考察对容器生命周期管理(如通过client-go编程式操作Pod/Deployment)、CRD开发与Operator模式落地能力;
  • 可观测性内建意识:评估是否能原生嵌入OpenTelemetry SDK,实现Span注入、指标暴露(如Prometheus GaugeVec注册)与日志结构化(zap字段化输出);
  • 弹性架构实践:验证对重试退避策略(backoff.Retry)、熔断器(gobreaker)及分布式追踪上下文传递(otel.GetTextMapPropagator().Inject())的实际编码能力。

核心测评工具链

工具类别 推荐方案 验证目标
云环境模拟 Kind + Helm + kubectl apply CRD安装、Operator部署验证
追踪能力验证 Jaeger All-in-One + OpenTelemetry SDK Span跨goroutine传播完整性
性能压测 Vegeta + Prometheus + Grafana 并发下熔断触发阈值准确性

快速验证示例

执行以下代码片段,检验基础云原生集成能力:

// 初始化OpenTelemetry TracerProvider(需提前启动Jaeger)
tp := oteltrace.NewTracerProvider(
    oteltrace.WithSampler(oteltrace.AlwaysSample()),
    oteltrace.WithSpanProcessor( // 将Span发送至Jaeger
        otlptracegrpc.NewClient(
            otlptracegrpc.WithEndpoint("localhost:4317"),
            otlptracegrpc.WithInsecure(),
        ),
    ),
)
otel.SetTracerProvider(tp)

// 在HTTP Handler中注入追踪上下文
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    span := trace.SpanFromContext(ctx) // 从传入请求提取父Span
    defer span.End() // 确保Span结束

    // 模拟下游调用并注入上下文
    client := &http.Client{}
    req, _ := http.NewRequestWithContext(
        otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier{}), // 注入TraceID
        "GET", "http://backend:8080/api", nil,
    )
    resp, _ := client.Do(req)
    defer resp.Body.Close()
}

该代码验证开发者是否掌握分布式追踪上下文透传这一云原生关键实践,缺失Inject调用将导致链路断裂——这是适配度失分的关键信号。

第二章:Go服务云原生架构关键评估维度解析

2.1 Go微服务治理能力与Service Mesh兼容性实测

Go原生微服务框架(如go-micro、Kit)通过插件化中间件支持熔断、限流、链路追踪,但需手动集成Sidecar通信逻辑。

数据同步机制

// 启用OpenTracing兼容的HTTP客户端拦截器
tr := opentracing.GlobalTracer()
http.DefaultClient = &http.Client{
    Transport: othttp.NewTransport(http.DefaultTransport, 
        othttp.WithTracer(tr), // 将Span注入HTTP Header
        othttp.WithFilter(func(r *http.Request) bool {
            return r.URL.Path != "/health" // 忽略健康检查路径
        }),
}

该配置使Go服务在不修改业务代码前提下,将traceID透传至Istio Envoy,验证了W3C Trace Context标准兼容性。

Mesh互通性验证结果

能力项 Go原生实现 Istio Sidecar接管 兼容状态
分布式追踪 ✅(Jaeger) ✅(B3/W3C) 完全互通
TLS双向认证 ⚠️(需自签) ✅(mTLS自动注入) 需CA对齐
流量镜像 依赖Mesh
graph TD
    A[Go服务] -->|HTTP/1.1 + B3 Headers| B[Envoy Proxy]
    B -->|mTLS + x-envoy-upstream-service-time| C[下游服务]
    C -->|OTLP上报| D[Jaeger Collector]

2.2 Go Runtime在容器化环境中的资源调度与GC行为建模

容器资源约束对GOMAXPROCS的影响

当容器设置 --cpus=1.5 时,Go 1.22+ 自动适配 GOMAXPROCS=min(ceil(cpus), numCPU),避免过度线程竞争:

// runtime/gc.go 中的自适应逻辑片段(简化)
func init() {
    if c := os.Getenv("GOMAXPROCS"); c != "" {
        // 用户显式设置优先级最高
        return
    }
    if cpus := getContainerCPULimit(); cpus > 0 {
        runtime.GOMAXPROCS(int(math.Ceil(cpus))) // 向上取整确保最小并发能力
    }
}

该逻辑确保在 1.5 核限制下 GOMAXPROCS=2,平衡调度粒度与上下文切换开销。

GC触发阈值的动态调整机制

Go Runtime 根据 cgroup memory limit 自动缩放 heapGoal

环境类型 初始GC触发比例 内存压力响应策略
无限制宿主机 100% 固定步进增长
2GB内存限制容器 75% 按剩余内存线性衰减至50%

GC暂停时间建模流程

graph TD
    A[读取cgroup/memory.max] --> B{是否<4GB?}
    B -->|是| C[启用soft heap goal]
    B -->|否| D[保持默认策略]
    C --> E[GC周期内监控pauseTarget=2ms]
    E --> F[根据上次STW偏差动态校准GC频率]

2.3 Go模块依赖图谱分析与不可变基础设施适配验证

依赖图谱可视化生成

使用 go mod graph 提取拓扑关系,结合 gomodgraph 工具生成可交互图谱:

go mod graph | \
  awk '{print $1 " -> " $2}' | \
  sed 's/ /\n/g' | \
  grep -v 'golang.org' | \
  head -20 > deps.dot

该命令过滤标准库依赖,截取前20条关键路径,输出为 Graphviz 兼容格式;$1 为上游模块,$2 为直接依赖项,grep -v 排除无关系统包以聚焦业务依赖链。

不可变镜像验证策略

验证流程需满足三项原子性约束:

  • 构建时锁定 go.sumgo.mod 哈希
  • 容器镜像仅含 /app 二进制与最小基础层(scratch
  • CI 中执行 go list -m all 与镜像内 ldd ./app 双向比对
验证项 工具 期望输出
模块完整性 go mod verify all modules verified
运行时符号绑定 objdump -T ./app 无外部 libc 动态引用

依赖收敛性检查

graph TD
  A[main.go] --> B[github.com/org/lib/v2]
  B --> C[github.com/other/util@v1.4.2]
  C --> D[golang.org/x/net@v0.25.0]
  D --> E[stdlib net/http]

图谱揭示跨版本间接依赖风险——util@v1.4.2 引入过时的 x/net,需通过 replace 或升级主依赖消解。

2.4 Go可观测性埋点完备性与OpenTelemetry原生集成实践

Go 应用的可观测性依赖于语义化、可组合、零侵入的埋点设计。OpenTelemetry Go SDK 提供了原生支持,无需适配层即可接入 Trace、Metrics、Logs 三类信号。

埋点完备性核心维度

  • ✅ 上下文透传(context.Context 集成)
  • ✅ 异步任务自动关联(goroutine 跨生命周期追踪)
  • ✅ HTTP/gRPC 中间件开箱即用
  • ❌ 手动 Span 创建遗漏(常见缺口)

OpenTelemetry 初始化示例

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer() {
    exporter, _ := otlptracehttp.New(context.Background())
    tp := trace.NewProvider(
        trace.WithBatcher(exporter),
        trace.WithResource(resource.MustNewSchema(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String("user-api"),
        )),
    )
    otel.SetTracerProvider(tp)
}

逻辑分析otlptracehttp.New 构建 OTLP over HTTP 导出器,默认连接 localhost:4318WithResource 注入服务元数据,确保链路归属清晰;WithBatcher 启用异步批处理,降低性能抖动。所有 Span 自动继承该 Provider,实现全局统一配置。

常见埋点覆盖对比

场景 是否默认支持 补充方式
HTTP Server otelhttp.NewHandler
Database Queries ⚠️ go-sql-driver/mysql + otelmysql 插件
Background Jobs 手动 Start(ctx) + End()
graph TD
    A[HTTP Handler] --> B[otelhttp.NewHandler]
    B --> C[自动注入Span]
    C --> D[Context传递至Service层]
    D --> E[DB调用拦截器]
    E --> F[otelmysql.Wrap]

2.5 Go并发模型(goroutine/channel)在Serverless弹性伸缩场景下的风险量化

goroutine泄漏加速冷启动雪崩

Serverless平台按需拉起实例,但未受控的goroutine可能持续驻留至实例销毁前,导致资源残留与后续请求延迟激增:

func handleRequest(ctx context.Context, req Request) {
    go func() { // ❌ 无ctx绑定、无done channel,易泄漏
        time.Sleep(10 * time.Second)
        log.Println("cleanup completed")
    }()
    return // 请求结束,goroutine仍在运行
}

该goroutine脱离请求生命周期管理,在函数实例被复用或回收前持续占用栈内存(默认2KB/个),单实例若累积超500个即触发OOM驱逐。

Channel阻塞引发横向扩缩失效

当channel缓冲区耗尽且无超时控制,goroutine将永久挂起,使实例无法响应健康检查,触发误扩容:

风险维度 无缓冲channel 缓冲容量=100 缓冲容量=1000
平均阻塞时长 ∞(死锁) 8.2s(P95) 0.3s(P95)
扩容误触发率 100% 47% 3%

弹性伸缩协同建议

  • 所有goroutine必须绑定ctx.WithTimeout()
  • channel操作须配合select+defaulttime.After()实现非阻塞兜底
  • 使用runtime.NumGoroutine()定期采样并告警(阈值>200)

第三章:云迁移风险评分模型构建原理

3.1 基于AST解析的Go代码云就绪度静态扫描算法设计

云就绪度评估需深入语义层,而非仅依赖正则匹配。我们构建轻量级AST遍历器,聚焦三类关键节点:*ast.CallExpr(外部依赖调用)、*ast.AssignStmt(配置赋值)、*ast.File(包声明与导入)。

核心扫描逻辑

func (s *Scanner) Visit(node ast.Node) ast.Visitor {
    switch n := node.(type) {
    case *ast.CallExpr:
        if ident, ok := n.Fun.(*ast.Ident); ok {
            if s.isCloudUnfriendlyCall(ident.Name) { // 如 os.RemoveAll, net.ListenTCP
                s.report(n.Pos(), "unsafe-cloud-call", ident.Name)
            }
        }
    case *ast.AssignStmt:
        s.analyzeConfigAssignment(n)
    }
    return s
}

该访客模式递归遍历AST,isCloudUnfriendlyCall基于预置白名单(如http.ListenAndServe)与黑名单(如os.Exit)双策略判定。n.Pos()提供精确源码位置,支撑CI/CD中精准失败定位。

云就绪度评估维度

维度 检查项 风险等级
进程生命周期 os.Exit, log.Fatal
网络绑定 net.Listen, http.Serve
本地存储 os.OpenFile(无云存储适配)

扫描流程概览

graph TD
A[Parse Go source → AST] --> B[Visit root *ast.File]
B --> C{Node type?}
C -->|CallExpr| D[Check cloud-unfriendly funcs]
C -->|AssignStmt| E[Extract config keys]
C -->|ImportSpec| F[Detect non-cloud SDKs]
D --> G[Generate finding]
E --> G
F --> G

3.2 架构图语义理解:从PlantUML/Diagrams.net到CNCF技术栈映射规则

架构图不是装饰,而是可执行的契约。当团队用 PlantUML 描绘「服务间调用」时,[ServiceA] --> [ServiceB] : HTTP/1.1 这一行隐含了可观测性、流量治理与安全策略的落地要求。

映射核心原则

  • 节点 → CNCF成熟度模型组件(如 DatabaseetcdPostgreSQL Operator
  • 连线 → OpenTelemetry Span + Service Mesh 路由策略
  • 容器分组 → Kubernetes Namespace + NetworkPolicy 边界

典型转换示例

[Frontend] --> [API Gateway]
[API Gateway] --> [Auth Service]

→ 自动映射为:

# 自动生成的 Istio VirtualService + PeerAuthentication
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: auth-svc-mtls
spec:
  mtls: {mode: STRICT}  # 基于连线标签 "TLS required" 推导

逻辑分析:PlantUML 箭头标注若含 TLSmTLS,触发 PeerAuthentication 模式生成;--> 默认启用 DestinationRulesimple: ROUND_ROBIN 负载策略。

PlantUML 元素 CNCF 对应物 语义约束
folder "K8s" Namespace + Cluster 隐含 RBAC scope 和 network isolation
cloud "AWS" Crossplane Provider 触发 ProviderConfig 自动生成
graph TD
  A[Diagrams.net SVG] --> B{解析器提取语义标签}
  B --> C[“service: mesh-enabled”]
  B --> D[“storage: persistent”]
  C --> E[Istio CRDs]
  D --> F[VolumeSnapshotClass]

3.3 风险权重动态校准:结合Kubernetes Operator成熟度与Go生态版本生命周期

Operator的生产就绪状态并非静态指标,需随其CRD稳定性、升级路径兼容性及依赖的Go SDK版本生命周期动态加权。

校准维度映射表

维度 权重因子 触发条件示例
Go SDK支持周期 ×0.4 Go 1.21+已EOL旧版net/http行为
CRD v1beta1弃用状态 ×0.35 Kubernetes 1.25+强制v1
Controller Runtime版本 ×0.25 v0.16+修复Finalizer竞态漏洞

自动化校准逻辑(Go片段)

func CalculateRiskWeight(op *OperatorProfile) float64 {
  weight := 0.0
  if op.GoVersion.Lifecycle == "EOL" { // Go官方维护状态
    weight += 0.4
  }
  if op.CRDVersion == "v1beta1" && op.K8sVersion >= "1.25" {
    weight += 0.35
  }
  if op.CtrlRuntime < semver.MustParse("0.16.0") {
    weight += 0.25
  }
  return weight // 返回[0.0, 1.0]区间风险分
}

该函数将Operator的Go生态兼容性、K8s API演进适配、控制器运行时安全性三者量化为统一风险标尺,驱动CI/CD流水线自动拦截高风险发布。

校准决策流

graph TD
  A[Operator Profile] --> B{Go版本EOL?}
  B -->|是| C[+0.4]
  B -->|否| D{CRD为v1beta1且K8s≥1.25?}
  D -->|是| E[+0.35]
  D -->|否| F{CtrlRuntime<0.16.0?}
  F -->|是| G[+0.25]

第四章:Go项目改造成本预估工程体系

4.1 依赖重构成本:gRPC/HTTP/Database驱动层云原生替代方案对比矩阵

云原生迁移中,驱动层重构成本常被低估。核心差异体现在协议耦合度、连接生命周期管理与可观测性内置能力。

协议抽象粒度对比

  • gRPC:强契约(.proto)、双向流、内置超时与重试策略
  • HTTP/REST:松耦合、依赖客户端适配、需手动实现幂等与重试
  • Database Driver:连接池绑定紧、TLS/认证配置分散、缺乏服务发现支持

典型重构代码片段(gRPC客户端封装)

// 使用 grpc-go 的拦截器统一注入 trace 和重试逻辑
conn, _ := grpc.Dial("svc.user.svc.cluster.local:8080",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithUnaryInterceptor(retryInterceptor), // 自定义重试:max=3, backoff=500ms
    grpc.WithStreamInterceptor(otelstream.UnaryClientInterceptor()), // OpenTelemetry 集成
)

该封装将重试策略、链路追踪、负载均衡解耦至通信层,避免业务代码污染;grpc.WithTransportCredentials 显式声明传输安全上下文,契合 Service Mesh 中 mTLS 自动注入范式。

维度 gRPC HTTP/REST Database Driver
协议标准化程度 高(IDL驱动) 中(OpenAPI) 低(厂商特有)
连接复用开销 极低(长连接+多路复用) 中(HTTP/2可优化) 高(连接池争用)
云原生集成度 原生支持 Istio/gRPC-Web 需适配 Gateway 需 Sidecar 代理
graph TD
    A[业务服务] -->|gRPC| B[Service Mesh]
    A -->|HTTP| C[API Gateway]
    A -->|JDBC/PGX| D[Database Proxy]
    B --> E[自动mTLS & 负载均衡]
    C --> F[限流/鉴权/转换]
    D --> G[连接池透明化 & SQL审计]

4.2 配置管理迁移:从flag/viper到ConfigMap/Secret/K8s External Secrets落地路径

为什么需要迁移?

本地配置(flag/viper)在多环境、多集群场景下存在硬编码风险、密钥泄露隐患及版本漂移问题。Kubernetes 原生的 ConfigMapSecret 提供声明式、RBAC 可控的配置分发能力,而 External Secrets 进一步桥接 Vault/AWS SSM 等可信后端。

典型迁移路径

  • 步骤1:将 viper.AddConfigPath() 中的 YAML 文件拆分为 ConfigMap(非敏感)和 Secret(base64 编码敏感字段)
  • 步骤2:用 envFromvolumeMount 注入容器
  • 步骤3:引入 ExternalSecret CRD,对接 HashiCorp Vault

示例:Secret 挂载代码

# db-secret.yaml
apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: db-creds
data:
  username: YWRtaW4=     # base64 encoded "admin"
  password: cGFzc3dvcmQxMjM=  # "password123"

data 字段必须为 base64 编码;K8s 不校验内容语义,但解码失败会导致 Pod 启动失败。生产环境应通过 CI 流水线自动编码并审计明文残留。

迁移对比表

维度 viper (文件) ConfigMap + Secret ExternalSecret
密钥轮换 需重启应用 kubectl apply 即生效 自动同步 Vault 版本
审计能力 Kubernetes audit log Vault audit + K8s event
graph TD
  A[本地配置文件] --> B[CI 构建时生成 ConfigMap/Secret]
  B --> C[部署至集群]
  C --> D{是否需动态密钥?}
  D -->|是| E[部署 ExternalSecret CR]
  D -->|否| F[直接挂载 Secret]
  E --> G[Vault webhook 同步]

4.3 生命周期管理升级:Go进程信号处理与K8s Pod Lifecycle Hook对齐实践

信号捕获与优雅退出的Go实现

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
<-sigChan // 阻塞等待信号
closeDBConnections() // 自定义清理逻辑
os.Exit(0)

该代码注册 SIGTERM(K8s默认终止信号)和 SIGINT,确保进程在收到K8s preStop hook触发或直接kill时能同步执行资源释放。os.Exit(0) 避免defer延迟执行,保障退出确定性。

K8s Lifecycle Hook对齐策略

  • preStop 必须配置 exechttpGet,超时需 ≥ Go信号处理+清理耗时
  • terminationGracePeriodSeconds 应覆盖最长清理窗口(建议 ≥30s)
Hook阶段 触发时机 Go侧响应方式
preStop K8s发送SIGTERM前 启动信号监听+预热清理
SIGTERM 容器进程收到信号 执行阻塞式优雅关闭

流程协同示意

graph TD
    A[K8s发起Pod删除] --> B[执行preStop hook]
    B --> C[Go进程接收SIGTERM]
    C --> D[关闭连接/刷新缓存/提交事务]
    D --> E[进程退出→Pod Terminated]

4.4 测试资产复用:单元测试/集成测试向e2e云环境迁移的ROI测算模型

将已有单元测试与集成测试资产迁入云原生e2e环境,关键在于量化复用收益。核心指标包括:复用率(R)环境适配成本(Cₐ)执行效率增益(E)缺陷拦截前移价值(Vₜ)

ROI基础公式

# ROI = (净收益) / (总投入) × 100%
roi = (V_t + E * baseline_cost - C_a) / (C_a + cloud_runtime_cost) * 100
# V_t:按P0/P1缺陷平均修复成本×前置拦截数估算(例:$5k/缺陷 × 12 = $60k)
# baseline_cost:本地CI单次e2e执行均耗($18.2)
# cloud_runtime_cost:云上Selenium Grid小时单价×并发时长

逻辑分析:V_t 体现质量左移的隐性收益;E 依赖容器化并行度提升(如从1→8并发,E≈3.6×);C_a 主要来自HTTP客户端适配、密钥注入、异步等待策略重构。

关键参数对照表

参数 本地CI 云e2e(复用后) 变化率
单次执行耗时 420s 118s −72%
稳定性(通过率) 83% 96% +13pp
维护人力周耗 6h 2.5h −58%

迁移决策流程

graph TD
    A[现有测试资产] --> B{是否含硬编码IP/DB路径?}
    B -->|是| C[重构为ConfigMap+Env注入]
    B -->|否| D[直接容器化封装]
    C --> E[注入Secrets & Service DNS]
    D --> E
    E --> F[接入云平台TestGrid API]
    F --> G[启动ROI动态仪表盘]

第五章:免费版测评工具链开源说明与使用指南

开源许可证与合规性说明

本工具链采用 MIT 许可证发布,允许商业使用、修改、分发及私有化部署,仅需保留原始版权声明。所有核心模块(eval-coredata-sandboxreport-gen)均托管于 GitHub 仓库 open-benchmark-toolchain,commit hash v0.8.3-rc2 已通过 SPDX 验证,兼容 GDPR 与 CCPA 数据处理要求。用户可在本地离线环境中完整构建,无需调用任何外部 SaaS 服务。

快速启动三步法

  1. 克隆仓库并初始化子模块:
    git clone https://github.com/open-benchmark-toolchain/open-benchmark-toolchain.git  
    cd open-benchmark-toolchain && git submodule update --init --recursive  
    make setup  # 自动安装 Python 3.9+、Poetry 及预编译二进制依赖
  2. 加载示例数据集(含 3 类真实业务场景样本):
    python cli.py load --dataset=ecommerce-2024-q2 --format=parquet  
  3. 执行端到端测评:
    python cli.py run --config=conf/llm-eval.yaml --output=reports/q2-llm-bench.html  

核心模块能力矩阵

模块名称 支持协议 最大并发数 输出格式 是否支持自定义指标
eval-core REST/HTTP2 128 JSONL + SQLite ✅(YAML 插件接口)
data-sandbox Parquet/CSV 无限制 Delta Lake ✅(SQL UDF 注册)
report-gen Webhook/PDF 64 HTML/PDF/MD ✅(Jinja2 模板)

实战案例:电商客服模型 A/B 测试

某头部电商平台使用该工具链对两个 LLM 客服模型(Qwen2-7B vs. Phi-3-mini)进行 72 小时压力测评。配置文件 conf/ecommerce-ab.yaml 中启用 response-latency-threshold: 850msintent-recall@top3: 0.92 双硬性阈值。工具链自动捕获 12,847 条真实会话日志,生成对比热力图(见下图),发现 Phi-3-mini 在“退货政策”类意图上召回率高出 11.3%,但 Qwen2-7B 在长对话上下文连贯性得分高 23.6%。

flowchart LR
    A[原始会话日志] --> B[数据沙箱清洗]
    B --> C{意图标注引擎}
    C --> D[Qwen2-7B 推理]
    C --> E[Phi-3-mini 推理]
    D & E --> F[多维指标计算]
    F --> G[HTML 报告生成]
    G --> H[PDF 归档与邮件分发]

插件开发实战路径

用户可通过继承 MetricPluginBase 类实现自定义评估逻辑。例如,某金融客户扩展了 RegulatoryComplianceScore 插件,加载银保监会 2023 年《AI 销售话术合规指引》规则库,将每条生成回复映射至 17 项禁止性条款,输出违规权重热力分布。插件代码经 make test-plugin 验证后,自动注入主流程,无需重启服务。

社区支持与问题定位

当出现 TimeoutError: data-sandbox failed to validate schema 时,建议执行 poetry run pytest tests/integration/test_schema_validation.py -v --log-cli-level=DEBUG 查看字段类型推断日志;常见原因包括 Parquet 文件中存在混合类型列(如 user_id 同时含整数与字符串)。社区 Slack 频道 #toolchain-support 提供平均响应时间

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

发表回复

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