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参数的构造,其实是一类场景:如何安全而又灵活构造有大量参数的数据对象?
- 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的定义(包括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 |
下一篇: Mysql中的临时表使用方法_MySQL
推荐阅读
-
android AgentWeb webview框架(强力推荐)
-
scikit实现机器学习常用模型
-
JQueryEasyUI datagrid框架的基本使用_jquery
-
IOS开发教程第一季之UI进阶day6合并IOS学习017--绘图2
-
最近学习php,看了两本基础的书,接下来想看点优秀的php代码,大家有什么开源项目的代码推荐吗?
-
学习编程有做笔记的必要吗?
-
JavaScript高级程序设计 事件学习笔记_javascript技巧
-
phpmyadmin: linux学习篇-使用apt-get方式安装LAMP包括phpmyadmin
-
html框架里的站内搜索_html/css_WEB-ITnose
-
26岁学习编程是瞎折腾吗?太晚了吗?学 Python 好吗?看《Python核心编程》可以吗?