Go:访问数据库代码组织方式

在这篇文章中,我们将看看四种不同的方法来组织你的代码和结构化访问你的数据库连接池,并解释它们什么时候适合你的项目。

应用配制

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

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、使用全局变量

让我们从在全局变量中存储数据库连接池开始。

这种方式可以说是最简单的可行方法。在main()函数中初始化sql.DB连接池,将其赋值给一个全局变量,然后从需要执行数据库查询的任何地方访问该全局变量。在我们的书店应用程序的上下文中,代码看起来像这样:

models.go
package models

import (
    "database/sql"
)

// 创建一个可导出的全局变量来保存数据库连接池。
var DB *sql.DB

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

// AllBooks返回books表中所有书籍的切片。
func AllBooks() ([]Book, error) {
    // 注意,我们在全局变量上调用Query()。
    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

    // 初始化sql.DB连接池,并将其分配给models.DB全局变量。
    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发送一个HTTP响应,列出所有的书。
func booksIndex(w http.ResponseWriter, r *http.Request) {
    bks, err := models.AllBooks()
    if err != nil {
        log.Println(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处理程序分布在多个包中,但所有与数据库相关的代码都位于一个包中。
  • 您不需要为了测试而模拟数据库。
    在实践中我发现,对于小型和简单的项目,使用这样的全局变量是很有效的,而且它(可以说)比我们将在本文中讨论的其他方法更清晰和更容易理解。

对于更复杂的应用程序,包含更多依赖项,而不仅仅是数据库连接池—通常使用依赖项注入更合适而不是将所有东西存储在全局变量中。

1b、使用InitDB函数设置全局变量

“全局变量”方法的一个变体,我有时看到使用一个初始化函数来设置连接池,像这样:

models.go
package models

import (
    "database/sql"

    _ "github.com/lib/pq"
)

// 这一次全局变量不可导出。
var db *sql.DB

// InitDB连接池全局变量的设置。
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) {
    // 现在使用不可导出的全局变量。
    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() {
    // 使用InitDB函数初始化全局变量。
    err := models.InitDB("postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Fatal(err)
    }

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

...             

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

  • 所有与数据库相关的代码现在都在一个包中,包括设置连接池的代码。
  • 全局变量db不可导出,消除它在运行时被其他包意外更改的可能性。
  • 在测试时,你可以重用InitDB函数初始化数据库连接池。

依赖注入

在一个更复杂的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
}

//  更新AllBooks函数,使其接受连接池作为参数。
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"
)

// 创建一个保存连接池的自定义Env结构。
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 := &Env{db: db}

    // 使用env.booksIndex作为/books路由的处理函数。
    http.HandleFunc("/books", env.booksIndex)
    http.ListenAndServe(":3000", nil)
}

// 将booksIndex定义为Env上的方法。
func (env *Env) booksIndex(w http.ResponseWriter, r *http.Request) {
    //  现在我们可以在处理程序中直接访问连接池。
    bks, err := models.AllBooks(env.db)
    if err != nil {
        log.Println(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}

    // 将Env结构体作为参数传递给booksIndex()。
    http.Handle("/books", booksIndex(env))
    http.ListenAndServe(":3000", nil)
}

// 闭包使处理程序逻辑可以使用Env。
func booksIndex(env *Env) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        bks, err := models.AllBooks(env.db)
        if err != nil {
            log.Println(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)
        }
    }
} 

这种模式使处理程序函数更加冗长,但如果您想在处理程序分散在多个包中时使用依赖注入,那么它可能是一种有用的技术。

3、包装连接池

我们将看到的第三种模式还是使用依赖注入,但这次我们将用自己的自定义类型包装sql.DB连接池。
直接看代码:

models.go
package models

import (
    "database/sql"
)

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

// 创建一个包装sql.DB连接池的自定义BookModel类型。
type BookModel struct {
    DB *sql.DB
}

// 使用自定义BookModel类型上的方法来运行SQL查询。
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
}
main.go
package main

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

    "bookstore.alexedwards.net/models"

    _ "github.com/lib/pq"
)

// 这次将BookModel作为env的依赖项。
type Env struct {
    books models.BookModel
}

func main() {
    // 正常初始化连接池。
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/bookstore")
    if err != nil {
        log.Fatal(err)
    }

    // 使用BookModel实例对env进行初始化 ,对DB封装
    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) {
    // 通过调用All()方法执行SQL查询。
    bks, err := env.books.All()
    if err != nil {
        log.Println(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 {
    // 将models.BookModel用一个实现了All方法的接口来替换,其他都不变
    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.Println(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的接口来实现。

一旦做出了改变,你就能够使用mockBookModel为booksIndex()处理程序创建并运行单元测试,如下所示:

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结构体将其与依赖注入结合在一起是非常好的方法:

  • 处理程序需要访问一组公共的依赖项。
  • 数据库层有更多的依赖项,而不仅仅是连接池。
  • 在单元测试期间可以模拟数据库

请求上下文(request context)

最后,让我们看看如何使用请求上下文来存储和传递数据库连接池。先说清楚,我不推荐使用这种方法,官方文档也建议不要使用。

换句话说,这意味着请求上下文应该只用于存储在单个请求周期中创建的、在请求完成后不再需要的值。它实际上并不打算存储长期存在的处理程序依赖关系,如连接池、日志对象或模板缓存。也就是说,有些人确实是以这种方式使用请求上下文的,如果你遇到它,需注意。

main.go
package main

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

    "bookstore.alexedwards.net/models"

    _ "github.com/lib/pq"
)

//  创建中间件,将现有的请求上下文替换为包含连接池的上下文。
func injectDB(db *sql.DB, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), "db", db)

        next.ServeHTTP(w, r.WithContext(ctx))
    }
}

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

    // 用injectDB中间件包装booksIndex处理程序,
    // 将连接池传入新的上下文环境中
    http.Handle("/books", injectDB(db, booksIndex))
    http.ListenAndServe(":3000", nil)
}

func booksIndex(w http.ResponseWriter, r *http.Request) {
    //将请求上下文传递到数据库层。
    bks, err := models.AllBooks(r.Context())
    if err != nil {
        log.Println(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)
    }
}                      

本质上,这里所发生的事情是,injectDB中间件将每个请求的请求上下文替换为包含连接池的请求上下文。然后,在处理程序中,我们将请求上下文传递给数据库层。

然后在数据库层,我们可以从上下文读取连接池,并使用:

models.go
package models

import (
    "context"
    "database/sql"
    "errors"
)

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

func AllBooks(ctx context.Context) ([]Book, error) {
    // 从上下文检索连接池,因为r.Context().Value()方法总是返回一个接口{}类型,所以需要通过断言来使用
    db, ok := ctx.Value("db").(*sql.DB)
    if !ok {
        return nil, errors.New("could not get database connection pool from context")
    }

    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
}   

如果您继续运行这段代码,可以正常工作。但这种模式也有一些严重的缺点:

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

那么,有没有一种情况是这种模式很适合的呢?如果您有一个庞大的代码库,其中的处理程序和数据库逻辑分布在许多不同的包中,那么传递连接池可能是一种简单的方式。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272

推荐阅读更多精彩内容