内容


面向文档的数据库 CouchDB

Comments

CouchDB 介绍

CouchDB 是一个文档型数据库服务器。与现在流行的关系数据库服务器不同,CouchDB 是围绕一系列语义上自包含的文档而组织的。 CouchDB 中的文档是没有模式的(schema free),也就是说并不要求文档具有某种特定的结构。 CouchDB 的这种特性使得相对于传统的关系数据库而言,有自己的适用范围。一般来说,围绕文档来构建的应用都比较适合使用 CouchDB 作为其后台存储。 CouchDB 强调其中所存储的文档,在语义上是自包含的。这种面向文档的设计思路,更贴近很多应用的问题域的真实情况。对于这类应用,使用 CouchDB 的文档来进行建模,会更加自然和简单。与此同时,CouchDB 也提供基于 MapReduce 编程模型的视图来对文档进行查询,可以提供类似于关系数据库中 SQL 语句的能力。 CouchDB 对于很多应用来说,提供了关系数据库之外的更好的选择。下面介绍 CouchDB 中的一些重要概念。

基本概念

文档(document)
文档是 CouchDB 中的核心概念。一个 CouchDB 数据库实际上是一系列文档的集合,而这些文档之间并不存在层次结构。每个文档都是自包含的数据单元,是一系列数据项的集合。每个数据项都有一个名称与对应的值,值既可以是简单的数据类型,如字符串、数字和日期等;也可以是复杂的类型,如有序列表和关联对象。每个文档都有一个全局惟一的标识符(ID)以及一个修订版本号(revision number)。 ID 用来惟一标识一个文档,而修订版本号则用来实现多版本并发控制(Multiversion concurrency control,MVVC)。在 CouchDB 中,文档是以 JSON 对象的形式保存的。
视图(view)
视图是 CouchDB 中文档的呈现方式。在很多情况下,应用都需要对文档进行一定的处理,包括过滤、组织、聚合和生成报表等。在关系数据库中,这通常是通过 SQL 语句来完成的。 CouchDB 中的视图声明了如何从文档中提取数据,以及如何对提取出来的数据进行处理。

扩展概念

设计文档
设计文档是一类特殊的文档,其 ID 必须以_design/开头。设计文档的存在是使用 CouchDB 开发 Web 应用的基础。在 CouchDB 中,一个 Web 应用是与一个设计文档相对应的。在设计文档中可以包含一些特殊的字段,其中包括:views包含永久的视图定义;shows包含把文档转换成非 JSON 格式的方法;lists包含把视图运行结果转换成非 JSON 格式的方法;validate_doc_update 包含验证文档更新是否有效的方法。
附件
CouchDB 中也可以保存二进制文件。这些文件是以文档的附件形式存储的。 CouchDB 支持两种形式的附件:一种是内嵌型的,附件是以 base64 编码的格式作为文档的一个字段保存;另一种是独立型,附件是独立于文档保存和管理的。附件的存在使得可以在 CouchDB 中保存 Web 应用中的 HTML、CSS 和 JavaScript 文件。

在开发 Web 应用之前,下面将先介绍 CouchDB 的安装与配置。

CouchDB 安装与配置

Apache CouchDB 目前的最新发布版本是 0.9.0 。它可以安装在主流的操作系统中,包括 Windows、Linux、Unix、Mac 和 Solaris 。需要注意的是 Windows 上的安装包目前还在测试阶段,并且只有 0.8.1 版本的。而本文中 的 Web 应用需要用到 0.9.0 版本的功能,因此下面主要介绍在 Ubuntu 9.0.4 上安装和配置 CouchDB 0.9.0 。

首先从 CouchDB 的官方网站(见参考资料)下载 0.9.0 的发布包并解压缩,接着需要安装 CouchDB 依赖的组件,包括 Erlang、ICU 和 OpenSSL 等,最后是构建和安装 CouchDB 。完整的脚本如代码清单 1 所示。

清单 1. CouchDB 在 Ubuntu 9.0.4 上的安装脚本
apt-get install build-essential erlang libicu-dev libmozjs-dev libcurl4-openssl-dev 
 wget http://labs.xiaonei.com/apache-mirror/couchdb/0.9.0/apache-couchdb-0.9.0.tar.gz 
 tar -xzvf apache-couchdb-0.9.0.tar.gz 
 cd apache-couchdb-0.9.0 
 ./configure 
 make && sudo make install

从安全的角度考虑,不建议使用超级用户来运行 CouchDB,而是为 CouchDB 新建一个专门的普通用户,由该用户来负责 CouchDB 的运行和管理。在 Ubuntu 上,可以运行代码清单 2 中的脚本来创建该用户。

清单 2. 创建与配置 CouchDB 专有用户的脚本
sudo adduser --system --home /usr/local/var/lib/couchdb --no-create-home \ 
            --shell /bin/bash --group --gecos "CouchDB Administrator" couchdb 
 chown -R couchdb:couchdb /usr/local/etc/couchdb 
 chown -R couchdb:couchdb /usr/local/var/lib/couchdb 
 chown -R couchdb:couchdb /usr/local/var/log/couchdb 
 chown -R couchdb:couchdb /usr/local/var/run 
 chmod -R 0770 /usr/local/etc/couchdb 
 chmod -R 0770 /usr/local/var/lib/couchdb 
 chmod -R 0770 /usr/local/var/log/couchdb 
 chmod -R 0770 /usr/local/var/run

安装配置完成之后,就可以启动 CouchDB 了。通过运行代码清单 3 中的脚本就可以启动 CouchDB 。在 CouchDB 启动完成之后,会显示“ Apache CouchDB has started,time to relax. ”。接下来就可以用浏览器访问地址http://127.0.0.1:5984/_utils/index.html来使用 CouchDB 自带的管理工具 Futon 。在安装完成之后,建议在 Futon 中运行 CouchDB 自带的测试集来确定安装是否正确。图 1 中给出了在 Futon 中运行测试集的界面。

清单 3. 启动 CouchDB 的脚本
sudo -i -u couchdb couchdb -b
图 1. 在 Futon 中运行 CouchDB 自带的测试集
CouchDB自带的测试集
CouchDB自带的测试集

在安装和配置 CouchDB 完成之后,下面将介绍用来操作数据库的 REST API 。

REST API

