Кодирование Base64 в Java: полное руководство
Вот ситуация: у вас есть байты. Файл, пароль, сертификат, 13-байтовое приветствие, загрузка в 200 мегабайт. И вам нужно уместить их во что-то, что понимает только текст: JSON-поле, HTTP-заголовок, колонку базы данных, URL, файл конфигурации. В этом и состоит вся работа Base64, и это руководство - java-справочник, как делать её хорошо. Быстрая ориентация, потому что домашняя страница разбирает формат шаг за шагом: Base64 переписывает каждые три байта данных в четыре символа из 64-буквенного алфавита, прицепляя один-два заполнителя =, когда последний кусок короче. Цена поездки - размер: каждые три байта становятся четырьмя символами, так что закодированный вывод получается примерно на 33 процента больше ввода, плюс ещё чуть-чуть, если в дело вмешиваются переводы строк.
Главная новость, и она радостная. С 18 марта 2014 года каждый JDK поставляется со всем готовым инструментарием Base64 в стандартной библиотеке: java.util.Base64. Никакого скачивания, никакой Maven-координаты, никакой нативной библиотеки. Один импорт, три характера кодировщиков - и одно и то же поведение от Java 8 до сегодняшней Java 26. Всё в этой статье построено на этом одном классе, и он никогда не бросает исключение на самих данных: работа кодировщика не может упасть на невалидном вводе, потому что закодировать можно любой возможный байт.
Одна честная граница перед стартом: здесь мы на стороне кодировщика. Вы узнаете о решении «строка в байты», которое на самом деле определяет корректность, о регуляторах заполнителя и переноса по строкам, о base64url и его режиме без заполнителя для токенов, и о тех случаях, где Java-разработчики чаще всего встречают закодированный вывод. Декодирование, где живёт большая часть настоящей боли, получит своё руководство, и ссылка на него будет в конце этой статьи.
Один импорт, ноль скачиваний
Установка Base64 в Java - это ответ в одну строку, который вы даёте у доски: «Оно в JDK.» Класс java.util.Base64 входит в модуль java.base с версии 1.8, и двенадцать лет спустя в его javadoc всё ещё написано Since: 1.8. Единственное, что вы устанавливаете, - это JDK: подойдёт любая Java 8 и новее от любого вендора (Oracle, Eclipse Temurin, Amazon Corretto, Zulu), а на машине на базе Debian это одна команда:
sudo apt install openjdk-17-jdk-headless
API устроено как фабрика: кодировщик вы никогда не конструируете, вы просите у класса один. У стороны кодировщиков четыре двери, и все они возвращают экземпляры вложенного класса Base64.Encoder:
| Фабричный метод | Алфавит | Форма вывода |
|---|---|---|
getEncoder() |
A-Z a-z 0-9 + / |
С заполнителем, без переводов строк |
getUrlEncoder() |
A-Z a-z 0-9 - _ |
С заполнителем, без переводов строк |
getMimeEncoder() |
A-Z a-z 0-9 + / |
С заполнителем, строки по 76 символов, CRLF |
getMimeEncoder(int, byte[]) |
A-Z a-z 0-9 + / |
С заполнителем, ваша длина строки, ваш разделитель |
Три свойства стоит знать заранее. Экземпляры потокобезопасны, и фабрика возвращает один и тот же общий экземпляр при каждом вызове, так что Base64.getEncoder() == Base64.getEncoder() - правда. Создайте один в статическом поле и делитесь им везде. Кодировщики никогда не бросают исключение на данных: у каждого байтового значения есть кодирование, так что состояния «невалидный ввод» не существует, а единственные исключения, с которыми вы встретитесь, связаны с неверной конфигурацией (плохой разделитель строк) или слишком маленьким массивом назначения. И каждый кодировщик в этом списке по умолчанию добавляет заполнитель. Регулятор, который его отключает, withoutPadding(), появится в разделе base64url, потому что именно там он вам понадобится.
В кодовых базах вы всё ещё будете встречать старые библиотеки, поэтому держите под рукой краткую карту местности. Apache Commons Codec (сейчас 1.22.1) отгружает собственную org.apache.commons.codec.binary.Base64 с версии 1.0, с API Builder, которое выставляет в виде регуляторов политику «строгий или снисходительный», длину строки и разделитель. Это правильный инструмент только если вам нужна поддержка JVM до Java 8. Guava отгружает com.google.common.io.BaseEncoding, ветерана с сопоставимыми силами, всё ещё распространённого в стеках больших данных. Для всего, что крутится на современной JVM, java.util.Base64 - выбор по умолчанию: ноль зависимостей, и бенчмарки комьюнити неизменно находят его самым быстрым из всех (об этом подробнее в разделе о безопасности и скорости).
Ваше первое кодирование
Девяносто процентов жизни кодирования умещается в три строки. Вот весь обряд, на самом маленьком примере, которым статья Wikipedia про Base64 объясняет алфавит:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstEncode {
public static void main(String[] args) {
byte[] text = "Man".getBytes(StandardCharsets.UTF_8);
String packed = Base64.getEncoder().encodeToString(text);
System.out.println(packed); // TWFu
}
}
Строка TWFu - это тот самый пример, которым статья Wikipedia про Base64 объясняет алфавит, так что если ваш кодировщик превращает в неё «Man», машина честна. Но посмотрите на первую строку того примера, потому что это именно та строка, где кодирование в Java происходит по-настоящему. Метода encodeToString(String) нет намеренно. Java-String - это последовательность юнитов кодирования UTF-16, а не байтов, и Base64 - байтовый формат, поэтому API заставляет вас решать вопрос о байтах самим: "Man".getBytes(StandardCharsets.UTF_8). Этот один вызов с явной кодировкой - вот где «café» остаётся правильным следующие сто лет, и это самая важная привычка всей этой статьи. Следующий раздел посвящён ей, потому что альтернатива - классический баг с кракозябрами.
Две заметки о второй строке. encodeToString() возвращает String, построенную из закодированных байтов; javadoc объясняет, что результат строится с использованием кодировки ISO-8859-1, что на практике не имеет значения, потому что каждый символ вывода Base64 - чистый ASCII и выглядит одинаково в Latin-1, UTF-8 и большей части остального зверинца кодировок. А если вы хотите сами владеть буфером вывода, encode(byte[]) возвращает свежий byte[], а encode(byte[] src, byte[] dst) пишет в предоставленный вами массив назначения и возвращает количество байтов (а если места не хватает, бросает IllegalArgumentException: Output byte array is too small for encoding all input bytes, не записав ни единого байта).
Решение о кодировке
Давайте сделаем шаг от строки к байтам конкретным на классическом примере. Слово «café» - одно слово, но в байтах оно целиком зависит от того, какую кодировку вы выбрали:
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetEncode {
public static void main(String[] args) {
byte[] utf8 = "café".getBytes(StandardCharsets.UTF_8);
byte[] latin1 = "café".getBytes(Charset.forName("ISO-8859-1"));
System.out.println(utf8.length + " vs " + latin1.length);
// 5 vs 4: акцент - два байта в UTF-8 и один в Latin-1
System.out.println(Base64.getEncoder().encodeToString(utf8));
// Y2Fmw6k=
System.out.println(Base64.getEncoder().encodeToString(latin1));
// Y2Fm6Q==
}
}
Две разные Base64-строки для одного слова, и обе «правильны», пока читателю сказано, какую кодировку использовать. Весь урок умещается в одну строку: кодировщик верен тем байтам, которые вы ему дали, а ответственность за байты - на вас. На практике это значит: договоритесь с собеседником о UTF-8, передавайте StandardCharsets.UTF_8 явно и записывайте кодировку в спецификации, схему или коммит-сообщение, потому что никто на принимающей стороне не угадает её по одному Base64. Близнец этой ошибки на стороне декодера - тема сестры-руководства.
Одна заметка о версиях, потому что она меняет режим отказа ленивого кода. Безаргументный new String(bytes) и String.getBytes() без кодировки используют кодировку платформы по умолчанию, которая исторически была Cp1252 на Windows и чем-то зависящим от локали на Linux. С JDK 18 (JEP 400, «UTF-8 по умолчанию») по умолчанию везде UTF-8, так что на современной JVM ленивая форма случайно оказывается правильной. Но это не делает её безопасной: ваш код переживёт JDK, под который он писался, и человек, который его унаследует, не должен знать, что там по умолчанию. Пишите кодировку.
Связанная проектная деталь: перегрузки encode(String) нет нигде во всём API, и это намеренно. Каждый другой шаг конвейера (массивы, буферы, потоки) принимает байты, и метод, принимающий String, был бы вынужден выбирать кодировку за вас, а это ровно то решение, на которое JDK отказывается идти. Единственный метод со строковым типом, encodeToString, находится на стороне вывода, где вопроса о кодировке не существует: вывод Base64 - чистый ASCII. Весь облик API - маленький аргумент в пользу «решайте вопрос о байтах осознанно».
Заполнитель, перенос строк и MIME-регулятор
Кодировщики Java по умолчанию принимают за вас два решения о формате, и оба стоит понять, потому что оба - это регуляторы, которые можно повернуть. Первое - заполнитель: все кодировщики добавляют символы =, делающие вывод кратным четырём, как требует RFC 4648: реализации должны включать подходящие символы заполнителя в конце закодированных данных, если спецификация, на которую они ссылаются, не говорит об ином. Второе - перенос строк: переносит строки только MIME-кодировщик, по 76 символов, с возвратом каретки и переводом строки, и он не добавляет разделитель строки после последней неполной строки - деталь, которую javadoc явно подчёркивает, а другие инструменты делают неверно:
| Кодировщик | Добавляет заполнитель | Переносит строки | Разделитель строк |
|---|---|---|---|
getEncoder() |
да | нет | н/д |
getUrlEncoder() |
да | нет | н/д |
getMimeEncoder() |
да | да, по 76 символов | CRLF |
getMimeEncoder(64, "\n") |
да | да, по 64 символа | LF |
MIME-регулятор - самая полезная часть API для людей, которые унаследовали чужие форматы. Стандартный конструктор - getMimeEncoder() (76, CRLF, прямо из RFC 2045), а двухаргументная версия, getMimeEncoder(int lineLength, byte[] lineSeparator), позволяет воспроизвести другие конвенции. Две странности, которые стоит знать: длина строки «округляется вниз до ближайшего кратного 4», так что запрос на 77 молча даст вам 76, а округлённое значение, не являющееся положительным, даёт вообще без переноса. И разделитель не должен содержать ни одного символа алфавита Base64, иначе конструктор на месте бросит IllegalArgumentException, потому что разделитель, который можно перепутать с данными, - это баг, ожидающий своего часа. Вот регулятор в действии: MIME-стандартный и в PEM-настроении:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WrapDials {
public static void main(String[] args) {
byte[] data = "Hello, wrapped world! This line keeps going and going and going until it finally has to wrap.".getBytes(StandardCharsets.UTF_8);
Base64.Encoder mime = Base64.getMimeEncoder();
Base64.Encoder pem = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.ISO_8859_1));
System.out.println(mime.encodeToString(data));
// строки по 76 символов, между ними CRLF
System.out.println(pem.encodeToString(data));
// строки по 64 символа, между ними чистый LF
}
}
Две практические заметки. Если ваш потребитель ожидает, что перенесённая по строкам строка закончится переводом строки (некоторые почтовые инструменты так и делают), добавьте его сами после кодирования: JDK намеренно останавливается после последней неполной строки. А если вы производите данные, которые будут жить в URL или токене, перенос - это вообще неверный регулятор: таким потребителям нужна одна длинная строка, обычно без заполнителя, и это следующая секция.
base64url и регулятор без заполнителя
Стандартный Base64 заканчивает свой алфавит символами + и /, и это ровно те два символа, которые плохо себя ведут в URL: + в строке запроса - уже пробел, ещё до того, как сервер его разобрал, / - разделитель путей, а свисающий = просится в процентное кодирование и превращается в трёхсимвольного монстра. Раздел 5 RFC 4648 рисует исправление: безопасный для URL и имён файлов алфавит, где + становится -, / становится _, а хвостовой заполнитель = обычно отбрасывается, когда длина известна неявно. RFC непреклонно настаивает на названии: эту кодировку «не следует считать той же, что base64-кодирование», и имя, которое вы услышите, - base64url. JSON Web Tokens, параметры state OAuth, сессионные ID API и одиннадцатисимвольные ID видео - все живут в этом диалекте.
Java даёт вам алфавит через getUrlEncoder(), но вот регулятор, который ловит людей: URL-безопасный кодировщик по умолчанию всё же добавляет заполнитель, а стандарты токенов заполнителя не хотят. RFC 7515 прямо говорит, что части JWS используют base64url «со всеми хвостовыми символами '=' опущенными ... и без включения каких-либо переводов строк, пробельных символов или каких-либо дополнительных символов». Так что канонический java-рецепт JWT - это цепочка из двух методов:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class TokenParts {
public static void main(String[] args) {
Base64.Encoder url = Base64.getUrlEncoder().withoutPadding();
byte[] header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8);
byte[] payload = "{\"sub\":\"1234567890\",\"name\":\"John Doe\"}".getBytes(StandardCharsets.UTF_8);
System.out.println(url.encodeToString(header));
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
System.out.println(url.encodeToString(payload));
// eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0
}
}
Вызов withoutPadding() возвращает новый экземпляр кодировщика, который ведёт себя идентично, кроме того, что опускает хвостовые заполнители, а исходный не трогается, и javadoc говорит об этом точно. Сторона декодера принимает и ввод с заполнителем, и без, так что значение, которое вы производите без заполнителя, всё ещё будет читаемо строгим декодером, - вот почему без заполнителя - безопасный выбор для всего, что пересекает границу API. Теперь, один большой дисклеймер: две части выше - это неподписанные половины JWT. Настоящий токен требует подписи, вычисленной по «header.payload», и это криптография, а не кодирование. Для продакшена выпускайте и проверяйте токены через JOSE-библиотеку: JJWT (0.13.0) или nimbus-jose-jwt (10.9.1). Артифакт API JJWT, например, находится в одной координате:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<!-- добавьте jjwt-impl и jjwt-jackson в рантайме, согласно документации проекта -->
ID YouTube - другая сторона этого регулятора: одиннадцать символов base64url без заполнителя, идентификатор, которому суждено переживать вставку куда угодно, где допустим URL. Если ваша система генерирует идентификаторы, которые путешествуют по URL, цепочка withoutPadding() выше - форма, которую стоит скопировать.
Кодируем файлы
Каждодневная файловая задача - зеркальное отражение любимой задачи декодера: прочитайте файл, закодируйте, запишите текст. Четыре строки на java.nio.file:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class EncodeFile {
public static void main(String[] args) throws Exception {
byte[] raw = Files.readAllBytes(Paths.get("report.pdf"));
String packed = Base64.getEncoder().encodeToString(raw);
Files.write(Paths.get("report.pdf.b64"), packed.getBytes(StandardCharsets.ISO_8859_1));
System.out.println(raw.length + " -> " + packed.length());
}
}
Последний вывод - это счёт на 33 процента, ставший видимым. Файл в 1 МБ превращается примерно в 1.33 МБ текста (4/3 от исходного, плюс максимум два символа заполнителя), а если вы перенесли его по MIME-образцу, переводы строк добавят ещё несколько процентов: старая почтовая арифметика, всё ещё верная, - 4/3 на 78/76, или примерно 1.37 от исходного для перенесённой по MIME нагрузки. Два следствия. Первое: размерьте любое хранилище или поле сообщения от длины закодированного, а не сырого значения: колонка VARCHAR(255), охотно держащая 192-байтовое сырое значение, отвергнет его 256-символьное кодирование. Второе: направление кодирования - то, что ухудшает память, так что для больших файлов массивная версия - неверный инструмент, а раздел о потоковой обработке - верный. Маленькая радость для файловых людей: поскольку первые символы вывода - чистая функция первых байтов ввода, каждый Base64-закодированный PNG начинается с iVBORw0K, а каждый закодированный GIF - с R0lGOD; вы можете узнать тип файла до того, как будет декодирован хоть один байт.
JSON, API и data URI
Два самых частых места, где в сети живёт закодированный вывод.
Одно: бинарник внутри JSON. Эндпоинты загрузки файлов, контентные API, хранилища секретов и вебхуки встраивают бинарные данные в JSON в виде Base64-текста, потому что сырые байты нарушили бы экранирование JSON-строк. Сторона кодирования - это строка на границе, и единственное решение - какой диалект требует спецификация:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class JsonField {
public static void main(String[] args) throws Exception {
byte[] image = Files.readAllBytes(Paths.get("logo.png"));
// Спецификация говорит base64url, без заполнителя:
String field = Base64.getUrlEncoder().withoutPadding().encodeToString(image);
// Передайте «field» в вашу JSON-библиотеку как обычное строковое значение.
System.out.println(field.length());
}
}
Ловушка - не в кодировании, она в чтении спецификации. Некоторые API хотят стандартный Base64 с заполнителем, некоторые - base64url без него, а некоторые снисходительны к обоим. Когда спецификация молчит, самый дешёвый способ - посмотреть на пример значения от другой стороны: - или _ где угодно решает вопрос об алфавите, а хвостовой = - о заполнителе. Если перепутать диалект, другая сторона обычно не падает; она обычно повреждает файл, а это самый медленный по обнаружению вид бага.
Второе: data URI. Строка data:image/png;base64,..., которая встраивает изображение прямо в HTML или CSS, - это data URI из RFC 2397: data:, опциональный медиа-тип, опциональный флажок ;base64, запятая, затем данные. Построить один - это конкатенация строк, и единственное решение - включать ли флажок (без флажка нагрузка - процентно-кодированный текст, чего никто не хочет для бинарника):
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DataUriBuild {
public static void main(String[] args) throws Exception {
byte[] icon = Files.readAllBytes(Paths.get("icon.png"));
String b64 = Base64.getEncoder().encodeToString(icon);
String uri = "data:image/png;base64," + b64;
System.out.println(uri.substring(0, Math.min(40, uri.length())) + "...");
// data:image/png;base64,iVBORw0KGgo...
}
}
Собственный совет RFC применим с интересом: data URI - для коротких значений. Встроить иконку в 50 КБ - нормальная сделка (на один запрос меньше); встроить фото в 5 МБ - это баг производительности в костюме удобства. Держите флажок, держите медиа-тип честным и держите байты маленькими.
Собираем заголовок Basic auth
Самый старый заголовок аутентификации в вебе по-прежнему самый лёгкий случай Base64 в Java, потому что это ровно один вызов кодирования. По RFC 7617, Basic-запрос присылает Authorization: Basic, за которым следует Base64-кодирование username:password; собственный пример RFC, QWxhZGRpbjpvcGVuIHNlc2FtZQ==, - это «Aladdin:open sesame» в маскировке. На стороне клиента сборка заголовка - две строки Base64 плюс современный HTTP-вызов:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuthClient {
public static void main(String[] args) throws Exception {
byte[] credentials = ("alice:secret123").getBytes(StandardCharsets.UTF_8);
String header = "Basic " + Base64.getEncoder().encodeToString(credentials);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/status"))
.header("Authorization", header)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
}
}
Три предостережения принадлежат этому заголовку. Первое: RFC прямо говорит, что Basic - это кодирование, а не защита: учётные данные читаемы любым, кто видит пакеты, так что этот заголовок лишь настолько же силён, насколько HTTPS под ним, и на всём, кроме TLS, - плохая идея. Второе: кодировка. RFC ожидает учётные данные в US-ASCII (UTF-8 для всего остального, а параметр аутентификации charset носит справочный характер), так что выбирайте StandardCharsets.UTF_8 и держитесь последовательно на обеих сторонах. Третье: заметка о версиях. Клиент java.net.http - это Java 11; на более старой JVM тот же заголовок вешается на HttpURLConnection одним вызовом setRequestProperty, а строка с Base64 идентична в обоих случаях. На серверной стороне того же заголовка разбор и декодирование - пример из сестры-руководства, с разделением по первому двоеточию и сравнением постоянным по времени. Две стороны - это два вызова одного API, и в этом тихая элегантность всего этого.
Значения в конфиге, переменных окружения и колонках
Base64 - это текстовый контейнер, и именно поэтому он появляется там, где вы его и не ждёте: DSN базы данных с точками с запятой в env-файле, пароль с кавычками в properties-файле, многострочный сертификат в config map, бинарный объект в TEXT-колонке, потому что схему задумали раньше, чем кто-нибудь подумал про BLOB. Сторона кодирования - один вызов, и честная рамка - это то, чем он является: трюк ради безопасности формата, а не ради секретности:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigEncode {
public static void main(String[] args) {
String dsn = "pg:host=db;password=qu\"ote";
byte[] raw = dsn.getBytes(StandardCharsets.UTF_8);
String packed = Base64.getEncoder().encodeToString(raw);
System.out.println(packed);
// cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
System.out.println("DB_DSN_B64=" + packed);
}
}
Два правила держат это в честности. Первое: никогда не храните секрет как Base64 и не называйте это зашифрованным: Base64 не добавляет энтропии и не убирает никакой информации, в момент, когда разработчик читает файл, он может декодировать значение одним вызовом, и раздел о безопасности RFC указывает ровно на этот провал: люди, раскрывающие учётные данные, вставляя «закодированные» обмены по протоколу. Если значение секретно, сначала зашифруйте его, и только потом упаковывайте шифртекст в Base64, если канал требует текст. Второе: закладывайте размер: хранимое значение примерно на треть больше исходного, и колонка или поле, в которые вмещалось сырое значение, не вместят закодированное. А когда значение возвращается, декодируйте его на границе и держите как байты (для бинарника) или как строку с явной кодировкой (для текста); это направление - территория сестры-руководства.
Потоковая обработка для больших данных
Кодирование - это направление, которое ухудшает память, поэтому история о больших файлах здесь - о том, чтобы держать рабочий набор маленьким. Массивная версия примера из раздела про файлы хороша до тех пор, пока файл помещается в памяти с комфортом. Дальше ход - поточный адаптер: wrap(OutputStream) возвращает выходной поток, который кодирует по мере записи, так что многогигабайтный файл никогда не приходится держать как единый массив байтов:
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamEncode {
public static void main(String[] args) throws Exception {
OutputStream packed = Base64.getEncoder().wrap(Files.newOutputStream(Paths.get("bigfile.b64")));
InputStream raw = Files.newInputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = raw.read(buf)) != -1) {
packed.write(buf, 0, n);
}
packed.close();
raw.close();
}
}
В этом потоке есть одно поведение, заслуживающее подсветки, потому что на него указывает сам javadoc: обёрнутый поток может удерживать внутри несколько оставшихся байтов, и рекомендуемая практика - «немедленно закрывать возвращённый выходной поток после использования, во время чего он вымоет все возможные оставшиеся байты в базовый выходной поток». Если вы перестанете писать и прочтёте выходной файл до закрытия, хвост ваших данных всё ещё сидит в кодировщике, и файл выглядит обрезанным. Вот почему в примере packed закрывается до того, как что-то ещё тронет файл, а в продакшене вы положили бы оба потока в блок try-with-resources. Вырабатывайте привычку: в кодировочном потоке закрытие - часть кодирования.
Встреча со старой гвардией
Унаследованные кодовые базы полны Base64-API, старше java.util.Base64, и их узнавание спасает от загадок «почему это переносит мой вывод по строкам». Четыре, с которыми вы действительно встретитесь:
| API | Где вы с ним встретитесь | Что делать |
|---|---|---|
sun.misc.BASE64Encoder / BASE64Decoder |
Код до Java 8 | Мигрировать на java.util.Base64; удалено в Java 9 |
javax.xml.bind.DatatypeConverter |
Код эпохи XML, старые веб-сервисы | Удалено в Java 11 (JEP 320); мигрировать |
org.apache.commons.codec.binary.Base64 |
Код, который должен работать на JVM до версии 8 | Оставить ради поддержки до 8; иначе класс JDK - выбор по умолчанию |
com.google.common.io.BaseEncoding |
Guava-насыщенные и стеки больших данных | Работает нормально; у класса JDK нет зависимостей |
Пара sun.misc - та, где драма. Это было внутреннее неподдерживаемое API (тот вид, который компилируется без проблем на JDK того времени и исчезает без предупреждения о депрекации), и у его вывода были собственные привычки, вроде переноса закодированного текста по строкам, - отсюда и удивительное количество багов «в моём Base64 есть переводы строк». Когда в сентябре 2017 вышел Java 9, наведение порядка в системе модулей убрало её, и официальный гайд по миграции не церемонится: «В частности, sun.misc.BASE64Encoder и sun.misc.BASE64Decoder были удалены. Вместо них используйте поддерживаемый класс java.util.Base64, который был добавлен в JDK 8». Если прогнать jdeps по коду, который всё ещё ссылается на старые классы, инструмент пометит зависимость как «JDK removed internal API», а это максимально близко к конусу, на какой только способен JDK. DatatypeConverter из JAXB прожил дольше, но жизнь была похожа: он был помечен устаревшим вместе с модулями Java EE в эпоху Java 9 и удалён начисто в Java 11 по JEP 320, «Удаление модулей Java EE и CORBA». Обе миграции механические: старые вызовы printBase64Binary и BASE64Encoder().encode ложатся один-в-один на getEncoder().encodeToString, если не считать различий в переносе, и как только код переедет на java.util.Base64, он будет работать на каждом JDK от 8 до 26 без дополнительных мыслей.
Безопасность и скорость
Раздел о безопасности короткий, потому что работа кодировщика не может упасть на данных, но он не пустой. Base64 - это не шифрование, и стандарт говорит об этом в самых прямых словах: Base-кодирование «визуально скрывает иначе легко узнаваемую информацию, такую как пароли, но не обеспечивает никакой вычислительной конфиденциальности», и тот же раздел отмечает, что это «известно как причина инцидентов безопасности». Практические следствия для стороны кодирования: не кодируйте секрет, чтобы сделать его безопасным (он станет менее безопасным, потому что помещается в больше каналов); если значение секретно, сначала зашифруйте его и закодируйте шифртекст; и держите в голове близнец маллиабельности, когда получатель может подменить одну валидную запись другой (другой заполнитель, мусор в запасных битах), не меняя декодированных данных. Детерминированный кодировщик здесь помогает: java.util.Base64 производит ровно один вывод на ровно один ввод, так что если ваша система и пишет, и читает значение, запись стабильна, и проверку канонической формы требуют внешние значения на границе доверия.
Со скоростью у стороны кодирования та же история, что и у стороны декодера: на современной JVM встроенная реализация достаточно быстра, чтобы Base64 почти никогда не была узким местом, и это точка отсчёта бенчмарков. Тот же бенчмарк gRPC-java 2025 года, о котором шла речь в сестре-руководстве (issue 11857, JMH на JDK 17 и 21), поставил кодировщик JDK примерно в 2.5-3.8 раза по пропускной способности выше гуавовского, с самым большим отрывом на x86. Две практические заметки: на горячих путях разделяйте один экземпляр кодировщика (фабрика и так возвращает один и тот же общий) и предпочитайте encode(byte[], byte[]) в заранее отмеренный массив, чтобы пропустить выделение; для огромных данных раздел о потоковой обработке - история про память, а цена переноса - шум рядом с диском. Единственная настоящая цена производительности в Base64 - это сам размер, и ни одна реализация, включая эту, не может торговаться с ней вниз.
Чек-лист ловушек
Все ловушки собраны в одном месте, и все они Java-специфичные:
- Пропущенная кодировка.
text.getBytes()без явной кодировки использует кодировку платформы по умолчанию: случайно правильная на JDK 18+, неправильная на любой более старой и неправильная в принципе везде. ПередавайтеStandardCharsets.UTF_8и записывайте кодировку в спецификации. - JWT с заполнителем.
getUrlEncoder()по умолчанию добавляет заполнитель, а токены его не хотят. ВызовwithoutPadding()- часть рецепта, а не опциональная добавка; токен с хвостовым=- это токен, который некоторые валидаторы отвергнут, а некоторые изувечат. - Перенесённый вывод. MIME-кодировщик переносит по 76 символов с CRLF и не добавляет хвостовой перевод строки. Если потребитель ожидает хвостовой перевод - добавьте его; если потребитель не ожидает переводов вообще - не используйте MIME-кодировщик.
- Двойное кодирование. Кодирование значения, которое уже Base64, даёт совершенно валидную и совершенно бесполезную строку. Классическая причина: поле приезжает из API уже закодированным, и ваш код «помощливо» кодирует его ещё раз. Проверяйте до кодирования.
- Плюсы в URL. Вывод стандартного Base64 содержит
+, который в строке запроса - уже пробел ещё до того, как его увидит сервер. Если значение стандартного алфавита должно ехать в URL, сделайте ему процентное кодирование или генерируйте его сразу в URL-безопасном алфавите. - Счёт на 33 процента. Значение, которое помещалось в сырую колонку, не поместится в закодированную. Размерьте хранилище, поля сообщений и заголовки от
4 * ceil(n / 3)и помните, что перенесённый по MIME вывод добавляет сверху несколько процентов. - Незакрытый поток. Обёрнутый выходной поток держит оставшиеся байты до закрытия. Чтение файла до закрытия даст обрезанное кодирование. Try-with-resources - каждый раз.
- Секреты на виду. Base64 - это упаковочный скотч, а не замок. Закодированные учётные данные в файле конфигурации, логе или переменной окружения - это читаемые учётные данные. Сначала шифруйте, либо не делайте этого вовсе.
- Стена Android. На Android
java.util.Base64существует только с уровня API 26; ниже - это фреймворковый классandroid.util.Base64со своими константами флагов (NO_PADDING,URL_SAFEиNO_WRAP). Захардкодить один без проверки - значит сломаться ровно на тех устройствах, которые вы никогда не тестировали. - Странность с длиной строки.
getMimeEncoder(77, ...)молча переносит по 76, потому что длина округляется вниз до кратного четырём, а запрос на 3 и меньше отключает перенос полностью. Если вашему формату нужна нечётная длина строки, MIME-регулятор - не тот инструмент.
От sun.misc к стандартной библиотеке
История Java - короткая, с чётким до и после. До 2014, если вам нужен был Base64 внутри JDK, вы получали внутреннюю пару sun.misc.BASE64Encoder и sun.misc.BASE64Decoder, неподдерживаемую с первого дня, с собственными привычками переноса по 76 символов, либо тянулись за javax.xml.bind.DatatypeConverter в XML-коде, либо добавляли в сборку Apache Commons Codec или Guava - именно так много корпоративных кодовых баз оказались с тремя реализациями Base64 и ни одним представлением, какая есть какая. 18 марта 2014 Java 8 отгрузила java.util.Base64: один класс, три алфавита, правила RFC 4648 и RFC 2045, реализованные как надо, фабричный паттерн, регуляторы заполнителя и переноса, и поточные адаптеры в обоих направлениях. Это был тот Base64, каким языку следовало обладать с самого начала, и javadoc с тех пор говорит Since: 1.8.
Наведение порядка пришло двумя волнами. Java 9 (21 сентября 2017) убрала пару sun.misc как часть наведения порядка в системе модулей, и гайд по миграции направил каждого разработчика к классу JDK 8, а Java 11 убрала модуль JAXB и его DatatypeConverter вместе с ним (JEP 320). Java 18 (22 марта 2022) доставила JEP 400, «UTF-8 по умолчанию», которая вообще не трогала Base64, но изменила режим отказа ленивых вызовов getBytes(), которые её кормят: кодировка платформы по умолчанию стала UTF-8 на каждой ОС, так что старые паттерны кракозябр просто перестали воспроизводиться на новых JVM. С 1.8 публичное API не поменяло ни одного метода. А двигался мотор под капотом: починки багов и работа над производительностью, поэтому бенчмарки комьюнити снова и снова находят, что версия из стандартной библиотеки обгоняет легаси-библиотеки, которые она заменила. Сегодня, на любом JDK от 8 до 26, ответ на «как мне сделать Base64 из этого в Java» - один импорт и фабричный вызов, и так уже больше десятилетия.
Несколько радостей для энтузиастов
Потому что справочник должен заканчиваться улыбкой, вот несколько Java-специфичных фактов, которые просто забавны:
- Javadoc говорит
Since: 1.8, и это правда уже двенадцать лет. Ни одного метода не добавлено, ни одного не удалено, ни одного поведения не изменено: одна из самых долго замороженных поверхностей API в языке, и вы пользуетесь ей не думая. encodeToStringстроит свою результирующую String с кодировкой ISO-8859-1, согласно javadoc. На практике это совершенно ненужная деталь, потому что вывод Base64 - чистый ASCII и выглядит одинаково в Latin-1, UTF-8 и большей части остального зверинца кодировок, но javadoc говорит вам об этом в любом случае, а это JDK, быть которым и должен JDK.- MIME-кодировщик не добавляет разделитель строки после последней неполной строки. Другие инструменты, включая несколько очень знаменитых почтовых библиотек, заканчивают перенесённый вывод хвостовым CRLF. Если ваш diff против референсной реализации - ровно два символа в конце, вы нашли эту странность.
- Попросите
getMimeEncoderстроки по 77 символов, и он даст вам 76: длина строки молча округляется вниз до ближайшего кратного четырём, потому что перенос, разрывающий четырёхсимвольную группу, произвёл бы мусор. API отказывается строить сломанную строку, вместо того чтобы спрашивать вашего разрешения. Base64.getEncoder() == Base64.getEncoder()- правда. Фабричные методы возвращают один и тот же общий экземпляр при каждом вызове, так что API «получи новый» - это костюм для синглтона, и обещание потокобезопасности - просто описание того, что JVM и так уже делает.- На Android близнец API
android.util.Base64выставляет те же решения в виде флагов:NO_PADDING,URL_SAFE,NO_WRAP. Два API, одна таблица решений, и это тихое свидетельство того, насколько устаканен дизайн Base64 к настоящему времени. - Раздел 5 RFC 4648 - там, где рождается название «base64url»: спецификация говорит, что URL-безопасная кодировка «может называться base64url», и предупреждает, что её «не следует считать той же, что base64-кодирование». Её происхождение отнесено сноской к посту 2001 года на рассылке P2P-hackers, так что название в каждом URL, который вы вставляете, имеет родословную из рассылки.
- Закодируйте слово
base64, и вы получитеYmFzZTY0, без заполнителя, потому что шесть делится на три. Формат, описывающий сам себя, - технический эквивалент зеркала, которое говорит азбукой Морзе, и это отражение самого зеркала. - Прогоните
jdeps -jdkinternalsпо коду до Java 8 и смотрите, как он помечаетsun.misc.BASE64Encoderкак «JDK removed internal API». Пример инструмента в официальном гайде по миграции - это класс Base64, и это JDK, указывающий на ваши импорты и говорящий «мы об этом уже говорили». - Коэффициент 1.37. Каждая перенесённая по MIME нагрузка стоит примерно 1.37 от своего исходного размера (4/3 за алфавит, 78/76 за ритм CRLF), дробь настолько стабильная, что старая почтовая арифметика до сих пор её цитирует: пошлина, которую почтовая инфраструктура 1990-х взимала с каждого вложения, - это ровно тот счёт, который
getMimeEncoder()взимает сегодня.
Идём в другую сторону
Вот и сторона кодировщика, и это спокойнее из двух: работа никогда не падает на данных, ловушки - про ваши решения (кодировка, заполнитель, перенос, диалект), а не про сюрпризы других людей, и всё API умещается в один импорт. Другое направление - там, где Base64 перестаёт быть удобным и начинает быть враждебным, потому что декодирование - это место, где вы встречаете чужой выбор заполнителя, чужие переводы строк, чужие кодировки и чужие обёртки, а между вами и правдой стоит IllegalArgumentException. Декодирование Base64 в Java, на которое есть ссылка с этой страницы, разбирает декодер с той же глубиной: три характера декодеров, точные сообщения об ошибках, правила заполнителя, base64url и JWT, MIME и PEM, а также Java-специфичные ловушки, собранные в одном месте. Прочитайте оба как пару - и вся тема ваша.
Последнее обновление: 2026-09-08
Связанная статья: Декодирование Base64 в Java: полное руководство