Posted in

defer + http.ResponseWriter.WriteHeader的竞态死区(Chrome DevTools Network面板无法捕获的HTTP状态码丢失)

第一章:defer机制的本质与HTTP状态码生命周期

defer 是 Go 语言中用于延迟执行函数调用的关键机制,其本质并非简单的“函数注册表”,而是一套基于栈结构的、与 goroutine 生命周期深度绑定的延迟调用链。每次 defer 语句执行时,对应的函数值、参数(按当前作用域求值)被压入当前 goroutine 的 defer 链表头部;当函数返回(包括正常 return 或 panic)时,运行时按后进先出(LIFO)顺序依次执行这些延迟调用。

HTTP 状态码的生命周期与 defer 存在隐性耦合——尤其在 HTTP handler 中,状态码一旦由 ResponseWriter.WriteHeader() 显式设置或由 Write() 首次写入响应体时即被“冻结”。此后任何对 WriteHeader() 的调用均无效,且 defer 中若尝试修改状态码(如日志记录后覆盖为 500),将被底层 http.responseWriter 忽略。

defer 的执行时机与陷阱

  • defer 在函数返回指令执行前触发,但早于返回值赋值完成(影响命名返回值);
  • 若函数 panic,defer 仍会执行,但无法捕获 panic 值(需配合 recover());
  • 多个 defer 按声明逆序执行,例如:
func example() {
    defer fmt.Println("first")  // 最后执行
    defer fmt.Println("second") // 先执行
    fmt.Println("main")
}
// 输出:main → second → first

HTTP 状态码的不可变性验证

可通过以下代码观察状态码锁定行为:

func handler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK) // 显式设置 200
    defer func() {
        w.WriteHeader(http.StatusInternalServerError) // 此调用被忽略
        fmt.Println("Status code attempted to change to 500")
    }()
    io.WriteString(w, "hello") // 触发实际写入,此时状态码已固化为 200
}

常见状态码与对应 defer 场景对照

状态码 含义 适用 defer 场景
200 OK 记录成功耗时、清理临时资源
401/403 Unauthorized/Forbidden 权限检查失败后审计日志(不改状态码)
500 Internal Server Error panic 恢复后统一设置并记录错误详情

正确使用 defer 需始终牢记:它不改变控制流,仅延迟副作用;而 HTTP 状态码是响应头的一部分,一旦写出即不可回溯。

第二章:defer + WriteHeader竞态的底层原理剖析

2.1 Go HTTP服务器状态码写入的双阶段机制(Header vs Body)

Go 的 http.ResponseWriter 实现了状态码写入的隐式双阶段控制:状态码仅在首次写入响应头(Header)或响应体(Body)时被最终确定并发送。

状态码锁定时机

  • 首次调用 WriteHeader(statusCode) → 显式设定并锁定状态码
  • 首次调用 Write([]byte) 且未调用过 WriteHeader()隐式写入 200 OK 并锁定
func handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain")
    // 此时状态码尚未发送,Header可自由修改
    w.WriteHeader(404) // ✅ 显式触发:状态码+Header立即写入底层连接
    w.Write([]byte("Not found")) // ✅ Body写入,但状态码已不可变
}

逻辑分析:WriteHeader(404) 调用会检查 w.wroteHeader 标志位;若为 false,则序列化 HTTP/1.1 404 Not Found 行 + 当前 Header map 到底层 bufio.Writer,并置位 wroteHeader = true。后续任何 WriteHeader() 调用将被静默忽略。

双阶段行为对比

阶段 触发条件 状态码是否可变更 Header 是否可修改
Header 准备期 WriteHeader() 未调用 ✅ 是 ✅ 是
Header 提交期 WriteHeader()Write() 首次调用后 ❌ 否 ❌ 否(已刷新)
graph TD
    A[Handler 开始] --> B{WriteHeader called?}
    B -->|Yes| C[Status + Headers flushed]
    B -->|No| D{Write called first?}
    D -->|Yes| E[Implicit 200 OK + Headers flushed]
    C --> F[Status locked, Body writable]
    E --> F

2.2 defer语句在HTTP handler执行栈中的实际插入时机验证

