github 位址github.com
作為靜态語言,golang 稍顯笨拙,還好 go 的标準包(反射)包彌補了這點不足,它提供了一系列強大的 API,能夠根據執行過程中對象的類型來改變程式控制流。本文将通過設計并實作一個簡易的 mysql orm 來學習它,要求讀者了解
reflect
基本知識,并且跟我一樣至少已經接觸 golang 兩到三個月。
mysql
orm 這個概念相信同學們都非常熟悉,尤其是寫過
rails
的同學,對
active_record
的強大肯定深有體會(得益于的
method_missing
和
define_method
方法,少寫了海量代碼),是以對 orm 我就不過多介紹了。本文要實作的 orm 隻提供基本的
CRUD
(增删改查)和
transaction
(事務)功能,核心代碼控制在 300 行左右。 如果想手把手照着寫,需要先做一些準備工作。
準備工作
在本地 mysql 裡
create database orm_db
,然後再
create
一張
user
表,結構如下:
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
`age` smallint(10) unsigned NOT NULL DEFAULT 0 COMMENT '年齡',
`first_name` varchar(45) NOT NULL DEFAULT '' COMMENT '姓',
`last_name` varchar(45) NOT NULL DEFAULT '' COMMENT '名',
`email` varchar(45) NOT NULL DEFAULT '' COMMENT '郵箱位址',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`id`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='使用者表';
同時,golang 代碼裡定義一個與之對應的
struct
:
type User struct {
ID int64 `json:"id"` // 自增主鍵
Age int64 `json:"age"` // 年齡
FirstName string `json:"first_name"` // 姓
LastName string `json:"last_name"` // 名
Email string `json:"email"` // 郵箱位址
CreatedAt time.Time `json:"created_at"` // 建立時間
UpdatedAt time.Time `json:"updated_at"` // 更新時間
}
與 mysql 互動需要用到一個 go 标準包和一個驅動,代碼
import
如下:
package orm
import (
"database/sql"
//register driver
_ "github.com/go-sql-driver/mysql"
)
首先按照
database
次元建立連接配接,寫一個可以傳回 mysql 連接配接的函數:
//Connect db by dsn e.g. "user:[email protected](127.0.0.1:3306)/dbname"
func Connect(dsn string) (*sql.DB, error) {
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
//設定連接配接池
conn.SetMaxOpenConns(100)
conn.SetMaxIdleConns(10)
conn.SetConnMaxLifetime(10 * time.Minute)
return conn, conn.Ping()
}
設計一個
struct
用于實作 orm(go 不是面向對象的語言,沒有
class
):
//Query will build a sql
type Query struct {
db *sql.DB
table string
}
最後将通過
Query
拼接出 sql 語句與 mysql 互動,是以寫一個綁定函數:
//Table bind db and table
func Table(db *sql.DB, tableName string) func() *Query {
return func() *Query {
return &Query{
db: db,
table: tableName,
}
}
}
傳回值是一個閉包函數,這樣使用時直接調用這個閉包函數就可以擷取一個綁定好的 database 和 table 的
Query
,比如現在有資料庫
orm_db
和
user
表:
//全局變量ormDB和users
ormDB, _ := Connect("user:[email protected](127.0.0.1:3306)/orm_db")
users := Table(ormDB, "user")
//調用
users().Insert(...)
準備工作到此完成,下面進入正題。
Insert 方法
首先分析一下标準
insert
語句:
insert into user (first_name, last_name) values ('Tom', 'Cat'), ('Tom', 'Cruise')
把 sql 語句中變化的部分抽象出來,其實就是
key
(字段)和
value
(值),那麼 orm 裡的
Insert
方法原型就有了,如下,參數是 struct 或者 map,因為它們都能提供鍵值對:
//Insert in can be *User, []*User, map[string]interface{}
func (q *Query) Insert(in interface{}) (int64, error) {
var keys, values []string
v := reflect.ValueOf(in)
//剝離指針
for v.Kind() == reflect.Ptr {
v = v.Elem()
}
switch v.Kind() {
case reflect.Struct:
keys, values = sKV(v)
case reflect.Map:
keys, values = mKV(v)
case reflect.Slice:
for i := 0; i < v.Len(); i++ {
//Kind是切片時,可以用Index()方法周遊
sv := v.Index(i)
for sv.Kind() == reflect.Ptr || sv.Kind() == reflect.Interface {
sv = sv.Elem()
}
//切片元素不是struct或者指針,報錯
if sv.Kind() != reflect.Struct {
return 0, errors.New("method Insert error: in slice is not structs")
}
//keys隻儲存一次就行,因為後面的都一樣了
if len(keys) == 0 {
keys, values = sKV(sv)
continue
}
_, val := sKV(sv)
values = append(values, val...)
}
default:
return 0, errors.New("method Insert error: type error")
}
//todo
//...
}
參數
in
可以是一個
User
(前文定義好的結構體)執行個體的指針(或者指針集合),也可以是一個 map,這兩個結構都可以提供鍵值對,我們通過反射來分析它的
類型
,然後根據類型執行相應的邏輯。 reflect 包裡的有兩個重要結構
Type
和
Value
,Type 是一個接口,定義了所有類型相關的 api,reflect 裡的
*rtype
實作了這個接口,通過 reflect.TypeOf 函數可以擷取任何傳入值的
*rtype
。Value 是一個 struct,通過 reflect.ValueOf 函數擷取,它在
*rtype
的基礎上又封裝了傳入值的 unsafe.Pointer 類型的
位址
以及這個值的
中繼資料
。 在 Type 和 Value 之上還有一個
Kind
,它代表傳入值的
原始類型
,比如:
type myInt int
var i myInt
t := reflect.TypeOf(i)
k := t.Kind()
t 是 myInt,而 k 是 int,Type 和 Kind 是不同的,這一點要注意區分。 如果 Type 的 Kind 是指針、接口、切片、map 等複合類型,可以調用 Elem()方法擷取基類型。 如果 Value 的 Kind 是指針、接口,可以調用 Elem()方法擷取實際值。 Value 上還定義了一個
Interface()
方法,它是 ValueOf()方法的反操作。 有了上面這些反射方法,我們可以封裝一個
sKV()
函數,它專門處理 struct 類型的值,擷取 key(取 json tag)和 value:
func sKV(v reflect.Value) ([]string, []string) {
var keys, values []string
t := v.Type()
for n := 0; n < t.NumField(); n++ {
tf := t.Field(n)
vf := v.Field(n)
//忽略非導出字段
if tf.Anonymous {
continue
}
//忽略無效、零值字段
if !vf.IsValid() || reflect.DeepEqual(vf.Interface(), reflect.Zero(vf.Type()).Interface()) {
continue
}
for vf.Type().Kind() == reflect.Ptr {
vf = vf.Elem()
}
//有時候根據需求會組合struct,這裡處理下,支援擷取嵌套的struct tag和value
//如果字段值是time類型之外的struct,遞歸擷取keys和values
if vf.Kind() == reflect.Struct && tf.Type.Name() != "Time" {
cKeys, cValues := sKV(vf)
keys = append(keys, cKeys...)
values = append(values, cValues...)
continue
}
//根據字段的json tag擷取key,忽略無tag字段
key := strings.Split(tf.Tag.Get("json"), ",")[0]
if key == "" {
continue
}
value := format(vf)
if value != "" {
keys = append(keys, key)
values = append(values, value)
}
}
return keys, values
}
sKV()
函數裡需要格式化字元串,那麼定義一個
format()
函數。
time.Time
類型怎麼轉化成各種資料庫的時間類型我有點拿不準,是以需要對比時間類型的值時,一律用 unxi 時間戳,感覺比較省事不會出錯:
func format(v reflect.Value) string {
//斷言出time類型直接轉unix時間戳
if t, ok := v.Interface().(time.Time); ok {
return fmt.Sprintf("FROM_UNIXTIME(%d)", t.Unix())
}
switch v.Kind() {
case reflect.String:
return fmt.Sprintf(`'%s'`, v.Interface())
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
return fmt.Sprintf(`%d`, v.Interface())
case reflect.Float32, reflect.Float64:
return fmt.Sprintf(`%f`, v.Interface())
//如果是切片類型,周遊元素,遞歸格式化成"(, , , )"形式
case reflect.Slice:
var values []string
for i := 0; i < v.Len(); i++ {
values = append(values, format(v.Index(i)))
}
return fmt.Sprintf(`(%s)`, strings.Join(values, ","))
//接口類型剝一層遞歸
case reflect.Interface:
return format(v.Elem())
}
return ""
}
map 類型處理起來和 struct 不同,是以我們再定義一個
mKV()
函數,目的和 sKV()一樣,都是擷取鍵值對:
func mKV(v reflect.Value) ([]string, []string) {
var keys, values []string
//擷取map的key組成的切片
mapKeys := v.MapKeys()
for _, key := range mapKeys {
value := format(v.MapIndex(key))
if value != "" {
values = append(values, value)
keys = append(keys, key.Interface().(string))
}
}
return keys, values
}
利用 sKV()和 mKV()函數取到鍵值對後,就得到了 insert 語句中的變化部分,補全 Insert()方法的
todo
部分:
//Insert in can be User, *User, []User, []*User, map[string]interface{}
func (q *Query) Insert(in interface{}) (int64, error) {
//already done
kl := len(keys)
vl := len(values)
if kl == 0 || vl == 0 {
return 0, errors.New("method Insert error: no data")
}
var insertValue string
//插入多條記錄時需要用","拼接一下values
if kl < vl {
var tmpValues []string
for kl <= vl {
if kl%(len(keys)) == 0 {
tmpValues = append(tmpValues, fmt.Sprintf("(%s)", strings.Join(values[kl-len(keys):kl], ",")))
}
kl++
}
insertValue = strings.Join(tmpValues, ",")
} else {
insertValue = fmt.Sprintf("(%s)", strings.Join(values, ","))
}
query := fmt.Sprintf(`insert into %s (%s) values %s`, q.table, strings.Join(keys, ","), insertValue)
log.Printf("insert sql: %s", query)
st, err := q.DB.Prepare(query)
if err != nil {
return 0, err
}
result, err := st.Exec()
if err != nil {
return 0, err
}
return result.LastInsertId()
}
原理很簡單,利用反射分析參數,取鍵值對,然後拼接 sql 語句,再通過 mysql 驅動入庫。 調用示例:
user1 := &User{
Age: 30,
FirstName: "Tom",
LastName: "Cat",
}
user2 := User{
Age: 30,
FirstName: "Tom",
LastName: "Curise",
}
user3 := User{
Age: 30,
FirstName: "Tom",
LastName: "Hanks",
}
user4 := map[string]interface{}{
"age": 30,
"first_name": "Tom",
"last_name": "Zzy",
}
users().Insert([]interface{}{user1, user2})
users().Insert(user3)
users().Insert(user4)
增删改查的
增
部分到此完成,因為查詢語句非常複雜多變,是以有了資料後,先進行
查
。
西二旗搬磚仔:手把手教你學之golang反射(2)zhuanlan.zhihu.com