Apache CouchDB 基于 Erlang 的、面向文档的开源数据库。CouchDB 是无模式的,原因是每个文档都独立存在,(除一个标识符和一个修订外)不需要任何特定字段。所有操作 — 从查询数据库到创建或更改数据库中的数据 — 都通过一个基于 REST 的 API 执行。对许多应用程序而言,CouchDB 可能是一个很不错的关系数据库替代品,特别涉及缺少结构的数据的应用程序。本文涵盖了使用 Clojure 执行基本 CouchDB 操作,通过视图查询以及数据库复制。本文也提供了一些代码示例,从两个层面展示如何从 Clojure 访问 REST API:在高级层面上使用 Clutch API,在低级层面上使用一个更基础的 HTTP 库 — clj-http。
本文的示例代码针对 CouchDB 1.0.1、Clojure 1.2.0、Clutch 0.2.4 和 clj-http 0.1.2 编写。Leiningen 构建工具用于下载并设置示例代码的依赖项。这些示例是从 Clojure REPL 编码的角度进行编写的。
开始之前,确保 CouchDB 已安装(参见 参考资料 了解安装信息);有一些预打包的二进制文件可用于许多操作系统,您需要的可能已经默认包含在内。要设置您的环境以运行代码,应首先安装 Leiningen(参见 参考资料 获取下载链接)。然后使用 lein new couchdb-from-clojure 语句创建一个新的 Leiningen 项目。将 Clutch 和 clj-http 添加到 project.clj 文件,以便它看起来如清单 1 所示:
清单 1. CouchDB 和 Clojure project.clj
(defproject couchdb-with-clojure "1.0.0-SNAPSHOT"
:description "CouchDB from Clojure Examples"
:dependencies [[org.clojure/clojure "1.2.0"]
[org.clojure/clojure-contrib "1.2.0"]
[com.ashafa/clutch "0.2.4"]
[clj-http "0.1.2"]])
|
下面,运行 lein deps 下载所需的 JAR 文件。您可以在您喜欢的任何环境中从一个 REPL 运行代码。可以通过 lein repl 命令从 Leiningen 启动一个 REPL,或者从您选择的 IDE 启动。从 REPL 提示,键入清单 2 中的 REPL 会话中显示的语句,以包含本文使用的名称空间:
清单 2. 必要的 clj-http、contrib.json 和 Clutch
user> (require ['com.ashafa.clutch :as 'clutch]) nil user> (require ['clj-http.client :as 'client]) nil user> (require ['clojure.contrib.json :as 'json]) nil user> (def movies-db "http://localhost:5984/movies") #'user/movies-db |
清单 2 中最后一个语句定义您将用于访问本文示例使用的 CouchDB 数据库的 URL。本地安装的 CouchDB 的默认 URL 是 http://localhost:5984。如果您的副本配置不同,可以根据需要在您的 REPL 会话中更改端口号。
CouchDB 中的数据以一种独立存在的 JavaScript Object Notation (JSON) 文档形式结构化 — 这是 CouchDB 与关系数据库的一个重大区别。我们来看一个电影数据库。要对一个关系数据库中的电影建模,您可能需要一个表来存储特定于电影的信息,比如标题和发行日期。电影还有演员、导演、制片人等,但您可能不愿意将那些信息存储在电影表中。相反,您可能会创建一个演员表或更一般的表(比如一个电影参与人表),然后让一个引用从电影表指向演员表中的一行。即使是这种结构也可能太简单。您可能需要使用一个联接表设置一个多对多关系,这需要几个联接来确定某个电影的演员。这个过程称为规范化(normalization),重组数据以限制冗余。关系数据库经过调优,以这种方式处理数据。
在一个 CouchDB 电影数据库中,某个电影的所有信息将包含在单个文档中。与更规范的结构相比,这可能会导致一些重复。例如,某位演员的姓名可能会出现在该演员参演的每部电影的文档中。清单 3 展示了存储在 CouchDB 中或从 CouchDB 发出的一个电影文档示例:
清单 3. 存储电影数据的 JSON 文档示例
{"movie-title":"Psycho",
"director":"Alfred Hitchcock",
"runtime":109,
"year-released":1960,
"studio":"Shamley Productions"
"actors":["Anthony Perkins" "Vera Miles" "John Gavin" "Janet Leigh"]}
|
JSON 格式在不同对象、数组和文本之间的格式也不同。花括号 {} 表示对象,方括号 [] 表示数组。文本是指 "Psycho" 这样的字符串和 1960 这样的整数。这种格式化方式完全适用于 Clojure 持久性数据结构,后者的花括号用于映射,方括号用于矢量;文本也有相同的语法。表 1 展示了一些 JSON 数据类型示例及其对等的 Clojure 数据类型:
表 1. 对等的 JSON 和 Clojure 数据类型
| 数据类型 | JSON 示例 | Clojure 示例 | 说明 |
|---|---|---|---|
| Number | 1 | 1 | 此类型用于表示整数和真实数字 |
| String | "Example String" | "Example String" | 此类型用于表示字符串 |
| Boolean | true/false | true/false | 布尔类型 |
| Array | [1, 2, 3, 4] | [1 2 3 4] | JSON 数组;Clojure 矢量 |
| Object | {"key1" : "value1",
"key2" : "value2"} | {:key1 "value1"
:key2 "value2"}
| JSON 对象;Clojure 映射 |
清单 4 是一个(来自 CouchDB 的)JSON 文档,后面是一个对等的 Clojure 映射表示:
清单 4. JSON 对象和 Clojure 映射比较
;;JSON object for Psycho
{"_id":"Psycho"
"Director":"Alfred Hitchcock",
"runtime":109,
"year-released":1960,
"studio":"Shamley Productions"}
;;Clojure map for Psycho
{:_id "Psycho"
:director "Alfred Hitchcock"
:runtime 109
:year-released 1960
:studio "Shamley Productions"}
|
clojure.contrib.json 库支持在 Clojure 中轻松使用 JSON。这个库支持在 Clojure 数据结构和 JSON 字符串之间转换。它允许将 JSON 对象映射项中的键转换为 Clojure 关键字,后者在 Clojure 中更具惯用语特征。稍后将展示的基于 HTTP 的示例使用 clojure.contrib.json。Clutch API 在后台使用这个库,您无需再考虑 JSON 规范。
假设您想从 CouchDB 查询电影 Psycho 的所有相关信息。为此,您需要保存一个如 清单 3 所示的文档。CouchDB 能容纳大量数据库,因此第一步是创建一个数据库,然后添加此文档。清单 4 在 movies-db URL 创建一个新数据库,然后创建一个文档:
清单 5. 使用 Clutch 创建一个 CouchDB 文档
user> (clutch/create-database movies-db)
{:ok true...}
user> (clutch/with-db movies-db
(clutch/create-document {:director "Alfred Hitchcock"
:runtime 109
:year-released 1960
:studio "Shamley Productions"}
"Psycho"))
{:_id "Psycho" ... }
|
清单 5 展示了 Clutch 为创建这个 CouchDB 文档在 API 后所执行的操作。注意,传递到 create-document 中的最后一个参数是文档 ID。
清单 6 展示了 clj-http 中的对等代码:
清单 6. 使用 clj-http 创建一个 CouchDB 文档
user> (client/put movies-db) ;; Create Database
{:status 201 ... :body "{\"ok\":true}\n"}
user> (->> {:director "Alfred Hitchcock"
:runtime 109
:year-released 1960
:studio "Shamley Productions"}
json/json-str
(hash-map :body)
(client/put (str movies-db "/Psycho")))
{:status 201... :body "{\"ok\":true,
\"id\":\"Psycho\",\"rev\":\"1-ba6b110617a1a8920903b648f208a8fac\"}\n"}
|
CouchDB 不允许两次创建同一个数据库。如果您想运行 清单 5 和 清单 6 示例,可以在先使用 (client/delete movies-db) 删除数据库,然后重新创建数据库。
清单 6 使用电影信息创建了一个哈希映射,然后将其从 Clojure 哈希映射转换为一个 JSON 文档(使用 json/json-str)。该文档然后被放置到另一个哈希映射中,clj-http 将那个哈希映射视为请求正文。这个代码最终发送一个 PUT 请求到 CouchDB 以保存文档。注意,用于 PUT 这个文档的 URL 是电影数据库 URL 加上这个 CouchDB 文档的 ID(这里是 Psycho)。
要检查文档是否已经成功持久化,可以以编程方式从 CouchDB 检索文档并检查它。清单 7 展示了如何使用 Clutch 检索文档,Clutch 将文档从 JSON 响应转换为 Clojure 映射:
清单 7. 使用 Clutch 从 CouchDB 检索文档
user> (clutch/with-db movies-db
(clutch/get-document "Psycho"))
{:_id "Psycho",
:_rev "1-a6b110617a1a8920903b648f208a8fac",
:director "Alfred Hitchcock",
:runtime 109,
:year-released 1960,
:studio "Shamley Productions"}
|
清单 8 展示了一个使用 clj-http 的类似示例:
清单 8. 使用 clj-http 从 CouchDB 检索文档
user> (-> (str movies-db "/Psycho")
client/get
:body
json/read-json)
{:_id "Psycho",
:_rev "1-a6b110617a1a8920903b648f208a8fac",
:director "Alfred Hitchcock",
:runtime 109,
:year-released 1960,
:studio "Shamley Productions"}
|
清单 8 中的 clj-http 代码是从 JSON 响应转换而来的,方法是提取响应的正文并在其上调用 json/read-json。
还可以在您的代码外部检查您的工作,方法有几种,一种方法是在浏览器中输入您代码中使用的 REST URL,或者使用 cURL 或其他类似工具。输入此前使用的 GET URL:http://localhost:5984/movies/Psycho。
最简单的方法是使用 CouchDB 的 Futon 应用程序(参见 参考资料)。该程序是 CouchDB 附带的,可以通过 URL http://localhost:5984/_utils 查看。(在这个 URL 中,如果您的 CouchDB 配置不是使用默认端口,则应替换为正确的端口号。)您还可以使用 Futon 来执行文档和视图的创建和复制等操作。
下面我们将深入研究如何向 CouchDB 添加文档。
在 创建 CouchDB 文档 小节中,我们为电影 Psycho 创建了一条记录。为了演示选择一个良好的文档 ID 的重要性,我们看看如何向数据库添加一个新电影。Psycho 于 1998 年被重拍,因此需要将新版本添加到数据库,如清单 9 所示:
清单 9. 添加一个带有冲突 ID 的文档
user> (clutch/with-db movies-db
(clutch/create-document {:director "Gus Van Sant"
:runtime 105
:year-released 1998
:studio "Universal Pictures"}
"Psycho"))
;;409 Conflict
|
清单 9 中的代码导致了错误,因为它试图使用数据库中已经存在的 ID。所有 CouchDB 文档都按 ID 存储,因此每个文档 ID 必须是惟一的。在本例中,电影标题应该是惟一的,但实际情况并非如此。将 ID 基于电影标题是一种容易引起冲突的设置。(在本例中,您遇到了一个单数据库冲突。在分布式环境中,这种情况甚至可能更常见。稍后我们将详细讨论这些复制问题。)因此,您需要重新执行指定文档 ID 的操作。推荐方法是使用某种能够保证惟一性的机制,比如 Universal Unique Identifier (UUID)。使用 Clutch 时,如果没有指定 ID,系统将自动生成。
清单 10 是一个经过重构的新文档,以便满足 ID 惟一性要求:
清单 10. 选择一个更好的电影 ID(Clutch 示例)
user> (clutch/with-db movies-db
(clutch/create-document {:movie-title "Psycho"
:director "Gus Van Sant"
:runtime 105
:year-released 1998
:studio "Universal Pictures"}))
{:_id "d6993381eb5ede34fded2f018b9f10b0",
:_rev "1-29ff788958134c2023d9be94a9231528",
:movie-title "Psycho",
:director "Gus Van Sant",
:runtime 105,
:year-released 1998,
:studio "Universal Pictures"}
|
清单 10 中的文档将原始 ID Psycho 移动到 movie-title,没有留下键对。注意文档中的两个额外字段。一个是 _rev,我们将稍后讨论。另一个是 _id,这是一个自动生成的值,以避免 清单 9 中的问题。注意,您自己的 _id 和 _rev 值应与 清单 10 中的值不同,因为它们是生成的。
清单 11 是类似的 clj-http 代码:
清单 11. 选择一个更好的电影 ID(clj-http 示例)
user> (->> {:movie-title "Psycho"
:director "Gus Van Sant"
:runtime 105
:year-released 1998
:studio "Universal Pictures"}
json/json-str
(hash-map :body)
(client/put (str movies-db "/" (java.util.UUID/randomUUID)))
:body
json/read-json)
{:ok true,
:id "f043a641-045b-4316-83f5-67c8f9bb99c3",
:rev "1-29ff788958134c2023d9be94a9231528"}
|
清单 11 与前面的 clj-http 代码基本相同,一个区别是 ID 的来源。清单 11 使用一个 JVM 创建的 UUID。如果您将 清单 11 中的文档 POST 到 CouchDB,CouchDB 也会自动生成一个 UUID 并使用自己的 UUID 生成策略将其添加到文档。您也可以从 URL http://localhost:5984/_uuids 获取 CouchDB 生成的 UUID。
要验证是否已成功创建文档,可以从数据库检索所有文档 ID。清单 12 使用 Clutch 执行这个操作:
清单 12. 使用 Clutch 获取数据库文档 IDs
user> (clutch/with-db movies-db
(->> (clutch/get-all-documents-meta)
:rows
(map :id)))
("d6993381eb5ede34fded2f018b9f10b0" "Psycho")
|
清单 13 使用 clj-http 检索文档 IDs:
清单 13. 使用 clj-http 获取文档 IDs
user> (->> (str movies-db "/_all_docs"
client/get
:body
json/read-json
:rows
(map :id))
("d6993381eb5ede34fded2f018b9f10b0" "Psycho")
|
清单 12 和 清单 13 中的调用向 CouchDB 请求电影数据库中的所有文档元数据,返回一个自动生成的 ID 和另一个名为 Psycho 的 ID。为实现一致性,清单 14 删除了 Psycho 文档并使用一个生成的 ID 将其返回:
清单 14. 删除带有旧键的文档并重新添加
(clutch/with-db movies-db
(let [original-psycho (clutch/get-document "Psycho")]
(clutch/delete-document original-psycho)
(-> original-psycho
(assoc :movie-title (:_id original-psycho))
(dissoc :_id)
clutch/create-document)))
{:_id "84bbfce1b0e4cf6c9aa2f4196909f39d", :movie-title "Psycho"...}
|
清单 14 检索当前 Psycho 文档,在 CouchDB 中删除它,然后通过添加一个带有当前 ID 值的新 movie-title 键并从映射移除旧 ID 来重新创建它。旧 ID 必须被移除,否则,Clutch 将使用该 ID 创建一个文档。
更新文档和插入文档类似,只有一个微小的区别。创建文档时,将自动向文档提供一个修订。清单 15 显示了新创建的电影的输出:
清单 15. 带有修订的示例文档
user> (clutch/with-db movies-db
(clutch/create-document {:movie-title "Rear Window"
:director "Alfred Hitchcock",
:runtime 112,
:year-released 1955,
:studio "Paramount Pictures"}))
{:_id "1f91c6a2e1af23fa89ca640e889bbdb6",
:_rev "1-43386b891e9ad538de0d16fcb66aff5e",
:movie-title "Rear Window"...}
|
修订是 清单 15 中的 _rev 映射条目。这个修订实际上是文档的 MD5 哈希表,由 CouchDB 自动添加。每当文档更改时,这个哈希表也会更改。更新 CouchDB 文档时,总是需要这个修订,以便 CounchDB 知道您的更改正在更新哪个文档版本。清单 16 获取 Rear Window 电影文档,进行一个修改,然后更新该文档,向电影添加一个替代标题:
清单 16. 更新文档
user> (clutch/with-db movies-db
(-> (clutch/get-document "1f91c6a2e1af23fa89ca640e889bbdb6")
(clutch/update-document {:alternate-titles ["La ventana indiscreta"]})))
=> {:alternate-titles ["La ventana indiscreta"]
:_id "1f91c6a2e1af23fa89ca640e889bbdb6",
:_rev "2-6601a377a55d733c0bd111539801edc8",
:movie-title "Rear Window"...}
|
注意,清单 16 按照 UUID 查询文档,因此检查 清单 12(或 清单 13)获取您自己的 UUID,以便在 清单 16 中使用它。
清单 16 中的 update-document 调用传入两个参数:第一个是原始文档;第二个是一个哈希映射,在更新文档存储在 CouchDB 中之前,会把该哈希映射和原始文档一起 merge 进来。update-document 功能实际是一个多重方法 (multimethod),映射了许多操作映射的标准 Clojure 方法,比如合并 “键/值” 对和更新 update-in 这样的嵌套结构。它还接受单一映射这样的参数,该映射已经进行了必要的更改(但保留修订和 ID 完整)。
清单 16 的方法从并发性的角度来看比较乐观。现在考虑清单 17 中的代码:
清单 17. 使用一个冲突更新
user> (clutch/with-db movies-db
(let [client1-rw (clutch/get-document
"1f91c6a2e1af23fa89ca640e889bbdb6")
client2-rw (clutch/get-document
"1f91c6a2e1af23fa89ca640e889bbdb6")]
(clutch/update-document client1-rw
#(conj % "Fenêtre sur cour")
[:alternate-titles])
(clutch/update-document client2-rw
#(conj % "Arka pencere")
[:alternate-titles])))
;; 409 Conflict Error
|
这里的文档被检索两次,第一次更新没有问题。第二次更新失败了,错误码为 409 — 一个 HTTP Conflict 错误码,应用程序使用此错误码转给调用者,说明由于与资源的当前状态发生冲突,操作无法完成。当文档被检索到时,它们拥有正确的修订 ID,因此第一次更新成功。第二次更新失败的原因是一个新的文档版本现在就绪,而第二个更新者看不到它。如果您的修订编号过期,CouchDB 将不允许您更新文档。那么,第二个更新者能做什么呢?遗憾的是,答案取决于您的目标。减少这种错误的可能性的一个方法是总是在更新前立即检索文档。如果如清单 18 所示修改 清单 17 中的代码,更新将起作用:
清单 18. 不冲突的两个客户机更新
(clutch/with-db movies-db
(-> (clutch/get-document "1f91c6a2e1af23fa89ca640e889bbdb6")
(clutch/update-document #(conj % "Fenêtre sur cour")
[:alternate-titles]))
(-> (clutch/get-document "1f91c6a2e1af23fa89ca640e889bbdb6")
(clutch/update-document #(conj % "Arka pencere")
[:alternate-titles])))
{:movie-title "Rear Window",
:alternate-titles ["La ventana indiscreta"
"Fenêtre sur cour"
"Arka pencere"]
...}
|
即使这种方法解决了当前问题,这种问题仍然会出现。您需要编写代码来处理这种情况。根据您的需求,解决方案可能很简单,只需检索文档并重新合并新版本。另一种可能性是向用户返回一个错误(例如,如果用户正在购买一个不再销售的商品)。
关于更新文档需要指出的最后一点是:CouchDB 没有文档部分 更改的概念,只能指出文档的确发生了更改。这是因为任何文档更改都会导致一个新的文档哈希表(这是 CouchDB 用于创建修订 ID 的东西)。纯粹的添加型更改、删除部分文档和修改文档都会得到相同的待遇并生成新的修订。这在复制数据库时很重要。
与关系数据库不同,CouchDB 不通过 SQL 查询。检索数据的主要方法是通过称为视图 的 MapReduce 式代码。您可以选择多种语言编写视图。(默认语言是 JavaScript。下面的示例也适用 JavaScript,但特定的 MapReduce 代码不同。)
对于本文,您将通过 Clojure 视图服务器将 Clojure 用作视图语言,Clojure 视图服务器与 Clutch 一起提供。要使用视图服务器,您需要将其安装在您的 CouchDB 副本中(参见 参考资料 中的链接,链接到 Clutch 网站上的安装信息)。注意,视图服务器是服务器、而不是您的客户机代码的一个附件。
首先,我们创建并运行一个视图,然后详细介绍它。首先,为了数据库中有更多可供查询的文档,需要再添加几个文档,如清单 19 所示:
清单 19. 使用 Clutch 批量添加文档
user> (clutch/with-db movies-db
(clutch/bulk-update
[{:movie-title "The Godfather"
:director "Francis Ford Coppola"
:runtime 175
:year-released 1972
:studio "Paramount"}
{:movie-title "The Godfather II"
:director "Francis Ford Coppola"
:runtime 200
:year-released 1974
:studio "Paramount"}
{:movie-title "The Godfather III"
:director "Francis Ford Coppola"
:runtime 162
:year-released 1990
:studio "Paramount"}]))
|
清单 19 使用 CouchDB 的批量更新特性。批量更新特性适用新创建的文档和多个现有文档。
清单 20 中的代码查询所有文档,以创建一个用于显示数据库中的所有电影的放映时间的临时视图:
清单 20. 临时视图示例
user> (clutch/with-db movies-db
(clutch/ad-hoc-view
(clutch/with-clj-view-server
{:map (fn [doc] (when (and (:movie-title doc)
(:runtime doc))
[[(:movie-title doc)
(:runtime doc)]]))})))
{:total_rows 6,
:rows [{:id "d6993381eb5ede34fded2f018b9f10b0",
:key "Psycho",
:value 105}
{:id "84bbfce1b0e4cf6c9aa2f4196909f39d",
:key "Psycho",
:value 109}
...]}
|
对于每部电影,将返回电影标题及其关联放映时间。注意,我们还检查了 movie-title 和 runtime 是否存在。这样做的原因是这段代码将在每个文档上运行,包括新创建的文档。CouchDB 不使用任何预定义架构,因此所有文档不需要包含相同的字段。但要查询的字段在所有文档中都不存在并不是不可能,因此保护您的视图、预防这种可能性是一个好主意。
清单 20 中的函数返回矢量的矢量,这是因为每个文档都可能生成零到多个映射条目,而每个映射条目都被表示为一个矢量。内部矢量中的第一项是键(本例中为 movie-title),第二项是值(本例中是一个表示放映时间的整数)。
这个视图的输出类似于已创建的文档。在本例中,视图输出的是电影名称和放映时间值,而不是一个映射,但在概念上二者是相同的。如果必要,电影的值可以是映射。还要注意,即使输出类似于文档,但并不适用相同的惟一性要求。使用视图时,作为 key(内部矢量中的第一个条目)发出的东西都在内部与那个 key 源自的文档的 ID 配对。您可以在 清单 20 的视图输出中看到那个 ID。这个输出正是您所期待的,显示数据库中的电影的所有放映时间。
我刚才展示的运行视图的方法有几个问题。首先,它是临时的,旨在用于开发。该方法将在每次执行时重新检查数据库中的每个文档,即使是自从该方法上次运行以来那些文档一直没有更改。所谓 “重新检查每个文档”,是指每个文档都被传递到函数中,且结果被添加到输出映射。其次,您被限制为只能通过您的 Clojure 代码运行查询。
要修复上述问题,可以持久化视图,如清单 21 所示:
清单 21. 通过 Clutch 存储 CouchDB 视图
user> (clutch/with-db movies-db
(clutch/save-view "movies" "runtimes"
(clutch/with-clj-view-server
{:map (fn [doc] (when (and (:movie-title doc)
(:runtime doc))
[[(:movie-title doc)
(:runtime doc)]]))})))
{:_id "_design/movies",
:language "clojure",
:views {"runtimes" ...}}
user> (clutch/with-db movies-db
(clutch/get-view "movies" "runtimes"))
{:total_rows 6,
:rows [{:id "d6993381eb5ede34fded2f018b9f10b0",
:key "Psycho",
:value 105}
{:id "84bbfce1b0e4cf6c9aa2f4196909f39d",
:key "Psycho",
:value 109}
...]}
|
通过持久化视图,您保存了第一次运行查询的结果(仅在文档更改时更新)。您向将(从 Clojure、web 浏览器、另一种语言等)执行查询的所有用户提供这个视图。清单 21 中的代码返回的结果与 清单 20 相同,但在缓存方面更加智能,可以通过其他语言、Futon 或其他浏览器重用。
与手动查询数据相比,使用视图具有明显的性能优势。在本文前面部分,您只是通过文档键查询数据库,通过获取一个文档列表来查找键或(当数据库以电影标题为键时)预先了解键。如果您知道正在查找的特定文档的 ID,查询将快速执行;反之(通常是这种情况),查询速度就比较慢。电影数据库只包含几个文档,因此即使是临时视图也能快速返回。在包含数万或数十万文档的数据库中,在每个文档上运行 map 函数非常耗费时间。因此,存储结果对于它们发挥作用很关键。
为使用 Clutch 保存视图,清单 21 使用 save-view 函数,向它传递两个字符串和一个 Clojure 映射,该映射带有 map 的单个 “键/值” 对,map 带有这个值的一个函数。Clutch 擅长抽象掉保存视图文档的单调细节。这些视图实际上被保存为常规 CouchDB 文档,但有特殊的名称。
清单 22 是使用 clj-http 创建视图文档的示例:
清单 22. 使用 clj-http 保存视图
user> (->> {:language "clojure"
:views {:runtimes
{:map "(fn [doc]
(when (and (:movie-title doc)
(:runtime doc))
[[(:movie-title doc)
(:runtime doc)]]))"}}}
json/json-str
(hash-map :body)
(client/put (str movies-db "/_design/movies/")))
{:status 201
...
:body "{\"ok\":true...}\n"}
user> (-> (str movies-db "/_design/movies/_view/runtimes")
client/get
:body
json/read-json
:rows)
[{:id "d6993381eb5ede34fded2f018b9f10b0",
:key "Psycho",
:value 105}
...]
|
清单 22 中的代码有一些有趣的事情需要注意。首先,这是一个 CouchDB 文档,就像您此前在 CouchDB 中保存过的其他文档一样。不同的是,它存储在一个特别命名的文档中,本例中为 _design/movies。CouchDB设计文档 是包含视图的 CouchDB 文档。它们的名称以 _design 开始。设计文档有一个 language 属性(本例中为 Clojure)和一个 views 属性,后者包含设计文档中存在的特定视图的一个映射。当 清单 21 通过 Clutch 调用 save-view 时,前两个参数定义设计文档和视图名称。映射的这个视图区域用于容纳许多相关查询。runtimes 视图拥有一个与之关联的映射,看起来与您通过 Clutch API 定义的原始函数类似。现在,您通过创建一个特殊命名的 CouchDB 文档来创建了一个视图。清单 22 的第二部分使用特殊的 URL 来获取视图的结果。
前面几个示例展示了如何检索视图返回的所有结果。这很有用,但可能不是您想要的功能。将选择电影标题背后的原始逻辑作为数据库的键。通过电影标题查询电影数据库似乎更合理,即便电影标题不惟一。鉴于这个目标,您可以创建一个返回关于一部电影的、以电影标题为键的完整文档的视图。这与您此前看到过的代码类似,但这次将返回完整文档,而不是返回单个数字。清单 23 展示了通过电影标题创建并查询视图的代码:
清单 23. 使用 Clutch 查询电影标题
user> (clutch/with-db movies-db
(clutch/save-view "movies" "by_title"
(clutch/with-clj-view-server
{:map (fn [doc] (when (and (:movie-title doc)
(:runtime doc))
[[(:movie-title doc)
doc]]))})))
user> (clutch/with-db movies-db
(:rows (clutch/get-view "movies" "by_title" {:key "Psycho"})))
[{:id "d6993381eb5ede34fded2f018b9f10b0",
:key "Psycho",
:value {:_id "d6993381eb5ede34fded2f018b9f10b0",
:movie-title "Psycho",
:director "Gus Van Sant",
...}
...}]
|
查询一部特定电影和查询所有电影之间的惟一区别是查询参数。清单 23 中的 Clutch 代码使用一个查询参数映射,该映射只是生成 URL 中的一个查询字符串,如清单 24 中的 clj-http 查询所示:
清单 24. 使用 clj-http 通过电影标题查询
user> (->> "\"Psycho\""
java.net.URLEncoder/encode
(str movies-db "/_design/movies/_view/by_title?key=")
client/get
:body
json/read-json
:rows)
[{:id "d6993381eb5ede34fded2f018b9f10b0",
:key "Psycho",
:value {...}
...}]
|
CouchDB 拥有大量查询选项,包括按升序/降序排列、键范围和限制。键还可以是其他 JSON 结构,比如列表或映射。要了解更多 CouchDB 视图信息,请参见 参考资料。
只使用映射函数通过视图查询数据可能能满足大多数开发人员的需求。但有时您可能需要获取汇总信息。平均值、合计和其他类型的汇总数据无法通过仅仅使用一个映射函数来实现。CouchDB 提供一个 reduce 函数来实现这个目的。清单 25 展示了一个示例,创建一个视图来显示数据库中存储的某个指定电影公司的电影的总数:
清单 25. 使用一个 reduce 函数的视图
user> (clutch/with-db movies-db
(clutch/save-view "movies" "studio"
(clutch/with-clj-view-server
{:map (fn [doc] (when (:studio doc)
[[(:studio doc) 1]]))
:reduce (fn [keys vals rereduce]
(if rereduce
(apply + vals)
(count vals)))})))
user> (clutch/with-db movies-db
(clutch/get-view "movies" "studio"))
{:rows [{:key nil, :value 6}]}
user> (clutch/with-db movies-db
(clutch/get-view "movies" "studio" {:key "Paramount"}))
{:rows [{:key nil, :value 3}]}
|
清单
25 中的 reduce 函数接受三个参数:
- 第一个参数是
keys,这是在map函数中创建的键的列表。注意,这个键列表不只包含 清单 25 中发出的studio,还包含这个 studio 和 ID。 - 第二个参数是
vals,这是传递到函数中的键的值列表。在本例中,这个值列表是被发出的值系列。 - 第三个参数是
rereduce,这个参数必须处理这个reduce函数是在汇总信息上操作还是在来自映射的原始结果(来自map函数的结果)上操作。
了解一些关于 CouchDB 如何存储这些结果的知识对于理解这个 reduce 函数很必要。reduce 调用的结果存储在一个 B-Tree 中;数据在 B-Tree 中的位置距离根越近,汇总级别也就越高。对 studio 视图的第一个调用返回 6,这是来自这个树的根的视图。如果您在这个点发出键,您还将看到针对数据库中的每个文档的一对 [studio doc-id]。当您从树根沿着树向下走时,其他汇总(小于树根处的汇总)可以被发出。清单 25 中的视图第二个调用请求 "Paramount" 电影公司的电影。这个调用将(以对数时间)遍历树,查找最靠近树根节点的电影公司电影汇总。这种结构的目标是提高性能。当数据更改或值需要被计算时,可以使用各级汇总,而不必重新运行所有计算。这种结构也是 rereduce 参数背后的结构。如果正在计算的节点的子节点已经过计算,则 rereduce 参数为真(在本例中并不只是各个值 1)。
CouchDB 可以扩展到许多实例,在集群中的各个节点之间进行增量式复制。这全部基于 CouchDB 的复制能力,我发现这种能力甚至在可伸缩分布式 CouchDB 数据库外部仍然有用。从 API 角度看,在 CouchDB 中复制数据是一个单步过程。复制可以在本地数据库、远程数据库以及任何结合本地和远程数据库的混合数据库之上进行。通过允许使用我们此前一直使用的 REST 接口来在数据库级别复制数据,CouchDB 使得数据复制工作变得很轻松。在这里,我们主要关注 CouchDB 的复制支持和如何以编程方式使用它,而不是如何将其应用到伸缩式 CouchDB(关于伸缩式 CouchDB 的更多信息,请参阅 参考资料。)
CouchDB 复制在两个现有(本地或远程)数据库之间进行。复制之前,两个数据库之间没有 “谱系” 要求。复制可以通过 Futon 应用程序执行,也可以以编程方式执行。假设您遇到了一些性能问题,需要添加第二个 CouchDB 数据库实例来满足需求。为了简化测试,我们只是复制到另一个本地数据库,如清单 26 所示:
清单 26. Clutch 复制示例
user> (def moviesb-db "http://localhost:5984/movies-b")
#'user/moviesb-db
user> (clutch/create-database moviesb-db)
{:ok true,...}
user> (clutch/replicate-database "movies" "movies-b")
{:ok true...}
user> (let [movie-ids (clutch/with-db movies-db
(->> (clutch/get-all-documents-meta)
:rows
(map :id)))
movie-b-ids (clutch/with-db moviesb-db
(->> (clutch/get-all-documents-meta)
:rows
(map :id)))]
(= movie-ids movie-b-ids))
true
|
清单 26 中的代码创建了一个新的(空)数据库,名为 movies-b(因为两个数据库不能拥有相同的名称),然后将 movies 数据库复制到这个新数据库。这样,新数据库就获得了所有文档 ID,并且确保它们都相等。由于您只是复制,因此它们应该相等。清单 27 是使用 clj-http 的相同示例:
清单 27. clj-http 复制示例
user> (def moviesb-db "http://localhost:5984/movies-b")
#'user/moviesb-db
user> (client/put moviesb-db)
{:ok true,...}
user> (->> {:source "movies" :target "movies-b"}
json/json-str
(hash-map :body)
(client/post "http://localhost:5984/_replicate"))
{:ok true...}
user> (let [movie-ids (->> (str movies-db "/_all_docs")
client/get
:body
json/read-json
:rows
(map :id))
movie-b-ids (->> (str moviesb-db "/_all_docs")
client/get
:body
json/read-json
:rows
(map :id))]
(= movie-ids movie-b-ids))
true
|
在 清单 27 中,您创建了一个包含源和目标的 JSON 文档。CouchDB 从那里获取它。现在,您了解了两个数据副本的优势和劣势。借助第二个数据库的威力,CouchDB 服务请求更快,但如果数据库更改会发生什么情况呢?作为一个示例,我们向每个数据库添加一部新电影并在它们之间复制,如清单 28 所示:
清单 28. 向两个数据库添加新文档
user> (clutch/with-db movies-db
(clutch/create-document
{:movie-title "Vertigo"
:director "Alfred Hitchcock",
:runtime 128,
:year-released 1958,
:studio "Paramount Pictures"}))
{:_id "728b2293180e0be566cea3f3127b6cf3"...}
user> (clutch/with-db moviesb-db
(clutch/create-document
{:movie-title "North by Northwest"
:director "Alfred Hitchcock",
:runtime 131,
:year-released 1959,
:studio "MGM"}))
{:_id "386d0400e336e54933a47aec656289c4"...}
(clutch/replicate-database "movies" "movies-b")
(clutch/replicate-database "movies-b" "movies")
|
在 清单 28 中的前两个语句之后,这两个数据库副本就产生了差异。movies 数据库拥有一个 Vertigo 文档,而 movies-b 没有;movies-b 拥有一个 North by Northwest 文档,而 movies 没有。这两个文档拥有不同的(生成)ID,代表不同的电影。由于文档不同,复制没有出现问题。注意,复制只是单向的。当您从 movies 向 movies-b 复制时,movie-b 中没有任何文档被移动。因此,您也需要从 movies-b 返回 movies 进行复制(以便获取 North by Northwest 文档的副本)。要验证两个数据库中是否都存在新文档,您可以重用 清单 27 中的代码,该代码比较两个数据库的文档列表。
我刚才描述的场景是一条平坦大道,它只处理新文档,没有任何冲突。另一条平坦大道是复制已在源上更新而没有在目标上更新的文档。如果在两个不同的数据库中修改了同一个文档然后再进行复制会发生什么情况呢?这是一个可能会产生复制问题的场景。要处理这个问题,最简单的方法是设计数据库以避免冲突性更新。在面向文档的数据库设计中,最好使文档尽可能独立使用。也可以这样设计数据库,以便新信息被放入新文档中,从而避免文档更新吗?事实上,这种设计并不总是可能的,但如果可能,那么复制工作将变得更容易。清单 29 中的示例向每个数据库中的一个文档添加一个新键 — 一个文档包含一个再发行年度,另一个文档包含关于电影混音的有关信息:
清单 29. 冲突性复制
user> (clutch/with-db movies-db
(-> (clutch/get-document "386d0400e336e54933a47aec656289c4")
(clutch/update-document {:re-released 1996})))
{:re-released 1996
:movie-title "North by Northwest"...}
user> (clutch/with-db moviesb-db
(-> (clutch/get-document "386d0400e336e54933a47aec656289c4")
(clutch/update-document {:sound-mix "Mono"})))
{:sound-mix "Mono"
:movie-title "North by Northwest"...}
user> (clutch/replicate-database "movies" "movies-b")
{:ok true...}
|
所有数据似乎似乎都已成功复制,直到我检查清单 30 中的结果:
清单 30. 检查冲突的复制
user> (clutch/with-db moviesb-db
(keys (clutch/get-document "386d0400e336e54933a47aec656289c4")))
(:movie-title :director :_conflicts :_rev :language
:runtime :studio :_id :sound-mix :year-released)
|
来自 movies 的更新似乎消失了,因为没有 re-released 键。当 movies 通过 movies-b 复制时冲突发生什么?这是因为想要复制同一个文档的不同修订版。当 CouchDB 发生这样的冲突时,不会丢失任何信息。相反,Couch 创建一个与该文档关联的新的冲突记录,该记录可以通过清单 31 所示的 Clutch 查询检索:
清单 31. 检查冲突(Clutch 示例)
user> (clutch/with-db moviesb-db
(clutch/get-document "386d0400e336e54933a47aec656289c4" {:conflicts true}))
{:movie-title "North by Northwest",
...
:_conflicts ["2-ac7e4d143dff32f7be437de99a659ba1"]
...}
|
清单 32 展示了对等的 clj-http 代码:
清单 32. 检查冲突(clj-http 示例)
user> (-> (str moviesb-db "/386d0400e336e54933a47aec656289c4?conflicts=true")
client/get
:body
json/read-json)
{:movie-title "North by Northwest"
...
:_conflicts ["2-ac7e4d143dff32f7be437de99a659ba1"]
...}
|
清单 31 和 清单 32 显示发生了一个冲突,该冲突源自文档的一个特定修订版(在本例中为 2-ac7e4d143dff32f7be437de99a659ba1)。可以提取特定的冲突版本,手动更新它。清单 33 展示了如何使用 Clutch 提取冲突文件:
清单 33. 提取冲突文档(Clutch)
user> (clutch/with-db moviesb-db
(keys (clutch/get-document "386d0400e336e54933a47aec656289c4"
{:rev "2-ac7e4d143dff32f7be437de99a659ba1"}
#{:rev})))
(:_id :_rev :re-released :movie-title :director
:runtime :year-released :studio)
|
清单 34 展示了对等的 clj-http 代码:
清单 34. 提取冲突文档(clj-http)
user> (-> (str moviesb-db
"/386d0400e336e54933a47aec656289c4"
"?rev=2-ac7e4d143dff32f7be437de99a659ba1")
client/get
:body
json/read-json
keys)
(:re-released :movie-title :director :_rev :language
:runtime :studio :_id :year-released)
|
注意,keys 列表不包含 sound-mix 键/值对,但包含 re-released 键/值对。解决这些冲突的负担落在了开发人员身上。另外,更新 CouchDB 文档 小节中的规则在这里也适用。对这个文档的任何 更改都会导致一个新的修订 ID 并有可能导致冲突。即使对一个文档的不同区域的更改也不能自动解决。解决这个问题的步骤如下:
- 读取当前文档。
- 读取旧(冲突)版本。
- 应用特定于域的合并逻辑。
- 将文档更新为新(合并)版本。
- 移除冲突文档版本。
步骤 5 等同于删除任何其他文档,这要使用一个额外的修订参数,该参数反映如何检索文档。
关于复制冲突的关键点是处理错误是特定于域的。在这个示例中,您想要合并更新,从而合并替代的标题字段。这个逻辑有很多替代方法。另一个选择是总是采用最新的更新。这种方法对于电影收入这样的文档很适用,因为您可能只需要电影的最新收入信息。而在其他情况下,您可能只想要第一个更新。
简单的 JSON 文档格式(REST API)和 Clutch 对 Clojure 的良好支持相互结合,形成了一种诱人的用途:通过 Clojure 访问 CouchDB。在 Clojure 中编写 CouchDB 视图的能力意味着能在应用程序中少支持一种语言并在代码中实现更多连续性。Clutch 提供的抽象,结合对 CouchDB 的 REST 基本原理的深刻理解,能促成一个可快速开发和可维护的基于 CouchDB 的应用程序。
学习
-
CouchDB - The Definitive Guide(J. Chris Anderson、Jan Lehnardt 和 Noah Slater,O'Reilly Media,2010 年 1 月):阅读这本书(在线版免费提供),了解独立语言的 CouchDB 信息。参阅 第 19 章 了解关于集群化 CouchDB 的更多信息。
-
“Introducing clj-http, a Clojure HTTP Client”(Mark McGranaghan,2010 年 8 月):clj-http 简介。
-
The CouchDB Wiki:访问 Apache 的 CouchDB 站点,其中包括关于 安装 CouchDB、Futon 和 HTTP View API 的部分。
-
Clojure:阅读在线 Clojure 文档。
-
“Clojure 编程语言”(Michael Galpin,developerWorks,2009 年 9 月):开始使用 Clojure,了解它的一些语法,利用 Clojure plug-in for Eclipse。
-
“Java 开发 2.0:通过 CouchDB 和 Groovy 的 RESTClient 实现 REST”(Andrew Glover,developerWorks,2009 年 11 月):了解更多 CouchDB 基础知识,以及如何从 Groovy 使用 CouchDB。
-
“Java
技术播客系列”(Andrew Glover,developerWorks):这个关于 Java 及其相关技术的技术播客系列包括对 Stuart Halloway 的关于 Clojure 的访谈以及对 Aaron Miller 和 Nitin Borwankar 的关于 CouchOne 的访谈。
-
Github 上的 Clutch:参阅 README,了解视图服务器安装说明,查看几个用法示例。
-
developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。
- 浏览 技术书店,阅读关于这些和其他技术主题的图书。
获得产品和技术
-
CouchDB:获取 CouchDB。
-
Clojure:下载 Clojure。
-
Github 上的 clj-http:下载 clj-http。
-
Clutch:下载 Clutch。
-
Leiningen:下载用于 Clojure 的 Leiningen 构建工具。
讨论
- 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户交流。

Ryan Senior 是 Revelytix 的一位工程师,致力于使用 Clojure 开发语义 web 软件。此前,在制造业、金融业和医疗保健业中担任过 Java 开发人员。Ryan 拥有西伊利诺伊斯大学计算机科学学士学位和伊利诺伊大学厄本那—香槟分校计算机科学硕士学位。Ryan 还是 Strange Loop 核心团队的成员。他的 Twitter 地址是 @objcmdo,博客地址是 Object Commando。