# 第十四章 持久化
> 原文:[Chapter 14 Persistence](http://greenteapress.com/thinkdast/html/thinkdast015.html)
> 譯者:[飛龍](https://github.com/wizardforcel)
> 協議:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
> 自豪地采用[谷歌翻譯](https://translate.google.cn/)
在接下來的幾個練習中,我們將返回到網頁搜索引擎的構建。為了回顧,搜索引擎的組件是:
+ 抓取:我們需要一個程序,可以下載一個網頁,解析它,并提取文本和任何其他頁面的鏈接。
+ 索引:我們需要一個索引,可以查找檢索項并找到包含它的頁面。
+ 檢索:我們需要一種方法,從索引中收集結果,并識別與檢索項最相關的頁面。
如果你做了練習 8.3,你使用 Java 映射實現了一個索引。在本練習中,我們將重新審視索引器,并創建一個新版本,將結果存儲在數據庫中。
如果你做了練習 7.4,你創建了一個爬蟲,它跟蹤它找到的第一個鏈接。在下一個練習中,我們將制作一個更通用的版本,將其查找到的每個鏈接存儲在隊列中,并對其進行排序。
然后,最后,你將處理檢索問題。
在這些練習中,我提供較少的起始代碼,你將做出更多的設計決策。這些練習也更加開放。我會提出一些最低限度的目標,你應該嘗試實現它們,但如果你想挑戰自己,有很多方法可以讓你更深入。
現在,讓我們開始編寫一個新版本的索引器。
## 14.1 Redis
索引器的之前版本,將索引存儲在兩個數據結構中:`TermCounter`將檢索詞映射為網頁上顯示的次數,以及`Index`將檢索詞映射為出現的頁面集合。
這些數據結構存儲在正在運行的 Java 程序的內存中,這意味著當程序停止運行時,索引會丟失。僅在運行程序的內存中存儲的數據稱為“易失的”,因為程序結束時會消失。
在創建它的程序結束后,仍然存在的數據稱為“持久的”。通常,存儲在文件系統中的文件,以及存儲在數據庫中的數據是持久的。
使數據持久化的一種簡單方法是,將其存儲在文件中。在程序結束之前,它可以將其數據結構轉換為 JSON 格式(<http://thinkdast.com/json>),然后將它們寫入文件。當它再次啟動時,它可以讀取文件并重建數據結構。
但這個解決方案有幾個問題:
+ 讀取和寫入大型數據結構(如 Web 索引)會很慢。
+ 整個數據結構可能不適合單個運行程序的內存。
+ 如果程序意外結束(例如,由于斷電),則自程序上次啟動以來所做的任何更改都將丟失。
一個更好的選擇是提供持久存儲的數據庫,并且能夠讀取和寫入數據庫的部分,而無需讀取和寫入整個數據。
有多種數據庫管理系統(DBMS)提供不同的功能。你可以在 <http://thinkdast.com/database> 閱讀概述。
我為這個練習推薦的數據庫是 Redis,它提供了類似于 Java 數據結構的持久數據結構。具體來說,它提供:
字符串列表,與 Java 的`List`類似。
哈希,類似于 Java 的`Map`。
字符串集合,類似于 Java 的`Set`。
> 譯者注:另外還有類似于 Java 的`LinkedHashSet`的有序集合。
Redis 是一個“鍵值數據庫”,這意味著它包含的數據結構(值)由唯一的字符串(鍵)標識。Redis 中的鍵與 Java 中的引用相同:它標識一個對象。我們稍后會看到一些例子。
## 14.2 Redis 客戶端和服務端
Redis 通常運行為遠程服務;其實它的名字代表“REmote DIctionary Server”(遠程字典服務,字典其實就是映射)。為了使用 Redis,你必須在某處運行 Redis 服務器,然后使用 Redis 客戶端連接到 Redis 服務器。有很多方法可用于設置服務器,也有許多你可以使用的客戶端。對于這個練習,我建議:
不要自己安裝和運行服務器,請考慮使用像 RedisToGo(<http://thinkdast.com/redistogo>)這樣的服務,它在云主機運行 Redis。他們提供了一個免費的計劃(配置),有足夠的資源用于練習。
對于客戶端,我推薦 Jedis,它是一個 Java 庫,提供了使用 Redis 的類和方法。
以下是更詳細的說明,以幫助你開始使用:
+ 在 RedisToGo 上創建一個帳號,網址為 <http://thinkdast.com/redissign> ,并選擇所需的計劃(可能是免費的起始計劃)。
+ 創建一個“實例”,它是運行 Redis 服務器的虛擬機。如果你單擊“實例”選項卡,你將看到你的新實例,由主機名和端口號標識。例如,我有一個名為`dory-10534`的實例。
+ 單擊實例名稱來訪問配置頁面。記下頁面頂部附近的網址,如下所示:
```
redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534
```
這個 URL 包含服務器的主機名稱`dory.redistogo.com`,端口號`10534`和連接到服務器所需的密碼,它是中間較長的字母數字的字符串。你將需要此信息進行下一步。
## 14.3 制作基于 Redis 的索引
在本書的倉庫中,你將找到此練習的源文件:
+ `JedisMaker.java`包含連接到 Redis 服務器并運行幾個 Jedis 方法的示例代碼。
+ `JedisIndex.java`包含此練習的起始代碼。
+ `JedisIndexTest.java`包含`JedisIndex`的測試代碼。
+ `WikiFetcher.java`包含我們在以前的練習中看到的代碼,用于閱讀網頁并使用`jsoup`進行解析。
你還將需要這些文件,你在以前的練習中碰到過:
`Index.java`使用 Java 數據結構實現索引。
`TermCounter.java`表示從檢索項到其頻率的映射。
`WikiNodeIterable.java`迭代`jsoup`生成的 DOM 樹中的節點。
如果你有這些文件的有效版本,你可以使用它們進行此練習。如果你沒有進行以前的練習,或者你對你的解決方案毫無信心,則可以從`solutions `文件夾復制我的解決方案。
第一步是使用 Jedis 連接到你的 Redis 服務器。`JedisMaker.java`展示了如何實現。它從文件讀取你的 Redis 服務器的信息,連接到它并使用你的密碼登錄,然后返回一個可用于執行 Redis 操作的 Jedis 對象。
如果你打開`JedisMaker.java`,你應該看到`JedisMaker`類,它是一個幫助類,它提供靜態方法`make`,它創建一個 Jedis 對象。一旦該對象認證完畢,你可以使用它來與你的 Redis 數據庫進行通信。
`JedisMaker`從名為`redis_url.txt`的文件讀取你的 Redis 服務器信息,你應該放在目錄`src/resources`中:
+ 使用文本編輯器創建并編輯`ThinkDataStructures/code/src/resources/redis_url.txt`。
+ 粘貼服務器的 URL。如果你使用的是 RedisToGo,則 URL 將如下所示:
```
redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534
```
因為此文件包含你的 Redis 服務器的密碼,你不應將此文件放在公共倉庫中。為了幫助你避免意外避免這種情況,倉庫包含`.gitignore`文件,使文件難以(但不是不可能)放入你的倉庫。
現在運行`ant build`來編譯源文件,以及`ant JedisMaker`來運行`main`中的示例代碼:
```java
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();
}
```
這個示例展示了數據類型和方法,你在這個練習中最可能使用它們。當你運行它時,輸出應該是:
```
Got value: myvalue
element2 is member: true
element at index 1: element2
frequency of word1: 2
frequency of word2: 1
```
下一節中我會解釋代碼的工作原理。
## 14.4 Redis 數據類型
Redis 基本上是一個從鍵到值的映射,鍵是字符串,值可以是字符串,也可以是幾種數據類型之一。最基本的 Redis 數據類型是字符串。我將用斜體書寫 Redis 類型,來區別于 Java 類型。
為了向數據庫添加一個字符串,請使用`jedis.set`,類似于`Map.put`; 參數是新的鍵和相應的值。為了查找一個鍵并獲取其值,請使用`jedis.get`:
```java
jedis.set("mykey", "myvalue");
String value = jedis.get("mykey");
```
在這個例子中,鍵是`"mykey"`,值是`"myvalue"`。
Redis 提供了一個集合結構,類似于 Java 的`Set<String>`。為了向 Redis 集合添加元素,你可以選擇一個鍵來標識集合,然后使用`jedis.sadd`:
```java
jedis.sadd("myset", "element1", "element2", "element3");
boolean flag = jedis.sismember("myset", "element2");
```
你不必用單獨的步驟來創建集合。如果不存在,Redis 會創建它。在這種情況下,它會創建一個名為`myset`的集合,包含三個元素。
`jedis.sismember`方法檢查元素是否在一個集合中。添加元素和檢查成員是常數時間的操作。
Redis 還提供了一個列表結構,類似于 Java 的`List<String>`。`jedis.rpush`方法在末尾(右端)向列表添加元素:
```java
jedis.rpush("mylist", "element1", "element2", "element3");
String element = jedis.lindex("mylist", 1);
```
同樣,你不必在開始添加元素之前創建結構。此示例創建了一個名為`mylist`的列表,其中包含三個元素。
`jedis.lindex`方法使用整數索引,并返回列表中指定的元素。添加和訪問元素是常數時間的操作。
最后,Redis 提供了一個哈希結構,類似于 Java 的`Map<String, String>`。`jedis.hset`方法為哈希表添加新條目:
```java
jedis.hset("myhash", "word1", Integer.toString(2));
String value = jedis.hget("myhash", "word1");
```
此示例創建一個名為的`myhash`哈希表,其中包含一個條目,該條目從將鍵`word1`映射到值`"2"`。
鍵和值都是字符串,所以如果我們要存儲`Integer`,在我們調用`hset`之前,我們必須將它轉換為`String`。當我們使用`hget`查找值時,結果是`String`,所以我們可能必須將其轉換回`Integer`。
使用 Redis 的哈希表可能會令人困惑,因為我們使用一個鍵來標識我們想要的哈希表,然后用另一個鍵標識哈希表中的值。在 Redis 的上下文中,第二個鍵被稱為“字段”,這可能有助于保持清晰。所以類似`myhash`的“鍵”標志一個特定的哈希表,然后類似`word1`的“字段”標識一個哈希表中的值。
對于許多應用程序,Redis 哈希表中的值是整數,所以 Redis 提供了一些特殊的方法,比如`hincrby`將值作為數字來處理:
```java
jedis.hincrBy("myhash", "word2", 1);
```
這個方法訪問`myhash`,獲取`word2`的當前值(如果不存在則為`0`),將其遞增`1`,并將結果寫回哈希表。
在哈希表中,設置,獲取和遞增條目是常數時間的操作。
你可以在 <http://thinkdast.com/redistypes> 上閱讀 Redis 數據類型的更多信息。
## 14.5 練習 11
這個時候,你可以獲取一些信息,你需要使用它們來創建搜索引擎的索引,它將結果儲存在 Redis 數據庫中。
現在運行`ant JedisIndexTest`。它應該失敗,因為你有一些工作要做!
`JedisIndexTest`測試了這些方法:
+ `JedisIndex`,這是構造器,它接受`Jedis`對象作為參數。
+ `indexPage`,它將一個網頁添加到索引中;它需要一個`StringURL`和一個`jsoup Elements`對象,該對象包含應該建立索引的頁面元素。
+ `getCounts`,它接收檢索詞,并返回`Map<String, Integer>`,包含檢索詞到它在頁面上的出現次數的映射。
以下是如何使用這些方法的示例:
```java
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");
```
如果我們在結果`map`中查看`url1`,我們應該得到`339`,這是 Java 維基百科頁面(即我們保存的版本)中,`the`出現的次數。
如果我們再次索引相同的頁面,新的結果將替換舊的結果。
將數據結構從 Java 翻譯成 Redis 的一個建議是:記住 Redis 數據庫中的每個對象都以唯一的鍵標識,它是一個字符串。如果同一數據庫中有兩種對象,則可能需要向鍵添加前綴來區分它們。例如,在我們的解決方案中,我們有兩種對象:
+ 我們將`URLSet`定義為 Redis 集合,它包含`URL`,`URL`又包含給定檢索詞。每個`URLSet`的鍵的起始是`"URLSet:"`,所以要獲取包含單詞`the`的 URL,我們使用鍵`"URLSet:the"`來訪問該集合。
+ 我們將`TermCounter`定義為 Redis 哈希表,將出現在頁面上的每個檢索詞映射到它的出現次數。`TermCounter`每個鍵的開頭都以`"TermCounter:"`開頭,以我們正在查找的頁面的 URL 結尾。
在我的實現中,每個檢索詞都有一個`URLSet`,每個索引頁面都有一個`TermCounter`。我提供兩個輔助方法,`urlSetKey`和`termCounterKey`來組裝這些鍵。
## 14.6 更多建議(如果你需要的話)
到了這里,你擁有了完成練習所需的所有信息,所以如果準備好了就可以開始了。但是我有幾個建議,你可能想先閱讀它:
+ 對于這個練習,我提供的指導比以前的練習少。你必須做出一些設計決策;特別是,你將必須弄清楚如何將問題分解成,你可以一次性測試的部分,然后將這些部分組合成一個完整的解決方案。如果你嘗試一次寫出整個項目,而不測試較小的部分,調試可能需要很長時間。
+ 使用持久性數據的挑戰之一是它是持久的。存儲在數據庫中的結構可能會在每次運行程序時發生更改。如果你弄亂了數據庫,你將不得不修復它或重新開始,然后才能繼續。為了幫助你控制住自己,我提供的方法叫`deleteURLSets`,`deleteTermCounters`和`deleteAllKeys`,你可以用它來清理數據庫,并重新開始。你也可以使用`printIndex`來打印索引的內容。
+ 每次調用 Jedis 的方法時,你的客戶端會向服務器發送一條消息,然后服務器執行你請求的操作并發回消息。如果執行許多小操作,可能需要很長時間。你可以通過將一系列操作分組為一個`Transaction`,來提高性能。
例如,這是一個簡單的`deleteAllKeys`版本:
```java
public void deleteAllKeys() {
Set<String> keys = jedis.keys("*");
for (String key: keys) {
jedis.del(key);
}
}
```
每次調用`del`時,都需要從客戶端到服務器的雙向通信。如果索引包含多個頁面,則該方法需要很長時間來執行。我們可以使用`Transaction`對象來加速:
```java
public void deleteAllKeys() {
Set<String> keys = jedis.keys("*");
Transaction t = jedis.multi();
for (String key: keys) {
t.del(key);
}
t.exec();
}
```
`jedis.multi`返回一個`Transaction`對象,它提供`Jedis`對象的所有方法。但是當你調用`Transaction`的方法時,它不會立即執行該操作,并且不與服務器通信。在你調用`exec`之前,它會保存一批操作。然后它將所有保存的操作同時發送到服務器,這通常要快得多。
## 14.7 幾個設計提示
現在你真的擁有了你需要的所有信息;你應該開始完成練習。但是如果你卡住了,或者如果你真的不知道如何開始,你可以再來一些提示。
在運行測試代碼之前,不要閱讀以下內容,嘗試一些基本的 Redis 命令,并在`JedisIndex.java`中編寫幾個方法。
好的,如果你真的卡住了,這里有一些你可能想要處理的方法:
```java
/**
* 向檢索詞相關的集合中添加 URL
*/
public void add(String term, TermCounter tc) {}
/**
* 查找檢索詞并返回 URL 集合
*/
public Set<String> getURLs(String term) {}
/**
* 返回檢索詞出現在給定 URL 中的次數
*/
public Integer getCount(String url, String term) {}
/**
* 將 TermCounter 的內容存入 Redis
*/
public List<Object> pushTermCounterToRedis(TermCounter tc) {}
```
這些是我在解決方案中使用的方法,但它們絕對不是將項目分解的唯一方法。所以如果他們有幫助,請接受這些建議,但是如果沒有,請忽略它們。
對于每種方法,請考慮首先編寫測試。當你弄清楚如何測試一個方法時,你經常會了解如何編寫它。
祝你好運!