Posted in

Go JSON序列化陷阱(结构体标签失效链):6段“看似正确”却引发数据丢失的代码段

第一章:Go JSON序列化陷阱(结构体标签失效链)导论

Go 语言中,json.Marshal 和 json.Unmarshal 的行为高度依赖结构体字段的可见性与标签(tag)配置。然而,一个看似微小的疏忽——例如字段未导出、标签拼写错误、或嵌套结构体中标签继承中断——便可能引发静默失效:序列化结果缺失字段、字段名不符合预期,甚至整个嵌套对象被忽略。

常见失效场景包括:

  • 字段首字母小写(如 name string),导致无法被 encoding/json 包访问;
  • 标签中使用了错误的分隔符(如 json:"name," 多余逗号)或非法字符;
  • 使用 json:",omitempty" 时,零值字段被意外剔除,而业务逻辑实际需要保留空字符串或零值占位;
  • 嵌套匿名结构体未显式指定标签,导致外层标签无法穿透生效。

以下代码演示典型失效链:

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
    Addr address // 未导出字段,且无 json tag
}

type address struct { // 首字母小写 → 非导出类型
    City string `json:"city"`
}

// 序列化结果:{"name":"Alice","age":30} —— Addr 完全消失,无报错、无警告
u := User{Name: "Alice", Age: 30, Addr: address{City: "Beijing"}}
data, _ := json.Marshal(u)
fmt.Println(string(data)) // 输出不含 City 字段

修复方案需同步满足三项条件:字段导出(首字母大写)、嵌套类型导出、标签显式声明。正确写法如下:

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
    Addr Address `json:"addr"` // 显式命名 + 导出字段
}

type Address struct { // 首字母大写,导出类型
    City string `json:"city"`
}
失效原因 检查要点 修复动作
字段不可见 字段名是否首字母大写? 改为 Addr 而非 addr
标签语法错误 是否含多余空格、逗号或引号? 使用 json:"city" 而非 json:"city ",
嵌套类型未导出 内嵌结构体是否为导出类型? address → Address

标签失效不是运行时 panic,而是“静默丢弃”,极易在测试覆盖不足时流入生产环境。务必在单元测试中校验 JSON 输出的字段完整性与结构一致性。

第二章:结构体字段可见性与JSON标签基础失效

2.1 字段首字母小写导致的不可导出性陷阱(理论+可复现代码验证)

Go语言规定:仅首字母大写的结构体字段才可被外部包导出(exported)。小写字母开头的字段在跨包访问时为私有,序列化/反射/ORM等场景将直接忽略。

序列化失效现象

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name string `json:"name"` // ✅ 导出字段
    age  int    `json:"age"`  // ❌ 首字母小写 → 不导出
}

func main() {
    u := User{Name: "Alice", age: 30}
    data, _ := json.Marshal(u)
    fmt.Println(string(data)) // 输出:{"name":"Alice"} —— age 字段消失!
}

逻辑分析:json.Marshal 通过反射遍历结构体字段;age 因未导出(unexported),反射无法读取其值,故跳过序列化。参数 age 的标签 json:"age" 无效——标签仅对导出字段生效。

关键规则对比

字段声明 是否导出 JSON序列化可见 反射可读取
Name string ✅ 是 ✅ ✅
age int ❌ 否 ❌(静默丢弃) ❌

修复方案

  • 将 age 改为 Age int
  • 或使用 json 标签配合 json:",omitempty" 等增强语义(但前提仍是字段必须导出)

2.2 匿名字段嵌入时标签继承失效的边界条件(理论+嵌套结构体实测)

标签继承的基本前提

Go 中匿名字段嵌入时,外层结构体可访问内层字段的 json、xml 等 struct tag——仅当嵌入层级为直接一级且无同名字段遮蔽。

失效的典型边界条件

  • 嵌入字段被同名字段显式覆盖(即使类型不同)
  • 嵌入链中任一中间结构体使用 指针类型匿名字段(*Inner 而非 Inner)
  • 最内层字段为未导出字段(首字母小写),导致标签不可见

实测嵌套结构体行为

type Inner struct {
    Field string `json:"inner_field"`
}
type Middle struct {
    Inner // ✅ 直接嵌入:标签有效
}
type Outer struct {
    Middle // ❌ 间接嵌入:Outer 不继承 inner_field 标签
}

