天天看点

《编程机制探析》第二十八章 ORM

《编程机制探析》第二十八章 ORM

本章的主题是ORM(Object Relation Mapping,对象与关系数据的映射)。

ORM是一种技术框架,其主要作用是在面向对象语言和关系数据库之间搭建一个转换桥梁。这个转换是双向的。ORM既可以把关系数据转换为对象,也可以把对象转换为关系数据。

ORM种类繁多,功能或繁或简,这里不便一一列举。本章只拣ORM的一些重要特性进行阐述。

ORM的最基本功能是关系数据到对象的转换。程序进行数据库查询时,会获取到一行行的关系数据的集合。这个关系数据集合的数据结构和关系数据表一模一样,都是一个二维表结构,在表头上,每一列都有自己的名字。比如,我们获取一个表名为department(部门)的数据表中的所有数据,得到的结果数据集如下:

id name

01 Office

02 QC

03 IT

04 Design

05 Customer Service

可以看到,这就是一个带有列名的数组结构。为什么不直接返回数组结构,而是提供一个Iterator呢?这是因为,数据库客户端(即调用数据库的进程)为数据结果集分配的进程内数据缓冲空间是有限的,如果结果数据量超过数据缓冲空间的话,超出的那些数据就只能在数据库服务器中等待下一次传唤。而且,很多时候,程序不再需要后面的数据,这时候,这种一部分一部分获取的Iterator Pattern的优势就体现出来了。

关系数据到对象的映射很简单。一行关系数据就是一个数组,只要按照顺序,根据列名,利用Reflection机制,将数据设置到对象的同名属性中就可以了。这个地方无需细说。

一般的情况下,一个关系表定义和一个对象定义之间是一对一的关系。即一个对象定义映射一张表定义。但凡事都有例外。一些功能强大的ORM提供了更加丰富的映射关系,一个关系表定义映射多个对象定义,多个关系表定义映射一个对象定义,甚至还可以分级别映射,一个映射表定义可以映射多级对象定义(即映射到对象中的属性对象),等等。除此之外,ORM还可能提供部分映射,即,把一张表的某些字段映射到对象中的某些属性。这种特性叫做Fetch Group(按组获取)。

这些ORM特性都有各自的特殊应用场景。不过,这不是本章所关心的。本章所关心的一个很有用的ORM特性是Lazy Fetch(延迟获取)。这个特性是用来代替关联表查询的。

关联表查询,即两张、或者两张以上的关系表一起进行条件查询。假设第一张表的记录数是N1,第二张表的记录数N2。这两张表关联查询时,需要处理的记录数就是N1 * N2,即两张表的笛卡尔乘积。当关系表中的记录数非常大的时候,关联查询的开销就会非常巨大。这时候,我们就要考虑Lazy Fetch(延迟获取)的方案,即,两行表分开查询,先按照某种条件,查询其中一张表,然后,再根据查询结果,查询另一张表。

下面举一个例子。前面已经有了一个department(部门)表,我们再引入一个employee(雇员)表。

id name department_id

001 John 01

002 Lee 01

003 Van 01

004 Lily 02

005 Harry 02

006 Long 02

007 Sunny 03

008 Tom 03

009 Tiger 03

这两张表的定义为:

Create table department (id, name)

Create table employee (id, name, department_id)

外键关系: deparment_id <-> department.id

对象关系: Employee对象中有一个Department对象属性。

我们先来看关联查询的例子:

Select employee.*, department.name

from employee, department

where employee.department_id = department.id

然后,我们把这个例子改成Lazy Fetch,即两张表分开查询。最直观的方法是先查询employee表,然后根据每个employee中的department_id去查询department表。产生的SQL如下:

Select * from employee

Select * from department where id = 001

Select * from department where id = 001

Select * from department where id = 001

Select * from department where id = 002

Select * from department where id = 002

Select * from department where id = 002

Select * from department where id = 003

Select * from department where id = 003

Select * from department where id = 003

