需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

Go 中的 Base64 编码:完整指南

时不时地,你的 Go 程序必须把二进制数据交给一个只收文字的世界:一个必须保持字符串形态的 JSON 字段、一个必须保持单一令牌的 URL、一个要穿越那些还记得 7 位时代的服务器的邮件附件、一张想住在 HTML 里让页面省掉一次请求的图片。Base64 正是这个工作的快递员,而本站首页已经深入解释了格式本身,所以这篇文章直接切入打包这门手艺:在 Go 里产出 base64 字符串,让地球上的每个解码器都能毫不费劲地打开它。

先把好消息摆在前面:编码器是故事里温和的那一半。一个方法,没有错误返回,没有失败模式,自第一个稳定发行版起的每个 Go 发行版上输出都字节级一致。所有的戏码都发生在这个方法周围:为字符串要穿行的通道选对字母表、那个在你忘记调用时会默默吞掉你最后两个字节的 Close 调用、这个格式随身携带的尺寸税,还有 Go 和 Python、Java、Node 一样,从不在 76 字符处折行输出。先认识这个函数,然后认识那些陷阱。

不会失败的打包

Go 里百分之九十的编码生活,就是 Encoding 类型上的一个方法,而且和这个包里的每个编码入口(EncodeAppendEncode)一样,它没有错误返回:

func (enc *Encoding) EncodeToString(src []byte) string

给它字节,它还你一个字符串,这就是全部的契约:

package main

import (
  "encoding/base64"
  "fmt"
)

func main() {
  packed := base64.StdEncoding.EncodeToString([]byte("Man"))
  fmt.Println(packed) // TWFu
}

没有错误值,因为没有什么可错的:任何字节都是合法输入,字母表永远覆盖得了它,输出永远是纯 ASCII。有三个性质值得背下来,因为它们能回答未来一半的问题。第一,输出长度是输入长度的纯算术函数,这个包甚至把公式当作方法递给你:EncodedLen(n) 对带填充的编码返回 (n+2)/3*4,所以 3 个输入字节变成 4 个字符,6 个变成 8 个,依此类推。第二,这个格式带着尺寸税:每三个字节的数据回来时是四个字符,这就是那个熟悉的约 33% 的膨胀,它会出现在你的带宽账单和存储配额里。第三,方法是确定性的:相同的字节永远产生相同的字符串,在任何机器上、任何版本的 Go 里、永远。正是这种确定性,让 base64 成为一种序列化格式,而不是一种神秘。

输入侧有一个 Go 特有的注意事项:这个方法接收 []byte,不是 string,而 []byte(...) 转换在每个调用点都是显式的 - Go 永远不会替你转换字符串到切片 - 而且它产生的是字符串字节的一份独立拷贝。当切片只被读取且不逃逸时,编译器可以省去那次拷贝,这就是为什么这个开销通常测不出来;但如果切片被存储或返回,运行时就要支付一次货真价实的 O(n) 拷贝。Go 程序里的文本按约定是 UTF-8,所以当你编码一个字符串时,你编码的是它的 UTF-8 字节,而这正是对端每个现代解码器所期望的。"文本、字节与 Unicode"一节还会再讲这个。

Go 怎么发货的

和这篇文章里的一切一样,编码器来自标准库包 encoding/base64,它从这门语言第一个发行版起就在发货,其源文件还带着 2009 年的版权头。没有要拉取的模块,没有要拨动的特性开关,也没有平台怪癖:go version 能用,go doc encoding/base64 就会把整个 API 打印给你。

截至本文写作时,最新的发行版是 Go 1.27.1,发布于 2026 年 9 月 1 日,另一条受支持的线是 Go 1.26(目前是 1.26.8)。你可以从 go.dev/dl 的官方压缩包、你的发行版包管理器(sudo apt install golang-go)安装 Go,或者如果你同时摆弄多个版本,就用 golang.org/dl 封装器。两条受支持线上的 base64 API 完全一致,而下面这张表就是它改过什么的完整历史,对于一个这么核心的包来说,这是一份很短的清单:

发行版 年份 encoding/base64 变了什么
Go 1.0 2012 从第一天起就稳定;源码版权 2009
Go 1.5 2015 为无填充输出加入 RawStdEncodingRawURLEncoding
Go 1.8 2017 为规范解码加入 Strict()(解码器侧)
Go 1.22 2024 加入 AppendEncodeAppendDecodeWithPadding 现在拒绝非法参数
Go 1.27.1 2026 当前发行版;API 未变,行为依 Go 1 承诺字节级稳定

