5.10. Работа с БД
Работа с базами данных в языке Go
Язык Go, разработанный в Google, позиционируется как инструмент для создания высокопроизводительных, масштабируемых и надёжных backend-систем. Одно из центральных мест в таких системах занимает работа с данными, в том числе с базами данных. Подход Go к этой задаче сочетает в себе низкоуровневый контроль и высокоуровневую абстракцию. Он не навязывает единой парадигмы — вместо этого предоставляет гибкую экосистему, где разработчик может выбирать инструменты в зависимости от требований к производительности, сложности доменной модели, объёма данных и архитектурных ограничений.
Прежде чем рассматривать конкретные инструменты взаимодействия с базами данных, целесообразно разобрать, как в Go организованы более фундаментальные уровни работы с данными: с файлами, с оперативной памятью и с внешними системами хранения. Это позволит построить логически выстроенную иерархию понятий.
Файловая работа в Go
В языке Go операции с файлами осуществляются через пакет os, дополненный функциональностью из io, bufio, io/ioutil (устаревший, заменён на os и io в Go 1.16+) и path/filepath. Объект *os.File реализует интерфейсы io.Reader, io.Writer, io.ReadWriteCloser, что делает файлы естественной частью общей системы потоков ввода-вывода.
Файл в Go — это не просто набор байтов на диске. Это ресурс ядра операционной системы, представленный дескриптором, жизненный цикл которого требует явного управления. Открытие файла через os.Open, os.Create или os.OpenFile возвращает указатель на структуру *os.File и ошибку. Закрытие файла через file.Close() освобождает дескриптор и гарантирует сброс буферизованных данных (в случае записи). Пропуск вызова Close() не вызовет немедленного сбоя, но может привести к утечке ресурсов, особенно при массовом открытии файлов (например, при обработке логов или импорте данных).
Работа с файлами в Go строится вокруг последовательного чтения и записи блоков данных. Для повышения эффективности применяются буферизованные обёртки: bufio.Reader и bufio.Writer. Они минимизируют количество системных вызовов, позволяя читать или писать данные крупными фрагментами, даже если логика приложения оперирует мелкими порциями. Например, чтение по строкам через bufio.Scanner или bufio.Reader.ReadString — это управляемая буферизация с внутренней логикой предзагрузки.
Ключевой особенностью Go является отсутствие глобального состояния в файловом API. Каждая операция явно принимает файловый дескриптор или его обёртку. Это исключает скрытые побочные эффекты, упрощает тестирование и соответствует принципу явного управления ресурсами.
Хотя файлы редко используются в production как основной механизм хранения структурированных данных, они играют важную роль в подготовке, выгрузке и бэкапе. Например, экспорт таблицы в CSV или JSON для передачи в аналитические системы, запись логов аудита, временные файлы для промежуточных вычислений — все эти сценарии опираются на стабильный и предсказуемый файловый API Go.
Работа с данными в памяти: типы, интерфейсы и сериализация
Перед тем как данные попадут в базу, они существуют в оперативной памяти процесса. Go предлагает строго типизированную систему представления данных: примитивные типы (int, float64, string, bool), составные (struct, slice, map, array, chan), а также указатели и интерфейсы. Особенно важно понимание роли struct — это основная единица доменной модели. Структура в Go объявляется как именованный набор полей, и может включать теги (tags), которые используются рефлексией для сериализации, валидации или отображения в СУБД.
Теги — это строковые аннотации в виде json:"name,omitempty" или db:"user_id". Они не влияют на компиляцию, но становятся доступны во время выполнения через пакет reflect. Именно теги связывают структуру в коде с внешними форматами: JSON для API, XML для legacy-интеграций, CSV для отчётов, и — критически — с колонками таблиц в реляционных базах данных.
Сериализация и десериализация — неотъемлемая часть работы с данными. В Go она реализуется через интерфейсы encoding/json.Marshaler/Unmarshaler, encoding/xml, encoding/gob, а также сторонние библиотеки вроде github.com/goccy/go-yaml или github.com/segmentio/encoding/avro. Интерфейсный подход позволяет писать обобщённый код: если тип реализует json.Marshaler, его можно передать в json.Marshal, не зная конкретной реализации. Это — основа для создания гибких конвейеров преобразования данных.
Типобезопасность Go проявляется и при работе с коллекциями. Срезы (slice) — это динамические массивы с известной длиной и вместимостью. Карта (map[K]V) — ассоциативный массив, гарантирующий O(1) в среднем для операций поиска и вставки. Оба типа передаются по значению (копируется заголовок), но данные внутри — общие. Это требует осторожности при мутации: изменение среза внутри функции может повлиять на вызывающую сторону, если не скопированы данные явно.
Важно отметить, что Go не имеет встроенной поддержки null-значений для примитивных типов (в отличие от SQL). Отсутствие значения выражается через нулевые значения (zero value): 0 для чисел, "" для строк, false для булевых. Для моделирования отсутствующих данных используются указатели (*string, *int) или специальные типы вроде sql.NullString. Это — осознанный дизайн: он заставляет программиста явно обрабатывать отсутствие данных, снижая вероятность скрытых ошибок.
Работа с базами данных: уровни абстракции
Подход Go к базам данных можно описать как «стандартизированный низкоуровневый слой + разнообразные высокоуровневые надстройки». Ядро этой системы — пакет database/sql, входящий в стандартную библиотеку. Он не содержит драйверов, но определяет общий интерфейс для взаимодействия с СУБД через понятия драйвера, соединения, запроса, транзакции и набора результатов.
Пакет database/sql: контракт, а не реализация
Пакет database/sql предоставляет следующие ключевые абстракции:
sql.DB— это пул соединений. Он потокобезопасен и предназначен для долгоживущего использования (рекомендуется создавать один экземпляр на приложение и передавать его в компоненты). МетодыOpen,Ping,Query,Exec,Beginи т.д. работают через этот пул.sql.Conn— конкретное соединение, выделенное из пула. Используется редко, в основном для сценариев, требующих привязки к одному соединению (например, временные таблицы в PostgreSQL).sql.Tx— транзакция. Предоставляет те же методыQueryиExec, но выполняет их в контексте одной транзакции.sql.Rowsиsql.Row— результаты запроса.Rows— итерируемый поток строк, требующий явного закрытия.Row— результат единственной строки (возвращаетсяQueryRow).
Важно: sql.DB лениво инициализирует соединения. Вызов sql.Open("driver", "dsn") не проверяет доступность базы — это лишь парсинг строки подключения (DSN, Data Source Name). Проверка происходит при первом запросе или при вызове Ping.
Для работы с конкретной СУБД необходим драйвер, реализующий интерфейсы, определённые в database/sql/driver. Драйвер регистрируется через sql.Register, но чаще используется через импорт с побочным эффектом (_ "github.com/lib/pq" для PostgreSQL). После регистрации имя драйвера (например, "postgres") используется в sql.Open.
Пример минимального подключения:
import (
"database/sql"
_ "github.com/lib/pq"
)
db, err := sql.Open("postgres", "host=localhost user=timur dbname=universe sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close() // хотя для долгоживущего db.Close() обычно не вызывается
if err := db.Ping(); err != nil {
log.Fatal("failed to connect to DB:", err)
}
Запросы строятся как строки, параметры передаются отдельно — через плейсхолдеры ($1, ?, :name в зависимости от СУБД). Это обеспечивает защиту от SQL-инъекций:
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = $1", 42).Scan(&name)
Сканирование результата в переменные требует строгого соответствия типов. Пакет sql поддерживает преобразование между SQL-типами и Go-типами, но не всегда идеально. Например, BIGINT в PostgreSQL отображается в int64, а TIMESTAMP — в time.Time. Для сложных типов (геоданные, JSONB, массивы) часто требуется кастомная логика или использование расширенных драйверов.
Преимущества и ограничения database/sql
Преимущества:
- Минимальная зависимость (только стандартная библиотека + драйвер).
- Полный контроль над SQL.
- Прямая поддержка транзакций, prepared statements, пула соединений.
- Стабильность и предсказуемость поведения.
Ограничения:
- Отсутствие типобезопасности на уровне запросов (строки SQL — это
string, ошибки обнаруживаются только в runtime). - Шаблонный код для сканирования (
rows.Scan(&a, &b, &c...)). - Нет встроенной поддержки миграций, схемы, ленивой загрузки.
- Ручное сопоставление структур и таблиц.
Эти ограничения порождают потребность в более высокоуровневых инструментах.
СУБД и Go: особенности интеграции
Хотя пакет database/sql стандартизирует интерфейс, каждый драйвер имеет свои особенности. Это связано с тем, что СУБД различаются по протоколам, типам данных и расширениям.
- PostgreSQL (
lib/pq,pgx) — наиболее популярная СУБД в Go-экосистеме. Драйверpgxсчитается более современным и производительным, чемlib/pq, особенно при использовании нативного интерфейса (безdatabase/sql). Поддерживает JSONB, массивы, NOTIFY/LISTEN, расширения (PostGIS). - MySQL (
go-sql-driver/mysql) — стабильный драйвер с поддержкой prepared statements, SSL, custom types. Требует внимания к кодировкам и режиму строгих SQL-модов. - SQLite (
mattn/go-sqlite3) — встраиваемая СУБД, удобна для тестов, локальных приложений, CLI-утилит. Часто используется в связке сsqlmockдля unit-тестов. - Microsoft SQL Server (
denisenkom/go-mssqldb) — поддерживает Windows-аутентификацию, TVP (табличные параметры), типыdatetime2,uniqueidentifier.
Кроме реляционных СУБД, Go активно используется с NoSQL-хранилищами: MongoDB (официальный драйвер), Redis (go-redis/redis), Elasticsearch (olivere/elastic), Cassandra (gocql). Эти драйверы не используют database/sql и предоставляют собственные API, оптимизированные под особенности хранилища.
ORM в Go: концепция и контекст
ORM (Object-Relational Mapping) — это паттерн программирования, направленный на устранение импедансного несоответствия между объектно-ориентированной моделью в коде и реляционной моделью в базе данных. В идеале ORM позволяет работать с таблицами как с коллекциями объектов, а с колонками — как со свойствами структур.
Однако в экосистеме Go отношение к ORM неоднозначно. Причины:
- Go — не объектно-ориентированный язык в классическом смысле (нет наследования, инкапсуляции в стиле Java/C#).
- Прямой SQL часто проще, быстрее и понятнее, особенно для сложных JOIN’ов или аналитических запросов.
- Некоторые ORM вводят избыточную магию (автогенерация SQL, неявные запросы), что нарушает прозрачность и усложняет отладку.
Тем не менее, для приложений с простыми доменными моделями, CRUD-сценариями и требованием быстрой разработки ORM остаются востребованными. Они ускоряют написание шаблонного кода, обеспечивают типобезопасность (частично), интегрируют миграции и валидацию.
Ключевые характеристики современного ORM в Go:
- Работа поверх
database/sql(надстройка). - Поддержка тегов (
db:"column_name"). - Генерация DDL-запросов для миграций.
- Поддержка транзакций и контекстов (
context.Context). - Возможность падать до «сырого» SQL, когда ORM недостаточно.
GORM: баланс между удобством и контролем
GORM (gorm.io/gorm) — самый распространённый ORM для Go на 2025 год. Он сочетает простоту для базовых операций с расширяемостью для сложных сценариев. Версия 2 (актуальная) переписана с нуля и предлагает чистый API, оптимизированный под Go 1.18+ и generics.
Основные принципы GORM
-
Декларативная модель данных. Структура Go с тегами описывает схему таблицы:
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;not null"`
Email string `gorm:"uniqueIndex"`
CreatedAt time.Time
UpdatedAt time.Time
}Поле
IDавтоматически становится первичным ключом,CreatedAtиUpdatedAt— автоматически управляются (soft delete — отдельная опция). Тегgormпозволяет задавать имя колонки, ограничения, индексы, типы. -
Автоматическое создание схемы. Метод
AutoMigrateгенерируетCREATE TABLE IF NOT EXISTSна основе структур:db.AutoMigrate(&User{}, &Post{})Это удобно для прототипирования, но не рекомендуется в production без контроля миграций.
-
Поддержка миграций. GORM предоставляет инструмент
gormigrate(отдельный пакет) для управления версиями схемы через идемпотентные шаги — как вalembicилиLiquibase. -
Универсальный метод
Save. Определяет, вставлять (INSERT) или обновлять (UPDATE) запись по наличию первичного ключа. Для явного выбора —Create,Update,Updates. -
Ассоциации. Поддержка
Has One,Has Many,Belongs To,Many To Manyчерез теги и предзагрузку (Preload):type Post struct {
ID uint
Title string
UserID uint
User User `gorm:"foreignKey:UserID"`
Tags []Tag `gorm:"many2many:post_tags;"`
}
// Загрузка поста с пользователем и тегами
db.Preload("User").Preload("Tags").First(&post, 1)Это — ленивая загрузка в классическом понимании (eager loading, а не lazy), так как Go не поддерживает прокси-объекты. Все данные загружаются явно в момент вызова
Preload. -
Транзакции. Через
TransactionилиBegin/Commit/Rollback:db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
if err := tx.Create(&post).Error; err != nil {
return err
}
return nil
}) -
Контекст и таймауты. Полная поддержка
context.Contextво всех операциях:ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
db.WithContext(ctx).Find(&users)
Производительность и отладка
GORM не скрывает SQL. Включив логирование (db.Logger = logger.Default.LogMode(logger.Info)), можно видеть генерируемые запросы, параметры и время выполнения. Это критически важно для выявления N+1 проблем или избыточных запросов.
Для высоконагруженных сценариев GORM позволяет:
- Использовать
Selectдля указания загружаемых полей. - Пакетную вставку (
CreateInBatches). - Обновление только изменённых полей (
Update("Status", "active")вместоSave). - Падать до «сырого» SQL через
db.Exec,db.Raw,db.Session(&gorm.Session{SkipHooks: true}).
Применение в архитектуре
GORM успешно применяется в:
- Микросервисах с доменом средней сложности (CRM, учётные системы, внутренние инструменты).
- Веб-приложениях на Gin, Echo, Fiber, где основные операции — CRUD.
- CLI-утилитах для администрирования БД (например, импорт данных с валидацией).
Он не рекомендуется для:
- Систем с экстремальными требованиями к latency (где каждый микросекундный оверхед критичен).
- Приложений, где доменная логика требует сложных оконных функций, CTE, кастомных агрегатов.
- Сценариев, где схема БД управляется внешней системой (например, legacy-база без прав на DDL).
В таких случаях предпочтителен «тонкий слой» поверх database/sql — например, sqlc (генерация типобезопасного кода из SQL-запросов) или ручное написание DAO с интерфейсами и моками.
Альтернативные подходы к работе с БД в Go
Выбор инструмента для взаимодействия с базой данных определяется соотношением стоимости разработки, стоимости сопровождения и требований к производительности. ORM, включая GORM, снижает порог входа и ускоряет CRUD-разработку, но вносит абстрактную нагрузку и ограничивает контроль. В Go развито несколько иных парадигм.
Генерация кода из SQL: sqlc
Проект sqlc (github.com/sqlc-dev/sqlc) представляет собой радикально иной подход: вместо того чтобы писать код, который генерирует SQL, разработчик пишет SQL-запросы вручную — а инструмент генерирует типобезопасный Go-код, способный их выполнять.
Процесс выглядит так:
-
В YAML-файле (
sqlc.yaml) указывается схема базы данных (через DDL-скрипты) и пути к SQL-файлам. -
В SQL-файле размещаются запросы с метками:
-- name: GetUser :one
SELECT id, name, email, created_at
FROM users
WHERE id = $1;
-- name: ListUsers :many
SELECT id, name, email
FROM users
ORDER BY id
LIMIT $1 OFFSET $2; -
Выполняется команда
sqlc generate. Инструмент:- Парсит DDL и строит модель метаданных.
- Анализирует каждый запрос: определяет параметры, возвращаемые колонки, кардинальность (
:one,:many,:exec). - Генерирует Go-файл с интерфейсом
Querier, структурами для параметров и результатов, и реализацией этих методов поверхdatabase/sql.
Результат — код вида:
type Querier interface {
GetUser(ctx context.Context, id int32) (User, error)
ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error)
}
func (q *Queries) GetUser(ctx context.Context, id int32) (User, error) {
row := q.db.QueryRowContext(ctx, getUser, id)
var i User
err := row.Scan(&i.ID, &i.Name, &i.Email, &i.CreatedAt)
return i, err
}
Преимущества sqlc:
- Полный контроль над SQL. Можно использовать оконные функции, CTE, JOIN’ы любой сложности, кастомные типы (например,
hstore,jsonb). - Типобезопасность на уровне компиляции: ошибка в имени колонки или типе параметра приведёт к ошибке сборки.
- Отсутствие runtime-рефлексии — производительность эквивалентна ручному написанию DAO.
- Чёткое разделение: SQL — в
.sql-файлах (можно проверять lint’ерами, подсвечивать в редакторе), логика — в Go.
Ограничения:
- Требует дисциплины: нужно поддерживать DDL-файлы в актуальном состоянии.
- Не поддерживает автоматическую генерацию DDL (миграции — внешняя задача).
- Нет встроенной поддержки ассоциаций; для связанных сущностей нужны отдельные запросы и ручная сборка графа объектов.
sqlc особенно эффективен в проектах с высокой долей аналитических запросов, в системах, где схема БД управляется отдельно (например, DBA-командой), и в микросервисах, где важна предсказуемость latency.
Декларативные ORM: Ent
Проект ent (entgo.io/ent) позиционируется как entity framework для Go. Он генерирует методы доступа и всю доменную модель — включая типы, методы, валидацию и отношения — на основе декларативного описания схемы на Go.
Схема описывается как Go-код:
// ent/schema/user.go
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Default("unknown"),
field.String("email").
Unique(),
}
}
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("posts", Post.Type),
}
}
Запуск go generate ./... порождает:
- Структуры
User,Post. - Методы
Create(),Update(),Query(),Delete(). - Цепочки запросов:
client.User.Query().Where(user.EmailEQ("t@example.com")).WithPosts().All(ctx). - Поддержку GraphQL (опционально).
В отличие от GORM, ent не использует теги и рефлексию. Весь код генерируется статически. Это даёт:
- Гарантированную типобезопасность.
- Возможность кастомизации через шаблоны генерации.
- Встроенную валидацию (например,
Match(regexp)на полях). - Продвинутую поддержку индексов, ограничений, расширений.
ent требует больше первоначальных усилий, но окупается в долгосрочной перспективе для проектов со сложной доменной моделью и высокими требованиями к целостности данных.
Минималистичные обёртки: upper/db, xo
-
upper/db— лёгкая надстройка надdatabase/sql, предлагающая fluent-интерфейс без ORM-магии. Пример:
sess.Collection("users").Find(db.Cond{"email": "t@example.com"}).One(&user)
Подходит для команд, ценящих явность, но желающих избежать ручногоScan. -
xo— генератор кода, похожий наsqlc, но работающий напрямую с метаданными СУБД (черезinformation_schema). Не требует DDL-файлов, но менее гибок в описании запросов.
Выбор между этими инструментами — вопрос баланса: чем выше контроль, тем выше стоимость разработки. В production-системах часто используется гибрид: например, sqlc для сложных запросов и GORM для простых сущностей.
Тестирование работы с базой данных
Тестирование кода, зависящего от внешнего хранилища, требует изоляции. В Go выделяют три основных стратегии.
Unit-тесты с моками: sqlmock
Библиотека sqlmock (github.com/DATA-DOG/go-sqlmock) позволяет заменить реальное соединение с БД на контролируемый мок. Она реализует интерфейсы driver.Conn, driver.Stmt, driver.Rows, что делает её совместимой с любым кодом на основе database/sql.
Пример:
func TestGetUser(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectQuery("SELECT (.+) FROM users WHERE id = \\$1").
WithArgs(42).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
AddRow(42, "Timur"))
repo := NewUserRepository(db)
user, err := repo.GetUser(context.Background(), 42)
require.NoError(t, err)
assert.Equal(t, "Timur", user.Name)
assert.NoError(t, mock.ExpectationsWereMet())
}
Плюсы:
- Высокая скорость: тесты не требуют БД.
- Точный контроль над поведением: можно смоделировать ошибки, пустые результаты, задержки.
- Подходит для тестирования логики обработки ошибок.
Минусы:
- Не проверяет корректность SQL (синтаксис, план выполнения).
- Сложно моделировать состояние транзакций и параллельный доступ.
- Риск расхождения: мок ведёт себя иначе, чем реальная СУБД.
Интеграционные тесты
Для проверки реального SQL и поведения драйвера используют интеграционные тесты с локальной или встраиваемой СУБД.
-
SQLite в режиме in-memory — самый популярный выбор. Запускается мгновенно, не требует сетевого взаимодействия, изолирован. Пример инициализации:
db, err := sql.Open("sqlite3", "file:test.db?mode=memory&cache=shared")Подходит для тестирования базовых CRUD-операций и транзакций. Не имитирует особенности PostgreSQL/MySQL (например, поведение
SERIALIZABLEизоляции). -
Testcontainers (
testcontainers/testcontainers-go) — запускает контейнер с реальной СУБД (PostgreSQL, MySQL) через Docker. Гарантирует максимальную близость к production, но замедляет тесты и требует Docker в CI.ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:15",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_DB": "test",
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
},
}
pgContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
Рекомендация: использовать моки для unit-тестов логики, SQLite — для DAO-слоя, Testcontainers — для сквозных сценариев и проверки миграций.
Управление схемой базы данных
Схема БД — часть кода. Её изменения должны быть:
- Версионируемыми.
- Идемпотентными (можно применять повторно без вреда).
- Обратимыми (предпочтительно).
- Независимыми от конкретного инструмента разработки.
В Go нет встроенного инструмента миграций, но есть устоявшиеся решения.
goose
goose (pressly/goose) — один из самых зрелых инструментов. Поддерживает миграции в виде SQL- или Go-файлов.
Пример SQL-миграции (20250101120000_add_users_table.sql):
-- +goose Up
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
-- +goose Down
DROP TABLE users;
Команды:
goose create add_users_table sql— создаёт шаблон.goose up— применяет все неприменённые миграции.goose down— откатывает последнюю.goose status— показывает применённые и ожидающие.
goose хранит историю в служебной таблице goose_db_version. Интегрируется в CI: миграции применяются перед запуском приложения.
migrate
migrate (github.com/golang-migrate/migrate) — альтернатива с более строгим подходом. Поддерживает множество СУБД и источников (локальные файлы, S3, GitHub). Использует двухфайловую схему: 123_name.up.sql и 123_name.down.sql.
Преимущества:
- Потокобезопасность: можно применять миграции из нескольких инстансов одновременно.
- Поддержка блокировок для избежания race condition.
- CLI и библиотека для встраивания.
Практика: как внедрить миграции в проект
-
Выбрать инструмент и зафиксировать его в
go.mod. -
Создать директорию
migrations/. -
Настроить применение миграций при старте приложения (в
main.go):m, err := migrate.New("file://migrations", dbURL)
if err != nil { /* ... */ }
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal("migration failed:", err)
} -
Обязательно проверять миграции в CI на чистой БД.
Важно: миграции — не место для бизнес-логики. Избегайте вызова внешних API, долгих вычислений, неатомарных операций. Если нужно пересчитать данные — делайте это в фоновом воркере после миграции.
Безопасность при работе с БД
Работа с базой данных влечёт за собой ряд рисков. Go предлагает встроенные и внешние механизмы их минимизации.
SQL-инъекции
Главная защита — параметризованные запросы. Все методы database/sql, GORM, sqlc используют их по умолчанию:
// Правильно
db.Query("SELECT * FROM users WHERE name = $1", userInput)
// Опасно: конкатенация строк
db.Query("SELECT * FROM users WHERE name = '" + userInput + "'")
Даже при использовании fmt.Sprintf для построения частей запроса (например, динамическое имя таблицы) — это нарушение безопасности. Для динамических имён используйте белые списки:
validTables := map[string]bool{"users": true, "posts": true}
if !validTables[table] {
return errors.New("invalid table")
}
query := fmt.Sprintf("SELECT * FROM %s WHERE id = $1", table)
Управление секретами
Строки подключения (DSN) никогда не должны храниться в коде. Используйте:
- Переменные окружения (
DATABASE_URL). - Секрет-менеджеры (HashiCorp Vault, AWS Secrets Manager).
- Файлы конфигурации вне VCS (например,
config.local.yamlв.gitignore).
В коде — валидация формата DSN и логирование без учётных данных:
// Перед логированием — очистка пароля
func sanitizeDSN(dsn string) string {
u, _ := url.Parse(dsn)
if u.User != nil {
u.User = url.UserPassword(u.User.Username(), "****")
}
return u.String()
}
Row-Level Security (RLS)
В PostgreSQL и некоторых других СУБД поддерживается RLS — политики на уровне строк. Например, пользователь может видеть только свои записи. Реализуется на уровне БД, но требует корректной передачи контекста (например, user_id) в сессию:
-- В PostgreSQL
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_posts ON posts
USING (user_id = current_setting('app.current_user_id')::int);
В Go перед применением запроса:
_, err := db.Exec(`SET app.current_user_id = $1`, userID)
Это смещает ответственность за безопасность с приложения на СУБД, что повышает надёжность.
Аудит и шифрование
- Аудит: логируйте все операции записи (INSERT/UPDATE/DELETE) с указанием времени, пользователя, IP. Можно использовать триггеры или middleware в Go (например,
GORMhooks). - Шифрование: для особо чувствительных данных (например, персональных) применяйте шифрование на уровне приложения (AES-GCM). Ключи храните отдельно от данных.