Skip to content
Go
encoding/xml · encoding/csv · encoding/base64

encoding/xml · encoding/csv · encoding/base64

这三个包都是"格式转换"工具,日常写法固定、坑集中在少数几个默认值上。所以本文不铺 API 全表,只把用法骨架 + 实测过的坑讲清楚:XML 的标签语义与命名空间现实能力,CSV 的缓冲与记录复用,Base64 的四种变体与填充严格配对。


1. encoding/xml

1.1 结构体标签

type User struct {
    XMLName xml.Name `xml:"user"`               // 元素名,必须是第一个字段
    ID      int      `xml:"id,attr"`            // 属性
    Name    string   `xml:"name"`               // 子元素
    Email   string   `xml:"email,omitempty"`    // 零值不输出
    Roles   []string `xml:"roles>role"`         // 嵌套:A>B 表示包一层
    Active  bool     `xml:"active,attr,omitempty"`
}

实测输出(xml.MarshalIndent(u, "", " ") 前面拼上 xml.Header):

<?xml version="1.0" encoding="UTF-8"?>
<user id="1">
  <name>Alice</name>
  <roles>
    <role>admin</role>
    <role>user</role>
  </roles>
</user>

Email、Active 因为是零值加 omitempty 直接消失——注意 XML 里"元素不存在"和"空元素"语义不同(<email/> vs 无),后端解析方若按 path 取节点,缺元素可能报"找不到节点"而不是空串。

标签的其他形式(下面每条都实测过):

  • xml:",chardata":把字段值作为元素文本(而非子元素),对应 <user>Alice</user> 这种"值在标签里"的写法。
  • xml:",innerxml":原样搬运内部 XML,不解结构。适合"这一段我不管,透传"。
  • xml:",cdata":输出成 <![CDATA[...]]>。
  • xml:",comment":输出成 <!--...-->(实测 <root><!--hi there--><v>2</v></root>),内容不能含 --。
  • xml:"-":跳过。
  • xml:",any":只在解码方向有意义。接收字段类型是 []struct{XMLName xml.Name; Chardata []string}(标准库没有 xml.Any 这个类型,网上贴的 []xml.Any 编译不过)。实测解 <root xmlns="urn:x"><known>k</known><extra a="1">text</extra><extra>two</extra></root>,两个 <extra> 都进了 Rest,XMLName.Space 为 urn:x,但 Chardata 是空的——子元素的文本不会随 ,any 落进来。同一个结构体反向 Marshal 时 ,any 不被特殊处理,会把字段按普通元素写出来(<x><Chardata>d</Chardata></x>),别指望它对称。
  • omitempty 在 XML 里的"empty"定义与 JSON 一致:false、0、nil 指针/interface、长度为 0 的数组/slice/map/字符串。实测 struct 值加 omitempty 照样输出(<s><A>0</A></s>),nil 指针才省略。XML 没有 omitzero。

陷阱:,cdata 与 ,innerxml 都属于"裸内容“字段——它们不生成自己的元素。在上面那个 User 结构体尾部再加两个字段(Raw string \xml:",innerxml”`和CData string `xml:",cdata"`,只给 CData赋值"x"),实测编码结果是 CDATA **直接贴在根元素内部**,没有 ` 包裹:

<user id="1">
  <name>Alice</name>
  <roles>
    <role>admin</role>
    <role>user</role>
  </roles><![CDATA[<b>x</b>]]>
</user>

解码方向更值得警惕:把这段解回同一个结构体,Raw(,innerxml)拿到的是从 <name> 到 CDATA 的整段内部 XML,而 CData 字段收到的是所有字符数据(缩进空白 + <b>x</b> 文本),两者都不是"精确取某一个节点"的手段。

结论:这两个字段适合"整段透传",不适合"精确取某一个节点的原始 XML"。要精确定位,给目标节点建独立元素,或用 Token/DecodeElement 流式解析(见 §1.2)。

1.2 流式解析(Pull 模式)

Go 的 XML 解析器是拉模式的,不像 SAX 回调,也不像 DOM 全量建树:

dec := xml.NewDecoder(strings.NewReader(xmlData))

