Golang持久化實(shí)用設(shè)計(jì):合理規(guī)劃數(shù)據(jù)庫接入方式

此篇文章轉(zhuǎn)自Alex Edwards的Practical Persistence in Go: Organising Database Access,原文地址http://www.alexedwards.net/blog/organising-database-access右钾。

在涉及到HTTP服務(wù)接入數(shù)據(jù)庫的問題上椭懊,不同的人會(huì)給出不一樣的答案:一些人會(huì)推薦依賴注入方式,另一些人會(huì)建議簡單粗暴的使用全局變量踪宠,還有一些會(huì)將連接池嵌入到x/net/context中自赔。

但是據(jù)我自己的經(jīng)驗(yàn)來講,應(yīng)當(dāng)根據(jù)項(xiàng)目需求選擇不同的方式柳琢。這里面涉及到的問題有工程的架構(gòu)和規(guī)模大小绍妨,軟件測試方式和工程的規(guī)劃方向润脸,所有的這些因素都會(huì)影響你選擇數(shù)據(jù)庫接入的方式。

在這里我簡要概述始終不同的代碼結(jié)構(gòu)以及數(shù)據(jù)庫接入方式他去。

Global variables

全局變量是我們第一種選擇毙驯,這種方式簡單、直接灾测,直接設(shè)置一個(gè)指向數(shù)據(jù)庫連接池的指針為全局變量爆价。

為了保持代碼的整潔和簡練,有時(shí)我們使用初始化函數(shù)以便數(shù)據(jù)庫連接服務(wù)可以在其他包完成工作媳搪。

我比較喜歡拿代碼說事铭段,所以我們拿一個(gè)在線圖書商店的的工程示例。示例使用使用MVC框架來進(jìn)行HTTP服務(wù)的處理秦爆,工程包括main包和models包序愚,models包含DB全局變量、InitDB()函數(shù)以及我們的數(shù)據(jù)庫業(yè)務(wù)邏輯鲜结。

bookstore

├── main.go

└── models

  ├── books.go

  └── 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
}

如果你執(zhí)行程序并且構(gòu)建一個(gè)請求到/books展运,應(yīng)該會(huì)得到一個(gè)類似下面的響應(yīng):

$ 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

使用全局變量方式,適用于以下情況:

  1. 所有的數(shù)據(jù)庫業(yè)務(wù)邏輯包含在一個(gè)包中精刷。
  2. 程序的規(guī)模小拗胜、頭腦中能夠清晰構(gòu)建出業(yè)務(wù)邏輯。
  3. 程序測試方法不需要mock數(shù)據(jù)庫實(shí)例怒允。

上例中我們使用全局變量效果還OK埂软,但是在更加復(fù)雜數(shù)據(jù)庫業(yè)務(wù)邏輯的程序中就會(huì)顯得力不從心了。一個(gè)可選的方式是多次調(diào)用InitDB函數(shù)纫事,這樣會(huì)導(dǎo)致程序代碼的碎片化(很容易忘記初始化一個(gè)數(shù)據(jù)庫連接勘畔,使得運(yùn)行時(shí)得到一個(gè)nil指針)。第二種可選方式是創(chuàng)建一個(gè)config包丽惶,用于管理數(shù)據(jù)庫連接的初始化和管理工作炫七。

Dependency injection

第二種方式就是我們將要看到的依賴注入方式,在下面這個(gè)例子中钾唬,我們傳遞一個(gè)連接指針到HTTP處理函數(shù)處理數(shù)據(jù)庫的業(yè)務(wù)邏輯万哪。

在現(xiàn)實(shí)的應(yīng)用程序中,可能存在一個(gè)并行的程序需要接入抡秆,例如logger奕巍、template cache和數(shù)據(jù)庫連接等。

到目前為止儒士,在線圖書超市中的止,我們處理函數(shù)都位于一個(gè)包中,一個(gè)優(yōu)雅的方式是將這些變量都嵌入到Env類型中

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

