分布式爬虫(三)| 青训营笔记

357 阅读8分钟

这是我参与「第四届青训营 」笔记创作活动的第三天

URL调度系统

4.jpg


URL调度系统是实现整个爬虫系统分布式的桥梁与关键,正是通过URL调度系统的使用,才使得整个爬虫系统可以较为高效(Redis作为存储)随机地获取URL,并实现整个系统的分布式。\

URL仓库

通过架构图可以看出,所谓的URL仓库不过是Redis仓库,即在我们的系统中使用Redis来保存URL地址列表,正是这样,才能保证我们的程序实现分布式,只要保存了URL是唯一的,这样不管我们的爬虫程序有多少个,最终保存下来的数据都是只有唯一一份的,而不会重复,是通过这样来实现分布式的。

同时URL仓库中的URL地址在获取时的策略是通过队列的方式来实现的,待会通过URL调度器的实现即可知道。

另外,在我们的URL仓库中,主要保存了下面的数据:

种子URL列表

Redis的数据类型为list。

种子URL是持久化存储的,一定时间后,由URL定时器通过种子URL获取URL,并将其注入到我们的爬虫程序需要使用的高优先级URL队列中,这样就可以保存我们的爬虫程序可以源源不断地爬取数据而不需要中止程序的执行。

高优先级URL队列

Redis的数据类型为set。

什么是高优先级URL队列?其实它就是用来保存列表URL的。

那么什么是列表URL呢?

说白了就是一个列表中含有多个商品,以京东为列,我们打开一个手机列表为例:\

5.png


该地址中包含的不是一个具体商品的URL,而是包含了多个我们需要爬取的数据(手机商品)的列表,通过对每个高级url的解析,我们可以获取到非常多的具体商品URL,而具体的商品URL,就是低优先URL,其会保存到低优先级URL队列中。

那么以这个系统为例,保存的数据类似如下:\

jd.com.higher
--https://list.jd.com/list.html?cat=9987,653,655&page=1
... 
suning.com.higher
--https://list.suning.com/0-20006-0.html
...


低优先级URL队列

Redis的数据类型为set。

低优先级URL其实就是具体某个商品的URL,如下面一个手机商品:\

6.png


通过下载该URL的数据,并对其进行解析,就能够获取到我们想要的数据。

那么以这个系统为例,保存的数据类似如下:\

jd.com.lower
--https://item.jd.com/23545806622.html
...
suning.com.lower
--https://product.suning.com/0000000000/690128156.html
...

\

URL调度器

所谓URL调度器,其实说白了就是URL仓库Java代码的调度策略,不过因为其核心在于调度,所以将其放到URL调度器中来进行说明,目前其调度基于以下接口开发:\

/**
* URL 仓库
* 主要功能:
*      向仓库中添加URL(高优先级的列表,低优先级的商品URL)
*      从仓库中获取URL(优先获取高优先级的URL,如果没有,再获取低优先级的URL)
*
*/
public interface IRepository {

/**
 * 获取URL的方法
 * 从仓库中获取URL(优先获取高优先级的URL,如果没有,再获取低优先级的URL)
 * @return
 */
public String poll();

/**
 * 向高优先级列表中添加商品列表URL
 * @param highUrl
 */
public void offerHigher(String highUrl);

/**
 * 向低优先级列表中添加商品URL
 * @param lowUrl
 */
public void offerLower(String lowUrl);

} 


其基于Redis作为URL仓库的实现如下:\

