手把手帶你從 0 搭建一個 Golang ORM 框架(上)!

當我深入的學習和了解了 GORM、XORM 後,我覺得它們不夠簡潔和優雅,有些笨重,有很大的學習成本。本着學習和探索的目的,於是我自己實現了一個簡單且優雅的 go 語言版本的 ORM。本文主要從基礎原理開始介紹,到一步一步步驟實現,繼而完成整個簡單且優雅的 MySQL ORM。

一、前置學習

(一)爲什麼要用 ORM

我們在使用各種語言去做需求的時候,不管是 PHP,Golang 還是 C++ 等語言,應該都接觸使用過用 ORM 去鏈接數據庫,這些 ORM 有些是項目組自己整合實現的,也有些是用的開源的組件。特別在 1 個全新的項目中,我們都會用一個 ORM 框架去連接數據庫,而不是直接用原生代碼去寫 SQL 鏈接,原因有很多,有安全考慮,有性能考慮,但是,更多的我覺得還是懶(逃)和開發效率低,因爲有時候一些 SQL 寫起來也是很複雜很累的,特別是查詢列表的時候,又是分頁,又是結果集,還需要自己 for next 去判斷和遍歷,是真的有累,開發效率非常低。如果有個 ORM,數據庫 config 一配,幾個鏈式函數一調,咔咔咔,結果就出來了。

所以 ORM 就是我們和數據庫交互的中間件,我們通過 ORM 提供的各種快捷的方法去和數據庫產生交互,繼而更加方便高效的實現功能。

一句話總結什麼是 ORM: 提供更加方便快捷的 curd 方法去和數據庫產生交互

(二)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")

改:

//方式一:
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})
}

更多具體詳細的課程和說明,可以參考我寫的這篇文章:https://blog.csdn.net/think2me/article/details/108317492

所以,總結一下,Golang 裏面原生連接 MySQL 的方法,非常簡單,就是直接寫 sql 嘛,簡單粗暴點就直接 Exec,複雜點但是效率會高一些就先 Prepare 再 Exec。總體而言,這個學習成本是非常低的,最大的問題嘛,就是麻煩和開發效率點。

所以我在想?我是不是可以基於原生代碼庫的這個優勢,自己開發 1 個 ORM 呢,第一:它能提供了各式各樣的方法來提高開發效率,第二:底層直接轉換拼接成最終的 SQL,去調用這個原生的組件,來和 MySQL 去交互。這樣豈不是一箭雙鵰,既能提高開發效率,又能保持足夠的高效和簡單。完美!

(三)ORM 框架構想

本 ORM 庫原理是簡單的 SQL 拼接。暴露各種 CURD 方法,並在底層邏輯拼接成 Prepare 和 Eexc 佔位符部分,繼而來調用 “github.com/go-sql-driver/mysql” 驅動的方法來實現和數據庫交互。

首先,先取個厲害的名字吧:smallorm,嗯,還行!

然後,整個調用過程採用鏈式的方法,這樣比較方便,比如這樣子

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

其次,暴露的 CURD 方法,使用起來要簡單,名字要清晰,無歧義,不要搞一大堆複雜的間接調用。

OK,我們梳理一下,sql 裏面常用到的一些 curd 的方法,把他們整理成 ORM 的一個個方法,並按照這個一步一步來實現,如下:

其中 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()

二、開始造

(一)連接 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()

(二)設置 / 讀取表名 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 節點上掛載的所有數據。

(三)新增 / 替換 Insert/Replace

下面就是本 ORM 第一個重頭戲和挑戰點了,如何往數據庫裏插入數據?在如何用 ORM 實現本功能之前,我們先回憶下上面講的原生的代碼是如何插入的:

我們用先 Prepare 再 Exec 這種方式,高效且安全:

stmt, err := db.Prepare("INSERT INTO userinfo (username, departname, created) VALUES (?, ?, ?)")
result2, err := stmt.Exec("zhangsan", "pro", time.Now().Format("2006-01-02"))

