自己动手写网络爬虫(修订版)
上QQ阅读APP看书,第一时间看更新

1.4 设计爬虫架构

上一节讲述了爬虫队列,本节将介绍如何设计爬虫架构。

良好的爬虫架构设计必须满足如下需求。

● 分布式:爬虫应该能够在多台机器上分布执行。

● 可伸缩性:爬虫结构应该能够通过增加额外的机器和带宽来提高抓取速度。

● 性能和有效性:爬虫系统必须有效地使用各种系统资源,例如,处理器、存储空间和网络带宽。

● 质量:鉴于互联网的发展速度,大部分网页都不可能及时出现在用户查询中,所以爬虫应该首先抓取有用的网页。

● 新鲜性:在许多应用中,爬虫应该持续运行而不是只遍历一次。

● 更新:因为网页会经常更新,例如论坛网站会经常有回帖。爬虫应该取得已经获取的页面的新拷贝。例如,一个搜索引擎爬虫要能够保证全文索引中包含每个索引页面的较新状态。对于搜索引擎爬虫这样连续的抓取,爬虫访问一个页面的频率应该和这个网页的更新频率一致。

● 可扩展性:为了能够支持新的数据格式和新的抓取协议,爬虫架构应该设计成模块化的形式。

1.4.1 爬虫架构

爬虫的简化版本架构如图1.9所示。

图1.9 爬虫物理分布简化架构图

这里最主要的关注对象是爬虫和存储库。其中的爬虫部分阶段性地抓取互联网上的内容。存储库存储爬虫下载下来的网页,是分布式的和可扩展的存储系统。在往存储库中加载新的内容时仍然可以读取存储库。

实际的爬虫逻辑架构如图1.10所示。其中:

图1.10 单线程爬虫结构

(1)URL Frontier包含爬虫当前待抓取的URL(对于持续更新抓取的爬虫,以前已经抓取过的URL可能会回到Frontier重抓)。

(2)DNS解析模块根据给定的URL决定从哪个Web服务器获取网页。

(3)获取模块使用HTTP协议获取URL代表的页面。

(4)解析模块提取文本和网页的链接集合。

(5)重复消除模块决定一个解析出来的链接是否已经在URL Frontier或者最近下载过。

DNS解析是网络爬虫的瓶颈。由于域名服务的分布式特点,DNS可能需要多次请求转发,并在互联网上往返,需要几秒有时甚至更长的时间解析出IP地址。如果我们的目标是一秒抓取数百个文件,这样就达不到性能要求。一个标准的补救措施是引入缓存,最近完成DNS查询的网址可能会在DNS缓存中找到,避免了访问互联网上的DNS服务器。然而,由于抓取礼貌的限制,降低了DNS缓存的命中率。

用DNS解析还有一个难点:在标准库中实现的查找是同步的。这意味着一旦一个请求发送到DNS服务器上,在那个节点上的其他爬虫线程即被阻塞,直到第一个请求完成。为了避免这种情况发生,许多爬虫自己来实现DNS解析。执行解析代码的线程i将发送一个消息到DNS服务器,然后执行一个定时等待,不超过这个设定的时间段,这个线程会继续执行。一个单独的DNS线程侦听标准的DNS端口(53端口)从名称服务器传入响应数据包。一旦接受一个响应,它就激活对应的爬虫线程i并把响应数据包交给i。如果i因为等待超时还没有恢复运行,爬虫线程在尝试5次全都失败后,下次发送一个新的消息给DNS服务说明等待的时间会延长一倍。鉴于有的主机名需要长达几十秒的时间来解析,所以等待DNS解析的时间范围可以为1~90秒。

1.4.2 设计并行爬虫架构

整个爬虫系统可以由一台抓取机器或多个爬虫节点组成。多机并行抓取的分布式系统需要考虑节点之间的通信和调度。关于分布式爬虫系统将在第2章介绍。在一个爬虫节点上实现并行抓取,可以考虑多线程同步I/O或者单线程异步I/O。多线程爬虫需要考虑线程之间的同步问题。

对单线程并行抓取来说,异步I/O是很重要的基本功能。异步I/O模型大体上可以分为两种:反应式(Reactive)模型和前摄式(Proactive)模型。传统的select/epoll/kqueue模型,以及Java NIO模型,都是典型的反应式模型,即应用代码对I/O描述符进行注册,然后等待I/O事件。当某个或某些I/O描述符所对应的I/O设备上产生I/O事件(可读、可写、异常等)时,系统将发出通知,于是应用便有机会进行I/O操作并避免阻塞。由于在反应式模型中应用代码需要根据相应的事件类型采取不同的动作,因此最常见的结构便是嵌套的if {…} else {…}或switch,并常常需要结合状态机来完成复杂的逻辑。前摄式模型则恰恰相反。在前摄式模型中,应用代码主动投递异步操作而不管I/O设备当前是否可读或可写。投递的异步I/O操作被系统接管,应用代码并不阻塞在该操作上,而是指定一个回调函数并继续自己的应用逻辑。当该异步操作完成时,系统将发起通知并调用应用代码指定的回调函数。在前摄式模型中,程序逻辑由各个回调函数串联起来,异步操作A的回调发起异步操作B,B的回调再发起异步操作C,以此往复。

