使用Rust async构建高性能爬虫

在之前的三篇文章(异步编程async、[Rust async原理剖析](https://xx/Rust async原理剖析)和async在实际场景中的应用)中,我们从异步编程的基本概念、Rust async的底层原理,最后到async在实际场景中的应用逐步对异步编程进行展开。本篇我们继续围绕异步编程这个主题,将之前介绍的内容应用到一个实战项目中–使用Rust async构建高性能爬虫。

为什么用Rust

Rust是是一门现代系统编程语言,专注于安全性、速度和并发性。它的性能接近C/C++,自身的并发模型和Tokio提供的成熟的异步运行时,能轻松支持百万协程并发,而且其在编译期就能保证内存安全和线程安全,并且无GC风险,避免了爬虫系统中常见的内存泄漏与崩溃风险,所以Rust非常适合开发高性能的爬虫系统。

Rust上述的这些优点也给它带来了一些使用门槛,如果你只是想抓几个页面,并没有太高的性能要求,那Rust可能不太适合你,或许用Python或者Go更能快的满足你的需求。

使用Rust开发爬虫系统涉及到的依赖包如下:

需求依赖包(推荐)
并发运行时tokio
HTTP下载reqwest
HTML解析scraper
URL去重dashmap

架构设计

为了构建一个高性能的异步爬虫系统,对其进行模块化设计,各模块主要包括:

  • 请求器:主要用于构建HTTP请求,自定义User_Agent等请求相关的功能
  • 请求执行器:主要负责发起HTTP请求,并发设置以及延迟请求等相关执行功能
  • 结果解析器:主要负责处理HTML响应,提取目标数据以及新链接处理等功能
  • 数据Pipeline:主要负责将结构化的数据进行异步存储
  • 任务调度器:主要负责管理爬虫任务的调度

模块实现

请求器(Requester)

请求器模块主要负责构建标准的HTTP请求,这里通过reqwest::RequestBuilder设置请求头、自定义的User-Agent等属性。

声明一个Requester结构体,增加一些请求相关的属性,比如user_agent和延迟请求时间delay_ms,示例代码如下:

use reqwest::{Client, RequestBuilder};
use std::time::Duration;

#[derive(Clone)]
pub struct Requester {
    client: Client,
    pub user_agent: String,
    pub delay_ms: u64,
}

impl Requester {
    pub fn new(user_agent: &str, delay_ms: u64) -> Self {
        let client = Client::builder()
            .user_agent(user_agent)
            .build()
            .unwrap();

        Self {
            client,
            user_agent: user_agent.to_string(),
            delay_ms,
        }
    }

    pub fn build_request(&self, url: &str) -> RequestBuilder {
        self.client.get(url)
    }
}Code language: PHP (php)

请求执行器(Fetcher)

请求执行器负责异步发起HTTP请求,这里通过tokioreqwest来实现并发请求。

声明一个Fetcher结构体,通过Requseter发起请求,示例代码如下:

use crate::requester::Requester; 
use tokio::time::{sleep, Duration};

pub struct Fetcher;

impl Fetcher {
    pub async fn fetch(requester: &Requester, url: &str) -> Option<String> {
        sleep(Duration::from_millis(requester.delay_ms)).await;

        let request = requester.build_request(url);

        match request.send().await {
            Ok(resp) => match resp.text().await {
                Ok(body) => Some(body),
                Err(e) => {
                    eprintln!("failed to read response: {e:?}");
                    None
                }
            },
            Err(e) => {
                eprintln!("failed to request: {e:?}");
                None
            }
        }
    }
}Code language: PHP (php)

结果解析器(Parser)

结果解析器处理HTML响应,提取目标数据和新的连接,这里使用scraper进行解析。

由于爬取网站的多样性,将解析器Parser抽象成一个trait,通过实现不同的Parser来完成不同的解析任务。示例代码如下:

use async_trait::async_trait;

#[async_trait]
pub trait Parser: Send + Sync {
    async fn parse(&self, html: &str) -> ParseResult;
}

pub struct ParseResult {
    pub data: Vec<String>,
    pub new_links: Vec<String>
}Code language: PHP (php)

数据Pipeline(Pipeliner)

数据Pipeline负责将结构化的数据进行存储,例如存储到数据库。

由于存储类型的多样性,也将Pipeliner声明为一个trait,示例代码如下:

use async_trait::async_trait;

#[async_trait]
pub trait Pipeline: Send + Sync {
    async fn process(&self, data: Vec<T>);
}Code language: PHP (php)

任务调度器(Scheduler)

任务调度器主要负责爬虫任务的调度,是其核心模块,这里使用tokio::sync::mpsc作为任务队列,并使用dashmap对URL去重。

use tokio::sync::mpsc::{self, Sender, Receiver};
use dashmap::DashSet;
use std::sync::Arc;

pub struct Scheduler {
    seen: Arc<DashSet<String>>,
    sender: Sender<String>,
}

impl Scheduler {
    pub fn new(sender: Sender<String>) -> Self {
        Self {
            seen: Arc::new(DashSet::new()),
            sender,
        }
    }

    pub fn try_enqueue(&self, url: String) {
        if self.seen.insert(url.clone()) {
            let _ = self.sender.try_send(url);
        }
    }
}Code language: PHP (php)

健壮性设计

上述模块只是爬虫系统中的基础模块,但是为了保证爬虫系统能长时间的稳定高效的自动运行,还需要在以下几个方面进行完善,保证系统的健壮性。

  • 重试机制
    由于网络不稳定或者链接失效,请求失败是很常见的,所以需要对这种情况进行处理,使其能够进行自动重试并记录失效链接。
  • 任务隔离
    爬虫系统中会有很多爬取任务,避免各个任务任务之间相互干扰而影响整个系统,可以使用tokio::spawn对任务进行隔离。
  • 链接校验
    在爬取过程中,会遇到很多URL链接,为了避免引起过多的重试机制,在爬取之前,应该校验URL是否合法并且完整。

性能提升

随着爬虫系统的长期稳定运行以及需求的不断增加,需要进一步提升爬虫的性能,可以从以下几个方面进行性能提升。

  • 连接复用
    reqwest默认基于hyper支持连接池,可直接使用其进行TCP复用连接,减少HTTP请求的连接。
  • 多任务调度
    将爬虫中的各模块中的任务调度拆分为多个任务队列,提升各自调度的吞吐量。
  • 分布式部署
    对整个爬虫系统进行分布式改造,进行多实例部署,基于消息队列对URL进行分发,各个节点并行爬取。
  • 增量爬取
    避免任务中途失败,任务重启之后从新进行爬取,可将其爬取进度保存下来,以便进行增量爬取。

总结

Rust由于专注于安全性、速度和并发性,为其提供了系统级的性能和内存安全保障,而且Rust还具有丰富的社区生态,使开发者能够方便高效的构建高性能、稳定和可扩展的爬虫系统。

本文通过对爬虫系统进行了内部分析,并将其拆分多个模块,随后通过完善其中的一些问题,增强系统的健壮性,最后根据一些优化策略来提升系统的性能,以便适应更多的需求。