我們分析下它的做法:

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.TypeOf 和 reflect.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()
1. 批量插入,傳入的數據就是一個切片數組了,`[]struct` 這樣的數據類型了。
2. 我們得先用反射算出,這個數組有多少個元素。這樣好算出 VALUES 後面有幾個`()`的佔位符。
3. 搞2個for循環,外面的for循環,得出這個子元素的type和value。裏面的第二個for循環,就和單個插入的反射操作一樣了,就是算出每一個子元素有幾個字段,反射出field名字,以及對應`()`裏面有幾個?問號佔位符。
4. 2層for循環把切片裏面的每個元素的每個字段的value放入到1個統一的AllExec中。

OK,直接上代碼吧:

//批量插入
func (e *SmallormEngine) BatchInsert(data interface{}) (int64, error) {
    return e.batchInsertData(data, "insert")
}
//批量替換插入
func (e *SmallormEngine) BatchReplace(data interface{}) (int64, error) {
    return e.batchInsertData(data, "replace")
}
//批量插入
func (e *SmallormEngine) batchInsertData(batchData interface{}, insertType string) (int64, error) {
  //反射解析
  getValue := reflect.ValueOf(batchData)
  //切片大小
  l := getValue.Len()
  //字段名
  var fieldName []string
  //佔位符
  var placeholderString []string
  //循環判斷
  for i := 0; i < l; i++ {
    value := getValue.Index(i) // Value of item
    typed := value.Type()      // Type of item
    if typed.Kind() != reflect.Struct {
      panic("批量插入的子元素必須是結構體類型")
    }
    num := value.NumField()
    //子元素值
    var placeholder []string
    //循環遍歷子元素
    for j := 0; j < num; j++ {
      //小寫開頭,無法反射,跳過
      if !value.Field(j).CanInterface() {
        continue
      }
      //解析tag,找出真實的sql字段名
      sqlTag := typed.Field(j).Tag.Get("sql")
      if sqlTag != "" {
        //跳過自增字段
        if strings.Contains(strings.ToLower(sqlTag), "auto_increment") {
          continue
        } else {
          //字段名只記錄第一個的
          if i == 1 {
            fieldName = append(fieldName, strings.Split(sqlTag, ",")[0])
          }
          placeholder = append(placeholder, "?")
        }
      } else {
        //字段名只記錄第一個的
        if i == 1 {
          fieldName = append(fieldName, typed.Field(j).Name)
        }
        placeholder = append(placeholder, "?")
      }
      //字段值
      e.AllExec = append(e.AllExec, value.Field(j).Interface())
    }
    //子元素拼接成多個()括號後的值
    placeholderString = append(placeholderString, "("+strings.Join(placeholder, ",")+")")
  }
  //拼接表,字段名,佔位符
  e.Prepare = insertType + " into " + e.GetTable() + " (" + strings.Join(fieldName, ",") + ") values " + strings.Join(placeholderString, ",")
  //prepare
  var stmt *sql.Stmt
  var err error
  stmt, err = e.Db.Prepare(e.Prepare)
  if err != nil {
    return 0, e.setErrorInfo(err)
  }
  //執行exec,注意這是stmt.Exec
  result, err := stmt.Exec(e.AllExec...)
  if err != nil {
    return 0, e.setErrorInfo(err)
  }
  //獲取自增ID
  id, _ := result.LastInsertId()
  return id, nil
}
//自定義錯誤格式
func (e *SmallormEngine) setErrorInfo(err error) error {
  _, file, line, _ := runtime.Caller(1)
  return errors.New("File: " + file + ":" + strconv.Itoa(line) + ", " + err.Error())
}

開始總結一下上面這一坨關鍵的地方。首先是獲取這個切片的大小,用於第一個 for 循環。可以通過下面的 2 行代碼:

//反射解析
getValue := reflect.ValueOf(batchData)
//切片大小
l := getValue.Len()

其次,在第一個 for 循環裏面,可以通過 value:= getValue.Index(i) 來獲取這個切片裏面的第 i 個元素的值,類似於上面插入單個數據中,反射出結構體的值一樣:v:= reflect.ValueOf(data)

然後,通過 typed:= value.Type() 來獲取這第 i 個元素的類型。類似於上面插入單個數據中,反射出結構體的類型一樣:t := reflect.TypeOf(data) 。這個東西被反射出來,主要是爲了獲取 tag 標籤用。

