你好,我是轩脉刃。

上一节课,我们梳理了Gorm的核心逻辑,也通过思维导图,详细分析了Gorm的源码搞清楚它是如何封装database/sql的。这节课我们就要思考和操作,如何将Gorm融合进入hade框架了。

Gorm的使用分为两个部分,数据库的连接和数据库的操作。

对于数据库操作接口的封装,Gorm已经做的非常好了,它在gorm.DB中定义了非常多的对数据库的操作接口,这些接口已经是非常易用了,而且每个操作接口在官方文档中都有对应的说明和使用教程。比如在DB的操作接口列表中,我们可以看到常用的增删改查的逻辑:

func (db *DB) Create(value interface{}) (tx *DB)

func (db *DB) Delete(value interface{}, conds ...interface{}) (tx *DB)

func (db *DB) Get(key string) (interface{}, bool)

func (db *DB) Update(column string, value interface{}) (tx *DB)

同时,官方首页的例子也把获取到DB后的增删改查操作显示很清楚了,建议你在浏览器收藏这个Gorm的说明文档,因为在具体的应用开发中,你会经常参考使用它的。

所以今天我们要做的事情,就是封装Gorm的数据库连接部分。

ORM服务

按照“一切皆服务”的思想,我们也计划将Gorm封装为一个服务。而服务三要素是服务接口、服务提供者、服务实例化。我们先来定义ORM服务接口。

服务接口

这个服务接口并不复杂,它的唯一任务就是能够初始化出gorm.DB 实例。回顾上节课说的Gorm初始化gorm.DB的方法:

  dsn := "xxxxxxx"
  db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

参数看起来就这两个部分DSN和gorm.Config。

不过我们希望设计一个hade框架自定义的配置结构,将所有创建连接需要的配置项整合起来。所以除了DSN和gorm.Config这两个配置项,其实还需要加上连接池的配置,就是上节课说的database/sql中提供的对连接池的配置信息。再回顾一下这四个影响底层创建连接池设置的配置信息:

// 设置连接的最大空闲时长
func (db *DB) SetConnMaxIdleTime(d time.Duration)
// 设置连接的最大生命时长
func (db *DB) SetConnMaxLifetime(d time.Duration)
// 设置最大空闲连接数
func (db *DB) SetMaxIdleConns(n int)
// 设置最大打开连接数
func (db *DB) SetMaxOpenConns(n int)

所以可以定义这么一个DBConfig结构,将所有的创建DB相关的配置都放在这里面。代码在framework/contract/orm.go中:

// DBConfig 代表数据库连接的所有配置
type DBConfig struct {
   // 以下配置关于dsn
   WriteTimeout string `yaml:"write_timeout"` // 写超时时间
   Loc          string `yaml:"loc"`           // 时区
   Port         int    `yaml:"port"`          // 端口
   ReadTimeout  string `yaml:"read_timeout"`  // 读超时时间
   Charset      string `yaml:"charset"`       // 字符集
   ParseTime    bool   `yaml:"parse_time"`    // 是否解析时间
   Protocol     string `yaml:"protocol"`      // 传输协议
   Dsn          string `yaml:"dsn"`           // 直接传递dsn,如果传递了,其他关于dsn的配置均无效
   Database     string `yaml:"database"`      // 数据库
   Collation    string `yaml:"collation"`     // 字符序
   Timeout      string `yaml:"timeout"`       // 连接超时时间
   Username     string `yaml:"username"`      // 用户名
   Password     string `yaml:"password"`      // 密码
   Driver       string `yaml:"driver"`        // 驱动
   Host         string `yaml:"host"`          // 数据库地址

   // 以下配置关于连接池
   ConnMaxIdle     int    `yaml:"conn_max_idle"`     // 最大空闲连接数
   ConnMaxOpen     int    `yaml:"conn_max_open"`     // 最大连接数
   ConnMaxLifetime string `yaml:"conn_max_lifetime"` // 连接最大生命周期
   ConnMaxIdletime string `yaml:"conn_max_idletime"` // 空闲最大生命周期

   // 以下配置关于gorm
   *gorm.Config // 集成gorm的配置
}