逻辑分析:Outer 通过 Middle 间接持有 Inner,但 json 包反射仅扫描直接匿名字段;Middle 是具名字段(虽无字段名,但类型名 Middle 占位),故 Inner 的 json:"inner_field" 对 Outer 不可见。参数说明:reflect.StructTag.Get("json") 在 Outer 的字段遍历中无法命中 Inner 的 tag。

嵌入方式 标签是否继承 原因
type A struct{ B } ✅ 是 直接匿名字段
type A struct{ *B } ❌ 否 指针类型破坏嵌入语义
type A struct{ b B } ❌ 否 非匿名(具名字段)
graph TD
    A[Outer] -->|嵌入| B[Middle]
    B -->|嵌入| C[Inner]
    C -->|tag: json:\"inner_field\"| D[仅对Middle生效]
    A -.->|反射遍历时跳过C| E[标签不可达]

2.3 json:"-" 与空标签 json:"" 的语义混淆与静默丢弃(理论+反序列化对比实验)

核心语义差异

  • json:"-":显式忽略字段,序列化与反序列化均跳过,Go 标准库完全不处理该字段
  • json:"":空标签被视为字段名为空字符串,触发 encoding/json 的特殊规则——反序列化时静默丢弃该字段值,但保留零值初始化

反序列化行为对比实验

type User struct {
    Ignored  string `json:"-"`
    EmptyTag string `json:""`
}

// 输入 JSON: {"Ignored":"x","":"y"}
var u User
json.Unmarshal([]byte(`{"Ignored":"x","":"y"}`), &u)
// 结果:u.Ignored==""(未赋值),u.EmptyTag==""(零值,"y"被静默丢弃)

逻辑分析:json:"-" 字段在 Unmarshal 的字段匹配阶段即被过滤;而 json:"" 会参与键匹配,因 JSON 键 "" 无法映射到结构体字段(无对应命名字段),故值 "y" 被直接丢弃,不报错、不警告。

行为差异速查表

标签形式 序列化行为 反序列化行为 是否报错
json:"-" 完全跳过字段 完全跳过字段 否
json:"" 输出空键 ""(极少用) 匹配失败 → 静默丢弃值 否

关键风险提示

  • 空标签 json:"" 在反序列化中不触发 error,也不填充字段,极易导致数据丢失且难以调试
  • 二者均无运行时提示,依赖开发者对标签语义的精确理解

2.4 omitempty 在零值类型上的误判链:指针、切片、map 的隐式空判定(理论+nil vs empty 深度验证)

omitempty 并非判断“是否为 nil”,而是依据 反射零值规则 进行序列化裁剪,导致语义混淆:

nil 与 empty 的本质差异

  • *int:nil ≠ new(int)(后者指向值为 的有效地址)
  • []string:nil 切片与 []string{}(len=0, cap=0)在 json.Marshal 中均被 omitempty,但底层结构不同
  • map[string]int:nil map 与 make(map[string]int) 均为空映射,但 len() 和 for range 行为一致

关键验证代码

type Config struct {
    Ptr  *int      `json:"ptr,omitempty"`
    Slice []int    `json:"slice,omitempty"`
    Map   map[int]int `json:"map,omitempty"`
}

func main() {
    v := Config{
        Ptr:  nil,                 // → 字段消失
        Slice: []int{},           // → 字段消失(len==0)
        Map:   make(map[int]int), // → 字段消失(len==0)
    }
    b, _ := json.Marshal(v)
    fmt.Println(string(b)) // 输出:{}
}

逻辑分析:json 包对每个字段调用 isEmptyValue(),该函数对指针判 v.IsNil(),对 slice/map 判 v.Len() == 0,完全忽略底层是否分配内存。

零值判定对照表

类型 nil 状态 len() isEmptyValue() 结果 序列化是否省略
*int true — true ✅
[]int{} false true ✅
map[int]int{} false true ✅

误判链形成路径

graph TD
A[struct tag omitempty] --> B[json.isEmptyValue]
B --> C1[指针:IsNil?]
B --> C2[切片/Map:Len()==0?]
C1 --> D1[忽略非nil但指向零值]
C2 --> D2[混淆nil与empty底层数组]
D1 & D2 --> E[数据丢失风险]

