數(shù)據(jù)結(jié)構(gòu)思維 第十四章 持久化

第十四章 持久化

原文:Chapter 14 Persistence

譯者:飛龍

協(xié)議:CC BY-NC-SA 4.0

自豪地采用谷歌翻譯

在接下來的幾個(gè)練習(xí)中,我們將返回到網(wǎng)頁搜索引擎的構(gòu)建。為了回顧,搜索引擎的組件是:

  • 抓?。何覀冃枰粋€(gè)程序,可以下載一個(gè)網(wǎng)頁,解析它,并提取文本和任何其他頁面的鏈接。
  • 索引:我們需要一個(gè)索引,可以查找檢索項(xiàng)并找到包含它的頁面。
  • 檢索:我們需要一種方法,從索引中收集結(jié)果,并識(shí)別與檢索項(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ì)列中,并對(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)識(shí)。Redis 中的鍵與 Java 中的引用相同:它標(biāo)識(shí)一個(gè)對(duì)象。我們稍后會(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ù)器,也有許多你可以使用的客戶端。對(duì)于這個(gè)練習(xí),我建議:

不要自己安裝和運(yùn)行服務(wù)器,請(qǐng)考慮使用像 RedisToGo(http://thinkdast.com/redistogo)這樣的服務(wù),它在云主機(jī)運(yùn)行 Redis。他們提供了一個(gè)免費(fèi)的計(jì)劃(配置),有足夠的資源用于練習(xí)。
對(duì)于客戶端,我推薦 Jedis,它是一個(gè) Java 庫,提供了使用 Redis 的類和方法。

以下是更詳細(xì)的說明,以幫助你開始使用:

  • 在 RedisToGo 上創(chuàng)建一個(gè)帳號(hào),網(wǎng)址為 http://thinkdast.com/redissign ,并選擇所需的計(jì)劃(可能是免費(fèi)的起始計(jì)劃)。
  • 創(chuàng)建一個(gè)“實(shí)例”,它是運(yùn)行 Redis 服務(wù)器的虛擬機(jī)。如果你單擊“實(shí)例”選項(xiàng)卡,你將看到你的新實(shí)例,由主機(jī)名和端口號(hào)標(biāo)識(shí)。例如,我有一個(gè)名為dory-10534的實(shí)例。
  • 單擊實(shí)例名稱來訪問配置頁面。記下頁面頂部附近的網(wǎng)址,如下所示:
    redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534
    

這個(gè) URL 包含服務(wù)器的主機(jī)名稱dory.redistogo.com,端口號(hào)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包含我們?cè)谝郧暗木毩?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í),或者你對(duì)你的解決方案毫無信心,則可以從solutions文件夾復(fù)制我的解決方案。

第一步是使用 Jedis 連接到你的 Redis 服務(wù)器。JedisMaker.java展示了如何實(shí)現(xiàn)。它從文件讀取你的 Redis 服務(wù)器的信息,連接到它并使用你的密碼登錄,然后返回一個(gè)可用于執(zhí)行 Redis 操作的 Jedis 對(duì)象。

如果你打開JedisMaker.java,你應(yīng)該看到JedisMaker類,它是一個(gè)幫助類,它提供靜態(tài)方法make,它創(chuàng)建一個(gè) Jedis 對(duì)象。一旦該對(duì)象認(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è)字符串,請(qǐng)使用jedis.set,類似于Map.put; 參數(shù)是新的鍵和相應(yīng)的值。為了查找一個(gè)鍵并獲取其值,請(qǐng)使用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)識(shí)集合,然后使用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)識(shí)我們想要的哈希表,然后用另一個(gè)鍵標(biāo)識(shí)哈希表中的值。在 Redis 的上下文中,第二個(gè)鍵被稱為“字段”,這可能有助于保持清晰。所以類似myhash的“鍵”標(biāo)志一個(gè)特定的哈希表,然后類似word1的“字段”標(biāo)識(shí)一個(gè)哈希表中的值。

對(duì)于許多應(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對(duì)象作為參數(shù)。
  • indexPage,它將一個(gè)網(wǎng)頁添加到索引中;它需要一個(gè)StringURL和一個(gè)jsoup Elements對(duì)象,該對(duì)象包含應(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");

如果我們?cè)诮Y(jié)果map中查看url1,我們應(yīng)該得到339,這是 Java 維基百科頁面(即我們保存的版本)中,the出現(xiàn)的次數(shù)。

如果我們?cè)俅嗡饕嗤捻撁妫碌慕Y(jié)果將替換舊的結(jié)果。

將數(shù)據(jù)結(jié)構(gòu)從 Java 翻譯成 Redis 的一個(gè)建議是:記住 Redis 數(shù)據(jù)庫中的每個(gè)對(duì)象都以唯一的鍵標(biāo)識(shí),它是一個(gè)字符串。如果同一數(shù)據(jù)庫中有兩種對(duì)象,則可能需要向鍵添加前綴來區(qū)分它們。例如,在我們的解決方案中,我們有兩種對(duì)象:

  • 我們將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è)輔助方法,urlSetKeytermCounterKey來組裝這些鍵。

14.6 更多建議(如果你需要的話)

到了這里,你擁有了完成練習(xí)所需的所有信息,所以如果準(zhǔn)備好了就可以開始了。但是我有幾個(gè)建議,你可能想先閱讀它:

  • 對(duì)于這個(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ù)。為了幫助你控制住自己,我提供的方法叫deleteURLSetsdeleteTermCountersdeleteAllKeys,你可以用它來清理數(shù)據(jù)庫,并重新開始。你也可以使用printIndex來打印索引的內(nèi)容。
  • 每次調(diào)用 Jedis 的方法時(shí),你的客戶端會(huì)向服務(wù)器發(fā)送一條消息,然后服務(wù)器執(zhí)行你請(qǐng)求的操作并發(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對(duì)象來加速:

    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對(duì)象,它提供Jedis對(duì)象的所有方法。但是當(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) {}

這些是我在解決方案中使用的方法,但它們絕對(duì)不是將項(xiàng)目分解的唯一方法。所以如果他們有幫助,請(qǐng)接受這些建議,但是如果沒有,請(qǐng)忽略它們。

對(duì)于每種方法,請(qǐng)考慮首先編寫測試。當(dāng)你弄清楚如何測試一個(gè)方法時(shí),你經(jīng)常會(huì)了解如何編寫它。

祝你好運(yùn)!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容