Posted in

golang课程结业≠能写生产代码:用Uber Go Style Guide逐行审计6门课作业,仅2门达标率超80%

第一章:golang课程结业≠能写生产代码:真实能力断层警示

完成一门Go语言入门课程、跑通Hello World、甚至实现一个简易HTTP服务,并不意味着你已具备交付生产级代码的能力。大量开发者在结业后首次参与真实项目时,遭遇了显著的能力断层:对并发安全的理解停留在goroutine字面,对错误处理仍用log.Fatal()粗暴终止进程,对依赖管理还停留在go get裸调用——这些看似微小的偏差,在高并发、长生命周期、多模块协作的生产环境中会迅速演变为系统性风险。

常见的“课程幻觉”与生产现实对比

  • 课程场景:用time.Sleep()模拟异步等待;
    生产现实:需配合context.WithTimeout()主动取消、释放资源,避免goroutine泄漏;
  • 课程场景err != nil { panic(err) }
    生产现实:需结构化错误包装(如fmt.Errorf("failed to parse config: %w", err))、分类日志记录、可监控的错误指标;
  • 课程场景:所有代码写在main.go,无模块划分;
    生产现实:必须遵循internal/分层、pkg/复用设计,且go mod tidygo.sum需受CI校验。

一个暴露断层的典型代码片段

// ❌ 课程风格:忽略上下文、未处理panic、无重试机制
func fetchUser(id int) *User {
    resp, _ := http.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
    defer resp.Body.Close()
    data, _ := io.ReadAll(resp.Body)
    var u User
    json.Unmarshal(data, &u) // panic on malformed JSON
    return &u
}

// ✅ 生产要求:显式context、错误链、超时控制、重试退避
func fetchUser(ctx context.Context, id int, client *http.Client) (*User, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.example.com/users/%d", id), nil)
    resp, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("http request failed: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
    }
    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("read response body failed: %w", err)
    }
    var u User
    if err := json.Unmarshal(data, &u); err != nil {
        return nil, fmt.Errorf("json decode failed: %w", err)
    }
    return &u, nil
}

真正的生产就绪能力,体现在对go vet警告的敏感度、对-race检测结果的敬畏、对pprof火焰图的解读能力,以及将go test -coverprofile=coverage.out纳入CI流水线的工程自觉。能力断层不是知识缺口,而是工程习惯的缺失。

第二章:Uber Go Style Guide核心原则深度解析与代码映射

2.1 命名规范与接口设计:从课程变量命名到生产级API契约对齐

变量命名的语义跃迁

课程中 stu_name 是可接受的,但微服务中应升维为 studentFullName——强调领域语义而非缩写习惯。

API契约对齐关键实践

  • 使用 OpenAPI 3.0 显式定义请求/响应结构
  • 所有字段遵循 snake_case(请求体)与 camelCase(响应体)双约定
  • 版本号嵌入路径而非 Header,保障可缓存性与可观测性

命名一致性校验示例

# openapi.yaml 片段
components:
  schemas:
    CourseEnrollmentRequest:
      type: object
      properties:
        student_id:      # ✅ 请求侧 snake_case
          type: string
          format: uuid
        course_code:     # ✅ 业务含义明确
          type: string
          minLength: 6

此处 student_idcourse_code 避免模糊缩写(如 stuidcrs),确保下游 SDK 自动生成时字段名具备自解释性;format: uuid 强制类型约束,替代字符串宽松校验。

接口演进对照表

场景 课程原型 生产级契约
错误码 {"error": "not found"} {"code": "COURSE_NOT_FOUND", "message": "...", "trace_id": "..."}
时间格式 "2024-01-01" "2024-01-01T00:00:00Z"(ISO 8601 UTC)
graph TD
  A[课程变量 stu_name] --> B[领域模型 Student.fullName]
  B --> C[API请求字段 student_full_name]
  C --> D[响应字段 studentFullName]
  D --> E[客户端TypeScript接口 studentFullName: string]

2.2 错误处理范式重构:对比课程panic滥用与Uber推荐的error wrapping实践

panic不是错误处理,而是程序终止信号

