自己动手写一个Golang ORM框架

作者:smallyang,腾讯 IEG 运营开发工程师

当我深入的学习和了解了 GORM,XORM 后,我还是觉得它们不够简洁和优雅,有些笨重,有很大的学习成本。本着学习和探索的目的,于是我自己实现了一个简单且优雅的 go 语言版本的 ORM。

如上面的导语所示,GORM 算是 Golang 里面 ORM 库的头牌,它功能虽然很强大,但是我觉得它有很深的学习成本,对于新人而言,纯使用没有啥问题,但是当遇到一些复杂的查询的时候,就会捉襟见肘了,因为它的内部实现太复杂了,以至于你很难摸透它。于是,本着一边学习一边探索的目的,我从基础原理开始讲起,到一步一步实现,继而完成整个简单且优雅的 MySQL ORM。

一、前置学习

1. 为什么要用 ORM

我们在使用各种语言去做需求的时候,不管是 PHP,Golang 还是 C++等语言,应该都接触使用过用 ORM 去链接数据库,这些 ORM 有些是项目组自己整合实现的,也有些是用的开源的组件。特别在 1 个全新的项目中,我们都会用一个 ORM 框架去连接数据库,而不是直接用原生代码去写 SQL 链接,原因有很多,有安全考虑,有性能考虑,但是,更多的我觉得还是懒(逃)和开发效率低,因为有时候一些 SQL 写起来也是很复杂很累的,特别是查询列表的时候,又是分页,又是结果集,还需要自己for next去判断和遍历,是真的有累,开发效率非常低。如果有个 ORM,数据库 config 一配,几个链式函数一调,咔咔咔,结果就出来了。

所以ORM就是我们和数据库交互的中间件,我们通过ORM提供的各种快捷的方法去和数据库产生交互,继而更加方便高效的实现功能。

一句话总结什么是 ORM: 提供更加方便快捷的curd方法去和数据库产生交互

2. Golang 里面是如何原生连接 MySQL 的

说完了啥是 ORM,以及为啥用 ORM 之后,我们再看下 Golang 里面是如何原生连接 MySQL 的,这对于我们开发一个 ORM 帮助很大,只有弄清楚了它们之间交互的原理,我们才能更好的开始造。

原生代码连接 MySQL,一般是如下步骤。

首先是导入 sql 引擎和 mysql 的驱动:

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

连接 MySQL :

db, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3306)/ApiDB?charset=utf8") //第一个参数数驱动名
if err != nil {
    panic(err.Error())
}

然后,我们快速过一下,如何增删改查:

增:

//方式一:
result, err := db.Exec("INSERT INTO userinfo (username, departname, created) VALUES (?, ?, ?)","lisi","dev","2020-08-04")

//方式二:
stmt, err := db.Prepare("INSERT INTO userinfo (username, departname, created) VALUES (?, ?, ?)")

result2, err := stmt.Exec("zhangsan", "pro", time.Now().Format("2006-01-02"))

删:

//方式一:
result, err := db.Exec("delete from userinfo where uid=?", 10795)

//方式二:
stmt, err := db.Prepare("delete from userinfo where uid=?")

result3, err := stmt.Exec("10795")

 

资料领取直通车:Golang云原生最新资料+视频学习路线icon-default.png?t=M85Bhttps://docs.qq.com/doc/DTllySENWZWljdWp4

Go语言学习地址:Golang DevOps项目实战icon-default.png?t=M85Bhttps://ke.qq.com/course/422970?flowToken=1043212

改:

//方式一:
result, err := db.Exec("update userinfo set username=? where uid=?", "lisi", 2)

//方式二:
stmt, err := db.Prepare("update userinfo set username=? where uid=?")

result, err := stmt.Exec("lisi", 2)

查:

//单条
var username, departname, status string
err := db.QueryRow("select username, departname, status from userinfo where uid=?", 4).Scan(&username, &departname, &status)
if err != nil {
    fmt.Println("QueryRow error :", err.Error())
}
fmt.Println("username: ", username, "departname: ", departname, "status: ", status)

//多条:
rows, err := db.Query("select username, departname, status from userinfo where username=?", "yang")
if err != nil {
    fmt.Println("QueryRow error :", err.Error())
}

//定义一个结构体,存放数据模型
type UserInfo struct {
    Username   string `json:"username"`
    Departname string `json:"departname"`
    Status    string `json:"status"`
}

//初始化
var user []UserInfo

for rows.Next() {
    var username1, departname1, status1 string
    if err := rows.Scan(&username1, &departname1, &status1); err != nil {
        fmt.Println("Query error :", err.Error())
    }
    user = append(user, UserInfo{Username: username1, Departname: departname1, Status: status1})
}