可以看到,这种方案的效率极低,产生了太多的SQL。这就是数据库查询中的所谓“1 + n”问题。下面,我们对这种方案进行改进。第一个想法就是合并其中重复的SQL,我们得到4条SQL语句:

Select * from employee

Select * from department where id = 001

Select * from department where id = 002

Select * from department where id = 003

这个结果仍然不能让人满意。SQL语句还是太多。我们继续优化,把上述的SQL语句合并为两条SQL。

Select * from employee

Select * from department where id in (001, 002, 003)

这样,我们就把“1 + n”问题转化成了“1 + 1”问题。

Lazy Fetch的实现流程总结如下:

(1)查询第一张表

(2)根据结果集,收集第二张表的id,并消除重复记录。

(3)根据第二张表的id集合,查询第二张表

(4)根据对象关系和id对应关系,将两张表查询出来的对象组装起来。

ORM的第一项功能——关系数据映射到对象,就讲到这里。下面我们来看ORM的第二项功能——SQL动态拼装。

有时候,用户需要利用各种条件组合来查询数据,服务器就需要根据用户输入的各种条件组合,拼装出对应的SQL。除了最原始的字符串拼接发之外,还有两种看起来比较清爽的动态拼装SQL思路。

第一种思路叫做条件对象组装。

这种思路基于一堆称为条件对象(Criteria)的API(Application Programming Interface)。所谓条件对象,就是一堆逻辑操作,如:与对象(and)、或对象(or)、比较对象(大于、小于、等于)等等。

这些条件对象可以组装起来,形成一个树形结构的对象,这个对象输出的结果就是一串SQL条件语句。

这个思路像什么?没错,很像是前面章节中讲过的“页面组件”技术,都是在一堆对象的代码中夹杂着字符串输出语句,最后再统一输出。

我是不赞同这种思路的。在我看来,SQL本身是一种可读性很好的领域专用语言(DSL,Domain Specific Language),其可读性远远超过条件对象(Criteria API)。

我认同这样一种观点——DSL Over API(领域专用语言优于编程函数定义),因为,DSL接近于自然语言,可读性远远超过API。只不过,在现实的世界中,API的定义十分简单,而DSL的定义十分困难,因为DSL涉及到语法解析器和解释器,这两者都不是省油的灯,不是一般人可以写出来的。

现在,既然有了现成的DSL(即SQL)却舍而不用,反而去用最原始的Criteria API,这不是舍本逐末吗?

我赞同第二种思路——SQL模板技术。

这种思路基于前面章节中讲述的“层次匹配”文本生成技术。程序员在SQL中加入begin end ${} 之类的自定义标签,将动态部分划分出来,然后,根据用户输入条件构造一个显示数据模型,最后,将SQL模板和显示数据模型匹配起来,就可以得到最终的SQL语句。

当然,不嫌麻烦的话,也可以采用“绝对位置”Flyweight的方案。不过,SQL一般都不会太长,层次结构也不会太复杂,使用Flyweight方案的好处很可能不足以抵偿带来的麻烦。

ORM的第三项功能是SQL命名参数。

数据库允许用“?”这样的通配符来代替SQL中的参数,如:

select …. where id = ? and name = ?

update … set name = ? where id = ?

delete … where id = ?

insert …. values ( ?, ?)

使用这种带有参数的SQL的时候,程序员需要把参数值按照顺序放进一个数组中,和带参数的SQL一起传给数据库。

这种带参数的SQL有两个问题。第一个问题是可读性不好,所有的参数部分都是“?”,不知道具体应该对应怎样的数值。第二个问题是参数值顺序不好掌握,数组中的参数值必须对应在SQL中的“?”顺序,这个顺序是比较难以对应的,程序员不得不非常小心仔细,有时需要耗费相当的精力。

为了解决这个问题,一般的ORM框架中都引入了“SQL命名参数”的功能。

SQL命名参数用参数名代替了“?”和位置顺序。比如:

select … where id = $id and name = $name

update …. set name = $name where id = $id

delete …. where id = $id

insert … values ($id, $name)