关键观测点:defer并非“函数返回时”才注册,而是在defer语句执行时立即注册,但延迟调用排队至外层函数return前。

func handler(w http.ResponseWriter, r *http.Request) {
    log.Println("→ handler start")
    defer log.Println("← defer #1 registered at line 3") // 此刻即入栈
    if r.URL.Path != "/" {
        http.Error(w, "not found", http.StatusNotFound)
        return // 此处return触发所有已注册defer
    }
    defer log.Println("← defer #2 registered at line 8") // 仅当路径为/时执行并注册
    fmt.Fprint(w, "OK")
}

defer语句本身是运行时指令:每执行一次defer xxx(),就将xxx及其当前参数快照压入该goroutine的defer链表。与return位置无关,只与语句是否被执行有关。

执行路径对比表

请求路径 注册的defer数量 触发顺序
/ 2 #2 → #1
/api 1 #1(仅#1被注册)

执行时序示意(mermaid)

graph TD
    A[handler invoked] --> B[log start]
    B --> C[defer #1 registered]
    C --> D{path == “/”?}
    D -- Yes --> E[defer #2 registered]
    D -- No --> F[http.Error + return]
    E --> G[fmt.Fprint]
    G --> H[return → run defer #2 then #1]
    F --> I[return → run defer #1 only]

2.3 WriteHeader被多次调用时的底层panic抑制与静默丢弃行为

Go HTTP 标准库对 WriteHeader 的重复调用采取静默丢弃策略,而非 panic。

行为验证示例

func handler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(200)
    w.WriteHeader(500) // 被忽略,无 panic,响应码仍为 200
    w.Write([]byte("OK"))
}

逻辑分析:responseWriter 内部通过 w.wroteHeader 布尔字段标记状态;第二次调用直接 return,不修改 w.status 或触发底层 write。参数 code 被完全丢弃。

关键状态流转

状态字段 初始值 首次 WriteHeader 后 二次 WriteHeader 后
w.wroteHeader false true true(不变)
w.status 0 200 200(未更新)

底层控制流

graph TD
    A[WriteHeader(code)] --> B{w.wroteHeader?}
    B -->|true| C[return // 静默退出]
    B -->|false| D[设置w.status = code<br>标记w.wroteHeader = true]

2.4 Chrome DevTools Network面板无法捕获状态码的协议层根源分析

Chrome DevTools 的 Network 面板依赖 HTTP/1.1 响应头解析Chromium 网络栈(NetLog)事件注入机制,但对以下场景无法捕获状态码:

  • HTTP/2 早期流复用中 HEADERS 帧未携带 :status(如因流重置中断)
  • QUIC v1 中 STREAM 帧携带状态码需解密后解析,而 DevTools 默认不接入 TLS 1.3 密钥日志
  • 本地环回请求(localhost)经 Mojo IPC 路径绕过 NetworkService 的状态上报链路

状态码丢失的关键路径对比

协议 状态码承载位置 DevTools 可见性 原因
HTTP/1.1 响应首行 HTTP/1.1 200 OK 直接文本解析
HTTP/2 :status 伪头字段 ⚠️(部分丢失) 流取消时帧未送达渲染进程
QUIC Application Close 无密钥日志,无法解密帧
// Chromium 源码中 NetworkPanel 状态码提取逻辑(net/http/http_stream_parser.cc)
int HttpStreamParser::ReadResponseHeaders(...) {
  // 仅当 headers_complete_ 为 true 且 status_line_.IsValid() 时才上报
  if (!headers_complete_ || !status_line_.IsValid()) {
    return ERR_CONNECTION_ABORTED; // 此时 DevTools 显示 "(failed)" 无状态码
  }
}

该逻辑表明:状态码提取强依赖响应头完整到达与解析成功。若底层协议因流控制、连接迁移或加密隔离导致 status_line_ 构造失败,则 Network 面板永远无法获取该字段。

graph TD
  A[客户端发起请求] --> B{协议类型}
  B -->|HTTP/1.1| C[Text-based status line → Parse → Report]
  B -->|HTTP/2| D[HEADERS frame → :status → 若流重置则丢弃]
  B -->|QUIC| E[Encrypted STREAM frame → 需 SSLKEYLOGFILE → 否则跳过]
  C --> F[Network Panel 显示状态码]
  D -->|流异常| G[Report as 'cancelled' 无 status]
  E -->|无密钥日志| G

2.5 使用net/http/httptest与Wireshark交叉验证竞态窗口的实验设计

实验目标

构建可复现的 HTTP 竞态场景,通过 httptest 在进程内模拟高并发请求,同时用 Wireshark 捕获真实 TCP 层时序,定位竞态窗口(race window)的起止边界。

关键代码片段

// 启动带延迟写入的测试服务,暴露竞态点
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    time.Sleep(10 * time.Millisecond) // 模拟临界区延迟
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}))
srv.Start()

逻辑分析:NewUnstartedServer 允许在启动前注入可控延迟;10ms 是人为拉宽的竞态窗口,便于 Wireshark 捕获 SYN-ACK 与响应数据包之间的时间差;w.WriteHeader()w.Write() 分离调用,使 HTTP 响应头与体分帧发送,增强 TCP 层可观测性。

验证方法对比

工具 观测层级 时间精度 是否需网络栈介入
httptest 应用层 ~µs
Wireshark 网络/传输层 ~ns

协同分析流程

graph TD
    A[Go 测试程序] -->|并发发起100+请求| B(httptest Server)
    B -->|HTTP 响应流| C[TCP socket]
    C --> D[Wireshark 抓包]
    D --> E[标记 FIN/ACK 与首个响应字节时间戳]
    E --> F[比对 httptest 日志中的 ServeHTTP 耗时]

第三章:典型误用模式与线上故障复现

3.1 defer resp.WriteHeader(http.StatusForbidden) 的隐蔽失效场景

WriteHeaderdefer 延迟调用,而 handler 中提前调用了 resp.Write()json.NewEncoder(resp).Encode(),Go HTTP Server 会自动触发隐式 WriteHeader(http.StatusOK) —— 此后再次调用 WriteHeader 将被静默忽略。

触发条件

  • resp.Header()Write 前被修改(不影响失效判定)
  • resp.Write() 写入非零字节(哪怕仅 []byte{0}
  • defer WriteHeader(...) 位于 Write 之后的代码路径中

典型失效代码

func handler(w http.ResponseWriter, r *http.Request) {
    data := []byte("access denied")
    defer w.WriteHeader(http.StatusForbidden) // ❌ 永远不会生效
    w.Write(data) // ✅ 触发隐式 WriteHeader(200)
}

逻辑分析w.Write(data) 内部检测到 header 未写入,自动调用 w.WriteHeader(http.StatusOK);后续 defer 执行时 w.headerWritten == true,直接 return。参数 http.StatusForbidden 被丢弃,响应码仍为 200。

失效判定对照表

场景 WriteHeader 是否生效 原因
defer WriteHeader() + Write([]byte{}) 零长写仍触发隐式 header
defer WriteHeader() + WriteHeader(200) 显式调用 header 已标记已写入
WriteHeader(403) + defer Write([]byte{}) header 先于 body 写入
graph TD
    A[handler 开始] --> B{是否已调用 Write/WriteHeader?}
    B -->|否| C[defer WriteHeader 执行]
    B -->|是| D[headerWritten == true → 忽略]
    C --> E[返回 403]
    D --> F[返回首次 WriteHeader 状态码]

3.2 中间件中defer写状态码导致主handler覆盖的链式失效案例

问题根源:HTTP 状态码的最终裁定权在 WriteHeader

Go 的 http.ResponseWriter 状态码由首次调用 WriteHeader() 决定,后续调用被忽略。中间件中若在 defer 里写状态码,而主 handler 已提前 WriteHeader(200),则 defer 中的 WriteHeader(500) 无效。

典型错误模式

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if r.Context().Value("auth_error") != nil {
                w.WriteHeader(http.StatusUnauthorized) // ❌ 被主 handler 覆盖
            }
        }()
        next.ServeHTTP(w, r)
    })
}