for {
    tok, err := dec.Token()
    if err == io.EOF {
        break
    } else if err != nil {
        return err
    }
    switch t := tok.(type) {
    case xml.StartElement:
        fmt.Printf("开始元素: %s\n", t.Name.Local)
        for _, attr := range t.Attr {
            fmt.Printf("  属性: %s=%s\n", attr.Name.Local, attr.Value)
        }
        // 需要结构化解某一段时:
        // var item Item
        // if err := dec.DecodeElement(&item, &t); err != nil { return err }
    case xml.EndElement:
        fmt.Printf("结束元素: %s\n", t.Name.Local)
    case xml.CharData:
        if s := strings.TrimSpace(string(t)); s != "" {
            fmt.Printf("  内容: %s\n", s)
        }
    case xml.Comment:
        fmt.Printf("  注释: %s\n", t)
    case xml.ProcInst:
        fmt.Printf("  处理指令: %s %s\n", t.Target, t.Inst)
    }
}

DecodeElement(&v, &start) 是流式和结构化的桥:在 StartElement 上调它,就能只把该子树解码到结构体,同时保持外层是手工遍历。处理"大 XML 里只有少数几段要解析"(比如 Sitemap、SOAP 信封)时,这是标准做法——比把整份文档 Unmarshal 省内存得多。

1.3 Decoder 的现实开关

dec := xml.NewDecoder(r)
dec.Strict = false            // 容忍不配对标签(HTML 风格);默认 true
dec.Entity = xml.HTMLEntity   // 认 &nbsp; 之类的 HTML 实体;默认只认 5 个内置实体
dec.AutoClose = xml.HTMLAutoClose
dec.CharsetReader = func(charset string, in io.Reader) (io.Reader, error) {
    // 把 GBK/Big5/Shift-JIS 等转成 UTF-8;不设的话非 UTF-8 文档直接报错
    return transform.NewReader(in, simplifiedchinese.GBK.NewDecoder()), nil
}
dec.DefaultSpace = "http://example.com/ns" // 给无前缀标签指定默认命名空间

这几条是 XML 实战里最常撞的墙,按出现频率排:

  1. 未定义实体。只写 dec.Entity = xml.HTMLEntity 就能解掉大量"报表导出 XML"里的 &nbsp; 报错(invalid entity name or unclosed "&")。

  2. 编码不是 UTF-8。encoding/xml 只处理 UTF-8,CharsetReader 是官方留的钩子,但转换表来自 golang.org/x/text/encoding(不是标准库)。国内老系统的 GBK XML 十有八九卡在这。

  3. 命名空间的处理是简化的。包文档原话:“Strict mode does not enforce the requirements of the XML name spaces TR."。实测两方向行为并不对称:

    • 解码宽松:文档是 <book xmlns="urn:x"><title>hello</title></book>,结构体标签写 xml:"urn:x book"、xml:"book"、甚至 xml:" book" 三种都能成功解出 Title:"hello"——匹配只看 Local 名,命名空间不参与匹配(XMLName.Space 会被填成实际值供你自行判断)。所以"字段全是零值"通常不是命名空间造成的,而是元素名/结构体层级写错了。
    • 编码逐个标注:字段标签带命名空间时,每个命中的元素都会各自带上 xmlns,实测 Root{Title:"T"} → <book xmlns="urn:x"><title xmlns="urn:x">T</title><plain>P</plain></root>,命名空间不会继承父级声明。想输出干净的"根上声明一次”,最实际的做法是只在 XMLName 上写命名空间、子字段一律不带前缀。

注意:SOAP、XSD 校验、XSLT 这些"XML 全家桶"在 Go 里基本等于自己实现。encoding/xml 的定位就是"能读写 XML 文本",不是 XML 处理器。项目里如果需要严格 schema 校验,通常是换思路(让上游给 JSON/Protobuf)而不是硬扛。


2. encoding/csv

2.1 写:WriteAll 已经帮你 Flush 了

var buf bytes.Buffer
w := csv.NewWriter(&buf)

records := [][]string{
    {"name", "age"},
    {"Alice", "30"},
}
if err := w.WriteAll(records); err != nil { // 内部是 Write + Flush,含错误传播
    return err
}
// 实测此刻 buf 已经是 "name,age\nAlice,30\n",不需要再调 Flush

旧写法里"必须手动 Flush“的说法只对逐条 Write 成立:Writer 带缓冲,Write 返回 nil 不代表数据到了底层 io.Writer,末尾必须 Flush 并查 w.Error()。WriteAll/Flush 二选一即可,重复 Flush 无害。

字段里含逗号、引号、换行时 Writer 自动加引号转义;Writer.UseCRLF = true 输出 \r\n(给 Windows Excel 看时常用)。

2.2 读:几个默认值必须知道

r := csv.NewReader(f)
r.Comma = '\t'            // TSV
r.Comment = '#'           // 忽略注释行(默认 0 = 不认注释)
r.FieldsPerRecord = -1    // 允许每行列数不同
r.TrimLeadingSpace = true // 容忍 "a, b, c" 这种带空格的写法
r.LazyQuotes = true       // 容忍不配对的引号
r.ReuseRecord = true      // 复用同一个 []string,减少分配

