Swift での Base64 デコード:完全ガイド
あなたのパイプラインのどこかで、データは仮装している:HTTP ヘッダーに忍び込んでいるトークン、JSON フィールドの奥に隠れているアバター、先週見てやるはずだった .b64 ファイル、文字の壁として届いたメールの添付ファイル。Swift でその仮装を脱がせるのは、この言語で最も心地よい仕事のひとつです:フレームワークは1つ、初期化子は1つ、ルールブックは付箋1枚に収まるほど短い。
このサイトのホームページでは、フォーマットそのもの(64個の表示可能な文字、1文字あたり6ビット、最後のグループには最大2つの = のパディング)はすでに扱っているので、ここではその話には立ち入りません。ポケットにしまってほしいのは2つの事実だけ。第一に、base64 はバイトをテキストに見せるための方法であり、鍵ではありません。第二に、Swift での base64 の旅はすべて1つの型 Data を通って走り、デコーダーはその上に失敗可能な初期化子として住んでいます。この1つの事実が、この記事の残り全体を形づくるのは、失敗可能な初期化子とは、その後に続くすべての行の書き方を変えるものだからです。
1つの型が仕事全体を抱える
Swift は base64 のヘルパーを十数個のモジュールにばらまきもせず、インストールさせるものも何もありません。デコーダーは Foundation にある Data(base64Encoded:options:) で、このフレームワークの初期からプラットフォームの一部でした(Apple はこの初期化子を iOS 8.0, macOS 10.10, tvOS 9.0, watchOS 2.0, visionOS 1.0 からと挙げています。エンコード側の行長オプションは、さらに iOS 7.0 まで遡ります)。Linux や Windows では同じ Foundation がオープンソースのツールチェーンに同梱されるので、以下のコードは iPhone アプリでも、サーバーのワーカーでも、あなたのターミナルのスクリプトでも同じ挙動をします。
兄弟の初期化子 Data(base64Encoded: Data, options:) があって、base64 が文字列ではなく生の ASCII バイトとして届く場合にこれを使います。両方とも options 引数を取り、デフォルトは [] です。そして両方に共通する、どのオプションよりも大切な1つの性格があります:失敗可能であることです。
import Foundation
let packed = "SGVsbG8sIFN3aWZ0IQ=="
if let data = Data(base64Encoded: packed) {
let text = String(data: data, encoding: .utf8)
print(text ?? "not text after all")
} else {
print("that was not base64")
}
// Hello, Swift!
Apple によるこの初期化子のドキュメントは見事なほど率直です:「入力が有効な Base-64 として認識されない場合は nil を返す」。例外もなく、throw されるエラーもなく、ログの洪水もない。ただ静かな nil と、それがユーザーにとって何を意味するのかを判断する責任だけがそこにある。Swift の base64 について1つだけ覚えてほしいことがあるなら、これがそれです:デコーダーは決してクラッシュせず、文句も言いません。ただ静かに断るだけです。
デコーダーの判定:Yes と No の表
ではこのデコーダーにとって「有効」とは何でしょうか。それは硬いルールの、ものすごく短いリストであることが分かりました。そしてそのリストこそが、「デモでは動く」ものと「本番で生き残る」ものの違いです。以下の表の各行は、現在のツールチェーンでの初期化子の実際の挙動なので、そのままエラーメッセージに引用できます:
| 入力 | 判定 | 理由 |
|---|---|---|
TWFu |
Man |
満員の4文字グループは、パディングがまったく不要 |
TQ== |
M |
1バイトにパッド2つ、教科書的なケース |
SGVsbG8h |
Hello! |
8文字は4の倍数なので、パッドは不要 |
==== |
空の Data |
何もない後ろへのパディングは合法で、ゼロバイトにデコードされる |
| 空文字列 | 空の Data |
何も入らなければ何も出ない、それでもオプショナルは成功する |
TQ |
nil |
長さ2: 4文字のグループが約束されても、届くことはなかった |
T |
nil |
1文字は6ビットしか運べず、1バイトには8ビット必要 |
SGVsbG8hTQ |
nil |
10文字:最後のグループがパッドなしで宙に浮いている |
TQ=== |
nil |
パッド3つ:3つ目はもう補うものがない |
TQ==TQ |
nil |
パディングのあとのデータは明確な No |
SGVs bG8h |
nil |
1つのスペースはアルファベット外で、厳格モードは容赦しない |
SGVsbG8h に末尾改行を追加 |
nil |
つい先ほど読んだファイル末尾の改行も、ノイズに数えられる |
3つの行が二度見に値します。==== の行は、if let のチェックが通り、あなたのコードがゼロバイトのまま出航することを意味します。だから、空のペイロードがあなたのアプリでは妥当な状態でないなら、デコード直後にバイト数をチェックしてください。空文字列の行は、化粧が薄いが同じ仕掛けです。そして末尾改行の行こそが、朝は完璧にエンコードできた base64 ファイルが午後にはデコードを拒む、最もよくある単独の原因です:経由地のどこかが改行コードを足し、厳格なデコーダーはそれを個人的なことにしているのです。
表には載せられない、有名な緩い場所もあります。TQ== と TS== を比べてみてください:両方とも同じバイト M にデコードされます。最後の文字の下の2ビットは、検査される前に捨てられてしまうからです。代わりに Tg== を見せれば、文句も言い争いもなく N が返ります。デコーダーが取り締まるのは文字だけで、末尾のビットは見逃す。その寛容さはバグではありませんが、2つの異なる文字列が同じデータを意味しうるということになり、あなたのシステムが base64 の値を比較したり、重複除去したり、キャッシュしたりし始めた瞬間に問題になり始めます(詳しくはセキュリティのセクションで)。
入力があなたの想像よりノイズが多いとき
現実世界の base64 が、1つの汚れのない行として届くことはめったにありません。メールの添付ファイルは1996年の MIME 仕様に受け継がれた癖で、各行の後にキャリッジリターンとラインフィードをつけて76文字で折り返されますし、証明書ファイルは64文字で折り返されています。そのノイズに対処するためにデコーダーが持つオプションはちょうど1つだけです:大きな1つです:
import Foundation
let mimeBody = "SGVs\r\nbG8sIG1h\naWwgbm9pc2Uu"
if let data = Data(base64Encoded: mimeBody, options: .ignoreUnknownCharacters) {
print(String(data: data, encoding: .utf8) ?? "")
}
// Hello, mail noise.
.ignoreUnknownCharacters は、ドキュメントでは「行末文字を含む、不明な非 Base-64 バイトを無視する」と説明されており、その仕事にはまさに最適な道具です:ノイズは削除され、アルファベットは生き残り、ペイロードは丸ごと出てくる。でもこのオプションには盲点があり、それは Swift 開発者を最も強く咬むものです:base64url の - や _ を含む、アルファベット外のすべての文字を削除します。それらを + や / に変換するのではなく、単に捨ててしまうのです。その削除で何が残り느냐によって、nil が返ることもあります(生き残った文字がもはや完全なグループを形作れないとき)、もっと悪いことに、バイト数が違っているのに確信満々の答えが返ることもあります。12バイトをエンコードした16文字の base64url 文字列が、寛容なデコーダーから別の9バイトとして、エラーも謝罪もなしに返ってくるのです。
覚えておくべきルール:.ignoreUnknownCharacters は搬送中のノイズ(改行、コピー&ペーストで混入した余計なスペース)のためにあって、アルファベットの違いのためにあってはなりません。ペイロードが base64url の可能性があるなら、まず自分で文字を変換してからにしてください。次のセクションが示すそのままのやり方で、デコーダーにはきれいな標準文字列を渡すのです。
URL アルファベット
RFC 4648 のセクション5が、あなたがこれまで出会ってきた標準アルファベットの親戚を定義しています:+ が - になり、/ が _ になり、= のパディングはたいてい落とされる base64url。理由は、あなたの URL を誠実な状態に保っているのとまったく同じものです:クエリ文字列の中では、+ はフォーム解析にスペースとして読まれ、/ はパスの区切りで、= はキーと値を分けます。RFC は2つの関係を率直に語っています:URL バリアントは「base64 エンコーディングと同じだとみなすべきではない」と。JWT、Web Push メッセージ、YouTube の動画 ID、そして大多数のモダンな API 識別子は base64url で話をするので、初日から出会えるはずだと覚悟してください。
デコード側では、レシピは2つの動きです:まずアルファベットを変換し、それからパディングを補う。厳格なデコーダーは、やはり4の倍数を要求するからです:
import Foundation
extension String {
func dataFromBase64URL() -> Data? {
var fixed = self
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let missing = fixed.count % 4
if missing > 0 {
fixed += String(repeating: "=", count: 4 - missing)
}
return Data(base64Encoded: fixed)
}
}
let tokenPart = "0S__zMWaTC-iVgJ-"
if let bytes = tokenPart.dataFromBase64URL() {
print(bytes.count) // 12
}
剰余の行こそが全部の仕掛けです:base64url のペイロードは通常パディングなしで届き、1つか2つの = 文字(3つは絶対にない)が、デコーダーが期待する4文字グループを取り戻してくれる。この5行エクステンションの何らかのバージョンが、驚くほど多くの Swift コードベースで見つかるはずです。それも正当な理由があって、将来短くなる理由も1つあります:最新の Apple SDK(執筆時点で 26.4 以降)はエンコーダーにネイティブの .base64URLAlphabet オプションを身に着け、対応するデコード側オプションは、より後方のツールチェーン向けの可用性マーカーの向こうで、オープンソースの Foundation の中でまだ成熟しつつあります。それがあなたの最低デプロイ目標に届くまでは、エクステンションこそが移植可能な答えで、構造上、あらゆるバージョンで動き続けます。
まずはバイト、その後に文字
ここに、デコーダーではあなたの代わりに下せない決断があります:それは Data、つまりバイトの袋をあなたに手渡すだけで、元のペイロードがどの文字セットで書かれたかなど知りません。ペイロードがテキストだった場合、その文字セットを選ぶのはあなたの仕事です。そして Swift は、バイトの世界から抜け出すためのドアを2つ用意していて、気性はまったく違います。
String(data:encoding:)は厳格なドアです。オプショナルを返し、そのバイトがあなたが名指ししたエンコーディングでは無効なときはnilと答えます。検証には理想ですが、答えを強制的にアンラップすると危険です。String(decoding:as:)は決して拒否しないドアです。常に文字列を返し、意味が通らないものには U+FFFD の置換文字を差し替えます。ログやプレビューには理想ですが、結果を保存してそれをデータと呼ぶなら危険です。
import Foundation
let bytes = Data([0xC3, 0xA5]) // å(リング付きの a)の UTF-8 表記
print(String(data: bytes, encoding: .utf8) ?? "?") // å、正しく読み取れた
print(String(data: bytes, encoding: .isoLatin1) ?? "?") // 迷子になった2文字、同じバイト
print(String(decoding: bytes, as: UTF8.self)) // å、しかもクラッシュしない
ほぼすべてをカバーするレシピ:まず厳格な UTF-8 を試す。モダンな API がほぼ常に意味しているのはそれがだからです。契約が沈黙しており、沈黙より「読めるけど間違っている」方を選べるなら、そのときだけ ISO Latin-1 にフォールバックします。決して拒否しないドアは、デバッグ出力のためにとっておく。そしてチェックすべき見えない侵入者が1人:ペイロードが UTF-8 BOM(3バイトの EF BB BF)で始まる場合、厳格な変換はそれをそのまま残し、あなたの文字列は見えない U+FEFF 文字で始まることになります。それは静かに等価性チェックと JSON のラウンドトリップを壊します。仕様が BOM の存在を約束していないなら、プレフィックスチェックで切り捨ててください。
ファイルを開く
「.b64 ファイルがある、隠しているものを見せて」という仕事は、読む、トリムする、デコードする、書き出す、の4つです。トリムはお飾りではありません:ファイルが開くかどうか、nil が返るかどうかの境界線です。ツールも、メールクライアントも、エディタも、最後に改行を残すのが大好きだからです:
import Foundation
let inbox = URL(fileURLWithPath: "Downloads/avatar.b64")
let outbox = URL(fileURLWithPath: "Downloads/avatar.png")
let raw = try String(contentsOf: inbox, encoding: .utf8)
if let data = Data(base64Encoded:
raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
try data.write(to: outbox)
} else {
print("the file was not base64 after all")
}
ファイルが MIME ラップ(76文字ごとに改行)されているなら、きれいな抜け道が2つあります:.ignoreUnknownCharacters でデコードして、オプションに改行を食べさせる。あるいは、厳格なデコードの前に自分で replacingOccurrences で取り除く。どちらも1行ずつです。とにかく大きいファイルには、全体を読み込むのではなく、整列したグループ単位でデコードしてください:4文字の各グループは独立してデコードできるので、読み取り境界をまたぐ際に運ぶのは、現在のグループと小さな残り分だけです:
import Foundation
func decodeBase64Chunks(_ stream: InputStream, into result: inout Data) throws {
let chunkSize = 65_536
var buffer = [UInt8](repeating: 0, count: chunkSize)
var leftover = ""
result = Data()
stream.open()
defer { stream.close() }
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: chunkSize)
if read < 0 { throw CocoaError(.fileReadUnknown) }
if read == 0 { break }
var text = String(decoding: buffer[0..<read], as: UTF8.self)
text = text.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
text = leftover + text
if text.count % 4 != 0 {
let whole = text.count - (text.count % 4)
leftover = String(text.suffix(text.count - whole))
text = String(text.prefix(whole))
} else {
leftover = ""
}
guard !text.isEmpty else { continue }
guard let part = Data(base64Encoded: text) else {
throw CocoaError(.fileReadCorruptFile)
}
result.append(part)
}
if !leftover.isEmpty {
guard let part = Data(base64Encoded: leftover) else {
throw CocoaError(.fileReadCorruptFile)
}
result.append(part)
}
}
ファイルがどれほど大きくてもメモリは平坦なままです:読み取りバッファ1つ、残り断片1つ、そして作り上げている結果。同じループが、ワイヤーの上を base64 として届くダウンロード、実はエンコードされたストリームであるログファイル、手に持てないほど大きいペイロードも処理します。
JWT: 3つの点をよむ
コンパクトな JSON Web Token は、点で結ばれた3つの base64url パートで、その最初の2つはトレンチコートに包まれた素の JSON です。パディングなしで届いてくるのがこれらで、これはまさに厳格なデコーダーが一瞥して拒否する組み合わせなので、URL セクションの dataFromBase64URL() ヘルパーがすべての重労働を引き受けてくれます:
import Foundation
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
func openPart(_ part: String) -> String? {
var fixed = part
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let missing = fixed.count % 4
if missing > 0 {
fixed += String(repeating: "=", count: 4 - missing)
}
guard let data = Data(base64Encoded: fixed) else { return nil }
return String(data: data, encoding: .utf8)
}
let pieces = token.split(separator: ".")
print(openPart(String(pieces[0])) ?? "?")
// {"alg":"HS256","typ":"JWT"}
print(openPart(String(pieces[1])) ?? "?")
// {"sub":"1234567890","name":"John Doe"}
2つの注意が同乗します。JWT は署名されたものにすぎず、暗号化はされていません:ヘッダーもペイロードも公開情報であり、まさにそれゆえパスワードがそこに属することはない(暗号化された親戚の JWE は、まったく別の仕様です)。そして3番目の点区切りパートは暗号署名であり、文書ではないので、パート1と2をデコードし、あとは手をつけないようにしてください。
Data URI:カンマの向こうにあるファイル
Web API は data: スキームを使って、テキストのなかにバイナリを隠すのが大好きです:プロフィールフィールドに入る PNG、CSS のブロブに入るフォント、設定ファイルに入る QR コード。フォーマットは data:{mime};base64,{payload} で、ペイロードを剥ぐのは区切り1回の距離です:
import Foundation
let uri = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
let payload = uri.components(separatedBy: ",").last ?? ""
if let bytes = Data(base64Encoded: payload) {
print(String(decoding: bytes.prefix(6), as: UTF8.self)) // GIF89a
print(bytes.count) // 42
} else {
print("not a base64 data uri")
}
この例で使われているのは、有名な42バイトの透過 GIF です。このフォーマットの中で最小の画像で、そのためその先頭の文字は、インターネット上のほぼどの base64 文字列よりも多くのコードベースに顔を出しています。Apple プラットフォームでは、このパイプラインは1行で終わります:今デコードしたばかりの同じ Data がそのまま UIImage(data:) や NSImage(data:) に渡せるからです。だからこそ「API からアバターを表示する」は小さな機能であって、プロジェクトではありません。
HTTP: Basic ヘッダーとその仲間たち
古い Authorization: Basic ヘッダーは、コロンで結ばれたユーザー名とパスワードを、旅のために標準 base64 で詰めたものです(URL 方言ではありません:これは + と / がまったく無害なヘッダーの中に住んでいます)。それを解くのは、区切ってデコードするだけです:
import Foundation
let header = "Basic ZWRpdG9yOnMzY3JldA=="
let packed = header.replacingOccurrences(of: "Basic ", with: "")
if let creds = Data(base64Encoded: packed) {
print(String(data: creds, encoding: .utf8) ?? "") // editor:s3cret
} else {
print("malformed header")
}
セキュリティの脚注は大きく掲げ続けてください。あなたが出会うことになるすべての base64 に適用されるものだからです:これは詰め物であり、保護ではない。Basic 認証が許されるのは HTTPS 経由だけで、そこで TLS が実際の見張りをし、base64 はバイトがヘッダーの文法を壊さないようにするだけ。同じ筋道で Authorization: Bearer トークンも説明できます:トークン自体が JWT なので、JWT セクションのデコードレシピがそのまま適用されます。
メール:76文字の癖
base64 でエンコードされたメールの添付ファイルは、CRLF 改行で76文字ごとに折り返されています。寛容オプションが存在する理由がまさにそのノイズです。生の MIME ヘッダーは、送り手がどのアルファベットとどの折り返しを使ったかを教えてくれます(Content-Transfer-Encoding: base64)。そして直しの方法はフラグ1つです:
import Foundation
let attachment = "VGhpcyBhdHRhY2htZW50IHN1cnZpdmVk\r\nIHRoZSA3Ni1jaGFyYWN0ZXIgaGFiaXQu"
if let data = Data(base64Encoded: attachment, options: .ignoreUnknownCharacters) {
print(String(data: data, encoding: .utf8) ?? "")
}
// この添付ファイルは 76文字の癖を生き延びた。
読む側ではなくメール機能を書く側なら、76文字ラップにもコストがかかることを忘れないでください:76文字ごとに改行が入ると、エンコードされたテキストは元のサイズの約 137% になります。だからこそ、ベテランのメールエンジニアは「元を 1.37 倍して、ヘッダーの約 800 バイトを足す」というショートカットで添付ファイルのサイズを目測していたのです。その数字は今は伝説ですが、足し算は相変わらず正しい足し算です。
2重にラップされたペイロード
base64 界で最もよくある「データが壊れた」というチケットは、2回詰められたデータです:1つ目の統合レイヤーがエンコードし、ドキュメントを読んだことのない2つ目のレイヤーがその結果をエンコードした。防御的な動きは、1回デコードして、何が手元に来たのかを見る。結果自体がきれいな base64 風の文字列(長さが合っている、アルファベットが合っている、不自然なものがない)だったら、わざと2回目をデコードして、そこで止まる。失敗するまでデコードし続けるループは書かないでください。そんなループは、中身がたまたま base64 っぽく見える完全に健全なファイルを嬉々として食べてしまい、走ったあとでは、元のデータがどこから始まったのか誰も分からなくなります:
import Foundation
func unwrapOnce(_ packed: String) -> Data? {
let cleaned = packed.trimmingCharacters(in: .whitespacesAndNewlines)
return Data(base64Encoded: cleaned)
}
let suspicious = "WVdKag==" // すでに詰め込まれている見た目
if let first = unwrapOnce(suspicious) {
let inner = String(data: first, encoding: .utf8) ?? ""
if let second = unwrapOnce(inner) {
print("it was wrapped twice:", String(data: second, encoding: .utf8) ?? "?")
}
}
// it was wrapped twice: abc
2回のアンラップ、2回の意識ある決断、そしてようやくただの abc に戻ったペイロード。
nil に意味を持たせる
デコーダーは throw する代わりに nil と答えるので、あなたの base64 コードのエラー処理スタイルはあなたが選んだものです。そして後になって「選んでよかった」と思う選択は、静かな拒絶を、大きくて具体的なエラーに変える小さなラッパーです:
import Foundation
enum Base64Failure: Error, CustomStringConvertible {
case notBase64(Int)
var description: String {
switch self {
case .notBase64(let length):
return "input of \(length) characters is not valid base64"
}
}
}
func decodeStrict(_ text: String) throws -> Data {
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = Data(base64Encoded: cleaned) else {
throw Base64Failure.notBase64(cleaned.count)
}
return data
}
do {
let bytes = try decodeStrict("c3ludGF4IGVycm")
print(String(data: bytes, encoding: .utf8) ?? "?")
} catch {
print(error) // input of 14 characters is not valid base64
}
このラッパーは、正規化が住む唯一の場所にもなります:トリム、あらゆるアルファベット変換、あらゆるパディングの補填。呼び出し側が手に入るのは1つの関数、1つの失敗の意味、そして視界に ! が1つもない状態。Data(base64Encoded:)! を強制アンラップすることは、悪いペイロードがクラッシュしたアプリになる方法であり、このラッパーはそれに対する安い保険です。同じパターンはコマンドラインでも機能します:CommandLine.arguments と FileHandle による書き込みのスクリプトなら、「シェルからこのファイルをデコードする」は、ウェブサイト経由のコピー&ペーストの寄り道ではなく、5行のユーティリティになります。
セキュリティ、バイトで測る
- 暗号化ではない。 Base64 は可逆で、すぐに読める詰め替えです。脅威モデルにブラウザと5秒を持つ人間が含まれるなら、保護はゼロです。しかもすべての JWT ヘッダーがそれを毎日証明しています。
- 比較する前に正規化する。
TQ==とTS==は同じバイトにデコードされるので、2つのシステムが同じデータの異なる綴りを保持することがあり得ます。2022年の論文「実践における Base64 の可変性」は、壊れた唯一性の保証が実世界で何を引き起こすかを記録しました:ログの不一致、サービス拒否攻撃、データベースエントリの重複。Swift アプリが base64 の値をキャッシュ・重複除去・比較するなら、入口で正規化されたデコード(または正規化された再エンコード)を1回実行してください。 - デコードの前に入力の上限を決める。 N文字のデコードは、入力文字列をまだ手にしているあいだ、Nバイトの約4分の3のメモリを割り当てます。敵意を持つクライアントは
Aという文字を100メガバイト送り、デコーダーが No と言う前にあなたのメモリが登っていくのを見守れます。まず長さをチェックし、安く済ませて、大きすぎたら拒否してください。 - 寛容オプションをフィルタとして使うな。
.ignoreUnknownCharactersは文字を削除します。それを通す「サニタイズ」は、エラーも出さず、有効な base64url ペイロードを別のデータに変えることがあります。これは改行のためのノイズフィルタであり、バリデーターではありません。 - できるかぎり URL から遠ざける。 クエリ文字列やパスにある大きな base64 ペイロードは、快適な URL 長を吹き飛ばし、プロキシに壊されます。代わりに、リクエストボディ、ファイル、トークンに入れてください。
パフォーマンス、簡単に
デコーダーはルックアップテーブルを1周する処理です:各文字が小さなテーブルのインデックスになり、いくつかのビットがシフトされて OR されて、出力バイトになっていきます。現在のツールチェーンでは、メモリに収まるものならどれも十分速く、覚えておくべき数字は出力比率です:デコードされたバイトは入力長の約4分の3なので、4メガバイトの文字列は、すでに持っている文字列に加えて約3メガバイトの結果のコストがかかります。Foundation そのものが許されない道にいるなら(深く埋め込まれたターゲット、WebAssembly バンドル)、コミュニティパッケージの swift-extras-base64 が注目に値する代替手段です:Foundation への依存のない純粋な Swift、base64url とパディングオプションを持つ RFC 4648 準拠のエンコーダーとデコーダー、そして Foundation を数倍速くするベンチマーク。同じパッケージの早い実装は、swift-nio の WebSocket サポートの中にすら同梱されており、サイドプロジェクトとしてはこれほど本番級に近いものはそうありません。通常のアプリやスクリプトには不要な荷物ですが、制約の厳しい Swift の端っこでは、これが標準の答えです。
開封の10年
Swift はこれらを何も発明していません。ツールボックスの各パーツがどこから来たのかを知っておく価値があります:
- 1980年代、同じ機械の時代。 この系列の最も初期のエンコーディング(UNIX の uuencode、TRS-80 とクラシック Mac の BinHex)は、向こう側が自分たちのような機械だと仮定したマシン同士でファイルをやり取りしていました。uuencode は大文字・数字・記号のアルファベットを使い、その文字は連続した ASCII 位置に並んでいるので、エンコードはルックアップテーブルすら不要な「32 を足す」で済みました。この時代のデコーダーは多くのことを仮定できましたが、データがエコシステムを越えた瞬間に、それは倒れました。
- 1987年、アルファベットに住所が付く。 RFC 989(Privacy-Enhanced Mail、1987年2月)は64文字のアルファベットを標準化し、行をちょうど64文字で折り返し、
=をパディングに、*を「エンコードされたが暗号化されていない」データの印に使いました。すべての PEM 風ブロックは、その文書の子孫です。 - 1996年、寛容の時代。 MIME(RFC 2045)はアルファベットをメールに持ち込み、折り返しを76文字に移し、準拠したデコーダーには CRLF の改行などアルファベット外のすべての文字を無視するよう命じました。この時代が、寛容なデコーダーを期待する1世代を育て、Swift の厳格なデフォルトは、その時代の期待をわざと壊すのです。
- 2003年から2006年、ルールが固まる。 RFC 3548(2003)がこの系列の統一に最初の一手を打ち、RFC 4648(2006年10月)が決着をつけ、パディングのルールを法文化し、URL 安全アルファベットを追加しました。そのデコーダーの段落こそが Swift が従うものです:アルファベット外の文字を拒否する。あなたが仕えるフォーマットが、MIME のように無視しろと明示している場合を除いて。
- 2013年から2014年、APIが控え室で待つ。 Apple の
NSDataクラスは何年も base64 を詰め・開けてきました。デコードオプションを持つオプションベースの API は iOS 7、つまり 2013 年に着いていて、Swift が存在する1年前のことです。Swift 1.0 が 2014年9月9日に発売されると、デコーダーは言語と一緒に歩いて入り、それ以来同じ性格を保っています:厳格なコア、1つの寛容なノブ、失敗可能な初期化子。 - 2015年12月3日、Linuxにデコーダーが来る。 Swift はその日にオープンソース化され、Foundation の base64 もそれに伴って Linux、そしてその後 Windows に渡りました。「非 Apple マシンでの Swift による Base64 デコード」の歴史はまだ10年にも満たない:1987年に始まったパーティーに遅れて来た客です。
- 2023年から2026年、書き換え。 Foundation の書き換え(swift-foundation プロジェクト)は
Dataを純粋な Swift のコアへ移し、2025年にはコミュニティのピッチがネイティブの base64url とパディング省略オプションを追加しました。執筆時点では、最新の SDK ベータとオープンソースのツールチェーンがエンコードオプションを搭載する一方、デコードオプションはまだオープンソースのツールチェーンの中で成熟途中なので、その間、手作りエクステンションは普遍的な答えのままです。
小さな驚き
====は合法な入力です。パッド4つにデータなしは空のDataにデコードされ、それは「ここに何もない」というのが全部内容である唯一の base64 文字列で、Swift もそれに同意します。- デコーダーの文字警察は、ビット警察の仕事は確認しません:
TS==とTQ==はどちらもMを手渡しますが、Tg==はNを手渡します。同じ文法、ビットが違う、何も問われない。 - Swift の
Dataは、Data(base64Encoded: Data)バリアントを通じて、文字列ではなくバイトとして届いた base64 もデコードできます。したがって、ASCII としてワイヤーを渡ったペイロードは、文字列のラウンドトリップを完全にスキップできます。 - テストベクターが生まれてからずっと base64 にされている言葉は
foobarで、それはZm9vYmFyに詰められます。実世界で base64 の例を見たことがあるなら、foobar が関与していた可能性は十分にあります。 - 有名な 1x1 の透過 GIF はちょうど42バイトで、魔法の言葉
GIF89aで始まります。そのためそのエンコードされた先頭8文字は、地球上のほぼどの base64 プレフィックスよりも多くのコードベースに顔を出しています。 - モダンなオープンソースのデコーダーは、1回の比較だけで無効文字チェックを行います:位置ごとの4つのルックアップ値を OR し、結果をセンチネルに対してテストする。そのため1つの分岐が、4文字グループ全体の運命を決めます。古い実装は同じ仕事を128バイトのテーブルでやっていて、0x80 以上の値はすべて「文字ではない」を意味しました。
- UTF-8 BOM は見えません:ペイロード先頭の
EF BB BFは U+FEFF 文字になり、厳格な変換を生き延びて、それから数行先のコードで等価性チェックを壊します。 - Swift は、自分がデコードするアルファベットより27歳若いのです。言語は 2014 年に発売され、その扱う64文字は 1987 年に標準化されてから変わっていません。
これぞデコーディングのツールボックス全体です:短いルールブックを持つ1つの失敗可能な初期化子、文書化された盲点を持つ1つの寛容なノブ、5行の base64url ヘルパー、あなたに委ねられた文字セットの決断、大きなファイルのためのチャンク化ループ、そして nil に意味を持たせるラッパー。デコードは base64 が咬む場所で、あなたはもはやすべての歯の名前を知っています。仕事が反転して、開く代わりに旅のためにバイトを詰める側になったとき、約 33% の手数料が主導権を握り、ラップオプションが姿を現します。関連するエンコード記事は、このラウンドトリップの片側を完全に扱っているので、逆方向に送り出す準備ができたら、そちらへどうぞ。
最終更新: 2026-10-09