逻辑分析defernext.ServeHTTP 返回后执行,但此时主 handler 可能已调用 w.WriteHeader(200)。Go 标准库对重复 WriteHeader 的处理是静默丢弃(net/http/server.go#L214),参数 w 是浅拷贝接口,无状态回滚能力。

正确实践对比

方式 是否可逆 时机控制 推荐度
defer w.WriteHeader() 滞后、不可干预 ⚠️ 避免
提前校验 + return 主动中断链路 ✅ 推荐
封装 ResponseWriter 拦截 完全可控 ✅ 进阶
graph TD
    A[AuthMiddleware] --> B{鉴权失败?}
    B -- 是 --> C[立即 WriteHeader 401 并 return]
    B -- 否 --> D[调用 next.ServeHTTP]
    D --> E[主 handler 可能 WriteHeader 200]
    C --> F[链路终止]
    E --> G[defer 执行 → WriteHeader 401 失效]

3.3 日志埋点与监控指标中状态码统计偏差的真实生产事故还原

事故背景

某支付网关在灰度发布后,监控大盘显示 HTTP 5xx 错误率突增 0.8%,但日志系统统计的 status_code 字段中 5xx 占比仅 0.02%——二者相差40倍。

根本原因定位

埋点代码未捕获 Netty 异步异常兜底状态:

// ❌ 错误:仅记录业务逻辑返回的状态码,忽略连接超时/重置等场景
log.info("status={}", response.getStatusCode()); // response可能为null或未初始化

// ✅ 修正:统一在ChannelHandler末尾注入真实响应状态
ctx.channel().attr(ATTR_STATUS).get(); // 由Netty生命周期钩子写入最终状态

该埋点遗漏了 IOException 触发的 599 Network Connect Timeout(自定义状态),而 Prometheus 指标采集器直接读取 response.status,导致指标与日志语义错位。

关键差异对比

统计源 覆盖状态码范围 是否含连接层错误
Nginx access log 2xx/3xx/4xx/5xx
应用层埋点日志 ResponseEntity 显式设值
Netty final status 2xx/4xx/5xx/599/600+

数据同步机制

graph TD
    A[Netty ChannelInactive] --> B{是否已写入status?}
    B -->|否| C[强制写入599]
    B -->|是| D[正常flush]
    C --> E[LogAppender捕获]

第四章:安全可靠的替代方案与工程化防护

4.1 显式状态码管理器(StatusCodeManager)的设计与泛型实现

StatusCodeManager 是一个类型安全的状态码中枢,支持编译期校验与运行时动态映射。

核心设计动机

  • 避免魔法数字散落各处
  • 统一错误传播路径与可观测性接入点
  • 支持多协议状态码语义对齐(HTTP/GRPC/自定义)

泛型实现要点

class StatusCodeManager<T extends string> {
  private readonly codes: Map<T, number> = new Map();

  register(code: T, value: number): this {
    this.codes.set(code, value);
    return this;
  }

  get(code: T): number | undefined {
    return this.codes.get(code);
  }
}

逻辑分析:T extends string 约束确保键为字面量类型(如 'NOT_FOUND' | 'TIMEOUT'),配合 TypeScript 的字符串字面量推导,可实现 manager.get('INVALID') 编译报错;register() 返回 this 支持链式注册。

常用状态码对照表

语义标识 HTTP 状态 gRPC Code
SUCCESS 200 OK
NOT_FOUND 404 NOT_FOUND
graph TD
  A[客户端请求] --> B[StatusCodeManager.get]
  B --> C{是否存在映射?}
  C -->|是| D[返回数值码]
  C -->|否| E[抛出 TypeMismatchError]

4.2 基于context.Context的状态码拦截中间件(含early-return检测)

在 HTTP 中间件中,利用 context.Context 携带状态码意图,可实现跨 handler 边界的响应控制。

核心设计思想

  • 将目标 HTTP 状态码(如 401, 429)注入 context
  • 后续中间件或 handler 通过 ctx.Value() 提前感知并终止执行流

early-return 检测流程

func StatusCodeInterceptor(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        if code, ok := ctx.Value("status_code").(int); ok {
            w.WriteHeader(code) // 非 2xx/3xx 时跳过后续逻辑
            return // ✅ early-return 触发点
        }
        next.ServeHTTP(w, r)
    })
}

