欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

orm框架学习

程序员文章站 2022-04-19 07:56:59
...

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的定义(包括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
相关标签: orm