我最近仔细考察了一个项目,该项目涉及相当多的 Web 快速搜索。当爬虫程 序爬过不同的 Web 站点时,它将建立一个数据库,该数据库中包括它所爬过的 站点和网页、每一页所包含的链接、每一页的分析结果等数据。最终结果是一组 报告,详细说明经过了哪些站点和页面、哪些是一直链接的、哪些链接已经断开 、哪些页面有错误、计算出的页面规格,等等。开始的时候,没人确切知道需要 什么样的报告,或者应当采用什么样的格式 —— 只知道有一些内容要报告。这 表明报告开发阶段会是一个反复的阶段,要经过多次反馈、修改,并且可能尝试 使用不同的结构。惟一确定的报告要求是,报告应当以 XML 形式展示,也可能 以 HTML 形式展示。因此,开发和修改报告的过程必须是轻量级的,因为报告要 求是“动态发现”的,而不是预先指定的。
不需要数据库
对这个问题的“最显而易见的”解决方法是将所有东西都放入 SQL 数据库中 —— 页面、链接、度量标准、HTTP 结果代码、计时结果和其他元数据。这个问 题可以借助关系表示来很好地解决,特别是因为这种方法不需要存储已访问页面 的内容,只需要存储它们的结构和元数据。
到目前为止,这个项目看起来像是一个典型的数据库应用程序,并且它并不 缺少可供选择的持久性策略。但是,或许可以避免使用数据库持久存储数据的复 杂性 —— 这个快速搜索工具(crawler)只访问数万个页面。这个数字不是很 大,因此可以将整个数据库放在内存中,当需要持久存储数据时,可以通过序列 化来实现它。(是的,加载和保存操作要花费较长的时间,但是这些操作并不经 常执行。)懒惰反而带来了一个好处 —— 不需要处理持久性极大地缩短了开发 应用程序的时间,因而显著地减少了开发工作量。构建和操纵内存中的数据结构 要比每次添加、提取或者分析数据时都使用数据库容易得多。不管选择了哪种持 久存储模型,都会限制任何触及到数据的代码的构造。
内存中的数据结构是一种树型结构,如清单 1 所示,它的根是快速搜索过的 各个网站的主页,因此 Visitor 模式是搜索这些主页或者从中提取数据的理想 模式。(构建一个防止陷入链接循环 —— A 链接到 B、B 链接到 C、C 链接到 A —— 的基本 Visitor 类并不是很难。)
清单 1. Web 爬行器的一个简化方案
public class Site {
Page homepage;
Collection<Page> pages;
Collection<Link> links;
}
public class Page {
String url;
Site site;
PageMetrics metrics;
}
public class Link {
Page linkFrom;
Page linkTo;
String anchorText;
}
这个快速搜索工具的应用程序中有十多个 Visitor,它们所做的事情类似于 选择页面做进一步分析、选择不带链接的页面、列出“被链接最多”的页面,等 等。因为所有这些操作都很简单,所以 Visitor 模式(如清单 2 所示)可以工 作得很好,由于数据结构可以放到内存中,因此就算进行彻底搜索,花费也不是 很大:
清单 2. 用于 Web 快速搜索工具数据库的 Visitor 模式
public interface Visitor {
public void visitSite(Site site);
public void visitLink(Link link);
}
噢,忘记报告了
如果不运行报告的话,Visitor 策略在访问数据方面会做得非常好。使用数 据库进行持久存储的一个好处是:在生成报告时,SQL 的能力就会大放光彩 — — 几乎可以让数据库做任何事情。甚至用 SQL 生成报告原型也很容易 —— 运 行原型报告,如果结果不是所需要的结果,那么可以修改 SQL 查询或者编写新 的查询,然后再试一试。如果改变的只是 SQL 查询的话,那么这个编辑-编译- 运行周期可能很快。如果 SQL 不是存储在程序中,那么您甚至可以跳过这个周 期的编译部分,这样可以快速生成报告的原型。确定所需要的报告后,将它们构 建到应用程序中就很容易了。
因此,虽然对于添加新结果、寻找特定的结果和进行特殊传输来说,内存中 的数据结构都表现得很不错,但是对于报告来说,这些变成了不利条件。对于所 有其自身结构与数据库结构不同的报告,Visitor 都必须创建一个全新的数据结 构,以包含报告数据。因此,每一种报告类型都需要有自己的、特定于报告的中 间数据结构来存放结果,还需要一个用来填充中间数据结构的访问者,以及用来 将中间数据结构转换成最终报告的后处理(post-processing)代码。似乎需要 做很多工作,尤其在大多数原型报告将被抛弃时。例如,假定您想要列出所有从 其他网站链接到某个给定网站的页面的报告、所有外部页面的列表报告,以及站 点上链接该页面的那些页面的列表,然后,根据链接的数量对报告进行归类,链 接最多的页面显示在最前面。这个计划基本上将数据结构从里到外翻了个个儿。 为了用 Visitor 实现这种数据转换,需要获得从某个给定网站可以到达的外部 页面链接的列表,并根据被链接的页面对它们进行分类,如清单 3 所示:
清单 3. Visitor 列出被链接最多的页面,以及链接到它们的页面
public class InvertLinksVisitor {
public Map<Page, Set<Page>> map = ...;
public void visitLink(Link link) {
if (link.linkFrom.site.equals(targetSite)
&& !link.linkTo.site.equals(targetSite)) {
if (!map.containsKey(link.linkTo))
map.put(link.linkTo, new HashSet<Page> ());
map.get(link.linkTo).add(link.linkFrom);
}
}
}
清单 3 中的 Visitor 生成一个映射,将每一个外部页面与链接它的一组内 部页面相关联。为了准备该报告,还必须根据关联页面的大小对这些条目进行分 类,然后创建报告。虽然没有任何困难步骤,但是每一个报告需要的特定于报告 的代码数量却很多,因此快速报告原型就成为一个重要的目标(因为没有提出报 告要求),试验新报告的开销比理想情况更高。许多报告需要多次传递数据,以 便对数据进行选择、汇总和分类。