这段历史的实际后果:2015 年针对这个 API 写的代码,今天编译和行为都一模一样,而你的程序在 2026 年编码出的字符串,在任何过去的或未来的 Go 发行版上都能正确解码。对一种序列化格式来说,这就是那种不动声色的超能力。

为目的地选字母表

编码只有一个真正的决策,它是个关于旅程的问题:这个字符串要去哪里?Go 给你四个现成的编码器,每一个都针对一种不同的通道调过音:

编码器 字母表 填充 字符串要穿过这些地方时发它
StdEncoding A-Z a-z 0-9 + / = JSON 正文、邮件 MIME 部分、data URL、HTTP Basic 认证、PEM、大多数 API
URLEncoding A-Z a-z 0-9 - _ = URL 路径和查询、文件名、任何 +/ 需要转义的地方
RawStdEncoding A-Z a-z 0-9 + / 填充不允许出现的紧凑标准字母表字符串
RawURLEncoding A-Z a-z 0-9 - _ JWT 段、紧凑标识符、嵌在 URL 里的令牌

变体背后的推理,就是格式本身的推理。标准字母表是 MIME 和大多数 API 所期望的,所以它是默认值,也是没人告诉你别的时的安全答案。URL 安全字母表存在的原因,是 +/ 在 URL 里是保留字符:查询字符串里的加号常被读成空格,斜杠会开启一个新的路径段,所以 URL 里的标准 base64 要么直接坏掉,要么就得对携带 +/= 的字符做百分号转义 - 占典型令牌的百分之几。把它们换成在路径、查询和文件名里都可以合法不转义的 -_,就是 RFC 4648 标准化的修复。Raw 变体则彻底丢掉末尾的等号,这在填充要么被禁止、要么压根没人用的场景里很重要,比如 JWT 段。能让你省下大部分调试的规则就这条:你挑的编码器和对端用的解码器是一份约定,而约定是由目的地写的,不是由你写的。

如果你对接的某个系统定义了一个私有的 64 字符字母表,base64.NewEncoding("...64 chars...") 可以为你构建一个编码器,WithPadding(rune) 让你换掉填充字符,或用 NoPadding 禁用它。这两个函数对非法参数都会 panic(字母表长度不对、字符重复、字母表里有换行、填充字符和字母表撞车),所以你的自定义编码器应该构建一次、在启动时,永远别在热路径上构建。

Close 陷阱

这是这个包里最著名的陷阱,它只在你编码一个流而不是一个字符串时出现。NewEncoder 把任何 io.Writer 包成一个 base64 编码写入器,而因为 base64 按块工作 - 三个输入字节产出四个输出字符 - 编码器必须把最后的一两个字节缓冲起来,等着看后面还有没有更多。只有当你关闭它时,它们才会被刷出去:

package main

import (
  "bytes"
  "encoding/base64"
  "fmt"
)

func main() {
  var buf bytes.Buffer
  enc := base64.NewEncoder(base64.StdEncoding, &buf)
  enc.Write([]byte("hello"))
  fmt.Println(buf.String()) // aGVs  -- "lo" 去哪儿了?

  buf.Reset()
  enc = base64.NewEncoder(base64.StdEncoding, &buf)
  enc.Write([]byte("hello"))
  enc.Close()
  fmt.Println(buf.String()) // aGVsbG8=  -- "hello" 的完整编码
}

第一个打印结果就是全部教训:没有 Close,编码器只吐出了第一个完整的块,"hello" 的前三个字节变成了 "aGVs",剩下的两个字节则悄无声息地消失在内部缓冲区里。第二个打印结果,在 Close 之后,才是正确完整的字符串。修复方式是一种习惯,而不是一种技术:在你创建编码器的同一时刻,就把它的清理也创建出来:

enc := base64.NewEncoder(base64.StdEncoding, w)
defer enc.Close() // 记得在生产代码里检查返回的错误

