手里有锤子的时候,看什么东西都像钉子(就像古谚语所说的那样)。但是如果没有锤子时该怎样办呢?有时,您可以去借一把锤子。然后,拿着这把借来的锤子敲打虚拟的钉子,最后归还锤子,没人知道这些。在本月的 Java 理论与实践 系列中,Brian Goetz 将演示如何将 SQL 或者 XQuery 这样的数据操纵之锤应用于非持久存储的数据。请在本文附带的 讨论论坛 中与作者和其他读者分享您对本文的看法。(也可以单击本文顶部或底部的 讨论 来访问该论坛。)
我最近仔细考察了一个项目,该项目涉及相当多的 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 生成一个映射,将每一个外部页面与链接它的一组内部页面相关联。为了准备该报告,还必须根据关联页面的大小对这些条目进行分类,然后创建报告。虽然没有任何困难步骤,但是每一个报告需要的特定于报告的代码数量却很多,因此快速报告原型就成为一个重要的目标(因为没有提出报告要求),试验新报告的开销比理想情况更高。许多报告需要多次传递数据,以便对数据进行选择、汇总和分类。
我的数据模型王国
这时,缺少一个正式的数据模型开始成为一项不利因素,该数据模型可以用于描述收集的数据,并且可以用它更容易地表示选择和聚合查询。也许懒惰不像开始希望的那样有效。但是,虽然这个应用程序缺少正式数据模型,但也许我们可以将数据存储到内存中的数据库,并凭借该数据库进行查询,通过这种方式借用一个数据模型。有两种可能会立即出现在您的脑海中:开源的内存中的 SQL 数据库 HSQLDB 和 XQuery。我不需要数据库提供的持久性,但是我确实需要查询语言。
HSQLDB 是一个用 Java 语言编写的可嵌入的数据库引擎。它既包含适用于内存中表的表类型,又包含适用于基于磁盘的表的表类型,设计该引擎为了将表完全嵌入到应用程序中,消除与大多数真实数据库相关的管理开销。要将数据装载到 HSQLDB,只需编写一个 Visitor 即可,该 Visitor 将遍历内存中的数据结构,并为每一个将要存储的实体生成相应的 INSERT 语句。然后可以对这个内存中的数据库表执行 SQL 查询,以生成报告,并在完成这些操作后抛弃这个“数据库”。
噢,忘记了关系数据库有多烦人
HSQLDB 方法是一个可行方法,但您很快就发现,我必须为对象关系的不匹配而两次(而不是一次)受罚 —— 一次是在将树型结构数据库转换为关系数据模型时,一次是在将平面关系查询结果转换成结构化的 XML 或者 HTML 结果集时。此外,将 JDBC ResultSet 后处理为 DOM 表示形式的 XML 或者 HTML 文档也不是一项很容易的任务,需要为每一个报告提供一些定制的编码。因此虽然内存中的 SQL 数据库 的确 可以简化查询,但是从数据库中存入和取出数据所需要的额外代码会抵消所有节省的代码。
让 XQuery 来拯救您
另一个容易得到的数据查询方法是 XQuery。XQuery 的优点是,它是为生成 XML 或者 HTML 文档作为查询结果而设计的,因此不需要对查询结果进行后处理。这种想法很有吸引力 —— 每个报告只有一层编码,而不是两层或者更多层。因此第一项任务是构建一个表示整个数据集的 XML 文档。设计一个简单的 XML 数据模型和编写遍历数据结构,并将每一个元素附加到一个 DOM 文档中的 Visitor 很简单。(不需要写出这个文档。可以将它保持在内存中,用于查询,然后在完成查询时丢弃它。当底层数据改变时,可以重新生成它。)之后,所有要做的就是编写 XQuery 查询,该查询将选择并聚集用于报告的数据,并按最终需要的格式(XML 或 HTML)对它们进行格式化。查询可以存储在单独的文件中,以便进行快速原型制造,因此,可支持多种报告格式。使用 Saxon 评估查询的代码如清单 4 中所示:
清单 4. 执行 XQuery 查询并将结果序列化为 XML 或 HTML 文档的代码
String query = readFile(queryFile + ".xq");
Configuration c = new Configuration();
StaticQueryContext qp = new StaticQueryContext(c);
XQueryExpression xe = qp.compileQuery(query);
DynamicQueryContext dqc = new DynamicQueryContext(c);
dqc.setContextNode(new DocumentWrapper(document, z.getName(), c));
List result = xe.evaluate(dqc);
FileOutputStream os = new FileOutputStream(fileName);
XMLSerializer serializer = new XMLSerializer (os, format);
serializer.asDOMSerializer();
for(Iterator i = result.iterator(); i.hasNext(); ) {
Object o = i.next();
if (o instanceof Element)
serializer.serialize((Element) o);
else if (o instanceof Attr) {
Element e = document.createElement("scalar");
e.setTextContent(((Attr) o).getNodeValue());
serializer.serialize(e);
}
else {
Element e = document.createElement("scalar");
e.setTextContent(o.toString());
serializer.serialize(e);
}
}
os.close();
|
表示数据库的 XML 文档的结构与内存中的数据结构稍有不同,每一个 <site> 元素都有嵌套的 <page> 元素,每一个 <page> 元素都有嵌套的 <link> 元素,而每一个 <link> 元素都有 <link-to> 和 <link-from> 元素。实践证明,这种表示方法对于大多数报告都很方便。
清单 5 显示了一个示例 XQuery 报告,这个报告处理链接的选择、分类和表示。它有几个地方优于 Visitor 方法 —— 不仅代码少(因为查询语言支持选择、聚积和分类),而且所有报告的代码 —— 选择、聚积、分类和表示 —— 都在一个位置上。
清单 5.生成链接次数最多的页面的完整报告的 XQuery 代码
<html>
<head><title>被链接最多的页面</title></head>
<body>
<ul>
{
let $links := //link[link-to/@siteUrl ne $targetSite
and link-from/@siteUrl eq $targetSite]
for $page in distinct-values($links/link-to/@url)
let $linkingPages := $links[link-to/@url eq $page]/link-from/@url
order by count($linkingPages)
return
<li>Page {$page}, {count($linkingPages)} links
<ul> {
for $p in $linkingPages return <li>Linked from {$p/@url}</li>
}
</ul></li>
}
</ul> </body> </html>
|
结束语
从开发成本角度看,XQuery 方法已证实可以节约大量成本。树型结构对于构建和搜索数据很理想,但对于报告,就不是很理想了。XML 方法很适合于报告(因为可以利用 XQuery 的能力),但是对于整个应用程序的实现,该方法还有很多不便,并会降低性能。因为数据集的大小是可管理的 —— 只有几十兆字节,所以可以将数据从一种格式转换为从开发的角度看最方便的另一种格式。更大的数据集,比如不能完全存储到内存中的数据集,会要求整个应用程序都围绕着一个数据库构建。虽然有许多处理数据持久性的好工具,但是它们需要的工作都比简单操纵内存中数据结构要多得多。如果数据集的大小合适,那么就可以同时利用这两种方法的长处。