2.5 结构体字段重命名冲突:同名字段在嵌入结构体中的标签覆盖失效(理论+字段阴影现象复现)

当多个嵌入结构体包含同名字段时,Go 的标签(json、xml 等)不会合并或优先级覆盖,而是被最外层直接定义的字段所遮蔽——即发生“字段阴影”(field shadowing)。

字段阴影复现实例

type User struct {
    Name string `json:"name"`
}

type Admin struct {
    User
    Name string `json:"admin_name"` // ❌ 不生效:Name 已被嵌入 User 的 Name 遮蔽
}

func main() {
    a := Admin{User: User{Name: "Alice"}, Name: "Bob"}
    fmt.Println(json.Marshal(a)) // 输出: {"name":"Alice"}
}

逻辑分析:Admin 中显式声明的 Name 字段虽带 json:"admin_name" 标签,但因与嵌入 User.Name 同名,Go 编译器将其视为对嵌入字段的遮蔽声明;序列化时仍使用嵌入字段的标签,显式字段的标签被完全忽略。

关键行为规则

  • 嵌入字段优先于同名显式字段参与结构体导出和序列化
  • 标签仅作用于实际参与内存布局的字段,遮蔽字段不参与 JSON 映射
  • 无编译错误,属静默语义陷阱
场景 是否触发阴影 标签是否生效
同名嵌入 + 同名显式字段 ✅ 是 ❌ 显式字段标签失效
不同名字段(如 AdminName) ❌ 否 ✅ 生效
graph TD
    A[定义 Admin 结构体] --> B{存在同名嵌入字段?}
    B -->|是| C[显式字段被遮蔽]
    B -->|否| D[标签正常绑定]
    C --> E[JSON 序列化使用嵌入字段标签]

第三章:反射机制与JSON编码器内部行为偏差

3.1 json.Encoder 缓冲写入与 panic 恢复导致的静默截断(理论+io.Writer 实现干扰分析)

数据同步机制

json.Encoder 内部维护一个 bufio.Writer,调用 Encode() 时先序列化到缓冲区,仅当缓冲区满或显式 Flush() 时才真正写入底层 io.Writer。若在此过程中发生 panic(如自定义 Write 方法中触发),而调用方用 recover() 捕获却忽略错误,缓冲区残留数据将永久丢失。

关键干扰点

以下 io.Writer 实现会破坏 Encoder 的错误传播契约:

type BrokenWriter struct{ buf []byte }
func (w *BrokenWriter) Write(p []byte) (n int, err error) {
    // 忽略部分写入,且不返回错误 → Encoder 认为成功,但数据未落盘
    if len(p) > 10 {
        w.buf = append(w.buf, p[:10]...)
        return 10, nil // ❌ 静默截断
    }
    w.buf = append(w.buf, p...)
    return len(p), nil
}

逻辑分析:json.Encoder.Encode() 调用 Write() 后仅检查 err != nil;此处始终返回 nil 错误,导致编码器认为全部字节已写出,实际 p[10:] 被丢弃。缓冲区后续 Flush() 也无济于事——因为 Write() 本就未报告失败。

场景 是否触发 panic 是否静默截断 根本原因
Write 返回 n<len(p), err=nil 否 是 Encoder 信任 n 正确性
Write panic + recover() 未重抛 是 是 defer 中 Flush() 被跳过
graph TD
    A[Encode call] --> B[Serialize to buffer]
    B --> C{Write to io.Writer}
    C -->|n < len| D[Encoder assumes success]
    C -->|panic| E[recover without error re-throw]
    D --> F[Buffer not flushed → data lost]
    E --> F

3.2 自定义 MarshalJSON 方法中未调用 json.Marshal 引发的递归缺失(理论+错误实现 vs 正确委托模式)

错误实现:手动拼接 JSON 字符串

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func (u User) MarshalJSON() ([]byte, error) {
    return []byte(`{"id":` + strconv.Itoa(u.ID) + `,"name":"` + u.Name + `"}`), nil
}

⚠️ 问题:忽略字段标签、转义逻辑、嵌套结构序列化,且完全绕过标准 json.Marshal 的递归处理机制,导致 time.Time、map[string]interface{} 等嵌套字段无法正确序列化。