其中DSN是一个复杂的字符串。但我们又不希望使用者直接设置这些复杂字符串来进行传递,所以这里设置了多个字段来生成这个DSN。

另外上节课也说过,DSN并没有一个标准的格式约定,不同的数据库可能有不同的解析,所以也同时保留直接设置DSN的权限,如果用户手动设置了Dsn字段,那么其他关于Dsn的字段设置均无效。

所以这里同时需要实现一个方法,使用DBConfig来生成最终使用的字符串Dsn,使用上节课介绍的 github.com/go-sql-driver/mysql 库,就能很方便地实现了。我们继续写:

import (
   "github.com/go-sql-driver/mysql"
   ...
)

// FormatDsn 生成dsn
func (conf *DBConfig) FormatDsn() (string, error) {
   port := strconv.Itoa(conf.Port)
   timeout, err := time.ParseDuration(conf.Timeout)
   if err != nil {
      return "", err
   }
   readTimeout, err := time.ParseDuration(conf.ReadTimeout)
   if err != nil {
      return "", err
   }
   writeTimeout, err := time.ParseDuration(conf.WriteTimeout)
   if err != nil {
      return "", err
   }
   location, err := time.LoadLocation(conf.Loc)
   if err != nil {
      return "", err
   }
   driverConf := &mysql.Config{
      User:         conf.Username,
      Passwd:       conf.Password,
      Net:          conf.Protocol,
      Addr:         net.JoinHostPort(conf.Host, port),
      DBName:       conf.Database,
      Collation:    conf.Collation,
      Loc:          location,
      Timeout:      timeout,
      ReadTimeout:  readTimeout,
      WriteTimeout: writeTimeout,
      ParseTime:    conf.ParseTime,
   }
   return driverConf.FormatDSN(), nil
}

可以看到Gorm配置,我们使用结构嵌套的方式,将gorm.Config直接嵌套进入DBConfig中。你可以琢磨下这种写法,它有两个好处。

一是可以直接设置DBConfig来设置gorm.Config。比如这个函数是可行的,它直接设置config.DryRun,就是直接设置gorm.Config:

func(container framework.Container, config *contract.DBConfig) error {
   config.DryRun = true
   return nil
}

二是DBConfig继承了*gorm.Config的所有方法。比如这段代码,我们来理解一下:

config := &contract.DBConfig{}
db, err = gorm.Open(mysql.Open(config.Dsn), config)

还记得gorm.Open的第二个参数是Option么,它是一个接口,需要实现Apply和AfterInitialize方法,而我们的DBConfig并没有显式实现这两个方法。但是它嵌套了实现了这两个方法的*gorm.Config,所以,默认DB.Config也就实现了这两个方法。

type Option interface {
   Apply(*Config) error
   AfterInitialize(*DB) error
}

现在,gorm.Open的两个参数DSN和gorm.Config都封装在DBConfig中,而修改DBConfig的方法,我们封装为DBOption。

如何让设置DBOption的方法更为优雅呢?这里就使用到上节课刚学到的Option可变参数的编程方法了。定义一个DBOption的结构,它代表一个可以对DBConfig进行设置的方法,这个结构作为获取ORM服务GetDB方法的参数。在framework/contract/orm.go中:

package contract

// ORMKey 代表 ORM的服务
const ORMKey = "hade:orm"

// ORMService 表示传入的参数
type ORMService interface {
   GetDB(option ...DBOption) (*gorm.DB, error)
}

// DBOption 代表初始化的时候的选项
type DBOption func(container framework.Container, config *DBConfig) error

这样就能通过设置不同的方法来对DBConfig进行配置。

比如要设置DBConfig中gorm.Config的DryRun空跑字段,设计了这么一个方法在framework/provider/orm/config.go中:

// WithDryRun 设置空跑模式
func WithDryRun() contract.DBOption {
   return func(container framework.Container, config *contract.DBConfig) error {
      config.DryRun = true
      return nil
   }
}

之后,在使用ORM服务的时候,我们就可以这样设置:

gormService := c.MustMake(contract.ORMKey).(contract.ORMService)
// 可变参数为WithDryRun()
db, err := gormService.GetDB(orm.WithDryRun())

服务提供者

下一步来完成服务提供者,我们也并不需要过于复杂的设计,只要注意一下两点:

所以具体的服务提供者代码如下,在framework/provider/orm/provider.go中:

package orm

import (
   "github.com/gohade/hade/framework"
   "github.com/gohade/hade/framework/contract"
)

// GormProvider 提供App的具体实现方法
type GormProvider struct {
}

// Register 注册方法
func (h *GormProvider) Register(container framework.Container) framework.NewInstance {
   return NewHadeGorm
}

// Boot 启动调用
func (h *GormProvider) Boot(container framework.Container) error {
   return nil
}

// IsDefer 是否延迟初始化
func (h *GormProvider) IsDefer() bool {
   return true
}

// Params 获取初始化参数
func (h *GormProvider) Params(container framework.Container) []interface{} {
   return []interface{}{container}
}

// Name 获取字符串凭证
func (h *GormProvider) Name() string {
   return contract.ORMKey
}

服务实例化

服务实例化是今天的重点内容,我们先把Gorm的配置结构和日志结构的准备工作完成,再写稍微复杂一点的具体ORM服务的实例 HadeGorm。

配置

前面定义了hade框架专属的DBConfig配置结构,如何设置它是一个需要讲究的问题。

虽然已经设计了一种修改配置文件的方式,就是通过GetDB中的Option参数来设置。但是每个字段都这么设置又非常麻烦,我们自然会想到使用配置文件来配置这个结构。另外如果要连接多个数据库,每个数据库都进行同样的配置,还是颇为麻烦,是不是可以有个默认配置呢?

于是我们的配置文件可以这样设计:在 database.yaml 中保存数据库的默认值,如果想对某个数据库连接有单独的配置,可以用内嵌yaml结构的方式来进行配置。看下面这个配置例子:

conn_max_idle: 10 # 通用配置,连接池最大空闲连接数
conn_max_open: 100 # 通用配置,连接池最大连接数
conn_max_lifetime: 1h # 通用配置,连接数最大生命周期
protocol: tcp # 通用配置,传输协议
loc: Local # 通用配置,时区

default:
    driver: mysql # 连接驱动
    dsn: "" # dsn,如果设置了dsn, 以下的所有设置都不生效
    host: localhost # ip地址
    port: 3306 # 端口
    database: coredemo # 数据库
    username: jianfengye # 用户名
    password: "123456789" # 密码
    charset: utf8mb4 # 字符集
    collation: utf8mb4_unicode_ci # 字符序
    timeout: 10s # 连接超时
    read_timeout: 2s # 读超时
    write_timeout: 2s # 写超时
    parse_time: true # 是否解析时间
    protocol: tcp # 传输协议
    loc: Local # 时区
    conn_max_idle: 10 # 连接池最大空闲连接数
    conn_max_open: 20 # 连接池最大连接数
    conn_max_lifetime: 1h # 连接数最大生命周期

read:
    driver: mysql # 连接驱动
    dsn: "" # dsn,如果设置了dsn, 以下的所有设置都不生效
    host: localhost # ip地址
    port: 3306 # 端口
    database: coredemo # 数据库
    username: jianfengye # 用户名
    password: "123456789" # 密码
    charset: utf8mb4 # 字符集
    collation: utf8mb4_unicode_ci # 字符序

在这个database.yaml中,我们配置了database.default和database.read两个数据源。database.read数据源,并没有设置诸如时区loc、连接池conn_max_open配置,这些缺省的配置要从databse.yaml的根结构中获取。

要实现这个也并不难,先在framework/provider/orm/service.go中实现一个GetBaseConfig方法,来读取database.yaml根目录的结构:

// GetBaseConfig 读取database.yaml根目录结构
func GetBaseConfig(c framework.Container) *contract.DBConfig {

   configService := c.MustMake(contract.ConfigKey).(contract.Config)
   logService := c.MustMake(contract.LogKey).(contract.Log)
   
   config := &contract.DBConfig{}
   // 直接使用配置服务的load方法读取,yaml文件
   err := configService.Load("database", config)
   if err != nil {
      // 直接使用logService来打印错误信息
      logService.Error(context.Background(), "parse database config error", nil)
      return nil
   }
   return config
}

