Posted in

Chi框架Group路由嵌套隐患:子router panic不被捕获、middleware作用域泄露、404 handler覆盖父级全局handler实测复现

第一章:Chi框架Group路由嵌套隐患全景概览

Chi 是轻量、高性能的 Go HTTP 路由器,其 Group 方法支持路由前缀与中间件的批量绑定,但深层嵌套 Group 会悄然引入多类运行时隐患,影响可维护性、调试效率与请求匹配行为。

路由匹配顺序被隐式覆盖

Chi 使用树形结构匹配路径,而嵌套 Group 会生成复合前缀(如 /api/v1/users → /api/v1/users/profile)。当多个 Group 共享相同前缀但注册顺序错位时,更宽泛的 Group(如 /api/*)可能因注册在后而“遮蔽”已注册的精细路由。例如:

r := chi.NewRouter()
r.Group(func(r chi.Router) {
    r.Use(authMiddleware)
    r.Get("/profile", profileHandler) // 实际注册为 /profile
}).Group(func(r chi.Router) {
    r.Use(loggingMiddleware)
    r.Get("/profile", legacyProfileHandler) // 同样注册为 /profile —— 覆盖前一个!
})

⚠️ 注意:Chi 不校验重复路径,后注册者直接覆盖前者,且无编译或启动时警告。

中间件作用域意外泄露

嵌套 Group 的中间件并非严格“局部作用”,而是沿调用链向上继承。若外层 Group 注册了 recoverer,内层 Group 即使未显式调用 Use(),仍会继承该中间件——但若内层 Group 提前 Return 或 Abort(),中间件执行链可能中断,导致 panic 捕获失效。

路径拼接易引发空段错误

空字符串前缀(如 r.Group(func(r chi.Router){}))或动态拼接路径(如 fmt.Sprintf("/v%s", version))可能产生双斜杠 //,Chi 默认不归一化路径,导致 GET //users 与 /users 视为不同路由,测试难以复现。

隐患类型 触发条件 典型后果
路由覆盖 同路径多次注册于嵌套 Group 处理函数静默替换
中间件泄漏 外层 Group Use + 内层 Abort recoverer 失效
路径歧义 动态前缀含 / 或空字符串 匹配失败或 404 误报

避免深层嵌套(建议 ≤2 层),优先使用扁平化分组 + 显式前缀声明,并在启动时通过 chi.Walk() 遍历验证路由唯一性。

第二章:子Router panic不被捕获的深层机制与修复方案

2.1 Chi内部panic传播链路分析与源码追踪

Chi框架中,panic的传播并非简单向上传递,而是被recover()主动捕获并转化为HTTP 500响应,但其原始调用栈仍需完整保留以供调试。

panic触发点定位

核心入口在http.ServeHTTP调用链末端:

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if err := recover(); err != nil {
            http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            log.Printf("panic recovered: %+v\n", err) // 关键日志
        }
    }()
    h.ServeHTTP(w, r) // 实际路由执行,此处可能panic
}

该defer块位于chi.Mux构造的中间件链末尾,确保所有中间件与最终handler的panic均被统一捕获。

传播路径关键节点

  • chi.(*Mux).ServeHTTP → chi.(*node).ServeHTTP → 用户注册handler
  • 每层ServeHTTP均不主动recover,依赖最外层兜底
节点 是否recover 作用
chi.Mux ✅ 全局panic兜底
自定义中间件 ❌(推荐) 避免掩盖深层错误
最终handler ❌ 应保持原始panic语义
graph TD
A[HTTP Request] --> B[chi.Mux.ServeHTTP]
B --> C[Middleware Chain]
C --> D[User Handler]
D -- panic --> B
B -- recover --> E[Log + HTTP 500]

2.2 复现子router panic未触发recover的最小可验证案例

核心复现逻辑

以下代码仅启用子 router,不注册任何中间件,且 panic 发生在子路由 handler 中:

func main() {
    r := gin.New()                    // 无全局 Recovery 中间件
    v1 := r.Group("/api/v1")
    v1.GET("/panic", func(c *gin.Context) {
        panic("sub-router panic!") // 此 panic 不会被捕获
    })
    r.Run(":8080")
}

逻辑分析:gin.New() 创建的引擎默认不启用 Recovery() 中间件;子 router 继承父引擎的中间件栈,但未显式添加 recovery,因此 panic 直接向上传播至 HTTP server,导致进程崩溃。

关键差异对比

场景 是否触发 recover 原因
gin.Default() + 子路由 panic ✅ 是 默认注入 Recovery() 全局中间件
gin.New() + 子路由 panic ❌ 否 中间件栈为空,无 recover 拦截点

调用链示意

graph TD
A[HTTP Server] --> B[gin.Engine.ServeHTTP]
B --> C[匹配 /api/v1/panic]
C --> D[执行 v1.GET handler]
D --> E[panic!]
E --> F[无 recover middleware → goroutine crash]

2.3 对比Gin/Echo/HttpRouter中panic处理策略差异

默认行为差异

  • Gin:默认启用 Recovery() 中间件,捕获 panic 并返回 500,同时记录堆栈
  • Echo:默认不自动恢复 panic,需显式调用 e.Use(middleware.Recover())
  • HttpRouter:无内置 panic 恢复机制,完全依赖用户手动 recover()

恢复时机与上下文保留能力

框架 自动恢复 恢复后是否保留 http.ResponseWriter 状态 可注入自定义错误处理器
Gin ✅ ❌(已写入部分响应可能被丢弃) ✅(RecoveryWithWriter)
Echo ❌(需启用) ✅(支持 HTTPError 自定义响应) ✅(Config.Recover)
HttpRouter ❌ ✅(完全可控) ✅(需包裹 handler)

Gin 的 Recovery 中间件核心逻辑

func Recovery() HandlerFunc {
    return func(c *Context) {
        defer func() {
            if err := recover(); err != nil { // 捕获任意 panic
                c.AbortWithStatus(500)         // 强制终止并设状态码
                c.Error(fmt.Errorf("%v", err)) // 存入 error ring
            }
        }()
        c.Next()
    }
}

recover() 在 defer 中执行,确保在请求生命周期末尾(或 panic 发生时)触发;c.AbortWithStatus(500) 阻断后续中间件,但无法回滚已写入的 response body —— 这是 Gin 的关键限制。

Echo 的 Recover 设计更精细

e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
    DisableStackAll: false,
    StackTraceHandler: func(c echo.Context, err error, stack string) error {
        return c.String(http.StatusInternalServerError, "Oops!")
    },
}))

通过 StackTraceHandler,可在恢复后精确控制响应内容与格式,且 c.Response().Writer 未被提前 flush,保障响应一致性。

2.4 在middleware链中安全注入recover handler的实践模式

核心原则:位置即安全

recover handler 必须置于 middleware 链最外层(紧邻 HTTP 服务器入口),否则上游 panic 将绕过捕获。

推荐注入模式

  • ✅ 使用 http.Handler 包装器,而非 http.HandlerFunc(避免类型擦除)
  • ✅ 在链首统一注册,禁止在业务中间件内动态插入
  • ❌ 避免与日志、认证等副作用中间件交叉嵌套

安全 recover 实现示例

func Recovery() func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if err := recover(); err != nil {
                    http.Error(w, "Internal Server Error", http.StatusInternalServerError)
                    log.Printf("PANIC: %+v\n", err) // 生产环境应脱敏
                }
            }()
            next.ServeHTTP(w, r)
        })
    }
}

逻辑分析:defer 在 handler 函数退出时执行,确保所有下游 panic 均被捕获;next.ServeHTTP 是唯一执行点,保证调用链完整性。参数 next 为链式下一环,类型强约束为 http.Handler,杜绝类型不安全转换。

中间件链典型顺序

位置 中间件 说明
1 Recovery 兜底 panic 捕获
2 Logging 记录请求生命周期
3 Auth 身份校验(panic 后不可执行)
graph TD
    A[HTTP Server] --> B[Recovery]
    B --> C[Logging]
    C --> D[Auth]
    D --> E[Business Handler]

2.5 自定义RouterWrapper封装panic防护层的工程化实现

在高可用网关场景中,未捕获的 panic 可能导致整个 HTTP 服务崩溃。为提升鲁棒性,需在路由分发前统一拦截异常。

核心设计原则

  • 零侵入:不修改现有 handler 签名
  • 可配置:支持自定义错误响应格式与日志级别
  • 可观测:记录 panic 堆栈、请求路径、方法

RouterWrapper 实现

func NewRouterWrapper(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
                log.Printf("[PANIC] %s %s: %v", r.Method, r.URL.Path, err)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

逻辑分析:defer 在 handler 执行后触发;recover() 捕获当前 goroutine panic;log.Printf 记录关键上下文。参数 next 为原始 http.Handler,确保链式调用兼容性。

防护能力对比

能力项 基础 wrapper 增强版(含上下文)
请求路径记录 ✅ ✅
用户身份透传 ❌ ✅
错误响应定制 ❌ ✅
graph TD
    A[HTTP Request] --> B[RouterWrapper]
    B --> C{panic?}
    C -->|Yes| D[Log + Return 500]
    C -->|No| E[Next Handler]
    D --> F[Client]
    E --> F

第三章:Middleware作用域泄露的隐式行为与边界控制

3.1 Group嵌套下middleware注册时机与作用域继承模型解析

Group嵌套结构中,中间件的注册时机决定其实际生效范围。注册发生在Group定义时(而非请求时),且遵循声明即绑定原则。

注册时机关键点

  • Middleware在Group.Use()调用时立即绑定到该Group实例
  • 子Group继承父Group已注册的中间件,但不继承后续动态追加的中间件
  • 父Group新增中间件不影响已创建的子Group实例

作用域继承模型示意

v1 := engine.Group("/v1")
v1.Use(authMiddleware) // ✅ 绑定至v1及所有现有/未来子Group

api := v1.Group("/api")
api.Use(loggingMiddleware) // ✅ 仅作用于/api路径及其子Group

admin := api.Group("/admin")
// admin 自动拥有 authMiddleware + loggingMiddleware

authMiddleware在v1上注册,因此api和admin均继承;loggingMiddleware注册于api,故admin继承但同级userGroup不共享。

中间件作用域继承规则

场景 是否继承 原因
子Group创建前父Group注册的中间件 ✅ 是 实例化时拷贝引用
子Group创建后父Group新增中间件 ❌ 否 Group结构不可变,无运行时监听
graph TD
    Root[Root Group] --> V1[/v1 Group/]
    V1 --> API[/v1/api Group/]
    API --> Admin[/v1/api/admin Group/]
    V1 -.->|authMiddleware| API
    V1 -.->|authMiddleware| Admin
    API -.->|loggingMiddleware| Admin

3.2 实测middleware在跨Group路径匹配时的意外生效场景

当使用 Gin 框架定义嵌套 Group(如 /api/v1 和 /admin/v1)并注册全局 middleware 时,路径匹配逻辑可能违背直觉。

路径注册与中间件绑定示例

r := gin.Default()
api := r.Group("/api")
api.Use(authMiddleware()) // 绑定到 /api 下所有子路径

admin := r.Group("/admin") // 独立 Group
admin.GET("/health", func(c *gin.Context) { c.String(200, "OK") })

⚠️ 关键点:authMiddleware() 仅声明于 api Group,但若 admin Group 中存在未显式指定 handler 的路由(如动态注册),或使用 r.Use() 全局注册,middleware 将无差别注入——Gin 的 middleware 是按注册顺序叠加到整个 Engine 栈,而非严格按 Group 范围隔离。

意外触发路径表

请求路径 是否触发 authMiddleware 原因说明
/api/users ✅ 是 显式绑定,符合预期
/admin/health ❌ 否 未被 api.Use() 影响
/admin/login ✅ 是(若误用 r.Use) 全局 middleware 无 Group 过滤

执行链路示意

graph TD
    A[HTTP Request] --> B{Router Match}
    B --> C[/api/* path?]
    B --> D[/admin/* path?]
    C --> E[authMiddleware → handler]
    D --> F[handler only]
    subgraph Misconfiguration Risk
      G[r.Use(auth)] --> B
    end

3.3 基于context.Value与middleware标识符的精准作用域隔离方案

在高并发 HTTP 服务中,跨中间件传递请求上下文需避免全局变量污染,context.WithValue 结合唯一 middleware 标识符可实现细粒度作用域隔离。

核心设计原则

  • 每个中间件使用私有 type key struct{} 作为键,杜绝字符串键冲突
  • 键值对仅在当前 middleware 生命周期内有效,下游不可见

示例:鉴权中间件隔离用户身份

type authKey struct{} // 私有类型,确保键唯一性

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := extractUserID(r) // 从 token 解析
        ctx := context.WithValue(r.Context(), authKey{}, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

逻辑分析:authKey{} 是未导出空结构体,无法被外部构造相同类型键;context.WithValue 返回新 ctx,原 r.Context() 不变,保证不可变性与线程安全。参数 userID 为 string 类型,由 JWT 解析获得,生命周期绑定至本次请求。

作用域对比表

场景 共享范围 安全性 可追溯性
context.WithValue 当前请求链 高 ✅(键类型明确)
全局 map 整个进程 低 ❌(竞态风险)
HTTP header 透传 跨服务(含网络) 中 ⚠️(需校验)

数据流示意

graph TD
    A[HTTP Request] --> B[AuthMiddleware]
    B --> C[WithContext: authKey→userID]
    C --> D[Handler]
    D --> E[业务逻辑中 Value(authKey{})]

第四章:404 Handler覆盖父级全局Handler的冲突逻辑与治理路径

4.1 Chi路由树中NotFoundHandler优先级判定规则源码剖析

Chi 的 NotFoundHandler 并非全局兜底,其触发时机严格依赖路由树匹配失败后的路径前缀判定逻辑。

匹配失败时的双重检查机制

当请求路径未命中任何注册路由时,Chi 执行以下判定:

  • 检查是否存在已注册的 NotFoundHandler
  • 验证当前请求路径是否“属于”某注册前缀(如 /api/ 下访问 /api/v1/missing)
// chi/mux.go 中关键片段
if mx.notFoundHandler != nil && mx.matched == nil {
    // 只有在无任何子节点匹配,且路径不落入已知前缀时才调用
    if !mx.hasMatchingPrefix(r.URL.Path) {
        mx.notFoundHandler.ServeHTTP(w, r)
        return
    }
}

hasMatchingPrefix 遍历所有已注册路由前缀,判断请求路径是否以任一前缀开头。若存在(如 /admin/),则认为“应由对应子树处理”,跳过 NotFoundHandler。

优先级判定本质

条件 是否触发 NotFoundHandler
完全无匹配 + 无重叠前缀 ✅
完全无匹配 + 存在 /api/ 前缀且请求为 /api/xxx ❌(期望子树实现)
graph TD
    A[请求路径] --> B{匹配任意路由?}
    B -->|是| C[执行对应Handler]
    B -->|否| D{是否存在重叠前缀?}
    D -->|是| E[返回 404 不触发 NotFoundHandler]
    D -->|否| F[调用 NotFoundHandler]

4.2 复现Group内Mount导致父级全局404 handler被静默替换的完整链路

根因定位:Mount时的Handler注册覆盖逻辑

当在 Group 实例上调用 .Mount("/api", subRouter) 时,内部会调用 mountHandler,该函数无条件覆盖当前路由树的 NotFoundHandler,即使父级已注册全局404处理器。

// gin.go 中 mountHandler 的关键片段
func (group *RouterGroup) Mount(path string, h http.Handler) IRoutes {
    // ... 路由前缀处理
    group.engine.addRoute(http.MethodGet, path+"/", handlers...) // ⚠️ 此处隐式重置 NotFoundHandler
    group.engine.notFoundHandler = h // ← 直接赋值,未做合并或保护!
    return group
}

逻辑分析:group.engine.notFoundHandler = h 将子路由 h(通常为 http.NotFoundHandler())直接写入引擎实例,覆盖了此前通过 engine.NoRoute(...) 设置的自定义全局404 handler。参数 h 是 subRouter 的根 Handler,其自身不携带父级 NotFound 策略。

触发链路可视化

graph TD
    A[engine.NoRoute(custom404)] --> B[Group.Mount\\n“/v1” → subRouter]
    B --> C[engine.notFoundHandler = subRouter]
    C --> D[所有未匹配路径跳转至 subRouter.ServeHTTP]
    D --> E[父级 custom404 永不执行]

关键验证步骤

  • ✅ 启动前注册 engine.NoRoute(custom404)
  • ✅ 创建 v1 := engine.Group("/v1")
  • ✅ 执行 v1.Mount("/users", userRouter)
  • ❌ 访问 /nonexistent → 触发默认 404,而非 custom404
阶段 行为 结果
初始化 engine.NoRoute(custom404) ✅ 全局 handler 生效
Mount后 engine.notFoundHandler 被覆盖 ❌ 自定义 handler 丢失

4.3 使用chi.Chain与chi.Mux组合构建分层404 fallback机制

在 chi 路由器中,chi.Mux 提供嵌套路由能力,而 chi.Chain 可对子路由统一注入中间件——二者协同可实现语义化降级的 404 fallback 层次结构。

分层 fallback 设计思想

  • 根 Mux 捕获全局未匹配路径,返回通用 404
  • 子 Mux(如 /api)配置专属 Chain,携带 Recoverer + 自定义 NotFoundHandler
  • 更深层路由(如 /api/v1/users)可覆盖 fallback 行为

示例代码

root := chi.NewMux()
root.NotFound(func(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "Root 404: path not found", http.StatusNotFound)
})

api := chi.NewMux()
api.Use(panicRecovery) // 自定义 Chain 中间件
api.NotFound(func(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode(map[string]string{"error": "API 404"})
})
root.Mount("/api", api)

逻辑分析:root.NotFound 是最外层兜底;api.NotFound 仅作用于 /api/** 下未注册的子路径。chi.Chain 未显式出现在代码中,但 api.Use(...) 实质构建了绑定到该子 Mux 的 Chain,确保所有子路由共享中间件与 fallback。参数 panicRecovery 是自定义错误恢复中间件,不影响 404 流程,仅增强健壮性。

fallback 优先级对照表

路径示例 触发的 NotFound Handler 所属层级
/unknown root.NotFound 全局
/api/missing api.NotFound API 子域
/api/v1/users/x api.NotFound(继承) 深层嵌套
graph TD
    A[HTTP Request] --> B{Match /api/?}
    B -->|Yes| C[/api Mux Chain]
    B -->|No| D[root NotFound]
    C --> E{Match /api/v1/users/?}
    E -->|No| F[api.NotFound]

4.4 兼容性迁移指南:从chi v4到v5+对404 handler语义变更的适配策略

chi v5+ 将 NotFoundHandler 语义从“兜底路由未匹配时调用”升级为“仅当所有路由(含中间件链)明确放弃处理时触发”,行为更严格、可预测。

核心差异对比

场景 chi v4 行为 chi v5+ 行为
中间件返回 nil 但未写响应 触发 404 handler 不触发,继续后续路由匹配
路由匹配成功但 handler panic 调用 404 handler 由全局 panic 恢复机制接管,跳过 404 handler

迁移关键步骤

  • ✅ 移除在中间件中 return nil 的隐式放行逻辑
  • ✅ 显式调用 http.Error(w, "Not Found", http.StatusNotFound) 替代依赖默认兜底
  • ✅ 使用 chi.WithValue(...) + 自定义上下文标记,统一判定“真·未处理”
// v5+ 推荐:显式终止并委托 404 处理
func custom404Handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    http.Error(w, `{"error":"resource not found"}`, http.StatusNotFound)
}
// 注册:r.NotFound(custom404Handler)

此代码确保响应体格式与状态码严格一致;http.Error 自动设置 Content-Type 和 Status, 避免手动头设置遗漏。参数 w 与 r 保持标准接口契约,兼容所有 chi 版本中间件链。

graph TD
    A[请求进入] --> B{路由匹配?}
    B -->|是| C[执行 Handler]
    B -->|否| D[检查中间件是否已写响应]
    D -->|已写| E[结束]
    D -->|未写| F[调用 NotFoundHandler]

第五章:结论与框架选型建议

实战场景驱动的选型逻辑

在某省级政务数据中台项目中,团队初期采用 Spring Boot + MyBatis 构建微服务,但面对高频并发的实时人口核验接口(QPS ≥ 8,200),响应延迟峰值达1.4s。经压测分析,ORM 层对象映射与动态 SQL 解析成为瓶颈。切换至 Quarkus + Hibernate Reactive 后,借助编译期优化与 Vert.x 非阻塞 I/O,在同等硬件资源下平均延迟降至 86ms,GC 暂停时间减少 93%。该案例表明:I/O 密集型场景下,反应式框架的底层运行时优势不可替代。

多维度评估矩阵

维度 Spring Boot 3.x Quarkus 3.13 Micronaut 4.4 Node.js 20.x
启动耗时(ms) 1,240 47 89 62
内存占用(MB) 382 68 91 115
HTTP 吞吐(req/s) 12,800 24,500 21,300 18,900
DevOps 支持度 ★★★★☆ ★★★★★ ★★★★☆ ★★★☆☆
生态兼容性 ★★★★★ ★★★★☆ ★★★☆☆ ★★★★☆

注:测试环境为 AWS c5.2xlarge(8vCPU/16GB RAM),JDK 21,GraalVM 23.2;Node.js 使用 Fastify + Prisma。

团队能力适配性分析

某金融科技公司曾强行推行 Go + Gin 架构重构核心交易系统,但因 Java 背景开发人员缺乏 goroutine 调试经验,上线后出现 3 次隐蔽的竞态死锁(均发生在 Redis 分布式锁续期逻辑)。最终回退至 Spring Cloud Alibaba,并通过 @Async + ThreadPoolTaskExecutor 显式控制并发线程池,配合 Arthas 实时诊断,将故障平均修复时间(MTTR)从 47 分钟压缩至 9 分钟。技术选型必须匹配团队的 调试工具链熟练度 与 线上问题归因能力,而非单纯追求性能指标。

混合架构落地实践

在跨境电商订单履约系统中,采用分层混合策略:

  • 订单创建、支付回调等强一致性事务 → Spring Boot + Seata AT 模式
  • 商品搜索、推荐流处理 → Quarkus + Kafka Streams(状态存储启用 RocksDB)
  • 实时库存扣减 → Rust 编写 WASM 模块嵌入 Envoy Proxy,通过 gRPC 调用
    该架构使系统在大促期间支撑单日 2.3 亿订单,库存超卖率低于 0.00017%,且各模块可独立灰度发布。关键在于:边界清晰的服务契约(gRPC IDL + OpenAPI 3.1)与 统一可观测性采集层(OpenTelemetry Collector + Prometheus + Loki)。
flowchart LR
    A[HTTP Gateway] --> B{路由决策}
    B -->|REST API| C[Spring Boot Service]
    B -->|Event Stream| D[Quarkus Kafka Consumer]
    B -->|Low-latency Call| E[Rust WASM Module]
    C --> F[(MySQL Cluster)]
    D --> G[(Kafka Topics)]
    E --> H[(Redis Cluster)]
    style C fill:#4CAF50,stroke:#388E3C
    style D fill:#2196F3,stroke:#0D47A1
    style E fill:#9C27B0,stroke:#4A148C

技术债量化管理机制

某 SaaS 平台建立框架健康度评分卡:

  • 自动化测试覆盖率
  • 关键依赖存在 CVE-2023-XXXX 级漏洞且无补丁 → 扣 25 分
  • 构建耗时 > 8 分钟 → 扣 10 分
  • 生产环境日志中 WARN 级别错误日均 > 500 条 → 扣 20 分
    当单项得分 ≤ 60 或总分

Docker 与 Kubernetes 的忠实守护者,保障容器稳定运行。

发表回复

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