Несколько недель назад кто-то создал топик на Reddit с просьбой:


Что бы Вы использовали в качестве лучшей практики Go для доступа к базе данных в (HTTP или других) обработчиках, в контексте веб-приложения?

Ответы, которые он получил, были разнообразными и интересными. Некоторые люди посоветовали использовать внедрение зависимостей, некоторые поддержали идею использования простых глобальных переменных, другие предложили поместить указатель пула соединений в x/net/context (c golang 1.7 используется пакет context).


Что касается меня? Думаю что правильный ответ зависит от проекта.


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


В этом посте рассматрим четыре разных подхода к организации вашего кода и структурирование доступа к пулу соединений к базе данных.


Данный пост является вольным переводом оригинальной статьи. Автор статьи предлагает четыре подхода по организации доступа к БД в приложении написанном на golang


Глобальные переменные


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


Что бы код выглядел красивым и соответствовал принципу DRY (Don't Repeat Yourself — рус. не повторяйся), вы можете использовать функцию инициализации, которая будет устанавливать глобальный пул соединений из других пакетов и тестов.


Мне нравятся конкретные примеры, давайте продолжим работу с базой данных онлайн магазина и кодом из моего предыдущего поста. Мы рассмотрим создание простых приложений с MVC (Model View Controller) подобной структурой — с обработчиками HTTP в основном приложении и отдельным пакетом моделей содержащей глобальные переменные для БД, функцию InitDB(), и нашу логику по базе данных.


bookstore
+-- main.go
L-- models
    +-- books.go
    L-- db.go

Код

File: main.go


package main

import (
    "bookstore/models"
    "fmt"
    "net/http"
)

func main() {
    models.InitDB("postgres://user:pass@localhost/bookstore")

    http.HandleFunc("/books", booksIndex)
    http.ListenAndServe(":3000", nil)
}

func booksIndex(w http.ResponseWriter, r *http.Request) {
    if r.Method != "GET" {
        http.Error(w, http.StatusText(405), 405)
        return
    }
    bks, err := models.AllBooks()
    if err != nil {
        http.Error(w, http.StatusText(500), 500)
        return
    }
    for _, bk := range bks {
        fmt.Fprintf(w, "%s, %s, %s, ?%.2f\n", bk.Isbn, bk.Title, bk.Author, bk.Price)
    }
}

File: models/db.go


package models

import (
    "database/sql"
    _ "github.com/lib/pq"
    "log"
)

var db *sql.DB

func InitDB(dataSourceName string) {
    var err error
    db, err = sql.Open("postgres", dataSourceName)
    if err != nil {
        log.Panic(err)
    }

    if err = db.Ping(); err != nil {
        log.Panic(err)
    }
}

File: models/books.go


package models

type Book struct {
    Isbn   string
    Title  string
    Author string
    Price  float32
}

func AllBooks() ([]*Book, error) {
    rows, err := db.Query("SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    bks := make([]*Book, 0)
    for rows.Next() {
        bk := new(Book)
        err := rows.Scan(&bk.Isbn, &bk.Title, &bk.Author, &bk.Price)
        if err != nil {
            return nil, err
        }
        bks = append(bks, bk)
    }
    if err = rows.Err(); err != nil {
        return nil, err
    }
    return bks, nil
}

Если вы запустите приложение и выполните запрос на /books вы должны получить ответ похожий на:


$ curl -i localhost:3000/books
HTTP/1.1 200 OK
Content-Length: 205
Content-Type: text/plain; charset=utf-8

978-1503261969, Emma, Jayne Austen, ?9.44
978-1505255607, The Time Machine, H. G. Wells, ?5.99
978-1503379640, The Prince, Niccolo Machiavelli, ?6.99

Использование глобальных переменных потенциально подходит, если:


  • вся логика по базам данных находится в одном пакете;
  • ваше приложение достаточно маленькое и отслеживание глобальных переменных не должно создавать вам проблем;
  • подход к тестированию означает, что вы не нуждаетесь в проверке базы данных и не запускаете параллельные тесты.

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


Один из вариантов это вызывать InitDB несколько раз, но такой подход быстро может стать неуклюжим и это выглядит немного странным (легко забыть инициализировать пул коннектов и получить панику вызова пустого указателя во время выполнения). Второй вариант это создание отдельного конфигурационного пакета с экспортируемой переменной БД и импортировать "yourproject/config" в каждый файл, где это необходимо. Если не понятно о чем идет речь, Вы можете посмотреть пример.


Внедрение зависимости


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


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


Для проектов, в которых все ваши обработчики находятся в одном пакете, аккуратный подход состоит в том, чтобы все элементы находились в пользовательском типе Env:


type Env struct {
    db *sql.DB
    logger *log.Logger
    templates *template.Template
}

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


Полный пример:


Код

File: main.go


package main

import (
    "bookstore/models"
    "database/sql"
    "fmt"
    "log"
    "net/http"
)

type Env struct {
    db *sql.DB
}

func main() {
    db, err := models.NewDB("postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Panic(err)
    }
    env := &Env{db: db}

    http.HandleFunc("/books", env.booksIndex)
    http.ListenAndServe(":3000", nil)
}

func (env *Env) booksIndex(w http.ResponseWriter, r *http.Request) {
    if r.Method != "GET" {
        http.Error(w, http.StatusText(405), 405)
        return
    }
    bks, err := models.AllBooks(env.db)
    if err != nil {
        http.Error(w, http.StatusText(500), 500)
        return
    }
    for _, bk := range bks {
        fmt.Fprintf(w, "%s, %s, %s, ?%.2f\n", bk.Isbn, bk.Title, bk.Author, bk.Price)
    }
}

File: models/db.go


package models

import (
    "database/sql"
    _ "github.com/lib/pq"
)

func NewDB(dataSourceName string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dataSourceName)
    if err != nil {
        return nil, err
    }
    if err = db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

File: models/books.go


package models

import "database/sql"

type Book struct {
    Isbn   string
    Title  string
    Author string
    Price  float32
}

func AllBooks(db *sql.DB) ([]*Book, error) {
    rows, err := db.Query("SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    bks := make([]*Book, 0)
    for rows.Next() {
        bk := new(Book)
        err := rows.Scan(&bk.Isbn, &bk.Title, &bk.Author, &bk.Price)
        if err != nil {
            return nil, err
        }
        bks = append(bks, bk)
    }
    if err = rows.Err(); err != nil {
        return nil, err
    }
    return bks, nil
}

Или использовать замыкания...


Если вы не хотите определять ваши обработчики и методы в Env, альтернативным подходом будет использование логики обработчиков в замыкании и закрытие переменной Env следующим образом:


Код

File: main.go


package main

import (
    "bookstore/models"
    "database/sql"
    "fmt"
    "log"
    "net/http"
)

type Env struct {
    db *sql.DB
}

func main() {
    db, err := models.NewDB("postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Panic(err)
    }
    env := &Env{db: db}

    http.Handle("/books", booksIndex(env))
    http.ListenAndServe(":3000", nil)
}

func booksIndex(env *Env) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != "GET" {
            http.Error(w, http.StatusText(405), 405)
            return
        }
        bks, err := models.AllBooks(env.db)
        if err != nil {
            http.Error(w, http.StatusText(500), 500)
            return
        }
        for _, bk := range bks {
            fmt.Fprintf(w, "%s, %s, %s, ?%.2f\n", bk.Isbn, bk.Title, bk.Author, bk.Price)
        }
    })
}

