Практическое сохранение в Go: организация доступа к базе данных
21.09.2017 г.
Несколько недель назад кто-то создал поток на Reddit с вопросом:
В контексте веб-приложения, что вы считаете лучшей практикой Go для доступа к базе данных в (HTTP или других) обработчиках?
Ответы, которые он получил, были действительно интересным миром. Некоторые люди посоветовали использовать инъекцию зависимостей, некоторые из них поддержали простоту использования глобальных переменных, другие предложили поместить указатель пула соединений в x / net / context.
Меня? Я думаю, что правильный ответ зависит от проекта.
Какова общая структура и размер проекта? Каков ваш подход к тестированию? Как это может расти в будущем? Все эти вещи и многое другое должны играть определенную роль, когда вы выбираете подход.
Поэтому в этом посте я рассмотрю четыре разных метода организации вашего кода и структурирования доступа к пулу подключений к базе данных.
Глобальные переменные
Первый подход, который мы рассмотрим, является общим и простым: поместить указатель в пул соединений с базой данных в глобальной переменной.
Чтобы код был красивым и сухим, вы иногда увидите его в сочетании с функцией инициализации, которая позволяет установить глобальный пул соединений из других пакетов и тестов.
Мне нравятся конкретные примеры, поэтому давайте продолжим работу с базой данных онлайн-магазина и кодом из моего предыдущего сообщения . Мы создадим простое приложение с MVC-подобной структурой - с обработчиками HTTP mainи modelsпакетом, содержащим глобальную DBпеременную, InitDB()функцию и нашу логику базы данных.
bookstore
├── main.go
└── models
├── books.go
└── db.go
Файл: 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)
}
}
Файл: 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)
}
}
Файл: 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, Niccolò Machiavelli, £6.99
Использование глобальной переменной, подобной этой, потенциально хорошо подходит, если:
Вся ваша логика базы данных содержится в одном пакете. Ваше приложение достаточно мало, чтобы отслеживать глобалы в голове не проблема. Ваш подход к тестированию означает, что вам не нужно издеваться над базой данных или запускать тесты параллельно. Для примера выше, используя глобальные работы, просто отлично. Но что происходит в более сложных приложениях, где логика базы данных распространяется на несколько пакетов?
Один из вариантов состоит в том, чтобы иметь несколько InitDBвызовов, но это может быстро стать беспорядочным, и я лично нашел его немного flaky (легко забыть инициализировать пул соединений и получать паники nil-pointer во время выполнения). Второй вариант - создать отдельный configпакет с экспортируемой DBпеременной и import "yourproject/config"в каждый файл, который ему нужен. На всякий случай не сразу понятно, что я имею в виду, я включил простой пример в эту суть .
Внедрение зависимости
Второй подход, который мы рассмотрим, - это инъекция зависимостей. В нашем примере мы хотим явно передать указатель пула соединений нашим обработчикам HTTP, а затем дальше к нашей логике базы данных.
В реальном приложении есть, возможно, дополнительные элементы уровня приложения (и совместимые с параллелизмом), к которым вы хотите иметь доступ к вашим обработчикам. Такие вещи, как указатели на ваш логгер или кеш шаблонов, а также пул соединений с базой данных.
Таким образом, для проектов, в которых все ваши обработчики находятся в одном пакете, аккуратный подход заключается в том, чтобы поместить эти элементы в настраиваемый Envтип:
type Env struct {
db *sql.DB
logger *log.Logger
templates *template.Template
}
... и затем определите обработчики как методы против Env. Это обеспечивает чистый и идиоматический способ создания пула соединений (и потенциально других элементов) для ваших обработчиков. Вот полный пример:
Файл: 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)
}
}
Файл: 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
}
Файл: 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переменной типа так:
Файл: 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)
}
})
}
Зависимость инъекции таким образом является довольно приятным подходом, когда:
Все ваши обработчики содержатся в одном пакете. Существует общий набор зависимостей, необходимых каждому из ваших обработчиков. Ваш подход к тестированию означает, что вам не нужно издеваться над базой данных или запускать тесты параллельно. Опять же, вы все равно можете использовать этот общий подход, если ваши обработчики и логика базы данных будут распределены по нескольким пакетам. Одним из способов достижения этого было бы настроить отдельный configпакет, экспортирующий Envтип и закрыть config.Env его так же, как в приведенном выше примере. Вот основной смысл .
Использование интерфейса
Мы можем принять этот пример инъекции зависимостей немного дальше. Давайте изменим modelsпакет так, чтобы он экспортировал настраиваемый DBтип (который внедрялся *sql.DB) и реализовал нашу логику базы данных как методы против DBтипа.
Преимущества этого в два раза: сначала он дает нашему кодексу действительно чистую структуру, но, что еще важнее, он также открывает потенциал для издевательства нашей базы данных для модульного тестирования.
Давайте поправьте пример, чтобы включить новый Datastoreинтерфейс, который реализует точно такие же методы, как наш новый DBтип.
type Datastore interface {
AllBooks() ([]*Book, error)
}
Затем мы можем использовать этот интерфейс вместо прямого DBтипа в нашем приложении. Вот обновленный пример:
Файл: 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)
}
}
Файл: 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
}
Файл: 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())
}
}
Контекст с учетом запросов
Наконец, давайте рассмотрим использование контекста с областью запросов для хранения и передачи пула подключений к базе данных. В частности, мы будем использовать пакет x / net / context .
Лично я не являюсь поклонником хранения переменных уровня приложения в контексте с охватом запросов - он чувствует себя неуклюжим и обременительным для меня. Контекстная документация x / net / context также советует:
Использовать контекст Значения только для данных с запросом, которые проходят процессы и API, а не для передачи необязательных параметров для функций.
Тем не менее, люди делают использовать этот подход. И если ваш проект состоит из разрастающегося набора пакетов - и использование глобальной конфигурации не может быть и речи - это довольно привлекательное предложение.
Давайте адаптируем пример книжного магазина в последний раз, перейдем context.Contextк нашим обработчикам, используя шаблон, предложенный в этой замечательной статье Джо Шоу.
Файл: 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)
}
}
Файл: 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
}
Файл: 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
}