第十四章 持久化
譯者:飛龍
協(xié)議:CC BY-NC-SA 4.0
自豪地采用谷歌翻譯
在接下來的幾個(gè)練習(xí)中梁剔,我們將返回到網(wǎng)頁搜索引擎的構(gòu)建昼钻。為了回顧,搜索引擎的組件是:
- 抓让镒摇:我們需要一個(gè)程序末融,可以下載一個(gè)網(wǎng)頁尝胆,解析它,并提取文本和任何其他頁面的鏈接捺球。
- 索引:我們需要一個(gè)索引缸浦,可以查找檢索項(xiàng)并找到包含它的頁面。
- 檢索:我們需要一種方法氮兵,從索引中收集結(jié)果裂逐,并識別與檢索項(xiàng)最相關(guān)的頁面。
如果你做了練習(xí) 8.3泣栈,你使用 Java 映射實(shí)現(xiàn)了一個(gè)索引卜高。在本練習(xí)中,我們將重新審視索引器掺涛,并創(chuàng)建一個(gè)新版本,將結(jié)果存儲(chǔ)在數(shù)據(jù)庫中疼进。
如果你做了練習(xí) 7.4拣帽,你創(chuàng)建了一個(gè)爬蟲修陡,它跟蹤它找到的第一個(gè)鏈接。在下一個(gè)練習(xí)中,我們將制作一個(gè)更通用的版本砾跃,將其查找到的每個(gè)鏈接存儲(chǔ)在隊(duì)列中,并對其進(jìn)行排序莹桅。
然后,最后沮峡,你將處理檢索問題疚脐。
在這些練習(xí)中,我提供較少的起始代碼邢疙,你將做出更多的設(shè)計(jì)決策棍弄。這些練習(xí)也更加開放。我會(huì)提出一些最低限度的目標(biāo)疟游,你應(yīng)該嘗試實(shí)現(xiàn)它們呼畸,但如果你想挑戰(zhàn)自己,有很多方法可以讓你更深入颁虐。
現(xiàn)在蛮原,讓我們開始編寫一個(gè)新版本的索引器。
14.1 Redis
索引器的之前版本聪廉,將索引存儲(chǔ)在兩個(gè)數(shù)據(jù)結(jié)構(gòu)中:TermCounter
將檢索詞映射為網(wǎng)頁上顯示的次數(shù)瞬痘,以及Index
將檢索詞映射為出現(xiàn)的頁面集合。
這些數(shù)據(jù)結(jié)構(gòu)存儲(chǔ)在正在運(yùn)行的 Java 程序的內(nèi)存中板熊,這意味著當(dāng)程序停止運(yùn)行時(shí)框全,索引會(huì)丟失。僅在運(yùn)行程序的內(nèi)存中存儲(chǔ)的數(shù)據(jù)稱為“易失的”干签,因?yàn)槌绦蚪Y(jié)束時(shí)會(huì)消失津辩。
在創(chuàng)建它的程序結(jié)束后,仍然存在的數(shù)據(jù)稱為“持久的”容劳。通常喘沿,存儲(chǔ)在文件系統(tǒng)中的文件,以及存儲(chǔ)在數(shù)據(jù)庫中的數(shù)據(jù)是持久的竭贩。
使數(shù)據(jù)持久化的一種簡單方法是蚜印,將其存儲(chǔ)在文件中。在程序結(jié)束之前留量,它可以將其數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換為 JSON 格式(http://thinkdast.com/json)窄赋,然后將它們寫入文件哟冬。當(dāng)它再次啟動(dòng)時(shí),它可以讀取文件并重建數(shù)據(jù)結(jié)構(gòu)忆绰。
但這個(gè)解決方案有幾個(gè)問題:
- 讀取和寫入大型數(shù)據(jù)結(jié)構(gòu)(如 Web 索引)會(huì)很慢浩峡。
- 整個(gè)數(shù)據(jù)結(jié)構(gòu)可能不適合單個(gè)運(yùn)行程序的內(nèi)存。
- 如果程序意外結(jié)束(例如错敢,由于斷電)翰灾,則自程序上次啟動(dòng)以來所做的任何更改都將丟失。
一個(gè)更好的選擇是提供持久存儲(chǔ)的數(shù)據(jù)庫稚茅,并且能夠讀取和寫入數(shù)據(jù)庫的部分纸淮,而無需讀取和寫入整個(gè)數(shù)據(jù)。
有多種數(shù)據(jù)庫管理系統(tǒng)(DBMS)提供不同的功能亚享。你可以在 http://thinkdast.com/database 閱讀概述萎馅。
我為這個(gè)練習(xí)推薦的數(shù)據(jù)庫是 Redis,它提供了類似于 Java 數(shù)據(jù)結(jié)構(gòu)的持久數(shù)據(jù)結(jié)構(gòu)虹蒋。具體來說,它提供:
字符串列表飒货,與 Java 的List
類似魄衅。
哈希,類似于 Java 的Map
塘辅。
字符串集合晃虫,類似于 Java 的Set
。
譯者注:另外還有類似于 Java 的
LinkedHashSet
的有序集合扣墩。
Redis 是一個(gè)“鍵值數(shù)據(jù)庫”哲银,這意味著它包含的數(shù)據(jù)結(jié)構(gòu)(值)由唯一的字符串(鍵)標(biāo)識。Redis 中的鍵與 Java 中的引用相同:它標(biāo)識一個(gè)對象呻惕。我們稍后會(huì)看到一些例子荆责。
14.2 Redis 客戶端和服務(wù)端
Redis 通常運(yùn)行為遠(yuǎn)程服務(wù);其實(shí)它的名字代表“REmote DIctionary Server”(遠(yuǎn)程字典服務(wù)亚脆,字典其實(shí)就是映射)做院。為了使用 Redis,你必須在某處運(yùn)行 Redis 服務(wù)器濒持,然后使用 Redis 客戶端連接到 Redis 服務(wù)器键耕。有很多方法可用于設(shè)置服務(wù)器,也有許多你可以使用的客戶端柑营。對于這個(gè)練習(xí)屈雄,我建議:
不要自己安裝和運(yùn)行服務(wù)器,請考慮使用像 RedisToGo(http://thinkdast.com/redistogo)這樣的服務(wù)官套,它在云主機(jī)運(yùn)行 Redis酒奶。他們提供了一個(gè)免費(fèi)的計(jì)劃(配置)蚁孔,有足夠的資源用于練習(xí)。
對于客戶端讥蟆,我推薦 Jedis勒虾,它是一個(gè) Java 庫,提供了使用 Redis 的類和方法瘸彤。
以下是更詳細(xì)的說明修然,以幫助你開始使用:
- 在 RedisToGo 上創(chuàng)建一個(gè)帳號,網(wǎng)址為 http://thinkdast.com/redissign 质况,并選擇所需的計(jì)劃(可能是免費(fèi)的起始計(jì)劃)愕宋。
- 創(chuàng)建一個(gè)“實(shí)例”,它是運(yùn)行 Redis 服務(wù)器的虛擬機(jī)结榄。如果你單擊“實(shí)例”選項(xiàng)卡中贝,你將看到你的新實(shí)例,由主機(jī)名和端口號標(biāo)識臼朗。例如邻寿,我有一個(gè)名為
dory-10534
的實(shí)例。 - 單擊實(shí)例名稱來訪問配置頁面视哑。記下頁面頂部附近的網(wǎng)址绣否,如下所示:
redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534
這個(gè) URL 包含服務(wù)器的主機(jī)名稱dory.redistogo.com
,端口號10534
和連接到服務(wù)器所需的密碼挡毅,它是中間較長的字母數(shù)字的字符串蒜撮。你將需要此信息進(jìn)行下一步。
14.3 制作基于 Redis 的索引
在本書的倉庫中跪呈,你將找到此練習(xí)的源文件:
-
JedisMaker.java
包含連接到 Redis 服務(wù)器并運(yùn)行幾個(gè) Jedis 方法的示例代碼段磨。 -
JedisIndex.java
包含此練習(xí)的起始代碼。 -
JedisIndexTest.java
包含JedisIndex
的測試代碼耗绿。 -
WikiFetcher.java
包含我們在以前的練習(xí)中看到的代碼苹支,用于閱讀網(wǎng)頁并使用jsoup
進(jìn)行解析。
你還將需要這些文件误阻,你在以前的練習(xí)中碰到過:
Index.java
使用 Java 數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)索引沐序。
TermCounter.java
表示從檢索項(xiàng)到其頻率的映射。
WikiNodeIterable.java
迭代jsoup
生成的 DOM 樹中的節(jié)點(diǎn)堕绩。
如果你有這些文件的有效版本策幼,你可以使用它們進(jìn)行此練習(xí)。如果你沒有進(jìn)行以前的練習(xí)奴紧,或者你對你的解決方案毫無信心特姐,則可以從solutions
文件夾復(fù)制我的解決方案。
第一步是使用 Jedis 連接到你的 Redis 服務(wù)器黍氮。JedisMaker.java
展示了如何實(shí)現(xiàn)唐含。它從文件讀取你的 Redis 服務(wù)器的信息浅浮,連接到它并使用你的密碼登錄,然后返回一個(gè)可用于執(zhí)行 Redis 操作的 Jedis 對象捷枯。
如果你打開JedisMaker.java
滚秩,你應(yīng)該看到JedisMaker
類,它是一個(gè)幫助類淮捆,它提供靜態(tài)方法make
郁油,它創(chuàng)建一個(gè) Jedis 對象。一旦該對象認(rèn)證完畢攀痊,你可以使用它來與你的 Redis 數(shù)據(jù)庫進(jìn)行通信桐腌。
JedisMaker
從名為redis_url.txt
的文件讀取你的 Redis 服務(wù)器信息,你應(yīng)該放在目錄src/resources
中:
- 使用文本編輯器創(chuàng)建并編輯
ThinkDataStructures/code/src/resources/redis_url.txt
苟径。 - 粘貼服務(wù)器的 URL案站。如果你使用的是 RedisToGo,則 URL 將如下所示:
redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534
因?yàn)榇宋募愕?Redis 服務(wù)器的密碼棘街,你不應(yīng)將此文件放在公共倉庫中蟆盐。為了幫助你避免意外避免這種情況,倉庫包含.gitignore
文件遭殉,使文件難以(但不是不可能)放入你的倉庫舱禽。
現(xiàn)在運(yùn)行ant build
來編譯源文件,以及ant JedisMaker
來運(yùn)行main
中的示例代碼:
public static void main(String[] args) {
Jedis jedis = make();
// String
jedis.set("mykey", "myvalue");
String value = jedis.get("mykey");
System.out.println("Got value: " + value);
// Set
jedis.sadd("myset", "element1", "element2", "element3");
System.out.println("element2 is member: " +
jedis.sismember("myset", "element2"));
// List
jedis.rpush("mylist", "element1", "element2", "element3");
System.out.println("element at index 1: " +
jedis.lindex("mylist", 1));
// Hash
jedis.hset("myhash", "word1", Integer.toString(2));
jedis.hincrBy("myhash", "word2", 1);
System.out.println("frequency of word1: " +
jedis.hget("myhash", "word1"));
System.out.println("frequency of word1: " +
jedis.hget("myhash", "word2"));
jedis.close();
}
這個(gè)示例展示了數(shù)據(jù)類型和方法恩沽,你在這個(gè)練習(xí)中最可能使用它們。當(dāng)你運(yùn)行它時(shí)翔始,輸出應(yīng)該是:
Got value: myvalue
element2 is member: true
element at index 1: element2
frequency of word1: 2
frequency of word2: 1
下一節(jié)中我會(huì)解釋代碼的工作原理罗心。
14.4 Redis 數(shù)據(jù)類型
Redis 基本上是一個(gè)從鍵到值的映射,鍵是字符串城瞎,值可以是字符串渤闷,也可以是幾種數(shù)據(jù)類型之一。最基本的 Redis 數(shù)據(jù)類型是字符串脖镀。我將用斜體書寫 Redis 類型飒箭,來區(qū)別于 Java 類型。
為了向數(shù)據(jù)庫添加一個(gè)字符串蜒灰,請使用jedis.set
弦蹂,類似于Map.put
; 參數(shù)是新的鍵和相應(yīng)的值。為了查找一個(gè)鍵并獲取其值强窖,請使用jedis.get
:
jedis.set("mykey", "myvalue");
String value = jedis.get("mykey");
在這個(gè)例子中凸椿,鍵是"mykey"
,值是"myvalue"
翅溺。
Redis 提供了一個(gè)集合結(jié)構(gòu)脑漫,類似于 Java 的Set<String>
髓抑。為了向 Redis 集合添加元素,你可以選擇一個(gè)鍵來標(biāo)識集合优幸,然后使用jedis.sadd
:
jedis.sadd("myset", "element1", "element2", "element3");
boolean flag = jedis.sismember("myset", "element2");
你不必用單獨(dú)的步驟來創(chuàng)建集合吨拍。如果不存在,Redis 會(huì)創(chuàng)建它网杆。在這種情況下羹饰,它會(huì)創(chuàng)建一個(gè)名為myset
的集合,包含三個(gè)元素跛璧。
jedis.sismember
方法檢查元素是否在一個(gè)集合中严里。添加元素和檢查成員是常數(shù)時(shí)間的操作。
Redis 還提供了一個(gè)列表結(jié)構(gòu)追城,類似于 Java 的List<String>
刹碾。jedis.rpush
方法在末尾(右端)向列表添加元素:
jedis.rpush("mylist", "element1", "element2", "element3");
String element = jedis.lindex("mylist", 1);
同樣,你不必在開始添加元素之前創(chuàng)建結(jié)構(gòu)座柱。此示例創(chuàng)建了一個(gè)名為mylist
的列表迷帜,其中包含三個(gè)元素。
jedis.lindex
方法使用整數(shù)索引色洞,并返回列表中指定的元素戏锹。添加和訪問元素是常數(shù)時(shí)間的操作。
最后火诸,Redis 提供了一個(gè)哈希結(jié)構(gòu)锦针,類似于 Java 的Map<String, String>
。jedis.hset
方法為哈希表添加新條目:
jedis.hset("myhash", "word1", Integer.toString(2));
String value = jedis.hget("myhash", "word1");
此示例創(chuàng)建一個(gè)名為的myhash
哈希表置蜀,其中包含一個(gè)條目奈搜,該條目從將鍵word1
映射到值"2"
。
鍵和值都是字符串盯荤,所以如果我們要存儲(chǔ)Integer
馋吗,在我們調(diào)用hset
之前,我們必須將它轉(zhuǎn)換為String
秋秤。當(dāng)我們使用hget
查找值時(shí)宏粤,結(jié)果是String
,所以我們可能必須將其轉(zhuǎn)換回Integer
灼卢。
使用 Redis 的哈希表可能會(huì)令人困惑绍哎,因?yàn)槲覀兪褂靡粋€(gè)鍵來標(biāo)識我們想要的哈希表,然后用另一個(gè)鍵標(biāo)識哈希表中的值鞋真。在 Redis 的上下文中蛇摸,第二個(gè)鍵被稱為“字段”,這可能有助于保持清晰灿巧。所以類似myhash
的“鍵”標(biāo)志一個(gè)特定的哈希表赶袄,然后類似word1
的“字段”標(biāo)識一個(gè)哈希表中的值揽涮。
對于許多應(yīng)用程序,Redis 哈希表中的值是整數(shù)饿肺,所以 Redis 提供了一些特殊的方法蒋困,比如hincrby
將值作為數(shù)字來處理:
jedis.hincrBy("myhash", "word2", 1);
這個(gè)方法訪問myhash
,獲取word2
的當(dāng)前值(如果不存在則為0
)敬辣,將其遞增1
雪标,并將結(jié)果寫回哈希表。
在哈希表中溉跃,設(shè)置村刨,獲取和遞增條目是常數(shù)時(shí)間的操作。
你可以在 http://thinkdast.com/redistypes 上閱讀 Redis 數(shù)據(jù)類型的更多信息撰茎。
14.5 練習(xí) 11
這個(gè)時(shí)候嵌牺,你可以獲取一些信息,你需要使用它們來創(chuàng)建搜索引擎的索引龄糊,它將結(jié)果儲(chǔ)存在 Redis 數(shù)據(jù)庫中逆粹。
現(xiàn)在運(yùn)行ant JedisIndexTest
。它應(yīng)該失敗炫惩,因?yàn)槟阌幸恍┕ぷ饕觯?/p>
JedisIndexTest
測試了這些方法:
-
JedisIndex
僻弹,這是構(gòu)造器,它接受Jedis
對象作為參數(shù)他嚷。 -
indexPage
蹋绽,它將一個(gè)網(wǎng)頁添加到索引中;它需要一個(gè)StringURL
和一個(gè)jsoup Elements
對象筋蓖,該對象包含應(yīng)該建立索引的頁面元素卸耘。 -
getCounts
,它接收檢索詞扭勉,并返回Map<String, Integer>
,包含檢索詞到它在頁面上的出現(xiàn)次數(shù)的映射苛聘。
以下是如何使用這些方法的示例:
WikiFetcher wf = new WikiFetcher();
String url1 =
"http://en.wikipedia.org/wiki/Java_(programming_language)";
Elements paragraphs = wf.readWikipedia(url1);
Jedis jedis = JedisMaker.make();
JedisIndex index = new JedisIndex(jedis);
index.indexPage(url1, paragraphs);
Map<String, Integer> map = index.getCounts("the");
如果我們在結(jié)果map
中查看url1
涂炎,我們應(yīng)該得到339
,這是 Java 維基百科頁面(即我們保存的版本)中设哗,the
出現(xiàn)的次數(shù)唱捣。
如果我們再次索引相同的頁面,新的結(jié)果將替換舊的結(jié)果网梢。
將數(shù)據(jù)結(jié)構(gòu)從 Java 翻譯成 Redis 的一個(gè)建議是:記住 Redis 數(shù)據(jù)庫中的每個(gè)對象都以唯一的鍵標(biāo)識震缭,它是一個(gè)字符串。如果同一數(shù)據(jù)庫中有兩種對象战虏,則可能需要向鍵添加前綴來區(qū)分它們拣宰。例如党涕,在我們的解決方案中,我們有兩種對象:
- 我們將
URLSet
定義為 Redis 集合巡社,它包含URL
膛堤,URL
又包含給定檢索詞。每個(gè)URLSet
的鍵的起始是"URLSet:"
晌该,所以要獲取包含單詞the
的 URL肥荔,我們使用鍵"URLSet:the"
來訪問該集合。 - 我們將
TermCounter
定義為 Redis 哈希表朝群,將出現(xiàn)在頁面上的每個(gè)檢索詞映射到它的出現(xiàn)次數(shù)燕耿。TermCounter
每個(gè)鍵的開頭都以"TermCounter:"
開頭,以我們正在查找的頁面的 URL 結(jié)尾姜胖。
在我的實(shí)現(xiàn)中誉帅,每個(gè)術(shù)語都有一個(gè)URLSet
,每個(gè)索引頁面都有一個(gè)TermCounter
谭期。我提供兩個(gè)輔助方法堵第,urlSetKey
和termCounterKey
來組裝這些鍵。
14.6 更多建議(如果你需要的話)
到了這里隧出,你擁有了完成練習(xí)所需的所有信息踏志,所以如果準(zhǔn)備好了就可以開始了。但是我有幾個(gè)建議胀瞪,你可能想先閱讀它:
- 對于這個(gè)練習(xí)针余,我提供的指導(dǎo)比以前的練習(xí)少。你必須做出一些設(shè)計(jì)決策凄诞;特別是圆雁,你將必須弄清楚如何將問題分解成,你可以一次性測試的部分帆谍,然后將這些部分組合成一個(gè)完整的解決方案伪朽。如果你嘗試一次寫出整個(gè)項(xiàng)目,而不測試較小的部分汛蝙,調(diào)試可能需要很長時(shí)間烈涮。
- 使用持久性數(shù)據(jù)的挑戰(zhàn)之一是它是持久的。存儲(chǔ)在數(shù)據(jù)庫中的結(jié)構(gòu)可能會(huì)在每次運(yùn)行程序時(shí)發(fā)生更改窖剑。如果你弄亂了數(shù)據(jù)庫坚洽,你將不得不修復(fù)它或重新開始,然后才能繼續(xù)西土。為了幫助你控制住自己讶舰,我提供的方法叫
deleteURLSets
,deleteTermCounters
和deleteAllKeys
,你可以用它來清理數(shù)據(jù)庫跳昼,并重新開始般甲。你也可以使用printIndex
來打印索引的內(nèi)容。 - 每次調(diào)用 Jedis 的方法時(shí)庐舟,你的客戶端會(huì)向服務(wù)器發(fā)送一條消息欣除,然后服務(wù)器執(zhí)行你請求的操作并發(fā)回消息。如果執(zhí)行許多小操作挪略,可能需要很長時(shí)間历帚。你可以通過將一系列操作分組為一個(gè)
Transaction
,來提高性能杠娱。
例如挽牢,這是一個(gè)簡單的deleteAllKeys
版本:
public void deleteAllKeys() {
Set<String> keys = jedis.keys("*");
for (String key: keys) {
jedis.del(key);
}
}
每次調(diào)用del
時(shí),都需要從客戶端到服務(wù)器的雙向通信摊求。如果索引包含多個(gè)頁面禽拔,則該方法需要很長時(shí)間來執(zhí)行。我們可以使用Transaction
對象來加速:
public void deleteAllKeys() {
Set<String> keys = jedis.keys("*");
Transaction t = jedis.multi();
for (String key: keys) {
t.del(key);
}
t.exec();
}
jedis.multi
返回一個(gè)Transaction
對象室叉,它提供Jedis
對象的所有方法睹栖。但是當(dāng)你調(diào)用Transaction
的方法時(shí),它不會(huì)立即執(zhí)行該操作茧痕,并且不與服務(wù)器通信野来。在你調(diào)用exec
之前,它會(huì)保存一批操作踪旷。然后它將所有保存的操作同時(shí)發(fā)送到服務(wù)器曼氛,這通常要快得多。
14.7 幾個(gè)設(shè)計(jì)提示
現(xiàn)在你真的擁有了你需要的所有信息令野;你應(yīng)該開始完成練習(xí)舀患。但是如果你卡住了,或者如果你真的不知道如何開始气破,你可以再來一些提示聊浅。
在運(yùn)行測試代碼之前,不要閱讀以下內(nèi)容现使,嘗試一些基本的 Redis 命令低匙,并在JedisIndex.java
中編寫幾個(gè)方法。
好的朴下,如果你真的卡住了努咐,這里有一些你可能想要處理的方法:
/**
* 向檢索詞相關(guān)的集合中添加 URL
*/
public void add(String term, TermCounter tc) {}
/**
* 查找檢索詞并返回 URL 集合
*/
public Set<String> getURLs(String term) {}
/**
* 返回檢索詞出現(xiàn)在給定 URL 中的次數(shù)
*/
public Integer getCount(String url, String term) {}
/**
* 將 TermCounter 的內(nèi)容存入 Redis
*/
public List<Object> pushTermCounterToRedis(TermCounter tc) {}
這些是我在解決方案中使用的方法苦蒿,但它們絕對不是將項(xiàng)目分解的唯一方法殴胧。所以如果他們有幫助,請接受這些建議,但是如果沒有团滥,請忽略它們竿屹。
對于每種方法,請考慮首先編寫測試灸姊。當(dāng)你弄清楚如何測試一個(gè)方法時(shí)拱燃,你經(jīng)常會(huì)了解如何編寫它。
祝你好運(yùn)力惯!