第二個 for 循環裏面的反射邏輯,基本上是和單個插入是一樣的了,唯一需要注意的就是,fieldName 的值,因爲我們只需要 1 個,所以我們用 i==1 判斷了一下。加入單次即可。

再一個就是 placeholderString 這個變量,因爲我們爲了實現多個 () 的效果,所以就又搞了 1 個切片。

這樣,批量插入,批量替換插入的邏輯就完成了。

爲了使我們的 ORM 足夠的優雅和簡單,我們可以把單個插入和批量插入,搞成 1 個方法暴露出去。那怎麼識別出傳入的數據是單個結構體,還是切片結構體呢?還是得用反射:

reflect.ValueOf(data).Kind()

它能給出我們答案。如果我們傳的是單個結構體,那麼它的值就是 Struct,如果是切片數組,那麼值就是 Slice 和 Array。這樣我們就好辦了,我們只需要稍做判斷即可:

//插入
func (e *SmallormEngine) Insert(data interface{}) (int64, error) {
  //判斷是批量還是單個插入
  getValue := reflect.ValueOf(data).Kind()
  if getValue == reflect.Struct {
    return e.insertData(data, "insert")
  } else if getValue == reflect.Slice || getValue == reflect.Array {
    return e.batchInsertData(data, "insert")
  } else {
    return 0, errors.New("插入的數據格式不正確,單個插入格式爲: struct,批量插入格式爲: []struct")
  }
}
//替換插入
func (e *SmallormEngine) Replace(data interface{}) (int64, error) {
  //判斷是批量還是單個插入
  getValue := reflect.ValueOf(data).Kind()
  if getValue == reflect.Struct {
    return e.insertData(data, "replace")
  } else if getValue == reflect.Slice || getValue == reflect.Array {
    return e.batchInsertData(data, "replace")
  } else {
    return 0, errors.New("插入的數據格式不正確,單個插入格式爲: struct,批量插入格式爲: []struct")
  }
}

OK,完成。

(四)條件 Where

下面,我們開始實現 Where 方法的邏輯,這個 where 主要是爲了替換 sql 語句中 where 後面這部分的邏輯,sql 語句中 where 用的還是非常多的,比如原生 sql:

select * from userinfo where status = 1
delete from userinfo where status = 1 or departname != "aa"
update userinfo set departname = "bb" where status = 1 and departname = "aa"

所以,把 where 後面的數據單獨拆出來,搞成 1 個 Where 方法是很有必要的。大部分的 ORM 也是這樣做的。

通過觀察上面 3 句 sql,我們可以得出基本的 where 的結構,要麼只有 1 個條件,這個條件的比較復符是豐富的,比如:=, !=, like,<,> 等等。要麼是多個條件,用 and 或者 or 隔開,表示且和或的關係。

通過最上面的原生代碼,我們是可以發現的,where 部分也是一樣的,先用 Prepare 生成問號佔位符,再和 Exce 替換值的方式來操作。

stmt, err := db.Prepare("delete from userinfo where uid=?")
result3, err := stmt.Exec("10795")
stmt, err := db.Prepare("update userinfo set username=? where uid=?")
result, err := stmt.Exec("lisi", 2)

所以,where 部分的拆分,其實也是分 2 部來走。和插入的 2 步走的邏輯是一樣的。大致的調用過程如下:

type User struct {
    Username   string `sql:"username"`
    Departname string `sql:"departname"`
    Status     int64  `sql:"status"`
}
user2 := User{
    Username:   "EE",
    Departname: "22", 
    Status:     1,
}
result1, err1 := e.Table("userinfo").Where(user2).Delete()
result2, err2 := e.Table("userinfo").Where(user2).Select()

我們本次實現的是 Where 部分, where 是中間層,它不會具體去執行結果的,它做的僅僅是將數據拆分出來,用 2 個新的子元素 WhereParam 和 WhereExec 來暫存數據,給最後的 CURD 操作方法來使用。

我們開始寫代碼,和 Insert 方法的反射邏輯幾乎一樣。

