Kotlin での Base64 デコード:完全ガイド
読める気配のしない値を凝視している:SGVsbG8sIFdvcmxkIQ==。アルファベットと数字がずらり、たまに + や / が混ざり、たいてい末尾に = が1つか2つぶら下がっている。これが Base64 で、このガイドが扱うのは、それをもとからだったもの - 一文でも、画像でも、証明書でも、バイナリの塊でも - に Kotlin 流で戻すことです。突入前に1行だけ復習:Base64 はバイト3つを、64記号のアルファベットから選んだ4文字にパックし、末尾の短い = パディングの列が、本当のデータがどこで終わったかを示します。フォーマットの全貌はホームページにあるので、ここでは1文だけ。もう1つだけ:4文字が3バイトが持っていた情報を持つため、テキスト形式は元のデータよりおよそ3分の1長くなります。
Kotlin の朗報:必要なパッケージは1つもありません。標準ライブラリはずっと前から独自の Base64 実装を同梱しており、Kotlin 2.2 以降は完全に安定し、ノート PC の JVM から Android の電話、Node.js、WASI のエッジ関数まで、Kotlin が走る全プラットフォームで動きます。下のすべては、プロジェクトに最初から入っている Kotlin でそのまま動きます。
最初に朗報:実際に必要なもの
Gradle に追加する base64 のアーティファクトも、NuGet 風のパッケージも、npm モジュールも、一切ありません。狙い目のクラスは kotlin.io.encoding.Base64 で、Kotlin 標準ライブラリそのものの一部です。println が書ければ、Base64 はデコードできます。Kotlin プロジェクトで Base64 の仕事ができてしまう API は3つあり、どれを選ぶかが最初の本物の判断になります:
| API | 動く場所 | 手を伸ばすタイミング |
|---|---|---|
kotlin.io.encoding.Base64 |
Kotlin の全プラットフォーム:JVM、Android、JS、Native、Wasm | デフォルトの選択。Kotlin 2.2 以降安定、マルチプラットフォーム、現代的な API |
java.util.Base64 |
JVM のみ(Java 8 以降。Android では API 26 以降) | Java との相互運用の地盤の上で動いている、JVM のみのコードベース |
android.util.Base64 |
Android のみ(API 8 以降) | レガシーな Android コード、あるいはそのフラグ定数がどうしても必要なとき |
知っておく価値のあるバージョンメモが2つあります。第一に、標準ライブラリのこのクラスが初めて登場したのは Kotlin 1.8.20(2023年4月)で、@ExperimentalEncodingApi の門の向こう側でした。Kotlin 2.0.20 が withPadding のつまみと、厳格なパディングのルールを持ち込み、Kotlin 2.2.0(2025年6月)が API を安定化させて PEM インスタンスを追加しました。つまり Kotlin 2.2 以降 - 現在の安定版ラインの 2.4.x を含む - では、注釈ゼロでこのガイドのすべてを使えます。第二に、プロジェクトの Kotlin バージョンが 1.8 から 2.1 の間に固定されている場合、同じクラスは存在しますが実験的とマークされており、関数に @OptIn 注釈をつけなければ、コンパイラは使用を許しません。
午後以上の時間を奪ってきたインストールの罠が1つあります:Debian と Ubuntu リポジトリの kotlin パッケージはバージョン 1.3.31 で、標準ライブラリの Base64 API よりずっと古く、この記事の例は1つたりともコンパイルできません。代わりに GitHub の Kotlin のリリースか SDKMAN からコンパイラを手に入れて、Gradle プロジェクトではプラグインを明示的に固定してください:
plugins {
kotlin("jvm") version "2.4.10"
}
初めてのデコード:2行とバイトの結果
儀式の全体は2文に収まり、定番の TWFu 文字列は絶好の出発点です:
import kotlin.io.encoding.Base64
fun main() {
val packed = "TWFu"
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // Man
}
このコードをゆっくり読んでください。3つの設計判断が隠れているからです。第一に、.Default なしの Base64.decode(...) はタイポではありません。Default はこのクラスのコンパニオンオブジェクトなので、クラス自体に直接関数を呼び出すのは、Base64.Default で呼び出すことへの省略形です。古いチュートリアルで Base64.Default.decode(...) を見ても、意味はまったく同じです。第二に、見た目より大事な点:decode が渡すのはByteArray であり、決して String ではありません。ペイロードは JPEG でも、X.509 証明書でも、一文でもありうるのに、API はそれを推測することを拒否するため、バイトからテキストへのひとひょい跳びは、別々の、意図的なステップになります。第三に、文字コードの判断が住んでいるのはこのステップで、「Base64 をデコードしたらゴミが返ってきた」バグのほとんどがここで生まれます。その話はすぐします。まずは、デコードが忠実であることを証明する往復の例:
import kotlin.io.encoding.Base64
fun main() {
val original = "Hello, World!".encodeToByteArray()
val packed = Base64.encode(original)
val back = Base64.decode(packed)
println(packed) // SGVsbG8sIFdvcmxkIQ==
println(back.contentEquals(original)) // true
}
4つのスキーム、4つの個性
このクラスはインスタンス化されません。用意された4つのインスタンスのどれかを選び、それぞれが別の気質でデコードします:
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello?".encodeToByteArray()
println(Base64.Default.encode(data)) // SGVsbG8/
println(Base64.UrlSafe.encode(data)) // SGVsbG8_
println(Base64.Mime.encode(data)) // SGVsbG8/
println(Base64.Pem.encode(data)) // SGVsbG8/
}
| インスタンス | アルファベット | デコードの仕方 |
|---|---|---|
Base64.Default |
A-Z a-z 0-9 + / |
厳格:アルファベット外の文字はすべて例外を投げる。パディング必須 |
Base64.UrlSafe |
A-Z a-z 0-9 - _ |
厳格だが、アルファベットは URL 用。入力に + や / があると例外を投げる |
Base64.Mime |
A-Z a-z 0-9 + / |
寛容:改行やその他のアルファベット外の文字は無視するが、= パディングの後に何もあってはならない。パディング必須 |
Base64.Pem |
A-Z a-z 0-9 + / |
寛容で Mime と同じルール。PEM/PKI 向けの同じアルファベットの風味 |
寛容/厳格の分かれ目は、頭に入れるべき1つで最も便利なことです。Default と UrlSafe は、よその文字を事件現場扱いして即座に例外を投げます。Mime と Pem は、改行やスペース、迷い込んだ句読点を肩をすくめて受け流します - 実際にメールや証明書ファイルにはそれが含まれているからです - ただし無制限ではありません。パディングのあとにデータ文字が1つでも現れた瞬間、これらでも例外を投げます。正確なエラーメッセージは、この記事の後半の失敗ガイドで見る予定です。
個性がもたらすもう1つの帰結:あるスキームは、別スキームの出力を読み取れません。base64url のトークンを Base64.Default に渡すと、- はそのアルファベットに存在しないため、IllegalArgumentException: Invalid symbol '-'(55) at index ... が返ってきます。文字列の出所が不確かなときは、気分に合わせて選ぶのではなく、生成側と一致するスキームを選べばよいです。
URL 安全な Base64 と JWT
標準アルファベットの2文字が、データが URL を通って運ばれなければならない瞬間に厄介を起こします。+ はクエリ文字列の中で、読まれるときにはたしてスペースとして読み替わっており、/ はパスの区切り記号なので、URL セグメントの中にありえません。RFC 4648 の第5節は、アルファベットの最後の2記号を差し替えてこれを解決します:+ が - に、/ が _ に。最も耳にする名前が base64url で、Kotlin では Base64.UrlSafe です。
base64url の最大の消費者が JSON Web Token です。コンパクト形式の JWT は、ドットで結ばれた3つの base64url 部分:header.payload.signature から成ります。RFC 7515 はこれら部分をパディングなしの base64url として規定しており、これは文字の違いだけでなく、プレーンアルファベットからの2つ目の違いです。ここで、検査のためにバラされていくトークン:
import kotlin.io.encoding.Base64
fun main() {
val token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
val (header, payload, signature) = token.split(".")
val lenient = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(lenient.decode(header).decodeToString())
// {"alg":"HS256"}
println(lenient.decode(payload).decodeToString())
// {"sub":"1234567890","name":"John Doe"}
println(signature.length) // 43
}
注目すべきは2つです。トークンの各部分はパディングを持ちませんが、Base64.UrlSafe は最初からパディングを要求するため、withPadding(PRESENT_OPTIONAL) の行は本物の仕事をしており、パディングあり・なしの両方の入力を受理します。そして split(".") とデストラクチャリングは、フォーマットが求めることをただの Kotlin がやっているだけです。真剣な警告を1つ:JWT をバラすのは、トークンを見るためであって、信頼するためではありません。ヘッダーとペイロードはデコード後、ただのプレーンなデータです。トークンが本物であることを告げるのは検証済みの署名だけで、それは手作り文字列の分割ではなく、本物の JWT ライブラリの用いるところです。
パディングモードと厳格さのつまみ
Kotlin では、パディングは Base64 の不変の事実ではなく、設定です。すべてのインスタンスが PaddingOption を持ち、4つのプリセットインスタンスはすべて PRESENT から始まり、withPadding は別の設定を持つ新しいインスタンスを渡し、元のインスタンスには一切触れません。ここがそのつまみで、オプションごとに挙動はこうです:
| オプション | パディングなしの入力 | 正しいパディング付きの入力 |
|---|---|---|
PRESENT(あらゆる場所のデフォルト) |
例外を投げる | デコードする |
ABSENT |
デコードする | 例外を投げる |
PRESENT_OPTIONAL |
デコードする | デコードする |
ABSENT_OPTIONAL |
デコードする | デコードする |
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello".encodeToByteArray()
println(Base64.Default.withPadding(Base64.PaddingOption.ABSENT).encode(data))
// SGVsbG8
println(Base64.Default.encode(data))
// SGVsbG8=
val eitherWay = Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(eitherWay.decode("SGVsbG8").decodeToString()) // Hello
println(eitherWay.decode("SGVsbG8=").decodeToString()) // Hello
}
デコード側では、PRESENT_OPTIONAL が安全網になります:「送信側がパディングしたかどうか分からないし、私は動き続けるつもりだ」と言っているオプションです。他の組み合わせのエラーメッセージは異例ほど親切なので、厳格なデコーダーが間違った入力に出会った瞬間に、すぐ見分けがつきます:PRESENT のもとでパディングが足りない場合は The padding option is set to PRESENT, but the input is not properly padded、ABSENT のもとでパディングがある場合は The padding option is set to ABSENT, but the input has a pad character at index 7 が返ります。人の目を引きやすい挙動が1つあります:SGVsbG8== のようなダブルパディングは、「余計だけど問題なし」ではありません。最初の = でデータは終わり、2つ目はデータが期待される位置に現れた文字なので、最も寛容なデコーダーでもこれを拒否します。
ここには隠れたバージョン履歴もあります。実験的な 1.8.x API に向けて書かれたコードを引き継いだなら、古い decode はパディングあり・なしの両方の入力を受理していたことを思い出してください。Kotlin 2.0.20 で Default が厳格な PRESENT ルールに移り、かつて動いていたパディングなしの入力は、このポイントリリース以降のアップグレードで例外を投げるようになりました。修正は1行:withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)、またはデコードする前に入力を正規化してください。
バイトからテキストへ:文字コードと Unicode
ByteArray を手に入れたら、次はそれが何を意味するかの問題です。ペイロードがテキストなら、デフォルトの答えは decodeToString() で、バイトを UTF-8 として解釈し、全プラットフォームで動きます。モダンな API、メール、ウェブデータという定番ケースなら、これだけで一生足り、絵文字も込みです:
import kotlin.io.encoding.Base64
fun main() {
val original = "héllo 😀"
val packed = Base64.encode(original.encodeToByteArray())
println(packed) // aMOpbGxvIPCfmIA=
println(Base64.decode(packed).decodeToString()) // héllo 😀
}
ところが送信側が UTF-8 以外を使っていた瞬間から、文字コードの判断はあなたの責任になります。Kotlin の組み込みテキスト変換が UTF-8 のみであるのは意図的なもので、decodeToString() には文字コードのパラメータがなく、文字列からバイトへの変換でパラメータを持つ関数も存在しません。JVM ではプラットフォームの文字コード API に降りていき、誠実で明示的です:
import kotlin.io.encoding.Base64
import java.nio.charset.Charset
fun main() {
val latinOne = "héllo".toByteArray(Charsets.ISO_8859_1)
val packed = Base64.encode(latinOne)
println(packed) // aOlsbG8=
val asUtf8 = Base64.decode(packed).decodeToString()
val asLatin = String(Base64.decode(packed), Charsets.ISO_8859_1)
println(asUtf8) // h?llo (é のバイトは有効な UTF-8 ではない)
println(asLatin) // héllo
val byName = String(Base64.decode(packed), Charset.forName("ISO-8859-1"))
println(byName) // héllo
}
真ん中の行にある ? はフォントの問題ではありません。U+FFFD、Unicode の代替文字です。有効な UTF-8 を形成しないバイトの代わりに立っています。デコード後それらがずらりと見えたなら、ペイロードは無傷 - 甘い文字コードの仮定が壊れているだけです。人を咬む非対称性にも注意:エンコード側には JVM 拡張の toByteArray(charset) が存在するのに、デコード側の対応するコンストラクタは String(bytes, charset) です。どちらも文字コードの名前は受け取りません。名前で渡すには Charset.forName("...") が必要で、架空の名前は UnsupportedCharsetException を投げます。設定値のタイプミスは、別のエンコーディングをこっそり選ばれるよりも、早く失敗するのです。
さて、バイトの地にいるついでに、Kotlin 特有の罠を1つ:Char は16ビットの値で、その toByte() は下位8ビットだけを黙って残します。文字から自分でバイトを組み立てると、"中".first().code.toByte() は 45 を返し、その文字と無関係な数字になります。正しい道は常に encodeToByteArray() で、本物のエンコーディングの仕事をするのはこれです - 同じ文字は UTF-8 で3バイトになり、Base64 形式は 5Lit です。エンコードは標準ライブラリに任せ、文字を手にバイトへ詰め込むのはやめましょう。
ファイル、部分文字列、大きな入力
Base64 データは、いつもメモリのなかのきれいな文字列であるとは限りません。ファイルかもしれないし、大きなレスポンスのスライスかもしれない、あるいは一度に全部保持するには大きすぎるかもしれない。Kotlin は3つのドアをすべて用意しています。
ファイルは、最高なまでに地味なケースです:バイトを読み、デコード、完了。標準のファイル API は両方とも動き、プロジェクトで既に使っている方を使えばよい:
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readBytes
fun main() {
val fromFile = File("payload.b64").readBytes()
println(Base64.decode(fromFile.decodeToString()).size) // デコード後のバイト数
val fromPath = Path("payload.b64").readBytes()
println(Base64.decode(fromPath.decodeToString()).size) // 同じ数
}
部分文字列こそ、CharSequence オーバーロードがその価値を証明する場所です。decode は開始と終了インデックスを持つどんな文字列列も受け取り、長いレスポンスボディのスライスを、まずスライスのコピーを作る前に渡せます:
import kotlin.io.encoding.Base64
fun main() {
val body = "prefix junk SGVsbG8= trailing junk"
val bytes = Base64.decode(body, 12, 20)
println(bytes.decodeToString()) // Hello
}
出力サイズがすでに分かっていてバッファを再利用したいなら、decodeIntoByteArray があなた自身が選んだ目的配列に書き、何バイト書いたかを教えてくれます。小さすぎるバッファを渡すと、メッセージに必要な容量を添えて IndexOutOfBoundsException を投げるので、エラーがそのままサイズのヒントになります。
JVM で本気で大きいストリームには、3つ目のドアがあります:ストリーミングデコーダー。まだ実験的とマークされています - だからオプトイン注釈が要る - かつ JVM 専用ですが、すべてをメモリに保持するのではなく、その場でデコードします:
import java.io.ByteArrayInputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.decodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
val stream = ByteArrayInputStream("SGVsbG8gV29ybGQh".toByteArray())
stream.decodingWith(Base64.Default).use {
println(it.readBytes().decodeToString()) // Hello World!
}
}
実務的な詳細が2つ。拡張関数はパッケージのトップレベルに暮らしているので、名前でインポートします(スターインポートでも動きますが、名前の方が優しい)。そしてデコーダーはパディングをハードストップとして扱います:Base64 セクションのあとで元のストリームが続いても、デコード済みストリームからの読み取りは = で終わり、残ったバイトは元のストリームにまだ利用可能なままです。Base64 を何かにくっつけて先頭につける形式には、これでさっぱりします。
実戦:HTTP API と JSON ボディ
JSON は生バイトを運べません - これはテキストのプロトコルだからです - そのため、バイナリ(画像、証明書、何でもいい)を動かせる必要がある API は、たいてい文字列フィールドの中に Base64 で包みます。パターンはこうです:JSON を解析し、フィールドを取り出して、デコード。公式のシリアライズライブラリがあれば、JSON の部分は注釈2つ分の距離にあります:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class ImageResponse(val name: String, val data: String)
fun main() {
val body = """{"name":"icon.png","data":"iVBORw0KGgo="}"""
val response = Json.decodeFromString<ImageResponse>(body)
val bytes = Base64.decode(response.data)
println("${response.name}: ${bytes.size} bytes") // icon.png: 8 bytes
}
この例にはシリアライズプラグインとライブラリが必要で、ビルドに1度だけ追加します:
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
ライブラリがなくても、同じ考え方は生文字列上で動き、クイックスクリプトには便利です:substringBetween でフィールドを引っ張ってきてデコードするだけ。罠は、おなじみの API 側のもので:フィールドが実は完全な data URL(後述の data:image/png;base64, プレフィックス付き)かもしれないし、ペイロードは改行で MIME ラップされているかもしれない、そしてエンコード済みペイロードは元のバイナリよりおよそ3分の1大きいことがあるので、大きなレスポンスではメモリ予算に注意してください。
実戦:メールと MIME 折り返し入力
メールは 7 ビットのテキストの世界で、RFC 2045 がバイナリ添付への答えとして選んだのは、ひねり付きの Base64 です:エンコード済み出力は、どの行も 76 文字を超えないように折り返さなければならない。テキストとして添付を受け取ったことがあるなら、Base64 の段がインデントされて整然と並んで見える理由がこれです。ちょうどこの入力のために、Base64.Mime が正しいデコーダーで、進む間に改行やその他のアルファベット外の文字を無視してくれるからです:
import kotlin.io.encoding.Base64
fun main() {
val wrapped = "SGVs\nbG8=\r\n"
println(Base64.Mime.decode(wrapped).decodeToString()) // Hello
val withJunk = "Y@{mFz!Z!TY}0"
println(Base64.Mime.decode(withJunk).decodeToString()) // base64
}
この寛容さは本物ですが、限りがあります。入力を折り返しても、スペースが1つ2つ混ざっても、問題なし。ただ、最後の = のあとにデータ文字を1つ足すと、Mime でも例外を投げます:Symbol 'e'(145) at index 7 is prohibited after the pad character。それから Mime は依然としてパディングの存在と正しさを要求しているのを覚えておいてください。パディングの欠落まで飲み込む MIME デコーダーは、厄介を招くだけです。ごちゃごちゃした受信メールのペイロード向けの実用的なレシピは、まず Mime でデコードし、例外が飛んだらメッセージを見ること - どのインデックスのどの記号がルールを破ったかを、正確に教えてくれます。
実戦:画像と data URL
data URL は、ファイルをドキュメントに直接インラインで埋め込むウェブのやり方です:メディアタイプ、base64, のマーカ、ペイロード、全部が1つの文字列に収まる。ブラウザ、CSS、埋め込み UI は、小さなアセット - アイコン、アバター、プレースホルダー画像 - にこれらを好んで使います。2回目のリクエストをしなくて済むからです。形式はこのようになります:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==
Kotlin で1つデコードするのは、文字列操作のあとに Base64 デコードをするだけです。プレフィックスに秘密はなく、カンマのあとのすべてがペイロードです:
import kotlin.io.encoding.Base64
fun main() {
val dataUrl = "data:image/png;base64,iVBORw0KGgo="
val mediaType = dataUrl.substringBefore(";")
val packed = dataUrl.substringAfter("base64,")
val bytes = Base64.decode(packed)
println(mediaType) // data:image/png
println(bytes.size) // 8
println(bytes.contentToString()) // [-119, 80, 78, 71, ...]
}
その最初のバイト -119(0x89 に相当)のあとに続く PNG の文字は、PNG ファイルを識別するマジックナンバーです。デコード後に先頭の4バイトまたは8バイトをチェックするのは、data URL が本当にそのプレフィックスが主張する中身を含むかを確かめる安価な方法です。率直な注意点2つ:Base64 はサイズにおよそ3分の1上乗せするため、data URL はネットワークの往復に対して行うサイズトレードオフであり、大きなものならたいてい、ファイルを本物の URL から配信してキャッシュに仕事を任せる方がよいです。
実戦:設定、環境変数、データベース
Base64 が設定ファイルや環境変数に現れるのは、バイナリ値がテキスト専用のチャネルに乗らなければならないときです:プロパティファイルに埋め込んだ小さなアイコン、コンテナの環境変数に保管されたトークン、まともなバイナリ型が Schema に登場する前からテキスト列に置き去りにされたバイトの塊。デコード側はあらゆる場所で同じ2ステップです - テキストを読み、デコードする:
import kotlin.io.encoding.Base64
fun main() {
val line = "icon: UE5HREFUQQ=="
val packed = line.substringAfter("icon: ").trim()
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // PNGDATA
val fromEnv: String? = System.getenv("MY_ICON_B64")
if (fromEnv != null) {
println(Base64.decode(fromEnv).size)
}
}
この一帯の罠は1行に収まります:Base64 は暗号ではありません。運送の手管であって、鍵ではない。Base64 の値を見て、中身が隠されていると思った人はいませんし、そうあってはなりません。見えるまでが1関数呼び出しの距離で、あなたが書くすべてのログ行に見えるのです。値が機密なら、先頭から末尻まで機密のままにしてください - シークレットストアでも、暗号化された列でも、スタックが提供するものでよい - Base64 はバイトをテキストを通して運ぶためにだけ使い、保護するために使うのはやめましょう。
実戦:コマンドライン
最も古いユースケース:コマンドライン上の Base64 の塊をファイルに変えること。完全なツールは Kotlin 8行で、重い仕事は標準ライブラリが引き受けるからです。Kotlin コンパイラで1度コンパイルすれば、それが永遠にあなたのものです:
import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
val packed = if (args.isNotEmpty()) args[0] else readlnOrNull().orEmpty()
val bytes = Base64.decode(packed.trim())
File("decoded.bin").writeBytes(bytes)
println("Wrote ${bytes.size} bytes to decoded.bin")
}
1度だけの値には引数で実行し、バッチ作業にはファイルをパイプで渡します:プログラムは引数があるならそれを読み、なければ標準入力にフォールバックします。ここでの trim() は地味な任務をこなしており、シェルの引数も貼り付けられた値も、厳格なデコーダーが拒否する余計な空白をつけたまま到着するのが好きだからです。そしてペイロードが base64url なら、Base64.decode を Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode に差し替えるだけで、トークンも扱えるようになります。
デコード失敗のフィールドガイド
このガイドのすべてのデコーダーは、2つの例外型のどちらかで失敗し、すべてのメッセージは、何が起きたかを正確に教えてくれるほど具体的です。標準ライブラリが産み出す正確なメッセージ付きで、完全な地図をここに:
| 状況 | 例外 | メッセージ(産み出されるまま) |
|---|---|---|
| アルファベット外の文字(スペース、改行、別スキームの記号) | IllegalArgumentException |
Invalid symbol ' '(40) at index 5 |
| パディングのあとのデータ文字 | IllegalArgumentException |
Symbol 'e'(145) at index 7 is prohibited after the pad character |
オプションが PRESENT のままパディング不足 |
IllegalArgumentException |
The padding option is set to PRESENT, but the input is not properly padded |
オプションが ABSENT のままパディングあり |
IllegalArgumentException |
The padding option is set to ABSENT, but the input has a pad character at index 7 |
| インデックスがソースの範囲外 | IndexOutOfBoundsException |
startIndex: 0, endIndex: 100, size: 8 |
startIndex が endIndex より大きい |
IllegalArgumentException |
startIndex: 3 > endIndex: 2 |
目的バッファが decodeIntoByteArray に対して小さすぎる |
IndexOutOfBoundsException |
The destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8 |
最初の2行にあるパターンに注目してください:メッセージは、問題の記号、その数値コード(括弧付き)、そしてそのインデックスを名指しします。これはデバッグへの贈り物です。本番でデコードが例外を投げたら、入力の先頭数十文字と、メッセージのインデックスをログに出せば、貼り付けられた改行でも、切り詰められたペイロードでも、標準デコーダーに迷い込んだ base64url 文字列でも、ほぼいつでも数秒で犯人を見つけられます。
Kotlin 開発者を特に痛める落とし穴
- ペイロードはテキストだと思い込む。
decodeは意図的にByteArrayを返します。「たぶんテキストだろう」と JPEG にdecodeToString()を呼ぶと、代替文字の壁が返ってきます。変換する前に、そのバイトが何かを決めましょう。 - コピー&ペーストの空白。 デフォルトのデコーダーは厳格で、チャットメッセージやログから持ち上げた値は、たいてい末尾の改行や先頭のスペースをつけたまま到着します。デコード前に trim するか、
Mime経由でデコードするか、IllegalArgumentExceptionを受け止めて対処するか。 - 実験時代からのアップグレード。 1.8.x の実験 API に向けて書かれたコードは
@OptIn(ExperimentalEncodingApi::class)注釈を持ち、パディングがオプションであることに依存していました。2.0.20 以降、同じ入力が例外を投げうるようになります。修正はPRESENT_OPTIONAL、またはデコーダーに届く前に入力を掃除することです。 - スキームと生成側の不一致。 JWT の部分を
Base64.Defaultでデコードすると、-と_の文字で失敗し、標準アルファベットのペイロードをUrlSafeでデコードすると、+と/で失敗します。例外は正確な記号を名指ししますが、修正は文字列の出所を知ることです。 - 文字コードのギャップ。
decodeToString()は UTF-8 のみで、他のエンコーディング用のオーバーロードはありません。送信側が Latin-1 や Windows-1252 を使っていたなら、JVM ではString(bytes, charset)の準備をしておき、忘れてしまったときの症状として U+FFFD の代替文字を想定してください。 - 配布パッケージのコンパイラ。 Debian と Ubuntu の
apt install kotlinが提供する 1.3.31 は、この API が存在する前のものです。例が突然「unresolved reference」でコンパイルを拒否したら、PATH に実際にどのコンパイラがあるかを確認してください。
デコードのベストプラクティス
- まずバイトにデコードし、解釈は二段目に。
Base64.decodeとテキスト変換を別々のステップに保ちましょう。文字コードが明示になり、バイナリのペイロードはバイナリのままでいられ、テストも自明になります:比較するのはバイト配列で、文字列ではない。 - 生成側と一致するインスタンスを選べ。 JWT と URL 行き先のデータなら
UrlSafe、メールと PEM ファイルならMimeかPem、それ以外はすべてDefaultから始まる。寛容なデコーダーは、ごちゃごちゃしていることが分かって入力のためにあって、汎用の安全網ではない。 - 信頼できない入力は、1度だけ、安価に正規化。 厳格なデコードの前に
trim()を、そして形式がクリーンだと分かっているなら空白の除去を。try-catch を何行書いても届かない現実の失敗を、これが見つけてくれます。出所不明の値には、PRESENT_OPTIONALのフォールバックを持つ小さなヘルパーが良いパターンです。 - 割り当てる前に、サイズに予算を。 デコード済み出力は、入力長の3分の4を上限とします(4記号が3バイトを持つ)。そのため、素早い長さチェックでデコード前に目的サイズが分かり、これは予割り当てバッファを埋める前や、複数メガバイトの文字列を受け入れる前にまさに欲しいことです。
- エラーメッセージを信頼しろ。 標準ライブラリは、記号、そのコード、そのインデックスを報告します。信頼できない入力ではそのインデックスの周辺をログに出して、推測をやめましょう。
- 隠すためにデコードせず、証明するためにデコードもしない。 Base64 は輸送のエンコーディングです。秘密も完全性も何も追加しません。どちらかが要るなら、それは暗号の仕事で、デコーダーの仕事ではない。
Base64 が Kotlin にやってきた経緯
Base64 は Kotlin より数十年も古く - 76文字の行ルールを与えた MIME 仕様は 1993 年、アルファベット自体は 1990 年代半ばの RFC に遡る - ですが、Kotlin 固有の話は短くて新しいものです。kotlin.io.encoding パッケージは 2023 年 4 月の Kotlin 1.8.20 で登場し、@ExperimentalEncodingApi 注釈の裏に Base64 を3つのインスタンス - Default、UrlSafe、Mime - と、いまでも実験的な JVM 専用のストリーミング拡張を載せてきました。2年間、それを使うのはすべての関数にオプトイン1行と、API が変わるかもしれない小さな賭けを意味していました。
2025 年 6 月リリースの Kotlin 2.2.0 が、契約を変えました。API 全体が1つのリリースで安定化し、Pem インスタンスが家族に合流しました(PKI 周辺で使われる RFC 1421 の 64 文字行変種です)。パディングがオプションな 1.8 世代のコードがアップグレード後に注意を要する厳格さは、1ステップ早く来ています:withPadding とその4つの PaddingOption 値が古い固定の挙動に置き換わり、デコーダーがパディングの要求を始めた 2.0.20 です。2.2 リリースは、kotlin.text の姉妹クラス HexFormat - Kotlin 1.9 以来実験的な16進フォーマット API - も安定化させたので、バイトレベルのテキストエンコーディングは標準ライブラリに落ち着いた住処を持つようになりました。そしてメンテナンスメモ:Kotlin 2.4.0 以降、JVM 標準ライブラリはリリースラインごとに18か月のサポートウィンドウを持つようになり、現在の 2.4.x ラインに乗るプロジェクトが、この API を動くものではなく固定点として扱える理由が、もう1つ増えました。
お遊びの雑学
- コンパニオンオブジェクトは仕事をしている。
DefaultはBase64のコンパニオンなので、クラス名がデフォルトインスタンスの二役を担います:Base64.decode(x)とBase64.Default.decode(x)は同じ呼び出しです。この記事の先頭の2行例が2行のままなのはこのためです。 - JVM では文字列デコードに速度の裏技がある。 よくあるデコードループはバイトで回りますが、Kotlin の文字列は文字の列です。JVM 実装は変換を避け、共有ループが回る前に
Stringの文字を1バイトの ISO-8859-1 値として再解釈します - ソースコードのコメントが主張するように、これは共通パスより最大10倍速いと言われ、長いペイロードでもdecode(String)が即座に感じる理由です。 - パッケージ名はヒントになっている。 この API は
kotlin.io.encodingにあって、kotlin.textにはありません。すべてがデータはバイトであることに起因し、入出力は I/O の形をしていて、テキストは結果のあとに起きるだけのものだからです。 - エラーメッセージには文字のコードが含まれる。
Symbol 'e'(145)は、問題の記号の字形だけでなく、その値を8進数で報告します。犯人が空白のときに便利:' '(40)は、それがスペースだと疑うはるか前に教えてくれます。 - PEM は遅れてやってきた。
Base64.Pemは元の 1.8.20 API の一部ではなく、2.2 の安定化に伴って登場しました。2023 年や 2024 年のブログ記事が3つのインスタンスしか列挙していないとしても、それは間違いではなく、ただリリース2つ分古いだけです。 - プラットフォームごとに書かれていて、委任していない。 標準ライブラリは expect/actual 関数で、各ターゲットのためにコーデックを別々に実装しています。JVM には
java.util.Base64に仕事を渡すはずのコメントアウトされた最適化まであり、未解決のコンパイラ問題の向こう側で無効化されています。だからこそ、Kotlin 実装の挙動が全プラットフォームの参照挙動です。
まとめ
Kotlin で Base64 をデコードすることは、短い意図的な選択のリストに集約されます:データの出所と一致するインスタンス、送信のされ方と一致するパディングモード、その大きさと一致するバッファまたはストリーム、意味と一致する文字コード。標準ライブラリは4つすべてを依存ゼロの普通の関数として渡し、そのエラーメッセージは、失敗が謎ではなく診断になるほど具体的です。逆方向 - あなたが Base64 を産む側として、正しいスキーム、パディング、行折り返しを選ぶこと - には独自の判断と独自の罠があり、姉妹サイトの関連記事が Kotlin での Base64 エンコードを深く扱っています。
最終更新: 2026-10-09