所以,总结一下,Golang 里面原生连接 MySQL 的方法,非常简单,就是直接写 sql 嘛,简单粗暴点就直接Exec,复杂点但是效率会高一些就先PrepareExec。总体而言,这个学习成本是非常低的,最大的问题嘛,就是麻烦和开发效率点。

所以我在想?我是不是可以基于原生代码库的这个优势,自己开发 1 个 ORM 呢,第一:它能提供了各式各样的方法来提高开发效率,第二:底层直接转换拼接成最终的 SQL,去调用这个原生的组件,来和 MySQL 去交互。这样岂不是一箭双雕,既能提高开发效率,又能保持足够的高效和简单。完美!

说干就干吧!

3. ORM 框架构想

本 ORM 库原理是简单的 SQL 拼接。暴露各种 CURD 方法,并在底层逻辑拼接成PrepareEexc占位符部分,继而来调用"github.com/go-sql-driver/mysql"驱动的方法来实现和数据库交互。

首先,先取个厉害的名字吧:smallorm,嗯,还行!

然后,整个调用过程采用链式的方法,这样比较方便,比如这样子:

db.Where().Where().Order().Limit().Select()

其次,暴露的 CURD 方法,使用起来要简单,名字要清晰,无歧义,不要搞一大堆复杂的间接调用。

OK,我们梳理一下,sql 里面常用到的一些 curd 的方法,把他们整理成 ORM 的一个个方法,并按照这个一步一步来实现,如下:

  • [ ] 0. 连接 Connect
  • [ ] 1. 设置表名 Table
  • [ ] 2. 新增/替换Insert/Replace
  • [ ] 3. 条件Where
  • [ ] 4. 删除Delete
  • [ ] 5. 修改Update
  • [ ] 6. 查询Select
  • [ ] 7. 执行原生 SQLExec/Query
  • [ ] 8. 设置查询字段Field
  • [ ] 9. 设置大小Limit
  • [ ] 10. 聚合查询Count/Max/Min/Avg/Sum
  • [ ] 11. 排序Order
  • [ ] 12. 分组Group
  • [ ] 13. 分组后判断Having
  • [ ] 14. 获取执行生成的完整 SQLGetLastSql
  • [ ] 15. 事务Begin/Commit/Rollback/

其中Insert/Replace/Delete/Select/Update是整个链式操作的最后一步。是真正的和 MySQL 交互的方法,后面不能再链式接其他的操作方法。

所以,我们可以畅享一下,这个完成后的 ORM,是如何调用的:

增:

type User1 struct {
    Username   string `sql:"username"`
    Departname string `sql:"departname"`
    Status     int64  `sql:"status"`
}

user2 := User1{
    Username:   "EE",
    Departname: "22",
    Status:     1,
}

// insert into userinfo (username,departname,status) values ('EE', '22', 1)

id, err := e.Table("userinfo").Insert(user2)

删:

// delete from userinfo where (uid = 10805)

result1, err := e.Table("userinfo").Where("uid", "=", 10805).Delete()

改:

// update userinfo set departname=110 where (uid = 10805)

result1, err := e.Table("userinfo").Where("uid", "=", 10805).Update("departname", 110)

查:

// select uid, status from userinfo where (departname like '%2') or (status=1)  order by uid desc limit 1

result, err := e.Table("userinfo").Where("departname", "like", "%2").OrWhere("status", 1).Order("uid", "desc").Limit(1).Field("uid, status").Select()

//select uid, status from userinfo where (uid in (1,2,3,4,5)) or (status=1)  order by uid desc limit 1

result, err := e.Table("userinfo").Where("uid", "in", []int{1,2,3,4,5}).OrWhere("status", 1).Order("uid", "desc").Limit(1).Field("uid, status").SelectOne()


type User1 struct {
    Username   string `sql:"username"`
    Departname string `sql:"departname"`
    Status     int64  `sql:"status"`
}

user2 := User1{
    Username:   "EE",
    Departname: "22",
    Status:     1,
}

user3 := User1{
    Username:   "EE",
    Departname: "22",
    Status:     2,
}

// select * from userinfo where (Username='EE' and Departname='22' and Status=1) or (Username='EE' and Departname='22' and Status=2)  limit 1
id, err := e.Table("userinfo").Where(user2).OrWhere(user3).SelectOne()

二、开始造

0. 连接Connect

连接 MySQL 比较简单,直接把原生的sql.Open("mysql", dsn)方法套一个函数壳即可,但是需要考虑协程和长连接的保持以及 ping 失败的情况。我们这里第一版本就先不考虑了

第一步,先构造 1 个变量引擎SmallormEngine,它是结构体类型的,用来存储各种各样的数据,其他的对外暴露的 CURD 方法也是基于这个结构体来继承的。