Внедрение зависимостей является хорошим подходом, когда:


  • все ваши обработчики находятся в одном пакете;
  • все обработчики имеют один и тот же набор зависимостей;
  • подход к тестированию означает, что вы не нуждаетесь в проверке базы данных и не запускаете параллельные тесты.

Еще раз, вы можете использовать этот подход, если ваши обработчики и логика базы данных будут распределены по нескольким пакетам. Один из способов добиться этого — создать отдельный конфигурационный пакет, экспортируемый тип Env. Один из способов использования Env в примере выше. А так же простой пример.


Использование интерфейсов


Мы будем использовать пример внедрения зависимостей немного позже. Давайте изменим пакет моделей так, что бы он возвращал пользовательский тип БД (который включает sql.DB) и внедрим логику базы данных в виде типа DB.


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


Давайте изменим пример и включим новый интерфейс Datastore, который реализовывает некоторые методы, в нашем новом типе DB.


type Datastore interface {
    AllBooks() ([]*Book, error)
}

Мы можем использовать данный интерфейс во всем нашем приложении. Обновленный пример.


Код

File: main.go


package main

import (
    "fmt"
    "log"
    "net/http"
    "bookstore/models"
)

type Env struct {
    db models.Datastore
}

func main() {
    db, err := models.NewDB("postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Panic(err)
    }

    env := &Env{db}

    http.HandleFunc("/books", env.booksIndex)
    http.ListenAndServe(":3000", nil)
}

