Добро пожаловать на мастер-класс по бэкенду!
В последней лекции мы узнали, как написать скрипт миграции для создания схемы базы данных для нашего простого примера банковского приложения.
Сегодня мы узнаем, как написать код на Golang для выполнения CRUD операций с базой данных.
Ниже:
- Ссылка на плейлист с видео лекциями на Youtube
- И на Github репозиторий
На самом деле, это 4 основные операции:
С
означает создать (Create
) или вставить новые записи в базу данных.R
- считать (Read
), т. е. извлечь записи из базы данных.U
- обновить (Update
), изменить содержимое записей в базе данных.- И
D
- удалить (Delete
) записи из базы данных.
Какую библиотеку использовать?
Существует несколько способов реализовать CRUD операции в Golang.
Первый — использовать пакет низкоуровневый стандартной библиотеки database/sql.
Как видите в этом примере на рисунке, мы просто используем функцию QueryRowContext()
,
передаем непосредственно SQL-запрос и некоторые параметры. Затем мы сканируем
результат в необходимые переменные.
Основное преимущество этого подхода в том, что он работает очень быстро, а написать код довольно просто.
Однако его недостатком является то, что нам приходится вручную сопоставлять поля SQL с переменными, что довольно трудоёмко и легко допустить ошибку. Если каким-то образом порядок переменных не совпадает или если мы забудем передать какие-то аргументы в вызов функции, ошибки появятся только во время выполнения.
Другой подход заключается в использовании Gorm, высокоуровневой библиотеки объектно-реляционного отображения для Golang.
Его очень удобно использовать, потому что все операции CRUD уже реализованы. Таким образом, наш продакшен код будет компактным, так как нам нужно только объявить модели и вызвать функции, предоставляемые Gorm.
Как видно в этих примерах кода на рисунке, нам доступны функции NewRecord()
и
Create()
для создания записи. И несколько функций для получения данных, такие
как First()
, Take()
, Last()
, Find()
.
Выглядит довольно круто, но проблема в том, что мы должны научиться писать запросы, используя функции, предоставляемые Gorm. Досадно, когда не знаешь какие функции использовать.
Особенно когда у нас есть сложные запросы, требующие соединения таблиц, мы должны научиться задавать дескрипторы в структурах, чтобы Gorm знал о связях между таблицами и мог генерировать правильный SQL-запрос.
Я предпочитаю сам писать SQL-запрос. Это более гибкий подход и у меня есть полный контроль над тем, что должна сделать база данных.
Одной из основных проблем при использовании Gorm является то, что он работает очень медленно при большом трафике. В Интернете есть несколько тестов, которые показывают, что Gorm может работать в 3-5 раз медленнее, чем стандартная библиотека.
Из-за этого многие люди переходят на промежуточный подход, который использует библиотеку sqlx.
Она работает почти так же быстро, как и стандартная библиотека, и очень проста в использовании. Сопоставление полей выполняется либо с помощью текста запроса, либо с помощью дескрипторов структуры.
Он предоставляет некоторые функции, такие как Select()
или StructScan()
,
которые автоматически сканируют результат в поля структуры, поэтому нам не
нужно выполнять сопоставление вручную, как в пакете database/sql
. Это поможет
сократить код и уменьшить вероятность ошибок.
Однако код, который нам предстоит написать, все еще довольно большой. И любые ошибки в запросе будут обнаружены только во время выполнения.
Существует ли какие-либо другие более подходящие варианты?
Ответ - sqlc!
Он работает очень быстро, как и database/sql
. Его очень легко использовать. И
самое интересное, нам нужно просто написать SQL-запросы, а затем для нас будут
автоматически сгенерированы CRUD коды на языке Golang.
Как видите в примере на рисунке, мы просто передаем схему базы данных и
SQL-запросы в sqlc
. Каждый запрос имеет один комментарий перед ним, чтобы указать
sqlc
сгенерировать правильную сигнатуру функции.
Затем sqlc
сгенерирует идиоматичные коды на Golang, использующие
стандартную библиотеку database/sql
.
И поскольку sqlc
анализирует SQL-запросы, чтобы понять, что делает запрос для
генерации кодов для нас, поэтому любые ошибки будут обнаружены и немедленно
сообщены. Звучит потрясающе, правда?
Единственная проблема, которую я обнаружил в sqlc
, заключается в том, что на
данный момент он полностью поддерживает только Postgres. MySQL все еще находится
в экспериментальный стадии. Поэтому, если вы используете Postgres в своем
проекте, я думаю, что sqlc
— правильный инструмент для использования. В
противном случае я бы предложил применять sqlx
.
Хорошо, теперь я покажу вам, как установить и использовать sqlc
для генерации
CRUD-кодов для нашего простого примера банковского приложения.
Сначала мы откроем его страницу на Github, затем найдем раздел «Установка».
У меня Mac, поэтому я буду использовать Homebrew. Давайте скопируем эту команду
brew install
и запустим ее в терминале:
brew install kyleconroy/sqlc/sqlc
Отлично, sqlc
теперь установлен!
Мы можем запустить команду sql version
, чтобы увидеть, какая версия установлена.
В моем случае это версия 1.4.0.
Давайте запустим sql help
, чтобы узнать, как его использовать.
- Сначала мы используем команду
compile
для проверки синтаксиса SQL и ошибок при вводе. - Затем самая важная команда —
generate
. Она будет выполнять как проверку ошибок, так и генерацию кодов на языке Golang из SQL запросов. - Также доступна команда
init
для создания пустого файла настроекslqc.yaml
.
Теперь я перейду в папку проекта Simple bank, над которым мы работали на предыдущих лекциях. Выполните:
sqlc init
Мы видим файл sqlc.yaml
. Сейчас он пустой. Итак, вернемся на страницу Github
sqlc
, выберем ветку с тегом v1.4.0
и найдем настройки.
Скопируем список с настройками и вставим в файл sqlc.yaml
.
Мы можем указать sqlc
сгенерировать несколько Go пакетов. Но для простоты я
пока буду использовать только один пакет.
version: "1"
packages:
- name: "db"
path: "./db/sqlc"
queries: "./db/query/"
schema: "./db/migration/"
engine: "postgresql"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: false
emit_exact_table_names: false
- Параметр
name
здесь указываетsqlc
название для Go пакета, который будет сгенерирован. Я думаю, чтоdb
- хорошее название пакета. - Далее нам нужно указать путь к папке для хранения сгенерированных файлов кода на
языке Golang. Я собираюсь создать новую папку
sqlc
внутри папкиdb
и изменить эту строку пути на./db/sqlc
. - Затем приведён параметр
queries
, который указываетsqlc
, где искать файлы SQL запросов. Давайте создадим новую папку query внутри папкиdb
. Затем измените это значение на./db/query
. - Точно так же параметр
schema
должен указывать на папку, содержащую схему базы данных или файлы миграции. В нашем случае это./db/migration
. - Следующим параметром является engine, чтобы указать
sqlc
, какой механизм базы данных мы хотели бы использовать. Мы используемpostgresql
для нашего примера банковского приложения. Если вы хотите поэкспериментировать с MySQL, вы можете изменить это значение наmysql
. - Далее мы устанавливаем для
emit_json_tags
значениеtrue
, потому что мы хотим, чтобыsqlc
добавлял дескрипторы JSON к сгенерированным структурам. emit_prepared_queries
указываетsqlc
генерировать коды, которые работают с подготовленным запросом. На данный момент нам пока не нужно оптимизировать производительность, поэтому давайте установим для этого параметра значениеfalse
, чтобы упростить задачу.- параметр
emit_interface
сообщаетsqlc
о необходимости создания интерфейсаQuerier
для сгенерированного пакета. Это может быть полезно позже, если мы хотим смоделировать работу базу данных для тестирования функций более высокого уровня. А пока давайте просто установим его вfalse
. - Последний параметр -
emit_exact_table_names
. По умолчанию это значение равноfalse
. Sqlc попытается использовать название таблицы в единственном числе в качестве имени структуры модели. Например, таблицаaccounts
станет структуройAccount
. Если вы установите для этого параметра значениеtrue
, имя структуры будет вместо этогоAccounts
. Я думаю, что имя в единственном числе лучше, потому что один объект типаAccounts
во множественном числе можно спутать с несколькими объектами.
Хорошо, теперь давайте откроем терминал и запустим
sqlc generate
У нас возникла ошибка, потому что в папке query
еще нет запросов.
Мы напишем запросы немного позднее. А пока давайте добавим в Makefile новую
команду sqlc
. Это поможет нашим коллегам легко найти все команды, которые
можно использовать для разработки, в одном месте.
...
sqlc:
sqlc generate
.PHONY: postgres createdb dropdb migrateup migratedown sqlc
Теперь давайте напишем наш первый SQL-запрос для СОЗДАНИЯ (CREATE
) счёта. Я
собираюсь создать новый файл account.sql
в папке db/query
.
Затем вернитесь на Github страницу sqlc
и найдите раздел Getting started
.
Здесь мы видим несколько примеров того, как должен выглядеть SQL-запрос. Давайте
скопируем команду CreateAuthor
и вставим ее в наш файл account.sql
.
Это просто базовый запрос INSERT
. Единственная особенность — это комментарий
перед ним. Этот комментарий проинструктирует sqlc
, как сгенерировать сигнатуру
функции на языке Golang для этого запроса.
В нашем случае имя функции будет CreateAccount
. И он должен возвращать один
единственный объект Account
, поэтому мы используем метку :one
здесь.
-- name: CreateAccount :one
INSERT INTO accounts (
owner,
balance,
currency
) VALUES (
$1, $2, $3
) RETURNING *;
Нам не нужно указывать id
, потому что это столбец с автоматическим
инкрементом. Каждый раз, когда вставляется новая запись, база данных автоматически
увеличивает порядковый номер идентификатора счёта использует его в качестве
значения для столбца id
.
Столбец created_at
также будет автоматически заполнен значением по
умолчанию, то есть временем создания записи.
Итак, нам нужно только предоставить значения для столбцов owner
, balance
и
currency
. Таким образом, нам нужно передать 3 аргумента в часть VALUES
.
Наконец, часть RETURNING *
используется, чтобы указать Postgres вернуть
значение всех столбцов после вставки записи в таблицу accounts
(включая id
и
created_at
). Это очень важно, так как после создания счёта мы всегда будем
хотеть вернуть его ID клиенту.
Итак, теперь давайте откроем терминал и выполним команду make sqlc
.
Затем вернитесь в Visual Studio Code. В папке db/sqlc
мы видим три новых
сгенерированных файла.
Первый — models.go
. Этот файл содержит определение структуры трех моделей:
Account
, Entry
и Transfer
.
// Code generated by sqlc. DO NOT EDIT.
package db
import (
"time"
)
type Account struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Balance int64 `json:"balance"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"created_at"`
}
type Entry struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
// can be negative or positive
Amount int64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
}
type Transfer struct {
ID int64 `json:"id"`
FromAccountID int64 `json:"from_account_id"`
ToAccountID int64 `json:"to_account_id"`
// must be positive
Amount int64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
}
Они содержат JSON дескрипторы, потому что мы задали для emit_json_tags
значение
true
в sqlc.yaml
. Поле Amount
структуры Entry
и Transfer
также содержит
комментарий перед ним, потому что мы добавили их в определение схемы базы
данных в предыдущей лекции.
Второй файл — db.go
. Этот файл содержит интерфейс DBTX
. Он определяет 4
общих метода, которые есть у объекта sql.DB
и sql.Tx
. Это позволяет нам
свободно использовать базу данных или транзакцию для выполнения запроса.
// Code generated by sqlc. DO NOT EDIT.
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
Как видно здесь функция New()
принимает DBTX
в качестве входных данных и
возвращает объект Queries
. Таким образом, мы можем передать объект sql.DB
или
sql.Tx
в зависимости от того, хотим ли мы выполнить только один запрос или
набор нескольких запросов в рамках транзакции.
Существует также метод WithTx()
, который позволяет связать экземпляр Queries
с транзакцией. Подробнее об этом мы узнаем из другой лекции о транзакциях.
Третий файл — это файл account.sql.go
.
// Code generated by sqlc. DO NOT EDIT.
// source: account.sql
package db
import (
"context"
)
const createAccount = `-- name: CreateAccount :one
INSERT INTO accounts (
owner,
balance,
currency
) VALUES (
$1, $2, $3
) RETURNING id, owner, balance, currency, created_at
`
type CreateAccountParams struct {
Owner string `json:"owner"`
Balance int64 `json:"balance"`
Currency string `json:"currency"`
}
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, createAccount, arg.Owner, arg.Balance, arg.Currency)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
Название пакета db
, как мы определили в файле sqlc.yaml
.
Вверху мы видим SQL-запрос для создания счёта. Он выглядит почти так же, как тот,
который мы написали в файле account.sql
, за исключением части с RETURN
.
Sqlc
явно заменил RETURN *
на названия всех столбцов. Это делает запрос
более четким и позволяет избежать сканирования значений в неправильном порядке.
Затем определена структура CreateAccountParams
, содержащая все столбцы, которые мы
хотим задать при создании нового счёта: owner
, balance
, currency
.
Функция CreateAccount()
определена как метод объекта Queries
. Она так называется,
потому что мы проинструктировали sqlc
с помощью комментария в нашем
SQL-запросе. Эта функция принимает контекст и объект CreateAccountParams
в
качестве входных данных и возвращает объект модели Account
или ошибку
(error
).
Visual Studio Code подчеркивает здесь несколько строк красным, потому что мы еще не инициализировали модуль для нашего проекта.
Откроем терминал и запустим:
go mod init github.com/MaksimDzhangirov/backendBankExample
Наш модуль называется github.com/MaksimDzhangirov/backendBankExample
. Для
нас был сгенерирован файл go.mod
. Давайте запустим следующую команду, чтобы
установить любые необходимые зависимости.
go mod tidy
Хорошо, теперь вернемся к файлу account.sql.go
. Все ошибки, подчеркнутые
красным, исчезли.
В функции CreateAccount()
мы вызываем QueryRowContext()
для выполнения
SQL-запроса создания счёта. Эта функция удовлетворяет интерфейсу DBTX
, который
мы видели ранее. Мы передаем контекст, запрос и три аргумента: owner
, balance
и currency
.
Функция возвращает объекту-строку из базы данных, которую мы можем использовать
для сканирования значения каждого столбца в правильные переменные. Это базовый
код, который нам часто приходится писать вручную, если мы используем стандартную
библиотеку database/sql
. Но как здорово, что он автоматически сгенерирован
за нас! Потрясающе, правда?
Еще одна удивительная особенность sqlc
: он проверяет синтаксис SQL-запроса
перед генерацией кода. Итак, если я попытаюсь удалить третий аргумент в запросе
-- name: CreateAccount :one
INSERT INTO accounts (
owner,
balance,
currency
) VALUES (
$1, $2
) RETURNING *;
и снова запущу make sqlc
, появится сообщение об ошибке:
INSERT has more target columns than expressions
(Количество столбцов больше
количества выражений).
Из-за этого, если sqlc
успешно сгенерирует коды, мы можем быть уверены, что в
наших SQL-запросах нет глупых ошибок.
При работе с sqlc
следует помнить, что мы не должны изменять содержимое
сгенерированных файлов, потому что каждый раз, когда мы запускаем make sqlc
,
все эти файлы будут создаваться заново, и наши изменения будут потеряны.
Поэтому обязательно создайте новые файлы, если вы хотите добавить больше кода
в пакет db
.
Итак, теперь мы знаем, как создавать записи в базе данных.
Перейдем к следующей операции: READ
.
В этом примере существует два основных запроса на получение данных: Get
и
List
. Скопируем их в наш файл account.sql
.
Get запрос используется для получения одной конкретного счёта по идентификатору.
Поэтому я собираюсь изменить его название на GetAccount
. И запрос будет иметь вид:
-- name: GetAccount :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1;
Мы используем здесь LIMIT 1
, потому что мы просто хотим выбрать одну единственную
запись.
Следующая операция — ListAccounts
. Он вернет несколько записей о счетах,
поэтому мы используем здесь метку :many
.
-- name: ListAccounts :many
SELECT * FROM accounts
ORDER BY id
LIMIT $1
OFFSET $2;
Как и в случае с запросом GetAccount
, мы выбираем записи из таблицы accounts
, а
затем упорядочиваем их по идентификаторам.
Так как счетов в базе может быть очень много, не стоит выбирать их все сразу.
Вместо этого мы будем осуществлять пагинацию. Поэтому мы используем LIMIT
,
чтобы установить количество строк, которые мы хотим получить, и используем
OFFSET
, чтобы указать Postgres пропустить это количество строк, прежде чем
начать возвращать результат.
Вот и всё!
Теперь давайте запустим make sqlc
для повторной генерации кодов и откроем файл
account.sql.go
.
Как видим были сгенерированы функции GetAccount()
и ListAccounts()
. Как и ранее,
sqlc
заменил для нас SELECT *
явными названиями столбцов.
const getAccount = `-- name: GetAccount :one
SELECT id, owner, balance, currency, created_at FROM accounts
WHERE id = $1 LIMIT 1
`
func (q *Queries) GetAccount(ctx context.Context, id int64) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccount, id)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
Функция GetAccount()
принимает контекст и идентификатор счёта в
качестве входных данных. А внутри он просто вызывает QueryRowContext()
с
непосредственным SQL-запросом и идентификатором счёта. Он сканирует строку базы
данных в объект Account
и возвращает ее вызывающей стороне. Довольно просто!
Функция ListAccounts
немного сложнее. Он принимает контекст, параметры
limit
и offset
в качестве входных данных и возвращает список объектов
Account
.
const listAccounts = `-- name: ListAccounts :many
SELECT id, owner, balance, currency, created_at FROM accounts
ORDER BY id
LIMIT $1
OFFSET $2
`
type ListAccountsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
rows, err := q.db.QueryContext(ctx, listAccounts, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Account
for rows.Next() {
var i Account
if err := rows.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
Внутри она вызывает QueryContext()
, передает запрос на возврат списка
счетов вместе с limit
и offset
.
Эта функция возвращает объект rows
. Он работает как итератор,
который позволяет нам просматривать записи одну за другой, сканировать каждую
запись в объект Account
и добавлять ее в список элементов.
Наконец, функция закрывает объект rows
, чтобы избежать утечки соединения с базой
данных. Она также проверяет наличие любых ошибок перед возвратом элементов
вызывающей стороне.
Код выглядит довольно большим, но его легко понять. И кроме того он работает очень
быстро! И нам не нужно беспокоиться о глупых ошибках в коде, потому что sqlc
уже гарантирует, что сгенерированный код будет работать идеально.
Отлично, давайте перейдем к следующей операции: UPDATE
.
Давайте скопируем этот код в наш файл account.sql
и изменим имя функции на
UpdateAccount()
.
-- name: UpdateAccount :exec
UPDATE accounts
SET balance = $2
WHERE id = $1;
Здесь мы используем новую метку :exec
, потому что эта команда не возвращает
никаких данных, она просто обновляет одну строку в базе данных.
Допустим, мы разрешаем только обновление баланса счета. Владелец счета и валюта не должны быть изменены.
Мы используем оператор WHERE, чтобы указать идентификатор счёта, который мы хотим обновить. Вот и все!
Теперь запустите make sqlc
в терминале, чтобы повторно сгенерировать код. И вуаля,
у нас есть функция UpdateAccount()
.
const updateAccount = `-- name: UpdateAccount :exec
UPDATE accounts
SET balance = $2
WHERE id = $1
`
type UpdateAccountParams struct {
ID int64 `json:"id"`
Balance int64 `json:"balance"`
}
func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) error {
_, err := q.db.ExecContext(ctx, updateAccount, arg.ID, arg.Balance)
return err
}
Он принимает переменные контекст, id
счёта и balance
в качестве входных
данных. Все, что он делает, это вызывает ExecContext()
с запросом и входными
аргументами, а затем возвращает ошибку (error
) вызывающей стороне.
Иногда бывает полезно также вернуть обновленный объект Account
. В этом
случае мы можем изменить метку :exec
на :one
и добавить RETURNING * в конце
этого запроса на обновление:
-- name: UpdateAccount :one
UPDATE accounts
SET balance = $2
WHERE id = $1
RETURNING *;
Затем повторно сгенерируйте код.
const updateAccount = `-- name: UpdateAccount :one
UPDATE accounts
SET balance = $2
WHERE id = $1
RETURNING id, owner, balance, currency, created_at
`
type UpdateAccountParams struct {
ID int64 `json:"id"`
Balance int64 `json:"balance"`
}
func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, updateAccount, arg.ID, arg.Balance)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
Теперь SQL-запрос изменился, и функция UpdateAccount()
возвращает обновленный
Account
вместе с ошибкой (error
). Класс!
Последняя операция DELETE
. Она реализуется даже проще, чем обновление.
Давайте скопируем этот пример запроса и изменим название функции на DeleteAccount
.
Я не хочу, чтобы Postgres возвращал удаленную запись, поэтому воспользуемся
меткой :exec
.
-- name: DeleteAccount :exec
DELETE FROM accounts
WHERE id = $1;
Давайте запустим make sqlc
, чтобы повторно сгенерировать код. Теперь у нас есть
функция DeleteAccount()
в коде.
const deleteAccount = `-- name: DeleteAccount :exec
DELETE FROM accounts
WHERE id = $1
`
func (q *Queries) DeleteAccount(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteAccount, id)
return err
}
Итак, мы научились генерировать все CRUD операции для нашей таблицы accounts
.
Вы можете попробовать сделать то же самое для двух оставшихся таблиц: entries
и transfers
самостоятельно в качестве упражнения.
Я запушу код на Github в этот репозитории, чтобы у вас был образец, если он вдруг вам понадобится.
И это все о чём я хотел рассказать на сегодняшней лекции. Большое спасибо за время, потраченное на чтение и до встречи на следующей лекции!