该中间件在请求链头部运行;一旦 ctx.Value("status_code") 存在且为 int 类型,立即写入状态码并返回,避免后续 handler 执行。"status_code" 是约定 key,需与上游(如鉴权中间件)协同注入。

典型注入场景(示意)

场景 注入时机 示例代码片段
JWT 过期 认证中间件末尾 r = r.WithContext(context.WithValue(r.Context(), "status_code", 401))
限流触发 速率限制中间件 ctx = context.WithValue(ctx, "status_code", 429)
graph TD
    A[Request] --> B[Auth Middleware]
    B -->|ctx.WithValue 401| C[StatusCodeInterceptor]
    C -->|WriteHeader+return| D[Response]
    B -->|no error| C2[Next Handler]
    C2 --> E[Normal Flow]

4.3 静态代码分析工具(golangci-lint自定义rule)识别危险defer模式

什么是危险的 defer 模式?

defer 延迟调用中包含可能 panic 的操作(如未判空解引用、非幂等资源关闭),且其执行时机晚于错误返回路径时,易导致资源泄漏或崩溃。

自定义 golangci-lint rule 示例

// rule: dangerous-defer-check
func checkDeferCall(n *ast.CallExpr, ctx *lint.RuleContext) {
    if isDangerousDeferTarget(n.Fun) { // 判定是否为已知高危函数(如 unsafeClose、derefPtr)
        ctx.Warn("defer of potentially panicking call may skip error-handling path", n)
    }
}