func (env *Env) booksIndex(w http.ResponseWriter, r *http.Request) {
    if r.Method != "GET" {
        http.Error(w, http.StatusText(405), 405)
        return
    }
    bks, err := env.db.AllBooks()
    if err != nil {
        http.Error(w, http.StatusText(500), 500)
        return
    }
    for _, bk := range bks {
        fmt.Fprintf(w, "%s, %s, %s, ?%.2f\n", bk.Isbn, bk.Title, bk.Author, bk.Price)
    }
}

File: models/db.go


package models

import (
    _ "github.com/lib/pq"
    "database/sql"
)

type Datastore interface {
    AllBooks() ([]*Book, error)
}

type DB struct {
    *sql.DB
}

func NewDB(dataSourceName string) (*DB, error) {
    db, err := sql.Open("postgres", dataSourceName)
    if err != nil {
        return nil, err
    }
    if err = db.Ping(); err != nil {
        return nil, err
    }
    return &DB{db}, nil
}

File: models/books.go


package models

type Book struct {
    Isbn   string
    Title  string
    Author string
    Price  float32
}

func (db *DB) AllBooks() ([]*Book, error) {
    rows, err := db.Query("SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    bks := make([]*Book, 0)
    for rows.Next() {
        bk := new(Book)
        err := rows.Scan(&bk.Isbn, &bk.Title, &bk.Author, &bk.Price)
        if err != nil {
            return nil, err
        }
        bks = append(bks, bk)
    }
    if err = rows.Err(); err != nil {
        return nil, err
    }
    return bks, nil
}

Из-за того, что наши обработчики теперь используют интерфейс Datastore, мы можем легко создать юнит тесты для ответов от базы данных.


Код
package main

import (
    "bookstore/models"
    "net/http"
    "net/http/httptest"
    "testing"
)

type mockDB struct{}

func (mdb *mockDB) AllBooks() ([]*models.Book, error) {
    bks := make([]*models.Book, 0)
    bks = append(bks, &models.Book{"978-1503261969", "Emma", "Jayne Austen", 9.44})
    bks = append(bks, &models.Book{"978-1505255607", "The Time Machine", "H. G. Wells", 5.99})
    return bks, nil
}

func TestBooksIndex(t *testing.T) {
    rec := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/books", nil)

    env := Env{db: &mockDB{}}
    http.HandlerFunc(env.booksIndex).ServeHTTP(rec, req)

    expected := "978-1503261969, Emma, Jayne Austen, ?9.44\n978-1505255607, The Time Machine, H. G. Wells, ?5.99\n"
    if expected != rec.Body.String() {
        t.Errorf("\n...expected = %v\n...obtained = %v", expected, rec.Body.String())
    }
}

Контекст в области видимости запроса (Request-scoped context)


Наконец-то давайте посмотрим на использование контекста в области видимости запроса и передачи пула подключений к базе данных. В частности мы будем использовать пакет x/net/context.


Лично я не фанат переменных уровня приложения в контексте области видимости запроса — он выглядит неуклюжим и обременительным для меня. Документация пакета x/net/context так же советует:


Используйте значения контекста только для области видимости данных внутри запроса, которые передают процессы и точки входа API, а не для передачи необязательных параметров функции.

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


Давайте адаптируем пример книжного магазина в последний раз, передавая контекст для наших обработчиков, используя шаблон, предложенный в замечательной статье от Joe Shaw


Код

File: main.go


package main

import (
  "bookstore/models"
  "fmt"
  "golang.org/x/net/context"
  "log"
  "net/http"
)

type ContextHandler interface {
  ServeHTTPContext(context.Context, http.ResponseWriter, *http.Request)
}

type ContextHandlerFunc func(context.Context, http.ResponseWriter, *http.Request)

func (h ContextHandlerFunc) ServeHTTPContext(ctx context.Context, rw http.ResponseWriter, req *http.Request) {
  h(ctx, rw, req)
}

type ContextAdapter struct {
  ctx     context.Context
  handler ContextHandler
}

func (ca *ContextAdapter) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
  ca.handler.ServeHTTPContext(ca.ctx, rw, req)
}

