Облегчаем работу с SQL в go и при этом не отстреливаем себе ноги
Продолжаю серию статей по программированию на Golang, в которой буду рассказывать о том, как упростить себе жизнь. В то же время я уделю достаточно большое количество внимания тому, как при этом не утонуть в бесконечном количестве ненужных делу фреймворков.
Давайте поглядим, что мы можем сделать с такой занудной и громоздкой процедурой, как написание кода для обработки SQL запросов. В небольших проектах с парой таблиц можно не заморачиваться. Вот тебе пять запросов, вот они проводятся и всё хорошо.
В проектах побольше мы уже садимся и задумываемся о том, а не пора ли подключать GORM и начинать делать всё по-серьёзному. Только вся серьёзность может закончиться, когда вы будете сидеть перед базой данных и пытаться понять, почему же выборка одного пользователя уносит продакшн сервер в очень глубокую задумчивость минуты на две.
С точки зрения контроля своей собственной программы, выход всегда был только один — SQL, который надо писать самому. Как бы то ни было и каким бы языком мы ни пользовались, писать SQL самому — это всегда крайне занудно. Можете посмотреть на статистику: попробуйте найти хоть один язык программирования из ТОП-50 в StackOverflow, в котором нет какой-нибудь ORM системы.
Подобные системы приходят к нам из мира ООП языков. Первые подобные проекты появились ещё в 1995 году в Smalltalk и C++, позже они были портированы в Java, а в этом монстре Hibernate живёт уже с 2001 года.
Но мы-то знаем, что golang — это не совсем объектно-ориентированный язык. И вместе с этим не совсем ООП языком мы можем смело выкинуть кучу вещей, которые считаются «нужными» в ООП языках.
Итак, давайте посмотрим, что же можно сделать вместо того, чтобы засовывать непонятно чьи компоненты в свой код.
В этом материале я хотел бы показать вам одну утилиту, которую я нашёл на просторах интернета и которая очень бодро помогла мне с SQL в го.
Встречайте — Gocode.
Мы уже решили, что не хотим впихивать неизвестные пакеты в наш код, но и не горим желанием писать всё с нуля. Для решения этой проблемы будем просто генерировать необходимый код.
Для начала сливаем код, компилируем его, делаем go install
и убеждаемся, что он у нас исполнился.
export PATH="$HOME/go/bin:$PATH"
После этого у нас должен стать доступным бинарник gocode. Эта программка пишет код на golang за вас. Всё достаточно просто, ибо следование стандартам правильного кода на golang — это не сложно. Поэтому и генерировать код не такая проблемная задача.
Давайте возьмём любую структуру и опишем её в каком-нибудь пакете.
type Pool struct {
PoolID string `json:"pool_id" toml:"pool_id" db:"pool_id"` // 05za6dbq0n6t5jytnjdt247af0
Address string `json:"address" toml:"address" db:"address"` // 10.22.1.42
AddressFamily string `json:"address_family" toml:"address_family" db:"address_family"` // ipv4
Path string `json:"path" toml:"path" db:"path"` // /dev/tars_05za6dbq0n6t5jytnjdt247af0
Free uint64 `json:"free" toml:"free" db:"free"`
Size uint64 `json:"size" toml:"size" db:"size"`
TypeCode string `json:"type_code" toml:"type_code" db:"type_code"` // nvme, copied from storage_host, could be removed but might be useful when querying
}
Тут я хотел бы сказать, что в данном случае будет разумным держать все определения структур, с которыми работает ваша программа, в одном пакете.
Как видите, я просто создал стандартную структуру и прописал теги для работы с toml, JSON и базой данных. Один и тот же тип будет передаваться через Rest API в виде JSON, писаться в TOML и храниться в БД.
Для gocode вам нужно создать тег db
. Тут единственным условием будет то, что вы должны назвать поле идентификатора %StructName%+ID
. Остальное — на ваше усмотрение.
После этого мы готовы запустить gocode и посмотреть, что нам нагенерируется:
gocode_sqlcrud -package sqlstore -type Pool
Ну, для начала заметим, что нам добавляется папка для migrations. И в ней мы можем найти…
Собственно, ничего особо нужного мы здесь не найдём. Тут просто один файл для примера. Если вам вдруг захочется делать миграции:
-- +goose Up
-- +goose Down
Выглядит он примерно так. Файл был и будет пустым. Схему базы данных надо писать самому. В данном случае вам предлагается использовать Goose. Но на самом деле, это даже не самое важное. Можно использовать любую систему миграций.
Решение достаточно интересное и простое. Вам просто не надо будет заморачиваться со скриптами миграции, которые сгенерированы автоматически. В начале производственного цикла эти скрипты удобны и всё такое, но когда приходит время менять что-то на проде, то таких систем миграции следует остерегаться.
На пока что — вот вам скрипт, выкладывайте базу данных в него и не парьтесь. На данном этапе можно особо не выдумывать.
Поехали смотреть дальше, что у нас появилось в go коде.
У нас теперь есть маленький файл под названием «sqlutil.go», который просто содержит в себе набор базовых утилит для обработки типов в go и преобразования их в SQL типы.
Если бы вы писали всё это руками, то я бы посоветовал начать именно с этого файла. Возможно, изначально он будет пустым, но со временем все мелкие функции будут накапливаться именно здесь.
Теперь пришло время посмотреть на store.go.
Создание новой структуры для работы с базой данных. Начало и коммит транзакции. Этот тип будет основой для более генерализованных типов, которые будут заниматься работой с конкретными таблицами.
Теперь доберёмся до самого файла работы с нашим типом .pool-store.go.
Тут у нас есть один подводный камень. Скорее всего, опечатка в исходном коде. Я сейчас создам PR для того, чтобы её починили. Ну да это мелочь.
В этом сгенерированном коде:
// tableName returns the name of the table.
func (s *Store) tableName() string {
return "mounted_storage_partition"
}
Нужно заменить s *Store на s *PoolStore
Ок. Продолжаем осматриваться:
type PoolList []Pool
Тут для простоты мы просто обзываем список наших экземпляров типа как…List.
Далее мы можем видеть интерфейс PoolResulter, который будет нужен для получения результатов запросов Select. Об этом чуть ниже.
Выборка по ID производится просто и незатейливо:
func (s *PoolStore) SelectByID(ctx context.Context, vPoolID string) (*Pool, error) {
var ret Pool
ctx, tx, txCreated, err := s.ctxTxx(ctx)
if err != nil {
return nil, err
}
if txCreated {
defer tx.Rollback()
}
sqlText := "SELECT " + strings.Join(dbFieldQuote(dbFieldNames(&ret)), ",") +
" FROM `" + s.tableName() + "` WHERE " + strings.Join([]string{
" `mounted_storage_partition_id` = ?",
}, ",")
err = tx.GetContext(ctx, &ret, sqlText, vPoolID)
if err != nil && errors.Is(err, sql.ErrNoRows) {
err = &ErrNotFound{err: err}
}
if err != nil {
return nil, err
}
if txCreated {
return &ret, tx.Commit()
}
return &ret, err
}
Кстати, обратите внимание на наличие указателя на CTX. Весь сгенерированный код поддерживает транзакции.
Давайте теперь посмотрим на то, как мы можем выбирать записи из БД:
/ Select runs the indicated query and loads it's return into result.
// Offset is the number of rows to skip, limit is the maximum to return (after any skip/offset).
// The criteria map is converted into a SQL WHERE clause (see sqlFilter in this package).
// The orderBy slice is converted into a SQL ORDER BY clause (see sqlSort in this package).
// Records are struct scanned and then passed into the appropriate method on result.
// Note that for more complex query needs it is recommended you add a custom select function
// instead of trying to adapt this one to every use case.
func (s *PoolStore) Select(ctx context.Context, offset, limit int64, critiera map[string]interface{}, orderBy []interface{}, result PoolResulter) error {
Сигнатура этого метода достаточно обычна для любых методов Select, которые пытаются получить всё и за один раз. У нас есть skip и limit, которые позволяют выбрать определённое количество результатов. Естественно, это ваш пропуск в мир пагинаторов. После чего вы можете передать набор параметров для самого запроса.
Например, если вам нужно получить что-то с storage_partition_id = 10, вы добавите такую структуру в запрос:
err := s.SQL.Pool().Select(ctx, 0, 0, map[string]interface{}{"storage_partition_id": 10}, nil, &r)
Ну и, естественно, в конце мы передаём ссылку на Resulter. Это может быть либо просто указатель на PoolList, либо указатель на PoolResulter.
// PoolList is a slice of Pool with relevant methods.
type PoolList []Pool
// PoolResult implements PoolResulter by adding
// to the slice.
func (l *PoolList) PoolResult(o Pool) error {
*l = append(*l, o)
return nil
}
// PoolResulter can receive Pool instances as they
// are streamed from the underlying data source.
type PoolResulter interface {
PoolResult(Pool) error
}
Тут всё, опять же, тривиально. Если мы передали ссылку на PoolList, то система просто вернёт нам список. А если мы создали свою собственную функцию-resulter, то мы можем сделать что угодно с получившимся результатом.
В случае если вы получаете 2 миллиона записей из SQL и пытаетесь их одновременно с этим обработать, вы можете просто запихнуть вашу обработку в resulter. Тогда вам не придётся мучиться с тем, что надо сначала разобрать SQL запрос, создать из него объекты, а после этого запихнуть всё в slice только для того, чтобы потом идти и проходить по этому slice ещё раз.
Обратите внимание на комментарий в начале метода Select:
// Note that for more complex query needs it is recommended you add a custom select function
// instead of trying to adapt this one to every use case.
Если вам нужна более сложная логика в Select, то просто напишите ещё один Select прямо в этом файле. Не пытайтесь приспособить этот Select для всего в мире.
После этого вы сможете найти все стандартные методы, которые вероятны в подобном коде. Cursor, Count, Insert, Delete и так далее.
Теперь идём дальше…
Собственно говоря, ходить дальше и не надо. На самом деле, у вас уже есть на руках весь код, который вам нужен для того, чтобы работать с вашей сущностью в БД.
В самом коде открываем соединение к базе данных и создаём объект store:
mdb, err := sql.Open("mysql", c.APIConfig.ConnString)
if err != nil {
log.Warn("Can't connect to the database")
}
sqlstr, err = sqlstore.NewStore(mdb, "mysql")
if err != nil {
log.Warn("Can't connect to the database")
}
После чего открываем транзакцию простым:
err = sqlstr.SQL.RunTxx(r.Context(), func(ctx context.Context) error {
np, err := s.SQL.Pool().SelectByID(ctx, p.PoolID)
if err != nil {
return fmt.Errorf("can't find storage partition with the id %s , %w", p.PoolID, err)
}
err = s.SQL.Pool().Select(ctx, 0, 0, map[string]interface{}{"partition_id": reqParam.PartitionID}, nil, &r)
if err != nil {
return err
}
s.SQL.Pool().Delete(ctx, reqParam.PartitionID)
})
Идея с транзакциями очень проста. Вы просто создаёте CTX и передаёте его в запросы. Если в какой-либо момент вы возвращаете err внутри SQL.RunTxx, то вся транзакция откатывается и никакие изменения не сохраняются. Либо если у вас нет ошибок, то всё сохраняется в базу данных.
Итоговый код достаточно прост.
Теперь нам надо посмотреть на пару файлов, которые мы обошли вниманием:
store_test.go
pool-store_test.go
Все мы знаем, что тесты писать надо, и все мы не хотим эти тесты писать.
Ну что же, gocode уже написал тесты на docker за нас. Будет скачана последняя версия mysql, и ваш код будет протестирован как полагается. А если вам захочется дописать ещё тестов, то вы всегда можете добавить больше данных.
Генератор написан в очень приятной манере, он никогда не перезапишет ваши изменения. Если вы создали код для работы с определённым типом, то повторная генерация кода не приведёт ни к чему. Поэтому вы можете смело менять код, который произвёл для вас gocode.
Казалось бы, игрушка. Но по факту это как раз не игрушка. Сейчас большой проприетарный проект на 150 таблиц отлично живёт, обслуживаемый скромной маленькой утилитой.
Почему? Потому что в этом проекте мы не стремились сделать так, чтобы программист мог сесть и написать код за 2 секунды. Как раз наоборот.
Когда кто-то приходит в проект, то первым делом мы сажаем его учиться. Он садится и разбирается в том, как работает наша база данных. Мы проверяем, что он понимает, что такое SQL синтаксис, и что он его не избегает.
Я увидел интересную тенденцию. Непонимание таких базовых вещей, как LEFT OUTER JOIN, или RIGHT INNER JOIN, приводит к тому, что люди начинают избегать самого SQL. Да, согласен, этот язык был сделан в 80-х годах и на самом деле он стар, и всё такое. Но это не значит, что вы, как программист, не должны уметь им пользоваться. Причём пользоваться уверенно.
После того как новички привыкли к базе данных, мы показываем им этот генератор кода, и они привыкают к тому, как мы работаем с SQL на golang. Вкратце это можно описать одним словом: «руками».
Почему?
Мы собираем бесконечно сложные микросервисные архитектуры и переписываем их в очень быстрые монолиты. Программы становятся всё более эффективными, и некоторые из моих пользователей радуются 300% падению нагрузок на процессор.
Да, возможно, что ПО нужно писать быстро. Но это не значит, что его не нужно писать качественно и с пониманием того, что и как в нём работает. Не стоит полагаться на популярные фреймворки только потому, что все так делают.
Полагаться стоит на ваше собственное умение понимать код и писать его качественно. Это занимает больше времени. Но при этом, когда счета за облачные платформы сжимаются до четырёхзначных сумм, бизнес идёт веселее, а мы получаем большие премии.
А какие решения применяете вы?
НЛО прилетело и оставило здесь промокоды для читателей нашего блога:
— 15% на все тарифы VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
— 20% на выделенные серверы AMD Ryzen и Intel Core — HABRFIRSTDEDIC.