Go Web开发扩展项-GROM框架

Go Web开发扩展项-其他配套框架

作者:可乐可乐可:https://blog.csdn.net/weixin_44494373
上一篇:Go Web开发入门指南<前半>
内容:Go Web开发套装,Gin,Gorm,viper,validator,zap,go-redis,grpc

本文难度:适合入门

上半部分,我们讲了讲Gin,想做到web开发,我们现在已经有了基础的MVC,我们只需要ORM框架(操作数据库)日志框架就能满足基本80%的需求了,初次之外,就是配置文件的支持,rpc微服务体系支持

我们一点一点来扩展我们的知识范围,相比各位已经很心急了,那我们按照这样的流程进行讲解

ORM框架gorm -> 日志框架:logrus、zap -> 配置文件viper

用Gorm来开启你的CRUD之旅(不是

功夫再深,也逃不了CRUD的命运,我与大佬的不同,不在与拧螺丝的工作,在于在什么地点拧螺丝,(开玩笑)

Go是编程语言里面的新生儿这种事情就不用再强调了,新时代的CRUD,呸呸呸,ORM操作,加上Go简洁的追求,Go的ORM框架用起来很轻松(但是经验告诉我,用起来越简单,听起来越全自动的东西,都越恐怖,声明,本句与Kubernetes没有任何关系)

这里也算提个醒吧,工具自动化是好事,但是这意味着你可能会有需要进入这自动化的代码中,看看他的原理,找到你的解答

来,先码一下gorm的官方文档:文档地址

官方文档写的很棒,多看看官方文档(毕竟中文版本)

写到这里,弟弟又去复习了一遍文档,gorm的文档,确实。。太到位了,建议各位看看官方文档吧,这里我给大家讲一下基本的思路让大家对gorm里的一些概念有个基础的认识

但是不写Gorm,总感觉很空虚,思来想去,最后还是写了出来,我会尽量用简单便于理解的方式帮助大家了解gorm,后面就只需要在官方文档找api了

操作数据库,一般需要两个部分,1、数据库驱动 2、orm框架

而一个orm框架,大致会有这些构建

  1. 类与表的映射
  2. 建立数据库连接
  3. 数据库连接池
  4. CRUD操作
  5. 事务控制
  6. 日志配置

这几个步骤也是我们使用一个ORM框架的基本步骤

数据库表映射

gorm没有另辟蹊径,是很经典的类型->表,字段对应表字段

我们创建一个model

package model

// data将会变成表名称
type Data struct {
	ID  uint   `json:"id,omitempty" gorm:"primaryKey"`
	Msg string `json:"msg,omitempty" gorm:"type:varchar(10) unique"`
}

关于表名与字段这里需要注意以下

GORM在字段和表面的约定

使用 ID 作为主键

默认情况下,GORM 会使用 ID 作为表的主键。

type User struct {
  ID   string // 默认情况下,名为 `ID` 的字段会作为表的主键
  Name string
}

你可以通过标签 primaryKey 将其它字段设为主键

// 将 `UUID` 设为主键
type Animal struct {
  ID     int64
  UUID   string `gorm:"primaryKey"`
  Name   string
  Age    int64
}

此外,您还可以看看 复合主键

复数表名

GORM 使用结构体名的 蛇形命名 作为表名。对于结构体 User,根据约定,其表名为 users

这里我很疑惑,我们使用AaBc会被创建为aa_bcs,但是我们命名为Data,将创建为data

总之,官方并没有仔细的叙述这里的逻辑,很无奈,我们只能被逼再次打开源码(好耶!

进入(db *DB) AutoMigrate(dst …interface{})方法,

我们发现他是适配器模式,调用了db.Migrator().AutoMigrate(dst…)

进入db.Migrator().AutoMigrate(dst…),这个接口只有一个实现类,妥妥的适配器模式

image-20210514143810505

在这里,我们大致看了看,逻辑分为创建和修改表,不用多想,他们的策略一定是一样的,我们看create即可

代码贴在了下面,大家来一起观赏一下,gorm的骚操作,原来也就是sql的拼接罢了

各位先复习一下sql创建表的语句

CREATE TABLE Persons
(
PersonID int,
LastName varchar(255),
FirstName varchar(255),
Address varchar(255),
City varchar(255)
);

大概看看就行,流程其实很简单,就是拼凑出一个sql

func (m Migrator) CreateTable(values ...interface{}) error {
  // 传进来的是多个表,遍历创建每一个
	for _, value := range m.ReorderModels(values, false) {
    // 开一个session
		tx := m.DB.Session(&gorm.Session{})
    // 开始拼接sql
		if err := m.RunWithValue(value, func(stmt *gorm.Statement) (errr error) {
      // CREATE TABLE ? (
      var (
        // sql语句,参数用?
				createTableSQL          = "CREATE TABLE ? ("
        // sql参数
				values                  = []interface{}{m.CurrentTable(stmt)}
				hasPrimaryKeyInDataType bool
			)
      // 拼接上每一个字段,还会加上PRIMARY KEY
      // CREATE TABLE ? ( ? ? )
			for _, dbName := range stmt.Schema.DBNames {
				field := stmt.Schema.FieldsByDBName[dbName]
				createTableSQL += "? ?"
        // 获取标签,取出字符串 PRIMARY KEY
				hasPrimaryKeyInDataType = hasPrimaryKeyInDataType || strings.Contains(strings.ToUpper(string(field.DataType)), "PRIMARY KEY")
				values = append(values, clause.Column{Name: dbName}, m.DB.Migrator().FullDataTypeOf(field))
				createTableSQL += ","
			}

			if !hasPrimaryKeyInDataType && len(stmt.Schema.PrimaryFields) > 0 {
				createTableSQL += "PRIMARY KEY ?,"
				primaryKeys := []interface{}{}
				for _, field := range stmt.Schema.PrimaryFields {
					primaryKeys = append(primaryKeys, clause.Column{Name: field.DBName})
				}

				values = append(values, primaryKeys)
			}
			// 索引逻辑
			for _, idx := range stmt.Schema.ParseIndexes() {
				if m.CreateIndexAfterCreateTable {
					defer func(value interface{}, name string) {
						if errr == nil {
							errr = tx.Migrator().CreateIndex(value, name)
						}
					}(value, idx.Name)
				} else {
					if idx.Class != "" {
						createTableSQL += idx.Class + " "
					}
					createTableSQL += "INDEX ? ?"

					if idx.Option != "" {
						createTableSQL += " " + idx.Option
					}

					createTableSQL += ","
					values = append(values, clause.Expr{SQL: idx.Name}, tx.Migrator().(BuildIndexOptionsInterface).BuildIndexOptions(idx.Fields, stmt))
				}
			}
			// 关系
			for _, rel := range stmt.Schema.Relationships.Relations {
				if !m.DB.DisableForeignKeyConstraintWhenMigrating {
					if constraint := rel.ParseConstraint(); constraint != nil {
						if constraint.Schema == stmt.Schema {
							sql, vars := buildConstraint(constraint)
							createTableSQL += sql + ","
							values = append(values, vars...)
						}
					}
				}
			}

			for _, chk := range stmt.Schema.ParseCheckConstraints() {
				createTableSQL += "CONSTRAINT ? CHECK (?),"
				values = append(values, clause.Column{Name: chk.Name}, clause.Expr{SQL: chk.Constraint})
			}

      // 要是结尾有, 就去掉
			createTableSQL = strings.TrimSuffix(createTableSQL, ",")

			createTableSQL += ")"

			if tableOption, ok := m.DB.Get("gorm:table_options"); ok {
				createTableSQL += fmt.Sprint(tableOption)
			}
			// 跑sql
			errr = tx.Exec(createTableSQL, values...).Error
			return errr
		}); err != nil {
			return err
		}
	}
	return nil
}

诶,不对啊,这里好像,没有处理字段名,?,不好意思(故意的,还不承认

我们回到上一级,发现这里有个ReorderModels的方法,好家伙就是你了,捞开看看

image-20210514145020691

这个方法里面,有这样一段代码,

parse,parse value,这是编程界处理解析数据的官话,我们锁定这个方法,点进去

image-20210514150050574

image-20210514150224771

schema是关系的意思,直接点进去,我们看到了反射,没毛病了,就是这里了

image-20210514150355353

首先就是经典的反射处理,验证类型,适配类型

检查类型是否实现了接口TableName,如果使用了,就直接调用返回

然后利用得到的类型创建一个新的空对象

接着用namer.TableName()获取解析后的表名,好家伙,可找到你了

image-20210514150647808

// TableName convert string to table name
func (ns NamingStrategy) TableName(str string) string {
   // 单数的table,说明这里来判断末尾是不是加s
   if ns.SingularTable {
      return ns.TablePrefix + ns.toDBName(str)
   }
   // 加上s
   return ns.TablePrefix + inflection.Plural(ns.toDBName(str))
}

// 将字符串处理
// 大小写变动,驼峰转下划线形式,想看可以看,其实就是一个字符一个字符判断,判断当前是转化,插入,还是加下划线
// Dat->dat
// Data->data
func (ns NamingStrategy) toDBName(name string) string {
	if name == "" {
		return ""
	}

	if ns.NameReplacer != nil {
		name = ns.NameReplacer.Replace(name)
	}

	if ns.NoLowerCase {
		return name
	}

	var (
		value                          = commonInitialismsReplacer.Replace(name)
		buf                            strings.Builder
		lastCase, nextCase, nextNumber bool // upper case == true
		curCase                        = value[0] <= 'Z' && value[0] >= 'A'
	)
	// 遍历表名
	for i, v := range value[:len(value)-1] {
		nextCase = value[i+1] <= 'Z' && value[i+1] >= 'A'
		nextNumber = value[i+1] >= '0' && value[i+1] <= '9'

		if curCase {
			if lastCase && (nextCase || nextNumber) {
				buf.WriteRune(v + 32)
			} else {
				if i > 0 && value[i-1] != '_' && value[i+1] != '_' {
					buf.WriteByte('_')
				}
				buf.WriteRune(v + 32)
			}
		} else {
			buf.WriteRune(v)
		}

		lastCase = curCase
		curCase = nextCase
	}

	if curCase {
		if !lastCase && len(value) > 1 {
			buf.WriteByte('_')
		}
		buf.WriteByte(value[len(value)-1] + 32)
	} else {
		buf.WriteByte(value[len(value)-1])
	}
	ret := buf.String()
	return ret
}

然后 Plural(str string) string 处理已经大小写处理过的字符串

Plural 复数

// Plural converts a word to its plural form
func Plural(str string) string {
   for _, inflection := range compiledPluralMaps {
      if inflection.regexp.MatchString(str) {
         return inflection.regexp.ReplaceAllString(str, inflection.replace)
      }
   }
   return str
}

等等,这个代码,马萨卡(不会),他竟然是枚举所有的特殊复数(比如fish鱼,的复数为fish,ac的复数为acs)。。。可真有你的

问题解决了兄弟们,data表名,是因为。。data是不可数(英语知识)

总结:Gorm创建表,默认会使用复数做表名,同时语义符合英文语法(尽管形式很生猛),可以通过Config进行选择是否使用复数。

TableName

您可以实现 Tabler 接口来更改默认表名,例如:

type Tabler interface {
    TableName() string
}

// TableName 会将 User 的表名重写为 `profiles`
func (User) TableName() string {
  return "profiles"
}

注意: TableName 不支持动态变化,它会被缓存下来以便后续使用。想要使用动态表名,你可以使用 Scopes,例如:

func UserTable(user User) func (tx *gorm.DB) *gorm.DB {
  return func (tx *gorm.DB) *gorm.DB {
    if user.Admin {
      return tx.Table("admin_users")
    }

    return tx.Table("users")
  }
}

db.Scopes(UserTable(user)).Create(&user)

临时指定表名

您可以使用 Table 方法临时指定表名,例如:

// 根据 User 的字段创建 `deleted_users` 表
db.Table("deleted_users").AutoMigrate(&User{})

// 从另一张表查询数据
var deletedUsers []User
db.Table("deleted_users").Find(&deletedUsers)
// SELECT * FROM deleted_users;

db.Table("deleted_users").Where("name = ?", "jinzhu").Delete(&User{})
// DELETE FROM deleted_users WHERE name = 'jinzhu';

查看 from 子查询 了解如何在 FROM 子句中使用子查询

命名策略

GORM 允许用户通过覆盖默认的命名策略更改默认的命名约定,命名策略被用于构建: TableNameColumnNameJoinTableNameRelationshipFKNameCheckerNameIndexName。查看 GORM 配置 获取详情

列名

根据约定,数据表的列名使用的是 struct 字段名的 蛇形命名

type User struct {
  ID        uint      // 列名是 `id`
  Name      string    // 列名是 `name`
  Birthday  time.Time // 列名是 `birthday`
  CreatedAt time.Time // 列名是 `created_at`
}

您可以使用 column 标签或 命名策略 来覆盖列名

type Animal struct {
  AnimalID int64     `gorm:"column:beast_id"`         // 将列名设为 `beast_id`
  Birthday time.Time `gorm:"column:day_of_the_beast"` // 将列名设为 `day_of_the_beast`
  Age      int64     `gorm:"column:age_of_the_beast"` // 将列名设为 `age_of_the_beast`
}

时间戳追踪

CreatedAt

对于有 CreatedAt 字段的模型,创建记录时,如果该字段值为零值,则将该字段的值设为当前时间

db.Create(&user) // 将 `CreatedAt` 设为当前时间

user2 := User{Name: "jinzhu", CreatedAt: time.Now()}
db.Create(&user2) // user2 的 `CreatedAt` 不会被修改

// 想要修改该值,您可以使用 `Update`
db.Model(&user).Update("CreatedAt", time.Now())
UpdatedAt

对于有 UpdatedAt 字段的模型,更新记录时,将该字段的值设为当前时间。创建记录时,如果该字段值为零值,则将该字段的值设为当前时间

db.Save(&user) // 将 `UpdatedAt` 设为当前时间

db.Model(&user).Update("name", "jinzhu") // 会将 `UpdatedAt` 设为当前时间

db.Model(&user).UpdateColumn("name", "jinzhu") // `UpdatedAt` 不会被修改

user2 := User{Name: "jinzhu", UpdatedAt: time.Now()}
db.Create(&user2) // 创建记录时,user2 的 `UpdatedAt` 不会被修改

user3 := User{Name: "jinzhu", UpdatedAt: time.Now()}
db.Save(&user3) // 更新世,user3 的 `UpdatedAt` 会修改为当前时间

注意 GORM 支持拥有多种类型的时间追踪字段。可以根据 UNIX(毫/纳)秒,查看 Model 获取详情

使用gorm:"primaryKey"标签可以指定主键

gorm支持创建数据表,使用AutoMigrate即可

err = db.AutoMigrate(&model.Data{})
if err != nil {
   fmt.Printf("error :%s",err.Error())
}

我们执行这个命令后,发现字符串类型为longText这万万不可啊,没关系,go提供了丰富的字段标签支持,帮助我们定义一个数据表

声明 model 时,tag 是可选的,GORM 支持以下 tag: tag 名大小写不敏感,但建议使用 camelCase 风格

标签名说明
column指定 db 列名
type列数据类型,推荐使用兼容性好的通用类型,例如:所有数据库都支持 bool、int、uint、float、string、time、bytes 并且可以和其他标签一起使用,例如:not nullsize, autoIncrement… 像 varbinary(8) 这样指定数据库数据类型也是支持的。在使用指定数据库数据类型时,它需要是完整的数据库数据类型,如:MEDIUMINT UNSIGNED not NULL AUTO_INCREMENT
size指定列大小,例如:size:256
primaryKey指定列为主键
unique指定列为唯一
default指定列的默认值
precision指定列的精度
scale指定列大小
not null指定列为 NOT NULL
autoIncrement指定列为自动增长
autoIncrementIncrement自动步长,控制连续记录之间的间隔
embedded嵌套字段
embeddedPrefix嵌入字段的列名前缀
autoCreateTime创建时追踪当前时间,对于 int 字段,它会追踪秒级时间戳,您可以使用 nano/milli 来追踪纳秒、毫秒时间戳,例如:autoCreateTime:nano
autoUpdateTime创建/更新时追踪当前时间,对于 int 字段,它会追踪秒级时间戳,您可以使用 nano/milli 来追踪纳秒、毫秒时间戳,例如:autoUpdateTime:milli
index根据参数创建索引,多个字段使用相同的名称则创建复合索引,查看 索引 获取详情
uniqueIndexindex 相同,但创建的是唯一索引
check创建检查约束,例如 check:age > 13,查看 约束 获取详情
<-设置字段写入的权限, <-:create 只创建、<-:update 只更新、<-:false 无写入权限、<- 创建和更新权限
->设置字段读的权限,->:false 无读权限
-忽略该字段,- 无读写权限
comment迁移时为字段添加注释

为了最大化复用字段,我们可能会用嵌套的方式,来表达一个数据,在Go里面也是支持嵌套的

这里补充一点官方没有提到的:tag怎么用233,事实上,gorm的tag是这么用的

package model

// data将会变成表名称
type Data struct {
	ID  uint   `json:"id,omitempty" gorm:"primaryKey"`
  // 都是用gorm标签,空格区分
	Msg string `json:"msg,omitempty" gorm:"type:varchar(10) unique"`
}

以下来自官方文档

嵌入结构体

对于匿名字段,GORM 会将其字段包含在父结构体中,例如:

type User struct {
  gorm.Model
  Name string
}
// 等效于
type User struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
  Name string
}

对于正常的结构体字段,你也可以通过标签 embedded 将其嵌入,例如:

type Author struct {
    Name  string
    Email string
}

type Blog struct {
  ID      int
  Author  Author `gorm:"embedded"`
  Upvotes int32
}
// 等效于
type Blog struct {
  ID    int64
    Name  string
    Email string
  Upvotes  int32
}

并且,您可以使用标签 embeddedPrefix 来为 db 中的字段名添加前缀,例如:

type Blog struct {
  ID      int
  Author  Author `gorm:"embedded;embeddedPrefix:author_"`
  Upvotes int32
}
// 等效于
type Blog struct {
  ID          int64
    AuthorName  string
    AuthorEmail string
  Upvotes     int32
}

创建连接

如何创建一个数据库连接

我们使用大伙用的最多的数据库MySQL,

首先需要我们找一下mysql的驱动,gorm提供了mysql的驱动https://github.com/go-gorm/mysql

在你的项目路径里面,跑俩命令

go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql

使用下面的命令创建一个数据库连接

// 参考 https://github.com/go-sql-driver/mysql#dsn-data-source-name 获取详情
dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

现在我们获取到的这个db变量*gorm.DB类型,gorm与gin的风格类似,使用一个类完成所有可能的操作

CRUD

GORM有两种风格来运行sql,第一种风格为链式调用,第二种风格为原生sql

当然,链式调用更轻松,一般用这个就够了,

在初始化测试数据,清空表等场景,用原生sql更快,更直白

这里我只简单讲解重要的点,更多内容建议移步官方文档 https://gorm.io/zh_CN/docs/create.html

看完基础后,各位回来看看下面的问题

链式调用

GORM内部有很多api,Where,Order,Frist,Last,Find等等,

这些文件分为三大类:链式方法,Finisher方法,新建会话方法

我们先不管后面两个,先来看看调用链

调用链?

我们以Where进行举例

// Where add conditions
func (db *DB) Where(query interface{}, args ...interface{}) (tx *DB) {
   tx = db.getInstance()
   if conds := tx.Statement.BuildCondition(query, args...); len(conds) > 0 {
      tx.Statement.AddClause(clause.Where{Exprs: conds})
   }
   return
}

从代码我们可以看出,首先获取db内部的tx对象,然后Where语句下一步是拼接出条件,然后调用db内部tx的AddClause()方法,将条件注册到内部的tx对象中,然后返回tx

那么这个tx是什么,getInstance是什么?

我们拿出get Instance的代码

func (db *DB) getInstance() *DB {
   if db.clone > 0 {
      tx := &DB{Config: db.Config, Error: db.Error}

      if db.clone == 1 {
         // clone with new statement
         tx.Statement = &Statement{
            DB:       tx,
            ConnPool: db.Statement.ConnPool,
            Context:  db.Statement.Context,
            Clauses:  map[string]clause.Clause{},
            Vars:     make([]interface{}, 0, 8),
         }
      } else {
         // with clone statement
         tx.Statement = db.Statement.clone()
         tx.Statement.DB = tx
      }

      return tx
   }

   return db
}

从这段代码,我们可以看出来,如果clone大于0,将新建一个tx出来,这个tx就是DB类型的对象,然后判断是clone还是新建,新建的话就新建一个tx.Statement出来,而且新建的tx的clone为0

也就是说,我们使用open获得的db,是一个根DB,其clone值为1,在使用它时,它将创建新的DB,而创建出的新DB,其clone值为0,将继续使用下去

这里大家要注意的是,gorm底层其实还是sql的拼接,所以各位要注意sql语法

创建会话模式

在上面的分析中,我们发现当使用db创建一个新调用时,db内部的clone为1,然后会创建新的DB来给后面的语句操作

那么这个clone为1的语句是从哪里来的?

——创建会话模式

新初始化的 *gorm.DB 或调用 新建会话方法

我们点开gorm.Open(mysql.Open(dsn), &gorm.Config{})这个代码,

其中有一行代码为

db = &DB{Config: config, clone: 1}

ok,实锤了一个

还有就是新建会话方法,没毛病,都是clone值为1

// Session create new db session
func (db *DB) Session(config *Session) *DB {
   var (
      txConfig = *db.Config
      tx       = &DB{
         Config:    &txConfig,
         Statement: db.Statement,
         Error:     db.Error,
         clone:     1,
      }
   )
   ...
}

所以官网示范中,下面的语句

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
// db 是一个刚完成初始化的 *gorm.DB 实例,其属于 `新建会话模式`
tx := db.Where("name = ?", "jinzhu")
// `Where("name = ?", "jinzhu")` 是第一个被调用的方法,它创建了一个新的 `Statement` 并添加条件

tx.Where("age = ?", 18).Find(&users)
// `tx.Where("age = ?", 18)` 会复用上面的那个 `Statement`,并向其添加条件
// `Find(&users)` 是一个 finisher 方法,它运行注册的查询回调,生成并运行下面这条 SQL:
// SELECT * FROM users WHERE name = 'jinzhu' AND age = 18

tx.Where("age = ?", 28).Find(&users)
// `tx.Where("age = ?", 18)` 同样会复用上面的那个 `Statement`,并向其添加条件
// `Find(&users)` 是一个 finisher 方法,它运行注册的查询回调,生成并运行下面这条 SQL:
// SELECT * FROM users WHERE name = 'jinzhu' AND age = 18 AND age = 28;

注意: 在示例 2 中,第一个查询会影响第二个查询生成的 SQL,因为 GORM 复用了 Statement 这可能会导致预期之外的问题,查看 协程安全 以避免该问题

这段话,就很轻松可以理解了。

协程安全

初次之外,文章还提到了协程安全,为什么这么说呢,协程安全,也就是其他语言里面的多线程安全,也就是说,没有锁,为什么呢?

——Where等语句操作的是db对象内部的tx对象,tx属于临界区,所以这是危险的。

然后在末尾补一句:

Order类似于order by 语句,Order后接First或者Last时,First或者Last的order失效

最近活多起来了,本次就先更到这里

后续:日志框架:logrus、zap -> 配置文件viper


版权声明:本文为weixin_44494373原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。