接下來可以定義你自己的處理函數(shù)着撩,處理函數(shù)的receiver是Env類型诅福。這種方式是處理數(shù)據(jù)庫連接的一種簡潔常用的方式匾委。

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
}

Or using a closure…

另外一個(gè)可選的方式是將Env類型封裝到你的業(yè)務(wù)處理邏輯中:

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

依賴注入在以下情況下是一個(gè)比較好的選擇:

  1. 所有的處理函數(shù)都位于一個(gè)包中
  2. 所有的處理函數(shù)的依賴有一個(gè)共性的集合
  3. 程序測試方法不需要mock數(shù)據(jù)庫實(shí)例

Using an interface

我們可以進(jìn)一步探討一下依賴注入的方式。改變一下models包以使models包輸出DB類型(包含*sql.DB)权谁,并且實(shí)現(xiàn)了基本的數(shù)據(jù)庫業(yè)務(wù)邏輯剩檀。

這種處理方式的優(yōu)點(diǎn)有兩個(gè):一方面可以使代碼的架構(gòu)整潔憋沿,另一方面尤為重要的是可以在程序測試中mock數(shù)據(jù)庫實(shí)例旺芽。

讓我們修正一下上面的例子,增加一個(gè)Datastore的接口辐啄,接口實(shí)現(xiàn)了上例DB類型的處理函數(shù)采章。

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

我們可以使用這種接口類型來替換DB類型,以下是改寫后的代碼:

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
}

由于我們現(xiàn)在的處理函數(shù)使用Datastore接口類型壶辜,所以可以很容易的在單元測試中mock數(shù)據(jù)庫實(shí)例:

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

最后讓我們看一下請求上下文來存儲(chǔ)和傳遞數(shù)據(jù)庫連接悯舟,我們將使用 x/net/context 包。我們最后修改一下在線圖書超市砸民,傳遞context.Context給我們的處理函數(shù)抵怎。

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
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市岭参,隨后出現(xiàn)的幾起案子反惕,更是在濱河造成了極大的恐慌,老刑警劉巖演侯,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件姿染,死亡現(xiàn)場離奇詭異,居然都是意外死亡秒际,警方通過查閱死者的電腦和手機(jī)悬赏,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來娄徊,“玉大人闽颇,你說我怎么就攤上這事〖娜瘢” “怎么了兵多?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長锐峭。 經(jīng)常有香客問我中鼠,道長,這世上最難降的妖魔是什么沿癞? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任援雇,我火速辦了婚禮,結(jié)果婚禮上椎扬,老公的妹妹穿的比我還像新娘惫搏。我一直安慰自己具温,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布筐赔。 她就那樣靜靜地躺著铣猩,像睡著了一般。 火紅的嫁衣襯著肌膚如雪茴丰。 梳的紋絲不亂的頭發(fā)上达皿,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天,我揣著相機(jī)與錄音贿肩,去河邊找鬼峦椰。 笑死,一個(gè)胖子當(dāng)著我的面吹牛汰规,可吹牛的內(nèi)容都是我干的汤功。 我是一名探鬼主播,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼溜哮,長吁一口氣:“原來是場噩夢啊……” “哼滔金!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起茂嗓,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤餐茵,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后在抛,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體钟病,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年刚梭,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了肠阱。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,096評論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡朴读,死狀恐怖屹徘,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情衅金,我是刑警寧澤噪伊,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站氮唯,受9級特大地震影響鉴吹,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜惩琉,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一豆励、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦良蒸、人聲如沸技扼。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽剿吻。三九已至,卻和暖如春串纺,著一層夾襖步出監(jiān)牢的瞬間丽旅,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工造垛, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留魔招,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓五辽,卻偏偏與公主長得像,于是被迫代替她去往敵國和親外恕。 傳聞我的和親對象是個(gè)殘疾皇子杆逗,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容