/**
* 基于Redis的全网爬虫,随机获取爬虫URL:
*
* Redis中用来保存URL的数据结构如下:
* 1. 需要爬取的域名集合(存储数据类型为set,这个需要先在Redis中添加)
*      key
*          spider.website.domains
*      value(set)
*          jd.com  suning.com  gome.com
*      key由常量对象SpiderConstants.SPIDER_WEBSITE_DOMAINS_KEY 获得
* 2. 各个域名所对应的高低优先URL队列(存储数据类型为list,这个由爬虫程序解析种子URL后动态添加)
*      key
*          jd.com.higher
*          jd.com.lower
*          suning.com.higher
*          suning.com.lower
*          gome.com.higher
*          gome.come.lower
*      value(list)
*          相对应需要解析的URL列表
*      key由随机的域名 + 常量 SpiderConstants.SPIDER_DOMAIN_HIGHER_SUFFIX或者SpiderConstants.SPIDER_DOMAIN_LOWER_SUFFIX获得
* 3. 种子URL列表
*      key
*          spider.seed.urls
*      value(list)
*          需要爬取的数据的种子URL
*       key由常量SpiderConstants.SPIDER_SEED_URLS_KEY获得
*
*       种子URL列表中的URL会由URL调度器定时向高低优先URL队列中
*/
public class RandomRedisRepositoryImpl implements IRepository {

/**
 * 构造方法
 */
public RandomRedisRepositoryImpl() {
    init();
}

/**
 * 初始化方法,初始化时,先将Redis中存在的高低优先级URL队列全部删除
 * 否则上一次URL队列中的URL没有消耗完时,再停止启动跑下一次,就会导致URL仓库中有重复的URL
 */
public void init() {
    Jedis jedis = JedisUtil.getJedis();
    Set<String> domains = jedis.smembers(SpiderConstants.SPIDER_WEBSITE_DOMAINS_KEY);
    String higherUrlKey;
    String lowerUrlKey;
    for(String domain : domains) {
        higherUrlKey = domain + SpiderConstants.SPIDER_DOMAIN_HIGHER_SUFFIX;
        lowerUrlKey = domain + SpiderConstants.SPIDER_DOMAIN_LOWER_SUFFIX;
        jedis.del(higherUrlKey, lowerUrlKey);
    }
    JedisUtil.returnJedis(jedis);
}

/**
 * 从队列中获取URL,目前的策略是:
 *      1. 先从高优先级URL队列中获取
 *      2. 再从低优先级URL队列中获取
 *  对应我们的实际场景,应该是先解析完列表URL再解析商品URL
 *  但是需要注意的是,在分布式多线程的环境下,肯定是不能完全保证的,因为在某个时刻高优先级url队列中
 *  的URL消耗完了,但实际上程序还在解析下一个高优先级URL,此时,其它线程去获取高优先级队列URL肯定获取不到
 *  这时就会去获取低优先级队列中的URL,在实际考虑分析时,这点尤其需要注意
 * @return
 */
@Override
public String poll() {
    // 从set中随机获取一个顶级域名
    Jedis jedis = JedisUtil.getJedis();
    String randomDomain = jedis.srandmember(SpiderConstants.SPIDER_WEBSITE_DOMAINS_KEY);    // jd.com
    String key = randomDomain + SpiderConstants.SPIDER_DOMAIN_HIGHER_SUFFIX;                // jd.com.higher
    String url = jedis.lpop(key);
    if(url == null) {   // 如果为null,则从低优先级中获取
        key = randomDomain + SpiderConstants.SPIDER_DOMAIN_LOWER_SUFFIX;    // jd.com.lower
        url = jedis.lpop(key);
    }
    JedisUtil.returnJedis(jedis);
    return url;
}

/**
 * 向高优先级URL队列中添加URL
 * @param highUrl
 */
@Override
public void offerHigher(String highUrl) {
    offerUrl(highUrl, SpiderConstants.SPIDER_DOMAIN_HIGHER_SUFFIX);
}

/**
 * 向低优先URL队列中添加URL
 * @param lowUrl
 */
@Override
public void offerLower(String lowUrl) {
    offerUrl(lowUrl, SpiderConstants.SPIDER_DOMAIN_LOWER_SUFFIX);
}

/**
 * 添加URL的通用方法,通过offerHigher和offerLower抽象而来
 * @param url   需要添加的URL
 * @param urlTypeSuffix  url类型后缀.higher或.lower
 */
public void offerUrl(String url, String urlTypeSuffix) {
    Jedis jedis = JedisUtil.getJedis();
    String domain = SpiderUtil.getTopDomain(url);   // 获取URL对应的顶级域名,如jd.com
    String key = domain + urlTypeSuffix;            // 拼接URL队列的key,如jd.com.higher
    jedis.lpush(key, url);                          // 向URL队列中添加URL
    JedisUtil.returnJedis(jedis);
}
} 