然后设计一个根据配置路径加载某个配置结构的方法。这里这个方法一定是在具体初始化某个DB实例的时候使用到,所以要封装为一个Option结构,写在framework/provider/orm/config.go中:

// WithConfigPath 加载配置文件地址
func WithConfigPath(configPath string) contract.DBOption {
   return func(container framework.Container, config *contract.DBConfig) error {
      configService := container.MustMake(contract.ConfigKey).(contract.Config)
        // 加载configPath配置路径
      if err := configService.Load(configPath, config); err != nil {
         return err
      }
      return nil
   }
}

现在,对于使用者来说,要初始化一个配置路径为database.default的数据库,就可以这么使用:

gormService := c.MustMake(contract.ORMKey).(contract.ORMService)
db, err := gormService.GetDB(orm.WithConfigPath("database.default"), orm.WithDryRun())

日志

配置项设计清楚了,我们再来思考下日志这块。上一章介绍过了,Gorm是有自己的输出规范的,在初始化参数 gorm.Config 中定义了一个日志输出接口Interface。我们来仔细看下这个接口的定义:

const (
   Silent LogLevel = iota + 1
   Error
   Warn
   Info
)

// Interface logger interface
type Interface interface {
   LogMode(LogLevel) Interface // 日志级别
   Info(context.Context, string, ...interface{})
   Warn(context.Context, string, ...interface{})
   Error(context.Context, string, ...interface{})
   Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error)
}

Gorm接口的日志级别分类比较简单:Info、Warn、Error、Trace。恰巧,这几个日志级别都在我们hade框架定义的7个日志级别中,所以完全可以将Gorm的这几个级别,映射到hade的日志级别中。也就是说,Gorm打印的Info级别日志输出到hade的Info日志中、error日志输出到hade的error日志中。

至于Gorm提供的一个LogMode来调整日志级别,由于我们的hade框架已经可以通过配置进行日志级别设置了,所以LogMode函数对我们来说是没有什么意义的。

好,了解Gorm的日志接口之后,我们明确了接下来要做的事情:实现一个Gorm的日志实现类,但是这个日志实现类中的每个方法都用 hade 的日志服务来实现

我们在framework/provider/orm/logger.go中定义一个OrmLogger结构,它带有一个logger属性,这个logger属性存放的是hade容器中的log服务:

// OrmLogger orm的日志实现类, 实现了gorm.Logger.Interface
type OrmLogger struct {
   logger contract.Log // 有一个logger对象存放hade的log服务
}

// NewOrmLogger 初始化一个ormLogger,
func NewOrmLogger(logger contract.Log) *OrmLogger {
   return &OrmLogger{logger: logger}
}

它实现了Gorm的Logger.Interface 接口。其中LogMode什么都不做,Info、Error、Warn、Trace 分别对应hade容器中log服务的Info、Error、Warn、Trace方法:

// Info 对接hade的info输出
func (o *OrmLogger) Info(ctx context.Context, s string, i ...interface{}) {
   fields := map[string]interface{}{
      "fields": i,
   }
   o.logger.Info(ctx, s, fields)
}

// Warn 对接hade的Warn输出
func (o *OrmLogger) Warn(ctx context.Context, s string, i ...interface{}) {
   fields := map[string]interface{}{
      "fields": i,
   }
   o.logger.Warn(ctx, s, fields)
}

// Error 对接hade的Error输出
func (o *OrmLogger) Error(ctx context.Context, s string, i ...interface{}) {
   fields := map[string]interface{}{
      "fields": i,
   }
   o.logger.Error(ctx, s, fields)
}

// Trace 对接hade的Trace输出
func (o *OrmLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
   sql, rows := fc()
   elapsed := time.Since(begin)
   fields := map[string]interface{}{
      "begin": begin,
      "error": err,
      "sql":   sql,
      "rows":  rows,
      "time":  elapsed,
   }

   s := "orm trace sql"
   o.logger.Trace(ctx, s, fields)
}

