在接下来的几个练习中,我们将返回到网页搜索引擎的构建。为了回顾,搜索引擎的组件是:
如果你做了练习 8.3,你使用 Java 映射实现了一个索引。在本练习中,我们将重新审视索引器,并创建一个新版本,将结果存储在数据库中。
如果你做了练习 7.4,你创建了一个爬虫,它跟踪它找到的第一个链接。在下一个练习中,我们将制作一个更通用的版本,将其查找到的每个链接存储在队列中,并对其进行排序。
然后,最后,你将处理检索问题。
在这些练习中,我提供较少的起始代码,你将做出更多的设计决策。这些练习也更加开放。我会提出一些最低限度的目标,你应该尝试实现它们,但如果你想挑战自己,有很多方法可以让你更深入。
现在,让我们开始编写一个新版本的索引器。
索引器的之前版本,将索引存储在两个数据结构中:TermCounter
将检索词映射为网页上显示的次数,以及Index
将检索词映射为出现的页面集合。
这些数据结构存储在正在运行的 Java 程序的内存中,这意味着当程序停止运行时,索引会丢失。仅在运行程序的内存中存储的数据称为“易失的”,因为程序结束时会消失。
在创建它的程序结束后,仍然存在的数据称为“持久的”。通常,存储在文件系统中的文件,以及存储在数据库中的数据是持久的。
使数据持久化的一种简单方法是,将其存储在文件中。在程序结束之前,它可以将其数据结构转换为 JSON 格式,然后将它们写入文件。当它再次启动时,它可以读取文件并重建数据结构。
但这个解决方案有几个问题:
一个更好的选择是提供持久存储的数据库,并且能够读取和写入数据库的部分,而无需读取和写入整个数据。
有多种数据库管理系统(DBMS)提供不同的功能。
我为这个练习推荐的数据库是 Redis,它提供了类似于 Java 数据结构的持久数据结构。具体来说,它提供:
字符串列表,与 Java 的List
类似。
哈希,类似于 Java 的Map
。
字符串集合,类似于 Java 的Set
。
译者注:另外还有类似于 Java 的
LinkedHashSet
的有序集合。
Redis 是一个“键值数据库”,这意味着它包含的数据结构(值)由唯一的字符串(键)标识。Redis 中的键与 Java 中的引用相同:它标识一个对象。我们稍后会看到一些例子。
Redis 基本上是一个从键到值的映射,键是字符串,值可以是字符串,也可以是几种数据类型之一。最基本的 Redis 数据类型是字符串。我将用斜体书写 Redis 类型,来区别于 Java 类型。
为了向数据库添加一个字符串,请使用jedis.set
,类似于Map.put
; 参数是新的键和相应的值。为了查找一个键并获取其值,请使用jedis.get
:
jedis.set("mykey", "myvalue");
String value = jedis.get("mykey");
在这个例子中,键是"mykey"
,值是"myvalue"
。
Redis 提供了一个集合结构,类似于 Java 的Set<String>
。为了向 Redis 集合添加元素,你可以选择一个键来标识集合,然后使用jedis.sadd
:
jedis.sadd("myset", "element1", "element2", "element3");
boolean flag = jedis.sismember("myset", "element2");
你不必用单独的步骤来创建集合。如果不存在,Redis 会创建它。在这种情况下,它会创建一个名为myset
的集合,包含三个元素。
jedis.sismember
方法检查元素是否在一个集合中。添加元素和检查成员是常数时间的操作。
Redis 还提供了一个列表结构,类似于 Java 的List<String>
。jedis.rpush
方法在末尾(右端)向列表添加元素:
jedis.rpush("mylist", "element1", "element2", "element3");
String element = jedis.lindex("mylist", 1);
同样,你不必在开始添加元素之前创建结构。此示例创建了一个名为mylist
的列表,其中包含三个元素。
jedis.lindex
方法使用整数索引,并返回列表中指定的元素。添加和访问元素是常数时间的操作。
最后,Redis 提供了一个哈希结构,类似于 Java 的Map<String, String>
。jedis.hset
方法为哈希表添加新条目:
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
将值作为数字来处理:
jedis.hincrBy("myhash", "word2", 1);
这个方法访问myhash
,获取word2
的当前值(如果不存在则为0
),将其递增1
,并将结果写回哈希表。
在哈希表中,设置,获取和递增条目是常数时间的操作。
这个时候,你可以获取一些信息,你需要使用它们来创建搜索引擎的索引,它将结果储存在 Redis 数据库中。
现在运行ant JedisIndexTest
。它应该失败,因为你有一些工作要做!
JedisIndexTest
测试了这些方法:
JedisIndex
,这是构造器,它接受Jedis
对象作为参数。indexPage
,它将一个网页添加到索引中;它需要一个StringURL
和一个jsoup Elements
对象,该对象包含应该建立索引的页面元素。getCounts
,它接收检索词,并返回Map<String, Integer>
,包含检索词到它在页面上的出现次数的映射。以下是如何使用这些方法的示例:
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
来组装这些键。
到了这里,你拥有了完成练习所需的所有信息,所以如果准备好了就可以开始了。但是我有几个建议,你可能想先阅读它:
deleteURLSets
,deleteTermCounters
和deleteAllKeys
,你可以用它来清理数据库,并重新开始。你也可以使用printIndex
来打印索引的内容。Transaction
,来提高性能。例如,这是一个简单的deleteAllKeys
版本:
public void deleteAllKeys() {
Set<String> keys = jedis.keys("*");
for (String key: keys) {
jedis.del(key);
}
}
每次调用del
时,都需要从客户端到服务器的双向通信。如果索引包含多个页面,则该方法需要很长时间来执行。我们可以使用Transaction
对象来加速:
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
之前,它会保存一批操作。然后它将所有保存的操作同时发送到服务器,这通常要快得多。
现在你真的拥有了你需要的所有信息;你应该开始完成练习。但是如果你卡住了,或者如果你真的不知道如何开始,你可以再来一些提示。
在运行测试代码之前,不要阅读以下内容,尝试一些基本的 Redis 命令,并在JedisIndex.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) {}
这些是我在解决方案中使用的方法,但它们绝对不是将项目分解的唯一方法。所以如果他们有帮助,请接受这些建议,但是如果没有,请忽略它们。
对于每种方法,请考虑首先编写测试。当你弄清楚如何测试一个方法时,你经常会了解如何编写它。
祝你好运!