Декодирование Base64 в Kotlin: полное руководство
Перед вами значение, которое отказывается читаться: SGVsbG8sIFdvcmxkIQ==. Буквы и цифры, изредка проскакивающие + или /, а на хвосте обычно висит один-два символа =. Это Base64, и это руководство о том, как вернуть ему прежний облик - фразу, изображение, сертификат, бинарный кусок - по-кotlin-ски. Одна строка на разогрев перед погружением: Base64 укладывает каждые три байта в четыре символа из 64-символьного алфавита, а короткий хвост из заполнителя = помечает место, где закончились настоящие данные. Полный тур по формату живёт на домашней странице, поэтому здесь мы тратим на него одно предложение, и вот ещё одно: поскольку четыре символа несут то, что несли три байта, текстовая форма примерно на треть длиннее исходных данных.
А хорошие новости про Kotlin: не нужен вообще никакой пакет. В стандартной библиотеке уже годами отгружается собственная реализация Base64; с Kotlin 2.2 она полностью стабильна, и работает на каждой платформе, где работает Kotlin, от JVM на вашем ноутбуке до Android-телефона, Node.js и WASI-функции на краю сети. Всё, что ниже, работает с тем Kotlin, который уже есть в вашем проекте.
Хорошие новости сперва: что вам действительно нужно
В Gradle нечего добавлять: ни артефакта base64, ни пакета в духе NuGet, ни npm-модуля. Класс, который вам нужен, - kotlin.io.encoding.Base64, часть самой стандартной библиотеки Kotlin. Если вы умеете писать println, вы умеете декодировать Base64. В Kotlin-проекте Base64-работу ведут три API, и выбор правильного из них - первое по-настоящему важное решение:
| API | Где работает | Когда тянуться за ним |
|---|---|---|
kotlin.io.encoding.Base64 |
Все платформы Kotlin: JVM, Android, JS, Native, Wasm | Выбор по умолчанию. Стабильна с Kotlin 2.2, мультиплатформенна, современное API |
java.util.Base64 |
Только JVM (Java 8+; на Android - API 26+) | JVM-кодовые базы, которые и так живут в мире взаимодействия с Java |
android.util.Base64 |
Только Android (API 8+) | Легаси-код Android, или когда вам как раз нужны его константы флагов |
Две заметки о версиях, которые стоит знать. Первая: класс из стандартной библиотеки впервые появился в Kotlin 1.8.20 (апрель 2023) за воротами @ExperimentalEncodingApi; Kotlin 2.0.20 принесла регулятор withPadding и строгое правило заполнителя, а Kotlin 2.2.0 (июнь 2025) сделала API стабильным и добавила экземпляр PEM. Так что на Kotlin 2.2 и новее - включая актуальную стабильную ветку 2.4.x - всё из этого руководства работает без единой аннотации. Вторая: если ваш проект закреплён на версии Kotlin между 1.8 и 2.1, тот же класс существует, но помечен экспериментальным, и компилятор не пустит к нему без аннотации @OptIn на функции.
Ещё одна ловушка установки, которая стоила кому-то не одно после полудня: пакет kotlin в репозиториях Debian и Ubuntu - это версия 1.3.31, которая целиком предшествует API Base64 стандартной библиотеки, так что она не скомпилирует ни одного примера из этой статьи. Заберите компилятор из релизов Kotlin на GitHub или из SDKMAN, а в Gradle-проектах закрепите плагин явно:
plugins {
kotlin("jvm") version "2.4.10"
}
Ваше первое декодирование: две строки и байты на выходе
Весь обряд умещается в два утверждения, а классическая строка TWFu - хорошее место, чтобы начать:
import kotlin.io.encoding.Base64
fun main() {
val packed = "TWFu"
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // Man
}
Читайте медленно, потому что здесь прячутся три проектных решения. Первое: Base64.decode(...) без .Default - не опечатка: 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
}
Четыре схемы, четыре характера
Класс никогда не инстанцируется; вы выбираете один из четырёх готовых экземпляров, и каждый декодирует со своим характером:
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-вариант того же алфавита |
Разделение на снисходительные и строгие - единственное, что действительно стоит вбить себе в голову. Default и UrlSafe трактуют любой посторонний символ как место преступления и бросают исключение сразу. Mime и Pem пожимают плечами на переносы строк, пробелы и блуждающую пунктуацию - потому что именно из этого состоят настоящие письма и файлы сертификатов - но и они не безграничны: как только символ данных появляется после заполнителя, даже они бросают исключение. Точные сообщения об ошибках вы увидите в гиде по сбоям чуть ниже в этой статье.
Ещё одно следствие характеров: схема не может прочитать вывод другой схемы. Подайте токен base64url в Base64.Default, и символ - не найдётся в его алфавите, так что получите IllegalArgumentException: Invalid symbol '-'(55) at index .... Если не уверены, откуда взялась строка, выбирайте схему, которая совпадает с производителем, а не с вашим настроением.
URL-безопасный Base64 и JWT
Два символа в стандартном алфавите начинают портить жизнь в момент, когда данным приходится ехать через URL. Символ + в строке запроса к тому моменту, когда кто-то его читает, обычно уже переосмыслен как пробел, а / - разделитель путей, так что в сегменте URL он вообще не может появляться. RFC 4648, раздел 5, решает это, поменяв местами два последних символа алфавита: + становится -, а / - _. Имя, которое вы услышите чаще всего, - base64url, а в Kotlin это Base64.UrlSafe.
Самый крупный потребитель base64url - JSON Web Token. JWT в компактной форме - это три части base64url, склеенные точками: header.payload.signature. RFC 7515 описывает эти части как base64url без заполнителя, что делает вторую разницу с обычным алфавитом, помимо самих символов. Вот токен, который распаковывают для осмотра:
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
}
Стоит заметить две вещи. Части токена не несут заполнителя, но Base64.UrlSafe из коробки требует заполнитель, так что строка withPadding(PRESENT_OPTIONAL) делает настоящую работу: она принимает и заполненный, и незаполненный ввод. А split(".") плюс деструктуризация - это просто обычный Kotlin, который делает то, что просит формат. Одно серьёзное предупреждение: распаковка JWT - это для осмотра токена, а не для доверия ему. Заголовок и пелод после декодирования - просто данные; что токен настоящий, говорит только проверенная подпись, а для этого нужна настоящая JWT-библиотека, а не кустарный разбор строки.
Режимы заполнителя и регулятор строгости
Заполнитель - не неизменный факт о Base64 в Kotlin; это настройка. У каждого экземпляра есть PaddingOption, все четыре готовых экземпляра стартуют с 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. Одно поведение заслуживает отдельного упоминания, потому что удивляет людей: двойной заполнитель вроде SGVsbG8== - это не «лишнее, но допустимое». Первый = завершает данные, а второй - символ там, где ожидались данные, поэтому даже самые снисходительные декодеры его отвергают.
Здесь же спрятана и история версий. Если вам достался код, написанный под экспериментальный API 1.8.x, помните, что старый decode принимал ввод и с заполнителем, и без. В Kotlin 2.0.20 Default перешла на строгое правило PRESENT, так что некогда работавший незаполненный ввод теперь бросает исключение после обновления за пределы этой точечной версии. Лечение - одна строка: 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-ловушка: Char - 16-битное значение, и toByte() от него молча оставляет только низкие восемь битов. Если вы вручную собираете байты из символов, "中".first().code.toByte() даст вам 45 - число, не имеющее к символу никакого отношения. Правильный путь - всегда encodeToByteArray(), который выполняет настоящую работу кодирования: тот же символ - три байта UTF-8, а его Base64-форма - 5Lit. Пусть кодирует стандартная библиотека; никогда не упаковывайте символы в байты вручную.
Файлы, подстроки и большие вводы
Данные Base64 - не всегда аккуратная строка в памяти. Иногда это файл, фрагмент более крупного ответа или что-то слишком большое, чтобы держать всё разом. Kotlin даёт вам все три двери.
Файлы - скучный случай в лучшем смысле: читайте байты, декодируйте, готово. Работают оба стандартных файлевых 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 есть и третья дверь: потоковые декодеры. Они всё ещё помечены экспериментальными - отсюда и аннотация согласия - и существуют только для 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!
}
}
Два практических замечания. Расширенные функции живут на верхнем уровне пакета, так что импортируются по имени (звёздочечный импорт тоже сработает, но имена вежливее). И декодер трактует заполнитель как твёрдую остановку: если лежащий под ним поток продолжается после Base64-секции, чтение из декодированного потока заканчивается на =, а оставшиеся байты остаются доступными в исходном потоке. Для форматов, которые приклеивают Base64 перед чем-то ещё, это выглядит аккуратно.
На передовой: HTTP API и тела в JSON
JSON не способен нести сырые байты - это текстовый протокол - поэтому API, которым нужно передвигать бинарные данные (изображения, сертификаты, произвольные куски), почти всегда заворачивают их в Base64 внутри строкового поля. Паттерн такой: разобрать JSON, взять поле, декодировать. С официальной библиотекой сериализации JSON-часть - всего в двух аннотациях:
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
}
Этот пример требует плагин и библиотеку сериализации, которые добавляются в сборку один раз:
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 URI (с префиксом data:image/png;base64,, о нём будет дальше в этой статье), пелод может быть перенесён на строки в 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
}
Снисходительность настоящая, но ограниченная. Перенесите ввод на строки, присыпьте парой пробелов - без проблем. Но добавьте символ данных после последнего = - и даже Mime бросит исключение: Symbol 'e'(145) at index 7 is prohibited after the pad character. И не забывайте, что Mime по-прежнему требует, чтобы заполнитель присутствовал и был корректен; MIME-декодер, который бы ещё и проглатывал отсутствующий заполнитель, был бы прямым путём к неприятностям. Практический рецепт для неопрятных входящих почтовых нагрузок: сначала декодировать через Mime, а если бросит - посмотреть на сообщение: оно как раз скажет, какой именно символ, на каком индексе, нарушил правила.
На передовой: изображения и data URI
data URI - способ веба встраивать файл прямо в документ: медийный тип, маркер base64, и пелод, всё в одной строке. Браузеры, CSS и встроенные интерфейсы обожают их для мелких ресурсов - иконок, аватаров, графических заглушек - потому что не нужно делать второй запрос. Формат выглядит так:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==
Декодирование такого в Kotlin - строковая операция, за которой следует декодирование 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. Проверка первых четырёх-восьми байтов после декодирования - дешёвый способ убедиться, что в data URI действительно то, что обещает префикс. Два честных предупреждения: Base64 добавляет к размеру примерно треть, так что data URI - это сделка по размеру, которую вы заключаете в обмен на сетевой рейс туда-обратно, а для всего крупного обычно лучше отдать файл с настоящего URL и позволить кэшу делать свою работу.
На передовой: конфигурация, переменные окружения и базы данных
Base64 появляется в конфигурационных файлах и переменных окружения всякий раз, когда бинарному значению приходится ехать по чисто текстовому каналу: маленькая встроенная иконка в properties-файле, токен, сохранённый в переменной окружения контейнера, бинарный кусок, припаркованный в текстовом столбце, потому что схема старше, чем нормальный бинарный тип. Декодирование везде - те же два шага: прочитать текст, декодировать:
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)
}
}
Ловушка всего этого квартала умещается в одну строку: Base64 - не шифрование. Это транспортная уловка, а не замок. Никто не должен прочитать значение Base64 и подумать, что данные внутри спрятаны: до видимости - один вызов функции, и оно видно в каждой строке лога, которую вы пишете. Если значение чувствительное, держите его чувствительным от начала до конца - хранилище секретов, зашифрованный столбец, что даёт ваш стек, - а используйте Base64 только для того, чтобы байты доехали через текст, а не для их защиты.
На передовой: командная строка
Самый древний из всех сценариев: превратить бинарный кусок Base64 в командной строке в файл. Полный инструмент - восемь строк Kotlin, потому что тяжёлую работу делает стандартная библиотека. Скомпилируйте его один раз компилятором Kotlin - и он ваш навсегда:
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")
}
Запустите с аргументом для разового значения или пропустите через него файл для пакетной работы: программа читает первый аргумент, если он есть, а иначе обращается к стандартному вводу. trim() тут неслышно несёт службу, потому что шелл-аргументы и вставленные значения любят приходить с лишними пробельными символами, которые строгий декодер отклонил бы. А если ваши нагрузки - base64url, замените Base64.decode на Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode - и инструмент будет готов и для токенов.
Полевой гид по сбоям декодирования
Каждый декодер в этом руководстве падает с одним из двух типов исключений, и каждое сообщение достаточно конкретно, чтобы сразу сказать, что пошло не так. Вот полная карта - с точными сообщениями, которые производит стандартная библиотека:
| Ситуация | Исключение | Сообщение (как выдаётся) |
|---|---|---|
| Символ вне алфавита (пробел, перенос строки, символ чужой схемы) | 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 |
Заметьте закономерность в первых двух строках: сообщение называет виновный символ, его числовой код в скобках и его индекс. Это подарок отладке. Когда декодирование падает в продакшене, запишите в лог первые пару десятков символов входа и индекс из сообщения - и почти всегда вы найдёте виновника за секунды, будь то вставленный перенос строки, оборванный пелод или строка base64url, забредшая в стандартный декодер.
Ловушки, которые бьют именно по Kotlin-разработчикам
- Допускать, что пелод - текст.
decodeнамеренно возвращаетByteArray. ВызватьdecodeToString()на JPEG, потому что «скорее всего это текст», - значит получить стеною заменяющих символов. Прежде чем конвертировать байты, определите, что это за байты. - Пробельные от копипаста. Декодер по умолчанию строгий, а значение, вырванное из чат-сообщения или лога, почти всегда приходит с хвостовым переносом строки или ведущим пробелом. Обрежьте перед декодированием, декодируйте через
Mimeлибо примитеIllegalArgumentExceptionи обработайте его. - Обновление со времён эксперимента. Код, написанный под экспериментальный API 1.8.x, носил аннотации
@OptIn(ExperimentalEncodingApi::class)и рассчитывал на то, что заполнитель - опционален. Начиная с 2.0.20 тот же самый ввод может бросить исключение. Лечение -PRESENT_OPTIONALлибо чистка вводов до того, как они дойдут до декодера. - Подбирать схему не под того производителя. Часть JWT, декодированная через
Base64.Default, падает на символах-и_; пелод стандартного алфавита, декодированный черезUrlSafe, падает на+и/. Исключение называет точный символ, но лечение - знать, откуда взялась строка. - Провал в кодировках.
decodeToString()- только UTF-8, оверлодов для других кодировок нет. Если отправитель использовал Latin-1 или Windows-1252, закладывайте на JVMString(bytes, charset), а при забывчивости ждите симптома в виде заменяющих символов U+FFFD. - Компилятор из пакетов дистрибутива.
apt install kotlinна Debian и Ubuntu отдаёт 1.3.31, времена до появления этого API. Если ваши примеры вдруг отказываются компилироваться с «unresolved reference», проверьте, какой компилятор реально стоит в PATH.
Лучшие практики декодирования
- Сначала декодируйте в байты, потом интерпретируйте. Держите
Base64.decodeи текстовую конвертацию отдельными шагами. Это делает кодировку явной, сохраняет бинарные нагрузки бинарными и упрощает тесты до банальности: сравнивайте массивы байтов, а не строки. - Выбирайте экземпляр, который совпадает с производителем. JWT и данные, привязанные к URL, - это
UrlSafe; почта и файлы PEM - этоMimeилиPem; всё остальное начинается сDefault. Снисходительные декодеры - для известного неопрятного ввода, а не общая страховочная сетка. - Нормализуйте недоверенный ввод один раз и дёшево.
trim()и, там, где формат известен как чистый, удаление пробельных символов перед строгим декодированием ловит больше реальных сбоев, чем сколько угодно try-catch. Небольшой хелпер с запаснымPRESENT_OPTIONAL- хороший паттерн для значений из неизвестных источников. - Рассчитайте размер до аллокации. Декодированный вывод - не больше трёх четвертей длины входа (четыре символа несут три байта), так что быстрая проверка длины говорит вам размер назначения ещё до декодирования, а это ровно то, что нужно перед заполнением заранее выделенного буфера или принятием строки в несколько мегабайт.
- Доверяйте сообщению об ошибке. Стандартная библиотека сообщает символ, его код и его индекс. Для недоверенного ввода записывайте в лог окрестности этого индекса - и перестаньте гадать.
- Не декодируйте, чтобы прятать, и не декодируйте, чтобы доказывать. Base64 - транспортное кодирование. Оно не добавляет ни секретности, ни целостности; если нужно то или другое, это работа криптографии, а не декодера.
Как Base64 попал в Kotlin
Base64 старше Kotlin на несколько десятилетий - MIME-спецификация, давшая правило строк в 76 символов, датируется 1993 годом, а сам алфавит - RFC середины 1990-х, - но история, специфичная для Kotlin, короткая и свежая. Пакет kotlin.io.encoding пришёл в Kotlin 1.8.20 в апреле 2023 года, принеся Base64 с тремя экземплярами - Default, UrlSafe и Mime - за аннотацией @ExperimentalEncodingApi, а вместе с ним и потоковые расширения только для JVM, которые и сегодня остаются экспериментальными. Два года пользоваться этим значило поставить строку согласия в каждой функции и держать в голове небольшой шанс, что API сдвинется.
Kotlin 2.2.0, вышедшая в июне 2025 года, изменила договорённость. Весь API стал стабильным одним релизом, и в семью присоединился экземпляр Pem (вариант с строками в 64 символа из RFC 1421, которым пользуются вокруг PKI). Строгость, из-за которой код с опциональным заполнителем из эпохи 1.8 требует внимания после обновления, пришла на шаг раньше: в 2.0.20, когда withPadding со своими четырьмя значениями PaddingOption заменило старое фиксированное поведение и декодер начал требовать заполнитель. Релиз 2.2 также стабилизировал соседний класс HexFormat в kotlin.text - API шестнадцатеричного форматирования, экспериментальное с Kotlin 1.9, - так что текстовые кодирования на уровне байтов теперь имеют устойчивый дом в стандартной библиотеке. И по части сопровождения: с Kotlin 2.4.0 стандартная библиотека JVM отгружается с окном поддержки в 18 месяцев на каждую ветку релизов, и это ещё одна причина, по которой проект на актуальной ветке 2.4.x может считать этот API застывшей точкой, а не подвижной.
Весёлые факты
- Сопутствующий объект делает работу. Поскольку
Default- сопутствующий объектBase64, имя класса служит и именем экземпляра по умолчанию:Base64.decode(x)иBase64.Default.decode(x)- один и тот же вызов. Именно поэтому двухстрочный пример в начале статьи остаётся двухстрочным. - Декодирование строк получает на JVM ускорительный ход. Общий цикл декодирования работает с байтами, но строки Kotlin - последовательности символов. JVM-реализация обходит конвертацию, переинтерпретируя символы
Stringкак однобайтовые значения ISO-8859-1 до запуска общего цикла, - трюк, который, по комментариям в исходном коде, до десяти раз быстрее обычного пути, и именно поэтомуdecode(String)ощущается мгновенным даже на длинных нагрузках. - Имя пакета - подсказка. Этот API живёт в
kotlin.io.encoding, а не вkotlin.text, потому что всё дело в том, что данные - это байты: и вход, и вывод имеют форму ввода-вывода, а текст - лишь то, что происходит с результатом потом. - Сообщения об ошибках включают код символа.
Symbol 'e'(145)сообщает значение виновного символа в восьмеричной системе, а не только его глиф. Удобно, когда виновник - пробельный символ:' '(40)говорит вам, что это был пробел, задолго до того, как вы начнёте его подозревать. - PEM пришла поздно.
Base64.Pemне входила в первоначальный API 1.8.20; она появилась вместе со стабилизацией 2.2. Если пост в блоге 2023 или 2024 года называет только три экземпляра, он не неправ - он просто на два релиза отстал. - Она написана для каждой платформы, а не делегирована. Стандартная библиотека реализует кодек отдельно для каждой цели через функции expect/actual. На JVM даже есть закомментированная оптимизация, которая отдала бы работу
java.util.Base64, но она отключена из-за открытой проблемы компилятора, и именно поэтому поведение Kotlin-реализации - референсное поведение на всех платформах.
Итоги
Декодирование Base64 в Kotlin сводится к короткому списку осознанных выборов: экземпляр, который совпадает с тем, откуда пришли данные, режим заполнителя, который совпадает с тем, как они были отправлены, буфер или поток, который совпадает с их размером, и кодировка, которая совпадает с их смыслом. Стандартная библиотека отдаёт вам все четыре как простые функции без зависимостей, а её сообщения об ошибках достаточно конкретны, чтобы сбой был диагнозом, а не загадкой. Обратное направление - выбирать правильную схему, заполнитель и перенос строк, когда Base64 производите вы, - имеет свои решения и свои ловушки, и связанная статья на сестринском сайте подробно разбирает кодирование Base64 в Kotlin.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в Kotlin: полное руководство