Несколько недель назад кто-то создал топик на 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)
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 }
aml
04.07.2017 01:36Посмотрите ссылку на мок, которую я выше привел. Транзакции отлично тестируются.
arzonus
04.07.2017 10:28Я не про тестирование. А про организацию транзакций, в случае когда один сервис должен вызывать метод другого сервиса с учетом транзакции.
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.
Было бы классно услышать, хорошие примеры решения данной проблемы.aml
04.07.2017 16:27Может мы о разных транзакциях говорим? Я не вижу ни одного sql-запроса в вашем примере. Кто, в итоге, и когда обращается к базе данных?
VolCh
05.07.2017 08:55По идее реализации I*Repository.Update() пишут. Как я понимаю задачу, при первом (в рамках обработки одного пользовательского запроса) вызове s.GetByID(id) должна стартовать SQL-транзакция, которая в конце либо закоммитит s.UserRepository.Update(user) и s.CarRepository.Update(car), либо откатит при любой ошибке (не только SQL) все изменения.
arzonus
05.07.2017 10:25Да все верно :)
Я просто к тому, что в рамках go, нету удобных вещей по разруливанию транзакций, используя Repository, без передачи sql.Tx в качестве аргумента. И было бы просто интересно, есть ли хорошие решения данной проблемы именно для Go.aml
05.07.2017 18:09А почему sql.Tx нельзя передать? Если от функции ожидается проводить операции в рамках транзакции, логично, что она должна к ней доступ иметь. Я пока не очень понимаю, что именно неудобно.
VolCh
06.07.2017 14:25В общем случае не ожидается. Репозиторию должно быть всё равно в транзакции он работает или нет. Паттерн "Репозиторий" и предназначен по сути для абстрагирования от таких деталей, а в целом даже от СУБД в целом, не говоря о том транзакционная она или нет.
aml
06.07.2017 18:32Если принципиально сохранить этот слой, который изолирует приложение от базы, то для него нужны свои способы тестирования — это факт. Свои какие-то интерфейсы, как минимум. Но обычно приложения на Go пишут без подобных абстракций. Нужна транзакция — пишут транзакцию. Нужно параллельно выполнить запросы — запускают горутины. Нужно последовательно — делают последовательно. И писать такой код проще, и поддерживать, тестировать.
Hypnoglow
06.07.2017 20:51В моем видении, репозиторий это НЕ просто объект для доступа к базе данных, и он не обязан отвечать за все запросы к определенной таблице. То есть,
UserRepository
не должен инкапсулировать абсолютно всё взаимодействие с таблицой пользователей.
Более того, можно рассматривать логику с такой точки зрения, что
ChangeOwner
не меняет сущность пользователя и не меняет сущность автомобиля. Меняется только право владения автомобилем — оно переходит к другому пользователю. И мы абстрагируемся от того, что в базе данных это реализовано как столбцы в таблицах пользователей и автомобилей.
С таким подходом мы можем создать отдельную структуру
CarOwnershipChange
и соответствующий репозиторий. При сохранении такого объекта, репозиторий инкапсулирует все нюансы транзакции, и логика "сделки" вCarService
получается проще и чище.arzonus
08.07.2017 10:57Это классная концепция, без учета возможной поддержки кеширования, других бд. Но ее огромный минус в том, что на каждый чих придется создавать свой repository. Плюс, Repository в классическом понимании, это паттерн доступа к данных.
youROCK
Ни один из этих подходов не является достаточно хорошо продуманным.
Третий вариант почти хорош, но только интерфейсы нужно объявлять там, где зависимость принимается, или, если интерфейс достаточно общий, то он должен быть выселен в отдельный пакет. Это позволяет избежать циклических зависимостей во многих случаях и не замусоривает код — не требуется менять реализацию models и не нужно копировать определения всех методов до того момента, когда нужен будет мок. Можно посмотреть на стандартную библиотеку — в ней есть, к примеру, *os.File, *bytes.Buffer и другие, которые реализуют интерфейс io.Reader. Везде при этом принимаются в качестве аргументов именно эти интерфейсы, а не конкретные реализации. Это позволяет, к примеру, создать фактическую циклическую зависимость между пакетами os и fmt, но при этом только os импортирует fmt, но не наоборот, поскольку fmt везде принимает io.Reader и io.Writer. Это одновременно и легко тестируемый код и очень гибкий в том плане, что «бесплатно» пакет fmt теперь умеет печатать в любой io.Writer.
aml
Модуль sql сам по себе — это уже абстракция над конкретными драйверами, там внутри есть все нужные интерфейсы. Я бы выбрал вариант 2 (с явной передачей *sql.DB в структуры обработчиков), а для тестирования использовал клёвый мок — https://godoc.org/github.com/DATA-DOG/go-sqlmock.