Продолжаю серию статей по программированию на 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.

Комментарии (18)


  1. anonymous
    00.00.0000 00:00


  1. sashamelentyev
    02.03.2022 16:13
    +1

    Не смотрели в сторону entgo.io?


    1. Nurked
      02.03.2022 17:58

      Хм. Интересно, а чем обосновано ваше предложение? Я вот, прочитал статью и в ней автор пишет "не пользуйтесь фреймворками, себе дороже будет". Это если так выжать смысл. А вы в ответ постите ссылку на фреймворк.


      1. sashamelentyev
        02.03.2022 21:14
        +1

        Согласен. Не обдумано предложил. Вы правы.
        На первый взгляд то что реализует описанный инструмент - пробежала ассоциация именно с тем инструментов ссылку на который оставил


  1. andreyiq
    02.03.2022 18:11

    Мы просто в бд создали для всего хранимые процедуры и написали сервис который обрабатывает рест запросы, по url определяется какую процедуру дергать, json конвертится во входные параметры для процедуры. Т.е. получился простой конвертер json->sql->json. Для всех хранимок генерится дока под сваггер. Служба очень маленькая на пару тысяч строк кода, зато больше не нужно писать на го, достаточно двух человек, один под sql пишет, второй фронт.


    1. Tatikoma
      02.03.2022 21:35

      Так, а если в базе поле с типом bytea и я хочу в него записать \x80 - как будет работать ваша API?


      1. andreyiq
        02.03.2022 21:54

        В чем проблема?


        1. Tatikoma
          02.03.2022 22:11

          В JSON. Как по-вашему будет выглядеть передача \x80 в JSON ?


          1. andreyiq
            03.03.2022 06:17

            Вы про всякие blob'ы? Они в base64 передаются


            1. Tatikoma
              03.03.2022 12:18

              Нет, blob - это binary large object. А тут один байт - \x80. Он тоже в base64? - Ну окей. Тогда у вас конвертер не в JSON, а в base64 + JSON. Костыли )


              1. andreyiq
                03.03.2022 12:28

                Если тип byte, то это Number в json, соответственно просто число передаться. Там сопоставление sql типов с json типами, соответственно простые типы передаются как есть, бинарные как base64. В json по другому не получиться. Go точно также конвертирует структуры в json


  1. neuterm
    02.03.2022 18:28

    Нет интересного опыта интеграции Golang и OracleDB?


    1. vtolstov
      03.03.2022 16:57

      А что там? есть gordor+sqlx вполне хватает. Или тут речь в контексте ORM ?


  1. vtolstov
    03.03.2022 11:20

    Куча конкатенаций строк при каждом вызове, создание мапов, никаких prepared statements, для продакшена - не вариант.


    1. Nurked
      03.03.2022 15:47

      Я помню как писал свой первый фреймворк на дотнете в 2005 году. К нам приходил препод в МЭСИ и говорил святую мантру:

      Все запросы должгы быть prep statements! Иначе - капут.

      С тех пор я всегда так и делал. А вот в 2017 году мне довелось переносить проект из SQL 2000 на SQL 2017. И я решил, что пришла пора подтвердить святые слова. Начал переносить всё на prep statements. Тут ко мне подошёл архитектор БД и спросил нафига я это делаю? Ну я давай оюъяснять и всё такое. А он мне в ответ: забей, мы не в 2001 году. БД сама разберётся.

      Я замерил производительность, и что бы вы думали? MSSQL отлично автоматом загонял однотипные запросы в кеш. Скорость была идентичной. Да и переносить логику в БД не надо.


      1. vtolstov
        03.03.2022 15:51

        Мой коммент нужно читать целиком, все слова связаны. =)

        Я согласен, что на текущий момент производительность может быть и примерно одинаковая, но поток трафика от клиента бд к серверу все же влияет. И тем более, если средства бд языка и тп позволяют общаться более эффективно, зачем упускать эту возможность? Большинство запросов это не короткая строчка из 20-50 символов. В приложениях зачастую один запрос на 50 строк. Зачем его слать постоянно на сервер бд?


        1. Tatikoma
          04.03.2022 16:48

          Затем что у тебя может быть кластер баз и твой prepared statement существует только в рамках одного инстанса. И по хорошему ты не должен знать на какой инстанс идёшь, потому что инстанс для тебя выбирает отказоустойчивый прокси, для большей производительности находящийся в режиме transaction pooling. Поэтому ты так или иначе каждый раз должен слать запрос.


          1. vtolstov
            04.03.2022 16:56

            ну что там за прокси это вопрос, зачем знать инстанс ?

            я все равно не вижу, почему должно быть оправданным выделение памяти каждый раз на конкатенацию кучи строк, про прокси сейчас не скажу, но pgbouncer норм работает с этим