Обертывание объекта БД в Go и запуск двух методов в одной транзакции

Чтобы немного лучше изучить Go, я пытаюсь реорганизовать ряд функций, которые принимают соединение с БД в качестве первого аргумента в методах структуры, и что-то более "идиоматически" Go.

Прямо сейчас мои методы "хранилища данных" примерно такие:

func CreateA(db orm.DB, a *A) error {
    db.Exec("INSERT...")
}

func CreateB(db orm.DB, b *B) error {
    db.Exec("INSERT...")
}

Эти функции работают отлично. orm.DB это интерфейс БД go-pg.

Поскольку две функции принимают соединение БД, я могу передать фактическое соединение или транзакцию (которая реализует один и тот же интерфейс). Я могу быть уверен, что обе функции, выпускающие SQL INSERT, выполняются в одной и той же транзакции, избегая несовместимого состояния в БД в случае сбоя одной из них.

Проблемы начались, когда я решил прочитать больше о том, как немного лучше структурировать код и сделать его "подшучивающим" в случае необходимости.

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

Результат примерно такой:

type Store {
    CreateA(a *A) error
    CreateB(a *A) error
}

type DB struct {
    orm.DB
}

func NewDBConnection(p *ConnParams) (*DB, error) {
    .... create db connection ...
    return &DB{db}, nil
}

func (db *DB) CreateA(a *A) error {
...
}

func (db *DB) CreateB(b *B) error {
...
}

что позволяет мне писать код как:

db := NewDBConnection()
DB.CreateA(a)
DB.CreateB(b)

вместо:

db := NewDBConnection()
CreateA(db, a)
CreateB(db, b)

Фактическая проблема заключается в том, что я потерял способность запускать две функции в одной транзакции. Прежде чем я смог сделать:

pgDB := DB.DB.(*pg.DB) // convert the interface to an actual connection
pgDB.RunInTransaction(func(tx *pg.Tx) error {
    CreateA(tx, a)
    CreateB(tx, b)
})

или что-то вроде:

tx := db.DB.Begin()

err = CreateA(tx, a)
err = CreateB(tx, b)

if err != nil {
  tx.Rollback()
} else {
  tx.Commit()
}

что более или менее то же самое.

Так как функции принимали общий интерфейс между соединением и транзакцией, я мог абстрагировать от уровня моей модели логику транзакции, посылая либо полное соединение, либо транзакцию. Это позволило мне в "обработчике HTTP" решить, когда создавать транзакцию, а когда нет необходимости.

Имейте в виду, что соединение - это глобальный объект, представляющий пул соединений, которые обрабатываются автоматически с помощью go, поэтому взломанный мной вариант:

pgDB := DB.DB.(*pg.DB) // convert the interface to an actual connection
err = pgDB.RunInTransaction(func(tx *pg.Tx) error {
    DB.DB = tx // replace the connection with a transaction
    DB.CreateA(a)
    DB.CreateB(a)
})

это явно плохая идея, потому что, хотя она работает, она работает только один раз, потому что мы заменяем глобальное соединение транзакцией. Следующий запрос ломает сервер.

Есть идеи? Я не могу найти информацию об этом, вероятно, потому что я не знаю правильных ключевых слов, являющихся нубом.

1 ответ

Я делал что-то подобное в прошлом (используя стандартные sql пакет, вам может понадобиться адаптировать его к вашим потребностям):

var ErrNestedTransaction = errors.New("nested transactions are not supported")

// abstraction over sql.TX and sql.DB
// a similar interface seems to be already defined in go-pg. So you may not need this. 
type executor interface {
    Exec(query string, args ...interface{}) (sql.Result, error)
    Query(query string, args ...interface{}) (*sql.Rows, error)
    QueryRow(query string, args ...interface{}) *sql.Row
}

type Store struct {
    // this is the actual connection(pool) to the db which has the Begin() method
    db       *sql.DB
    executor executor
}

func NewStore(dsn string) (*Store, error) {
    db, err := sql.Open("sqlite3", dsn)
    if err != nil {
         return nil, err
    }      
    // the initial store contains just the connection(pool)
    return &Store{db, db}, nil
}

func (s *Store) RunInTransaction(f func(store *Store) error) error {
    if _, ok := s.executor.(*sql.Tx); ok {
        // nested transactions are not supported!
        return ErrNestedTransaction
    }

    tx, err := s.db.Begin()
    if err != nil {
        return err
    }

    transactedStore := &Store{
        s.db,
        tx,
    }

    err = f(transactedStore)
    if err != nil {
        tx.Rollback()
        return err
    }

    return tx.Commit()
}

func (s *Store) CreateA(thing A) error {
    // your implementation
    _, err := s.executor.Exec("INSERT INTO ...", ...)
    return err
}

И тогда вы используете это как

// store is a global object
store.RunInTransaction(func(store *Store) error { 
    // this instance of Store uses a transaction to execute the methods
    err := store.CreateA(a)
    if err != nil {
        return err
    }
    return store.CreateB(b)
})

Хитрость заключается в том, чтобы использовать executor вместо *sql.DB в ваших методах CreateX, что позволяет динамически изменять базовую реализацию (tx и db). Однако, поскольку существует очень мало информации о том, как справиться с этой проблемой, я не могу заверить вас, что это "лучшее" решение. Другие предложения приветствуются!

Другие вопросы по тегам