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 // 认 之类的 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 实战里最常撞的墙,按出现频率排:
-
未定义实体。只写
dec.Entity = xml.HTMLEntity就能解掉大量"报表导出 XML"里的 报错(invalid entity name or unclosed "&")。 -
编码不是 UTF-8。
encoding/xml只处理 UTF-8,CharsetReader是官方留的钩子,但转换表来自golang.org/x/text/encoding(不是标准库)。国内老系统的 GBK XML 十有八九卡在这。 -
命名空间的处理是简化的。包文档原话:“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 fieldsFieldsPerRecord 默认是 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。