互联网开发最重要的一部分就是与数据库的交互,该部分在我们分层互联网模型中会归属于 — 模型层 + 仓库层。ORM — Object Relational Mapping,即代码模型与数据库模型(主要指向关系型数据库模型)之间的映射。熟悉 Java 网络编程的同学可能都接触过 MyBatis、Hibernate等ORM框架,这些框架大大地减少了我们与数据库之间交互的繁杂性。
Golang 作为21世纪新兴的编程语言,出生就自带了数据库交互组件,gosdk 中 database/sql 包就是对数据库提供支持的组件包。
但是,在本次文档中我们就先跳过原生的数据库组件,先从进阶的 gorm
包开始讨论。(PS:不讨论实现原理)
在接下来的文档里,我将以一种简单的互联网项目的思路去设计模型层 + 仓库层:
建立目录 ${ROOT}/model/conf
,在内部创建文件 db.go
。编写一下结构体:
type DB struct {
Dialect string `yaml:"dialect"` // 数据库语言
Username string `yaml:"username"` // 数据库连接用户名
Password string `yaml:"password"` // 数据库连接密码
Name string `yaml:"name"` // 数据库名
Host string `yaml:"host"` // 数据库连接服务器地址
Port int `yaml:"port"` // 数据库连接服务器端口号
Query string `yaml:"query"` // 数据库连接使用的extra参数
DebugMode bool `yaml:"debugMode"` // 数据库是否进入debug模式
}
创建上述结构体的原因在于,我们在项目中将要使用yaml文件的方式去进行配置,对go-yaml不是很熟悉的同学可以阅读我博客中关于go解析yaml的文档:Golang — 解析yaml。
使用上述结构体中的部分参数我们可以组成我们连接数据库使用的dsn:
func (d *DB) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", d.Username, d.Password, d.Host, d.Port, d.Name, d.Query)
}
创建文件 ${ROOT}/dao/init.go
,这是我们初始化数据库的文件。这里我们定义两个非出口的全局变量:
var (
db *gorm.DB // 数据库指针
debug bool // 是否是debug模式
)
这里教大家一种互联网公司经常使用的初始化函数模型 Init
和 MustInit
。这两个单词字面意思就是初始化和必须初始化,其实在实践中也是同样的意思,初始化时发生错误我就返回错,必须初始化时发生错误我就 panic
。
// Init 初始化数据库,发生错误时返回错误
func Init(config *conf.DB) error {
debug = config.DebugMode
var err error
db, err = gorm.Open(config.Dialect, config.DSN())
return err
}
// MustInit 初始化数据库,发生错误时 panic
func MustInit(config *conf.DB) {
if err := Init(config); err != nil {
panic(err)
}
}
这里我们第一次接触到了 gorm
包中的函数 Open
。gorm.Open
接受 Dialect
和 DSN
作为参数,初始化 gorm.DB
对象并返回指向其的指针。这个过程中,我们要将指针存到我们之前定义的全局变量 db
中。
有同学肯定已经注意到了,我们在 model/conf/db.go
中定义的配置结构体在这我们就很方便地使用到了。
由于我们定义的全局变量 db
是非出口的(小写字母开头),我们就需要定义一个 getter。在这个 getter 中我们要对 db 对象做一些前置的处理:
// DB 使用全局变量 debug 和参数 tableName 对全局变量 db 做前置处理后返回
func DB(tableName string) *gorm.DB {
if debug {
return db.Debug().Table(tableName)
}
return db.Table(tableName)
}
db.Debug()
可以使 db 进入debug模式,开始打印出所有执行的sql语句。db.Table
可以指定db接下来的操作要在哪一个table上进行。
gorm.DB
的链式模型如果你有心去观察 db.Open
、db.Debug()
、db.Table
等函数你会发现,这些函数都有一个共同点:他们的 reveiver都是 gorm.DB
的指针,返回值也是 gorm.DB
的指针。实际上,在db上面的操作都会生成一个新的db对象,我们来看看 db.Debug
的源码:
// Debug start debug mode
func (s *DB) Debug() *DB {
return s.clone().LogMode(true)
}
s.clone().LogMode(true)
就是克隆了一个新的对象,将其设为debug模型并返回。这就是为什么我们要将全局变量 db
设为非出口的,db
的属性不能被任何人改动,任何其他人获取到的都是 db
的克隆。
定义模型之前先为大家介绍 gorm
中预置的模型结构体 gorm.Model
:
// Model base model definition, including fields `ID`, `CreatedAt`, `UpdatedAt`, `DeletedAt`, which could be embedded in your models
// type User struct {
// gorm.Model
// }
type Model struct {
ID uint `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"`
}
该结构体中包含了各种数据库模型共同具有的一些参数,包括主键、创建时间、更新时间和删除时间。我们以 go
语言的内嵌结构体的形式将其插入到我们的结构体当中。这样 gorm
就会感知到这些参数的存在,并在一般的数据库操作中为我们自动更新这些参数(PS:在进行Query时,gorm
会去判断 DeletedAt
是否为 nil,只返回那些 DeletedAt
不为 nil 的记录)。
接下来,我们就开始定义我们的数据库模型:
我们就以简单的作者-书籍模型为例:
先定义作者模型 Author
。创建文件 ${ROOT}/model/author/author.go
。
package author
import (
"github.com/elzatahmed/go-gorm/dao"
"github.com/jinzhu/gorm"
)
// 以非出口的形式创建gender类型
// 因为gender类型只能是我们自定义的,不能由用户自定义
type gender int8
// 只定义两种性别,不能自定义
const (
GenderMale gender = iota + 1
GenderFemale
)
// Author 为作者模型
type Author struct {
// 嵌入gorm.Model
gorm.Model
Name string `gorm:"column:name"` // 名字
Gender gender `gorm:"column:gender"` // 性别
Age int `gorm:"age"` // 岁数
}
// New 创建新的 Author 对象并返回其指针
func New(name string, gender gender, age int) *Author {
return &Author{
Name: name,
Gender: gender,
Age: age,
}
}
// TableName是在使用gorm时不传递表名的情况下,被gorm调用获取表名的方法
func (a Author) TableName() string {
return dao.TableNameAuthor
}
其次以同样的逻辑定义书籍模型 Book
,创建文件 ${ROOT}/model/book/book.go
。
package book
import (
"github.com/elzatahmed/go-gorm/dao"
"github.com/jinzhu/gorm"
)
type Book struct {
// 内嵌gorm.Model
gorm.Model
Title string `gorm:"column:title"` // 标题
AuthorId uint `gorm:"column:author_id"` // 关联作者id
Intro string `gorm:"column:intro"` // 简介
Genre string `gorm:"column:genre"` // 体裁
}
// New 创建新的 Book 对象并返回其指针
func New(title, intro, genre string, authorId uint) *Book {
return &Book{
Title: title,
AuthorId: authorId,
Intro: intro,
Genre: genre,
}
}
func (b Book) TableName() string {
return dao.TableNameBook
}
在定义模型参数时我使用了 gorm
标签去定义一些额外参数,gorm
标签类型其实非常多,我们甚至可以完整的定义一列数据的所有属性并将DDL操作交给 gorm
去执行,以下是 gorm
典型的标签:
标签名 | 描述 |
---|---|
column | 列名 |
type | 类型 |
size | 数据大小/长度 |
primary_key | 主键标识 |
unique | 唯一键标识 |
not null | 不能为空 |
auto_increment | 自增记录 |
需要了解更多更详细的标签进入:gorm模型定义
在一般的大型互联网项目中,模型和数据库操作是分隔开来的,数据库中每一个表都会有一个对应的 handler
处理各种不一样的 CRUD 请求。在本次项目中为了简单起见我将CUD(创建、更新和删除)操作直接编写到模型下。
在进入代码之间,我们先讨论讨论如何使用 gorm
的CRUD方法。
首先我们已经见到了 db.Table
方法,其作用是指定作用的数据库表。
其次最常用的还有 db.Where
方法:
// Where return a new relation, filter records with given conditions, accepts `map`, `struct` or `string` as conditions, refer http://jinzhu.github.io/gorm/crud.html#query
func (s *DB) Where(query interface{}, args ...interface{}) *DB {
return s.clone().search.Where(query, args...).db
}
db.Where
的作用与 Sql 中的 Where 是一样的,即创建条件。其用法非常灵活,可以传入 string、map 甚至结构体。传入 string 即写入 sql query,将变量用 ?替代并将变量以 args 参数的形式传入:db.Where("id = ?", id)
。而 map 和 struct 是以 key-value 的形式传入 query。
配合 db.Where
使用的方法有 db.Or
、db.Not
等都是拼接query的方法。
在拼接完Query之后执行该Query的常用方法有两种:db.First
和 db.Find
,db.First
返回数据库表中满足query的第一条记录(按照主键排序),db.Find
会返回满足条件的所有记录,根据不同 db.First
的输出一般会传入单个对象指针,db.Find
会传入slice。
(PS:其实 db.First
db.Find
都支持直接在参数中传入where参数,但是在平常使用时我们会选利用 db.Where
构成链式的query)
db.Save
为创建记录的最常用的方法:
// Save update value in database, if the value doesn't have primary key, will insert it
func (s *DB) Save(value interface{}) *DB {
scope := s.NewScope(value)
if !scope.PrimaryKeyZero() {
newDB := scope.callCallbacks(s.parent.callbacks.updates).db
if newDB.Error == nil && newDB.RowsAffected == 0 {
return s.New().Table(scope.TableName()).FirstOrCreate(value)
}
return newDB
}
return scope.callCallbacks(s.parent.callbacks.creates).db
}
注意要传入对象指针,Save会再将记录存入DB后将获取到的主键赋值到对应的主键域中。
记录的更新我们一般会利用 db.Update
或 db.Updates
,实际上这两个方法的使用方式类似,源码中 db.Update
调用 db.Updates
去实现功能。调用 db.Update 时我们可以传入多个参数,这意味着我们可以将列名与更新值分开传递(PS:这种方式只能更新一个),同时我们也可以利用 map
,struct
以键值对的形式去传递参数。
进入 ${ROOT}/model/author/author.go
中,定义以下三个方法:
// 创建方法
func (a *Author) Save() error {
// 获取DB、调用Save方法并传入对应值即可
db := dao.DB(a.TableName()).Save(a)
if db.Error != nil {
return dao.ErrDBServer
}
return nil
}
// 更新方法
func (a *Author) Update() error {
fields := updateFields(a)
db := dao.DB(a.TableName()).Where("id = ?", a.ID).Updates(fields)
if db.Error != nil {
return dao.ErrDBServer
}
return nil
}
// 删除方法
func (a *Author) Delete() error {
db := dao.DB(a.TableName()).Where("id = ?", a.ID).Delete(a)
if db.Error != nil {
return dao.ErrDBServer
}
return nil
}
// 获取需要更新的域即不为零值的域
func updateFields(a *Author) (fields map[string]interface{}) {
fields = make(map[string]interface{})
if a.Name != "" {
fields["name"] = a.Name
}
if a.Gender != 0 {
fields["gender"] = a.Gender
}
if a.Age > 0 {
fields["age"] = a.Age
}
return fields
}
进入 ${ROOT}/model/book/book.go
中,以同样的方式定义以下三个方法:
func (b *Book) Save() error {
db := dao.DB(b.TableName()).Save(b)
if db.Error != nil {
return dao.ErrDBServer
}
return nil
}
func (b *Book) Update() error {
fields := updateFields(b)
db := dao.DB(b.TableName()).Where("id = ?", b.ID).Updates(fields)
if db.Error != nil {
return dao.ErrDBServer
}
return nil
}
func (b *Book) Delete() error {
db := dao.DB(b.TableName()).Where("id = ?", b.ID).Delete(b)
if db.Error != nil {
return dao.ErrDBServer
}
return nil
}
func updateFields(b *Book) (fields map[string]interface{}) {
fields = make(map[string]interface{})
if b.Title != "" {
fields["title"] = b.Title
}
if b.Genre != "" {
fields["genre"] = b.Genre
}
if b.Intro != "" {
fields["intro"] = b.Intro
}
return fields
}
实现查询之前,我们先定义两个查询Handler interface,创建文件 ${ROOT}/query/itf.go
,编写一下代码:
package query
import (
"github.com/elzatahmed/go-gorm/model/author"
"github.com/elzatahmed/go-gorm/model/book"
)
type AuthorHandler interface {
FindByID(ID uint) (auth *author.Author, err error)
FindAllByName(name string) (authors []*author.Author, err error)
FindAllByAgeLessThan(age uint) (authors []*author.Author, err error)
FindAllByMaleAuthor() (authors []*author.Author, err error)
FindAllFemaleAuthor() (authors []*author.Author, err error)
}
type BookHandler interface {
FindByID(ID uint) (b *book.Book, err error)
FindAllByTitle(title string) (books []*book.Book, err error)
FindAllByGenre(genre string) (books []*book.Book, err error)
FindAllByAuthorID(authorID uint) (books []*book.Book, err error)
}
我们在这里定义了我们需要实现的所有查询方法,接下来我们就要实现他。
创建文件 ${ROOT}/query/impl.go
,在内部定义结构体 AuthorHandlerImpl
,让该结构体实现 AuthorHandler
的所有方法。接触过go接口编程的同学可能发现了go在实现interface的时候并没有像java那样implements的关键词,所以我们并不能用肉眼保证其一定实现了该接口,在编译阶段检查该行为的一种方式如下:
type AuthorHandlerImpl struct{}
// 这种方式并不会创建新的变量,因为变量已被 _ 忽略
// 但是如果AuthorHandlerImpl并没有实现接口AuthorHandler,这个表达式在编译期间就不会通过
var _ AuthorHandler = (*AuthorHandlerImpl)(nil)
接下来的任务就是要实现所有方法:
func (a AuthorHandlerImpl) FindByID(ID uint) (auth *author.Author, err error) {
db := dao.DB(dao.TableNameAuthor).Where("id = ?", ID).First(auth)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (a AuthorHandlerImpl) FindAllByName(name string) (authors []*author.Author, err error) {
db := dao.DB(dao.TableNameAuthor).Where("name LIKE ?", "%"+name).Find(authors)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (a AuthorHandlerImpl) FindAllByAgeLessThan(age uint) (authors []*author.Author, err error) {
db := dao.DB(dao.TableNameAuthor).Where("age < ?", age).Find(authors)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (a AuthorHandlerImpl) FindAllMaleAuthor() (authors []*author.Author, err error) {
db := dao.DB(dao.TableNameAuthor).Where("gender = ?", author.GenderMale).Find(authors)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (a AuthorHandlerImpl) FindAllFemaleAuthor() (authors []*author.Author, err error) {
db := dao.DB(dao.TableNameAuthor).Where("gender = ?", author.GenderFemale).Find(authors)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
在阅读上述代码时你会发现这几个方法的逻辑都几乎一模一样,唯一不一样的一点就是gorm的使用方法。大家需要学习的就是这里gorm的链式调用的使用方法。如果对我的错误处理方式有感兴趣的可以阅读文档 Golang中的错误处理。
接下来,以同样的方式去实现 BookHandlerImpl
:
type BookHandlerImpl struct {}
var _ BookHandler = (*BookHandlerImpl)(nil)
func (bk BookHandlerImpl) FindByID(ID uint) (b *book.Book, err error) {
db := dao.DB(dao.TableNameBook).Where("id = ?", ID).First(b)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (bk BookHandlerImpl) FindAllByTitle(title string) (books []*book.Book, err error) {
db := dao.DB(dao.TableNameBook).Where("title LIKE ?", "%"+title).Find(books)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (bk BookHandlerImpl) FindAllByGenre(genre string) (books []*book.Book, err error) {
db := dao.DB(dao.TableNameBook).Where("genre = ?", genre).Find(books)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
func (bk BookHandlerImpl) FindAllByAuthorID(authorID uint) (books []*book.Book, err error) {
db := dao.DB(dao.TableNameBook).Where("author_id = ?", authorID).Find(books)
if db.Error != nil {
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
return nil, dao.ErrDBNoRecord
}
return nil, dao.ErrDBServer
}
return
}
还剩最后一步操作,即我们需要为interface AuthorHandler
和 BookHandler
编写构造函数:
func NewAuthorHandler() AuthorHandler {
return AuthorHandlerImpl{}
}
func NewBookHandler() BookHandler {
return BookHandlerImpl{}
}
这样我们所有的数据库操作模型已建立完成!
读到这不知道你还记不记得我们的所有初始化操作是用yaml文件的解析来完成的,所以我们首先需要编写我们的yaml文件:
${ROOT}/conf/db.yaml
dialect: "mysql"
username: "root"
password: "*****"
name: "db"
host: "localhost"
port: "3306"
query: "useSSL=true"
debugMode: true
我们再在main中去解析yaml文件并传入到初始化函数中:
func main() {
dbConf := loadConfig("conf")
dao.MustInit(dbConf)
}
func loadConfig(configDir string) *conf.DB {
var (
dbConf *conf.DB
)
db, err := os.Open(configDir + "/db.yaml")
if err != nil {
panic(err)
}
if yaml.NewDecoder(db).Decode(dbConf) != nil {
panic(err)
}
return dbConf
}
这样初始化完成后,我们就可以在main中执行我们的CRUD:
func main() {
dbConf := loadConfig("conf")
dao.MustInit(dbConf)
martin := author.New("George.R.R.Martin", author.GenderMale, 60)
err := martin.Save()
if err != nil {
panic(err)
}
martin.Age = 70
err = martin.Update()
if err != nil {
panic(err)
}
err = martin.Delete()
if err != nil {
panic(err)
}
authorHandler := query.NewAuthorHandler()
authors, err := authorHandler.FindAllByName("George")
if err != nil {
panic(err)
}
fmt.Println(authors)
}