Downloader是负责请求url获取返回值(html、json、jsonp等)的一个组件。当然会同时处理POST重定向、Https验证、ip代理、判断失败重试等。
接口:Downloader 定义了download方法返回Page,定义了setThread方法来请求的设置线程数。
抽象类:AbstractDownloader。 定义了重载的download方法返回Html,同时定义了onSuccess、onError状态方法,并定义了addToCycleRetry来判断是否需要进行重试。
实现类:HttpClientDownloader。负责通过HttpClient下载页面
辅助类:HttpClientGenerator。负责生成HttpClient实例。
1、AbstractDownloader
public Html download(String url, String charset) {
Page page = download(new Request(url), Site.me().setCharset(charset).toTask());
return (Html) page.getHtml();
}
这里download逻辑很简单,就是调用子类实现的download下载。
protected Page addToCycleRetry(Request request, Site site) {
Page page = new Page();
Object cycleTriedTimesObject = request.getExtra(Request.CYCLE_TRIED_TIMES);
if (cycleTriedTimesObject == null) {
page.addTargetRequest(request.setPriority(0).putExtra(Request.CYCLE_TRIED_TIMES, 1));
} else {
int cycleTriedTimes = (Integer) cycleTriedTimesObject;
cycleTriedTimes++;
if (cycleTriedTimes >= site.getCycleRetryTimes()) {
return null;
}
page.addTargetRequest(request.setPriority(0).putExtra(Request.CYCLE_TRIED_TIMES, cycleTriedTimes));
}
page.setNeedCycleRetry(true);
return page;
}
判断重试逻辑:先判断CYCLE_TRIED_TIMES是否为null,如果不为null,循环重试次数+1,判断是否超过最大允许值(默认为3次),然后设置needCycleRetry标志说明需要被重试。这在我们Spider分析篇提到过这个,我们再来看看Spider中的代码片段加深理解
// for cycle retry
if (page.isNeedCycleRetry()) {
extractAndAddRequests(page, true);
sleep(site.getRetrySleepTime());
return;
}
2、HttpClientDownloader
继承了AbstractDownloader.负责通过HttpClient下载页面.
实例变量
httpClients:是一个Map型的变量,用来保存根据站点域名生成的HttpClient实例,以便重用。
httpClientGenerator:HttpClientGenerator实例,用来生成HttpClient
主要方法:
a、获取HttpClient实例。
private CloseableHttpClient getHttpClient(Site site, Proxy proxy) {
if (site == null) {
return httpClientGenerator.getClient(null, proxy);
}
String domain = site.getDomain();
CloseableHttpClient httpClient = httpClients.get(domain);
if (httpClient == null) {
synchronized (this) {
httpClient = httpClients.get(domain);
if (httpClient == null) {
httpClient = httpClientGenerator.getClient(site, proxy);
httpClients.put(domain, httpClient);
}
}
}
return httpClient;
}
主要思路是,通过Site获取域名,然后通过域名判断是否在httpClients这个map中已存在HttpClient实例,如果存在则重用,否则通过httpClientGenerator创建一个新的实例,然后加入到httpClients这个map中,并返回。
注意为了确保线程安全性,这里用到了线程安全的双重判断机制。
b、download方法:
public Page download(Request request, Task task) {
Site site = null;
if (task != null) {
site = task.getSite();
}
Set acceptStatCode;
String charset = null;
Map headers = null;
if (site != null) {
acceptStatCode = site.getAcceptStatCode();
charset = site.getCharset();
headers = site.getHeaders();
} else {
acceptStatCode = WMCollections.newHashSet(200);
}
logger.info("downloading page {}", request.getUrl());
CloseableHttpResponse httpResponse = null;
int statusCode=0;
try {
HttpHost proxyHost = null;
Proxy proxy = null; //TODO
if (site.getHttpProxyPool() != null && site.getHttpProxyPool().isEnable()) {
proxy = site.getHttpProxyFromPool();
proxyHost = proxy.getHttpHost();
} else if(site.getHttpProxy()!= null){
proxyHost = site.getHttpProxy();
}
HttpUriRequest httpUriRequest = getHttpUriRequest(request, site, headers, proxyHost);
httpResponse = getHttpClient(site, proxy).execute(httpUriRequest);
statusCode = httpResponse.getStatusLine().getStatusCode();
request.putExtra(Request.STATUS_CODE, statusCode);
if (statusAccept(acceptStatCode, statusCode)) {
Page page = handleResponse(request, charset, httpResponse, task);
onSuccess(request);
return page;
} else {
logger.warn("get page {} error, status code {} ",request.getUrl(),statusCode);
return null;
}
} catch (IOException e) {
logger.warn("download page {} error", request.getUrl(), e);
if (site.getCycleRetryTimes() > 0) {
return addToCycleRetry(request, site);
}
onError(request);
return null;
} finally {
request.putExtra(Request.STATUS_CODE, statusCode);
if (site.getHttpProxyPool()!=null && site.getHttpProxyPool().isEnable()) {
site.returnHttpProxyToPool((HttpHost) request.getExtra(Request.PROXY), (Integer) request
.getExtra(Request.STATUS_CODE));
}
try {
if (httpResponse != null) {
//ensure the connection is released back to pool
EntityUtils.consume(httpResponse.getEntity());
}
} catch (IOException e) {
logger.warn("close response fail", e);
}
}
}
注意,这里的Task入参,其实就是Spider实例。
首先通过site来设置字符集、请求头、以及允许接收的响应状态码。
之后便是设置代理:首先判断site是否有设置代理池,以及代理池是否可用。可用,则随机从池中获取一个代理主机,否则判断site是否设置过直接代理主机。
然后获取HttpUriRequest(它是HttpGet、HttpPost的接口),执行请求、判断响应码,并将响应转换成Page对象返回。期间还调用了状态方法onSuccess,onError,但是这两个方法都是空实现。(主要原因可能是在Spider中已经通过调用Listener来处理状态了)。
如果发生异常,调用addToCycleRetry判断是否需要进行重试。
如果这里返回的Page为null,在Spider中就不会调用PageProcessor,所以我们在PageProcessor中不用担心Page是否为null
最后的finally块中进行资源回收处理,回收代理入池,回收HttpClient的connection等(EntityUtils.consume(httpResponse.getEntity());)。
c、具体说说怎么获取HttpUriRequest
protected HttpUriRequest getHttpUriRequest(Request request, Site site, Map headers,HttpHost proxy) {
RequestBuilder requestBuilder = selectRequestMethod(request).setUri(request.getUrl());
if (headers != null) {
for (Map.Entry headerEntry : headers.entrySet()) {
requestBuilder.addHeader(headerEntry.getKey(), headerEntry.getValue());
}
}
RequestConfig.Builder requestConfigBuilder = RequestConfig.custom()
.setConnectionRequestTimeout(site.getTimeOut())
.setSocketTimeout(site.getTimeOut())
.setConnectTimeout(site.getTimeOut())
.setCookieSpec(CookieSpecs.BEST_MATCH);
if (proxy !=null) {
requestConfigBuilder.setProxy(proxy);
request.putExtra(Request.PROXY, proxy);
}
requestBuilder.setConfig(requestConfigBuilder.build());
return requestBuilder.build();
}
首先调用selectRequestMethod来获取合适的RequestBuilder,比如是GET还是POST,同时设置请求参数。之后便是调用HttpClient的相关API设置请求头、超时时间、代理等。
关于selectRequestMethod的改动:预计在WebMagic0.6.2(目前还未发布)之后由于作者合并并修改了PR,设置POST请求参数会大大简化。
之前POST请求设置参数需要
request.putExtra("nameValuePair",NameValuePair[]);然后这个NameValuePair[]需要不断add BasicNameValuePair,而且还需要UrlEncodedFormEntity,设置参数过程比较繁琐,整个过程如下:
List formparams = new ArrayList();
formparams.add(new BasicNameValuePair("channelCode", "0008"));
formparams.add(new BasicNameValuePair("pageIndex", i+""));
formparams.add(new BasicNameValuePair("pageSize", "15"));
formparams.add(new BasicNameValuePair("sitewebName", "广东省"));
request.putExtra("nameValuePair",formparams.toArray());
之后我们只需要如下就可以了:
request.putParam("sitewebName", "广东省");
request.putParam("xxx", "xxx");
d、说说下载的内容如何转换为Page对象:
protected Page handleResponse(Request request, String charset, HttpResponse httpResponse, Task task) throws IOException {
String content = getContent(charset, httpResponse);
Page page = new Page();
page.setRawText(content);
page.setUrl(new PlainText(request.getUrl()));
page.setRequest(request);
page.setStatusCode(httpResponse.getStatusLine().getStatusCode());
return page;
}
这个方法没什么好说的,唯一要说的就是它调用getContent方法。
protected String getContent(String charset, HttpResponse httpResponse) throws IOException {
if (charset == null) {
byte[] contentBytes = IOUtils.toByteArray(httpResponse.getEntity().getContent());
String htmlCharset = getHtmlCharset(httpResponse, contentBytes);
if (htmlCharset != null) {
return new String(contentBytes, htmlCharset);
} else {
logger.warn("Charset autodetect failed, use {} as charset. Please specify charset in Site.setCharset()", Charset.defaultCharset());
return new String(contentBytes);
}
} else {
return IOUtils.toString(httpResponse.getEntity().getContent(), charset);
}
}
getContent方法,首先判断是否有charset(这是在Site中配置的),如果有,直接调用ApacheCommons的IOUtils将相应内容转化成对应编码字符串,否则智能检测响应内容的字符编码。
protected String getHtmlCharset(HttpResponse httpResponse, byte[] contentBytes) throws IOException {
return CharsetUtils.detectCharset(httpResponse.getEntity().getContentType().getValue(), contentBytes);
}
getHtmlCharset是调用CharsetUtils来检测字符编码,其思路就是,首先判断httpResponse.getEntity().getContentType().getValue()是否含有比如charset=utf-8
否则用Jsoup解析内容,判断是提取meta标签,然后判断针对html4中html4.01 和html5中分情况判断出字符编码。
当然,你懂的,如果服务端返回的不是完整的html内容(不包含head的),甚至不是html内容(比如json),那么就会导致判断失败,返回默认jvm编码值.
所以说,如果可以,最好手动给Site设置字符编码。
3、HttpClientGenerator
用于生成HttpClient实例,算是一种工厂模式了。
public HttpClientGenerator() {
Registry reg = RegistryBuilder.create()
.register("http", PlainConnectionSocketFactory.INSTANCE)
.register("https", buildSSLConnectionSocketFactory())
.build();
connectionManager = new PoolingHttpClientConnectionManager(reg);
connectionManager.setDefaultMaxPerRoute(100);
}
构造函数主要是注册http以及https的socket工厂实例。https下我们需要提供自定义的工厂以忽略不可信证书校验(也就是信任所有证书),在webmagic0.6之前是存在不可信证书校验失败这一问题的,之后webmagic合并了一个关于这一问题的PR,目前的策略是忽略证书校验、信任一切证书(这才是爬虫该采用的嘛,我们爬的不是安全,是寂寞。)
private CloseableHttpClient generateClient(Site site, Proxy proxy) {
CredentialsProvider credsProvider = null;
HttpClientBuilder httpClientBuilder = HttpClients.custom();
if(proxy!=null && StringUtils.isNotBlank(proxy.getUser()) && StringUtils.isNotBlank(proxy.getPassword()))
{
credsProvider= new BasicCredentialsProvider();
credsProvider.setCredentials(
new AuthScope(proxy.getHttpHost().getAddress().getHostAddress(), proxy.getHttpHost().getPort()),
new UsernamePasswordCredentials(proxy.getUser(), proxy.getPassword()));
httpClientBuilder.setDefaultCredentialsProvider(credsProvider);
}
if(site!=null&&site.getHttpProxy()!=null&&site.getUsernamePasswordCredentials()!=null){
credsProvider = new BasicCredentialsProvider();
credsProvider.setCredentials(
new AuthScope(site.getHttpProxy()),//可以访问的范围
site.getUsernamePasswordCredentials());//用户名和密码
httpClientBuilder.setDefaultCredentialsProvider(credsProvider);
}
httpClientBuilder.setConnectionManager(connectionManager);
if (site != null && site.getUserAgent() != null) {
httpClientBuilder.setUserAgent(site.getUserAgent());
} else {
httpClientBuilder.setUserAgent("");
}
if (site == null || site.isUseGzip()) {
httpClientBuilder.addInterceptorFirst(new HttpRequestInterceptor() {
public void process(
final HttpRequest request,
final HttpContext context) throws HttpException, IOException {
if (!request.containsHeader("Accept-Encoding")) {
request.addHeader("Accept-Encoding", "gzip");
}
}
});
}
//解决post/redirect/post 302跳转问题
httpClientBuilder.setRedirectStrategy(new CustomRedirectStrategy());
SocketConfig socketConfig = SocketConfig.custom().setSoTimeout(site.getTimeOut()).setSoKeepAlive(true).setTcpNoDelay(true).build();
httpClientBuilder.setDefaultSocketConfig(socketConfig);
connectionManager.setDefaultSocketConfig(socketConfig);
if (site != null) {
httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(site.getRetryTimes(), true));
}
generateCookie(httpClientBuilder, site);
return httpClientBuilder.build();
}
前面是设置代理代理及代理的用户名密码
这里主要需要关注的两点是
1、post/redirect/post 302跳转问题:这是是通过设置一个自定义的跳转策略类来实现的。(这在0.6版本之前是存在问题的,0.6版本之后合并了PR)
httpClientBuilder.setRedirectStrategy(new CustomRedirectStrategy());
CustomRedirectStrategy在继承HttpClient自带额LaxRedirectStrategy(支持GET,POST,HEAD,DELETE请求重定向跳转)的基础上,对POST请求做了特殊化处理,如果是POST请求,代码处理如下:
HttpRequestWrapper httpRequestWrapper = (HttpRequestWrapper) request;
httpRequestWrapper.setURI(uri);
httpRequestWrapper.removeHeaders("Content-Length");
可以看到,POST请求时首先会重用原先的request对象,并重新设置uri为新的重定向url,然后移除新请求不需要的头部。重用request对象的好处是,post/redirect/post 302跳转时会携带原有的POST参数,就防止了参数丢失的问题。
否则默认实现是这样的
if (status == HttpStatus.SC_TEMPORARY_REDIRECT) {
return RequestBuilder.copy(request).setUri(uri).build();
} else {
return new HttpGet(uri);
}
SC_TEMPORARY_REDIRECT是307状态码,也就是说只有在307状态码的时候才会携带参数跳转。
2、HttpClient的重试: 这是是通过设置一个默认处理器来实现的,同时设置了重试次数(也就是Site中配置的retryTimes)。
httpClientBuilder.setRetryHandler(newDefaultHttpRequestRetryHandler(site.getRetryTimes(), true));
之后便是配置Cookie策略。
private void generateCookie(HttpClientBuilder httpClientBuilder, Site site) {
CookieStore cookieStore = new BasicCookieStore();
for (Map.Entry cookieEntry : site.getCookies().entrySet()) {
BasicClientCookie cookie = new BasicClientCookie(cookieEntry.getKey(), cookieEntry.getValue());
cookie.setDomain(site.getDomain());
cookieStore.addCookie(cookie);
}
for (Map.Entry> domainEntry : site.getAllCookies().entrySet()) {
for (Map.Entry cookieEntry : domainEntry.getValue().entrySet()) {
BasicClientCookie cookie = new BasicClientCookie(cookieEntry.getKey(), cookieEntry.getValue());
cookie.setDomain(domainEntry.getKey());
cookieStore.addCookie(cookie);
}
}
httpClientBuilder.setDefaultCookieStore(cookieStore);
}
首先创建一个CookieStore实例,然后将Site中的cookie加入到cookieStore中。并配置到httpClientBuilder中。那么在这个HttpClient实例执行的所有请求中都会用到这个cookieStore。比如登录保持就可以通过配置Site中的Cookie来实现。
4、关于Page对象说明:
Page对象代表了一个请求结果,或者说相当于页面(当返回json时这种说法有点勉强)。
public Html getHtml() {
if (html == null) {
html = new Html(UrlUtils.fixAllRelativeHrefs(rawText, request.getUrl()));
}
return html;
}
通过它得到的页面,原始页面中的链接是不包含域名的情况下会被自动转换为http[s]开头的完整链接。
关于Downloader就分析到这,后续会进行补充,下篇主题待定。