两个细节让这个陷阱比看上去更锋利。第一,Close 做实事:它刷出挂起的未完成块,而且它可能失败,因为它要写向底层写入器,所以惯用版本会检查它的错误,尤其是当目的地是网络或磁盘时。第二,文档说在 Close 之后调用 Write 是错误,但运行时并不强制执行这句话。如果你关闭之后又写,编码器会默默地开一个新块并追加上去,产出一个填充落在中间的字符串,而那是无效的 base64,大多数解码器会用一个令人困惑的偏移量拒绝它。这份契约由你来遵守。

折行,Go 的方式

你用过的所有其他主流 base64 实现都会折行:MIME 要求行最多 76 个字符,PEM 用 64,世界各地的邮件客户端时不时就插一个 CRLF。Go 的编码器一样都不做。无论载荷多大,它都输出一整条连续的行,而且从包出生起就是如此。一兆字节数据的输出是一整条一兆又三分之一字符的行,从头到尾,没有任何断点。

这是一个刻意的选择,不是疏忽。这个格式有没有换行都一样工作,Go 自己的解码器会跳过输入中任何位置的换行,而一个悄悄往你数据里插 CRLF 的编码器,会让把字符串存进数据库列或者拿它做相等比较的程序大吃一惊。代价是当通道需要折行时你得自己折,而这只是一个小小的助手:

package main

import (
  "bytes"
  "encoding/base64"
  "fmt"
)

func wrapAt(s string, width int) string {
  var out bytes.Buffer
  for i := 0; i < len(s); {
    end := i + width
    if end > len(s) {
      end = len(s)
    }
    out.WriteString(s[i:end])
    out.WriteByte('\n')
    i = end
  }
  return out.String()
}

func main() {
  raw := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 100))
  fmt.Print(wrapAt(raw, 76))
}

关于行进方向的一个提醒:因为 Go 的解码器忽略任何位置的换行,所以折行过的输入在任何桥的 Go 这一侧都能完美解码。需要小心的是另一个方向:如果你把折行过的输出发给一个不期待断点的消费者(一个 JSON 字段、一个 URL、一个令牌),先剥掉换行,因为那个消费者可能会把换行当成损坏字符。搞清楚你的通道活在哪个约定里,然后有意地按它输出。

为邮件和 MIME 打包

邮件是 base64 最古老的家。最初的 SMTP 协议被设计来传输 7 位 ASCII,所以附件在发送前先做 base64 编码,到达时再解码,而 MIME 标准(RFC 2045)把这种做法正式化了:头 Content-Transfer-Encoding: base64 标记一个部分,正文应折成每行最多 76 个字符,行间用 CRLF。

Go 的 net/smtp 包发送你给它的字节,它不会替你构建 MIME 部分,所以在一个组邮件的程序里,base64 那一半长这样:

package main

import (
  "bytes"
  "encoding/base64"
  "fmt"
)

func main() {
  body := []byte("hi from Go")
  var part bytes.Buffer
  part.WriteString("Content-Transfer-Encoding: base64\r\n")
  part.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")

  encoded := base64.StdEncoding.EncodeToString(body)
  for i := 0; i < len(encoded); i += 76 {
    end := i + 76
    if end > len(encoded) {
      end = len(encoded)
    }
    part.WriteString(encoded[i:end] + "\r\n")
  }
  fmt.Print(part.String())
}

有三点值得注意。标准编码器在这里是正确的选择,因为 MIME 是最初的标准字母表场景。折行用的是 CRLF,不是平台的原生换行,因为那是 RFC 规定的、邮件解析器期望的。还有,如果你的程序真的在大批量发真实邮件,一个维护中的 MIME 库会替你构建整条消息;这个例子的重点是 base64 那一半,它是属于这个包的部分。把字母表和折行约定弄对,MIME 的其余就是别人的问题了。

为文件打包

对于装得进内存的文件,模式和别处一样是两行:读,然后 EncodeToString。对于装不下的,流式处理让你的内存保持平稳,配方是:一个文件、一个编码器、一次拷贝,外加按正确顺序的两个关闭:

in, err := os.Open("photo.jpg")
if err != nil {
  panic(err)
}
defer in.Close()

out, err := os.Create("photo.b64")
if err != nil {
  panic(err)
}
enc := base64.NewEncoder(base64.StdEncoding, out)
if _, err := io.Copy(enc, in); err != nil {
  panic(err)
}
if err := enc.Close(); err != nil {
  panic(err) // 刷出最后的未完成块
}
if err := out.Close(); err != nil {
  panic(err)
}