初学者常将panic用于业务异常(如参数校验失败),这破坏了调用链可控性,且无法被上层恢复。

Uber Go Style Guide 的 error wrapping 原则

  • 永远用 fmt.Errorf("context: %w", err) 包装底层错误
  • 保留原始错误类型与堆栈(需 Go 1.13+)
  • 避免重复包装(%w 只能出现一次)
// ✅ 推荐:语义化包装,保留原始错误
func fetchUser(id int) (User, error) {
    if id <= 0 {
        return User{}, fmt.Errorf("invalid user ID %d: %w", id, ErrInvalidID)
    }
    u, err := db.Query(id)
    if err != nil {
        return User{}, fmt.Errorf("failed to query user %d from DB: %w", id, err)
    }
    return u, nil
}

逻辑分析:%w 触发 Unwrap() 方法链,使 errors.Is(err, ErrInvalidID)errors.As(err, &e) 可穿透多层包装;id 是上下文参数,增强可调试性。

关键差异对比

维度 panic滥用 error wrapping
可恢复性 ❌ 不可捕获(仅 defer recover) ✅ 可逐层检查、重试或降级
调试信息 仅含 panic message 含原始错误 + 上下文 + 调用路径
graph TD
    A[HTTP Handler] --> B[fetchUser]
    B --> C{ID valid?}
    C -->|No| D[fmt.Errorf with %w]
    C -->|Yes| E[DB Query]
    E -->|Error| F[fmt.Errorf with %w]
    D --> G[Return to caller]
    F --> G

2.3 并发模型审计:goroutine泄漏检测与context.Context在课程作业中的缺失分析

goroutine泄漏的典型模式

学生作业中常见无限 for 循环启动 goroutine,却无退出机制:

func serveConn(conn net.Conn) {
    go func() { // ❌ 无取消信号,连接关闭后仍运行
        defer conn.Close()
        for {
            buf := make([]byte, 1024)
            n, _ := conn.Read(buf) // 阻塞读,conn.Close() 后返回 0,但循环继续
            if n == 0 { break }
            process(buf[:n])
        }
    }()
}

逻辑分析conn.Read() 在连接关闭后返回 (0, io.EOF),但未检查错误即 break,导致 goroutine 永驻内存;defer conn.Close() 在 goroutine 退出时才执行,而 goroutine 永不退出。

context.Context 缺失的后果

场景 无 context 表现 正确做法
HTTP 超时控制 请求卡死,goroutine 累积 ctx, cancel := context.WithTimeout(...)
并发任务协同取消 子任务无法响应父任务终止 select { case <-ctx.Done(): return }

检测建议

  • 使用 pprof 监控 goroutine 数量趋势
  • 静态扫描:查找 go func() { for { ... } }() 且无 ctx.Done() 检查的模式
graph TD
    A[HTTP Handler] --> B[启动 goroutine]
    B --> C{是否监听 ctx.Done?}
    C -->|否| D[goroutine 泄漏风险]
    C -->|是| E[安全退出]

2.4 包结构与依赖管理:解构6门课module布局 vs Uber“单一职责+internal封装”标准

模块职责边界对比

6门课项目采用扁平化 module 划分(course-corecourse-uicourse-data),依赖通过 api/implementation 显式声明;Uber 规范强制每个 module 仅暴露 internal 接口,对外仅提供 public facade。

依赖声明示例

// course-ui/build.gradle.kts  
dependencies {  
    api(project(":course-core")) // ❌ 违反单一职责:UI 层不应导出核心逻辑  
    implementation(project(":course-data")) // ✅ 合理:仅内部使用数据层  
}

api 导致 course-ui 的 transitive 依赖泄漏至消费者,破坏封装性;implementation 限定作用域,符合 Uber 的“黑盒模块”原则。

封装层级对照表

维度 6门课方案 Uber 标准
包可见性 public 默认开放 internal 默认封禁
module 导出 全量 class 可见 @PublishedApi 注解类可导出

架构演进路径

graph TD  
    A[Flat Module] --> B[职责分离]  
    B --> C[internal 封装]  
    C --> D[Facade 模式收敛]  