CouchDB 提供 REST API 来供客户端程序使用 CouchDB 的功能,并对数据库进行操作。。 REST API 主要针对 CouchDB 中的三种资源:数据库、文档和视图。下面分别介绍这三种 REST API 的细节。

数据库 REST API

数据库 REST API 用来查询、创建和删除数据库。 CouchDB 中数据库的名称只能是小写字母、数字以及特殊字符_$()+-/。需要注意的是大写字母是不允许的,这是由于某些操作系统的文件系统是大小写不敏感的。 CouchDB 为了避免可能出现的问题,限制了不能使用大写字母。数据库 REST API 的具体用法如下:

  • 通过 GET 请求访问 URL/_all_dbs可以查询 CouchDB 中所有的数据库名称。该请求返回的是一个 JSON 数组,其中每个元素表示一个数据库名称。
  • 通过 GET 请求访问 URL/databasename/可以查询名为databasename的数据库的具体信息。该请求返回的是一个 JSON 对象。
  • 通过 PUT 请求访问 URL/databasename/可以创建名为databasename的数据库。如果数据库创建成功的话,返回 HTTP 状态代码 201 ;如果已有一个同名数据库的话,返回 HTTP 状态代码 412 。
  • 通过 DELETE 请求访问 URL/databasename/可以删除名为databasename的数据库。如果数据库删除成功的话,返回 HTTP 状态代码 200 ;如果数据库不存在,返回 HTTP 状态代码 404 。

文档 REST API

文档 REST API 用来查询、创建、更新和删除文档。具体的用法如下:

  • 通过 GET 请求访问 URL/databasename/doc_id可以获取名称为databasename的数据库中 ID 为doc_id文档的内容。文档的内容是一个 JSON 对象,其中以“ _ ”作为前缀的顶层字段是由 CouchDB 保留使用的,如_id_rev
  • 通过 PUT 请求访问 URL/databasename/doc_id可以在名称为databasename的数据库中创建 ID 为doc_id的文档。通过 POST 请求访问 URL/databasename/也可以创建新文档,不过是由 CouchDB 来生成文档的 ID 。
  • 通过 PUT 请求访问 URL/databasename/doc_id可以更新已有的文档。在 PUT 请求内容的文档中需要包含_rev字段,表示文档的修订版本号。 CouchDB 使用该字段来做更新时的冲突检测。如果该字段的值与 CouchDB 中保存的该文档的修订版本号一致,则表明没有冲突,可以进行更新。当更新完成之后,返回 HTTP 状态代码 201 ;否则返回 HTTP 状态代码 409,表示有版本冲突。
  • 通过 DELETE 请求访问 URL/databasename/doc_id?rev=rev_id可以删除数据库databasename中 ID 为doc_id,并且修订版本号为rev_id的文档。

视图 REST API

视图是 CouchDB 中文档的呈现方式。在 CouchDB 中保存的是视图的定义。 CouchDB 中有两种视图:永久视图和临时视图。永久视图保存在设计文档的views字段中。如果需要修改永久视图的定义,只需要通过文档 REST API 来修改设计文档即可。临时视图是通过发送 POST 请求到 URL/databasename/_temp_view 来执行的。在 POST 请求中需要包含视图的定义。一般来说,临时视图只在开发测试中使用,因为它是即时生成的,性能比较差;永久视图的运行结果可以被 CouchDB 缓存,因此一般用在生产环境中。

附件 REST API

前面提到,CouchDB 有内嵌型和独立型两种附件存储方式。内嵌型附件是保存在文档的_attachments字段中。每个附件都包含名称、MIME 类型和数据等三项内容。附件的实际数据是以 base64 编码的形式保存在文档中的。对内嵌附件进行操作的 REST API 与文档 REST API 是类似的,只需要修改_attachments字段即可。在请求文档的时候,附件的实际数据默认是不包含的,包含的只是附件的元数据,如代码清单 4 所示。可以通过在请求的时候添加参数attachments=true来包含实际数据,不过这会降低性能。在请求附件的内容时,CouchDB 会自动进行 base64 解码。也就是说只需要在保存附件的时候进行 base64 编码,获取附件的时候,并不需要客户端代码完成解码的工作。

清单 4. 请求带附件的文档时的响应内容
{ 
   "_id": "testdoc", 
   "_rev": "3-1364618102", 
   "_attachments": { 
       "Screenshot.png": { 
           "stub": true, 
           "content_type": "image/png", 
           "length": 164279 
       } 
   } 
 }

独立型附件是 CouchDB 0.9 中新增的功能,可以在不改变文档的情况下,对附件进行操作。另外,不需要对附件进行 base64 编码。要创建独立型附件,只需要发送 PUT 请求到databasename/doc_id/attachment?rev=rev_id就可以创建或更新一个名为attachment的附件。 PUT 请求的内容类型(Content-Type)和内容指明了附件的类型和数据。

在介绍完 CouchDB 的 REST API 之后,下面在使用 CouchDB 的时候如何对应用进行建模。

为应用建模

由于关系数据库的流行,很多开发者对于实体 - 关系(Entity-Relation,ER)模型非常熟悉。而 CouchDB 使用的是面向文档(Document oriented)的模型。在使用 CouchDB 的时候,需要完成从 ER 模型到文档模型的思维方式的转变。下面通过几个具体的例子来说明如何在 CouchDB 中对于一些典型的场景进行建模,并与关系数据库中的建模方式进行比较。

描述实体

第一个场景是对实体的描述。关系数据库中使用表来表示实体。数据库表是有固定的模式的,该模式定义了表中每行数据应该满足的格式。表中每行数据都对应于实体的一个实例。比如应用中如果需要描述“注册用户”和“图书”两种实体的话,就需要两张不同的表。而在 CouchDB 中,所有的实体都是以文档来描述的。文档是没有模式的,可以用任意的 JSON 对象来表示。同一实体的实例在结构上也可能不同。这更能反映问题域中数据的真实状态。比如对“人”这一实体进行描述时,有一个字段是“传真号码”。因为不是所有人都拥有传真机,这一字段是可选的。如果用关系数据库来建模的话,则需要在表中添加一列表示传真号码。对于没有传真机的人来说,该列的值为null。而如果用 CouchDB 中的文档来描述的话,对于有传真机的人,其 JSON 对象中就有一个属性表示“传真号码”,否则的话就没有此属性。 CouchDB 强于关系数据库的另外一个特性是可以非常容易的表示复杂数据类型。通常来说,关系数据库中表的列只能是简单数据类型。而 CouchDB 中的文档由于用 JSON 来描述,可以使用任意复杂的嵌套结构。同样是对“人”这一实体的描述,另外一个有用的信息是“家庭住址”。“家庭住址”可以简单地用一个字符串来表示,也可以拆分成“国家”、“省(市)”、“县”和“街道”等多个字段来表示。对于后者,如果用关系数据库来描述的话,则需要使用多个列或是额外的表;而用 CouchDB 的文档来描述的话,可以直接把复杂的 JSON 对象作为字段的值,如{"address" : {"country" : " 中国 ", "city" : " 北京 "}}。比起关系数据库来说,要更加简单和自然。

