Go中访问数据库的最佳实践

在 Web 应用程序的上下文中,您认为在 (HTTP 或其他) 处理程序中访问数据库的 Go 最佳实践是什么?

有些人建议使用依赖注入,有些人喜欢使用全局变量的简单性,其他人建议将连接池指针放入请求上下文中。

正确答案取决于项目。
项目的整体结构和规模如何?你的测试方法是什么?未来可能会如何发展?当你选择一种方法时,所有这些因素以及其他因素都应该发挥作用。

因此,在这篇文章中,我们将介绍四种不同的组织代码和构建数据库连接池访问的方法,并解释它们何时可能适合您的项目,或可能不适合。

应用程序设置
我喜欢具体的例子,所以让我们设置一个简单的书店应用程序来帮助说明这四种不同的方法。如果您想继续,您需要创建一个新的bookstore数据库,然后执行以下 SQL 来创建books表并添加一些示例记录。

CREATE TABLE books (
    isbn char(14) NOT NULL,
    title varchar(255) NOT NULL,
    author varchar(255) NOT NULL,
    price decimal(5,2) NOT NULL
);

INSERT INTO books (isbn, title, author, price) VALUES
('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);

ALTER TABLE books ADD PRIMARY KEY (isbn);


在本教程中,我将使用 PostgreSQL,但无论您使用什么数据库,原理都是相同的。

您还需要运行以下命令来构建基本的应用程序结构并初始化 Go 模块:

$ mkdir bookstore && cd bookstore
$ mkdir models
$ touch main.go models/models.go
$ go mod init bookstore.alexedwards.net
go: creating new go.mod: module bookstore.alexedwards.net

此时,您bookstore的机器上应该有一个结构完全相同的目录:

bookstore/
├── go.mod
├── main.go
└── models
    └── models.go

1.使用全局变量
好的,让我们首先看看如何将数据库连接池存储在全局变量中。

这种方法可以说是最简单的方法。您可以在函数中初始化sql.DB连接池main(),将其分配给全局变量,然后从需要执行数据库查询的任何位置访问全局变量。

在我们的书店应用程序中,代码看起来像这样:

models/models.go

package models

import (
    "database/sql"
)

// Create an exported global variable to hold the database connection pool.
var DB *sql.DB

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

// AllBooks returns a slice of all books in the books table.
func AllBooks() ([]Book, error) {
   
// Note that we are calling Query() on the global variable.
    rows, err := DB.Query(
"SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var bks []Book

    for rows.Next() {
        var bk 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
}               

main.go

package main

import (
    "database/sql"
   
"fmt"
   
"log"
   
"net/http"

   
"bookstore.alexedwards.net/models"

    _
"github.com/lib/pq"
)

func main() {
    var err error

   
// Initalize the sql.DB connection pool and assign it to the models.DB 
   
// global variable.
    models.DB, err = sql.Open(
"postgres", "postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Fatal(err)
    }

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

// booksIndex sends a HTTP response listing all books.
func booksIndex(w http.ResponseWriter, r *http.Request) {
    bks, err := models.AllBooks()
    if err != nil {
        log.Print(err)
        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)
    }
}           

此时,如果您运行此应用程序并向端点/books发出请求,您应该得到以下响应:

$ curl localhost:3000/books
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


在以下情况下,使用全局变量来存储数据库连接池可能是一个不错的选择:

  • 您的应用程序小而简单,在头脑中跟踪全局变量不是问题。
  • 您的 HTTP 处理程序分布在多个包中,但所有与数据库相关的代码都位于一个包中。
  • 您不需要为了测试目的而模拟数据库。

使用全局变量的缺点是有据可查的,但在实践中我发现对于小型和简单的项目,使用这样的全局变量就很好了,并且它(可以说)比我们将在本文中介绍的其他一些方法更清晰、更容易理解。

对于更复杂的应用程序(处理程序除了数据库连接池之外还有更多的依赖项),通常最好使用依赖注入,而不是将所有内容存储在全局变量中。

如果您的数据库逻辑分布在多个包中,我们在这里采用的方法也不起作用,尽管——如果您真的想要——您可以将一个config包含导出的DB全局变量的单独包放入每个需要它的文件中。我在这个要点import "yourproject/config"中提供了一个基本示例。


1b. 具有 InitDB 函数的全局变量
我有时看到的“全局变量”方法的变体使用初始化函数来设置连接池,如下所示:

文件:models/models.go

package models

import (
    "database/sql"

    _
"github.com/lib/pq"
)

// This time the global variable is unexported.
var db *sql.DB

// InitDB sets up setting up the connection pool global variable.
func InitDB(dataSourceName string) error {
    var err error

    db, err = sql.Open(
"postgres", dataSourceName)
    if err != nil {
        return err
    }

    return db.Ping()
}

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

func AllBooks() ([]Book, error) {
   
// This now uses the unexported global variable.
    rows, err := db.Query(
"SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var bks []Book

    for rows.Next() {
        var bk 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
}  

文件:main.go
package main

import (
    "fmt"
   
"log"
   
"net/http"

   
"bookstore.alexedwards.net/models"
)

func main() {
   
// Use the InitDB function to initialise the global variable.
    err := models.InitDB(
"postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Fatal(err)
    }

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

...             

这是对全局变量模式的一个小调整,但它给我们带来了一些好处:

  • 所有与数据库相关的代码现在都位于一个包中,包括设置连接池的代码。
  • 全局db变量未被导出,这消除了它在运行时被其他包意外改变的可能性。
  • 在测试期间,您可以重用该InitDB()函数来初始化到测试数据库的连接池(通过在TestMain()测试运行之前调用它)。

2.依赖注入
在更复杂的 Web 应用程序中,您可能希望处理程序能够访问其他应用程序级对象。例如,您可能希望处理程序也能访问共享记录器或模板缓存以及数据库连接池。

一个简洁的方法是将它们存储在单个自定义Env结构中,而不是将所有这些依赖项存储在全局变量中,如下所示:

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

这样做的好处是,您可以将处理程序定义为针对的方法Env。这为您提供了一种简单而惯用的方法,使连接池(以及任何其他依赖项)可供处理程序使用。

这是一个完整的例子:

文件:models/models.go

package models

import (
    "database/sql"
)

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

// Update the AllBooks function so it accepts the connection pool as a 
// parameter.
func AllBooks(db *sql.DB) ([]Book, error) {
    rows, err := db.Query(
"SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var bks []Book

    for rows.Next() {
        var bk 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
}     

文件:main.go
package main

import (
    "database/sql"
   
"fmt"
   
"log"
   
"net/http"

   
"bookstore.alexedwards.net/models"

    _
"github.com/lib/pq"
)

// Create a custom Env struct which holds a connection pool.
type Env struct {
    db *sql.DB
}

func main() {
   
// Initialise the connection pool.
    db, err := sql.Open(
"postgres", "postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Fatal(err)
    }

   
// Create an instance of Env containing the connection pool.
    env := &Env{db: db}

   
// Use env.booksIndex as the handler function for the /books route.
    http.HandleFunc(
"/books", env.booksIndex)
    http.ListenAndServe(
":3000", nil)
}

// Define booksIndex as a method on Env.
func (env *Env) booksIndex(w http.ResponseWriter, r *http.Request) {
   
// We can now access the connection pool directly in our handlers.
    bks, err := models.AllBooks(env.db)
    if err != nil {
        log.Print(err)
        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 结构),我们只需查看它在 main() 函数中的初始化方式,就能知道它们在运行时的取值。

另一个好处是,处理程序的单元测试可以完全独立。 例如,针对 booksIndex() 的单元测试可以创建一个 Env 结构,其中包含一个连接到测试数据库的连接池,然后调用它的 booksIndex() 方法来测试处理程序的行为。 测试之外无需依赖任何全局变量。

一般来说,在以下情况下,这种依赖注入是一种很好的方法:

  • 您的处理程序需要访问一组通用的依赖关系。
  • 所有 HTTP 处理程序都位于一个包中,但与数据库相关的代码可能分布在多个包中。
  • 您不需要为了测试目的而模拟数据库。

2b. 通过闭包进行依赖注入
如果不想将处理程序定义为 Env 上的方法,另一种方法是将处理程序逻辑放到闭包中,并像这样在 Env 变量上闭包:

main.go

package main

import (
    "database/sql"
   
"fmt"
   
"log"
   
"net/http"

   
"bookstore.alexedwards.net/models"

    _
"github.com/lib/pq"
)

type Env struct {
    db *sql.DB
}

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

    env := &Env{db: db}

   
// Pass the Env struct as a parameter to booksIndex().
    http.Handle(
"/books", booksIndex(env))
    http.ListenAndServe(
":3000", nil)
}

// Use a closure to make Env available to the handler logic.
func booksIndex(env *Env) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        bks, err := models.AllBooks(env.db)
        if err != nil {
            log.Print(err)
            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)
        }
    }
}    

这种模式会让我们的处理程序函数变得有点冗长,但如果你想在处理程序分散在多个软件包中时使用依赖注入,这种技术还是很有用的。 下面的 gist 演示了如何使用。

3. 封装连接池
我们要看的第三种模式再次使用依赖注入,但这次我们要将 sql.DB 连接池封装到我们自己的自定义类型中。

models/models.go

package models

import (
    "database/sql"
)

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

// Create a custom BookModel type which wraps the sql.DB connection pool.
type BookModel struct {
    DB *sql.DB
}

// Use a method on the custom BookModel type to run the SQL query.
func (m BookModel) All() ([]Book, error) {
    rows, err := m.DB.Query(
"SELECT * FROM books")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var bks []Book

    for rows.Next() {
        var bk 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
}

File: main.go
package main

import (
    "database/sql"
    
"fmt"
    
"log"
    
"net/http"

    
"bookstore.alexedwards.net/models"

    _
"github.com/lib/pq"
)

// This time make models.BookModel the dependency in Env.
type Env struct {
    books models.BookModel
}

func main() {
   
// Initialise the connection pool as normal.
    db, err := sql.Open(
"postgres", "postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Fatal(err)
    }

   
// Initalise Env with a models.BookModel instance (which in turn wraps
   
// the connection pool).
    env := &Env{
        books: models.BookModel{DB: db},
    }

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

func (env *Env) booksIndex(w http.ResponseWriter, r *http.Request) {
   
// Execute the SQL query by calling the All() method.
    bks, err := env.books.All()
    if err != nil {
        log.Print(err)
        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)
    }
}

乍一看,这种模式可能会比我们看过的其他选项更令人困惑--尤其是如果你对 Go 不是很熟悉的话。

但与我们之前的示例相比,它有一些明显的优势:

  • 数据库调用简洁明了,而且从我们的处理程序的角度来看,读起来非常顺畅:env.books.All() 而不是之前的 models.AllBooks(env.db)。 在复杂的应用程序中,数据库访问层可能不仅仅依赖于连接池。 这种模式允许我们将所有这些依赖关系存储在自定义 BookModel 类型中,而不必在每次调用时都将它们作为参数传递。
  • 由于数据库操作现在被定义为自定义 BookModel 类型的方法,因此我们有机会在应用代码中用接口替换对 BookModel 的任何引用。 反过来,这也意味着我们可以创建一个 BookModel 的模拟实现,供测试时使用。

最后一点可能是最重要的,让我们来看看它在实践中会是什么样子:

main.go

package main

import (
    "database/sql"
    
"fmt"
    
"log"
    
"net/http"

    
"bookstore.alexedwards.net/models"

    _
"github.com/lib/pq"
)

type Env struct {
   
// Replace the reference to models.BookModel with an interface 
   
// describing its methods instead. All the other code remains exactly 
   
// the same. 
    books interface {
        All() ([]models.Book, error)
    }
}

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

    env := &Env{
        books: models.BookModel{DB: db},
    }

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

func (env *Env) booksIndex(w http.ResponseWriter, r *http.Request) {
    bks, err := env.books.All()
    if err != nil {
        log.Print(err)
        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)
    }
}

一旦完成了上述更改,就可以使用 mockBookModel 创建并运行针对 booksIndex() 处理程序的单元测试:
$ touch main_test.go

main_test.go

package main

import (
    "net/http"
    
"net/http/httptest"
    
"testing"

    
"bookstore.alexedwards.net/models"
)

type mockBookModel struct{}

func (m *mockBookModel) All() ([]models.Book, error) {
    var bks []models.Book

    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{books: &mockBookModel{}}

    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())
    }
}

$ go test -v

=== RUN   TestBooksIndex


PASS: TestBooksIndex (0.00s)
PASS
ok      bookstore.alexedwards.net       0.003s

在以下情况下,使用自定义类型包装连接池并通过Env结构将其与依赖注入相结合是一种非常好的方法:

  • 您的处理程序需要访问一组通用的依赖关系。
  • 您的数据库层除了连接池之外还有更多的依赖项。
  • 您想在单元测试期间模拟数据库。

4. 请求上下文
最后,让我们看看如何使用请求上下文来存储和传递数据库连接池。首先要明确一点,我不建议使用这种方法,官方文档也不建议这样做:
上下文值仅用于传输流程和 API 的请求范围数据,而不是用于将可选参数传递给函数。

换句话说,这意味着请求上下文仅应用于存储在单个请求周期内创建的值,并且在请求完成后不再需要。它实际上并不用于存储长期存在的处理程序依赖项,如连接池、记录器或模板缓存。

也就是说,有些人确实以这种方式使用请求上下文,如果你遇到它,就值得注意。

这种模式有一些很大的缺点:

  • 每次从上下文中检索连接池时,我们都需要对其进行类型断言并检查是否有任何错误。这使我们的代码更加冗长,并且我们失去了其他方法所具有的编译时类型安全性。
  • 与依赖注入模式不同,仅通过查看函数的签名无法清楚地知道函数具有哪些依赖项。相反,您必须通读代码才能查看它从请求上下文中检索的内容。在小型应用程序中这不是问题 - 但如果您试图掌握庞大而陌生的代码库,那么它就不理想了。
  • 这不符合 Go 的惯用语言。以这种方式使用请求上下文违背了官方文档中的建议,这意味着这种模式可能会让其他 Go 开发人员感到惊讶或不熟悉。