Java 6版本开始引入的NIO包,通过Selectors类提供了非阻塞式的I/O。Java 7附带的NIO.2文件系统中包含了异步I/O支持,也可以使用框架实现异步I/O。例如:

● Mina(http://mina.apache.org/)为开发高性能和高可用性的网络应用程序提供了非常便利的框架。当前发行的MINA版本支持基于Java的NIO技术的TCP/UDP应用程序开发。MINA是借由Java的NIO反应式实现的模拟前摄式模型。

● Grizzly是Web服务器GlassFish的I/O核心。Grizzly通过队列模型提供异步读/写功能。

● Netty是一个NIO客户端服务器框架。

● Naga(http://naga.googlecode.com)是一个很小的库,提供了一些Java类,把普通的Socket和ServerSocket封装成支持NIO的形式。

从性能测试上比较,Netty和Grizzly都很快,而Mina稍慢一些。

JDK 1.6内部并不使用线程来实现非阻塞式I/O。在Windows平台下,使用select();在新的Linux核下,使用epoll工具。

Niocchi(http://www.niocchi.com)是Java实现的开源异步I/O爬虫。

在爬虫中使用NIO的时候,主要用到下面的两个类。

● java.nio.channels.Selector:Selector类通过调用select方法,将注册的channel中有事件发生的SelectionKey取出来进行处理。如果想要把管理权交到Selector类手中,首先就要在Selector对象中注册相应的Channel。

● java.nio.channels.SocketChannel:SocketChannel用于和Web服务器建立连接。

下面是Niocchi中使用NIO下载网页的例子,首先发送请求:

SocketChannel sc = SocketChannel.open();
sc.configureBlocking(false);
sc.socket(). setTcpNoDelay(true);

boolean connected = false;
try
{
    connected = sc.connect(query_.getInetSocketAddress());
}
catch( IOException e )
{
    _logger.warn("IOException " + query_.getURL(), e);
    query_.setStatus(INTERNAL_ERROR);
    processCrawledQuery(query);
    return;
}

if(connected) //检查连接是否建立
{
    if(! sendQuery( query_, sc))
    {
        query_.setStatus(UNREACHABLE);
        processCrawledQuery(query_);
        return ;
    }

    sc.register(selector, SelectionKey.OP_READ, query);
}
else
{
    sc.register(selector, SelectionKey.OP_CONNECT, query);
}

接着接收数据:

int i = _selector.select(selectTimeout);
select_total_time += new Date().getTime()- t;

if(i == 0)
{
    _logger.debug("Select timeout");

    Set keys = _selector.keys();
    Iterator it = keys.iterator();
    while(it.hasNext())
    {
        SelectionKey key = (SelectionKey)it.next();
        Query query = (Query) key.attachment();
        if (_logger.isDebugEnabled())
            _logger.debug("Select timeout for '" + query.getURL() + "'");
        query.setStatus(TIMEOUT);
        processKey(key);
    }

    if( ! _resolver_queue.hasNextQuery() && (_reg_count == 0) &&
     (!_redirection_resolver_queue.hasMore()) )
    {
        break;    // 没有更多URL
    }

    continue;
}

// 检查是否超时
long time = System.currentTimeMillis();
Set keys = _selector.keys();
Iterator it = keys.iterator();
boolean some_timeout = false;
while( it.hasNext())
{
    SelectionKey key = (SelectionKey)it.next();
    Query query = (Query) key.attachment();
    if(time - query.getRegisterTime() > _timeout)
    {
        if (logger.isDebugEnabled())
            _logger.debug("Timeout for '" + query.getURL() + "'");
        some_timeout = true;

        query.setStatus(TIMEOUT);
        processKey(key);
    }
}
if(some_timeout)
{
    i = _selector.selectNow();
    if(i == -1) continue;
}

Set skeys = _selector.selectedKeys();
it = skeys.iterator();

while(it.hasNext())
{
    SelectionKey key = (SelectionKey)it.next();
    it.remove();
    if((key.readyOps() & SelectionKey.OP_READ) ==
        SelectionKey.OP_READ )
    {
        SocketChannel sc = (SocketChannel)key.channel();
        Exception e = null;
        t = new Date().getTime();
        Query query = (Query) key.attachment();
        Resource resource = query.getResource();

        int r = 0;
         try
        {
            r = resource.read(sc);
            read_total_time += new Date().getTime() - t;
            if (_logger.isTraceEnabled())
                _logger.trace( "read " + r + "for URL '" +
                query.getURL() + "'" );
        }
       catch(IOException ee)
        {
            _logger.warn( "For URL '" + query.getURL() + "' " +
            ee.getMessage() );
            e = ee;
        }
        catch(ResourceException ee)
        {
            _logger.warn( "For URL '" + query.getURL() + "' " +
            ee.getMessage() );
            e = ee;
        }

           // 资源完整地被抓取下来
           if (e != null)
               query.setStatus(INCOMPLETE);

           if ( r < 0 && e == null) {
               if(resource.getHTTPStatus() == 200)
               query.setStatus(CRAWLED);
               else query.setStatus(HTTPERROR);
           }

           // 如果抓取过程出错
           if(e != null || r < 0)
           {
               processKey(key);
           }
       }
       else if((key.readyOps() & SelectionKey.OP_CONNECT) ==
                SelectionKey.OP_CONNECT )
       {
           SocketChannel sc = (SocketChannel)key.channel();
           Query query = (Query) key.attachment();
           try
           {
               sc.finishConnect();
           }
           catch(IOException e)
           {
               _logger.warn("Connection error to " + query.getURL());
               + '\'' );
               processKey(key);
               continue;
           }

           if( ! sendQuery(query, sc))
           {
               processKey(key);
               query.setStatus(UNREACHABLE);
               continue;
           }


           sc.register(selector, SelectionKey.OP_READ, query);
       }
}

1.4.3 详解Heritrix爬虫架构

前面介绍了一个开源爬虫软件Heritrix。现在,我们来看一下它的架构是如何设计的。

Heritrix采用的是模块化的设计,各个模块由一个控制器类(CrawlController类)来协调,因此控制器是它的核心。CrawlController类的结构如图1.11所示。

图1.11 CrawlController类的结构

CrawlController类是整个爬虫的总控制者,控制整个抓取工作的起点,决定整个抓取任务的开始和结束。CrawlController从Frontier获取URL,传递给线程池(ToePool)中的ToeThread处理。

Frontier(边界控制器)主要确定下一个将被处理的URL,负责访问的均衡处理,避免对某一Web服务器造成太大的压力。Frontier保存着爬虫的状态,包括已经找到的URI、正在处理中的URI和已经处理过的URI。

Heritrix是按多线程方式抓取的爬虫,主线程把任务分配给Toe线程(处理线程),每个Toe线程每次处理一个URL。Toe线程对每个URL执行一遍URL处理器链。URL处理器链包括以下5个处理步骤。

(1)预取链:主要是做一些准备工作。例如,对处理进行延迟和重新处理,否决随后的操作。

(2)提取链:主要是下载网页,进行DNS转换,填写请求和响应表单。

(3)抽取链:当提取完成时,抽取感兴趣的HTML和JavaScript,通常那里有新的要抓取的URL。

(4)写链:存储抓取结果,可以在这一步直接做全文索引。Heritrix提供了用ARC格式保存下载结果的ARCWriterProcessor实现。

(5)提交链:做和此URL相关操作的最后处理。检查哪些新提取出的URL在抓取范围内,然后把这些URL提交给Frontier。另外还会更新DNS缓存信息。

处理URL的流程如图1.12所示。在实现上,所有的处理类都是一个名称为Processor类的子类。例如,PreconditionEnforcer类继承了Processor。

图1.12 Heritrix处理链图

Frontier记录哪些URI被预定采集和哪些URI已经被采集,并选择下一个URI,剔除已经处理的URI。处理器链包含若干处理器获取的URI,分析结果,并将它们传回给Frontier。

服务器缓存(Server cache)存放服务器的持久信息,能够被爬行部件随时查到,包括被抓取的Web服务器信息,例如DNS查询结果,也就是IP地址。

分析完Controller之后,我们就要看看Heritrix软件的整体架构,如图1.13所示。

图1.13 Heritrix软件的整体架构

图1.13把Heritrix分成以下三部分。

● Web可管理控制台。可以在界面上设置运行时使用哪个模块。Heritrix也是因为有良好的管理界面,所以得到了广泛的应用。Web管理界面默认运行在Heritrix安装包自带的Java HTTP服务器Jetty中,但也可以作为Web应用运行在Tomcat或Resin等Web服务器中。操作者可以通过选择Crawler命令来操作控制台。

● 抓取顺序配置文件。可以在配置文件ORDER.XML中指定抓取的顺序。

● 总控整个爬虫的CrawlController。

Heritrix包含以下关键特性。

● 用单个爬虫在多个独立的站点一直不断地抓取。

● 从一个种子URL开始爬,不断抓取页面所指向的URL。

● 主要是用宽度优先算法进行处理。

● 主要部件都是高效的和可扩展的。

可以配置的部分包括:

● 可设置输出日志、归档文件和临时文件的位置;

● 可设置下载的最大字节、最大数量的下载文档和最大的下载时间;

● 可设置工作线程数量;

● 可设置所利用的带宽的上界;

● 可在设置一定时间之后重新选择;

● 包含一些可设置的过滤机制、表达方式、URI路径深度选择等。

尽管Heritrix是设计良好的爬虫,但它也有以下一些局限。

● 不支持多机分布式抓取。

● 在有限的机器资源情况下,却要复杂的操作。

● 只有官方支持,仅仅在Linux上进行了测试。

● 每个爬虫是单独进行工作的,没有对更新进行修订。

● 在硬件和系统失败时,恢复能力很差。

● 性能还不够优化。

本节我们介绍了如何设计一个爬虫架构,并且详细讲述了一个开源爬虫——Heritrix的爬虫结构。为读者今后开发自己的爬虫提供了整体上的参考。