2.5 测试覆盖率与测试风格:课程单元测试盲区 vs Uber要求的table-driven test+mock边界覆盖

课程常见盲区

初学者常写“单例式”测试:每个函数配一个 TestXxx(),硬编码输入输出,遗漏边界组合(如空切片、负值、并发竞争)。覆盖率数字虚高,但真实场景漏检率超40%。

Uber工程实践核心

  • 使用 table-driven test 统一驱动多组用例
  • 所有外部依赖强制 mock(如 HTTP client、DB)
  • 边界覆盖要求:nil、空、超限、错误注入三类必测

示例:用户服务校验逻辑

func TestValidateUser(t *testing.T) {
    tests := []struct {
        name     string
        input    User
        wantErr  bool
        mockDB   func(*mockDB)
    }{
        {"empty name", User{}, true, nil},
        {"valid", User{Name: "A", Age: 25}, false, nil},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            db := newMockDB()
            if tt.mockDB != nil {
                tt.mockDB(db)
            }
            err := ValidateUser(db, tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

逻辑分析tests 切片定义结构化用例;t.Run 实现并行隔离;mockDB 字段预留依赖行为定制能力。tt.wantErr 控制断言方向,避免 if err == nil 的反向逻辑陷阱。

维度 课程常见测试 Uber table-driven test
用例组织 散点式函数 结构体切片统一驱动
边界覆盖密度 ≤3种 ≥8种(含 mock 错误分支)
可维护性 修改需复制粘贴 新增用例仅追加结构体项
graph TD
    A[原始输入] --> B{ValidateUser}
    B --> C[DB 查询]
    C --> D[年龄校验]
    D --> E[名称非空]
    E --> F[返回 error 或 nil]
    C -.->|mock 返回 ErrDB| F
    D -.->|Age < 0| F

第三章:6门主流Go课程作业的逐行合规性审计方法论

3.1 审计工具链搭建:go-critic + staticcheck + custom linter规则注入实战

Go 工程质量保障需多层静态检查协同。我们整合 go-critic(高阶代码异味检测)、staticcheck(语义级缺陷识别)与自定义 linter(业务规则嵌入)。

工具链统一入口:golangci-lint 配置

通过 .golangci.yml 聚合三者:

run:
  timeout: 5m
  skip-dirs: ["vendor", "tests"]
linters-settings:
  go-critic:
    enabled-checks: ["undesired-block-size", "range-val-address"]
  staticcheck:
    checks: ["all", "-ST1003"] # 禁用冗余注释警告
  custom:
    rules:
      - name: "require-context-timeout"
        description: "HTTP handler must use context.WithTimeout"
        code: |
          if m, ok := n.(*ast.CallExpr); ok &&
             isIdent(m.Fun, "http.HandleFunc") &&
             !hasContextTimeout(m.Args[1]) {
            report("missing context timeout in handler")
          }

逻辑分析:该配置将 go-critic 启用两项性能敏感检查,staticcheck 全量启用但排除低价值项;custom 块通过 AST 模式匹配,在 http.HandleFunc 第二参数中检测是否调用 context.WithTimeout,实现业务强约束。

效果对比(单位:检出数/万行)

工具 通用缺陷 业务违规 平均耗时
staticcheck 42 0 1.8s
go-critic 27 0 3.2s
custom linter 0 8 0.9s
graph TD
  A[源码] --> B[golangci-lint]
  B --> C[staticcheck]
  B --> D[go-critic]
  B --> E[custom rule engine]
  C & D & E --> F[统一报告]

3.2 关键指标量化模型:可维护性(Cyclomatic Complexity)、可观测性(log/trace注入点)、安全性(SQL/HTTP注入防护)三维度打分

三维度统一评分框架

采用加权归一化公式:
Score = 0.4×M + 0.3×O + 0.3×S,其中

  • M = max(0, 1 − (CC−1)/(CCₘₐₓ−1))(CC∈[1,50],CCₘₐₓ=50)
  • O = (log_count + trace_span_count) / (file_lines × 0.05)(上限1.0)
  • S = 1 − (vuln_count / scan_lines)(基于静态扫描结果)

可维护性:圈复杂度自动检测(Python示例)

def calculate_cc(func_ast):
    """递归统计AST中决策节点数:if/for/while/try/bool_op/compare"""
    count = 0
    for node in ast.walk(func_ast):
        if isinstance(node, (ast.If, ast.For, ast.While, ast.Try)):
            count += 1
        elif isinstance(node, ast.BoolOp):
            count += len(node.values) - 1
        elif isinstance(node, ast.Compare):
            count += len(node.ops) - 1
    return count + 1  # 基础路径为1

逻辑说明:count 统计所有控制流分支节点;+1 表示基础执行路径;BoolOpand/or 每增加一个操作数即新增独立路径。

安全性防护检查表

检查项 合规示例 风险模式
SQL参数化 cursor.execute("SELECT * FROM u WHERE id=%s", [uid]) 字符串拼接 f"WHERE id={uid}"
HTTP输入过滤 request.args.get('q', '').strip()[:100] 直接 request.args['q']

可观测性注入点分布(Mermaid)

graph TD
    A[HTTP Handler] --> B[Entry Log]
    A --> C[Trace Span Start]
    B --> D[DB Query]
    C --> D
    D --> E[Exit Log & Span End]
    E --> F[Metrics Export]

3.3 典型反模式案例库构建:从“教学友好”到“生产危险”的12类高频违规模式归档

数据同步机制

常见反模式:“双写即最终一致”——在订单服务与库存服务中分别执行本地事务后异步发消息,未配补偿或幂等。

# ❌ 危险双写(无事务边界、无重试保障)
def create_order(order):
    db.order.insert(order)          # 本地事务提交
    mq.publish("inventory_decrease", order.items)  # 网络失败即丢失

逻辑分析:db.order.insert() 成功但 mq.publish() 失败时,库存不扣减,导致超卖;参数 order.items 未序列化校验,消息体结构漂移将引发下游解析异常。

配置热加载陷阱

  • 无版本校验的 YAML 动态重载
  • 环境变量覆盖未做 schema 合法性断言
  • 配置变更未触发健康检查熔断
反模式类型 触发场景 线上影响
静态配置硬编码 DEBUG=True 提交至 prod 分支 敏感日志全量输出
未加锁的并发 reload 多线程同时解析 config.yaml 内存中配置状态撕裂
graph TD
    A[配置文件修改] --> B{是否通过Schema校验?}
    B -- 否 --> C[拒绝加载,告警]
    B -- 是 --> D[原子替换config对象]
    D --> E[触发/healthz 自检]

第四章:达标率超80%的2门课程重构路径与工程化迁移指南

4.1 从作业模板到SRE-ready代码:添加health check、metrics endpoint与config validation的增量改造

健康检查:轻量级可探测性入口

暴露 /health 端点,返回结构化状态:

@app.get("/health")
def health_check():
    # 检查核心依赖(DB连接、缓存连通性)
    db_ok = database.ping()  # 超时300ms,失败则返回 degraded
    cache_ok = redis.client.ping() if redis else True
    status = "healthy" if db_ok and cache_ok else "degraded"
    return {"status": status, "timestamp": time.time()}

逻辑:仅探测关键路径,避免嵌套调用;db_okcache_ok 分别控制服务可用性分级,为K8s readiness probe 提供语义化依据。

指标端点与配置校验协同演进

阶段 关键动作 SRE价值
初始模板 无指标/校验 无法观测、易因错配崩溃
增量改造 Prometheus /metrics + Pydantic config schema 实时监控+启动时fail-fast
graph TD
    A[启动加载配置] --> B{Pydantic校验}
    B -->|通过| C[初始化metrics registry]
    B -->|失败| D[日志报错+exit 1]
    C --> E[HTTP server 启动]

4.2 CI/CD流水线嵌入式合规检查:GitHub Actions中集成Uber linter与PR gate策略

自动化合规门控设计

pull_request 触发时,强制执行静态分析与风格校验,阻断不合规代码合入主干。

GitHub Actions 配置示例

- name: Run Uber Go linter
  uses: golangci/golangci-lint-action@v6
  with:
    version: v1.57
    args: --config .golangci.yml

该步骤调用官方 Action,指定配置文件路径与版本;--config 参数确保团队统一规则集(含 errcheckgovetstaticcheck 等插件),避免本地环境差异导致检查漏失。

PR Gate 策略核心逻辑

graph TD
  A[PR opened] --> B{Run linter}
  B -->|pass| C[Approve & merge]
  B -->|fail| D[Comment on violations]
  D --> E[Block merge until fixed]

关键参数对照表

参数 作用 推荐值
skip-pkg 跳过特定目录 ./cmd/.*
timeout 单次检查超时 3m
fail-on-warnings 警告即失败 true

4.3 生产环境适配层封装:为课程项目注入zap日志、opentelemetry tracing与structured error reporting

适配层采用统一中间件模式,解耦业务逻辑与可观测性能力。

日志标准化封装

使用 zap.NewNop() 构建基础 logger,通过 zap.WrapCore 注入 request ID 与 service context:

func NewLogger() *zap.Logger {
    return zap.New(zapcore.NewCore(
        zapcore.NewJSONEncoder(zapcore.EncoderConfig{
            TimeKey:        "ts",
            LevelKey:       "level",
            NameKey:        "logger",
            CallerKey:      "caller",
            MessageKey:     "msg",
            StacktraceKey:  "stacktrace",
            EncodeLevel:    zapcore.LowercaseLevelEncoder,
            EncodeTime:     zapcore.ISO8601TimeEncoder,
            EncodeDuration: zapcore.SecondsDurationEncoder,
        }),
        zapcore.AddSync(os.Stdout),
        zap.InfoLevel,
    ))
}

该配置启用结构化 JSON 输出,EncodeTime 统一为 ISO8601 格式便于 ELK 解析,SecondsDurationEncoder 确保耗时字段单位可读。

追踪与错误协同设计

能力 实现方式 关键参数说明
OpenTelemetry otelhttp.NewHandler 中间件 WithTracerProvider 指向 Jaeger exporter
结构化错误上报 自定义 ErrorReporter 接口 包含 Code, Cause, Context 三元组
graph TD
    A[HTTP Handler] --> B[OTel Middleware]
    B --> C[Zap Logger with TraceID]
    C --> D[Structured Error Reporter]
    D --> E[Prometheus + Loki + Jaeger]

4.4 团队协作规范落地:基于audit结果定制团队Go编码公约与新人onboarding checklist

从audit报告提炼关键约束

golintstaticcheckgo vet 的聚合审计暴露高频问题:未使用的变量、错误忽略、硬编码日志级别。据此定义三条核心公约:

  • error 必须显式处理或注释跳过理由(//nolint:errcheck
  • ✅ 所有HTTP handler 必须包含 context.WithTimeout
  • ❌ 禁止 log.Printf,统一使用结构化日志 zerolog.Ctx(r.Context()).Info().Str("path", r.URL.Path).Send()

新人onboarding checklist

  • [ ] 配置 VS Code + gopls + revive 规则集(含团队自定义规则)
  • [ ] 运行 make audit 并修复全部 CRITICAL 级别告警
  • [ ] 提交首个PR前,通过 go test -race ./...

公约落地示例

// ✅ 符合公约:显式错误处理 + 上下文超时 + 结构化日志
func handleUser(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()
    user, err := userService.Get(ctx, r.URL.Query().Get("id"))
    if err != nil {
        zerolog.Ctx(ctx).Error().Err(err).Msg("failed to fetch user")
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }
    // ...
}

逻辑分析context.WithTimeout 防止goroutine泄漏;zerolog.Ctx(ctx) 继承请求上下文链路ID;defer cancel() 确保资源释放。参数 5*time.Second 来源于审计中95% API响应P95

检查项 工具 严重等级 示例
错误忽略 errcheck CRITICAL json.Unmarshal(b, &v)
日志非结构化 自定义revive rule HIGH log.Println("user created")
graph TD
    A[新人clone repo] --> B[运行make setup]
    B --> C[执行make audit]
    C --> D{0 CRITICAL?}
    D -->|Yes| E[提交PR]
    D -->|No| F[自动定位违规文件行号]

第五章:结语:走出“Hello World陷阱”,走向可交付的Go工程师 maturity curve

从单文件到可交付制品的跨越

一位在某金融科技公司实习的Go开发者,最初提交的PR仅包含main.go中打印带时间戳的”Hello World”。两周后,他交付了第一个生产级模块:一个基于net/httpgo.uber.org/zap构建的轻量API网关,支持JWT鉴权、请求速率限制(使用golang.org/x/time/rate)及结构化日志归档至ELK。关键转折点在于他主动将go build -ldflags="-s -w"集成进CI脚本,并通过docker build --platform linux/amd64 -t payment-gateway:v1.2 .生成多阶段构建镜像,最终镜像体积从327MB压缩至18MB。

工程成熟度的四个实证指标

维度 初学者表现 可交付工程师实践
错误处理 if err != nil { panic(err) } 使用errors.Join()聚合上下文错误,自定义AppError类型携带HTTP状态码与追踪ID
依赖管理 go get github.com/some/pkg直连master分支 go mod edit -replace github.com/legacy/db=github.com/internal/db@v0.4.1锁定内部封装版本
测试覆盖 仅测试func TestHelloWorld(t *testing.T) go test -race -coverprofile=coverage.out ./... + gocov生成HTML报告,核心业务逻辑覆盖率≥85%

在Kubernetes集群中验证交付能力

# 实际部署流水线片段(GitLab CI)
- name: deploy-to-staging
  script:
    - export KUBECONFIG=/etc/kube/staging.conf
    - kubectl set image deployment/payment-gateway gateway=registry.internal/payment-gateway:v1.2.3
    - kubectl rollout status deployment/payment-gateway --timeout=120s
    - curl -sf http://staging-gw/api/health | jq '.status == "ok"'  # 真实健康检查断言

构建可审计的发布链路

使用cosign sign --key cosign.key payment-gateway:v1.2.3对容器镜像签名,配合notary-server实现镜像签名验证。当安全团队扫描出alpine:3.18基础镜像含CVE-2023-4585时,工程师在2小时内完成Dockerfile升级至alpine:3.19,触发自动化签名+K8s滚动更新,全程无服务中断。

拒绝“玩具项目思维”的三个动作

  • fmt.Println("debug")替换为log.With(zap.String("trace_id", r.Header.Get("X-Trace-ID"))).Info("request_processed")
  • go.mod中显式声明go 1.21并启用GODEBUG=gocacheverify=1确保构建可重现
  • //go:build !test约束生产代码不引入testify/mock等测试专用依赖

成熟度曲线不是线性阶梯

某电商团队曾因过度追求“完美架构”在微服务拆分中陷入停滞:6周内设计了12个独立Go服务但零上线。后来采用渐进式策略——先将订单服务中库存校验逻辑抽离为inventory-checker独立二进制,通过Unix Domain Socket通信,2天完成灰度发布。监控数据显示P99延迟从420ms降至87ms,验证了小步快跑的价值。

生产环境中的真实压力测试

使用k6 run --vus 500 --duration 5m load-test.js模拟高并发场景,暴露sync.Pool未复用bytes.Buffer导致GC压力激增问题。通过pprof火焰图定位后,重构为bufferPool := sync.Pool{New: func() interface{} { return &bytes.Buffer{} }},内存分配减少63%。

工程师成长的隐性成本

某SaaS公司统计显示:初级Go工程师平均需经历3.2次线上P1故障(如goroutine泄漏致OOM)才能建立对runtime/pprofgo tool trace的肌肉记忆。第4次故障后,该工程师自主编写goroutine-leak-detector工具嵌入CI,在go test阶段自动捕获runtime.NumGoroutine()异常增长。

可交付的终极标志

当运维同事不再需要你解释kubectl logs -n prod payment-gateway-7c8f9b4d5-xvq2p | grep "panic",而是直接转发alertmanager告警链接并附言“已按预案执行kubectl rollout undo deployment/payment-gateway”时,你已真正穿越成熟度曲线的关键隘口。

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

发表回复

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