orm框架學習

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參數的構造,其實是一類場景:如何安全而又靈活構造有大量參數的數據對象?

  1. mybatis的思路,也是最簡單的思路,窮舉所有可能的參數組合,重載實現多個構造函數。優點就是簡單粗暴,但參數數量較大時,寫代碼難以保證所有參數被傳遞,可能會搞錯順序;同時需要參數的參數組合太多,構造函數的數量會爆炸;
  2. 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)
   }
}
  1. gorm的思路,不同於kite這種參數相對不多且固定的場景,search的查詢條件比較複雜,且參數有可能是列表而非單個值,提供了鏈式調用的方式,但由於構造過程不再是“原子的”,所以每一次set都需要clone
  2. 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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章