描述一对一和一对多关系

第二个场景是描述一对一和一对多的关系。在关系数据库中,实体之间的一对一和一对多关系是通过外键来描述。比如在一个电子商务应用中,订单与其中包含的单项商品是一对一或一对多的关系。如果用关系数据库来描述的话,需要在表示单项商品的表中添加一个字段作为外键,引用到订单的主键。在 CouchDB 中,一般来说有两种方式可以描述。第一种方式是把相关的实体内嵌在主文档中。如在表示某个订单的文档,可以有一个字段是用来表示其中包含的单项商品,如代码清单 5 所示。不过这种方式只适用于相关的实体数量比较少的情况,否则的话,会导致文档过大而影响性能。另外一种方式是用分开的文档来表示这两种实体,并在其中一个文档中添加一个字段,其值是另外一个文档的 ID 。这种做法类似于关系数据库中的外键引用方式。代码清单 5 中也给出了使用这种方式表示的订单和单项商品的文档。

清单 5. 描述一对多关系
// 用内嵌文档描述一对多关系
 { 
  "_id" : "order001", 
  "type" : "order", 
  "username" : "Alex", 
  "created_at" : "Tue Jun 02 2009 21:49:00 GMT+0800", 
  "line_items":[ 
    { 
	  "name" : " 杜拉拉升职记 ", 
	  "price" : "17.7", 
	  "quantity" : 1 
	 }, 
    { 
	  "name" : " 狼图腾 ", 
	  "price" : "24", 
	  "quantity" : 2 
	 } 	
  ] 
 } 

 // 用分开的文档描述一对多关系
 { 
  "_id" : "order001", 
  "type" : "order", 
  "username" : "Alex", 
  "created_at" : "Tue Jun 02 2009 21:49:00 GMT+0800" 
 } 
 { 
  "_id" : "line_item_001", 
  "order_id" : "order001", 
  "name" : " 杜拉拉升职记 ", 
  "price" : "17.7", 
  "quantity" : 1 
 } 
 { 
  "_id" : "line_item_002", 
  "order_id" : "order001", 
  "name" : " 狼图腾 ", 
  "price" : "24", 
  "quantity" : 2 
 }

描述多对多关系

最后一个场景是描述多对多的关系。在关系数据库中,实体之间的多对多关系一般是通过额外的关联表来实现的。比如一个典型的场景是应用中“注册用户”与“角色”之间的关系,一个用户可以同时具备多个角色,一个角色也可以同时有多个用户。在关系数据库中,用户和角色都各自用一张表来描述,它们之间的关联关系存放在另外一张表中,该表包含用户和角色的外键引用与其它附加信息。 CouchDB 中有两种方式来描述多对多关系。第一种类似于一对多关系中的内嵌文档方式,只是内嵌的不是文档本身,而只是文档的 ID 。第二种做法类似于关系数据库中的关联表,使用一个额外的关联文档来描述关系。代码清单 6 中给出了使用这两种做法描述用户和角色的实例。

清单 6. 描述多对多关系
// 用内嵌文档 ID 描述多对多关系
 { 
  "_id" : "user1", 
  "username" : "Alex", 
  "email" : "alexcheng1982@gmail.com", 
  "roles":["db_admin","backup_admin"] 
 } 
 { 
  "_id" : "db_admin", 
  "name" : " 数据库管理员 ", 
  "priority" : 2 
 } 

 // 用关联文档描述多对多关系
 { 
  "_id" : "user1", 
  "username" : "Alex", 
  "email" : "alexcheng1982@gmail.com" 
 } 
 { 
  "_id" : "db_admin", 
  "name" : " 数据库管理员 ", 
  "priority" : 2 
 } 
 { 
  "_id" : "user_role_001", 
  "user_id" : "user1", 
  "role_id" : "db_admin" 
 }

上面说明了如何在 CouchDB 中使用文档来对一些典型的应用场景进行建模。下面将介绍开发 Web 应用的具体内容。

实战开发

开发 Web 应用

CouchDB 不仅是一个数据库服务器,同时也是一个应用服务器。在前面对 REST API 的介绍中,说明了如何把 CouchDB 作为一个数据库服务器来使用。下面将介绍如何将 Web 应用运行在 CouchDB 上。

由于 CouchDB 的 REST API 使用 JSON 作为展现形式,因此使用 CouchDB 的 Web 应用只需要编写浏览器端的代码就可以使用 JavaScript 与 CouchDB 进行交互;而 CouchDB 所支持的附件功能,又使得浏览器端的 HTML、JavaScript 和 CSS 代码可以直接存放在 CouchDB 中。这样 CouchDB 中不但保存了 Web 应用的数据,也保存了 Web 应用的逻辑。也就是说,只需要 CouchDB 就可以构建一个完整的 Web 应用运行环境。

在 CouchDB 中,一个 Web 应用对应的是一个设计文档。这个 Web 应用可以操作 CouchDB 中保存的文档型数据。当需要创建新的 Web 应用的时候,只需要创建新的设计文档即可。 CouchDB 使得 Web 应用的部署和管理变得非常简单,只需要通过 REST API 管理设计文档即可。从更大的角度来说,CouchDB 有可能创造一种新的 Web 应用开发模式。在这种模式中,CouchDB 中保存的文档型数据可以为每个应用开发者所使用,开发者在数据之上创建满足各种需求的 Web 应用。

本文中将以一个具体的小型网站作为实例来介绍使用 CouchDB 开发 Web 应用中的细节。该网站是一个类似“豆瓣”的用户点评网站。在该网站中用户可以对图书进行编辑和评价。

使用 CouchApp