该检查遍历 AST 中所有 defer 节点,对 n.Fun(被延迟调用的函数表达式)进行白名单/黑名单匹配,并结合作用域分析判断是否位于 if err != nil { return } 后。

检测覆盖场景对比

场景 是否触发 说明
defer f()(f 安全) 无副作用或 panic 风险
defer ptr.Close()(ptr 可能为 nil) 解引用前未校验
defer mu.Unlock()(在 lock 失败后) 逻辑顺序错位
graph TD
    A[解析 defer 语句] --> B{是否调用高危函数?}
    B -->|是| C[检查前置错误分支]
    B -->|否| D[跳过]
    C --> E{是否存在未覆盖的 error return 路径?}
    E -->|是| F[报告危险 defer]

4.4 单元测试+集成测试双覆盖:断言WriteHeader调用次数与最终状态码一致性

在 HTTP 处理器测试中,WriteHeader 的调用行为常被忽略,但其调用次数与最终响应状态码的匹配性直接决定协议合规性。

为什么需双重验证?

  • 单元测试可隔离验证 WriteHeader 是否被恰好调用一次(防重复调用);
  • 集成测试通过 httptest.ResponseRecorder 捕获实际写入的状态码,确保与预期一致。

断言逻辑示例

// 测试 WriteHeader 调用次数与最终状态码一致性
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// rec.WriteHeaderCalls 是 httptest 扩展字段(需自定义 Recorder 或使用第三方库如 go-http-recorder)
assert.Equal(t, 1, rec.WriteHeaderCalls)       // 断言仅调用一次
assert.Equal(t, http.StatusOK, rec.Code)        // 断言最终状态码

WriteHeaderCalls 需基于 ResponseWriter 包装器实现计数;rec.Code 返回最后一次 WriteHeader 或隐式写入的最终状态码,二者必须语义对齐。

常见不一致场景对比

场景 WriteHeader 调用次数 rec.Code 合规性
正常流程 1 200
未显式调用,仅 Write() 0 200(隐式) ⚠️ 协议允许但难观测
重复调用 WriteHeader(500)WriteHeader(200) 2 200(后一次生效) ❌ 违反 HTTP/1.1
graph TD
    A[Handler.ServeHTTP] --> B{是否已写入header?}
    B -- 否 --> C[记录 WriteHeader 调用并设置 code]
    B -- 是 --> D[忽略后续 WriteHeader 调用]
    C --> E[最终 rec.Code = 最后有效 code]

第五章:从defer陷阱到Go HTTP语义演进的再思考

defer不是万能的资源守门员