正确委托模式:安全复用标准库

func (u User) MarshalJSON() ([]byte, error) {
    type Alias User // 防止无限递归
    return json.Marshal(&struct {
        *Alias
        FullName string `json:"full_name"`
    }{
        Alias:    (*Alias)(&u),
        FullName: u.Name + " (user)",
    })
}

✅ 关键:通过匿名嵌入 Alias 类型规避 MarshalJSON 重入,同时复用 json.Marshal 的完整递归能力与转义逻辑。

方式 递归支持 标签解析 安全转义 嵌套类型兼容
手动拼接 ❌ ❌ ❌ ❌
委托 json.Marshal ✅ ✅ ✅ ✅

3.3 json.RawMessage 与 interface{} 混用时的类型擦除丢失(理论+动态结构体解析失败案例)

当 json.RawMessage 被赋值给 interface{},Go 运行时会丢失原始字节的结构上下文,仅保留底层 []byte —— 类型信息彻底擦除。

动态解析失效根源

var raw json.RawMessage = []byte(`{"id":1,"name":"alice"}`)
var v interface{} = raw // ← 此刻 raw 的类型元数据已丢失
var user struct{ ID int; Name string }
json.Unmarshal(raw, &user) // ✅ 成功
json.Unmarshal(v.([]byte), &user) // ❌ panic: interface {} is []uint8, not []byte

interface{} 存储的是 []uint8(Go 内部表示),而非可直接解码的 []byte 类型;强制类型断言失败。

关键差异对比

场景 类型保留 可直接 Unmarshal 运行时安全
json.RawMessage 变量 ✅ ✅ ✅
interface{} 中的 RawMessage ❌ ❌(需显式转换) ❌

安全解法流程

graph TD
    A[RawMessage] --> B{赋值给 interface{}} 
    B -->|类型擦除| C[丢失 byte slice 语义]
    C --> D[必须显式转换:v.([]byte)]
    D --> E[否则 panic]

正确做法:始终通过 json.RawMessage 字段接收,避免经由 interface{} 中转。

第四章:跨包交互与第三方库引发的标签污染链

4.1 gorm 或 sqlx 标签与 json 标签共存时的 struct tag 解析优先级冲突(理论+structtag 包源码级验证)

Go 的 reflect.StructTag 本身不定义优先级,仅提供 Get(key) 方法线性查找——json、gorm、sqlx 等标签并列存储于同一字符串,解析权完全交由各库自行实现。

标签解析本质是“按需提取”

type User struct {
    ID   int    `json:"id" gorm:"primaryKey" sqlx:"id"`
    Name string `json:"name" gorm:"size:100" sqlx:"name"`
}
  • json.Marshal 仅调用 tag.Get("json"),无视 gorm/sqlx;
  • gorm 内部使用正则 ^gorm:(.*)$ 提取并解析 gorm 子串;
  • sqlx 则调用 reflect.StructTag.Get("sqlx"),严格区分命名空间。
库 解析方式 是否覆盖其他标签 源码依据
encoding/json tag.Get("json") 否 src/encoding/json/encode.go
gorm.io/gorm 自定义 parseTag 函数 否 schema/field.go#ParseTag
github.com/jmoiron/sqlx tag.Get("sqlx") 否 bind.go#parseStructTag
graph TD
    A[struct tag string] --> B[json.Unmarshal]
    A --> C[gorm.AutoMigrate]
    A --> D[sqlx.NamedExec]
    B --> B1{Get “json”}
    C --> C1{正则匹配 “gorm:.*”}
    D --> D1{Get “sqlx”}

冲突只发生在开发者手动混用标签且期望语义重叠时(如 json:"-" gorm:"-"),但各库互不干涉——所谓“优先级”实为误解,本质是标签域隔离。

4.2 mapstructure 解码后二次 JSON 序列化时的标签丢失(理论+struct → map → struct 转换链断裂分析)

标签丢失的本质根源

mapstructure.Decode 将 JSON 解码为 struct 时,仅依据字段名(非结构体标签)映射;而后续 json.Marshal 默认使用 json 标签——但经 mapstructure 中转生成的中间 map[string]interface{} 已剥离所有结构体标签信息。

