爬虫重构总结

953 阅读5分钟

写在前面

最近做项目,后端一直在用一个叫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有惊喜哦.命令行的这两个参数可以变哦.