关闭的顺序才是微妙的部分,它是 Close 陷阱的文件版:编码器必须先于文件关闭,因为 enc.Close 才是把最后的未完成块写进文件的那一步,而先关闭文件会留下一个块,被写进一个已经什么都不是的缓冲区。用 defer 时要记住,延迟调用按相反顺序执行,所以先注册 out.Close、后注册 enc.Close(或者像上面的例子那样,在 defer 文件之前显式关闭编码器),才能让这个顺序安全。

围绕这个模式做规划时,把尺寸税放在心上:一张 10 兆字节的照片变成大约 13.3 兆字节的文本,一个 100 兆字节的归档变成磁盘上 133 兆字节的字符串。如果目的地有配额、上限或按字节计费,被计数的就是你文件的 base64 版本,而不是原件。

为 Web 打包:Data URL

浏览器很乐意从一个住在 HTML 或 CSS 内部的字符串里加载图片或字体,而这个字符串就是 data URL:媒体类型、;base64 标志、逗号、载荷,全在一个 URL 里。Go 没有 data URL 助手,但构建一个只是字符串拼接,因为这个格式是一份你能亲眼看到写出来的约定:

package main

import (
  "fmt"
  "os"
  "encoding/base64"
)

func main() {
  img, err := os.ReadFile("logo.png")
  if err != nil {
    panic(err)
  }
  url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
  fmt.Println(url)
  // data:image/png;base64,iVBORw0KGgo...
}

两条规则让 data URL 不踩坑。永远带上媒体类型:它在语法里是可选的(默认是 text/plain;charset=US-ASCII),但让浏览器去猜你二进制载荷的类型,不是你想看到的场景。还有,把 data URL 当作小资源技巧。RFC 说这个方案只对短值有用,而 33% 的膨胀正是"一个 2 千字节、省下一次请求的图标"和"一张 5 兆字节、拖慢每次页面加载的照片"之间的区别,它没有缓存可以共享,也没有 URL 可以递给任何人。图标、favicon、小精灵图:可以。产品照片:不行。

为 HTTP 打包

三种 HTTP 场景主导着 Go 服务里的 base64,其中两种自带帮助。第一种是 JSON 正文,干活的主力:你在序列化之前编码一个值,字段就带着一个纯字符串穿过网络:

package main

import (
  "encoding/base64"
  "encoding/json"
  "fmt"
)

type avatar struct {
  Data string `json:"data"`
}

func main() {
  png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
  a := avatar{Data: base64.StdEncoding.EncodeToString(png)}
  body, err := json.Marshal(a)
  if err != nil {
    panic(err)
  }
  fmt.Println(string(body))
  // {"data":"iVBORw0KGgo="}
}

如果一个类型出现在很多地方,干净的 Go 做法是给它实现 MarshalJSONUnmarshalJSON,让 base64 这一步对每个调用点都不可见。第二种场景是 HTTP Basic 认证,标准库把整个活都干了:Request.SetBasicAuth(user, pass) 替你构建 Authorization 头,对 RFC 2617 规定的 user:pass 对跑标准编码器。那里只有一条规则:别即兴发挥。Basic 认证是带 Basic 前缀的标准 base64,一个 URL 安全字母表或一个缺失的填充符号,就会把一次能用的登录变成一个没人能解释的 401。

第三种场景是 URL,字符串是路径段或查询参数的载荷。这里标准字母表是个糟糕的选择,因为 +/= 全都和 URL 语法冲突,每一处出现都需要百分号转义。改用 URL 安全变体来编码,令牌就能完好地活过 URL。如果消费者仍然对它做了百分号转义,什么都不会坏;如果它没有,你就为自己省掉了一类 404。

URL 安全输出

URL 安全 base64 在 Go 里值得单独一节,因为它是你比标准版用得更勤的变体,而且 Go 让这次切换免费。RFC 4648 的替代字母表把 + 换成 -/ 换成 _,所以输出在 URL 路径、查询或文件名里都不需要转义,在日志行里读起来是一个干净的单一令牌。两个现成的编码器是 URLEncoding(带填充)和 RawURLEncoding(无填充):