这里稍微注意下Trace方法,Gorm的Trace方法的参数中有传递时间戳begin,这个时间戳代表SQL执行的开始时间,而在函数中使用time.Now获取到当前时间之后,两个相减,我们可以获取到这个SQL的实际执行时间,然后作为hade 日志服务的fields map的一个字段输出。除了Trace,其他几个基本上简单封装hade的日志服务方法就好了。

服务实例

好了,到现在Gorm的配置结构和日志结构也完成了。万事俱备,下面我们就开始写具体的ORM服务的实例 HadeGorm,在framework/provider/orm/service.go中。

首先,定义实现contract.ORMService的结构HadeGorm。要明确一点,我们会使用这个结构来生成不同数据库的gorm.DB结构,所以这个HadeGorm是一个与某个数据库设置无关的结构,而且它应该对单个数据库是一个单例模式,即在一个服务中,我从HadeGorm两次获取到的default数据库的gorm.DB是同一个。

设置HadeGrom结构如下:

// HadeGorm 代表hade框架的orm实现
type HadeGorm struct {
   container framework.Container // 服务容器
   dbs       map[string]*gorm.DB // key为dsn, value为gorm.DB(连接池)

   lock *sync.RWMutex
}

dbs就是为了单例存在,它的key直接设计为一个string,也就是连接数据库的DSN字符串,而value就是gorm.DB结构。

这样我们在拿到一个DSN的时候,从这个map中就能判断出是否已经实例化过这个数据库对应的gorm.DB了;如果没有实例化过,就实例化一个gorm.DB,并且将这个实例挂到这个map中。不过这个逻辑会对dbs有并发修改操作,所以这里要使用一个读写锁来锁住这个dbs的修改

对应实例化HadeGorm的方法为NewHadeGorm,它的具体实现就是初始化HadeGorm中的每个字段。继续写入这段:

// NewHadeGorm 代表实例化Gorm
func NewHadeGorm(params ...interface{}) (interface{}, error) {
   container := params[0].(framework.Container)
   dbs := make(map[string]*gorm.DB)
   lock := &sync.RWMutex{}
   return &HadeGorm{
      container: container,
      dbs:       dbs,
      lock:      lock,
   }, nil
}

重头戏在GetDB方法的实现上。

首先初始化orm.Config,其中包括从配置中获取设置项,也包括初始化内部的Gorm;然后将GetDB的option参数作用于初始化的orm.Config,修改默认配置;通过orm.Config生成DSN字符串。

  // 读取默认配置
    config := GetBaseConfig(app.container)

    logService := app.container.MustMake(contract.LogKey).(contract.Log)

    // 设置Logger
    ormLogger := NewOrmLogger(logService)
    config.Config = &gorm.Config{
        Logger: ormLogger,
    }

    // option对opt进行修改
    for _, opt := range option {
        if err := opt(app.container, config); err != nil {
            return nil, err
        }
    }

之后根据dsn字符串判断数据库实例gorm.DB是否已经存在了。如果存在直接返回gorm.DB,如果不存在需要实例化gorm.DB,这一步逻辑稍微复杂一点:

代码如下:

// 如果最终的config没有设置dsn,就生成dsn
    if config.Dsn == "" {
        dsn, err := config.FormatDsn()
        if err != nil {
            return nil, err
        }
        config.Dsn = dsn
    }

    // 判断是否已经实例化了gorm.DB
    app.lock.RLock()
    if db, ok := app.dbs[config.Dsn]; ok {
        app.lock.RUnlock()
        return db, nil
    }
    app.lock.RUnlock()

    // 没有实例化gorm.DB,那么就要进行实例化操作
    app.lock.Lock()
    defer app.lock.Unlock()

    // 实例化gorm.DB
    var db *gorm.DB
    var err error
    switch config.Driver {
    case "mysql":
        db, err = gorm.Open(mysql.Open(config.Dsn), config)
    case "postgres":
        db, err = gorm.Open(postgres.Open(config.Dsn), config)
    case "sqlite":
        db, err = gorm.Open(sqlite.Open(config.Dsn), config)
    case "sqlserver":
        db, err = gorm.Open(sqlserver.Open(config.Dsn), config)
    case "clickhouse":
        db, err = gorm.Open(clickhouse.Open(config.Dsn), config)
    }

    // 设置对应的连接池配置
    sqlDB, err := db.DB()
    if err != nil {
        return db, err
    }

    if config.ConnMaxIdle > 0 {
        sqlDB.SetMaxIdleConns(config.ConnMaxIdle)
    }
    if config.ConnMaxOpen > 0 {
        sqlDB.SetMaxOpenConns(config.ConnMaxOpen)
    }
    if config.ConnMaxLifetime != "" {
        liftTime, err := time.ParseDuration(config.ConnMaxLifetime)
        if err != nil {
            logger.Error(context.Background(), "conn max lift time error", map[string]interface{}{
                "err": err,
            })
        } else {
            sqlDB.SetConnMaxLifetime(liftTime)
        }
    }

    if config.ConnMaxIdletime != "" {
        idleTime, err := time.ParseDuration(config.ConnMaxIdletime)
        if err != nil {
            logger.Error(context.Background(), "conn max idle time error", map[string]interface{}{
                "err": err,
            })
        } else {
            sqlDB.SetConnMaxIdleTime(idleTime)
        }
    }

    // 挂载到map中,结束配置
    if err != nil {
        app.dbs[config.Dsn] = db
    }

    return db, err

如果前面的内容都理解了,这段代码实现也没有什么难点了。唯一要注意的地方就是锁的使用,由于对存在gorm.DB的map是读多写少,所以这里也是使用读写锁,在读取的时候加了一个读锁,如果map中没有我们要的gorm.DB,先把读锁解开,再加一个写锁,初始化完gorm.DB、保存进入map映射后,再把写锁解开。这样能有效防止对map的并发读写。

完整的GetDB方法可以参考GitHub上的framework/provider/orm/service.go

最后记得去业务代码main.go中,把我们的GormProvider注入服务容器:

func main() {
   // 初始化服务容器
   container := framework.NewHadeContainer()
   ...
   container.Bind(&orm.GormProvider{})

   ...

   // 运行root命令
   console.RunCommand(container)
}

整个Gorm就已经结合到hade框架中了。

测试

下面来做一下测试。我们用真实的MySQL进行测试。当然你需要在本机/远端/Docker搭建一个MySQL,至于怎么搭建,教程网上有很多了,这里就不详细描述。

我用的是Mac,使用homebrew 能很方便搭建一个MySQL服务。我的MySQL实例搭建在本机的3306端口,并且搭建完成之后,我创建了一个coredemo的database数据库:

所以我的配置文件config/development/database.yaml配置如下:

conn_max_idle: 10 # 通用配置,连接池最大空闲连接数
conn_max_open: 100 # 通用配置,连接池最大连接数
conn_max_lifetime: 1h # 通用配置,连接数最大生命周期
protocol: tcp # 通用配置,传输协议
loc: Local # 通用配置,时区

default:
    driver: mysql # 连接驱动
    dsn: "" # dsn,如果设置了dsn, 以下的所有设置都不生效
    host: localhost # ip地址
    port: 3306 # 端口
    database: coredemo # 数据库
    username: jianfengye # 用户名
    password: "123456789" # 密码
    charset: utf8mb4 # 字符集
    collation: utf8mb4_unicode_ci # 字符序
    timeout: 10s # 连接超时
    read_timeout: 2s # 读超时
    write_timeout: 2s # 写超时
    parse_time: true # 是否解析时间
    protocol: tcp # 传输协议
    loc: Local # 时区

我们想在coredemo数据库中增加一个user表,按照Gorm的规范,需要先定义一个数据结构User。在app/http/module/demo/model.go中:

// User is gorm model
type User struct {
   ID           uint
   Name         string
   Email        *string
   Age          uint8
   Birthday     *time.Time
   MemberNumber sql.NullString
   ActivatedAt  sql.NullTime
   CreatedAt    time.Time
   UpdatedAt    time.Time
}