我们可以用“层次匹配”技术来处理这个SQL,把$id和$name替换成?,并且按照参数名取出数据模型中的对应属性,并根据参数名的顺序填入到参数数组中,最后把带有?的SQL和参数数组一起传给数据库。

可以看出,这个工作不仅是简化了参数设置工作,同时还完成了“对象到关系数据的映射”的工作。比如,上面的update、delete、insert语句,就是根据一个对象的属性信息,对数据表进行增、删、改等操作。

ORM的第四项功能是缓存(Cache)。

首先,我们需要明确缓存的应用场景。缓存的目的是为了提高查找速度,但不是所有情况都可以应用缓存。当用户对数据准确度要求很高的情况下,比如银行转账,是不可以应用缓存的,因为缓存具有时效性,很可能过期。只有在对数据准确度要求不高、能够容忍一定程度过期数据的情况下,缓存才有用武之地。

衡量缓存优劣的最重要参数是命中率,即从缓存中查到所需数据的概率。我们可以用一个简单的公式来大致表述:命中率 = 缓存命中次数 / 缓存数据总量

ORM缓存分为两种——ID缓存和Query缓存。

ID缓存,顾名思义,就是以关系表ID为索引的缓存。每行关系数据的ID都是唯一的,对应的对象的ID也都是唯一的。这种缓存很容易理解,不必赘述。

Query缓存,是针对SQL查询语句的缓存。一条SQL查询语句可能查出来一个关系数据集。这个关系数据集也可以存放在缓存中。当下次用户再用同样的SQL查询语句的时候,就可以直接返回Query缓存中的数据结果集。

ID缓存和Query缓存可以分开实现,也可以合并实现。

分开实现的话,两个缓存各不相干,各管一摊,实现上比较简单,但是,时间空间效率和命中率都不高。比如,下面的两条SQL语句。

select …. where department = “QC”

select … where id = $id

第一条SQL语句不是ID查询,是Query查询,对应的是Query缓存。第二条SQL语句对应的是ID查询,对应的是ID缓存。这两条SQL语句查询出来的结果,分别存放到两个不同的缓存空间中。

但是,第一条查询语句中的结果集,很可能包含了第二条查询语句的结果。也就是说,ID缓存和Query缓存有很大的可能性存在重复数据。

为了时间空间效率和命中率起见,ID缓存和Query缓存最好合起来实现,共用同一份缓存。其实现原理如下:

当ID查询的时候,ORM缓存把ID作为键值,把对象存放到缓存中。这时候,实现的是ID缓存的功能。

当Query查询的时候,ORM缓存把结果集一条条展开,把一个个对象的ID作为键值,把对象存放到缓存中,这时候,实现的是ID缓存的功能。然后,根据结果集构造一个ID列表,把SQL本身作为键值,把这个ID列表存放到缓存中,这时候,实现的是Query缓存的功能。

对缓存进行查询的时候,如果键值是ID,那么,就直接取出ID对应的对象。如果键值是SQL,那么,就以SQL为键值,获取的就是一个ID列表。然后,根据这个ID列表,从缓存中把对象一个个取出来,组成一个对象列表,最后返回。

就这样,ID缓存和Query缓存就统一起来了。

除了命中率问题,缓存还需要考虑的重要问题是过期数据清理问题。最简单的过期数据清理策略是按时清理,定义一个清理周期,每隔一定时间就清除缓存中所有数据。

稍微复杂一点的过期数据清理策略是实时清理。当程序遇到任何一条增删改SQL语句的时候,就根据SQL中涉及到的表名,把缓存中所有相关的数据全都清理掉。这种策略很可能会误杀不少没有过期的数据。但缓存就是这样,宁可误杀一千个非过期数据,不可放过一个过期数据。

如果是增删改操作特别多的情况下(按理来说,这种情况下就不应该用缓存),还想使用缓存的话,那么,可以采用更加灵活的数据清除策略,比如,由程序员自己指定清除那些数据,毕竟,程序员自己对于代码逻辑是最了解的。

继续阅读