raw := []byte{0xfb, 0x0f, 0x67, 0x01}
fmt.Println(base64.StdEncoding.EncodeToString(raw))     // +w9nAQ==
fmt.Println(base64.URLEncoding.EncodeToString(raw))     // -w9nAQ==
fmt.Println(base64.RawURLEncoding.EncodeToString(raw))  // -w9nAQ

一个输入,三种输出:标准版需要为它的加号做百分号转义,URL 安全版是一个令牌,raw 版连填充也丢了。每一种的典型 Go 任务:服务生成然后存进 URL、路由或文件名的不透明标识符;客户端粘贴进查询字符串的 API 令牌;任何会出现在日志行里、一个加号或斜杠离被误读成语法只差一个字符的东西。

让这件事保持干净的纪律和这篇文章里到处一样:变体是和消费者之间的约定。如果对端期望标准 base64 而你发了 URL 安全,它的解码器会在第一个连字符处失败,而错误会是好端端一个字符串末尾附近的一个字节偏移量,这可不是容易调试的东西。拿不准时,问对端期望什么,读它指向的规范,从目的地而不是从习惯里挑编码器。

为 JWT 打包

JSON Web Token 是现代 API 里最显眼的 base64 消费者,而且它把确切的变体钉死了:按 RFC 7515,JWS 紧凑序列化是三段无填充的 base64url 段,用点号连接。头部、载荷、签名。这意味着你手工构建任何东西时首选的编码器是 RawURLEncoding

package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/base64"
  "encoding/json"
  "fmt"
)

func main() {
  secret := []byte("hmac-secret")
  header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
  payload, _ := json.Marshal(map[string]any{"sub": "1234567890"})

  signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
    base64.RawURLEncoding.EncodeToString(payload)

  mac := hmac.New(sha256.New, secret)
  mac.Write([]byte(signingInput))
  signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
  fmt.Println(signingInput + "." + signature)
}

把这个例子当作"这个格式是什么"的课来读,而不是当作"就该这么发货"的建议:它精确地展示了 base64 坐在哪里(签名前两次,签名后一次),以及签名为什么覆盖的是编码后的段,而不是原始 JSON。在生产环境里,用维护中的库签名和验证,因为 JWT 有一条长长的错误尾巴(过期时间的时钟偏差、算法混淆、缺失的受众检查),而 base64 层看不见这些。事实标准的 Go 库是 github.com/golang-jwt/jwt/v5,用 go get github.com/golang-jwt/jwt/v5 安装:

package main

import (
  "fmt"
  "log"
  "time"

  "github.com/golang-jwt/jwt/v5"
)

func main() {
  secret := []byte("hmac-secret")
  token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "sub": "1234567890",
    "exp": time.Now().Add(time.Hour).Unix(),
  })
  signed, err := token.SignedString(secret)
  if err != nil {
    log.Fatal("signing failed:", err)
  }
  fmt.Println(signed)
}

这个库在内部完成每个段的 base64url 编码,所以你根本不用碰 encoding/base64,这是最好的结局:填充或字母表错误可以藏身的地方少了一个。还要看它免费给你的那道护栏:v5 拒绝声称 alg=none 的令牌,除非你用它的 UnsafeAllowNoneSignatureType 常量明确选择放行,这正是你想要的那种不需要动脑筋的保护。

文本、字节与 Unicode

Go 在这个问题上的立场是所有主要语言里最短的,这正是 base64 在这里用起来舒服的原因:Go 里的 string 是一段只读的字节序列,而你程序里的文本是 UTF-8。没有隐藏的编码层,没有"字符串其实是 UTF-16"的惊吓,也没有要设置的字符集标志。当你写下 EncodeToString([]byte(myText)) 时,你编码的就是这段文本的 UTF-8 字节,没有别的:

s := "Café ☕"
packed := base64.StdEncoding.EncodeToString([]byte(s))
fmt.Println(packed) // Q2Fmw6kg4piV

这一行就是现代文本的全部故事,包括 emoji 和 CJK:base64 操作的是字节,UTF-8 只是一段字节序列,而对端每一个遵守同样约定的解码器都会把相同的字符串还给你。[]byte(...) 转换是一份独立拷贝,当切片只被读取且不逃逸时编译器会省去它 - 所以实际上它的代价测不出来。