func main() {
  db, err := models.NewDB("postgres://user:pass@localhost/bookstore")
  if err != nil {
    log.Panic(err)
  }
  ctx := context.WithValue(context.Background(), "db", db)

  http.Handle("/books", &ContextAdapter{ctx, ContextHandlerFunc(booksIndex)})
  http.ListenAndServe(":3000", nil)
}

func booksIndex(ctx context.Context, w http.ResponseWriter, r *http.Request) {
  if r.Method != "GET" {
    http.Error(w, http.StatusText(405), 405)
    return
  }
  bks, err := models.AllBooks(ctx)
  if err != nil {
    http.Error(w, http.StatusText(500), 500)
    return
  }
  for _, bk := range bks {
    fmt.Fprintf(w, "%s, %s, %s, ?%.2f\n", bk.Isbn, bk.Title, bk.Author, bk.Price)
  }
}

File: models/db.go


package models

import (
    "database/sql"
    _ "github.com/lib/pq"
)

func NewDB(dataSourceName string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dataSourceName)
    if err != nil {
        return nil, err
    }
    if err = db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

File: models/books.go


package models

import (
    "database/sql"
    "errors"
    "golang.org/x/net/context"
)

type Book struct {
    Isbn   string
    Title  string
    Author string
    Price  float32
}