实测行为:

r := csv.NewReader(strings.NewReader("a,b,c\nd,e\n"))
r.Read() // 第一行 OK
r.Read() // record on line 2: wrong number of fields

FieldsPerRecord 默认是 0,含义是"用第一行的列数当标准”,后续行数不符直接报错。 想宽松就设 -1;想强制列数就设成正数。

ReuseRecord = true 是省分配的手段,但它返回的 slice 会在下一次 Read 被改写。实测:

r.ReuseRecord = true
first, _ := r.Read()   // [a b]
r.Read()               // 此时 first 变成 [c d]

所以要留存记录必须自己拷贝:slices.Clone(record)。这是"加了 ReuseRecord 后数据莫名变成别的行"的唯一原因。

LazyQuotes 实测:输入 a,b"c → 解析成 ["a", b"c] 且无错误。开了它,引号错误会被吞成数据的一部分——只在处理不干净的真实数据时开,别当默认。

2.3 大文件的推荐形状

f, err := os.Open("large.csv")
if err != nil {
    return err
}
defer f.Close()

r := csv.NewReader(f)
header, err := r.Read() // 第一行当表头
if err != nil {
    return err
}
col := make(map[string]int, len(header))
for i, name := range header {
    col[name] = i
}

for {
    rec, err := r.Read()
    if err == io.EOF {
        break
    }
    if err != nil {
        line, col := r.FieldPos(0) // 注意返回两个值 (line, column)
        return fmt.Errorf("record at line %d col %d: %w", line, col, err)
    }
    process(rec[col["name"]], rec[col["age"]])
}

按列名取而不是按下标取,是 CSV 脚本能长期活下来的关键——上游加一列不会把你的逻辑整体错位。定位错误有两个工具:csv 自己的错误文本已经带行号(实测 record on line 2: wrong number of fields),大多数情况读错误就够了;r.FieldPos(field) 返回 (line, column) 两个值,注意它指的是最近一次成功 Read 的那条记录,字节偏移要用 r.InputOffset()。

CSV 没有类型系统:所有字段都是 string,"" 和 null 不可区分,数字里的千分位逗号、Excel 导出的 9.999999E+18 都要自己处理(strconv.ParseFloat 认科学计数法,ParseInt 不认)。


3. encoding/base64

3.1 四种编码,差异只在两个字符和填充

base64.Encoding 由两个维度组合:字母表用 +/ 还是 -_,填充用不用 =。

bin := []byte{0xfb, 0xff, 0xbe, 0xf7, 0xff}
base64.StdEncoding.EncodeToString(bin)     // "+/++9/8="
base64.URLEncoding.EncodeToString(bin)     // "-_--9_8="
base64.RawStdEncoding.EncodeToString(bin)  // "+/++9/8"
base64.RawURLEncoding.EncodeToString(bin)  // "-_--9_8"

注意:用可读文本做例子是看不出差异的——实测 base64.StdEncoding.EncodeToString([]byte("Hello, 世界")) 与 URLEncoding 输出完全相同("SGVsbG8sIOS4lueVjA=="),因为那段字节的 6 位分组刚好没落到 +// 上。测试 base64 必须用二进制数据,否则等于没测。

3.2 编码与解码必须严格配对

这是 base64 最容易出线上事故的一条:带填充的 StdEncoding 不能解无填充的串,反之亦然。实测:

base64.StdEncoding.DecodeString("aa")      // illegal base64 data at input byte 0
base64.RawStdEncoding.DecodeString("aa==") // illegal base64 data at input byte 2
base64.StdEncoding.DecodeString(无填充输出)  // illegal base64 data at input byte 4

所以一旦协议里定了填充方式,两端都要一致;对端可能发有填充也可能发无填充时,先探测再解码:

func decodeB64(s string) ([]byte, error) {
    if strings.HasSuffix(s, "=") {
        return base64.StdEncoding.DecodeString(s)
    }
    return base64.RawStdEncoding.DecodeString(s)
}

Encoding.Strict() 常被误认为"禁止无填充",实际它管的是填充位必须为零比特(RFC 4648 的尾比特校验),对缺 = 依然报错——别指望 StdEncoding.Strict() 能解 raw 串。

URL 安全的 token、JWT、文件名一律 RawURLEncoding(+ / = 在 URL、query、文件名里都是麻烦)。

3.3 流式与容量估算

