Swift 中的 Base64 编码:完整指南
你有一份需要上路的东西,而路只通文本:一个拒绝原始字节的 JSON API,一个还记得自己 7 比特出身的邮件通道,一个遇到叫不出名字的东西就噎住的 URL,一个只接受最朴素字符的配置文件。欢迎来到 base64 的打包侧:Swift 用一个方法调用就把你的字节变成一面友好的字母墙,外加大约每三个字节多收一个字符的附加费,还有几个换行选项,它们存在,是因为两个不同的年代对行长各有想法。
本站首页已经详细解释过这个格式(64 个可打印字符、每三个输入字节换四个字符、最后一组最多两个 = 填充字符),所以格式课还没开讲就结课了。带进这篇文章的两个事实:base64 是打包,不是上锁;而打包会让你的数据膨胀大约 33%,每当你离某个大小限制很近时,这一点都算数。在 Swift 里,整份活儿穿过一个类型 Data 和一个全函数方法 base64EncodedString(options:)。真正需要的唯一技能,是知道那个方法前后两步里发生了什么,因为方法本身从不失败。会失败的是那两步。
一个方法,零借口
Swift 里所有和 base64 相关的一切都住在 Foundation 框架的 Data 上,而且从这门语言最初发布时起它就住在那里(Apple 标注该方法自 iOS 8.0、macOS 10.10、tvOS 9.0、watchOS 2.0 和 visionOS 1.0 起可用)。流水线永远是同样三步:把内容装进一个 Data,调用方法,发出字符串。
import Foundation
let note = "Pack it, wrap it, ship it."
let packed = Data(note.utf8).base64EncodedString()
print(packed) // UGFjayBpdCwgd3JhcCBpdCwgc2hpcCBpdC4=
那三行里有两处细节值得细看。第一,Data(note.utf8) 是安静的那一步:utf8 视图按定义能表示每一个 Unicode 标量,所以它从不失败,这也是它成为大多数例子里默认写法的原因。可失败的表亲 note.data(using:) 在有些编码下会、也确实会回答 nil,而整个"选哪些字节"的决定在下面有专节,因为那是你的数据会丢失的第一个地方。第二,方法本身是全函数:它总有答复,没有错误分支,它问你的唯一问题是想要哪种换行。还有一个兄弟 base64EncodedData(options:),它把打包结果作为 ASCII 字节的 Data 而非字符串返回,适用于下一站是二进制 API 而不是文本字段的流水线。
既然你们有一半人是冲着"我的 Swift 应用需要一个 base64 依赖"来的:没有什么可安装的。Base64 是 Foundation 的一部分,Foundation 是工具链的一部分,而工具链在每个平台上都以同样的方式到达。在 macOS 上它是 Xcode 或命令行工具;在 Linux 和 Windows 上是 swift.org 的安装包,截至本文写作时当前稳定线是 6.3.x,Swiftly 版本管理器是推荐的大门;官方的 Docker 镜像则照顾容器那一拨人。你的 Package.swift 保持空着,它就该如此。
第一个真正的决定:选哪些字节?
在第一个 base64 字符诞生之前,你已经做出了最重要的决定,因为base64 打包的是字节,而字符串在你选定它的字节形态之前都只是字符串。UTF-8 是理智的默认值,对几乎一切都正确,但一旦你的数据来自遗留系统、二进制协议或 Unicode 的某个角落,这个选择就不再隐形了:
import Foundation
let phrase = "héllo"
print(phrase.data(using: .utf8)?.count ?? -1) // 6
print(phrase.data(using: .ascii) == nil) // true
print(phrase.data(using: .utf16)?.count ?? -1) // 12
print(phrase.data(using: .utf16LittleEndian)?.count ?? -1) // 10
print(phrase.data(using: .utf8)!.base64EncodedString())
// aMOpbGxv
print(phrase.data(using: .utf16LittleEndian)!.base64EncodedString())
// aADpAGwAbABvAA==
| 转换 | "héllo" 的字节数 | base64 承载的东西 |
|---|---|---|
.utf8 |
6 | aMOpbGxv,现代 API 期望的拼法 |
.ascii |
以 nil 失败 |
带重音的字符高于 0x7F,ASCII 拒绝它 |
.utf16 |
12 | 是 UTF-8 的两倍,外加一个搭便车排在最前面的两字节字节序标记 |
.utf16LittleEndian |
10 | 没有 BOM 标签的同一个词:10 字节,仍然是这里列出的无 BOM 选项里最重的 |
那份输出里藏着三条教训。data(using:) 形式是可失败的,.ascii 是失败的头号候选,所以强解它,就是一个完好的短语变成崩溃应用的方式。裸的 .utf16 转换会在前面加一个两字节字节序标记(在小端机器上是 FF FE),而那个 BOM 会搭进你的打包输出,搞糊涂任何一个没料到它的解码器。而且尺寸算术毫不宽恕:一个不小心的字符集选择让你对两倍的数据付 base64 附加费,所以问题从来不是"这个能编码吗?",而是"对方解包时预期会找到什么?"黄金法则:旅程的两端必须在 base64 开始之前就对字节形态达成一致,因为解码器没有办法猜你选了什么,它也不会问。
换行:两种习惯,一个参数
这个方法的选项全都在说换行,而它们全都存在,是因为二十世纪的两个格式对一行字母该多长达不成一致。MIME,1996 年的邮件标准,把 base64 按76 个字符加 CRLF 换行。PEM,1987 年 Privacy-Enhanced Mail 一脉,按64 个字符换行,而你在证书和密钥里看到的就是这个形状,那些存放在服务器配置目录里的 -----BEGIN CERTIFICATE----- 块。
import Foundation
let certBytes = Data((0..<300).map { UInt8($0 % 256) })
let raw = certBytes.base64EncodedString()
let pemStyle = certBytes.base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed])
let mimeStyle = certBytes.base64EncodedString(options: [.lineLength76Characters,
.endLineWithCarriageReturn, .endLineWithLineFeed])
print(raw.count) // 一行 400 个字符
print(pemStyle.components(separatedBy: "\n").count) // 7 行,每行至多 64
print(mimeStyle.components(separatedBy: "\r\n").count) // 6 行,每行至多 76
| 选项 | 职责 | 当心 |
|---|---|---|
.lineLength64Characters |
64 个字符后截断成行,PEM 的习惯 | 除非你另作说明,行尾是 CRLF |
.lineLength76Characters |
76 个字符后截断成行,MIME 的习惯 | 同样是 CRLF 默认 |
.endLineWithCarriageReturn |
在行尾里包含回车 | 单用它就是只有 CR,老 Mac 风格,很少是你想要的 |
.endLineWithLineFeed |
在行尾里包含换行 | 想要 CRLF 时两个选项都要传 |
现在来说那个让人意外的默认值:要求任何 .lineLength 选项却不选行尾,你收到的行尾就是CRLF,完整的回车加换行组合。这个方法有家规,而它的家规是 1996 年的。想要只有 LF?显式地付这笔钱:.endLineWithLineFeed,加什么都别加。再记一条家规:最后一行永远不带行尾。无论选了什么选项,换行后的结果都结束在最后一个数据字符或它的 = 填充上,所以你可以放心拼接粘贴,末尾不会多出一个孤零零的空行。而完全不带选项时,输出是一根不断行的单行,这正是 JSON 正文、URL 和 API payload 该有的形状:现代 Swift 应用实际上大多数时候干的活。
Base64url:一个能出远门的字符串
标准字母表在 JSON 里是体面的公民,在 URL 里是糟糕透顶的公民。在查询字符串里,+ 会被表单解析读成空格,/ 是路径分隔符,= 分隔键和值,所以给标准字母表做百分号编码,让它变得更长更丑,而不是更短。RFC 4648 的第 5 节"URL 与文件名安全字母表"正是为了修这个毛病而存在的:在那里 + 变成 -,/ 变成 _,= 填充通常被丢掉,因为 URL 里的填充通常会变成 %3D,白费力气。RFC 附了一条值得裱起来的警告:这种编码"不应被视为与 base64 编码相同"。YouTube 视频 ID、JWT 和大多数现代 API 标识符说的都是它,所以做好要用的准备。
import Foundation
extension Data {
var base64URLEncoded: String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
let tricky = Data("The + / and = trio goes home.".utf8)
print(tricky.base64EncodedString())
// VGhlICsgLyBhbmQgPSB0cmlvIGdvZXMgaG9tZS4=
print(tricky.base64URLEncoded)
// VGhlICsgLyBhbmQgPSB0cmlvIGdvZXMgaG9tZS4
仔细看那个输出:这个特定的 payload 碰巧没产生 + 或 /,所以两种拼法只差在丢掉的填充上。改一个字节,它们就会在字母表上分道扬镳,而这正是重点。两条交战规则。在数据与外部世界相遇的边界上,把方言只选一次,永远不要在同一个文档里混用字母表:一个收到 base64url 的标准解码器(或反过来)要么拒绝输入,要么在宽松模式下删掉外来字符、递给你错误的字节。还有,给你的助手取个诚实的名字,让下一个开发者知道这个字符串是 base64url 而不是手误。同样的 extension 在更新的工具链上可以变短:最新的 SDK beta 现在已经带了一个原生的 .base64URLAlphabet 选项,在框架内部完成字母表交换,还配了相应的 .omitPaddingCharacter 选项,开源 Foundation 也把同样的选项放在面向更晚工具链的可用性标记后面。在它们到达你的最低部署目标之前,这个四行 extension 就是可移植的答案,而且由于构造上的原因,它会在每个平台上继续工作。
JSON 与 API:你没要过的 Base64
这一条最让和 Codable 打交道的人惊讶,所以它值得独占一节:JSONEncoder 对 Data 属性的默认策略本来就是 base64。如果一个 Codable 结构体有 Data 字段,编码器会自动用标准 base64 打包它,JSONDecoder 回程时也会自动解包。没有选项,没有配置,没有仪式。
import Foundation
struct Snapshot: Codable {
let name: String
let icon: Data
}
let snap = Snapshot(name: "cat", icon: Data("🐱".utf8))
let json = try JSONEncoder().encode(snap)
print(String(decoding: json, as: UTF8.self))
// 图标以 "8J+QsQ==" 的样子走完了线
icon 属性以 8J+QsQ== 的样子走完了线,因为那是家规。有替代方案,而你真正会遇到的两个是 .custom,它把数据和一个编码器交给你,让你决定表现形式;还有较新的 .deferredToData,它把决定权交给数据实例本身。一旦某个 API 要的是 base64url 而不是标准,.custom 就是上一节那个 extension 插上来的地方:
import Foundation
extension Data {
var base64URLEncoded: String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
struct Snapshot: Codable {
let name: String
let icon: Data
}
let encoder = JSONEncoder()
encoder.dataEncodingStrategy = .custom { data, enc in
var container = enc.singleValueContainer()
try container.encode(data.base64URLEncoded)
}
let json = try encoder.encode(Snapshot(name: "cat", icon: Data("🐱".utf8)))
print(String(decoding: json, as: UTF8.self))
// 图标以 "8J-QsQ" 的样子走完了线
一条区分"能用的功能"和"生产事故"的警告:JSON 字符串里不能有原始换行。如果你用 .lineLength 选项给 payload 换行,再把结果不转义地插进 JSON 文档,那你根本没有造出一个 JSON 值;你造出的是一个带 base64 口音的语法错误,解析器会当场证明。换行后的输出属于邮件正文和证书文件。一切住在 JSON、URL 或查询字符串里的东西,拿到的都是不包换行的普通字符串。
Data URI:字符串里的图像
网页最爱的把戏是把文件的字节直接嵌进 URL:data:{mime};base64,{payload}。在 Swift 里造一个,是一次读取、一次编码、一次字符串拼接:
import Foundation
let gif = Data(base64Encoded: "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")!
let uri = "data:image/gif;base64," + gif.base64EncodedString()
print(uri.hasPrefix("data:image/gif;base64,R0lGODlh")) // true
print(gif.count) // 42
这个例子把那个著名的 42 字节透明 GIF(更小的非透明 GIF 也存在,但人人嵌的都是这一个)重建成一个 data URI,浏览器不用第二次请求就能渲染它。在 Apple 平台上,反方向是一行代码的事:你打包的那个 Data 直接喂进 UIImage(data:) 或 NSImage(data:)。代价是尺寸,而且会滚雪球:一张 100 千字节的图像,在你加上 data:image/png;base64, 前缀之前,就已经变成一根超过 133,000 字符的字符串。Data URI 在图标、头像和微小资源上闪闪发光,用在主图上却会悄悄撑肥带宽,所以把它留给小的东西。
JWT:封装前两部分
JSON Web Token 的编码侧是两次封装加一个签名,而封装就是你的 base64url extension 丢掉填充,这正是格式所要求的。头和 payload 是 JSON 文档,两部分待遇相同:
import Foundation
extension Data {
var base64URLEncoded: String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
func seal(_ text: String) -> String {
Data(text.utf8).base64URLEncoded
}
let header = seal(#"{"alg":"HS256","typ":"JWT"}"#)
let claims = seal(#"{"sub":"42","role":"editor"}"#)
print("\(header).\(claims).signature-here")
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJlZGl0b3IifQ.signature-here
两个提醒。第三段点分隔的部分是算在前两部分之上的密码学签名,也是令牌里唯一提供任何保证的部分:头和声明是穿着风衣的普通 JSON,所以秘密永远别放进去。另外注意 seal() 里填充是怎么消失的:另一侧的 JWT 解码器(包括姊妹文章里的那个)会用取模补齐把它放回去,所以旅程的两个方向在共同的地面上会合。
HTTP 头:Basic 和其他
老派的 Authorization: Basic 头要的是用户名和密码,用冒号连起来,用标准 base64 打包,因为头里 + 和 / 无害,方言问题根本不会出现:
import Foundation
let credentials = "editor:s3cret"
let header = "Basic " + Data(credentials.utf8).base64EncodedString()
print("Authorization: " + header)
// Authorization: Basic ZWRpdG9yOnMzY3JldA==
和处处相同的那条响亮脚注:打包本身提供零安全,头只有承载它的那个 HTTPS 连接那么安全。现代的表亲 Authorization: Bearer 带的是 JWT,所以 JWT 一节的封装配方就是那里走线的东西。HTTP 里方言问题确实会出现的唯一地方是查询字符串:如果你的 API 让标识符搭 URL 上路,那个标识符应该是 base64url,退一步说至少是百分号编码的标准 base64,绝不能用裸的标准字母表、让它的 + 留着被读成空格。
邮件附件:76 字符的合同
当你的应用产出的附件必须熬过 SMTP 的 7 比特出身时,合同是 MIME 的:base64 按 76 个字符换行、带 CRLF 行尾,外加一个 Content-Transfer-Encoding: base64 头告诉接收方预期什么。选项一节已经展示了拼法;这里是一个换行后正文的完整形状:
import Foundation
let attachment = Data((0..<400).map { UInt8(65 + $0 % 26) })
let body = attachment.base64EncodedString(options: [.lineLength76Characters,
.endLineWithCarriageReturn, .endLineWithLineFeed])
let lines = body.components(separatedBy: "\r\n")
print(lines.count) // 8 行
print(lines.map { $0.count }.max() ?? 0) // 76,最长的一行
print(body.hasSuffix("\r\n")) // false,最后一行不带行尾
这个方言的尺寸账单是那个著名的:4/3 的字母表税,加上每 76 个字符一个换行,落在原始大小的137%附近,而老派邮件工程的捷径"原始大小乘以 1.37,再加上大约 800 字节的头"在今天目测邮件客户端里的附件大小时依然好用。它是算术正确的传说,也是这篇文章里 33% 附加费长出第二位小数的唯一地方。
配置、环境变量与数据库:藏在下划线底下的
有一类安静的活儿,base64 在其中的唯一优点就是它的输出是一个小的、可预测的字符集:把一个二进制大对象或一个结构化的值塞进一个只想要纯文本的地方。必须熬过 shell 配置文件的环保变量,一个觉得 varchar 比 blob 顺眼的数据库里的列,一个带 base64 标记的 LDAP 文件,一个扫字母比扫比特更可靠的二维码。模式处处相同:决定字节,编码,存下字符串,在另一端解码。
import Foundation
struct FeatureFlags: Codable {
var betaToolbar: Bool
var maxRetries: Int
}
do {
let flags = FeatureFlags(betaToolbar: true, maxRetries: 5)
let json = try JSONEncoder().encode(flags)
let storable = json.base64EncodedString()
print(storable)
guard let packed = Data(base64Encoded: storable) else {
print("decode failed, that is odd")
exit(1)
}
let restored = try JSONDecoder().decode(FeatureFlags.self, from: packed)
print(restored.betaToolbar, restored.maxRetries)
} catch {
print(error)
}
这里住着两个坑。第一个是双重包装:两个集成层都"好心"地编码,于是你存下的值是 base64 的 base64,而只解码一次的读者得到一堵字母墙,以为功能坏了。恰好编码一次,在恰好一个边界上,并在注释里说明。第二个是随环境漂移的方言:如果这个值将来要穿过 URL、表单字段,或者一个会把 + 和 / 搞乱的 shell,就改存 base64url 的拼法,因为字符集正是这个格式的全部意义。
文件:.b64 往返
"把这个文件变成一个 .b64 文本文件"这类活儿,是一次读取、一次调用、一次写入:
import Foundation
let source = URL(fileURLWithPath: "photos/cat.png")
let archive = URL(fileURLWithPath: "photos/cat.b64")
let bytes = try Data(contentsOf: source)
try Data(bytes.base64EncodedString().utf8).write(to: archive)
// 之后,也许在另一个进程里
let packed = try String(contentsOf: archive, encoding: .utf8)
let restored = Data(base64Encoded:
packed.trimmingCharacters(in: .whitespacesAndNewlines))
if let restored = restored {
try restored.write(to: URL(fileURLWithPath: "photos/cat-copy.png"))
} else {
print("the .b64 file was not base64 after all")
}
回程里的 trimmingCharacters 之所以存在,是因为写下这个文件的东西可能加了行尾,而严格解码器把末尾换行当成 nil 的裁决。那个往返会逐字节地原样回来,而你第一次上线时就该验证这一点。对于大到让内存使用变得有趣的文件,不要一次编码整个缓冲区。Base64 有一个可爱的性质,让流式处理精确无误:每三个输入字节产生四个相互独立的输出字符,所以只要你编码的每个块都是三字节的倍数,拼接起来的输出就和一次性编码整个文件一模一样。打破对齐,输出就变了,因为块边界会在流中间把一个三字节组劈开:
import Foundation
func streamEncode(_ input: InputStream, output: OutputStream, lineLength: Int = 76) throws {
input.open()
output.open()
defer { input.close(); output.close() }
var buffer = [UInt8](repeating: 0, count: 65_536)
var pending = [UInt8]()
var line = ""
var lineCount = 0
func addText(_ text: String) {
line += text
while line.count > lineLength {
if lineCount > 0 { _ = output.write(Array("\r\n".utf8), maxLength: 2) }
_ = output.write(Array(String(line.prefix(lineLength)).utf8), maxLength: lineLength)
line = String(line.dropFirst(lineLength))
lineCount += 1
}
}
func flushGroup(_ group: [UInt8]) {
addText(Data(group).base64EncodedString())
}
while input.hasBytesAvailable {
let n = input.read(&buffer, maxLength: buffer.count)
if n < 0 { throw CocoaError(.fileReadUnknown) }
if n == 0 { break }
pending.append(contentsOf: buffer[0..<n])
let groups = pending.count / 3
if groups > 0 {
flushGroup(Array(pending[0..<(groups * 3)]))
pending.removeFirst(groups * 3)
}
}
if !pending.isEmpty {
flushGroup(pending)
}
if !line.isEmpty {
if lineCount > 0 { _ = output.write(Array("\r\n".utf8), maxLength: 2) }
_ = output.write(Array(line.utf8), maxLength: line.utf8.count)
}
}
不管文件多大,峰值内存都是一个读取缓冲区加当前行,换行后的输出与一次性 .lineLength76Characters 的拼法完全一致。同样的三字节倍数规则,角色对调之后,就是姊妹文章里流式解码器所依赖的那一条,所以旅程的两侧共享同一个算术真理。
大 payload 与内存账单
让我们把下次有人问"这个能 base64 吗?"时你会需要的算术做一遍。每三个输入字节变成四个输出字符,所以尺寸乘以 4/3:一个 100 千字节的文件变成 133,336 字符的字符串,一个 10 兆字节的文件变成 13,333,336 字符,依此类推。填充在最末尾最多加两个字符,对超过几个字节的东西来说是个舍入误差,而空输入是唯一的豁免,税务局在那里签发一张免费通行证,结果是空字符串。三个实际后果。第一,动手前先做预算:如果你的 payload 已经贴近某个限制(URL 大约 2,000 字符的舒适区、JSON 字段的合同、数据库列的宽度),在编码之前把限制除以 1.33,而不是之后(涉及换行时除以 1.37)。第二,打包期间,你同时握着原始字节和打包后的字符串,所以工作集约为原始的 2.33 倍,上面那些流式函数就是当这个数字不再舒服时的逃生舱。第三,这笔税实际上单向征收:你打包时付钱,别人的字节在解包时回家,所以真正的问题从来不是"base64 贵吗?",而是"我走的这条只通文本的路是否要求它?"。
咬人的错误
- 可失败的字符集步骤。
String.data(using:)会回答nil(拿一个带重音的字符试.ascii就知道),而强解它正是把坏输入升级成崩溃应用的经典操作。要守卫的是转换,而不只是 base64 调用,后者才是简单的那部分。 - CRLF 家规。不带行尾选项的
.lineLength选项默认产生 CRLF。如果你的格式只要 LF,而你忘了选项,你的输出就带着它本来不该有的回车。 - 只有 CR 的陷阱。单独用
.endLineWithCarriageReturn会产生老 Mac 风格的纯 CR 行尾。如果你要的是 CRLF(对 MIME 来说你就是),两个行尾选项都要传。 - JSON 里换行。JSON 字符串里的原始换行就是无效 JSON,句号。插进文档的换行 base64 是一个带 base64 口音的语法错误。把换行后的输出留在邮件正文和证书文件里。
- 搭便车的 BOM。裸的
.utf16转换会在前面加一个两字节 BOM,它搭进你的打包输出,搞糊涂没料到它的解码器。当你需要不带标签的 UTF-16 时,用.utf16LittleEndian或.utf16BigEndian。 - 方言漂移。标准和 base64url 是不同的字母表,RFC 白纸黑字这么说。一个活到查询字符串里的
+会变成空格;一个抵达宽松标准解码器的-会被删掉。在边界上选方言,然后守住它。 - 大小写是字母的一部分。字母表区分
A和a。一次折叠了大小写的复制粘贴,或一次热情过头的大写化调用,会悄悄损坏数据,因为两个版本都仍然能通过所有字母表检查。Base64 区分大小写,就像护照号码区分大小写一样。 - 对齐规则。流式编码器必须按三字节的倍数切块。错位的块会改变输出,而改变是无声的:字符串照样解码,解出的是错误的数据。
- 双重包装。两个都编码的层产生 base64 的 base64。只解码一次的读者在应该看到字节的地方看到字母,事故自己就写好了。
- 可用性之墙。新的原生选项(
.base64URLAlphabet、.omitPaddingCharacter)存在于最新的 SDK beta 和开源 Foundation(在可用性标记后面),但不在你的 CI 会碰到的每一个工具链上。如果你采用它们,用可用性检查来守卫,让同一份源码能在旧版 Xcode 和 Linux 上构建。在当前稳定工具链上,这个四行 extension 编译在每一个选项缺席的地方。 - Base64 不是加密。如果要求是机密性,你选错的工具整整差了一个门类。Base64 的工作是让字节上路,而且它只做那一份工作,一点不多。
怎么把它发出去
- 编码的是字节,不是愿望。调用方法之前先决定字节形态,默认 UTF-8,不是它时就显式命名,并守卫可失败的
data(using:)步骤,因为数据真正丢失的是那里。 - 默认不换行,按合同换行。朴素的单行输出对 JSON、API 和大多数数据库来说是正确的;只有当接收格式要求时才伸手去拿 64/76 换行选项,而当你说 CRLF 时,两个行尾选项都要付钱。
- 每个边界一种方言。以文本为中心的目的地用标准 base64,会碰到 URL 或文件名的任何东西用 base64url,永远不要两种出现在同一个文档里。转换写一次,名字取诚实,然后复用。
- 给附加费做预算。动手前乘以 4/3(换行在场时乘以 1.37),当 payload 大到让工作集不舒服时,用三字节对齐的块做流式处理。
- 别拿包装胶带当锁。如果要求是保密,就在 base64 货架前停下,改拿加密。
打包简史
你拿来打包的字母表和你换行所依的行长,是四十年来关于"多少二进制能在一条只通文本的路上活下来"的争论留下的化石,而 Swift 在那段历史里的位置短小但有趣:
- 1980 年代,同一台机器的时代。这个家族最早的编码器存在于拨号连接上,在默认另一端和自己一样是台机器的系统之间搬文件。UNIX 上的 uuencode 用大写字母、数字和标点,它的设计者发现了一个省算力的技巧:字母表位于连续的 ASCII 位置上,所以编码字面意义上就是"加 32",没有查找表。BinHex,这个 1981 年生在 TRS-80 上的表亲,跳到了 Apple II,1984 年成为经典 Macintosh 的格式,它押了不同的注:它的 64 个字符省略了
7、O、W、g、o,以及差不多一半的小写字母。 - 1987,字母表拿到门牌。RFC 989,第一份 Privacy-Enhanced Mail 规范,标准化了你今天敲下的那 64 个字符,把输出按每行 64 个字符换行,用
=做填充,用*标记已编码但未加密的数据。你往服务器配置里粘贴过的每一个 PEM 风格的块,都是这份文档的后代。 - 1996,开明的时代。MIME(RFC 2045)把字母表用于邮件附件,把换行移到 76 个字符,并加了一条让换行可以被安全产出的规则:解码器应该忽略换行。编码器学会了换行;解码器学会了原谅。Swift 的 76 字符选项正是那场争论的活纪念物。
- 2003 到 2006,规则变硬。RFC 3548(2003 年)宣布填充不得省略(除非格式另有说明),解码器必须拒绝字母表之外的字符;RFC 4648(2006 年 10 月)安顿了这个家族,加了 URL 安全字母表,明确地为了让长标识符能在 URL 里安家,而不必给每个特殊字符做百分号转义。"URL 方言不带填充"的约定也出生在同一个文档里,因为 URL 里的填充字符通常会变成
%3D,白费力气。 - 2013 到 2014,API 已经在此。Apple 的
NSData类打包 base64 已经好几年了,带四个换行选项的基于选项的 API 在 2013 年的 iOS 7 到来,那时 Swift 根本还不存在。当 Swift 1.0 在 2014 年 9 月 9 日到来时,它继承了一个带四个换行选项的全函数编码器,以及 1987 年的 64 字母字母表,从那以后性格没变过。 - 2015 年 12 月 3 日,工具链走出大楼。Swift 在那天开源,Foundation 的 base64 也随之跨到 Linux,后来又跨到 Windows。"在 Apple 机器之外用 Swift 做 base64 编码"满打满算才不过十年:一个很年轻的客人,赴的还是 1987 年那场派对。
- 2023 到 2026,重写与 URL 方言。Foundation 重写(swift-foundation 项目)把
Data移进了纯 Swift 内核,2025 年一个社区提案加了原生的 base64url 和省略填充选项。截至本文写作时,最新的 SDK beta 和开源工具链发布的是编码选项,家族其余成员在开源 Foundation 的可用性标记后面继续成熟,而社区 extension 在此期间仍然是可移植的桥梁。
小小的趣味
- 1 兆字节打包后正好是 1,333,336 个 base64 字符,4/3 的税加上两个填充字符,精确到个位。唯一完全逃税的输入是空的那个:没有进去,没有出来。
- 编码器是解码器所不具备的那种全函数。它从不返回 nil,从不抛异常,从不拒绝。整条流水线里唯一的失败住在上游,在字符集那一步,这就是这个方法感觉比它的表亲平静得多的原因。
- 用 UTF-8 编码
héllo这个词,它变成aMOpbGxv;用 UTF-16 小端编码,它变成aADpAGwAbABvAA==。同一个词,两本不同的护照,都有效,谁也不能互换。 - base64 世界的测试词是
foobar,它打包成Zm9vYmFy。如果你曾在野外见过 base64 例子,foobar 参与其中的可能性相当高。 - 那个著名的 1x1 透明 GIF 是 42 字节,以魔法词
GIF89a开头,这就是前缀R0lGODlh比地球上几乎任何其他 base64 字符串都出现在更多代码库里的原因。 - 你的
Codable结构体多半已经悄无声息地发送 base64 好几年了:JSONEncoder对Data的默认策略用标准 base64 打包,这就是Data字段走线时是一根带填充的字符串、而不是一串数字数组的原因。 - 填充永远不超过两个字符,从来没有例外。1 字节的 payload 以
==收尾,2 字节以=收尾,3 字节什么都不带。最后一组的整套语法能放进一个指甲盖里。 - Swift 比它用来打包的字母表年轻 27 岁。这门语言 2014 年发布;那 64 个字母 1987 年标准化,此后再未变过。
这就是完整的打包工具箱:一个全函数方法、一个排在它之前的可失败步骤、四个带 CRLF 家规的换行选项、一个四行 base64url extension、一条用于流式的三字节对齐规则,外加一笔 4/3 附加费,那是登上只通文本之路的入场费。编码是你付 base64 账单的地方,而你在签字之前已经认出了每一行明细。一旦旅程翻转,你开始打开别人打包好的东西,nil 回来了,空白字符的裁决来了,宽松旋钮的盲点登台了。相关的解码文章完整主演了往返的这一半,所以当字母开始到达时,你已经确切知道该怎么打开它们。
最后更新: 2026-09-08
相关文章: Swift 中的 Base64 解码:完整指南