在生产环境排查一个持续数小时的连接泄漏问题时,我们发现如下典型模式:

func handleUpload(w http.ResponseWriter, r *http.Request) {
    file, err := r.MultipartReader().NextPart()
    if err != nil {
        http.Error(w, "bad part", http.StatusBadRequest)
        return
    }
    defer file.Close() // ❌ 错误:file可能为nil,panic!

    // ... 处理逻辑
}

file.Close()file == nil 时直接 panic,而 defer 不会跳过执行。正确写法应先判空:

if file != nil {
    defer file.Close()
}

更稳健的做法是封装为带空值防护的 safeClose 工具函数,并在 CI 流程中通过 staticcheck -checks=SA1019 检测未检查的 io.Closer 使用。

HTTP/1.1 连接复用与 context 超时的隐性冲突

Go 1.12 引入 http.Transport.IdleConnTimeout 后,大量服务在高并发下出现 net/http: request canceled (Client.Timeout exceeded while awaiting headers)。根本原因在于:

  • 客户端设置 context.WithTimeout(ctx, 5s)
  • 服务端 Handler 内部调用下游 HTTP 服务耗时 4.8s
  • 此时连接池中的空闲连接被 IdleConnTimeout=30s 保留,但客户端上下文已取消

我们通过 httptrace 注入追踪后发现,约 17% 的请求在 GotConn 阶段即因 ctx.Done() 被中断,却未触发 Transport 的连接释放逻辑,导致连接堆积。解决方案是显式配置 Transport.ResponseHeaderTimeout 与业务超时对齐,并启用 ForceAttemptHTTP2 = true 以减少 TLS 握手开销。

Go 1.22 中 net/http 的语义收缩

Go 1.22 移除了 http.ServeMux.Handler 方法的隐式重定向逻辑(如 /foo//foo 的自动 301),并要求开发者显式注册路径后缀。这暴露了长期被忽略的路由歧义问题:

场景 Go 1.21 行为 Go 1.22 行为 影响
mux.Handle("/api/", h) + 请求 /api 自动 301 重定向 返回 404 前端 fetch 因 CORS 限制无法处理重定向
mux.HandleFunc("/health", h) + 请求 /health/ 404 404 行为一致,无风险

迁移时需批量扫描所有 Handle/HandleFunc 调用,使用正则 \/\w+\/?$ 提取路径,并为带尾斜杠的路由补充显式注册:

mux.Handle("/api/", http.StripPrefix("/api/", apiHandler))
mux.Handle("/api", http.RedirectHandler("/api/", http.StatusFound)) // 显式声明

中间件链中 defer 的生命周期错位

在 Gin 框架中,以下中间件看似合理实则危险:

func authMiddleware(c *gin.Context) {
    token := c.GetHeader("Authorization")
    user, err := validateToken(token)
    if err != nil {
        c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
        return
    }
    defer recordUserAccess(user.ID) // ⚠️ 此处 defer 在 c.Next() 后才执行!
    c.Next()
}

当后续 handler panic 时,recordUserAccess 仍会执行,导致错误审计日志。正确方式是将审计逻辑放在 c.Next() 之后、c.Abort() 之前,或使用 c.Set() 传递状态并在 recovery 中统一处理。

HTTP 流式响应的 context 可取消性验证

我们构建了一个压力测试矩阵,验证不同 ResponseWriter 实现对 context.Context 的响应能力:

Writer 类型 context.Done() 触发后是否立即停止 Write 是否释放 goroutine 测试结论
标准 http.ResponseWriter 否(阻塞至 TCP 窗口满) 否(goroutine 挂起) 需配合 http.TimeoutHandler
flute.ResponseWriter(第三方) 是(检测 ctx.Err() 推荐用于长轮询场景
net/http/httputil.ReverseProxy 部分(仅 header 阶段可中断) 反向代理需自定义 Director 注入 cancel

该数据直接驱动了公司 API 网关的流控策略升级:对 SSE 接口强制注入 context.WithCancel 并监听 Done(),避免客户端断连后服务端持续推送。

用代码写诗,用逻辑构建美,追求优雅与简洁的极致平衡。

发表回复

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