var buf bytes.Buffer
enc := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := enc.Write([]byte("chunk1")); err != nil { // Encoder 的 Write 也会返回错
    return err
}
enc.Write([]byte("chunk2"))
if err := enc.Close(); err != nil { // 必须 Close:补最后一段和填充并 flush
    return err
}

dec := base64.NewDecoder(base64.StdEncoding, &buf)
io.Copy(os.Stdout, dec)

base64.NewEncoder 返回的是 io.WriteCloser,忘记 Close 会得到截断的输出(最后不足 3 字节的尾巴和填充都在 Close 里写出)。这和 gzip.Writer、csv.Writer 的问题同源:Go 里"Writer 带缓冲"的类型,收尾动作都在 Close/Flush。

容量估算:EncodedLen(n) 给出 n 字节编码后的长度(可预先分配),DecodedLen(n) 反之。大文件/base64 存数据库时用来算膨胀(约 +33%)。


4. 陷阱汇总

# 现象 原因与对策
1 XML 字段输出成 <CData>...? 没有 ,cdata/,innerxml 是裸内容字段,不生成元素;解码时会吞整段内部 XML(§1.1 实测)
2 XML 解出空结构体,字段全零 元素名或嵌套层级不匹配(命名空间不参与匹配,实测三种标签写法都能解,§1.3);用 Token 打印真实结构核对
3 invalid entity name or unclosed "&" 文档用了 HTML 实体,设 dec.Entity = xml.HTMLEntity
4 中文 XML 报 unsupported encoding scheme "GBK" encoding/xml 只认 UTF-8,必须设 CharsetReader
5 CSV 写到一半文件是空的 逐条 Write 后没 Flush(WriteAll 自带 flush,实测)
6 CSV 报 wrong number of fields FieldsPerRecord 默认按第一行强约束,改 -1 或补齐数据
7 开了 ReuseRecord 后记录互相覆盖 返回的 slice 会被复用,留存要 slices.Clone
8 CSV 里的引号内容变成数据的一部分 LazyQuotes 会吞掉引号错误,只在处理脏数据时开
9 base64 解出 illegal base64 data 填充方式与解码器不配对(§3.2 实测);探测 = 后再选
10 base64 输出比输入多 1/3 正常,EncodedLen 估算容量,别当 bug
11 base64.NewEncoder 少尾巴 没 Close(与 gzip.Writer 同类问题)
12 用 URLEncoding 却测不出差异 示例数据不含 +// 对应的字节(§1 实测),换二进制测
13 CSV 第一列表头变成 ;name 之类乱码 Excel 导出的 CSV 常带 UTF-8 BOM,encoding/csv 不会剥。读时 strings.TrimPrefix(firstField, "\uFEFF"),写时自己决定要不要加 BOM(见 bytes-and-strings.md §5)

5. 实战建议

XML 只在"不得不"时用。 对接老系统、Sitemap、OPML、RSS、Office 文档时没有选择;自研服务之间没必要用 XML。一旦要用,优先上 Token/DecodeElement 流式,把不关心的部分跳过——Unmarshal 整个大文档到结构体的做法,通常在内存和命名空间坑上双重付出代价。别指望 Go 做 XSD 校验,需要契约就用 JSON Schema/Protobuf。

CSV 一律按列名取,不做位置假设。 表头到索引的 map 只花 10 行代码,却能扛住上游加列。读的时候显式设 FieldsPerRecord/LazyQuotes 并注明原因——这三个开关的默认值都是"严格",宽容模式必须由你主动选择,因为宽容的代价是静默出错数据。写 CSV 给 Excel 时记得 UseCRLF,并处理中文 BOM(utf8 BOM 要自己写,标准库不管)。

base64 不是编码方案,是传输适配层。 它把数据膨胀约 33% 且不加密——把 token、密码、内部 ID “base64 一下"就当安全存储,是审计里最常见的一类问题(安全性见 hash-mac-aead.md)。选变体时记一句:进 URL/文件名/Header → RawURLEncoding;进 JSON/DB 字段 → StdEncoding;二进制协议里其实不需要 base64。

跨包的共性收尾规则。 csv.Writer 要 Flush,base64.Encoder/gzip.Writer 要 Close,bufio 要 Flush——Go 里凡是"包一层 writer"的 API,错误和收尾都藏在 Flush/Close 的返回值里。写完记得判这两个的 error,否则磁盘满时你会得到一份"看起来成功"的空文件。

相关阅读:encoding-json.md(同目录,结构体标签语义的另一套体系)、hash-mac-aead.md(hex/pem 与 base64 的选型边界)、../05-文件与系统IO/compress-archive.md(gzip 与 Writer 收尾规则)、../05-文件与系统IO/bufio.md。