CouchApp 是一个开发使用 CouchDB 的 Web 应用的小型框架。它的主要功能是可以把一个文件系统的目录转换成 CouchDB 中的一个设计文档。在开发的时候,可以按照一般 Web 应用的结构来组织文件系统,当需要测试和部署的时候,只需要一条命令就可以把该目录保存到 CouchDB 中。 CouchApp 目前有 Python 和 Ruby 两种语言的版本,本文中使用的是 Python 版本。由于目前 CouchApp 正在开发中,所以最好是从源代码安装。代码清单 7 中给出了 CouchApp 的安装脚本。

清单 7. CouchApp 安装脚本
sudo apt-get install git 
 git clone git://github.com/jchris/couchapp.git 
 cd couchapp 
 python setup.py build 
 sudo python setup.py install

CouchApp 有两条基本的命令,分别是pushgenerate

  • push命令的作用是把文件系统目录保存到 CouchDB 的设计文档中。它的语法是couchapp push [options] [appdir] [appname] [dburl],如命令couchapp push . http://127.0.0.1:5984/databasename的作用是把当前目录的内容保存到数据库databasename中。
  • generate命令的作用是创建一个应用,所创建的应用有比较好的目录结构,推荐使用。它的语法是couchapp generate <appname> [appdir],如命令couchapp generate myapp的作用是在当前目录创建名为myapp的应用。

对于本文中介绍的示例应用来说,首先使用couchapp generate dianping来创建,然后使用couchapp push . http://127.0.0.1:5984/dianping来保存到 CouchDB 中,然后就可以通过浏览器访问http://127.0.0.1:5984/dianping/_design/dianping/index.html来查看该应用。在对 CouchApp 生成的目录结构进行删减之后,就得到了该应用的目录结构,如图 2 所示。

图 2. 示例应用的目录结构图
目录结构图
目录结构图

图 2所示,_attachments目录中包含的是静态的 JavaScript 和 CSS 文件;vendor目录中包含的是 CouchApp 提供的一些 JavaScript 类库;views目录中包含的是永久视图定义;shows目录中包含的是格式化文档的 show 方法;lists目录中包含的是格式化视图运行结果的 list 方法;templates目录中包含的是 show 和 list 方法所需的 HTML 模板。在下面的章节中将会具体介绍这些目录中存放的文件。

使用 CouchDB jQuery 插件

前面提到 CouchDB 提供了返回 JSON 数据的 REST API,在浏览器中使用 JavaScript 就可以很容易的通过 Ajax 请求来操纵 CouchDB 。 CouchDB 自带的管理工具 Futon 使用了一个 jQuery 的插件来操纵 CouchDB 。在一般的 Web 应用中也可以使用该插件,其 JavaScript 文件的路径是 /_utils/script/jquery.couch.js表 1 中给出了该插件中的常用方法。本文的示例应用使用 jQuery 和该插件来开发。

表 1. jQuery CouchDB 插件的常用方法
方法说明
$.couch.allDbs(options)获取 CouchDB 中所有数据库的信息。
$.couch.db(dbname).create(options)创建名为dbname的数据库。
$.couch.db(dbname).drop(options)删除名为dbname的数据库。
$.couch.db(dbname).info(options)获取名为dbname的数据库的信息。
$.couch.db(dbname).allDocs(options)获取名为dbname的数据库中的全部文档。
$.couch.db(dbname).allDesignDocs(options)获取名为dbname的数据库中的全部设计文档。
$.couch.db(dbname).openDoc(docId, options)获取名为dbname的数据库中 ID 为docId的文档内容。
$.couch.db(dbname).saveDoc(doc, options)把内容为doc的文档保存到名为dbname的数据库中。
$.couch.db(dbname).removeDoc(doc, options)从名为dbname的数据库中删除内容为doc的文档。
$.couch.db(dbname).query(mapFun, reduceFun, language, options)基于 Map 和 Reduce 方法创建临时视图并进行查询。
$.couch.db(dbname).view(viewname, options)获取名为dbname的数据库中永久视图viewname的运行结果。

表 1 中,所有方法的参数 options 表示调用 CouchDB REST API 的可选参数,其中一般需要包含 successerror 两个方法作为请求正确完成和出现错误时的回调方法。

示例应用建模

在对示例应用经过分析之后,确定应用中应该包含两类实体,即图书和用户评论。图书实体的属性有名称、作者、出版日期、出版社、简介、标签等,用户评论的属性有评论者的姓名和评论内容。代码清单 8 中给出了两类实体在 CouchDB 中的文档实例,其中 type 字段是用来区分不同类别的文档,方便用视图来进行查询。

清单 8. 图书和用户评论的 CouchDB 文档实例
// 图书
 { 
   "_id": "4c4e301b00351326f5692b5e7be41d43", 
   "_rev": "3-3409240079", 
   "title": " 光月道重生美丽 ", 
   "author": " 自由鸟 ", 
   "press": " 长江文艺出版社 ", 
   "price": "19.8", 
   "tags": [ 
       " 小说 ", 
       " 爱情 ", 
       " 都市小说 " 
   ], 
   "summary": " 人与人之间喜爱、憎恨、吸引、排斥 ... 皆因生活在同一个世界而产生,好像绿绒桌子上 .....", 
   "type": "book", 
   "publish_date": "2009-2-1" 
 } 

 // 用户评论
 { 
   "_id": "27026e72f41cbc4ea3e29d402984dcdc", 
   "_rev": "1-2177730796", 
   "book_id": "8ee34f275e6ed7de6e219f5ea1dcaafd", 
   "commenter_name": "alex", 
   "comment": " 这本书写得不错 ", 
   "type": "comment", 
   "created_at": 1243767814421 
 }

下面将具体介绍如何在应用中管理文档和使用视图。

管理文档

下面以图书这类文档为例来说明如何对文档进行操作,所涉及的操作包括文档的创建、更新和删除。对文档进行管理需要提供给用户相应的 HTML 页面,而实际的操作是通过CouchDB jQuery 插件调用 CouchDB 的REST API来完成的。

创建与更新文档

创建文档和更新文档的行为是类似的,都需要一个 HTML 表单来接受用户的输入。所不同的是更新文档的时候,需要用文档的当前内容填充表单。在表单提交的时候,需要提取表单中的内容并创建文档的 JSON 对象,接着将该 JSON 对象保存到 CouchDB 中。