通过代码分析也是可以知道,其核心就在如何调度URL仓库(Redis)中的URL。\

URL定时器

一段时间后,高优先级URL队列和低优先URL队列中的URL都会被消费完,为了让程序可以继续爬取数据,同时减少人为的干预,可以预先在Redis中插入种子URL,之后定时让URL定时器从种子URL中取出URL定存放到高优先级URL队列中,以此达到程序定时不间断爬取数据的目的。
\

URL消费完毕后,是否需要循环不断爬取数据根据个人业务需求而不同,因此这一步不是必需的,只是也提供了这样的操作。因为事实上,我们需要爬取的数据也是每隔一段时间就会更新的,如果希望我们爬取的数据也跟着定时更新,那么这时定时器就有非常重要的作用了。不过需要注意的是,一旦决定需要循环重复爬取数据,则在设计存储器实现时需要考虑重复数据的问题,即重复数据应该是更新操作,目前在我设计的存储器不包括这个功能,有兴趣的朋友可以自己实现,只需要在插入数据前判断数据库中是否存在该数据即可。
另外需要注意的一点是,URL定时器是一个独立的进程,需要单独启动。

定时器基于Quartz实现,下面是其job的代码:\

/**
* 每天定时从URL仓库中获取种子URL,添加进高优先级列表
*/
public class UrlJob implements Job {

// log4j日志记录
private Logger logger = LoggerFactory.getLogger(UrlJob.class);

@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
    /**
     * 1. 从指定URL种子仓库获取种子URL
     * 2. 将种子URL添加进高优先级列表
     */
    Jedis jedis = JedisUtil.getJedis();
    Set<String> seedUrls = jedis.smembers(SpiderConstants.SPIDER_SEED_URLS_KEY);  // spider.seed.urls Redis数据类型为set,防止重复添加种子URL
    for(String seedUrl : seedUrls) {
        String domain = SpiderUtil.getTopDomain(seedUrl);   // 种子url的顶级域名
        jedis.sadd(domain + SpiderConstants.SPIDER_DOMAIN_HIGHER_SUFFIX, seedUrl);
        logger.info("获取种子:{}", seedUrl);
    }
    JedisUtil.returnJedis(jedis);
//        System.out.println("Scheduler Job Test...");
}

} URL
调度器的实现如下:
{{{/**
* URL定时调度器,定时向URL对应仓库中存放种子URL
*
* 业务规定:每天凌晨1点10分向仓库中存放种子URL
*/
public class UrlJobScheduler {

public UrlJobScheduler() {
    init();
}

/**
 * 初始化调度器
 */
public void init() {
    try {
        Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();

        // 如果没有以下start方法的执行,则是不会开启任务的调度
        scheduler.start();

        String name = "URL_SCHEDULER_JOB";
        String group = "URL_SCHEDULER_JOB_GROUP";
        JobDetail jobDetail = new JobDetail(name, group, UrlJob.class);
        String cronExpression = "0 10 1 * * ?";
        Trigger trigger = new CronTrigger(name, group, cronExpression);

        // 调度任务
        scheduler.scheduleJob(jobDetail, trigger);

    } catch (SchedulerException e) {
        e.printStackTrace();
    } catch (ParseException e) {
        e.printStackTrace();
    }
}

public static void main(String[] args) {
    UrlJobScheduler urlJobScheduler = new UrlJobScheduler();
    urlJobScheduler.start();
}

/**
 * 定时调度任务
 * 因为我们每天要定时从指定的仓库中获取种子URL,并存放到高优先级的URL列表中
 * 所以是一个不间断的程序,所以不能停止
 */
private void start() {
    while (true) {

    }
}
}