转换链断裂示意图

graph TD
    A[原始JSON] --> B[mapstructure.Decode → struct]
    B --> C[struct → map[string]interface{}]
    C --> D[json.Marshal → 新JSON]
    D -.-> E[丢失 json:\"xxx\" 标签]

典型复现代码

type User struct {
    Name string `json:"full_name"`
    Age  int    `json:"age_years"`
}
var raw = `{"full_name":"Alice","age_years":30}`
var m map[string]interface{}
json.Unmarshal([]byte(raw), &m) // 直接解到 map
var u User
mapstructure.Decode(m, &u)      // 无标签上下文
b, _ := json.Marshal(u)          // 输出:{"Name": "Alice", "Age": 30} ← 标签丢失!
  • mapstructure.Decode 不读取 json 标签,仅按字段名匹配
  • json.Marshal 对 struct 字段默认忽略 json 标签(因未触发反射标签解析路径)
  • 中间 map 层彻底切断结构体元数据传递链
阶段 是否保留 json 标签 原因
原始 struct ✅ 编译期嵌入
中间 map ❌ 运行时无类型信息
重建 struct ❌ mapstructure 不注入标签

4.3 Protobuf 生成结构体中 json_name 选项与原生 json tag 的竞争失效(理论+protoc-gen-go 版本兼容性实测)

理论冲突根源

当 .proto 文件中字段同时声明 json_name 选项(如 string name = 1 [json_name="full_name"];),而生成的 Go 结构体又被手动添加 json:"full_name,omitempty" tag 时,protoc-gen-go 会忽略用户自定义 tag——因生成器在代码生成阶段直接覆写 json tag。

版本行为差异(实测)

protoc-gen-go 版本 是否覆盖 json tag 备注
v1.28.x(旧) 否 尊重用户已有 tag
v1.30.0+(新) 是 强制以 json_name 为准

典型失效示例

// user.proto
message User {
  string display_name = 1 [json_name="displayName"];
}
// 手动添加的 tag 将被静默覆盖
type User struct {
  DisplayName string `json:"display_name,omitempty"` // ← 无效!生成后变为 `json:"displayName,omitempty"`
}

生成逻辑:protoc-gen-go 在 v1.30.0+ 中通过 descriptorpb.FieldOptions.JsonName 优先注入 json tag,跳过 ast 层已有 tag 检查。

解决路径

  • ✅ 始终通过 json_name 控制序列化名
  • ❌ 避免对生成结构体手工修改 json tag
  • ⚠️ 升级前需扫描所有 //go:generate 脚本及 CI 中的 protoc-gen-go 版本一致性

4.4 Go 1.21+ //go:embed 或 //go:build 注释干扰 struct tag 解析的边缘 case(理论+构建约束下反射行为变异)

当 //go:embed 或 //go:build 注释紧邻结构体定义(无空行分隔)时,Go 1.21+ 的 go/types 包在构建期解析 AST 时可能将注释误关联至后续 struct 字段,导致 reflect.StructTag 解析出非预期的 tag 内容。

构建约束引发的反射行为漂移

//go:embed config.json
type Config struct {
    Port int `json:"port"`
}

⚠️ 此处 //go:embed 被 go/types 错误绑定到 Config 类型节点,但某些构建模式(如 -toolexec 或自定义 loader)会将其“泄漏”进字段 tag 映射,使 reflect.TypeOf(Config{}).Field(0).Tag.Get("json") 返回空字符串而非 "port"。

关键差异对比

场景 Go 1.20 行为 Go 1.21+ 行为 触发条件
紧邻 //go:embed + struct tag 解析正常 tag 被清空或污染 无空行分隔、启用 -gcflags="-l"
//go:build 在 struct 前 忽略注释 可能触发类型跳过,导致 reflect 获取零值 条件不满足时 struct 不参与编译

防御性实践

  • 始终在 directive 与 type 定义间插入空行
  • 使用 go vet -tags 检测潜在 tag 冲突
  • 在 CI 中启用 GOOS=linux GOARCH=amd64 go build -a -gcflags="-l" 复现构建路径变异

第五章:防御性编程与标签安全最佳实践总结

标签注入的典型攻击路径还原

某电商后台管理系统曾因未过滤用户提交的 product_name 字段,导致 <script>alert(document.cookie)</script> 被直接渲染进商品列表页。攻击者通过构造含 <img src=x onerror="fetch('/api/admin?token='+document.cookie)"> 的恶意商品名,在管理员查看页面时窃取会话令牌。该漏洞本质是 HTML 标签未转义 + 事件属性未剥离 + 上下文感知缺失三重失效。

安全编码检查清单(强制执行项)

  • 所有动态插入 DOM 的字符串必须经 DOMPurify.sanitize() 处理(v3.0.5+ 支持 SAFE_FOR_TEMPLATES: true 模式)
  • 模板引擎中禁止使用 v-html(Vue)、{{{ }}}(Handlebars)等原始输出语法,改用 v-text 或 textContent
  • <input>、<textarea> 等表单控件值写入前需调用 encodeURIComponent() 防止 URL 编码绕过
  • 后端返回 JSON 中的 description 字段若含富文本,必须携带 content-security-policy: default-src 'self'; script-src 'none' 响应头

实战防护代码片段对比

// ❌ 危险写法(直接 innerHTML)
document.getElementById('userBio').innerHTML = userData.bio;

// ✅ 安全写法(上下文感知渲染)
const container = document.getElementById('userBio');
container.textContent = ''; // 清空旧内容
const safeText = document.createTextNode(userData.bio);
container.appendChild(safeText);

// ✅ 富文本场景(使用 DOMPurify)
const cleanHTML = DOMPurify.sanitize(userData.richBio, {
  ALLOWED_TAGS: ['b', 'i', 'u', 'p', 'br'],
  ALLOWED_ATTR: ['class']
});
container.innerHTML = cleanHTML;

关键配置项验证表

组件类型 必须启用的安全机制 检测命令示例 违规示例
React 应用 react-dom v18+ 自动转义 grep -r "dangerouslySetInnerHTML" ./src/ 存在 dangerouslySetInnerHTML 使用
Vue 3 项目 @vue/compiler-sfc 模板校验 npm run build && grep -i "v-html" dist/ 构建产物含未转义 HTML 标签
Spring Boot API Content-Security-Policy curl -I https://api.example.com/user | grep "Content-Security-Policy" 响应头缺失 CSP 策略

流程图:标签安全防护决策树

flowchart TD
    A[接收到用户输入] --> B{是否为纯文本显示?}
    B -->|是| C[使用 textContent 或 innerText]
    B -->|否| D{是否需保留部分 HTML?}
    D -->|是| E[调用 DOMPurify.sanitize\(\) 并限定白名单]
    D -->|否| F[拒绝渲染,返回 400 错误]
    C --> G[完成安全输出]
    E --> G
    F --> H[记录审计日志并告警]

生产环境监控指标

  • 每日 DOMPurify.removed 事件触发次数 > 50 次时自动触发安全团队告警
  • Chrome DevTools 控制台中 Refused to execute inline script 报错率突增 300% 触发 CSP 策略复审
  • OWASP ZAP 扫描报告中 XSS 风险等级从 High 降至 None 的修复闭环率需 ≥95%

紧急响应案例:某 SaaS 平台 XSS 漏洞热修复

2023年Q4,客户反馈仪表盘图表标题栏可执行任意 JS。排查发现 Chart.js 的 options.plugins.title.text 直接拼接了未过滤的用户输入。热修复方案:

  1. 在 title.text 渲染前增加 String.prototype.escapeHtml() 扩展方法(正则替换 <>&'")
  2. 临时禁用所有 title.text 的 HTML 解析能力(设置 options.plugins.title.display = false)
  3. 发布补丁版本后 4 小时内完成全量灰度发布,验证 alert(1) 输入被转义为 &lt;script&gt;alert(1)&lt;/script&gt;

安全测试自动化脚本片段

# 检测前端资源是否包含危险模式
find ./public -name "*.js" -exec grep -l "innerHTML\|outerHTML\|document\.write" {} \;
# 验证 CSP 头是否生效
curl -sI https://app.example.com/ | grep -i "content-security-policy" | grep -q "script-src.*'self'" && echo "CSP OK" || echo "CSP MISSING"

记录一位 Gopher 的成长轨迹,从新手到骨干。

发表回复

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