type SmallormEngine struct {
   Db           *sql.DB
   TableName    string
   Prepare      string
   AllExec      []interface{}
   Sql          string
   WhereParam   string
   LimitParam   string
   OrderParam   string
   OrWhereParam string
   WhereExec    []interface{}
   UpdateParam  string
   UpdateExec   []interface{}
   FieldParam   string
   TransStatus  int
   Tx           *sql.Tx
   GroupParam   string
   HavingParam  string
}

因为我们这 ORM 的底层本质是 SQL 拼接,所以,我们需要把各种操作方法生成的数据,都保存到这个结构体的各个变量上,方便最后一步生成 SQL。

其中需要简单说明的是这 2 个字段:Db字段的类型是*sql.DB,它用于直接进行 CURD 操作,Tx*sql.Tx类型的,它是数据库的事务操作,用于回滚和提交。这个后面会详细讲,这里有一个大致的概念即可。

接下来就可以写连接操作了:

//新建Mysql连接
func NewMysql(Username string, Password string, Address string, Dbname string) (*SmallormEngine, error) {
    dsn := Username + ":" + Password + "@tcp(" + Address + ")/" + Dbname + "?charset=utf8&timeout=5s&readTimeout=6s"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err
    }

    //最大连接数等配置,先占个位
   //db.SetMaxOpenConns(3)
   //db.SetMaxIdleConns(3)

    return &SmallormEngine{
        Db:         db,
        FieldParam: "*",
    }, nil
}

创建了一个方法NewMysql来创建 1 个新的连接,参数是(用户名,密码,ip和端口,数据库名)。之所以用这个名字的考虑是:1. 万一 2.0 版本支持了其他数据库呢(手动狗头)2. 后续连接池的加入。

其次,如何实现链式的方式调用呢?只需要在每个方法返回实例本身即可,比如:

func (e *SmallormEngine) Where (name string) *SmallormEngine {
   return e
}

func (e *SmallormEngine) Limit (name string) *SmallormEngine {
   return e
}

这样我们就可以链式的调用了:

e.Where().Where().Limit()

1. 设置/读取表名Table/GetTable

我们需要 1 个设置和读取数据库表名字的方法,因为我们所有的 CURD 都是基于某张表的:

//设置表名
func (e *SmallormEngine) Table(name string) *SmallormEngine {
   e.TableName = name

   //重置引擎
   e.resetSmallormEngine()
   return e
}

//获取表名
func (e *SmallormEngine) GetTable() string {
   return e.TableName
}

这样我们每一次调用Table()方法,就给本次的执行设置了一个表名。并且会清空SmallormEngine节点上挂载的所有数据。

2. 新增/替换Insert/Replace

2.1 单个数据插入

下面就是本 ORM 第一个重头戏和挑战点了,如何往数据库里插入数据?在如何用 ORM 实现本功能之前,我们先回忆下上面讲的原生的代码是如何插入的:

我们用先PrepareExec这种方式,高效且安全:

stmt, err := db.Prepare("INSERT INTO userinfo (username, departname, created) VALUES (?, ?, ?)")

result2, err := stmt.Exec("zhangsan", "pro", time.Now().Format("2006-01-02"))

我们分析下它的做法:

  1. 先在Prepare里,把插入的数据的 value 值用?占位符代替,有几个 value 就用几个?
  2. Exec里面,把 value 值给补上,和?的数量一直即可。

ok,妥了,整明白了。那我们就按照这 2 部拆分数据即可。

为了保持方便,我们调用这个Insert方法进行插入数据的时候,参数是要传 1 个 k-v 的键值对类,比如:[field1:value1,field2:value2,field3:value3],field 表示表的字段,value 表示字段的值。在 go 语言里面,这样的类型可以是Map或者Struct,但是Map必须得都是同一个类型的,显然是不符合数据库表里面,不同的字段可能是不同的类型的这一情况,所以,我们选择了Struct结构体, 它里面是可以有多种数据类型存在,也刚好符合情况。

由于 go 里面的数据都得是先定义类型,再去初始化 1 个值,所以,大致的调用过程是这样的:

type User struct {
    Username   string `sql:"username"`
    Departname string `sql:"departname"`
    Status     int64  `sql:"status"`
}

user2 := User{
    Username:   "EE",
    Departname: "22",
    Status:     1,
}

id, err := e.Table("userinfo").Insert(user2)

我们注意下,User 结构体的每一个元素后面都有一个sql:"xxx",这个叫Tag标签。这是干啥用的呢?是因为 go 里面首字母大写表示是可见的变量,所以如果是可见的变量都是大写字母开头,而 sql 语句表里面的字段首字母名一般是小写,所以,为了照顾这个特殊的关系,进行转换和匹配,才用了这个标签特性。如果你的表的字段类型也是大小字母开头,那就可以不需要这个标签,下面我们会具体说到如何转换匹配的。

