Spiderman源码分析(二)调度和执行

来源:互联网 发布:网络黄金双涡轮平台 编辑:程序博客网 时间:2024/06/02 06:37

    这节我们来看看Spiderman是如何调度和执行一个爬取任务的。用户可以自行设置爬取任务的调度策略,也可以只执行一次。Spiderman提供了完善的调度策略接口,下面我们先来看看都有哪些和调度相关的字段:

Spiderman:

         isShutdownNow:是否立刻停止每个site的爬取线程,true:表示主调度线程(Spiderman所在线程)已经等待完毕设定的scheduleTime,由主线程完成每个site爬取的停止,

                                      false则表明由定时器任务来停止site的爬取

         pool:所有site爬取调度线程池,这里的每个task是一个site的爬取主线程

         isSchedule:本次爬取是否为调度执行

         scheduleTime:每次调度允许所有site爬取的时间,到点后所有site爬取将被停止

         scheduleDelay:调度间隔,如果是调度执行,调度时间到达后,间隔这时间再进行下一轮爬取

         scheduleTimes:已经执行的调度次数

        maxScheduleTimes:用户设定的最大调度次数

Site:

       thread:每个site爬取的线程数目

       pool:每个site爬取的线程池

       waitQueue:每个site内部等待爬取的任务队列(这里的每个任务就是一个经过封装的URL)

下面我们从四个方面来分析spiderman的调度执行过程,总体调度转移流程,总体调度策略,每个site内部的调度策略,site内部具体的一个Task的执行。

总体调度转移流程

     当用户通过相关接口设定好调度参数后,由spiderman创建一个外部的线程池pool(线程池的corePoolSize和maxPoolSize是site的个数),然后将每个site封装成一个可运行的线程提交到该线程池,由该线程池负责管理每个site线程的运行,在每个site内部又有一个线程池来存放每个具体的爬取任务,这里的线程池corePoolSize和maxPoolSize为thread变量指定大小。spiderman里的调度在下面函数完成:

private Spiderman _startup(){for (Site site : sites){pool.execute(new Spiderman._Executor(site));listener.onInfo(Thread.currentThread(), null, "spider tasks of site[" + site.getName() + "] start... ");}return this;}

上面的pool创建如下:

private void initPool(){if (pool == null){int size = sites.size();if (size == 0)throw new RuntimeException("there is no website to fetch...");pool = new ThreadPoolExecutor(size, size,                    60L, TimeUnit.SECONDS,                    new LinkedBlockingQueue<Runnable>());listener.onInfo(Thread.currentThread(), null, "init thread pool size->"+size+" success ");}}

从pool的创建代码可以看出corePoolSize和maxPoolSize被设定为site个数,这样设置可以保证每个site的爬取平等执行。

site由于没有继承Thread类也没有实现Runnable接口,因此不能独立执行,其被封装在一个Spiderman的内部类_Executor中,该类实现了Runnable接口。下面来看看具体的调度策略:


总体调度策略

 先来看一个直观的调度执行用法:

Spiderman.me().listen(listener)//设置监听器.schedule("10s")//调度,爬虫运行10s.delay("2s")//每隔 10 + 2 秒后重启爬虫.times(3)//调度 3 次.startup()//启动.blocking();//阻塞直到所有调度完成


上面代码,如果最后不调用blocking,主线程可以继续做其它事情,否则就被block知道所有调度执行完毕.schedule,delay,times都是设定调度参数,具体的调度在startup中完成,下面结合startup代码来分析:

public Spiderman startup() {if (isSchedule) <span style="color:#3366ff;">{//为调度执行          final Spiderman _this = this;timer.schedule(new TimerTask() {//<span style="color:#3366ff;">创建TimerTask      public void run() {//限制schedule的次数 
if (_this.maxScheduleTimes > 0 && _this.scheduleTimes >= _this.maxScheduleTimes){try {_this.cancel();} catch (Throwable e){e.printStackTrace();_this.listener.onError(Thread.currentThread(), null, e.toString(), e);}_this.listener.onInfo(Thread.currentThread(), null, "Spiderman has completed and cancel the schedule.");try {_this.listener.onAfterScheduleCancel();} catch (Throwable e) {e.printStackTrace();_this.listener.onError(Thread.currentThread(), null, e.toString(), e);}_this.isSchedule = false;} else {//<span style="color:#3366ff;">未达到最大调度次数</span></strong></span>
//阻塞,判断之前所有的网站是否都已经停止完全//加个超时,加超时保证判断不能无休止的进行,因为即便到了下次调度,上一次调度仍可能有任务未完成             long start = System.currentTimeMillis();long timeout = 1*60*1000;while (true) {try {if ((System.currentTimeMillis() - start) > timeout){_this.listener.onError(Thread.currentThread(), null, "timeout of restart blocking check...", new Exception());
 
                                                                    //到了设定的缓冲时间,停止所有的site爬取
for (Site site : _this.sites) {if (!site.isStop){try {site.destroy(_this.listener, _this.isShutdownNow);} catch (Throwable e){e.printStackTrace();_this.listener.onError(Thread.currentThread(), null, e.toString(), e);}}}break;}if (_this.sites == null || _this.sites.isEmpty())break;                                        //未到设定缓冲时间,如果所有site已停止也可以立刻开始下一轮调度                      Thread.sleep(1*1000);boolean canBreak = true;for (Site site : _this.sites) {if (!site.isStop){canBreak = false;_this.listener.onInfo(Thread.currentThread(), null, "can not restart spiderman cause there has running-tasks of this site -> "+site.getName()+"...");}}if (canBreak)break;} catch (Exception e) {_this.listener.onError(Thread.currentThread(), null, "", e);throw new RuntimeException("Spiderman can not schedule", e);}}try {//只有所有的网站资源都已被释放[特殊情况timeout]完全才重启Spiderman_this.scheduleTimes++;String strTimes = _this.scheduleTimes + "";if (_this.maxScheduleTimes > 0)strTimes += "/"+_this.maxScheduleTimes;//记录每一次调度执行的时间_this.scheduleAt.add(new Date());_this.listener.onInfo(Thread.currentThread(), null, "Spiderman has scheduled "+strTimes+" times.");if (_this.scheduleTimes > 1)_this.listener.onBeforeEveryScheduleExecute(_this.scheduleAt.get(_this.scheduleTimes-2));                                                        //开始一轮新的执行,这里的keepStrict即让调度线程等待设定的调度时间,从该函数内容可以看到,如果设定时间到达,则会立刻去停掉所有的site_this.init()._startup().keepStrict(scheduleTime);} catch (Throwable e) {e.printStackTrace();_this.listener.onError(Thread.currentThread(), null, e.toString(), e);}}}}, new Date(), (CommonUtil.toSeconds(scheduleTime).intValue() + CommonUtil.toSeconds(scheduleDelay).intValue())*1000);return this;}                //非调度执行,只执行一次return _startup();}

通过上述分析,我可以看出spiderman的调度通过Timer和TimerTask来完成,每次调度时间到达后,如果还有site未停止执行,则再留一定的缓冲时间,时间到仍有未完成则停止,这个是TimerTask方面的策略,具体到spiderman的每次执行,即_startup和keepStrict每次启动了所有site的爬取后会等待scheduleTime,如果时间到,则会即刻停止执行

public Spiderman keepStrict(String time){return keepStrict(CommonUtil.toSeconds(time).longValue()*1000);}public Spiderman keepStrict(long time){try {Thread.sleep(time);} catch (InterruptedException e) {}shutdownNow(true);return this;}


 

public void shutdownNow(boolean isCallback){listener.onInfo(Thread.currentThread(), null, "isCallback->" + isCallback);if (isCallback) {//此处添加一个监听回调try {listener.onBeforeShutdown();} catch (Throwable e){e.printStackTrace();listener.onError(Thread.currentThread(), null, e.toString(), e);}}                if (sites != null) {for (Site site : sites){site.destroy(listener, true);listener.onInfo(Thread.currentThread(), null, "Site[" + site.getName() + "] destroy... ");}}if (pool != null) {pool.shutdownNow();listener.onInfo(Thread.currentThread(), null, "Spiderman shutdown now... ");}isShutdownNow = true;if (isCallback) {//此处添加一个监听回调try {listener.onAfterShutdown();} catch (Throwable e){e.printStackTrace();listener.onError(Thread.currentThread(), null, e.toString(), e);}}}

从上面代码可以看出keepStrict会在scheduleTime后关闭所有的site执行,而且Timer的调度间隔大于scheduleTime,按道理,在正常情形下每次TimeTask的调度时,所有的site应该已经都是停止了的,因此上面调度函数中增加的对site是否全部停止的判断似乎有点多余,但这个只是个人猜测,或许某些时候keepStrict并没有完全停掉site,因此这里再增加判断可以起到双重保险的作用,或许本人的理解还不到位,有些情况并没有考虑到,这里顺带谈谈site的destroy(SpiderListener listener, boolean isShutdownNow)

这里的isShutdownNow用来区分site的pool的停止方式,ThreadPoolExecutor提供了两种不同的停止方式shutdownNow和shutdown,从JDK注释可以知道,这两个函数都是停止当前pool中正在执行的任务(不等待任务完成),不同的是后者按照任务提交的顺序停止,前者可以返回正在等待执行的任务列表,估计spiderman设计isShutdownNow来区分这两个底层接口的调用是为了给以后返回正在等待执行的任务留下了接口。

每个site内部的调度策略
从源码观察可以看出,每个site被封装成一个_Executor,而site的执行由重写的run函数完成,在创建_Executor时主要做了两部分工作:

第一部分:根据site配置的线程数来创建每个site的线程池pool,第二部分:实现RejectedExecutionHandler接口,将来不及调度别rejected的任务重新放到任务队列中

public _Executor(Site site){this.site = site;String strSize = site.getThread();int size = Integer.parseInt(strSize);listener.onInfo(Thread.currentThread(), null, "site thread size -> " + size);RejectedExecutionHandler rejectedHandler = new RejectedExecutionHandler() {public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {//拿到被弹出来的爬虫引用Spider spider = (Spider)r;try {//将该爬虫的任务 task 放回队列spider.pushTask(Arrays.asList(spider.task));String info = "repush the task->"+spider.task+" to the Queue.";spider.listener.onError(Thread.currentThread(), spider.task, info, new Exception(info));} catch (Exception e) {String err = "could not repush the task to the Queue. cause -> " + e.toString();spider.listener.onError(Thread.currentThread(), spider.task, err, e);}}};                        <span style="color:#3366ff;">//根据设定的线程数目创建ThreadPoolExecutor,corePoolSize和maximumPoolSize相同保证最大并发度,这里使用了无界的BlockingQueue可以最大程度保证不会有task被rejected,根据<span style="color:#3366ff;">ThreadPoolExecutor</span>的定义,这时的keepAliveTime已经无意义</span> if (size > 0)this.site.pool = new ThreadPoolExecutor(size, size,60L, TimeUnit.SECONDS,                    new LinkedBlockingQueue<Runnable>(),                    rejectedHandler);else<span style="color:#3366ff;">//Site中thread的默认值是1,如果这里读到的为0(或许用户误设置等其它原因),则重建有界的BlockingQueue但无上界的</span><span style="color:#3366ff;">maximumPoolSize</span>this.site.pool = new ThreadPoolExecutor(0, Integer.MAX_VALUE,                    60L, TimeUnit.SECONDS,                    new SynchronousQueue<Runnable>(),                    rejectedHandler);//<span style="color:#3366ff;">在这种ThreadPoolExecutor中BloockingQueue的size为1,corePoolSize为0,可以保证每个提交的task都会及时被执行,并且最大化执行的并发度,如果没有新的任务提交执行,则会在60second后停止活动状态的空闲线程</span>
}

下面看看site的调度

site的调度分为两步,第一步是启动,先把每个种子url(用户在配置文件中设定的,如果没有设定,则直接以当前site的url作为种子)封装成一个执行单位Task,Task对象保存了当前任务的一些静态信息,如url,源url,htttpmethod(可配置的)等,然后再利用该Task创建一个可执行单元Spider对象,具体的任务执行全部放在spider对象完成(这个我们将放在下一节来分析),然后运行这些初始的任务(这里也可以将这些种子任务放到pool中执行,但这会无形中占用有限的调度资源,因此可以单独放在外面执行)。第二步,迭代执行,即,不断从当前site的任务队列取出一个新任务,创建一个对应的Spider并提交到pool中;如果任务队列为空,则等待设定的时间,继续扫描队列。

public void run() {if (site.isStop)return ;// 获取种子urlSeeds seeds = site.getSeeds();Collection<Task> seedTasks = new ArrayList<Task>();if (seeds == null || seeds.getSeed() == null || seeds.getSeed().isEmpty()) {seedTasks.add(new Task(this.site.getUrl(), this.site.getHttpMethod(), null, this.site, 10));}else{for (Iterator<Seed> it = seeds.getSeed().iterator(); it.hasNext(); ){Seed s = it.next();seedTasks.add(new Task(s.getUrl(), s.getHttpMethod(), null, this.site, 10));}}// 运行种子任务for (Iterator<Task> it = seedTasks.iterator(); it.hasNext(); ) {Task seedTask = it.next();Spider seedSpider = new Spider();seedSpider.init(seedTask, listener);//this.site.pool.execute(seedSpider);seedSpider.run();}//final float times = CommonUtil.toSeconds(this.site.getSchedule()) * 1000;//long start = System.currentTimeMillis();while(true){if (site.isStop)break;try {//扩展点:TaskPoll,这<span style="color:#3366ff;">里可以使用用户自定义的任务抽取策略来从队列中取任务,而且这里的每个TaskPollPoint在插件加载时已经按照sort排过序,优先级高的排在前面</span>
                                 Task task = null;Collection<TaskPollPoint> taskPollPoints = site.taskPollPointImpls;if (taskPollPoints != null && !taskPollPoints.isEmpty()){for (Iterator<TaskPollPoint> it = taskPollPoints.iterator(); it.hasNext(); ){TaskPollPoint point = it.next();task = point.pollTask();}}if (task == null){long wait = CommonUtil.toSeconds(site.getWaitQueue()).longValue();//listener.onInfo(Thread.currentThread(), null, "queue empty wait for -> " + wait + " seconds");if (wait > 0) {try {Thread.sleep(wait * 1000);} catch (Exception e){}}continue;}Spider spider = new Spider();spider.init(task, listener);this.site.pool.execute(spider);}catch (DoneException e) {listener.onInfo(Thread.currentThread(), null, e.toString());return ;} catch (Exception e) {listener.onError(Thread.currentThread(), null, e.toString(), e);}finally{if (site.isStop)break;if (site.pool == null)break;}}}}} 

Site内部具体的Task执行

前面讲过每个可执行的Task都是一个Spider对象,即,每个任务的执行就是执行一个Spider对象的run函数,总体上run函数执行的可以分为如下六个步骤,而每步都使用了预定义的插件,从而可以最大限度支持用户的扩展,源码中除了对PoJo没有默认实现的插件外,其余各步骤都有默认实现的插件,这六步为:

第一步:调用BeginPoint插件进行预处理工作

         这一步默认的插件所做的工作就是过滤掉host不在有效host列表中的url(这里的有效host可以站点配置文件中配置,如果没有配置,那么将要求task的url必须和所属site的url具有相同的host),这部分源码逻辑简单清晰,不在列出

第二步:调用Fetch插件下载页面(Fetch将作为单独一节后续分析)

第三步:抽取新的URL并处理封装成新的Task放入爬取队列

         新的URL的抽取比较繁琐,在大的方面主要分成两种情况,第一种:配置文件中配置了抽取规则,如digUrl或者nextPage,这里的digUrl和nextPage都是一个页面model,即抽取模型,本质上就是一个页面解析模型,只不过这里用来抽取url,关于页面解析,后面将作为单独一节来分析,这里就简单略过,如果定义了digUrl,则依据digUrl抽取url,如果定义了nextPage,则按照分页规则递归的从每个页面中抽取url,不管是digUrl还是nextPage都定义了抽取所需的最基本条件,如parser,在整个spiderman中,parser有三种表现形式:XPATH,正则表达式和exp(FEL使用)。第二种:如果digUrl和nextPage都没有定义,则使用默认的抽取方式,即:抽取所有a标签,iframe标签和frame标签中的超链接。

        对抽取的Url的处理包含三部分:去重,对新生成的Task排序,将新生成的Task放入任务队列

        URL去重也采用了BDB-JE数据库作为支撑,只不过这里的key是url生成的MD5值,value依然是当前url对应的id(按照添加顺序递增),另外,还还增加了去重策略的是否严格的支持,即如果配置了严格去重,则只要Url不同(生成的MD5也不同)才认为是不同的URL,如果是非严格去重,则如果URL相同,但来源URL不同,也可认为是不同的URL(这时用source_url+url来生成MD5)。

// 默认是严格限制重复URL的访问,只要是重复的URL,都只能访问一次String docKey = CommonUtil.md5(newTask.url);// 如果配置的是不严格限制重复URL的访问,则将去重复判断的key变成 TargetUrl + SourceUrl// 这样就表示不同来源的TargetUrl,就算相同,也是可以访问的String isStrict = site.getIsDupRemovalStrict();if ("0".equals(isStrict) && tgt != null) {docKey = CommonUtil.md5(newTask.url + newTask.sourceUrl);}int docId = this.site.db.getDocId(docKey);if (docId < 0) {validTasks.add(newTask);this.site.db.newDocID(docKey);}

          对生成的Task进行排序,这里的排序实质上是设置每个Task的权重(即sort值),任务队列(后面章节会分析)会依据这个值来对其中Task自动排序。

public synchronized Collection<Task> sortTasks(Collection<Task> tasks) throws Exception {float i = 0f;for (Task task : tasks) {i += 0.00001;// 检查url是否符合target的url规则,并且是否是来自于来源url,如果符合排序调整为0Target tgt = Util.matchTarget(task);Rules rules = site.getTargets().getSourceRules();Rule fromSourceRule = SourceUrlChecker.checkSourceUrl(rules, task.sourceUrl);if (tgt != null && fromSourceRule != null){task.sort = 0 + CommonUtil.toDouble("0."+System.currentTimeMillis()) + i;}else{//检查url是否符合target的sourceUrl规则,如果符合排序调整为5,否则为10Rule sourceRule = SourceUrlChecker.checkSourceUrl(rules, task.url);if (sourceRule != null){task.sort = 5 + CommonUtil.toDouble("0."+System.currentTimeMillis()) + i;}else{task.sort = 10 + CommonUtil.toDouble("0."+System.currentTimeMillis()) + i;}}}return tasks;}

从源码可以看出,如果当前task符合某个target规则,并且其来源URL也符合source rules,则sort最大(优先级最高)。
         将新生成的Task放入任务队列

         这部分放在Frontier章节来分析

第四步:对当前task进行验证,包括target和source_url

//扩展点:target 确认是否有目标配置匹配当前URLTarget target = null;Collection<TargetPoint> targetPoints = task.site.targetPointImpls;if (targetPoints != null && !targetPoints.isEmpty()){for (Iterator<TargetPoint> it = targetPoints.iterator(); it.hasNext(); ){TargetPoint point = it.next();target = point.confirmTarget(task, target);}}if (target == null) {return ;}task.target = target;this.listener.onTargetPage(Thread.currentThread(), task, page);if (task.site.isStop)return ;//检查sourceUrlRules rules = task.site.getTargets().getSourceRules();Rule sourceRule = SourceUrlChecker.checkSourceUrl(rules, task.sourceUrl);if (sourceRule == null){return ;}

可以看出,这里主要是基于配置文件中的Target和SourceRules进行过滤,所谓Target即用户在配置文件中设定的目标URL,即只能爬取符合该条件的URL,所谓SourceRules即指定了所有Target的来源RULE,即不是从这些URL来的URL不予爬取。因此,如果没有匹配上的Target或者不符合SourceRules,则不再进行后续处理。

第五步:解析,每个Task解析出来的结果都放在List<Map<String, Object>>中,List中的每个成员为一个Model解析的结果而Map中每个成员为一个Field解析的结果,解析的具体细节后面将用单独一节来分析。

第六步:对挖掘新URL的Field的处理,某些Target的Model中有些Field会用作挖掘新URL,这在配置中用isForDigNewUrl属性来标记,如果该标记被设置,则在解析页面时,会将当前Field解析出来的结果是为新的URL放到Task中,并在这里继续进行和上面一样的处理:去重,排序,放入队列。

第七步:将抓取结果转换成用户定义的对象

这个功能可以方便用户基于挖掘结果快速生成后续处理所需的对象,从而实现挖掘结果的快速利用,这里的对象类型完全由用户定义(在配置文件中提供),转换过程也作为插件方式提供,从而具有高度的灵活性,需要注意的是每个Model只能定义一种类型(严格的讲,每个Target的Model(不管是否多个)都只能定义一个类型,最终每个Model会转换成一个该类型的对象。

第八步:收尾,做一些爬取收尾工作,如和业务相关的统计,爬取收据的存储等。

这一节就分析到这里,下一节我们来看看Fetcher所做的工作。


 

0 0
原创粉丝点击