关系数据库已经统治数据存储30 多年了,但是无模式(或NoSQL)数据库的逐渐流行表明变化正在发生。尽管 RDBMS 为在传统的客户端服务器架构中存储数据提供了一个坚实的基础,但它不能轻松地(或便宜地)扩展到多个节点。在高度可伸缩的 Web 应用程序(比如 Facebook 和 Twitter)的时代,这是一个非常不幸的弱点。
尽管关系数据库的早期替代方案(还记得面向对象的数据库吗?)不能解决真正紧急的问题,NoSQL 数据库(比如 Google 的 Bigtable 和 Amazon 的 SimpleDB)却作为对 Web 的高可伸缩性需求的直接响应而崛起。本质上,NoSQL 可能是一个杀手问题的杀手应用程序 —随着 Web 2.0 的演变,Web 应用程序开发人员可能会遇到更多,而不是更少这样的应用程序。
在这期 Java 开发 2.0 中,我将向您介绍无模式数据建模,这是经过关系思维模式训练的许多开发人员使用 NoSQL 的主要障碍。您将了解到,从一个域模型(而不是关系模型)入手是简化您的改变的关键。如果您使用 Bigtable(如我的示例所示),您可以借助 Gaelyk:Google App Engine 的一个轻量级框架扩展。
NoSQL:一种新的思维方式?
当开发人员谈论非关系或 NoSQL 数据库时,经常提到的第一件事是他们需要改变思维方式。我认为,那实际上取决于您的初始数据建模方法。如果您习惯通过首先建模数据库结构(即首先确定表及其关联关系)来设计应用程序,那么使用一个无模式数据存储(比如 Bigtable)来进行数据建模则需要您重新思考您的做事方式。但是,如果您从域模型开始设计您的应用程序,那么 Bigtable 的无模式结构将看起来更自然。
非关系数据存储没有联接表或主键,甚至没有外键这个概念(尽管这两种类型的键以一种更松散的形式出现)。因此,如果您尝试将关系建模作为一个 NoSQL 数据库中的数据建模的基础,那么您可能最后以失败告终。从域模型开始将使事情变得简单;实际上,我已经发现,域模型下的无模式结构的灵活性正在重新焕发生机。
从关系数据模型迁移到无模式数据模型的相对复杂程度取决于您的方法:即您从基于关系的设计开始还是从基于域的设计开始。当您迁移到 CouchDB 或 Bigtable 这样的数据库时,您 的确会丧失 Hibernate(至少现在)这样的成熟的持久存储平台的顺畅感觉。另一方面,您却拥有能够亲自构建它的 “绿地效果”。在此过程中,您将深入了解无模式数据存储。
实体和关系
无模式数据存储赋予您首先使用对象来设计域模型的灵活性(Grails 这样的较新的框架自动支持这种灵活性)。您的下一步工作是将您的域映射到底层数据存储,这在使用 Google App Engine 时再简单不过了。
在文章 “Java 开发 2.0:针对 Google App Engine 的 Gaelyk” 中,我介绍了 Gaelyk —— 一个基于 Groovy 的框架,该框架有利于使用 Google 的底层数据存储。那篇文章的主要部分关注如何利用 Google 的 Entity对象。下面的示例(来自那篇文章)将展示对象实体如何在 Gaelyk 中工作。
清单1. 使用 Entity 的对象持久存储
- def ticket = new Entity("ticket")
- ticket.officer = params.officer
- ticket.license = params.plate
- ticket.issuseDate = offensedate
- ticket.location = params.location
- ticket.notes = params.notes
- ticket.offense = params.offense
这种对象持久存储方法很有效,但容易看出,如果您频繁使用票据实体 —例如,如果您正在各种 servlet 中创建(或查找)它们,那么这种方法将变得令人厌烦。使用一个公共 servlet(或 Groovlet)来为您处理这些任务将消除其中一些负担。一种更自然的选择——我将稍后展示——将是建模一个 Ticket对象。
返回比赛
我不会重复 Gaelyk 简介中的那个票据示例,相反,为保持新鲜感,我将在本文中使用一个赛跑主题,并构建一个应用程序来展示即将讨论的技术。
如图 1 中的 “多对多” 图表所示,一个 Race拥有多个 Runner,一个 Runner可以属于多个 Race。
图1. 比赛和参赛者
如果我要使用一个关系表结构来设计这个关系,至少需要 3 个表:第 3 表将是链接一个 “多对多” 关系的联接表。所幸我不必局限于关系数据模型。相反,我将使用 Gaelyk(和 Groovy 代码)将这个 “多对多” 关系映射到 Google 针对 Google App Engine 的 Bigtable 抽象。事实上,Gaelyk 允许将 Entity当作 Map,这使得映射过程相当简单。
无模式数据存储的好处之一是无须事先知道所有事情,也就是说,与使用关系数据库架构相比,可以更轻松地适应变化。(注意,我并非暗示不能更改架构;我只是说,可以更轻松地适应变化。)我不打算定义我的域对象上的属性 —我将其推迟到 Groovy 的动态特性(实际上,这个特性允许创建针对 Google 的 Entity对象的域对象代理)。相反,我将把我的时间花费在确定如何查找对象并处理关系上。这是 NoSQL 和各种利用无模式数据存储的框架还没有内置的功能。
Model 基类
我将首先创建一个基类,用于容纳 Entity对象的一个实例。然后,我将允许一些子类拥有一些动态属性,这些动态属性将通过 Groovy 的方便的 setProperty方法添加到对应的 Entity实例。setProperty针对对象中实际上不存在的任何属性设置程序调用。(如果这听起来耸人听闻,不用担心,您看到它的实际运行后就会明白。)
清单2展示了位于我的示例应用程序的一个 Model实例的第一个 stab:
清单2. 一个简单的 Model 基类
- package com.b50.nosql
- import com.google.appengine.api.datastore.DatastoreServiceFactory
- import com.google.appengine.api.datastore.Entity
- abstract class Model {
- def entity
- static def datastore = DatastoreServiceFactory.datastoreService
- public Model(){
- super()
- }
- public Model(params){
- this.@entity = new Entity(this.getClass().simpleName)
- params.each{ key, val ->
- this.setProperty key, val
- }
- }
- def getProperty(String name) {
- if(name.equals("id")){
- return entity.key.id
- }else{
- return entity."${name}"
- }
- }
- void setProperty(String name, value) {
- entity."${name}" = value
- }
- def save(){
- this.entity.save()
- }
- }
注意抽象类如何定义一个构造函数,该函数接收属性的一个 Map ——我总是可以稍后添加更多构造函数,稍后我就会这么做。这个设置对于 Web 框架十分方便,这些框架通常采用从表单提交的参数。Gaelyk 和 Grails 将这样的参数巧妙地封装到一个称为 params的对象中。这个构造函数迭代这个 Map并针对每个 “键 / 值” 对调用 setProperty方法。
检查一下 setProperty方法就会发现 “键” 设置为底层 entity的属性名称,而对应的 “值” 是该 entity的值。
Groovy 技巧
如前所述,Groovy 的动态特性允许我通过 get和 set Property方法捕获对不存在的属性的方法调用。这样,清单 2 中的 Model的子类不必定义它们自己的属性 —它们只是将对一个属性的所有调用委托给这个底层 entity对象。
清单 2 中的代码执行了一些特定于 Groovy 的操作,值得一提。首先,可以通过在一个属性前面附加一个 @来绕过该属性的访问器方法。我必须对构造函数中的 entity对象引用执行上述操作,否则我将调用 setProperty方法。很明显,在这个关头调用 setProperty将打破这种模式,因为 setProperty方法中的 entity变量将是 null。
其次,构造函数中的调用 this.getClass().simpleName将设置 entity的 “种类” —— simpleName属性将生成一个不带包前缀的子类名称(注意,simpleName的确是对 getSimpleName的调用,但 Groovy 允许我不通过对应的 JavaBeans 式的方法调用来尝试访问一个属性)。
最后,如果对 id属性(即,对象的键)进行一个调用,getProperty方法很智能,能够询问底层 key以获取它的 id。在 Google App Engine 中,entities的 key属性将自动生成。
Race 子类
定义 Race子类很简单,如清单 3 所示:
清单3. 一个 Race 子类
- package com.b50.nosql
- class Race extends Model {
- public Race(params){
- super(params)
- }
- }
当一个子类使用一列参数(即一个包含多个 “键 / 值” 对的 Map)实例化时,一个对应的 entity将在内存中创建。要持久存储它,只需调用 save方法。
清单4. 创建一个 Race 实例并将其保存到 GAE 的数据存储
- import com.b50.nosql.Runner
- def iparams = [:]
- def formatter = new SimpleDateFormat("MM/dd/yyyy")
- def rdate = formatter.parse("04/17/2010")
- iparams["name"] = "Charlottesville Marathon"
- iparams["date"] = rdate
- iparams["distance"] = 26.2 as double
- def race = new Race(iparams)
- race.save()
清单4 是一个 Groovlet,其中,一个 Map(称为 iparams)创建为带有 3 个属性 ——一次比赛的名称、日期和距离。(注意,在 Groovy 中,一个空白 Map通过 [:]创建。)Race的一个新实例被创建,然后通过 save方法存储到底层数据存储。
可以通过 Google App Engine 控制台来查看底层数据存储,确保我的数据的确在那里,如图 2 所示:
图2. 查看新创建的Race
查找程序方法生成持久存储的实体
现在我已经存储了一个 Entity,拥有查找它的能力将有所帮助。接下来,我可以添加一个 “查找程序” 方法。在本例中,我将把这个 “查找程序” 方法创建为一个类方法(static)并且允许通过名称查找这些 Race(即基于 name属性搜索)。稍后,总是可以通过其他属性添加其他查找程序。
我还打算对我的查找程序采用一个惯例,即指定:任何名称中不带单词 all的查找程序都企图找到 一个实例。名称中包含单词 all的查找程序(如 findAllByName)能够返回一个实例 Collection或 List。清单 5 展示了 findByName查找程序:
清单5. 一个基于 Entity 名称搜索的简单查找程序
- static def findByName(name){
- def query = new Query(Race.class.simpleName)
- query.addFilter("name", Query.FilterOperator.EQUAL, name)
- def preparedQuery = this.datastore.prepare(query)
- if(preparedQuery.countEntities() > 1){
- return new Race(preparedQuery.asList(withLimit(1))[0])
- }else{
- return new Race(preparedQuery.asSingleEntity())
- }
- }
这个简单的查找程序使用 Google App Engine 的 Query和 PreparedQuery类型来查找一个类型为 “Race” 的实体,其名称(完全)等同于传入的名称。如果有超过一个 Race符合这个标准,查找程序将返回一个列表的第一项,这是分页限制 1(withLimit(1))所指定的。
对应的 findAllByName与上述方法类似,但添加了一个参数,指定 您想要的实体个数,如清单 6 所示:
清单 6. 通过名称找到全部实体
- static def findAllByName(name, pagination=10){
- def query = new Query(Race.class.getSimpleName())
- query.addFilter("name", Query.FilterOperator.EQUAL, name)
- def preparedQuery = this.datastore.prepare(query)
- def entities = preparedQuery.asList(withLimit(pagination as int))
- return entities.collect { new Race(it as Entity) }
- }
与前面定义的查找程序类似,findAllByName通过名称找到 Race实例,但是它返回 所有 Race。顺便说一下,Groovy 的 collect方法非常灵活:它允许删除创建 Race实例的对应的循环。注意,Groovy 还支持方法参数的默认值;这样,如果我没有传入第 2 个值,pagination将拥有值 10。
清单7. 查找程序的实际运行
- def nrace = Race.findByName("Charlottesville Marathon")
- assert nrace.distance == 26.2
- def races = Race.findAllByName("Charlottesville Marathon")
- assert races.class == ArrayList.class
清单 7中的查找程序按照既定的方式运行:findByName返回一个实例,而 findAllByName返回一个 Collection(假定有多个 “Charlottesville Marathon”)。
“参赛者” 对象没有太多不同
现在我已能够创建并找到 Race的实例,现在可以创建一个快速的 Runner对象了。这个过程与创建初始的 Race实例一样简单,只需如清单 8 所示扩展 Model:
清单 8. 创建一个参赛者很简单
- package com.b50.nosql
- class Runner extends Model{
- public Runner(params){
- super(params)
- }
- }
看看 清单 8,我感觉自己几乎完成工作了。但是,我还需创建参赛者和比赛之间的链接。当然,我将把它建模为一个 “多对多” 关系,因为我希望我的参赛者可以参加多项比赛。
没有架构的域建模
Google App Engine 在 Bigtable 上面的抽象不是一个面向对象的抽象;即,我不能原样存储关系,但可以共享键。因此,为建模多个 Race和多个 Runner之间的关系,我将在每个 Race实例中存储一列 Runner键,并在每个 Runner实例中存储一列 Race键。
我必须对我的键共享机制添加一点逻辑,但是,因为我希望生成的 API 比较自然 —我不想询问一个 Race以获取一列 Runner键,因此我想要一列 Runner。幸运的是,这并不难实现。
在清单 9 中,我已经添加了两个方法到 Race实例。但一个 Runner实例被传递到 addRunner方法时,它的对应 id被添加到底层 entity的 runners属性中驻留的 id的 Collection。如果有一个现成的 runners的 collection,则新的 Runner实例键将添加到它;否则,将创建一个新的 Collection,且这个 Runner的键(实体上的 id属性)将添加到它。
清单9. 添加并检索参赛者
- def addRunner(runner){
- if(this.@entity.runners){
- this.@entity.runners << runner.id
- }else{
- this.@entity.runners = [runner.id]
- }
- }
- def getRunners(){
- return this.@entity.runners.collect {
- new Runner( this.getEntity(Runner.class.simpleName, it) )
- }
- }
当清单 9 中的 getRunners方法调用时,一个 Runner实例集合将从底层的 id集合创建。这样,一个新方法(getEntity)将在 Model类中创建,如清单 10 所示:
清单10. 从一个id 创建一个实体
- def getEntity(entityType, id){
- def key = KeyFactory.createKey(entityType, id)
- return this.@datastore.get(key)
- }
getEntity方法使用 Google 的 KeyFactory类来创建底层键,它可以用于查找数据存储中的一个单独实体。
最后,定义一个新的构造函数来接受一个实体类型,如清单 11 所示:
清单11. 一个新添加的构造函数
- public Model(Entity entity){
- this.@entity = entity
- }
如清单 9、10和 11、以及 图 1的对象模型所示,我可以将一个 Runner添加到任一 Race,也可以从任一Race获取一列 Runner实例。在清单 12 中,我在这个等式的 Runner方上创建了一个类似的联系。清单 12 展示了 Runner类的新方法。
清单12. 参赛者及其比赛
- def addRace(race){
- if(this.@entity.races){
- this.@entity.races << race.id
- }else{
- this.@entity.races = [race.id]
- }
- }
- def getRaces(){
- return this.@entity.races.collect {
- new Race( this.getEntity(Race.class.simpleName, it) )
- }
- }
这样,我就使用一个无模式数据存储创建了两个域对象。
通过一些参赛者完成这个比赛
此前我所做的是创建一个 Runner实例并将其添加到一个 Race。如果我希望这个关系是双向的,如图1中我的对象模型所示,那么我也可以添加一些 Race实例到一些Runner,如清单 13 所示:
清单 13. 参加多个比赛的多个参赛者
- def runner = new Runner([fname:"Chris", lname:"Smith", date:34])
- runner.save()
- race.addRunner(runner)
- race.save()
- runner.addRace(race)
- runner.save()
将一个新的 Runner添加到 race并添加对Race的save的调用后,这个数据存储已使用一列ID 更新,如图 3 中的屏幕快照所示:
图3. 查看一项比赛中的多个参赛者的新属性
通过仔细检查Google App Engine 中的数据,可以看到,一个Race实体现在拥有了一个Runners 的list,如图 4 所示。
图4. 查看新的参赛者列表
同样,在将一个 Race添加到一个新创建的 Runner实例之前,这个属性并不存在,如图 5 所示。
图5. 一个没有比赛的参赛者
但是,将一个 Race关联到一个 Runner后,数据存储将添加新的 races ids 的 list。
图6. 一个参加比赛的参赛者
无模式数据存储的灵活性正在刷新 —属性按照需要自动添加到底层存储。作为开发人员,我无须更新或更改架构,更谈不上部署架构了!
NoSQL 的利弊
当然,无模式数据建模也有利有弊。回顾上面的比赛应用程序,它的一个优势是非常灵活。如果我决定将一个新属性(比如 SSN)添加到一个 Runner,我不必进行大幅更改 —事实上,如果我将该属性包含在构造函数的参数中,那么它就会自动添加。对那些没有使用一个 SSN 创建的旧实例而言,发生了什么事情?什么也没发生!它们拥有一个值为 null的字段。
另一方面,我已经明确表明要牺牲一致性和完整性来换取效率。这个应用程序的当前数据架构没有向我施加任何限制 —理论上我可以为同一个对象创建无限个实例。在 Google App Engine 引擎的键处理机制下,它们都有惟一的键,但其他属性都是一致的。更糟糕的是,级联删除不存在,因此如果我使用相同的技术来建模一个 “一对多” 关系并删除父节点,那么我得到一些无效的子节点。当然,我可以实现自己的完整性检查 —但关键是,我必须亲自动手(就像完成其他任务一样)。
使用无模式数据存储需要严明的纪律。如果我创建各种类型的 Races —有些有名称,有些没有,有些有 date属性,而另一些有 race_date属性 —那么我只是在搬起石头砸自己(或使用我的代码的人)的脚。
当然,也有可能联合使用 JDO、JPA 和 Google App Engine。在多个项目上使用过关系模型和无模式模型后,我可以说 Gaelyk 的低级 API 最灵活,使用最方便。使用 Gaelyk 的另一个好处是能够深入了解 Bigtable 和一般的无模式数据存储。
结束语
流行时尚来了又去,有时无需理会它们(明智的建议来自一个衣橱里满是休闲服的家伙)。但 NoSQL 看起来不太像一种时尚,更像是高度可伸缩的 Web 应用程序开发的一个新兴基础。NoSQL 数据库不会替代 RDBMS,但是,它们将补充它。无数成功的工具和框架基于关系数据库,RDBMSs 本身似乎没有面临过时的危险。
总之,NoSQL 数据库的作用是向对象——关系数据模型提供一个及时的替代方案。它们向我们展示,有些事情是可行的,并且对于一些特定的、高度强制的用例甚至更好。无模式数据库最适用于需要高速数据检索和可伸缩性的多节点 Web 应用程序。它们还有一个极好的副作用,即允许开发人员从一个面向域的视角、而不是关系视角进行数据建模。