orm框架做了什麼?
對原生的db操作進行封裝,提供簡單一致的接口,將db中的關係型數據轉換成程序中的對象,
實現一個orm框架需要做什麼?
最簡單的思路:
func bizObj get(params)
func result set(bizObj)
以get方法爲例,入參通常是一些查詢參數,而出參是業務對象,這裏需要考慮的問題:
- 怎麼讓使用者提供bizObj與數據庫表的映射關係?
- 怎麼讓使用者提供get方法的查詢語句,並生成sql?
- 多表查詢時,結果如何填充到bizObj中,如何避免n+1問題?
- 怎麼管理db連接/支持事務
set方法其實就是get方法的逆過程。
接下來以上述問題爲出發點,分析gorm和mybatis兩個orm框架是怎麼做的,並比較其優缺點
gorm框架的做法
數據結構
對db連接資源的封裝
// DB contains information for current db connection
type DB struct {
Error error
RowsAffected int64
callbacks *Callback
db sqlCommon //封裝了底層db連接池,go sql原生提供
logger logger
...
}
對單次操作上下文的封裝
// Scope contain current operation's information when you perform any operation on the database
type Scope struct {
Search *search
Value interface{} //最終的返回值,對應上面的bizObj
SQL string
SQLVars []interface{}
db *DB
fields *[]*Field
...
}
對查詢條件的封裝
type search struct {
whereConditions []map[string]interface{}
orConditions []map[string]interface{}
omits []string
orders []interface{}
preload []searchPreload
...
}
核心邏輯都封裝在callback中
type Callback struct {
creates []*func(scope *Scope)
updates []*func(scope *Scope)
deletes []*func(scope *Scope)
queries []*func(scope *Scope)
}
可以將gorm看作一個數據加工廠,原始數據(從DB.db這個數據庫連接中獲取)在一條流水線上(scope),經過一系列工序(db.Callback),最終得到成品(scope.Value)。一個scope對象記錄着一次加工的整個執行上下文
具體實現
- 通過tag的方式,將業務結構與數據庫表關聯起來,提供表達外鍵關聯的tag:FOREIGNKEY/ASSOCIATION_FOREIGNKEY,在程序執行過程中,以reflect.Type(對應一個業務結構)作爲key,將ModelStruct(對業務結構各個字段的結構化描述)做爲value緩存起來,避免每次查詢都要通過反射建立關聯關係。構建ModelStruct的思路也很直接,通過反射遍歷業務結構組成的‘關聯關係圖’,並通過外鍵關聯的tag解析出各結構之間一對多或者多對多的關係(其他tag也在此時解析,如omit等)
- 提供鏈式調用的構造方式來構造查詢條件,查詢條件全部保存在scope.search中。發生特定的查詢命令(如find/first/scan/pluck等)後,將scope.search中保存的信息拼接成sql。這樣做的優點是很靈活的的構造查詢條件,缺點則是將查詢條件的構造“拆成了多步”,在步與步之間search對象的值可能發生任何的變化,這就導致gorm中存在大量的clone操作。
- scope.fields中維護了scope.Value中所有字段的反射引用,以及各個字段與數據庫列的映射關係
// 將db結果集中的一行數據賦值到業務結構中
func (scope *Scope) scan(rows *sql.Rows, columns []string, fields []*Field) {
var (
//臨時存放db中數據
values = make([]interface{}, len(columns))
//將values的每一個interface初始化爲明確的go類型,略過
...
scope.Err(rows.Scan(values...))
//這裏實際上是對scope.Value的每一個字段賦值
for index, field := range resetFields {
if v := reflect.ValueOf(values[index]).Elem().Elem(); v.IsValid() {
field.Field.Set(v)
}
}
}
// 循環賦值,數據庫表的多行記錄映射到業務結構數據中
for rows.Next() {
scope.scan(rows, columns, scope.New(elem.Addr().Interface()).Fields())
if isSlice {
if isPtr {
results.Set(reflect.Append(results, elem.Addr()))
} else {
results.Set(reflect.Append(results, elem))
}
}
}
- 對於關聯查詢,也就是業務結構一對多的場景下,gorm提供了preload功能,有幾個preload就會發起幾次查詢,不存在n+1問題。另外gorm還有一個join選項,但這個選項並不是用在結果集需要映射到多個對象的場景下,gorm最終處理結果集時其實只關注“select”出來的字段,至於select中有幾個join,都不影響對結果集的處理
func init() {
//核心查詢功能
DefaultCallback.Query().Register("gorm:query", queryCallback)
//在queryCallback之後執行,主要是爲了處理一對多/多對多這種關聯關係
DefaultCallback.Query().Register("gorm:preload", preloadCallback)
}
// 一個user對應一個role
// 先將滿足條件的所有user查出來,再把所有user的外鍵roleId抽出來,再去in這些roleId查詢所有role
// Where("user_id in (?)", userIds).Preload("role")對應的邏輯:
func (scope *Scope) handleBelongsToPreload(field *Field, conditions []interface{}) {
// 前面已經查出來了user數據
// 這裏可以理解爲“getRolesByIds”
preloadDB, preloadConditions := scope.generatePreloadDBWithConditions(conditions)
// roleIds
primaryKeys := scope.getColumnAsArray(relation.ForeignFieldNames, scope.Value)
// find relations
results := makeSlice(field.Struct.Type)
//複用了queryCallback
scope.Err(preloadDB.Where(fmt.Sprintf("%v IN (%v)", toQueryCondition(scope, relation.AssociationForeignDBNames), toQueryMarks(primaryKeys)), toQueryValues(primaryKeys)...).Find(results, preloadConditions...).Error)
// assign find results
var (
resultsValue = indirect(reflect.ValueOf(results))
//user(list)對象
indirectScopeValue = scope.IndirectValue()
)
//對於多個user,多個role,兩層for循環將role設置到對應的user中
for i := 0; i < resultsValue.Len(); i++ {
result := resultsValue.Index(i)
if indirectScopeValue.Kind() == reflect.Slice {
value := getValueFromFields(result, relation.AssociationForeignFieldNames)
for j := 0; j < indirectScopeValue.Len(); j++ {
object := indirect(indirectScopeValue.Index(j))
if equalAsString(getValueFromFields(object, relation.ForeignFieldNames), value) {
object.FieldByName(field.Name).Set(result)
}
}
} else {
scope.Err(field.Set(result))
}
}
}
- 管理事務和連接,思路很簡單,gorm.DB中的db域維護了db連接,這個連接既有可能是sql.DB,也可能是sql.Tx,只要實現了Exec/Query/Prepare等方法,這也是利用了go的隱式繼承特性。另外sql.DB本身就實現了連接池的功能,gorm只需在queryCallback中調用sql.Rows的close方法即可
// gorm.DB.db的類型,用在普通查詢時
type sqlCommon interface {
Exec(query string, args ...interface{}) (sql.Result, error)
Prepare(query string) (*sql.Stmt, error)
Query(query string, args ...interface{}) (*sql.Rows, error)
QueryRow(query string, args ...interface{}) *sql.Row
}
// 開始事務時調用
type sqlDb interface {
Begin() (*sql.Tx, error)
}
// 事務中調用
type sqlTx interface {
Commit() error
Rollback() error
}
func (s *DB) Begin() *DB {
if db, ok := c.db.(sqlDb); ok {
tx, err := db.Begin()
c.db = interface{}(tx).(sqlCommon)
}
return c
}
set的邏輯基本是get的逆過程,很多邏輯都類似,這裏不再贅述
mybatis的思路
- 通過xml或註解的方式來定義對象與數據庫表的映射
<select id="getUserByName" parameterType="String" resultType="com.wzq.mybatis.model.User">
SELECT * FROM tmp_user WHERE username=#{username} limit 1;
</select>
public class User {
private int id;
private String username;
private String password;
}
public interface UserMapper {
User getUserByName(String username);
}
將上述xml文件select節點下的所有內容結構化存儲到MappedStatement中,並緩存起來,包括入參和出參的定義,sql以及一些元數據,可以理解爲一個MappedStatement對應一個dal中的方法(MapperMethod),事實上mybatis也是這麼做的:以MapperMethod的name做key,將對應MappedStatement緩存起來,通過jdk動態代理技術對業務Dao(一個定義了多個類似getxxxByxxx的業務方法的接口)生成代理對象,這個代理對象相當於一個集中路由,外部調用某個get方法時,路由到相應的MapperMethod,即可找到對應的MappedStatement
優點:每次調用時都可以複用一個MappedStatement對象,除了查詢參數sql基本上是固定的,實現了以MappedStatement爲單位的全局二級緩存
缺點:對動態sql的支持不如gorm,沒有gorm那種鏈式調用的api,需要在xml裏面用if/else的方式來實現動態sql,代碼相對難以維護
擴展:sql參數的構造,其實是一類場景:如何安全而又靈活構造有大量參數的數據對象?
- mybatis的思路,也是最簡單的思路,窮舉所有可能的參數組合,重載實現多個構造函數。優點就是簡單粗暴,但參數數量較大時,寫代碼難以保證所有參數被傳遞,可能會搞錯順序;同時需要參數的參數組合太多,構造函數的數量會爆炸;
- kite框架的思路,定義一個options,包含所有參數的定義,再以功能點爲單位封裝對參數的設值邏輯,這樣的邏輯通常不會太多
// Options .
type Options struct {
RPCTimeout time.Duration
ReadWriteTimeout time.Duration
ConnTimeout time.Duration
ConnMaxRetryTime time.Duration
}
func WithConnTimeout(timeout time.Duration) Option {
return Option{func(op *Options) {
op.ConnTimeout = timeout
}}
}
//接受任意組合的“參數賦值邏輯”
func NewWithThriftClient(name string, thriftClient Client, ops ...Option) (*KitcClient, error) {
opts := newOptions()
for _, do := range ops {
do.f(opts)
}
}
- gorm的思路,不同於kite這種參數相對不多且固定的場景,search的查詢條件比較複雜,且參數有可能是列表而非單個值,提供了鏈式調用的方式,但由於構造過程不再是“原子的”,所以每一次set都需要clone
- builder設計模式,抽象出來一個builder,包含了所有的參數,並提供鏈式調用的方式來構造builder,只有在builder.build()方法被調用時,纔會用builder中的參數進行一次性的構造,保證了構造過程中的原子性,又提供的鏈式調用的靈活性,缺點就是實現的代碼太多。。
- 將一次查詢的過程抽象成一個sqlSession對象,類似gorm的scope(工廠中的流水線),再抽象出來一個Executor(操作流水線的工人),這個工人可以拿到這次流水線操作的操作手冊(上面提到的MappedStatement),MappedStatement中包含sql,入參和出參的詳細定義,工人便很容易的將入參和sql裝起來,向db發起查詢。
一般一個sqlSession對象持有一個Executor對象的引用,從SqlSession和Executor的接口定義可以看出,sqlSession是面向用戶側的,定義了selectOne/selectList/selectMap/insert/update/delete等用戶常用的方法;而Executor則是一個更底層的接口,只定義了update/query等幾個方法,比較靠近mysql的server端,Executor的部分方法:
int update(MappedStatement ms, Object parameter)
// boundSql爲最終需要執行的sql語句(可能存在動態sql的場景)及參數
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql)
sqlSession中大部分方法實際上都被集中代理到Executor上述兩個方法上,例如
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
MappedStatement ms = configuration.getMappedStatement(statement);
List<E> result = executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
return result;
}
executor.query方法的大致實現
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameterObject);
// 生成一級緩存的key
CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
// public class CachingExecutor implements Executor {
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
Cache cache = ms.getCache();
if (cache != null) {
// 省略,返回cache的數據
}
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
優點:實現了一級緩存,工人Executor維護着本session內所有的查詢緩存,key即上面的CacheKey,由MappedStatement+sql語句+查詢參數構成,同一session內多次做相同的查詢,只有第一次會實際去查db,後續查詢直接走緩存。再看gorm,每次查詢,甚至每次構造參數,都會clone出一個scope,因此沒辦法實現會話級別的查詢緩存
擴展:這裏的cache類似於一個裝飾器模式,和java的io差不多,首先有一個simpleExecutor,實現實際的查詢邏輯,再在外面套一個cacheExecutor,維護一個本地cache,兩者實現同一個接口
// executor的核心邏輯由StatementHandler實現,先看接口定義
Statement prepare(Connection connection)
void parameterize(Statement statement)
int update(Statement statement)
<E> List<E> query(Statement statement, ResultHandler resultHandler)
ParameterHandler getParameterHandler();
StatementHandler會通過MappedStatement中定義的映射關係,來處理mysql中的statement,BaseStatementHandler有兩個核心成員:
// 處理db中的row到model的映射
ResultSetHandler resultSetHandler;
// 處理查詢參數
ParameterHandler parameterHandler;
- 通過columnPrefix對select出來的字段進行“切分”,達到不同的字段映射到不同對象的目的,將結果集中的數據以主鍵做key存在map中,也通過這個key實現一對多關聯關係的聚合功能。這種方式需要用戶編寫join語句;mybatis還提供了另一種方式:association+select,這樣雖然不用編寫join語句,但會產生n+1問題,因此還有另一個選項:fetchType=lazy,思路就是查詢時返回被代理的User對象,並不立即查詢關聯的role,在user對象的role字段被訪問時,才進行實際的查詢
優點:columnPrefix滿足了一些簡單的關聯查詢場景,將join語句暴露給用戶,也提供了優化程序的空間,lazyload的方式將“n+1”中“n”的消耗擴散到程序運行的各個時間段。
和gorm比較:gorm只結構化了對象之間的關聯關係,因此無法做到類似用columnPrefix切分結果集映射到不同對象的功能,但preload的思路相對mybatis的“association+select”更優,直接避開了n+1的問題,也不存在上述join過程中重複信息的問題。不過gorm在preload場景下,映射對象的時間複雜度是(m*n),應該可以將關聯對象事先放在map裏面,以主鍵做key來優化複雜度 - 以sqlSession爲單位管理事務,整合spring時,將db連接放在threadLocal中,通過aop來管理事務的提交和回滾
感想&雜談
gorm思路簡單清晰,幫助理清一個orm框架該做哪些事情,怎麼做這些事情,幾乎沒有冗餘的功能。從gorm的思路出發看mybatis,會發現其中的代碼並沒有很難看懂(之前裸看裏面一些代碼的時候,經常會有有種雲深不知處的感覺,類的數量太多,追源碼追着追着就不知道到哪了),理解其中很多設計模式也容易了很多
mybatis | gorm | |
---|---|---|
不變的部分 | MappedStatement,dao層方法&model定義 | model的定義(包括tag),model之間的關係 |
變化的部分 | MappedStatement中的查詢參數的值(查詢條件相對固定) | 查詢參數&查詢條件 |
功能組成 | session/executor/StatementHandler/resultHandler/typeHandler(面向對象) | query_callback preload_callback(面向過程) |
各自優點 | 實現了一/二級緩存;columnPrefix切分結果集,映射到不同model;面向對象設計,擴展性強(但代碼相對難懂) | 支持複雜動態的查詢條件,鏈式調用;preload使用in,避免n+1問題;代碼易讀 |
各自缺點 | 對複雜動態的查詢條件支持不友好 | 1.共享一個全局的scope,所有狀態的變化都體現在scope和gorm.DB上,對外暴露同一個類型:gorm.DB,各種方法的返回值和err都差不多,有時候會造成使用上的困惑(從api不能理解設計思想)2.數據的作用域太大,每一個插件都能改裏面的值,callback較多的話代碼可能變的難以控制 3.- 鏈式調用api的實現導致了一些不必要的內存複製,一個scope結構只支持一個struct,不支持struct的slice,所以對結果集的每一行都需要新生成scope |