故事会变长的唯一情况是遗留数据:由 Windows-1252、Shift JIS 或 ISO-8859-1 系统产生、不是合法 UTF-8 的字节。如果你原封不动地 base64 编码那些字节,你就忠实地运送了一段损坏的文本,而这不是任何人想要的。修复方式是在编码之前归一化,用 golang.org/x/text,让 base64 字符串从离开你程序的那一刻起就带着干净的 UTF-8:

import (
  "golang.org/x/text/encoding/charmap"
  "golang.org/x/text/transform"
)

legacy := []byte{0x43, 0x61, 0x66, 0xE9} // Windows-1252 里的 "Café"
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), legacy)
if err != nil {
  panic(err)
}
packed := base64.StdEncoding.EncodeToString(utf8)
// Q2Fmw6k=  -- 同样的 "Café",现在是干净的、随时可以出发的 UTF-8 字节

同一个模块除了 charmap,还覆盖 japanesekoreansimplifiedchinesetraditionalchinese。实用规则:转换一次,在遗留字节进入你程序的那个边界上,从那时起你编码的一切都是 UTF-8。不要转两次,不要猜,也永远别让非 UTF-8 的载荷溜进一个现代消费者会解码并显示的 base64 字符串里。

给编码器做测量

编码器是一次查表,对输入没有分支,分配也只有输出字符串那一份,数字也体现了这一点。在一颗运行 Go 1.26 的现代桌面 CPU 上,编码 500 字节大约花零点三微秒,两次分配,折算下来是每秒一吉字节半的量级。一兆字节数据编码远不到一毫秒;你几乎感觉不到编码器在干活。

唯一值得知道的杠杆是热循环里的分配概况。EncodeToString 每次调用都分配输出字符串,这对 99% 的场景是合适的权衡。如果你每秒要把成千上万个块编码进一个不断增长的缓冲区,Go 1.22 加入的 AppendEncode 会把编码后的字节追加到你复用的切片上,缓冲区长到合适尺寸后的稳态里零分配:

var out []byte
for _, chunk := range chunks {
  out = base64.StdEncoding.AppendEncode(out, chunk)
}

一次性任务用 EncodeToString,紧循环用 AppendEncode,流和文件用 NewEncoder。无论你挑哪个,都记住:编码器周围的网络或磁盘几乎总是慢的那一环,所以在优化字母表之前,先对整个路径做剖析。

安全考量

这篇文章里最重要的一句安全话:base64 不是加密,"我们先 base64 一下"不是安全措施。字母表让数据变成文本安全的,不是秘密的,任何有浏览器开发者工具的人都能瞬间读懂你的 base64。机密性来自 TLS 和访问控制,而 base64 的工作是把字节完好无损地送过一条只收文本的通道。在设计里和文档里把这两份工作分开,你就避开了那种经典的评审评论:"密码被保护了,看,它是 base64。"

第二项考量是尺寸。因为这个格式膨胀三分之一,你系统里的每个上限都有个 base64 版本:一个接受 4 兆字节 JSON 的 API,在载荷是 base64 字段时只接受大约 3 兆字节的原始数据,一个有长度预算的 URL 在令牌是 URL 安全且无填充时能装下更多原始字节,而一个为原始值定好尺寸的数据库列可能对编码后的值太小。在存储、发送或设限之前,先用 EncodedLen 做算术,并且记住:膨胀发生在你出发的输入上,不是你得到的字符串上。

第三,想想编码后的字符串会出现在哪里被看到。base64 字符串对日志友好、对屏幕友好,这是个优点,直到一个 20 兆字节的附件 base64 成 26 兆字节的文本,你的访问日志忠实地在每次请求里记下它。记录长度、开头几十个字符、标识符,而不是载荷,你的日志就能保持可读,磁盘也能活下来。最后,在 URL 里优先用 URL 安全变体,让你的令牌不用把任何字符花在百分号转义上,那会撑大 URL,偶尔还会绊倒某个对"查询字符串里该有什么"看法严格的网关或代理。

趣味事实与 Go 怪癖

