В этой статье хочу обобщить проблемы работы с базами данных под управлением 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) Signup2(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(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()
    ...
}

Заключение


Предлагаемый пакет позволяет расширить возможности взаимодействия с базой данных, при этом скрыв излишние детали. Это позволяет улучшить качество кода, оставляя его слабозвязным и прозрачным, несмотря на рост сложности приложения.

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


  1. Cobolorum
    29.03.2019 12:55
    -1

    Разрабатывая переносимый между платформами и БД код на Perl, C++ и Java cтолкнулся в Go с «database/sql» так больно мне не было давно… Начиная от компляции Ораклового драйвера в проект когда без работающей среды gcc не сделаешь ни чего. А мучение и боль при необходимости переключиться с MySQL на Oracle и обратно превращается в какой то ад. притом что в коде на Perl ни чего не меняется, даже в гребной Java практически ни чего (Да буде жить вечно JDBC!) ну ладно в C-x пару десятков строк. А Go… Даже не хочется начинать новые проекты на нем. Go это к сожалению не энтрпрайз и не понятно когда там все наладится. От кода:
    tx, err := m.DB.Begin()
    if err != nil {
    return err
    }
    Хочется стреляться.


    1. youROCK
      30.03.2019 01:30

      Хочется сказать, что Go никогда и не был нацелен на «Enterprise» :). Этот язык создавал Google для решения своих задач прежде всего, и по-моему, задачу написания высококонкурентных сетевых сервисов он помогает решать очень хорошо :). А драйвер для Oracle под Go наверняка уже переписали на чистый Go, хотя я не смотрел, если честно.


  1. DmitriyTitov
    30.03.2019 04:58

    Вопрос к автору статьи: можно в двух словах основной посыл. Вроде должно быть полезно, но я мало чего понял.
    Скажем, зачем вы добавляете функцию в структуру а потом ещё и меняете сигнатуру, почему не написать отдельную функцию для чего угодно и просто вызывать при необходимости? Функция в структуре не очень смотрится, как по мне.
    Вы рекламируете свой пакет вместо стандартного, в чём его преимущество?