然后在应用目录app/http/module/demo/api_orm.go中,定义了一个新的路由方法DemoOrm,在这个方法中,我们先从容器中获取到gorm.DB的实例,然后使用db.AutoMigrate 同步数据表user。

如果第一次执行的时候,数据库中没有表user,它会自动创建user表,然后分别调用db.Create、db.Save、db.First、db.Delete来对user表进行增删改查操作:

// DemoOrm Orm的路由方法
func (api *DemoApi) DemoOrm(c *gin.Context) {
    logger := c.MustMakeLog()
    logger.Info(c, "request start", nil)

    // 初始化一个orm.DB
    gormService := c.MustMake(contract.ORMKey).(contract.ORMService)
    db, err := gormService.GetDB(orm.WithConfigPath("database.default"))
    if err != nil {
        logger.Error(c, err.Error(), nil)
        c.AbortWithError(50001, err)
        return
    }
    db.WithContext(c)

    // 将User模型创建到数据库中
    err = db.AutoMigrate(&User{})
    if err != nil {
        c.AbortWithError(500, err)
        return
    }
    logger.Info(c, "migrate ok", nil)

    // 插入一条数据
    email := "foo@gmail.com"
    name := "foo"
    age := uint8(25)
    birthday := time.Date(2001, 1, 1, 1, 1, 1, 1, time.Local)
    user := &User{
        Name:         name,
        Email:        &email,
        Age:          age,
        Birthday:     &birthday,
        MemberNumber: sql.NullString{},
        ActivatedAt:  sql.NullTime{},
        CreatedAt:    time.Now(),
        UpdatedAt:    time.Now(),
    }
    err = db.Create(user).Error
    logger.Info(c, "insert user", map[string]interface{}{
        "id":  user.ID,
        "err": err,
    })

    // 更新一条数据
    user.Name = "bar"
    err = db.Save(user).Error
    logger.Info(c, "update user", map[string]interface{}{
        "err": err,
        "id":  user.ID,
    })

    // 查询一条数据
    queryUser := &User{ID: user.ID}

    err = db.First(queryUser).Error
    logger.Info(c, "query user", map[string]interface{}{
        "err":  err,
        "name": queryUser.Name,
    })

    // 删除一条数据
    err = db.Delete(queryUser).Error
    logger.Info(c, "delete user", map[string]interface{}{
        "err": err,
        "id":  user.ID,
    })
    c.JSON(200, "ok")
}

记得修改app/http/module/demo/api.go中的路由注册:

func Register(r *gin.Engine) error {
   api := NewDemoApi()
   ...
   r.GET("/demo/orm", api.DemoOrm)
   return nil
}

现在,使用 ./hade build self 来重新编译hade文件,使用 ./hade app start 启动服务,并挂起在控制台,日志会输出到控制台。浏览器调用 http://localhost:8888/demo/orm ,控制台打印日志如下:

可以清晰地通过trace日志看到底层的Insert/Update/Select/Delete的操作,并且可以通过time字段看到这个请求的具体耗时。到这里Gorm融合hade框架就验证完成了。

本节课我们主要修改了framework目录下的contract/orm.go 和 provider/orm 目录。目录截图如下,供对比查看,所有代码都已经上传到geekbang/25分支了。

小结

对于Gorm这样比较庞大的库,要把Gorm完美集成到hade框架,更好地支持业务对数据库频繁的增删改查操作,我们并不是一开始就动手修改代码,而是先把Gorm的实例化部分的源码都理清楚了,再动手集成才不会出现问题。

现在我们可以在hade框架中方便获取到gorm.DB了。但是在具体开发业务的时候,如何使用好Gorm来为业务服务,也是一个非常值得花心思研究的课题。好在我们的技术选型是目前Golang业界最火的Gorm,网络上关于如何使用Gorm的课程有非常多了,在具体开发业务的时候,你可以自己参考和研究。

思考题

在ORM框架中,model层的存放位置一直是个很有争论的话题。比如geekbang/25 分支上model层的User结构,我存放在app/http/module/demo中,有同学会觉得model层放在 app/http/model目录比较好么?具体model是否应该单独作为一个文件夹出来呢?

欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~