В этой статье хочу обобщить проблемы работы с базами данных под управлением golang. При решении простых задач обычно эти проблемы не видны. С ростом проекта масштабируются и проблемы. Наиболее злободневные из них:
- Снижение связности приложения, работающего с базой данных
- Журналирование запросов в отладочном режиме
- Работа с репликами
Статья построена на основании пакета github.com/adverax/echo/database/sql. Семантика использования этого пакета максимально приближена к стандартному пакету database/sql, поэтому не думаю, что при его использовании у кого-нибудь возникнут проблемы.
Область видимости
Как правило, крупные системы стараются делать слабо связными с четкой зоной ответственности каждого компонента системы. Поэтому широко практикуются паттерны проектирования типа издатель/подписчик. Рассмотрим необольшой пример по регистрации нового пользователя в системе.
package main
import "database/sql"
type User struct {
Id int64
Name string
Language string
}
type Manager struct {
DB *sql.DB
OnSignup func(db *sql.DB, user *User) error
}
func (m *Manager) Signup(user *User) (id int64, err error) {
id, err = m.insert(user)
if err != nil {
return
}
user.Id = id
err = m.OnSignup(m.DB, user)
return
}
func (m *Manager) insert(user *User) (int64, error) {
res, err := m.DB.Exec("INSERT ...")
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
return id, err
}
func main() {
manager := &Manager{
// ...
OnSignup: func(db *sql.DB, user *User) error {
},
}
err := manager.Signup(&User{...})
if err != nil {
panic(err)
}
}
В этом примере нас прежде всего интересует событие OnSignup. Для упрощения, обработчик представлен единственной функцией (в реальной жизни все сложнее). В сигнатуре события мы жестко прописываем тип первого параметра, что обычно имеет далекоидущие последтсвия.
Предположим, что теперь мы хотим расширить функционал нашего приложения и в случае успешной регистрации пользователя отправлять сообщение ему в личный кабинет. В идеале, сообщение должно помещаться в той же транзакции, что и регистрация пользователя.
type Manager struct {
DB *sql.DB
OnSignup func(tx *sql.Tx, user *User) error
}
func (m *Manager) Signup(user *User) error {
tx, err := m.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
id, err := m.insert(user)
if err != nil {
return err
}
user.Id = id
err = m.OnSignup(tx, id)
if err != nil {
return err
}
return tx.Commit()
}
func main() {
manager := &Manager{
// ...
OnSignup: func(db *sql.Tx, user *User) error {
},
}
err := manager.Signup(&User{...})
if err != nil {
panic(err)
}
}
Как видно из примера, мы вынуждены были изменить сигнатуру события. Такое решение не является чистым и подразумевает, что обработчики обладают знаниями о контексте исполнения запроса к базе данных. Значительно более чистым решением было бы использовать обобщенный интерфейс базы данных и транзакции — область видимости.
import "github.com/adverax/echo/database/sql"
type Manager struct {
DB sql.DB
OnSignup func(scope sql.Scope, user *User) error
}
func (m *Manager) Signup(user *User) error {
tx, err := m.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
id, err := m.insert(user)
if err != nil {
return err
}
err = m.OnSignup(tx, id)
if err != nil {
return err
}
return tx.Commit()
}
func main() {
manager := &Manager{
// ...
OnSignup: func(scope sql.Scope, user *User) error {
},
}
err := manager.Signup(&User{...})
if err != nil {
panic(err)
}
}
Для реализации этого подхода нам понадобится поддержка вложенных транзакций, поскольку обработчик в свою очередь может задействовать транзакции. К счастью, это не является проблемой, поскольку большинство СУБД поддерживает механизм SAVEPOINT.
База данных и контекст
При обычной практике, соединение с базой данных не передается в виде параметра, как было показано выше, а каждый менеджер хранит у себя ссылку на соединение с базой. Это позволяет упростить сигнатуры методов и повысить читаемость кода. В нашем же случае избежать этого невозможно, поскольку требуется передавать ссылку на транзакцию.
Достаточно изящное решение заключается в том, чтобы поместить ссылку на транзакцию (область видимости) в контекст, ведь контекст позиционируется, как сквозной параметр. Тогда мы сможем еще упростить наш код:
import (
"context"
"github.com/adverax/echo/database/sql"
)
type Manager struct {
sql.Repository
OnSignup func(ctx context.Context, user *User) error
}
func (m *Manager) Signup(ctx context.Context, user *User) error {
return m.Transaction(
ctx, func(ctx context.Context, scope sql.Scope) error {
id, err := m.insert(user)
if err != nil {
return err
}
user.Id = id
return m.OnSignup(ctx, user)
},
)
}
type Messenger struct {
sql.Repository
}
func(messenger *Messenger) onSignupUser(ctx context.Context, user *User) error {
_, err := messenger.Scope(ctx).Exec("INSERT ...")
return err
}
func main() {
db := ...
messenger := &Messenger{
Repository: sql.NewRepository(db),
}
manager := &Manager{
Repository: sql.NewRepository(db),
OnSignup: messenger.onSignup,
}
err := manager.Signup(&User{...})
if err != nil {
panic(err)
}
}
Из этого примера видно, что мы сохранили полную изоляцию менеджеров, повысили читаемость кода и добились их совместной работы в единой области видимости.
Поддержка репликаций
Библиотека также поддерживает использование репликаций. Все запросы типа Exec направляются на Master. Запросы же типа Slave передаются на случаным образом выбранный Slave. Для поддержки репликации достаточно указать несколько источников данных:
func work() {
dsc := &sql.DSC{
Driver: "mysql",
DSN: []*sql.DSN{
{
Host: "127.0.0.1",
Database: "echo",
Username: "root",
Password: "password",
},
{
Host: "192.168.44.01",
Database: "echo",
Username: "root",
Password: "password",
},
},
}
db := dsc.Open(nil)
defer db.Close()
...
}
В случае использования единственного источника данных при открытии базы данных, она будет открыта в обычном режиме без дополнительного оверхеда.
Метрики
Как известно, метрики дешевы, а логи дороги. Поэтому было решено добавить поддержку метрик по умолчанию.
Профилирование и логгирование запросов
Очень часть необходимо во время отладки протоколировать запросы к базе данных. Однако, мне не встречался качественный механизм протоколирования с нулевым оверхедом в продакшене. Библиотека позволяет элегантно решить эту проблему за счет обертки базы данных. Для профилирования базы данных достаточно передать ей соответствующий активатор:
func openDatabase(dsc sql.DSC, debug bool) (sql.DB, error){
if debug {
return dsc.Open(sql.OpenWithProfiler(nil, "", nil))
}
return dsc.Open(nil)
}
func main() {
dsc := ...
db, err := openDatabase(dsc, true)
if err != nil {
panic(err)
}
defer db.Close()
...
}
Заключение
Предлагаемый пакет позволяет расширить возможности взаимодействия с базой данных, при этом скрыв излишние детали. Это позволяет улучшить качество кода, оставляя его слабозвязным и прозрачным, несмотря на рост сложности приложения.