因为需要返回的是 HTML 页面,因此需要用到前面提到的设计文档中的 show 方法。所有的 show 方法都是存放在设计文档的 shows 字段里面的,如代码清单 9 所示。

清单 9. 设计文档中的shows字段
{ 
 "_id" : "_design/dianping", 
 "shows" : { 
  "example_show" : "function(doc, req) { ... }", 
  "another_show" : "function(doc, req) { ... }" 
 }

代码清单 9中定义了两个 show 方法,分别是example_showanother_show。通过 URL/dianping/_design/dianping/_show/example_show/doc_id就可以调用数据库dianping中设计文档dianping中名为example_show的方法,并且传入对应的文档 IDdoc_id。每个 show 方法都可以有两个参数:docreq,其中 doc表 示的是与请求的文档 ID 对应的文档内容,而req则表示与当前请求相关的内容,是一个 JSON 对象。表 2 中给出了该 JSON 对象的属性和含义。

表 2. show 方法的req参数
属性说明
body对于 GET 请求来说,该属性的值是undefined;对于 POST/PUT 请求来说,该属性的值是请求的内容。
cookie该属性表示浏览器端的 cookie 。
form如果请求的内容类型(Content Type)是application/x-www-form-urlencoded的话,该属性包含解码之后的 JSON 对象。
info该属性包含所请求的 CouchDB 数据库的信息。
path该属性是一个数组,表示请求的路径。
query该属性包含对请求的查询字符串解码之后的 JSON 对象。
verb该属性表示 HTTP 请求的方法,一般是 GET/POST/PUT/DELETE 。

这里需要注意的是请求中的文档 ID 与 show 方法的参数doc的关系,具体的情况如下:

  • 请求中传入了文档 ID,并且数据库中存在与此 ID 对应的文档:这种情况下,doc的值就是此 ID 对应的文档内容。
  • 请求中传入了文档 ID,但是数据库中没有与此 ID 对应的文档:这种情况下,doc的值是null,可以通过req.docId获取此 ID 。一般的行为是创建 ID 为req.docId的文档。
  • 请求中没有传入文档 ID:这种情况下,docreq.docId的值都为null。一般的行为是由 CouchDB 生成一个 ID,并创建文档。

show方法都需要返回一个包含了 HTTP 响应信息的 JSON 对象。该 JSON 对象中可以包含表 3中给出的几个字段。表示 HTTP 响应内容的jsonbodybase64只需要设置一个即可。

表 3. show 方法返回的 JSON 对象
属性说明
code该属性表示 HTTP 响应的状态代码,默认是 200 。
headers该属性表示 HTTP 响应的头,是一个 JSON 对象,如{"Content-Type" : "application/xml"}
json设置该属性表示把一个 JSON 对象发送给客户端。
body设置该属性表示把一个任意的字符串发送给客户端。
base64设置该属性表示把 base64 编码的二进制数据发送给客户端。

由于 CouchApp 可以把目录结构转换到 CouchDB 的设计文档中,因此创建show方法的时候,只需要在 Web 应用的根目录下面创建一个shows目录,并在其中创建 JavaScript 文件即可。如该目录下的book-edit.js文件会被转换成名为book-edit的方法。创建文档的show方法需要返回 HTML 文档,有两种方法可以实现。

  • 直接在show方法构造 HTML 文档内容的字符串。这种方式比较直接,不过字符串拼接比较繁琐,而且容易出错,同时带来的维护成本也比较高。
  • 使用简单的模板技术来实现。 CouchApp 自带了一个基于 JavaScript 的简单模板实现,定义在vendor/couchapp/template.js中。该模板实现可以把模板中的<%= title %>这样的声明替换成传入的 JSON 对象中title属性的值。

本文的示例应用中使用的是模板来实现的,代码清单 10给出了book-edit.js文件的内容。

清单 10. 创建和更新图书的show方法
function(doc, req) {  
  // !json templates.book.edit 
  // !code vendor/couchapp/path.js 
  // !code vendor/couchapp/template.js 

  return template(templates.book.edit, { 
    doc : doc, 
    docid : toJSON((doc && doc._id) || null), 
    assets : assetPath(), 
    index : listPath('index','recent-posts',{descending:true,limit:8}) 
  }); 
 }

代码清单 10中,!json!code都是由 CouchApp 提供的宏声明,用来包含外部文件。!json用来包含设计文件中的 JSON 对象,后面接着的是 JSON 对象在设计文档中的路径。如!json templates.book.edit会把设计文档中templates字段的book字段的edit字段的内容包含到当前的show方法中,并作为变量templates.book.edit的值。!code用来包含一个 JavaScript 文件,后面接着的是 JavaScript 文件相对于根目录的路径。如!code vendor/couchapp/template.js会把template.js文件包含进来。template是包含在template.js文件中的一个方法,用来完成 HTML 模板内容的替换,它的第一个参数是 HTML 模板字符串,第二个参数是包含模板中<%= %>占位符实际值的 JSON 对象。变量templates.book.edit的值是templates目录下子目录bookedit.html文件的内容。该文件的主体内容如代码清单 11所示,完整代码见下载assetPathlistPath是由 CouchApp 提供的帮助方法,用来生成所需的路径,可以在vendor/couchapp/path.js文件找到这些方法的定义。

清单 11. edit.html 文件的内容
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> 
  <head> ...... </head> 
  <body> 
     <div id="content"> 
        <form id="new-book" action="new.html" method="POST"> 
            <h1> 添加新的图书 </h1> 
            <fieldset> 
               <p> 
                  <label for="title"> 标题 </label> 
                  <input type="text" size="40" name="title" id="title"> 
               </p> 
               <p> 
                  <label for="author"> 作者 </label> 
                  <input type="text" size="20" name="author" id="author"> 
               </p> 
               ......   
            </fieldset> 
            <p> 
                 <input type="submit" value=" 保存 " id="save"> 
                 <span id="message" style="display:none"></span> 
            </p> 
        </form> 
     </div> 
  </body> 
  <script src="/_utils/script/json2.js"></script> 
  <script src="/_utils/script/jquery.js?1.2.6"></script> 
  <script src="/_utils/script/jquery.couch.js?0.8.0"></script> 
  <script src="/_utils/script/jquery.cookies.js"></script> 
  <script src="<%= assets %>/dianping.js"></script> 
  <script type="text/javascript"> 
     $(function() { 
        var dbname = document.location.href.split('/')[3]; 
        var dname = unescape(document.location.href).split('/')[5]; 
        var db = $.couch.db(dbname); 
        var localDoc = {}; 
        var bookFields = ["title", "author", "press", "publish_date", "price", 
		 "tags", "thumbnail", "summary"];  
        $("form#new-book").submit(function(e) { 
          e.preventDefault(); 
          $.dianping.bookFormToDoc("form#new-book", bookFields, localDoc); 
          if (localDoc.tags) { 
             localDoc.tags = localDoc.tags.split(","); 
             for(var idx in localDoc.tags) { 
                localDoc.tags[idx] = $.trim(localDoc.tags[idx]); 
             } 
          } 
          db.saveDoc(localDoc, { 
             success : function(resp) { 
               $("#message").text(" 保存成功! ").fadeIn(500).fadeOut(3000); 
             }, 
             error : function(status, error, reason) { 
               $("#message").text(" 保存失败,原因是:" + reason); 
             } 
          }); 
        }); 

        var docId = <%= docid %>; 
        if (docId) { 
           db.openDoc(docId, { 
             success : function(doc) { 
               $("h1").html(" 编辑图书信息 - " + doc.title); 
               localDoc = doc; 
               $.dianping.docToBookForm("form#new-book", doc, bookFields); 
             } 
           }); 
        } 
     }); 
  </script> 
 </html>

代码清单 11中,edit.html的主体是一个 HTML 表单,用来输入图书的相关信息。如果在调用此show方法的时候传入了文档 ID 作为参数的话,会通过db.openDoc方法获取文档的内容,并填充表单。在表单提交的时候,首先把表单中用户输入的值变成 JSON 对象,再通过db.saveDoc方法保存文档。

修改文档结构

熟悉关系数据库的开发者可能都有过类似的经历,那就是要修改一个关系数据库的表结构是一件比较困难的事情,尤其当应用中已经有一定量的数据的时候。而 CouchDB 中保存的文档是没有结构的,因此当需要根据应用的需求做修改的时候,比关系数据库要简单。在本文的示例应用中,一开始并没有考虑为图书添加封面的缩略图。如果要增加这样的功能,只需要在创建文档的表单中添加一项,用来让用户输入缩略图的链接即可。之后再创建的文档就会自动添加该字段。

删除文档

删除文档只需要调用表 1中列出的$.couch.db(dbname).removeDoc(doc, options)方法即可。

视图

视图是 CouchDB 中用来查询和呈现文档的。完成视图的定义之后,视图的运行由专门的视图服务器来完成。 CouchDB 中默认的视图定义语言是 JavaScript 。 CouchDB 中的视图运行使用的是 MapReduce 编程模型(见参考资料)。每个视图的定义中至少需要提供 Map 方法,Reduce 方法是可选的。

视图的 Map 与 Reduce

Map 方法的参数只有一个,就是当前的文档对象。 Map 方法的实现需要根据文档对象的内容,确定是否要输出结果。如果需要输出的话,可以通过emit来完成。emit方法有两个参数,分别是keyvalue,分别表示输出结果的键和值。使用什么样的键和值应该根据视图的实际需要来确定。当希望对文档的某个字段进行排序和过滤操作的时候,应该把该字段作为键(key)或是键的一部分;value的值可以提供给 Reduce 方法使用,也可能会出现在最终的结果中。可以作为键的不仅是简单数据类型,也可以是任意的 JSON 对象。比如emit([doc.title, doc.price], doc)中,使用数组作为键。

通过 Map 方法输出的结果称为中间结果。中间结果可以通过 Reduce 方法来进一步做聚集操作。聚集操作是对结果中键(key)相同的数据集合来进行的。 Reduce 方法的输入不仅是 Map 方法输出的中间结果,也可以是上一次 Reduce 方法的结果,后面这种情况称为 rereduce 。 Reduce 方法的参数有三个:keyvaluesrereduce,分别表示键、值和是否是 rereduce 。由于 rereduce 情况的存在,Reduce 方法一般需要处理两种情况:

  • 传入的参数rereduce的值为false:这表明 Reduce 方法的输入是 Map 方法输出的中间结果。参数key的值是一个数组,对应于中间结果中的每条记录。该数组的每个元素都是一个包含两个元素的数组,第一个元素是在 Map 方法中通过emit输出的键(key),第二个元素是记录所在的文档 ID 。参数values的值是一个数组,对应于 Map 方法中通过emit输出的值(value)。
  • 传入的参数rereduce的值为true:这表明 Reduce 方法的输入是上次 Reduce 方法的输出。参数key的值为null。参数values的值是一个数组,对应于上次 Reduce 方法的输出结果。

下面通过一个实例来说明视图 Map 和 Reduce 的用法。该视图要解决的问题是对图书简介中出现的字符进行计数,这也是一个经典的 MapReduce 编程模型的实例。代码清单 12中给出了该视图的定义。

清单 12. 对图书简介中的字符计数的视图定义
//Map 方法
 function(doc) { 
  if(doc.type == 'book' && doc.summary) { 
    var words = Array.prototype.slice.apply(doc.summary); 
    for (var i = 0; i < words.length; i++) { 
      emit(words[i], 1); 
    } 
  } 
 } 
 //Reduce 方法
 function(key, values) { 
  return sum(values); 
 }

该视图定义的基本思路是对于每本图书的简介,把其中包含的每个字符都作为键输出,而对应的值是 1,表明是一次计数。在介绍视图 REST API的时候提过,只需要发送 HTTP GET 请求就可以获得视图的运行结果。代码清单 12中视图的名字是word-count,因此只需要发送 GET 请求到http://127.0.0.1:5984/dianping/_design/dianping/_view/word-count就可以获得如代码清单 13所示的运行结果。

清单 13.word-count视图的运行结果
{ 
  "rows":[ 
    {"key":null,"value":439} 
  ] 
 }

代码清单 13中可以看到,视图的运行结果只有一行,value的值 439 是 Reduce 方法的最终运行结果,表示全部图书简介中共包含 439 个字符。默认情况下,Reduce 方法会把 Map 方法输出的记录变成一行。不过这里需要统计的是每个字符的出现次数,应该需要对字符进行分组来计数。通过在请求中添加参数group=true可以让 Reduce 方法按照 Map 方法输出的键进行分组,得到的部分运行结果如代码清单 14所示。

清单 14. 添加参数group=true之后word-count视图的运行结果
{ 
  "rows":[ 
    {"key":"\u4ea7","value":1}, 
    {"key":"\u4eba","value":6}, 
    {"key":"\u4ec0","value":1}, 
    {"key":"\u4ee5","value":1}, 
    {"key":"\u4eec","value":4}, 
    {"key":"\u4f1a","value":1}, 
    {"key":"\u4f24","value":1}, 
    {"key":"\u4f46","value":1}, 
    {"key":"\u97f3","value":2}, 
    {"key":"\u9996","value":1} 
  ] 
 }

代码清单 14中,rows 数组中的每个元素表示一条记录,其中 key 是由 emit 方法输出的键,而 value 则是 emit 方法输出的值经过 Reduce 方法(如果有的话)得到的结果。由于指定了参数 group=true,相同的字符被分在一组并计数。

在获取视图运行结果的时候可以添加额外的参数,具体如表 4 所示。

表 4. 运行视图时的可选参数
参数说明
key限定结果中只包含键为该参数值的记录。
startkey限定结果中只包含键大于或等于该参数值的记录。
endkey限定结果中只包含键小于或等于该参数值的记录。
limit限定结果中包含的记录的数目。
descending指定结果中记录是否按照降序排列。
skip指定结果中需要跳过的记录数目。
group指定是否对键进行分组。
reduce指定reduce=false可以只返回 Map 方法的运行结果。

视图定义说明

视图定义是存放在设计文档中views字段中的,因此需要在 Web 应用根目录下新建一个 views 目录,该目录下的每个子目录都表示一个视图。每个子目录下至少需要有 map.js 文件提供 Map 方法,可以有 reduce.js 文件提供 Reduce 方法。下面通过几个具体的视图定义来解释视图的用法。

第一个例子是对应用中的标签(Tag)进行统计。每本图书都可以有多个用户自定义的标签,一个常见的需求是统计每个标签的使用次数,并生成标签云(Tag Cloud)方便用户浏览。该视图定义的 Map 和 Reduce 方法见代码清单 15

清单 15. 标签统计的视图定义
//Map 方法
 function(doc) { 
  if(doc.tags && doc.tags.length) { 
        for(var index in doc.tags) { 
            emit(doc.tags[index], 1); 
        } 
  } 
 } 

 //Reduce 方法
 function(key, values) { 
    return sum(values); 
 }

代码清单 15中,Map 方法首先判断文档是否包含标签,然后对于某个标签,输出标签作为键,计数值 1 作为值;而在 Reduce 方法中,将计数值累加。该视图定义与代码清单 12word-count 视图定义类似。

第二个视图是根据标签来浏览图书,也就是说给定一个标签,列出包含该标签的图书。由于需要根据标签进行查询,因此把标签作为键,而对应的值则是图书文档。通过使用参数key=" 原创 "就可以查询包含标签“原创”的图书。该视图定义只包含 Map 方法,如代码清单 16 所示。

清单 16. 根据标签浏览图书的视图定义
//Map 方法
 function(doc) { 
  if(doc.type == 'book' && doc.tags && doc.tags.length) { 
        for(var index in doc.tags) { 
            emit(doc.tags[index], doc); 
        } 
  } 
 }

最后一个视图是用来查询每本图书对应的用户评论。该视图只有 Map 方法,其实现是对于用户评论,以其关联的图书文档 ID 和评论的创建时间作为键,输出文档的内容作为值。在使用该视图的时候需要添加参数 startkey=[docId]endkey=[docId, {}]来限定只返回 ID 为 docId 的图书的用户评论。具体的视图定义如代码清单 17 所示。

清单 17. 查询图书评论的视图定义
function(doc) { 
  if (doc.type == "comment") { 
    emit([doc.book_id, doc.created_at], doc); 
  }  
 };

使用list方法呈现视图

show方法对应,list方法用来把视图转换成非 JSON 格式。list方法保存在设计文档的lists字段中。代码清单 18 中给出了list方法在设计文档中的示例。

清单 18. 设计文档中的 lists 字段
{ 
 "_id" : "_design/dianping", 
 "views" { 
  "book-by-tag" : "function(doc){...}" 
 }, 
 "lists" : { 
  "browse-book-by-tag" : "function(head, row, req, row_info) { ... }" 
 }

代码清单 18 中的设计文档中定义了视图book-by-tag和 list 方法browse-book。通过 GET 请求访问/databasename/_design/dianping/_list/browse-book/book-by-tag可以获取用browse-book格式化视图book-by-tag的结果。由于视图的运行结果包含多行数据,list 方法需要迭代每行数据并分别进行格式化,因此对于一个视图的运行结果,list 方法会被多次调用。 list 方法的调用过程是迭代之前调用一次,对结果中的每行数据都调用一次,最后在迭代之后再调用一次。比如,假设结果中包含 10 条记录的话,list 方法会被调用1 + 10 + 1 = 12次。每个 list 方法都可以有四个参数:headrowreqrow_info。根据调用情况的不同,这四个参数的实际值也不同。具体如下面所示。

  • 在迭代之前的调用中,head的值非空,包含与视图相关的信息,其中有两个字段:total_rows表示视图结果的总行数,offset表示当前结果中第一条记录在整个结果集中的起始位置,可以用来对视图结果进行分页。
  • 在对每行数据的调用中,rowrow_info的值非空:row的值为视图运行结果中的当前行,对应于代码清单 14中所示的rows数组中的一个元素。row_info包含与迭代状态相关的信息,包括row_number表示当前的行号,first_key表示结果中第一条记录的键,prev_key表示前一行的键。
  • 在迭代之后的调用中,headrow的值均为空。在所有的调用中,req都包含了与此次请求相关的信息,其内容与 show 方法的第二个参数req相同,如表 2所示。

在 list 方法的实现中,需要根据这四个参数的值来确定当前的迭代状态,并输出对应的结果。下面通过一个实例来说明 list 方法的使用。

该 list 方法用来列出应用中的全部图书的概要信息。首先需要定义一个视图recent-books,该视图用来查询全部图书的概要信息,其定义如代码清单 19 所示。

清单 19.recent-books视图定义
function(doc) { 
  if (doc.type == "book") { 
    emit(null, { 
      title : doc.title, 
      author : doc.author, 
      price : doc.price, 
      publish_date : doc.publish_date 
      press : doc.press 
    });     
  } 
 };

代码清单 19 中可以看到,doc.type == "book"确定了只有图书才会出现在视图中,并且视图中的结果只包含图书的基本信息。在定义了视图之后,下面需要定义 list 方法。代码清单 20 中给出了 list 方法的定义。

清单 20. list 方法的定义
function(head, row, req, row_info) { 
  // !json templates.index 
  // !code vendor/couchapp/path.js 
  // !code vendor/couchapp/date.js 
  // !code vendor/couchapp/template.js 
  if (head) { 
    return template(templates.index.head, { 
      assets : assetPath(), 
      edit : showPath("book-edit"), 
      index : listPath('index','recent-books',{limit:10}), 
      total_books : head.total_rows 
    }); 
  } 
  else if (row) { 
    var book = row.value; 
    return template(templates.index.row, { 
      title : book.title, 
      author : book.author, 
      price : book.price, 
      publish_date : book.publish_date, 
      press : book.press, 
      link : showPath("book-view", row.id), 
      assets : assetPath() 
    }); 
  } 
  else { 
    return template(templates.index.tail, { 
      assets : assetPath() 
    }); 
  } 
 };

代码清单 20 中可以看到如何根据 headrow 的值来判断当前的迭代状态。首先 head 不为空,这是迭代之前的状态,应该输出整个 HTML 文档的头部;接着row不为空,这是对视图运行结果的每行进行迭代,应该输出代表每行结果的 HTML 片断,如代码清单 21 所示;最后是迭代之后,应该输出整个 HTML 文档的尾部。所有这些调用的结果会被组合起来,形成一个完整的 HTML 文档,返回给客户端。

清单 21. 显示每行记录的 HTML 片断
<table width="100%" class="book"> 
   <tbody> 
      <tr> 
         <td width="100px" valign="top"></td> 
         <td valign="top"> 
            <div class="title"> 
              <a href="<%= link %>"><%= title %></a> 
            </div> 
            <p><%= author %> / <%= publish_date %> / <%= press %> / <%= price %></p> 
         </td> 
      </tr> 
   </tbody> 
 </table>

在 list 方法输出的 HTML 文档中,同样可以添加 JavaScript 代码使用 CouchDB jQuery 插件来进行数据库操作,其做法类似于 show 方法。

至此,关于使用 CouchDB 开发 Web 应用的主要方面已经介绍完毕。下面介绍一些高级话题。

高级话题

权限控制与安全

CouchDB 目前只支持一种角色,即“系统管理员”。“系统管理员”可以执行任意的 HTTP REST API 对数据库进行任意的修改。可以在 CouchDB 的配置文件中添加系统管理员的帐号和密码。 CouchDB 也自带对 HTTP 基本认证的支持,同样可以在配置文件中启用这一认证方式。

由于目前 CouchDB 对于权限控制功能比较弱,一种比较好的做法是用 Apache HTTP 服务器作为 CouchDB 的反向代理,由 Apache HTTP 服务器来处理访问控制。关于配置 Apache HTTP 服务器,见参考资料

文档更新校验

CouchDB 允许文档在创建和更新之前先进行校验。只有校验通过的文档才能被保存在数据库中。校验方法是由设计文档中的validate_doc_update字段来表示的。所有的文档更新都会调用该方法,如果该方法抛出异常,则说明校验失败,CouchDB 会返回异常中的错误信息给客户端。

validate_doc_update的示例如代码清单 22 所示。该方法可以接受 3 个参数:newDocoldDocuserCtx,其中 newDoc 表示待创建或更新的文档对象,oldDoc表示数据库中已有的文档对象,userCtx 则是一个包含 dbnameroles 三个属性的 JSON 对象,分别表示数据库名称、用户名和用户所属角色的数组。

清单 22.validate_doc_update方法示例
function(newDoc, oldDoc, userCtx) { 
  if(newDoc.type == "book") { 
    if(newDoc.title === undefined) { 
      throw {required_field_is_missing : "Book must have a title."}; 
    } 
    else if (newDoc.author === undefined) { 
      throw {required_field_is_missing : "Book must have an author."}; 
    } 
  } 
 }

代码清单 22中,validate_doc_update方法限定了图书必须包含标题和作者。

分组

在前面提到过,可以通过group参数在进行 Reduce 的时候对键进行分组。默认情况下,该参数的值为false,Reduce 方法会将结果变成一条记录。如果指定了group参数的值为true,则 Map 方法输出的所有记录中,键相同的记录将被分在一个组中。 Reduce 方法会把每个组都变成一条记录,也就是说得到一个单一的值做为结果。

还可以通过 group_level 参数来对分组的级别进行更细的限定。代码清单 23 中给出了 Map 方法输出的一些键。

清单 23. Map 方法输出的键
["Alex", "2009.08", 3] 
 ["Alex", "2009.08", 4] 
 ["Bob", "2009.02", 10] 
 ["John", "2009.01", 8] 
 ["Bob", "2009.03", 5]

代码清单 23 中,Map 方法输出的键是一个数组,其中三个元素分别表示用户名、购买时间和购买数量。如果指定 group_level=1 的话,则会根据键的第一个元素进行分组,也就是说结果中包含三条记录。如果指定 group_level=2 的话,则会根据键的前两个元素进行分组,也就是说前两个元素相同的键作为一组,结果中应该包含四条记录。

键的排序

在运行视图的时候,CouchDB 总是会对键进行排序。 CouchDB 允许使用任意复杂的 JSON 对象来作为键,而键的排序顺序与键的数据类型有关。下面根据键的类型,给出了基本的排序顺序。

  • 特殊类型:nullfalsetrue
  • 数字:按照数字大小排序。
  • 字符串:按照字典顺序。长字符串在短字符串之后,大写字母在小写字母之后。
  • 数组:按照长度和对应元素排序。
  • JSON 对象:按照属性的名称和值排序。

将这个排序规则与 startkeyendkey两个参数结合,可以非常灵活的限定视图运行结果中所包含的键的范围。比如键的类型是表示标签的字符串,想查找所有以“ web ”开头的标签,就可以使用 startkey="web"endkey="web\u9999" 来限定。

声明

本人所发表的内容仅为个人观点,不代表 IBM 公司立场、战略和观点。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Open source, Web development
ArticleID=418644
ArticleTitle=面向文档的数据库 CouchDB
publish-date=08062009