func (e *SmallormEngine) Where(data interface{}) *SmallormEngine {
    //反射type和value
    t := reflect.TypeOf(data)
    v := reflect.ValueOf(data)
    //字段名
    var fieldNameArray []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 != "" {
        fieldNameArray = append(fieldNameArray, strings.Split(sqlTag, ",")[0]+"=?")
      } else {
        fieldNameArray = append(fieldNameArray, t.Field(i).Name+"=?")
      }
      //反射出Exec的值。
      e.WhereExec = append(e.WhereExec, v.Field(i).Interface())
    }
    //拼接
    e.WhereParam += strings.Join(fieldNameArray, " and ")
    return e 
}

這樣,我們就可以調用 Where() 反覆,轉換成生成了 2 個暫存變量。我們打印下這 2 個值看看:

WhereParam = "username=? and departname=? and Status=?"
WhereExec = []interface{"EE", "22", 1}

由於 Where() 是中間態的方法,是可以提供多次調用的,每次調用都是 and 的關係。比如這樣:

e.Table("userinfo").Where(user2).Where(user3).XXX

所以,我們得改造一下 e.WhereParam 得讓他拼接上一次生成的生成的數據。

先判斷理一下,是否爲空,如果不爲空,則說明這是第二次調用了,我們用 “and (” 來做隔離。

//多次調用判斷
if e.WhereParam != "" {
  e.WhereParam += " and ("
} else {
  e.WhereParam += "("
}

// 結束拼接的時候,加上結束括號 “)”。

e.WhereParam += strings.Join(fieldNameArray, " and ") + ") "

這樣,就達到了我們的目的了。我們看下多次調用後的打印結果:

WhereParam = "(username=? and departname=? and status=?) and (username=? and departname=? and status=?)"
WhereExec = []interface{"EE", "22", 1, "FF", "33", 0}

需要注意的是,這樣方式的調用,我們爲了簡化調用的結構更清晰更簡單,每個條件之間默認都是 = 的關係。如果有其他的關係判斷,可以用下面的方式。

上面的 Where 方法的參數,其實是我們和 Insert 一樣,傳入的是 1 個結構體,但是有時候,如果傳入 1 個結構體,得先定義再實例化,也很麻煩。而且有時候,我們僅僅只需要查詢 1 個字段,如果再去定義 1 個結構體再實例化就太麻煩了。所以,我們 ORM 還得提供快捷的方法調用,比如:

Where("uid", "=", 1234)
Where("uid", ">=", 1234)
Where("uid", "in", []int{2, 3, 4})

這樣,我們也可以用其他非 and 的判斷表達式,比如:!=,like,not in,in 等。

OK,那我們開始寫一下,這種方式怎麼判斷呢?對比傳入結構體的方式更簡單:方法有 3 個參數,第一個是需要查詢的字段,第 2 個是比較符,第三個是查詢的值

func (e *SmallormEngine) Where(fieldName string, opt string, fieldValue interface{}) *SmallormEngine {
    //區分是操作符in的情況
    data2 := strings.Trim(strings.ToLower(fieldName.(string)), " ")
    if data2 == "in" || data2 == "not in" {
      //判斷傳入的是切片
      reType := reflect.TypeOf(fieldValue).Kind()
      if reType != reflect.Slice && reType != reflect.Array {
        panic("in/not in 操作傳入的數據必須是切片或者數組")
      }
      //反射值
      v := reflect.ValueOf(fieldValue)
      //數組/切片長度
      dataNum := v.Len()
      //佔位符
      ps := make([]string, dataNum)
      for i := 0; i < dataNum; i++ {
        ps[i] = "?"
        e.WhereExec = append(e.WhereExec, v.Index(i).Interface())
      }
      //拼接
      e.WhereParam += fieldName.(string) + " " + fieldValue + " (" + strings.Join(ps, ",") + ")) "
    } else {
      e.WhereParam += fieldName.(string) + " " + fieldValue.(string) + " ?) "
      e.WhereExec = append(e.WhereExec, fieldValue)
    }
    return e 
}

上面代碼唯一需要注意的就是第二參數如果是 in 操作符的話,後面第三個參數要是切片類型,就得反射出來,用 in (?,?,?) 這樣的方式。

所以,我們把這 2 種方式,拼接一下,融合成 1 種方式,智能的去判斷即可,下面是完整的代碼:

//傳入and條件
func (e *SmallormEngine) Where(data ...interface{}) *SmallormEngine {
  //判斷是結構體還是多個字符串
  var dataType int
  if len(data) == 1 {
    dataType = 1
  } else if len(data) == 2 {
    dataType = 2
  } else if len(data) == 3 {
    dataType = 3
  } else {
    panic("參數個數錯誤")
  }
  //多次調用判斷
  if e.WhereParam != "" {
    e.WhereParam += " and ("
  } else {
    e.WhereParam += "("
  }
  //如果是結構體
  if dataType == 1 {
    t := reflect.TypeOf(data[0])
    v := reflect.ValueOf(data[0])
    //字段名
    var fieldNameArray []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 != "" {
        fieldNameArray = append(fieldNameArray, strings.Split(sqlTag, ",")[0]+"=?")
      } else {
        fieldNameArray = append(fieldNameArray, t.Field(i).Name+"=?")
      }
      e.WhereExec = append(e.WhereExec, v.Field(i).Interface())
    }
    //拼接
    e.WhereParam += strings.Join(fieldNameArray, " and ") + ") "
  } else if dataType == 2 {
    //直接=的情況
    e.WhereParam += data[0].(string) + "=?) "
    e.WhereExec = append(e.WhereExec, data[1])
  } else if dataType == 3 {
    //3個參數的情況
    //區分是操作符in的情況
    data2 := strings.Trim(strings.ToLower(data[1].(string)), " ")
    if data2 == "in" || data2 == "not in" {
      //判斷傳入的是切片
      reType := reflect.TypeOf(data[2]).Kind()
      if reType != reflect.Slice && reType != reflect.Array {
        panic("in/not in 操作傳入的數據必須是切片或者數組")
      }
      //反射值
      v := reflect.ValueOf(data[2])
      //數組/切片長度
      dataNum := v.Len()
      //佔位符
      ps := make([]string, dataNum)
      for i := 0; i < dataNum; i++ {
        ps[i] = "?"
        e.WhereExec = append(e.WhereExec, v.Index(i).Interface())
      }
      //拼接
      e.WhereParam += data[0].(string) + " " + data2 + " (" + strings.Join(ps, ",") + ")) "
    } else {
      e.WhereParam += data[0].(string) + " " + data[1].(string) + " ?) "
      e.WhereExec = append(e.WhereExec, data[2])
    }
  }
  return e
}

