Кодирование Base64 в Go: полное руководство
Время от времени вашей Go-программе приходится передавать бинарные данные миру, который принимает только текст: JSON-полю, которое должно остаться строкой, URL, который должен остаться одним токеном, вложению в письме, переезжающему через сервера, помнящие 7-битные времена, изображению, которое хочет жить внутри HTML, чтобы страница пропустила запрос. Base64 - курьер ровно для этой работы, и домашняя страница этого сайта уже подробно объясняет формат, так что статья пойдёт сразу к мастерству упаковки: производству base64-строк в Go, которые любой декодер на планете сможет открыть без борьбы.
Хорошие новости сразу: кодировщик - тихая половина истории. Один метод, без возврата ошибки, без режима сбоя, побайтово одинаковый вывод в каждом выпуске Go с первого стабильного. Весь драматизм живёт вокруг этого метода: выбор правильного алфавита для канала, по которому будет путешествовать строка, вызов Close, который молча сжирает ваши последние два байта, когда вы о нём забываете, налог на размер, который несёт формат, и тот факт, что Go, как Python, Java и Node, никогда не разбивает свой вывод на строки по 76 символов. Сначала познакомьтесь с функцией, а потом - с ловушками.
Упаковка без сбоев
Девяносто процентов кодирующей жизни в Go - это один метод типа Encoding, и, как у любой кодирующей точки входа в пакете (Encode, AppendEncode), у него нет возврата ошибки:
func (enc *Encoding) EncodeToString(src []byte) string
Дайте ему байты, он отдаст строку, и это весь договор:
package main
import (
"encoding/base64"
"fmt"
)
func main() {
packed := base64.StdEncoding.EncodeToString([]byte("Man"))
fmt.Println(packed) // TWFu
}
Значения ошибки нет, потому что нечему ломаться: любой байт - законный вход, алфавит всегда его покрывает, а вывод всегда чистый ASCII. Три свойства заслуживают того, чтобы их запомнили, потому что они отвечают на половину всех будущих вопросов. Первое: длина вывода - чисто арифметическая функция длины входа, и пакет даже отдаёт вам формулу в виде метода: EncodedLen(n) возвращает (n+2)/3*4 для кодировок с заполнением, так что 3 входных байта становятся 4 символами, 6 становятся 8, и так далее. Второе: формат несёт налог на размер: каждые три байта данных возвращаются четырьмя символами, это привычное расширение примерно на 33 процента, которое вылезает в счетах за пропускную способность и в квотах на хранилище. Третье: метод детерминирован: одни и те же байты всегда производят одну и ту же строку, на любой машине, в любой версии Go, вечно. Именно эта детерминированность делает base64 форматом сериализации, а не загадкой.
Одна заметка, специфичная для Go, на входе: метод принимает []byte, а не string, и конвертация []byte(...) явно стоит в каждой точке вызова - Go никогда не превращает строку в срез за вас - и она производит независимую копию байтов строки. Компилятор может опустить эту копию, когда срез только читается и не уходит за пределы функции, поэтому стоимость обычно неизмерима; но если срез сохраняется или возвращается, рантайм платит за настоящую копию в O(n). Текст в Go-программе по договорённости - UTF-8, так что когда вы кодируете строку, вы кодируете её UTF-8-байты, и это ровно то, чего ждёт каждый современный декодер на другом конце. Больше об этом в разделе «Текст, байты и юникод».
Как Go его поставляет
Как и всё в этой статье, кодировщик приходит из пакета стандартной библиотеки encoding/base64, который поставляется с первого выпуска языка, и в исходном файле которого до сих пор стоит его шапка с копирайтом 2009 года. Тянуть не нужно никакого модуля, переключать - никакого фич-флага, и никаких платформенных причуд: если go version работает, go doc encoding/base64 напечатает для вас весь API.
На момент написания статьи самый свежий выпуск - Go 1.27.1, вышедший 1 сентября 2026 года, а второй поддерживаемой линией идёт Go 1.26 (сейчас это 1.26.8). Установите Go из официальных tar-архивов на go.dev/dl, из пакетного менеджера вашего дистрибутива (sudo apt install golang-go) или через обёртку golang.org/dl, если вы жонглируете версиями. Base64 API идентичен в обеих поддерживаемых линиях, а таблица ниже - вся история того, что вообще менялось, что для такого центрального пакета - короткий список:
| Выпуск | Год | Что изменилось в encoding/base64 |
|---|---|---|
| Go 1.0 | 2012 | пакет стабилен с первого дня; копирайт исходника 2009 |
| Go 1.5 | 2015 | добавлены RawStdEncoding и RawURLEncoding для вывода без заполнения |
| Go 1.8 | 2017 | добавлен Strict() для канонического декодирования (сторона декодера) |
| Go 1.22 | 2024 | добавлены AppendEncode и AppendDecode; WithPadding теперь отклоняет некорректные аргументы |
| Go 1.27.1 | 2026 | текущий выпуск; API не изменилось, поведение побайтово стабильно по обещанию Go 1 |
Практическое следствие этой истории: код, написанный против этого API в 2015 году, компилируется и ведёт себя идентично сегодня, а строки, которые ваша программа закодирует в 2026 году, корректно декодируются на любом выпуске Go, прошлом или будущем. Для формата сериализации это тихая суперсила.
Выбор алфавита под пункт назначения
В кодировке есть одно настоящее решение, и это вопрос путешествия: куда отправится эта строка? Go даёт вам четыре готовых кодировщика, и каждый настроен на свой канал:
| Кодировщик | Алфавит | Заполнение | Сюда отправляйте, когда строка путешествует через |
|---|---|---|---|
StdEncoding |
A-Z a-z 0-9 + / |
= |
тела JSON, MIME-части писем, data URL, HTTP Basic auth, PEM, большинство API |
URLEncoding |
A-Z a-z 0-9 - _ |
= |
пути и запросы URL, имена файлов, везде, где + или / пришлось бы экранировать |
RawStdEncoding |
A-Z a-z 0-9 + / |
нет | компактные строки со стандартным алфавитом, где заполнение не должно появляться |
RawURLEncoding |
A-Z a-z 0-9 - _ |
нет | сегменты JWT, компактные идентификаторы, токены, встроенные в URL |
Логика вариантов - та же, что и логика самого формата. Стандартный алфавит - это то, чего ждут MIME и большинство API, так что это вариант по умолчанию и безопасный ответ, когда вам ничего не сказали. URL-безопасный алфавит существует, потому что + и / - зарезервированные символы URL: плюс в строке запроса часто читается как пробел, а слэш начинает новый сегмент пути, так что стандартный base64 в URL либо ломается, либо требует процентного экранирования символов, несущих +, / или = - это несколько процентов типичного токена. Замена их на - и _, которые законны без экранирования в путях, запросах и именах файлов, - это и есть решение, которое стандартизировал RFC 4648. Варианты Raw отбрасывают хвостовые знаки равенства целиком, и это важно там, где заполнение либо запрещено, либо просто никогда не используется, как в сегментах JWT. Правило, которое спасает от большинства отладки: кодировщик, который вы выбираете, и декодер, который использует другая сторона, - это один договор, и договор пишется пунктом назначения, а не вами.
Если система, с которой вы общаетесь, определила частный 64-символьный алфавит, base64.NewEncoding("...64 chars...") соберёт для вас кодировщик под него, а WithPadding(rune) позволяет заменить символ заполнения или отключить его через NoPadding. Обе функции вызывают панику на неверных аргументах (неверная длина алфавита, дублирующийся символ, перевод строки в алфавите, символ заполнения, который конфликтует с алфавитом), так что собирайте свои кастомные кодировщики один раз, при запуске, и никогда - в горячем пути.
Ловушка Close
Вот самая знаменитая ловушка этого пакета, и она проявляется только тогда, когда вы кодируете поток, а не строку. NewEncoder оборачивает любой io.Writer в base64-кодирующий writer, и поскольку base64 работает блоками из трёх входных байтов, производящими четыре выходных символа, кодировщику приходится буферизовать ваши последние один-два байта, дожидаясь, придут ли ещё. Они сбрасываются только тогда, когда вы закрываете его:
package main
import (
"bytes"
"encoding/base64"
"fmt"
)
func main() {
var buf bytes.Buffer
enc := base64.NewEncoder(base64.StdEncoding, &buf)
enc.Write([]byte("hello"))
fmt.Println(buf.String()) // aGVs -- где «lo»?
buf.Reset()
enc = base64.NewEncoder(base64.StdEncoding, &buf)
enc.Write([]byte("hello"))
enc.Close()
fmt.Println(buf.String()) // aGVsbG8= -- полное кодирование «hello»
}
Первая распечатка - весь урок целиком: без Close кодировщик выдал только первый полный блок - три байта «hello» стали «aGVs», - а оставшиеся два байта просто испарились во внутреннем буфере. Вторая распечатка, после Close, - правильная, полная строка. Лечится это привычкой, а не техникой: в тот момент, когда вы создаёте кодировщик, сразу создавайте и его расчистку:
enc := base64.NewEncoder(base64.StdEncoding, w)
defer enc.Close() // не забывайте проверять возвращаемую ошибку в коде для продакшена
Две детали делают эту ловушку острее, чем кажется. Первая: Close делает настоящую работу: он сбрасывает зависший частичный блок, и он может упасть, потому что пишет в базовый writer, так что идиоматичная версия проверяет его ошибку, особенно когда пункт назначения - сеть или диск. Вторая: документация говорит, что вызывать Write после Close - ошибка, но рантайм не проверяет соблюдение этого предложения. Если вы снова напишете после закрытия, кодировщик молча начнёт свежий блок и допишет его, производя строку с заполнением посреди неё, - это невалидный base64, который большинство декодеров отклонят с непонятным смещением. Держать договор - ваша работа.
Перенос строк, по-Go
Все остальные крупные base64-реализации, которые вы когда-либо использовали, разбивают свой вывод на строки: MIME просит строки не длиннее 76 символов, PEM использует 64, почтовые клиенты по всему миру то и дело вставляют CRLF. Go-кодировщик ничего этого не делает. Он выдаёт одну непрерывную строку, каким бы большим ни был пелод, и так делает с рождения пакета. Вывод для мегабайта данных - одна строка на мегабайт с третью, от начала до конца, без разрывов.
Это осознанный выбор, а не недосмотр. Формат работает одинаково с переводами строк и без, собственный декодер Go пропускает их где угодно во входе, а кодировщик, который молча вставляет CRLF в ваши данные, удивил бы программы, которые хранят строку в колонке базы данных или сравнивают её на равенство. Цена - вам приходится переносить строки самим, когда канал этого требует, и для этого нужен один маленький помощник:
package main
import (
"bytes"
"encoding/base64"
"fmt"
)
func wrapAt(s string, width int) string {
var out bytes.Buffer
for i := 0; i < len(s); {
end := i + width
if end > len(s) {
end = len(s)
}
out.WriteString(s[i:end])
out.WriteByte('\n')
i = end
}
return out.String()
}
func main() {
raw := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 100))
fmt.Print(wrapAt(raw, 76))
}
Заметка о направлении путешествия: поскольку декодер Go игнорирует переводы строк где угодно, перенесённый на строки ввод декодируется безупречно на Go-стороне любого моста. В другую сторону нужна осторожность: если вы отправляете перенесённый вывод потребителю, который не ожидает разрывов (поле JSON, URL, токен), сначала уберите их, потому что этот потребитель может считать перевод строки повреждённым символом. Знайте, в каких соглашениях живёт ваш канал, и выдавайте их намеренно.
Упаковка для почты и MIME
Электронная почта - древнейший дом base64. Исходный протокол SMTP был задуман для передачи 7-битного ASCII, поэтому вложения кодировались в base64 перед отправкой и декодировались по прибытии, а стандарт MIME (RFC 2045) оформил эту практику в правило: заголовок Content-Transfer-Encoding: base64 помечает часть, а тело должно быть разбито на строки не длиннее 76 символов с CRLF между ними.
Пакет net/smtp в Go отправляет те байты, которые вы ему дадите, и MIME-части за вас не соберёт, так что в программе, которая компонует почту, base64-часть выглядит так:
package main
import (
"bytes"
"encoding/base64"
"fmt"
)
func main() {
body := []byte("hi from Go")
var part bytes.Buffer
part.WriteString("Content-Transfer-Encoding: base64\r\n")
part.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
encoded := base64.StdEncoding.EncodeToString(body)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
part.WriteString(encoded[i:end] + "\r\n")
}
fmt.Print(part.String())
}
Три вещи, на которые стоит обратить внимание. Стандартный кодировщик - правильный здесь, потому что MIME - изначальный контекст стандартного алфавита. Переводы строк - CRLF, а не нативный перевод строки платформы, потому что именно так прописано в RFC и именно этого ждут почтовые парсеры. И если ваша программа отправляет настоящую почту в объёме, поддерживаемая MIME-библиотека соберёт всё письмо за вас; смысл этого примера - в base64-половине, потому что это часть, которая принадлежит именно этому пакету. Сделайте алфавит и соглашение о строках правильно, и остальной MIME - проблема кого-то другого.
Упаковка файлов
Для файлов, которые помещаются в памяти, паттерн - те же две строки, что и везде: прочитать, затем EncodeToString. Для файлов, которые не помещаются, потоковая обработка держит память ровной, а рецепт такой: файл, кодировщик, копия и два закрытия в правильном порядке:
in, err := os.Open("photo.jpg")
if err != nil {
panic(err)
}
defer in.Close()
out, err := os.Create("photo.b64")
if err != nil {
panic(err)
}
enc := base64.NewEncoder(base64.StdEncoding, out)
if _, err := io.Copy(enc, in); err != nil {
panic(err)
}
if err := enc.Close(); err != nil {
panic(err) // сбрасывает последний частичный блок
}
if err := out.Close(); err != nil {
panic(err)
}
Порядок закрытий - тонкая часть, и это файловая версия ловушки Close: кодировщик нужно закрыть раньше файла, потому что именно enc.Close дописывает последний частичный блок в файл, а если закрыть файл первым, этот блок останется в буфере, который пишет в никуда. С defer помните, что отложенные вызовы выполняются в обратном порядке, так что регистрация out.Close первой и enc.Close второй (или, как в примере выше, явное закрытие кодировщика до defer файла) - то, что делает последовательность безопасной.
Держите налог на размер в голове, когда планируете вокруг этого паттерна: фото на 10 мегабайт становится примерно 13,3 мегабайта текста, а архив на 100 мегабайт - строкой на 133 мегабайта на диске. Если у пункта назначения есть квота, лимит или цена за байт, считается base64-версия вашего файла, а не оригинал.
Упаковка для веба: data URL
Браузеры охотно загрузят изображение или шрифт из строки, которая живёт внутри самого HTML или CSS, и эта строка - data URL: тип носителя, флаг ;base64, запятая и пелод, всё в одном URL. В Go нет помощника для data URL, но собрать его - это склейка строк, потому что формат - это договор, который вы видите, записанным прямо перед вами:
package main
import (
"fmt"
"os"
"encoding/base64"
)
func main() {
img, err := os.ReadFile("logo.png")
if err != nil {
panic(err)
}
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
fmt.Println(url)
// data:image/png;base64,iVBORw0KGgo...
}
Два правила, чтобы с data URL не залезть в кусты. Всегда указывайте тип носителя: в грамматике он необязателен (по умолчанию text/plain;charset=US-ASCII), но браузер, который угадывает тип вашего бинарного пелода, - это не тот сценарий, которого вы хотите. И относитесь к data URL как к трюку для маленьких ассетов. RFC говорит, что схема полезна только для коротких значений, и именно расширение на 33 процента делает разницу между иконкой на 2 килобайта, которая экономит запрос, и фото на 5 мегабайт, которое раздувает каждую загрузку страницы, - без кэша, с которым можно поделиться, и без URL, который можно передать кому-либо. Иконки, фавиконки, маленькие спрайты: да. Товарная фотография: нет.
Упаковка для HTTP
Три HTTP-контекста доминируют над base64 в Go-сервисах, и два из них идут со встроенной помощью. Первый - тело JSON, рабочая лошадка: вы кодируете значение до маршалинга, и поле несёт по проводу обычную строку:
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
)
type avatar struct {
Data string `json:"data"`
}
func main() {
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
a := avatar{Data: base64.StdEncoding.EncodeToString(png)}
body, err := json.Marshal(a)
if err != nil {
panic(err)
}
fmt.Println(string(body))
// {"data":"iVBORw0KGgo="}
}
Если тип появляется во многих местах, чистый Go-ход - реализовать для него MarshalJSON и UnmarshalJSON, чтобы base64-шаг стал невидим для каждой точки вызова. Второй контекст - HTTP Basic аутентификация, где стандартная библиотека делает всю работу: Request.SetBasicAuth(user, pass) строит заголовок Authorization за вас, прогнав стандартный кодировщик по паре user:pass, которую прописывает RFC 2617. Единственное правило там - не импровизировать: Basic auth - это стандартный base64 с префиксом Basic , и URL-безопасный алфавит или недостающий знак заполнения превратят работающий логин в 401, который никто не сможет объяснить.
Третий контекст - URL, где строка является пелодом сегмента пути или параметра запроса. Здесь стандартный алфавит - плохой выбор, потому что +, / и = все сталкиваются с грамматикой URL, и каждое их вхождение требует процентного экранирования. Вместо этого кодируйте URL-безопасным вариантом, и токен переживёт URL неповреждённым. Если потребитель всё равно его процентно экранирует, ничего не ломается, а если нет - вы сэкономили себе целый класс 404.
URL-безопасный вывод
URL-безопасный base64 заслуживает собственного раздела в Go, потому что это вариант, к которому вы будете тянуться чаще, чем к стандартному, и потому что Go делает переключение бесплатным. Альтернативный алфавит из RFC 4648 заменяет + на - и / на _, так что выводу не нужно экранирование в путях URL, запросах или именах файлов, а в строке лога он читается как один чистый токен. Два готовых кодировщика - URLEncoding (с заполнением) и RawURLEncoding (без заполнения):
raw := []byte{0xfb, 0x0f, 0x67, 0x01}
fmt.Println(base64.StdEncoding.EncodeToString(raw)) // +w9nAQ==
fmt.Println(base64.URLEncoding.EncodeToString(raw)) // -w9nAQ==
fmt.Println(base64.RawURLEncoding.EncodeToString(raw)) // -w9nAQ
Один вход, три вывода: стандартная версия требует процентного экранирования своего плюса, URL-безопасная - это один токен, а raw-версия сбрасывает и заполнение. Типичные Go-работы для каждой: непрозрачные идентификаторы, которые сервис генерирует, а затем хранит в URL, роутах или именах файлов; API-токены, которые клиенты вставляют в строки запросов; всё, что появится в строке лога, где плюс или слэш - в одном символе от того, чтобы их приняли за синтаксис.
Дисциплина, которая держит это в чистоте, - та же, что и везде в этой статье: вариант - это договор с потребителем. Если другая сторона ждёт стандартный base64, а вы шлёте URL-безопасный, её декодер упадёт на первом дефисе, и ошибкой будет байтовое смещение где-то в конце совершенно нормальной строки, а это не самое очевидное, что отлаживать. Когда сомневаетесь - спросите, чего ждёт другая сторона, прочитайте спецификацию, на которую она ссылается, и выбирайте кодировщик от пункта назначения, а не от привычки.
Упаковка JWT
JSON Web Token - самый заметный потребитель base64 в современных API, и он закрепляет точный вариант: компактная сериализация JWS, по RFC 7515, - это три base64url-сегмента без заполнения, склеенные точками. Заголовок, пелод, подпись. Это значит, что кодировщик для всего, что вы строите вручную, - RawURLEncoding:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
)
func main() {
secret := []byte("hmac-secret")
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
payload, _ := json.Marshal(map[string]any{"sub": "1234567890"})
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(signingInput))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
fmt.Println(signingInput + "." + signature)
}
Читайте этот пример как урок о том, что такое формат, а не как рекомендацию шипить его в прод: он показывает, где именно сидит base64 (дважды до подписи, один раз после) и почему подпись покрывает закодированные сегменты, а не сырой JSON. В продакшене подписывайте и проверяйте поддерживаемой библиотекой, потому что у JWT длинный хвост ошибок (сдвиг часов на срок действия, путаница с алгоритмом, пропущенные проверки audience), которые base64-слой не видит. Библиотека Go де-факто - github.com/golang-jwt/jwt/v5, устанавливается командой go get github.com/golang-jwt/jwt/v5:
package main
import (
"fmt"
"log"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := []byte("hmac-secret")
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "1234567890",
"exp": time.Now().Add(time.Hour).Unix(),
})
signed, err := token.SignedString(secret)
if err != nil {
log.Fatal("signing failed:", err)
}
fmt.Println(signed)
}
Библиотека выполняет base64url-кодирование каждого сегмента внутри, так что вы вообще никогда не трогаете encoding/base64, и это лучший исход: на одно место меньше, где может спрятаться ошибка заполнения или алфавита. И обратите внимание на стражу, которую она даёт вам бесплатно: v5 отклоняет токены, которые заявляют alg=none, если вы явно не включите это через её константу UnsafeAllowNoneSignatureType, - это та защита, которая вам нужна, даже не думая о ней.
Текст, байты и юникод
Позиция Go по этому вопросу - самая короткая из всех крупных языков, и именно поэтому base64 здесь такой приятный: string в Go - это последовательность байтов только для чтения, и текст в вашей программе - UTF-8. Нет скрытого слоя кодирования, нет сюрприза «строка на самом деле UTF-16» и нет флага charset, который нужно выставлять. Когда вы пишете EncodeToString([]byte(myText)), вы кодируете UTF-8-байты текста, точка:
s := "Café ☕"
packed := base64.StdEncoding.EncodeToString([]byte(s))
fmt.Println(packed) // Q2Fmw6kg4piV
Эта одна строка - вся история для современного текста, включая эмодзи и CJK: base64 работает с байтами, UTF-8 - просто последовательность байтов, и каждый декодер на другом конце, который придерживается того же соглашения, вернёт вам ту же строку. Конвертация []byte(...) - независимая копия, которую компилятор опускает, когда срез только читается и не уходит за пределы функции, - так что на практике её стоимость ниже порога измерений.
Один случай, когда история становится длиннее, - устаревшие данные: байты, произведённые системой на Windows-1252, Shift JIS или ISO-8859-1, которые не являются валидным UTF-8. Если вы base64-закодируете эти байты как есть, вы честно переправите сломанный текст, чего никто не хотел. Лечится это нормализацией до кодирования, с помощью golang.org/x/text, чтобы base64-строка несла чистый UTF-8 с момента, когда покидает вашу программу:
import (
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
legacy := []byte{0x43, 0x61, 0x66, 0xE9} // «Café» в Windows-1252
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), legacy)
if err != nil {
panic(err)
}
packed := base64.StdEncoding.EncodeToString(utf8)
// Q2Fmw6k= -- тот же «Café», теперь чистые UTF-8-байты, готовые в путь
Тот же модуль покрывает japanese, korean, simplifiedchinese и traditionalchinese в дополнение к charmap. Практическое правило: преобразуйте один раз, на границе, где устаревшие байты входят в вашу программу, и с того момента всё, что вы кодируете, - UTF-8. Не преобразуйте дважды, не гадайте, и никогда не пускайте не-UTF-8 пелод в base64-строку, которую современный потребитель декодирует и отображает.
Замеряем кодировщик
Кодировщик - это табличный поиск без ветвления на входных данных и без аллокаций, кроме строки вывода, и это видно по цифрам. На недавнем настольном процессоре под Go 1.26 кодирование 500 байт занимает примерно три десятых микросекунды с двумя аллокациями, что даёт порядка гигабайта с половиной в секунду. Мегабайт данных кодируется заметно меньше чем за миллисекунду; кодировщик редко будет тем, что вы вообще ощутите.
Единственный рычаг, который стоит знать, - это профиль аллокаций в горячих циклах. EncodeToString аллоцирует строку вывода на каждый вызов, и это правильный обмен для случая с 99 процентами. Если вы кодируете тысячи чанков в секунду в растущий буфер, AppendEncode, добавленный в Go 1.22, дописывает закодированные байты в срез, который вы переиспользуете, и в установившемся режиме не выполняет аллокаций, когда буфер вырос до нужного размера:
var out []byte
for _, chunk := range chunks {
out = base64.StdEncoding.AppendEncode(out, chunk)
}
Используйте EncodeToString для разовых случаев, AppendEncode - для тесных циклов, а NewEncoder - для потоков и файлов. Какой бы вы ни выбрали, помните, что сеть или диск вокруг кодировщика почти всегда и есть медленная часть, поэтому профилируйте весь путь, прежде чем оптимизировать алфавит.
Принципы безопасности
Самое важное предложение о безопасности в этой статье: base64 - не шифрование, и «мы сначала его base64-ируем» - не мера безопасности. Алфавит делает данные безопасными для текста, но не секретными, и любой, у кого есть инструменты разработчика в браузере, прочитает ваш base64 за секунду. Конфиденциальность приходит из TLS и из контроля доступа, а работа base64 - доставить байты по чисто текстовому каналу, не повредив их. Держите эти две работы раздельными - и в дизайне, и в документации, - и вы избежите классического ревью-комментария «пароль защищён, смотрите, он же base64».
Второе соображение - размер. Поскольку формат расширяется на треть, у каждого лимита в вашей системе есть base64-версия: API, которое принимает 4 мегабайта JSON, принимает примерно 3 мегабайта исходных данных, когда пелод - base64-поле; URL с бюджетом длины становится короче в сырых байтах, когда токен URL-безопасный и без заполнения; а колонка базы данных, размерённая под сырое значение, может оказаться слишком маленькой для закодированного. Сделайте арифметику через EncodedLen до того, как хранить, отправлять или ограничивать, и помните, что расширение считается от входа, с которого вы начинаете, а не от строки, с которой вы заканчиваете.
Третье: подумайте о том, где закодированная строка может быть зафиксирована. Base64-строки дружелюбны к логам и экранам, и это фича - до тех пор, пока вложение на 20 мегабайт не превратится в base64 в 26 мегабайт текста, которые ваш журнал доступа добросовестно записывает на каждый запрос. Логируйте длину, первые пару десятков символов и идентификатор, а не пелод, - и логи останутся читаемыми, а диск - живым. Наконец, в URL предпочитайте URL-безопасный вариант, чтобы токены не тратили ни одного символа на процентное экранирование, которое раздувает URL и изредка цепляет гейтвей или прокси со строгим мнением о том, что положено быть в строке запроса.
Интересные факты и странности Go
Несколько фактов, специфичных для этого пакета, на случай, когда вы хотите быть правы в код-ревью:
EncodeToString- рабочая лошадка, и, как любая кодирующая точка входа в пакете (Encode,AppendEncode), она не возвращает ошибку - кодирование не может упасть в Go, а это редкая и тихая разновидность свободы: любой байт - законный вход, и единственный способ получить плохую строку - выбрать неверный алфавит для канала.EncodedLen- чистая арифметика,(n+2)/3*4для кодировок с заполнением, вычисляется без аллокаций и без цикла. Он существует, чтобы вы могли размерить буферы и квоты, ни разу не закодировав ни байта.- Внутри потокового кодировщика прячутся входной буфер на 3 байта и выходной буфер на 1024 байта - поэтому
NewEncoderпишет чанками и поэтому последний частичный блок может выйти только черезClose. Буферы - причина ловушки. - Документация говорит, что писать после вызова
Close- ошибка, но рантайм не исполняет это предложение. ПозднийWriteпринимается, дописывает свежий блок и производит строку с заполнением посреди неё: невалидный base64, произведённый вежливо, ни с одним значением ошибки на горизонте. - Go-кодировщик никогда не разбивал свой вывод на строки по 76 символов - как и кодировщики Python, Java и Node, Go-кодировщик производит одну строку на мегабайт данных. Ваш помощник для MIME-переноса - личный проект, и это тоже хороший способ вспомнить, что переводы строк в почтовом base64 - соглашение MIME, а не требование base64.
- По состоянию на август 2026 более 244 000 публичных пакетов на pkg.go.dev импортируют
encoding/base64. Какая бы ни была ваша Go-программа, почти наверняка где-то она делает base64, знаете вы об этом или нет. - Обещание совместимости Go 1 действует для этого пакета с особой силой: вывод программы, которая закодировала строку в 2013 году, побайтово идентичен на Go 1.27 сегодня. Base64-строки в Go - по сути бессмертны.
Ошибки, которые возвращаются снова и снова
Ошибки кодирования, которые постоянно всплывают в кодовых базах Go, примерно в том порядке, в котором они приходят:
- Забыть
Closeу потокового кодировщика и отпустить в мир строку без её последних одного-двух байтов. Баг переживает каждый тест, где вход имеет длину, кратную трём, - именно так он добирается до продакшена. - Закрыть файл раньше кодировщика, так что последний частичный блок сбрасывается в файловый дескриптор, которого уже нет. Вывод обрезан ровно на ту же величину, а ошибка проступает только на входах, чей размер не кратен трём.
- Ожидать переносы на 76 символов в MIME- или почтовом выводе и удивляться, когда Go отдаёт одну длинную строку. Перенос - это соглашение канала, и в Go наложить его - работа вашего кода.
- Использовать стандартный алфавит внутри URL, а потом тратить полдня на погоню за 404 и 400, которые на самом деле - проблема процентного кодирования. Если строка будет жить в URL, начинайте с
URLEncodingилиRawURLEncoding. - Выдавать заполнение там, где потребитель его запрещает: сегменты JWT, некоторые форматы токенов, несколько строгих парсеров. Raw-варианты существуют ровно для этого, а сообщение об ошибке от другой стороны часто - байтовое смещение в самом конце вашей строки.
- Base64-ировать секрет и называть это защитой. Это не она. Заголовок, токен, «зашифрованное» поле: любой прочитает за полсекунды. Используйте TLS, используйте хеширование там, где протокол хочет хеш, и позвольте base64 делать свою единственную честную работу.
- Забыть про 33 процента, когда настраиваете лимиты: размеры тел, ширины колонок, бюджеты URL, проверки квот. Арифметика - один вызов
EncodedLen, а цена её пропуска - 413 или обрезанная колонка в продакшене. - Кодировать текст, который не UTF-8, что честно переправляет поломку. Нормализуйте устаревшие кодировки через
golang.org/x/textдо кодирования, чтобы base64-строка несла чистые байты. - Писать в кодировщик после его закрытия - по привычке или из цикла повторов. Ошибка не поднимается, и вывод молча становится невалидным.
- Предполагать, что декодер на другом конце так же снисходителен, как Go. Go пропускает переводы строк где угодно, но другие языки и парсеры строже к пробелам и к длине строк, так что подстраивайтесь под соглашение канала, а не под настроение Go-рантайма.
Обратная сторона
Вот и вся сторона кодирования истории: один метод, который не может упасть, четыре кодировщика, подобранных под каналы, по которым будут путешествовать их строки, потоковый кодировщик с одним обязательным Close и формат, который расширяет ваши данные на треть и никогда-никогда не переносит свои строки. Выбирайте алфавит от пункта назначения, закрывайте кодировщики, делайте арифметику размера заранее - и base64 в Go остаётся тихой утилитой с нулём зависимостей, какой она была с 2009 года.
И когда трафик разворачивается, когда ваша программа получает одну из этих строк и должна её открыть, связанная статья о декодировании Base64 в Go подробно разбирает ту сторону: правила терпимости декодера, смещения ошибок, которые говорят, на каком байте вход ломается, строгий режим для придирчивых протоколов и те же четыре кодировки, но с другой стороны.
Последнее обновление: 2026-09-08
Связанная статья: Декодирование Base64 в Go: полное руководство