func AllBooks(ctx context.Context) ([]*Book, error) {
    db, ok := ctx.Value("db").(*sql.DB)
    if !ok {
        return nil, errors.New("models: could not get database connection pool from context")
    }

    rows, err := db.Query("SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    bks := make([]*Book, 0)
    for rows.Next() {
        bk := new(Book)
        err := rows.Scan(&bk.Isbn, &bk.Title, &bk.Author, &bk.Price)
        if err != nil {
            return nil, err
        }
        bks = append(bks, bk)
    }
    if err = rows.Err(); err != nil {
        return nil, err
    }
    return bks, nil
}

P.S. Автор перевода будет благодарен за указанные ошибки и неточности перевода.

Поделиться с друзьями
-->

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


  1. youROCK
    02.07.2017 22:40

    Ни один из этих подходов не является достаточно хорошо продуманным.

    Третий вариант почти хорош, но только интерфейсы нужно объявлять там, где зависимость принимается, или, если интерфейс достаточно общий, то он должен быть выселен в отдельный пакет. Это позволяет избежать циклических зависимостей во многих случаях и не замусоривает код — не требуется менять реализацию models и не нужно копировать определения всех методов до того момента, когда нужен будет мок. Можно посмотреть на стандартную библиотеку — в ней есть, к примеру, *os.File, *bytes.Buffer и другие, которые реализуют интерфейс io.Reader. Везде при этом принимаются в качестве аргументов именно эти интерфейсы, а не конкретные реализации. Это позволяет, к примеру, создать фактическую циклическую зависимость между пакетами os и fmt, но при этом только os импортирует fmt, но не наоборот, поскольку fmt везде принимает io.Reader и io.Writer. Это одновременно и легко тестируемый код и очень гибкий в том плане, что «бесплатно» пакет fmt теперь умеет печатать в любой io.Writer.


    1. aml
      03.07.2017 07:46

      Модуль sql сам по себе — это уже абстракция над конкретными драйверами, там внутри есть все нужные интерфейсы. Я бы выбрал вариант 2 (с явной передачей *sql.DB в структуры обработчиков), а для тестирования использовал клёвый мок — https://godoc.org/github.com/DATA-DOG/go-sqlmock.


  1. bat
    03.07.2017 12:47

    Странным показался совет использовать x/net/context, но глянул на дату оригинальной статьи, для 15 года ок. Возможно, следовало добавить в сноску инфу по актуальному пакету.


    1. KosToZyB
      03.07.2017 14:45

      Согласен. Сделал сноску на актуальный пакет.


  1. arzonus
    03.07.2017 15:13

    Третий вариант хорош, однако было бы интересно, что делать, когда у нас операции должны быть в транзакции. Особенно, когда бизнес-логика усложнена.

    К примеру:

    type Car struct {
    	ID int
    	OwnerID int
    }
    
    type User struct {
    	ID int
    	CarID int
    }
    
    type IUserRepository interface {
    	FindByID(id int) (*User, error)
    	Update(user *User) error
    }
    
    type ICarRepository interface {
    	FindByID(id int) (*Car, error)
    	Update(car *Car) error
    }
    
    
    type IUserService interface {
      GetByID(id int) (*User, error)
    }
    
    type UserService struct {
      UserRepository IUserRepository
    }
    
    func (s UserService) GetByID(id) (*User, error) {
    	user, err := s.UserRepository.FindByID(id)
    	if err != nil {
    		return nil, err
    	}
    	if user == nil {
    		return nil, fmt.Errorf("not found user")
    	}
    	return user, nil
    }
    
    
    type CarService struct {
      UserRepository IUserRepository
      UserService IUserService
      CarRepository ICarRepository
    }
    
    func (s CarService) GetByID(id) (*Car, error) {
    	car, err := s.CarRepository.FindByID(id)
    	if err != nil {
    		return nil, err
    	}
    	if car == nil {
    		return nil, fmt.Errorf("not found car")
    	}
    	return car, nil	
    }
    
    func (s CarService) ChangeOwner(carID int, newOwnerID int) error {
    	car, err := s.GetByID(id)
    	if err != nil {
    		return err
    	}
    
    
    	user, err = s.UserService.GetByID(newOwnerID)
    	if err != nil {
    		return err
    	}
    
    	user.CarID = car.ID
    	car.OwnerID = user.ID
    
    	/* Начало транзакции */
    
    	if err := s.UserRepository.Update(user); err != nil {
    		return err
    	}
    
    	if err := s.CarRepository.Update(car); err != nil {
    		return err
    	}
    
    	/* Конец транзакции */
    	return nil
    }
    


    1. aml
      04.07.2017 01:36

      Посмотрите ссылку на мок, которую я выше привел. Транзакции отлично тестируются.


      1. arzonus
        04.07.2017 10:28

        Я не про тестирование. А про организацию транзакций, в случае когда один сервис должен вызывать метод другого сервиса с учетом транзакции.


      1. arzonus
        04.07.2017 10:39

        Расширил пример:

        type Car struct {
        	ID int
        	OwnerID int
        }
        
        type User struct {
        	ID int
        	CarID int
        }
        
        type IUserRepository interface {
        	FindByID(id int) (*User, error)
        	Update(user *User) error
        }
        
        type ICarRepository interface {
        	FindByID(id int) (*Car, error)
        	Update(car *Car) error
        }
        
        
        type IUserService interface {
          GetByID(id int) (*User, error)
          MethodInTransaction() error
        }
        
        type UserService struct {
          UserRepository IUserRepository
        }
        
        func (s UserService) GetByID(id) (*User, error) {
        	user, err := s.UserRepository.FindByID(id)
        	if err != nil {
        		return nil, err
        	}
        	if user == nil {
        		return nil, fmt.Errorf("not found user")
        	}
        	return user, nil
        }
        
        func (s UserService) MethodInTransaction() error {
        	return nil
        }
        
        
        type CarService struct {
          UserRepository IUserRepository
          UserService IUserService
          CarRepository ICarRepository
        }
        
        func (s CarService) GetByID(id) (*Car, error) {
        	car, err := s.CarRepository.FindByID(id)
        	if err != nil {
        		return nil, err
        	}
        	if car == nil {
        		return nil, fmt.Errorf("not found car")
        	}
        	return car, nil	
        }
        
        func (s CarService) ChangeOwner(carID int, newOwnerID int) error {
        	car, err := s.GetByID(id)
        	if err != nil {
        		return err
        	}
        
        
        	user, err = s.UserService.GetByID(newOwnerID)
        	if err != nil {
        		return err
        	}
        
        	user.CarID = car.ID
        	car.OwnerID = user.ID
        
        	/* Начало транзакции */
        
        	if err := s.UserService.MethodInTransaction(); err != nil {
        		return err
        	}
        
        	if err := s.UserRepository.Update(user); err != nil {
        		return err
        	}
        
        	if err := s.CarRepository.Update(car); err != nil {
        		return err
        	}
        
        	/* Конец транзакции */
        
        	return nil
        }
        


        По факту, нужно пересоздавать все Repository и Service с учетом требования к транзакции.

        Во всех примерах в Go, где используются Repository и тп, не как не учитываются транзакции, так как это совсем непростая тема.

        Есть 2 пути решения данной проблемы, на мой взгляд.
        1. «Чистый». Пересборка всех сущностей, которые требуется в транзации, используя глобальную фабрику, которая создает все Repository и тп. Но он накладен довольно таки, так как в Go нету используемых фреймворков для IOC-контейнеров, так как это не go-way.
        2. «Грязный». Передача информации о транзакции как аргумента функции в Repository, однако это чревато тем, что нарушается принцип DI. Хотя, как я понял, DI не жалуют в go.

        Было бы классно услышать, хорошие примеры решения данной проблемы.


        1. aml
          04.07.2017 16:27

          Может мы о разных транзакциях говорим? Я не вижу ни одного sql-запроса в вашем примере. Кто, в итоге, и когда обращается к базе данных?


          1. VolCh
            05.07.2017 08:55

            По идее реализации I*Repository.Update() пишут. Как я понимаю задачу, при первом (в рамках обработки одного пользовательского запроса) вызове s.GetByID(id) должна стартовать SQL-транзакция, которая в конце либо закоммитит s.UserRepository.Update(user) и s.CarRepository.Update(car), либо откатит при любой ошибке (не только SQL) все изменения.


            1. arzonus
              05.07.2017 10:25

              Да все верно :)
              Я просто к тому, что в рамках go, нету удобных вещей по разруливанию транзакций, используя Repository, без передачи sql.Tx в качестве аргумента. И было бы просто интересно, есть ли хорошие решения данной проблемы именно для Go.


              1. aml
                05.07.2017 18:09

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


                1. VolCh
                  06.07.2017 14:25

                  В общем случае не ожидается. Репозиторию должно быть всё равно в транзакции он работает или нет. Паттерн "Репозиторий" и предназначен по сути для абстрагирования от таких деталей, а в целом даже от СУБД в целом, не говоря о том транзакционная она или нет.


                  1. aml
                    06.07.2017 18:32

                    Если принципиально сохранить этот слой, который изолирует приложение от базы, то для него нужны свои способы тестирования — это факт. Свои какие-то интерфейсы, как минимум. Но обычно приложения на Go пишут без подобных абстракций. Нужна транзакция — пишут транзакцию. Нужно параллельно выполнить запросы — запускают горутины. Нужно последовательно — делают последовательно. И писать такой код проще, и поддерживать, тестировать.


        1. Hypnoglow
          06.07.2017 20:51

          В моем видении, репозиторий это НЕ просто объект для доступа к базе данных, и он не обязан отвечать за все запросы к определенной таблице. То есть, UserRepository не должен инкапсулировать абсолютно всё взаимодействие с таблицой пользователей.


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


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


          1. arzonus
            08.07.2017 10:57

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