上面的寫法,參數改成 1 個了,但是中用到了..interface{} 這個寫法,它表示傳入的參數是一個可變參數類型,可以是 1 個,2 個或者 3 個的情況。用這種方式,方法裏獲取到的就是 1 個切片類型了。我們得用 len() 函數,來判斷到底是切片裏面有幾個元素,然後依次對應上我們的分支邏輯。值得注意的是,當我們傳入的是結構體的時候,也是需要用 data[0] 的方式來獲取。

這樣,我們就可以用 Where 方法來快捷的愉快的調用了:

// where uid = 123
e.Table("userinfo").Where("uid", 123) 
// where uid not in (2,3,4)
e.Table("userinfo").Where("uid", "not in", []int{2, 3, 4})
// where uid in (2,3,4)
e.Table("userinfo").Where("uid", "in", []int{2, 3, 4})
// where uid like '%2%'
e.Table("userinfo").Where("uid", "like", "%2%")
// where uid >= 123
e.Table("userinfo").Where("uid", ">=", 123)
// where (uid >= 123) and (name = 'vv')
e.Table("userinfo").Where("uid", ">=", 123).Where("name", "vv")

(五)條件 OrWhere

上面的 Where 方法生成的數據塊之間都是 and 的關係,其實我們有一些 sql 是需要 or 的關係的,比如:

where (uid >= 123) or (name = 'vv')
where (uid = 123 and name = 'vv') or (uid = 456 and name = 'bb')

那麼這種情況,其實也是需要考慮進去的,寫起來也很簡單,只需要新加一個 OrWhereParam 參數,替換上面 Where 方法裏面的 whereParam 即可,WhereExec 不需要變化。然後把拼接關係改成 or,其他代碼一摸一樣:

func (e *SmallormEngine) OrWhere(data ...interface{}) *SmallormEngine {
  ...
  //判斷使用順序
  if e.WhereParam == "" {
    panic("WhereOr必須在Where後面調用")
  }
  //WhereOr條件
  e.OrWhereParam += " or ("
  ...
  return e
}

需要注意的是,OrWhere 方法是必須得先調用 Where 後再調用的。因爲一般用到了 or,前面肯定也有前置的 where 判斷的

也是一樣,有三種調用方式:

OrWhere("uid", 1234) //默認是等於
OrWhere("uid", ">=", 1234)
OrWhere(uidStruct) //傳入1個結構體,結構體之間用and連接

看下使用效果:

// where (uid = 123) or (name = "vv")
e.Table("userinfo").Where("uid", 123).OrWhere("name", "vv")
// where (uid not in (2,3,4)) or (uid not in (5,6,7))
e.Table("userinfo").Where("uid", "not in", []int{2, 3, 4}).OrWhere("uid", "not in", []int{5, 6, 7})
// where (uid like '%2') or (uid like '%5%')
e.Table("userinfo").Where("uid", "like", "%2").OrWhere("uid", "like", "%5%")
// where (uid >= 123) or (uid <= 454)
e.Table("userinfo").Where("uid", ">=", 123).OrWhere("uid", "<=", 454)
// where (username = "EE" and departname = "22" and status = 1) or (name = 'vv') or (status = 1)
type User struct {
    Username   string `sql:"username"`
    Departname string `sql:"departname"`
    Status     int64  `sql:"status"`
}
user2 := User{
    Username:   "EE",
    Departname: "22", 
    Status:     1,
}
e.Table("userinfo").Where(user2).OrWhere("name", "vv").OrWhere("status", 1)

爲了使這個方法更簡單的被使用,不搞複雜,這種方式的 or 關係,實質上是針對於多次調用 where 之間的,是不支持同一個 where 裏面的數據是 or 關係的。那如果需要的話,可以這樣調用:

// where (username = "EE") or (departname = "22") or (status = 1)
e.Table("userinfo").Where(username, "EE").OrWhere("departname", "22").OrWhere("status", 1)

(六)刪除 Delete

刪除也是 sql 邏輯中的最常見的操作了,當我們完成了前面 Where 和 OrWhere 的數據邏輯綁定後,其實寫 Delete 方法是最簡單的了,爲什麼呢?因爲 Delete 方法是 CURD 的最後一步,是直接和數據庫進行操交互的了,是不需要我們再去反射各種數據進行綁定了。我們僅僅需要把 Where 裏面綁定的 2 個值,往 Prepare 和 Exec 裏面套即可。

我們看下具體是怎麼寫:

//刪除
func (e *SmallormEngine) Delete() (int64, error) {
  //拼接delete sql
  e.Prepare = "delete from " + e.GetTable()
  //如果where不爲空
  if e.WhereParam != "" || e.OrWhereParam != "" {
    e.Prepare += " where " + e.WhereParam + e.OrWhereParam
  }
  //limit不爲空
  if e.LimitParam != "" {
    e.Prepare += "limit " + e.LimitParam
  }
  //第一步:Prepare
  var stmt *sql.Stmt
  var err error
  stmt, err = e.Db.Prepare(e.Prepare)
  if err != nil {
    return 0, err
  }
  e.AllExec = e.WhereExec
  //第二步:執行exec,注意這是stmt.Exec
  result, err := stmt.Exec(e.AllExec...)
  if err != nil {
    return 0, e.setErrorInfo(err)
  }
  //影響的行數
  rowsAffected, err := result.RowsAffected()
  if err != nil {
    return 0, e.setErrorInfo(err)
  }
  return rowsAffected, nil
}

是不是很熟悉?和 Insert 方法的邏輯幾乎是一樣的,只是 e.Prepare 中的 sql 語句不一樣。

這樣看下調用方式和結果:

// delete from userinfo where (uid >= 123) or (uid <= 454)
rowsAffected, err := e.Table("userinfo").Where("uid", ">=", 123).OrWhere("uid", "<=", 454).Delete()