几条专门属于这个包的事实,供你想在代码评审里说对的时候取用:

  • EncodeToString 是干活的主力,和这个包里的每个编码入口(EncodeAppendEncode)一样,它没有错误返回 - 在 Go 里编码不会失败,这是一种罕见而安静的自由:任何字节都是合法输入,而得到坏字符串的唯一方式,就是给通道挑错了字母表。
  • EncodedLen 是纯算术,对带填充的编码是 (n+2)/3*4,无分配、无循环就能算完。它存在的意义,是让你不必编码一个字节就能给缓冲区和配额定尺寸。
  • 内部的流编码器藏着一个 3 字节输入缓冲区和一个 1024 字节输出缓冲区,这就是 NewEncoder 分块写入的原因,也是最后的未完成块只能从 Close 走出来的原因。缓冲区就是陷阱存在的原因。
  • 文档说在 Close 之后写入是错误,但运行时不执行这句话。迟到的 Write 会被接受,追加一个新块,产出一个中间带着填充的字符串:无效的 base64,客气地生成,连一个错误值都看不见。
  • Go 的编码器从不在 76 字符处折行输出 - 和 Python、Java、Node 的编码器一样,Go 的编码器给一兆字节数据产出一整行。你的 MIME 折行助手是个个人项目,这也是记住"邮件 base64 里的换行是 MIME 约定,不是 base64 要求"的好办法。
  • 截至 2026 年 8 月,pkg.go.dev 上超过 244,000 个公开包导入 encoding/base64。无论你的 Go 程序是什么,它几乎肯定在某个地方做 base64,不管你知不知道。
  • Go 1 兼容性承诺在这个包上格外有力:一个 2013 年编码字符串的程序的输出,在今天的 Go 1.27 上字节级一致。base64 字符串在 Go 里,实际上是永生的。

不断重演的错误

这些编码错误不断在 Go 代码库里重演,大致按它们登场的顺序排列:

  • 忘了给流编码器 Close,发出去一个少了最后一两个字节的字符串。这个 bug 能通过所有长度恰好是三的倍数的输入的测试,这是它到达生产环境的方式。
  • 先关闭文件再关闭编码器,最后的未完成块就刷进了一个已经离去的文件句柄。输出恰好被截掉同样的长度,而错误只在大小不是三的倍数的输入上出现。
  • 期待 MIME 或邮件输出里有 76 字符的折行,然后对 Go 递给你的一整长行感到困惑。折行是通道的约定,在 Go 里,应用它是你代码的活。
  • 在 URL 里用标准字母表,然后花一下午追那些其实是百分号编码问题的 404 和 400。如果字符串要住在 URL 里,从 URLEncodingRawURLEncoding 开始。
  • 在消费者禁止填充的地方发出填充:JWT 段、某些令牌格式、几个严格的解析器。raw 变体正是为这个而存在的,而对端的错误消息常常是你字符串最末尾的一个字节偏移量。
  • 把秘密 base64 了一下,然后管它叫保护。它不是。那个头、那个令牌、那个"已加密"字段:任何人半秒就能读懂。用 TLS,用哈希 - 在协议要的是哈希的地方 - 然后让 base64 做它唯一诚实的那份工作。
  • 设限时忘了那 33%:正文大小、列宽、URL 预算、配额检查。算术就是对 EncodedLen 的一次调用,而跳过它的代价是生产环境里的一个 413 或一个被截断的列。
  • 编码非 UTF-8 的文本,忠实地把损坏也运送过去。编码之前先用 golang.org/x/text 归一化遗留字符集,让 base64 字符串带着干净的字节走。
  • 关闭编码器之后又往里写,出于习惯或出于重试循环。不会抛出错误,输出悄悄地变成无效的。
  • 假设对端的解码器和 Go 的一样宽容。Go 跳过任何位置的换行,但其他语言和解析器对空白和行长更严格,所以去匹配通道的约定,而不是 Go 运行时的心情。

另一面

这就是故事里的整个编码侧:一个不会失败的方法,四个和它们字符串要穿行的通道相匹配的编码器,一个带一次强制 Close 的流编码器,以及一个把你的数据膨胀三分之一、而且永远、永远不折行的格式。从目的地挑字母表,关闭你的编码器,提前做完尺寸算术,Go 里的 base64 就会保持它自 2009 年以来的样子:安静的、零依赖的工具。

而当交通反向,当你的程序收到这些字符串之一并且必须打开它时,关于 Go 中 Base64 解码的相关文章会详细覆盖那一边:解码器的宽容规则、告诉你输入在哪个字节出错的错误偏移量、给挑剔协议用的严格模式,以及从另一个方向看的同样四个编码。

最后更新: 2026-09-08

相关文章: Go 中的 Base64 解码:完整指南