所以,接下来的难点就是把user2进行解析,拆分成这 2 步:

第一步:将sql:"xxx"标签进行解析和匹配,依次替换成全小写的,解析成(username, departname, status),并且依次生成对应数量的?

stmt, err := db.Prepare("INSERT INTO userinfo (username, departname, status) VALUES (?, ?, ?)")

第二步: 将user2的子元素的值都拆出来,放入到Exec中。

result2, err := stmt.Exec("EE", "22", 1)

那么,user2里面的 3 个子元素的 field,如何解析成(username, departname, status)呢?由于我们是一个通用的方法,golang 是没法直接通过 for 循环来知道传入的数据结构参数里面包含哪些 field 和 value 的,咋办呢?这个时候,大名鼎鼎的反射就可以派上用场了。我们可以通过反射来推导出传入的结构体变量,它的 field 是多少,value 是什么,类型是什么。tag 是什么。都可以通过反射来推导出来。

我们现在试一下其中的 2 个函数reflect.TypeOfreflect.ValueOf

type User struct {
    Username   string `sql:"username"`
    Departname string `sql:"departname"`
    Status     int64  `sql:"status"`
}

user2 := User{
    Username:   "EE",
    Departname: "22",
    Status:     1,
}


//反射出这个结构体变量的类型
t := reflect.TypeOf(user2)

//反射出这个结构体变量的值
v := reflect.ValueOf(user2)

fmt.Printf("==== print type ====\n%+v\n", t)
fmt.Printf("==== print value ====\n%+v\n", v)

我们打印看看,结果是啥?

==== print type ====
main.User

==== print value ====
{Username:EE Departname:22 Status:1}

通过上面的打印,我们可以知道了,他的类型是User这个类型,值也是我们想要的值。OK。第一步完成。接下来,我们接下来通过 for 循环遍历t.NumField()t.Field(i)来拆分里面的值:

//反射type和value
t := reflect.TypeOf(user2)
v := reflect.ValueOf(user2)

//字段名
var fieldName []string

//问号?占位符
var placeholder []string

//循环判断
for i := 0; i < t.NumField(); i++ {

  //小写开头,无法反射,跳过
  if !v.Field(i).CanInterface() {
    continue
  }

  //解析tag,找出真实的sql字段名
  sqlTag := t.Field(i).Tag.Get("sql")
  if sqlTag != "" {
    //跳过自增字段
    if strings.Contains(strings.ToLower(sqlTag), "auto_increment") {
      continue
    } else {
      fieldName = append(fieldName, strings.Split(sqlTag, ",")[0])
      placeholder = append(placeholder, "?")
    }
  } else {
    fieldName = append(fieldName, t.Field(i).Name)
    placeholder = append(placeholder, "?")
  }

  //字段的值
  e.AllExec = append(e.AllExec, v.Field(i).Interface())
}

//拼接表,字段名,占位符
e.Prepare =  "insert into " + e.GetTable() + " (" + strings.Join(fieldName, ",") + ") values(" + strings.Join(placeholder, ",") + ")"

如上面所示:t.NumField()可以获取到这个结构体有多少个字段用于 for 循环,t.Field(i).Tag.Get("sql")可以获取到包含sql:"xxx"的 tag 的值,我们用来 sql 匹配和替换。t.Field(i).Name可以获取到字段的 field 名字。通过v.Field(i).Interface()可以获取到字段的 value 值。e.GetTable()来获取我们设置的标的名字。通过上面的这一段稍微有点复杂的反射和拼接,我们就完成了Db.Prepare部分:

e.Prepare =  "INSERT INTO userinfo (username, departname, status) VALUES (?, ?, ?)"

接下来,我们来获取stmt.Exec里面的值的部分,上面我们把所有的值都放入到了e.AllExec这个属性里面,之所以它用interface类型,是因为,结构体里面的值的类型是多变的,有可能是 int 型,也可能是 string 类型。

//申明stmt类型
var stmt *sql.Stmt

//第一步:Db.prepare
stmt, err = e.Db.Prepare(e.Prepare)

//第二步:执行exec,注意这是stmt.Exec
result, err := stmt.Exec(e.AllExec...)
if err != nil {
  //TODO
}

//获取自增ID
id, _ := result.LastInsertId()

上面我们用到 go 里面的一个很重要的知识点,就是:stmt.Exec(e.AllExec...)三个点的操作符,它能将我们传入的切片,全部拆开,一个的一个传入,就很巧妙的解决了可变参数

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值