(七)修改 Update

修改數據,也是 CURD 的最後一步,但是它和 Delete 不同的是,他是有 2 個數據需要綁定的,1 個通過 Where 方法綁定的 where 數據,還有 1 個,就是需要去更新的數據,這個我們還沒做。

update userinfo set status = 1 where (uid >= 123) or (uid <= 454)

其中 status=1 這部分的數據,我們也是需要提煉出來搞成 1 個對外暴露的方法。所以,最終的調用方式會是這樣的:

e.Table("userinfo").Where("uid", 123).Update("status", 1)
e.Table("userinfo").Where("uid", 123).Update(user2)

和 Where 的可變參數類似,我們也是提供了 2 種參數傳遞方式,既可以傳入一個結構體變量,也可以只傳入單個更新的變量,用起來會更方便更靈活。

仔細一看,Update 中獲取數據的方式,和 Insert 方法插入單個數據的方式不能說特別像吧,可以說簡直一模一樣啊。

直接上代碼吧:

//更新
func (e *SmallormEngine) Update(data ...interface{}) (int64, error) {
  //判斷是結構體還是多個字符串
  var dataType int
  if len(data) == 1 {
    dataType = 1
  } else if len(data) == 2 {
    dataType = 2
  } else {
    return 0, errors.New("參數個數錯誤")
  }
  //如果是結構體
  if dataType == 1 {
    t := reflect.TypeOf(data[0])
    v := reflect.ValueOf(data[0])
    var fieldNameArray []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 != "" {
        fieldNameArray = append(fieldNameArray, strings.Split(sqlTag, ",")[0]+"=?")
      } else {
        fieldNameArray = append(fieldNameArray, t.Field(i).Name+"=?")
      }
      e.UpdateExec = append(e.UpdateExec, v.Field(i).Interface())
    }
    e.UpdateParam += strings.Join(fieldNameArray, ",")
  } else if dataType == 2 {
    //直接=的情況
    e.UpdateParam += data[0].(string) + "=?"
    e.UpdateExec = append(e.UpdateExec, data[1])
  }
  //拼接sql
  e.Prepare = "update " + e.GetTable() + " set " + e.UpdateParam
  //如果where不爲空
  if e.WhereParam != "" || e.OrWhereParam != "" {
    e.Prepare += " where " + e.WhereParam + e.OrWhereParam
  }
  //limit不爲空
  if e.LimitParam != "" {
    e.Prepare += "limit " + e.LimitParam
  }
  //prepare
  var stmt *sql.Stmt
  var err error
  stmt, err = e.Db.Prepare(e.Prepare)
  if err != nil {
    return 0, e.setErrorInfo(err)
  }
  //合併UpdateExec和WhereExec
  if e.WhereExec != nil {
    e.AllExec = append(e.UpdateExec, e.WhereExec...)
  }
  //執行exec,注意這是stmt.Exec
  result, err := stmt.Exec(e.AllExec...)
  if err != nil {
    return 0, e.setErrorInfo(err)
  }
  //影響的行數
  id, _ := result.RowsAffected()
  return id, nil
}

其中有一個地方,需要注意的是:合併 UpdateExec 和 WhereExec 這一步。需要在 e.WhereExec 後面加...,這樣的目的就是把切片全部展開成 1 個 1 個的可變參數,追加到 UpdateExec 切片的後面。如果不加是會報語法報錯的。

cannot use []interface{} literal (type []interface{}) as type interface{} in append

golang 裏面,貌似沒有一個函數可以把 2 個切片直接合並的方法,類似於 PHP 中的 array_merge,也可能是我還沒找到。

$a1=array("red","green");
$a2=array("blue","yellow");
print_r(array_merge($a1,$a2));   // Array ( [0] => red [1] => green [2] => blue [3] => yellow )

** 作者簡介**

楊義

騰訊高級工程師

騰訊高級工程師,主要負責 IEG 遊戲活動運營及高可用平臺的建設,對雲服務、k8s 以及高性能服務上也有很深的瞭解。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BkQkTWQBQBZPSMuZYnRDjA