写在前面
最近做项目,后端一直在用一个叫Vertx的高性能网络库.高性能到底有多高,跟其他的网络库比起来差距有多大,这些都没有测试,高性能只是建立在我的直觉上…我之所以认为Vertx高性能,是因为它的Request-Response模型是一种类似于nodejs的基于事件通知和函数回调的,本身再加上其底层依赖于Netty,所以凭直觉我就觉得它的性能是很强劲的.Vertx也有一个WebClient库支持http client请求,我所做的爬虫的网络库就是基于它的.除了这些,Vertx还封装了一套跨平台的异步文件IO编程模型,操作起文件来简直不能再爽了…
问题来了
虽然Vertx的API用起来各种爽,可是写多了回调,很容易写成著名的Callback Hell.写成这样的代码是很难维护的,想加个功能都不好加,因为回调嵌套的太多,加功能时得十分小心,一不小心改变了什么变量,那么程序执行的结果就会出现偏差,再加上爬虫程序固有的特点,比如抓取网页速率限制,纯回调的开发模型无法解决问题…
解决策略
既然回调层数太多不易阅读,那么我就尽量把层数拆开成一个一个分离的函数就好了嘛.同时配合lambda,任何变量都可以加.这里为什么要配合lambda呢?原因是因为爬虫任务调度.我将每一次的抓取页面动作都装到一个lambda里,然后把这个lambda装到并发安全的任务队列里,在自己的定时器定时触发任务的策略下对任务进行限制性的执行,这样就满足了限制爬虫程序发出网络请求的速率这一需求.作为对比,下面的代码是改造前和改造后的代码:
for (int i = 1; i <= 91; i++) {
String url = String.format("http://tu.hanhande.com/scy/scy_%d.shtml", i);
spider.get(url, ar -> {
if (ar.succeeded()) {
spider.infoLog(url);
Elements links = spider.select(ar, "body div.main div.content ul li p a");
links.forEach(link -> {
String imagesFolder = String.format("images/%s_%s", link.text(), new UID().toString());
spider.getFileSystem().mkdir(imagesFolder, ar2 -> {
if (ar2.succeeded()) {
String href = link.attr("href");
spider.get(href, ar3 -> {
if (ar3.succeeded()) {
spider.infoLog(href);
Elements images = spider.select(ar3, "html body div.main div.content div.picshow div.picshowlist div.picshowlist_mid div.picmidmid ul#picLists li a img");
images.forEach(image -> {
String imageSrc = image.attr("src");
try {
String imageFile = imageSrc.substring(38);
spider.get(imageSrc, ar4 -> {
if (ar4.succeeded()) {
spider.infoLog(imageSrc);
String file = String.format("%s/%s", imagesFolder, imageFile);
spider.getFileSystem().writeFile(file, ar4.result().body(), ar5 -> {
if (ar5.failed()) {
spider.error(String.format("Write iamge file %s failed", file));
}
});
} else {
spider.errorLog(imageSrc, ar4.cause().getMessage());
}
});
} catch(IndexOutOfBoundsException e) {
}
});
} else {
spider.errorLog(href, ar3.cause().getMessage());
}
});
}
});
});
} else {
spider.errorLog(url, ar.cause().getMessage());
}
});
}
package me.yougar.spider;
import io.vertx.core.AsyncResult;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.core.logging.LoggerFactory;
import io.vertx.core.logging.Logger;
import io.vertx.ext.web.codec.BodyCodec;
import org.jsoup.select.Elements;
import org.jsoup.nodes.Element;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import java.rmi.server.UID;
import io.vertx.core.file.OpenOptions;
import me.yougar.spider.lib.Dispatcher;
import me.yougar.spider.lib.util.WebClientUtil;
import me.yougar.spider.lib.util.VertxUtil;
public class Handler {
private static Logger logger = LoggerFactory.getLogger(Handler.class);
private static void info(String url, int statusCode) {
logger.info(String.format("Get %s with statusCode %d", url, statusCode));
}
// 处理第一阶段的任务
public static void handle(String url, Dispatcher dispatcher) {
WebClientUtil.webClient.getAbs(url).as(BodyCodec.string("gb2312")).send(asyncResult -> {
if (asyncResult.succeeded()) {
HttpResponse<String> res = asyncResult.result();
if (res.statusCode() != 200) {
logger.error(String.format("When get %s return status code %d", url, res.statusCode()));
return;
}
// 记录请求过程
info(url, res.statusCode());
// 解析Html内容
Document root = Jsoup.parse(res.body());
Elements links = root.select("body div.main div.content ul li p a");
links.forEach(link -> {
handle(link, dispatcher);
});
} else {
logger.error(asyncResult.cause());
}
});
}
// 生成第二阶段任务
private static void handle(Element link, Dispatcher dispatcher) {
// 生成存储图片的目录, 后面的UID保证该目录的唯一性
String imagesFolder = String.format("images/%s_%s", link.text(), new UID().toString());
// 创建该目录
VertxUtil.fileSystem.mkdir(imagesFolder, asyncResult -> {
if (asyncResult.succeeded()) {
String href = link.attr("href");
dispatcher.addJob(2, () -> {
handle(href, imagesFolder, dispatcher);
});
} else {
logger.error(asyncResult.cause());
}
});
}
// 处理第二阶段任务,并生成第三阶段任务
private static void handle(String href, String imagesFolder, Dispatcher dispatcher) {
WebClientUtil.webClient.getAbs(href).as(BodyCodec.string("gb2312")).send(asyncResult -> {
if (asyncResult.succeeded()) {
HttpResponse<String> res = asyncResult.result();
info(href, res.statusCode());
// 解析html生成图片的链接
Document root = Jsoup.parse(res.body());
Elements images = root.select("html body div.main div.content div.picshow div.picshowlist div.picshowlist_mid div.picmidmid ul#picLists li a img");
images.forEach(image -> {
String imageSrc = image.attr("src");
try {
// 生成图片的名字
String imageName = imageSrc.substring(38);
dispatcher.addJob(3, () -> {
handle(imageName, imageSrc, imagesFolder, dispatcher);
});
} catch(Exception e) {
logger.error(e);
}
});
} else {
logger.error(asyncResult.cause());
}
});
}
// 下载并存储图片
private static void handle(String imageName, String imageSrc, String imagesFolder, Dispatcher dispatcher) {
// 首先创建一个异步文件流
VertxUtil.fileSystem.open(String.format("%s/%s", imagesFolder, imageName), new OpenOptions().setCreate(true).setWrite(true), asyncFile -> {
if (asyncFile.succeeded()) {
// 下载图片并保存图片
WebClientUtil.webClient.getAbs(imageSrc).as(BodyCodec.pipe(asyncFile.result())).send(asyncResult -> {
if (asyncResult.failed()) {
logger.error(asyncResult.cause());
}
});
} else {
logger.error(asyncFile.cause());
}
});
}
}
这样改造下来,虽然代码行数多了,但是可读性明显的变好了.这样再去添加什么功能就方便了.
爬虫任务调度
这里讲的是这个爬虫最核心也是最关键的任务(网络请求)调度(速率限制)实现.先分析如下代码:
// 生成第二阶段任务
private static void handle(Element link, Dispatcher dispatcher) {
// 生成存储图片的目录, 后面的UID保证该目录的唯一性
String imagesFolder = String.format("images/%s_%s", link.text(), new UID().toString());
// 创建该目录
VertxUtil.fileSystem.mkdir(imagesFolder, asyncResult -> {
if (asyncResult.succeeded()) {
String href = link.attr("href");
dispatcher.addJob(2, () -> {
handle(href, imagesFolder, dispatcher);
});
} else {
logger.error(asyncResult.cause());
}
});
}
// 这里的dispatcher关联的数据结构和算法如下:
public class Dispatcher {
// 任务队列链表
private ArrayList<ConcurrentLinkedQueue<Job>> jobs = new ArrayList();
// 按照任务优先级添加任务
public void addJob(int priority, Job job) {
jobs.get(priority - 1).add(job);
}
}
// 此处的Job定义如下:
// 一个闭包,把相关的代码封进去
@FunctionalInterface
public interface Job {
public void handle();
}
当有任务产生后,我就把它要执行的参数封装到一个函数调用里面(
handle(href, imagesFolder, dispatcher);),然后呢在封装到一个叫job的lambda里(() ->handle(href, imagesFolder, dispatcher)).这样在设置它的优先级后添加到调度器的全局任务队列里.这里的优先级很有用.因为我这个爬虫抓取深度为3,深度越大的页面要抓取的数量越多,如果只是一味的往一个全局队列里面添加,那么这个队列只会先长到最大.这对内存使用是极其不友好的,会占用大量内存.根据任务优先级去消费优先级高的任务(即深度为3的抓取任务),才能保证内存占用做到最小.说了这么多,还是自己看调度器实现的源码吧…private void dispatch() {
for (int level = priority - 1; level >= 0; level--) {
Job job = jobs.get(level).poll();
if (job != null) {
job.handle();
break;
}
// 当任任务消费完后,消费hit
if (level == 0) {
hit--;
}
}
// 设置的触发次数满足后,结束进程
if (hit == 0) {
WebClientUtil.webClient.close();
VertxUtil.vertx.close();
}
}
public void run() {
// 利用vertx设置定时执行器,每隔1秒执行一次调度
VertxUtil.vertx.setPeriodic(1000, l -> {
dispatch();
});
}完整代码请前往github.对了…还有一点,如果你download下来运行,记得在项目目录下先
mkdir images,然后执行java -jar targets/spider-version.jar 1 1有惊喜哦.命令行的这两个参数可以变哦.