摘要:
本篇主要剖析webmagic的downloader模块,对于httpclient模块涉及到的http相关的知识,例如:Request、Response以及重定向策略进行一定的分析。首先梳理了本模块的结构、然后对于执行流程进行了分析,最后对于其中涉及的设计模式:单例模式和相关算法进行了代码分析。
0x00:downloader的模块结构
downloader涉及到的类和接口主要如下表所示:
类名称 | 作用 | 方法说明 | 备注 |
---|---|---|---|
Downloader | 定义downloader接口规范 | downloade(r:Request,t:Task):Page | 接口 |
AbstractDownloader | 定义downloader状态接口 | onSuccess(),onError(),@Overdide:downloade() | 抽象类, |
HttpClientDownloader | 具体的下载接口 | 继承自AbstractDownloader | 具体类 |
CustomRedirectStrategy | 定义重定向策略 | ||
HttpClientGenerator | 配置httpCliet的辅助类 | getHttpClient(s:Site):HttpClient | |
HttpClientRequestContext | 数据类 | 存储requestcontext和clinetcontext | |
HttpUriRequestConverter | 配置Request的辅助类 | convert(r:Request,s:Site,p:Proxy):Request |
ox01:downloade的具体执行逻辑
首先来看具体的downloade代码:
@Override
public Page download(Request request, Task task) {
if (task == null || task.getSite() == null) {
throw new NullPointerException("task or site can not be null");
}
CloseableHttpResponse httpResponse = null;
CloseableHttpClient httpClient = getHttpClient(task.getSite());
Proxy proxy = proxyProvider != null ? proxyProvider.getProxy(task) : null;
HttpClientRequestContext requestContext = httpUriRequestConverter.convert(request, task.getSite(), proxy);
Page page = Page.fail();
try {
httpResponse = httpClient.execute(requestContext.getHttpUriRequest(), requestContext.getHttpClientContext());
page = handleResponse(request, request.getCharset() != null ? request.getCharset() : task.getSite().getCharset(), httpResponse, task);
onSuccess(request);
logger.info("downloading page success {}", request.getUrl());
return page;
} catch (IOException e) {
logger.warn("download page {} error", request.getUrl(), e);
onError(request);
return page;
} finally {
if (httpResponse != null) {
//ensure the connection is released back to pool
EntityUtils.consumeQuietly(httpResponse.getEntity());
}
if (proxyProvider != null && proxy != null) {
proxyProvider.returnProxy(proxy, page, task);
}
}
}
可以看到主要的代码流程还是很清晰的,首先得到配置好的httpClient,这是通过getClient()
方法得到的,这个方法具体涉及到设计模式中的单例,我们稍后再详细讲,然后根据传递过来的Request得到RequestContext和ClinetContext,根据执行httlClient的execute方法,这个方法就是具体的向服务端发送资源请求的方法,该方法会将服务器的资源封装到Response对象中。最后将Request和Response封装到Page中去,供后续的PageProcessor使用。
下面个用伪代码描述上面的流程:
fun download(r:Requst,t:Task):Page
httpClient = getClient(t.site())
context = convert(r,t.site(),proxy)
response = httpClient.execute(context.requestContext,context.clinetContext)
page = handle(r,response)
return page
可以看到downloade函数实际上关键的核心代码就是httpClinet的execute方法,其他的代码统一都可以抽象成准备工作。
0x02:初始化策略
httpClient初试化实际上涉及了一系列的参数配置,包括使用到的socket的参数配置,以及http一些连接配置,由于涉及到的参数非常多,对于socket的参数配置和httpClinet均使用到了Builder模式。具体的代码代码如下:
private CloseableHttpClient generateClient(Site site) {
HttpClientBuilder httpClientBuilder = HttpClients.custom();
httpClientBuilder.setConnectionManager(connectionManager);
if (site.getUserAgent() != null) {
httpClientBuilder.setUserAgent(site.getUserAgent());
} else {
httpClientBuilder.setUserAgent("");
}
if (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.Builder socketConfigBuilder = SocketConfig.custom();
socketConfigBuilder.setSoKeepAlive(true).setTcpNoDelay(true);
socketConfigBuilder.setSoTimeout(site.getTimeOut());
SocketConfig socketConfig = socketConfigBuilder.build();
httpClientBuilder.setDefaultSocketConfig(socketConfig);
connectionManager.setDefaultSocketConfig(socketConfig);
httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(site.getRetryTimes(), true));
generateCookie(httpClientBuilder, site);
return httpClientBuilder.build();
}
可以看到实际上就是根据站点来配置client参数的过程,也就是说,我们可以将一些自定义参数放置到Site实例中,这样就可以将参数填入了。这实际上也是我么常用的初始化策略,当参数众多时,我们抽象出相关的配置类,这样可以将参数集中管理起来,实现代码的结构化。
ox03:单例模式
在第一节中我们提到,httpClinet使用了单例模式,下面我们看具体的实现过程:
private CloseableHttpClient getHttpClient(Site site) {
if (site == null) {
return httpClientGenerator.getClient(null);
}
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);
httpClients.put(domain, httpClient);
}
}
}
return httpClient;
}
可以看到代码的关键部分如下:
if(httpClient == null) {
synchronized(this) {
if(httpClinet == null) {
htttpClinet = httpClinetGenerator.getClinet();
}
}
}
也就是代码判断了两次单例是否为空,第一次判断为空,然后加锁进行单例的判断,这个比较容易理解,但是第二次再次判断是为什么呢,我们设想如下情况:
当前单例未被创建,所以httpClient为null,线程一判断结果为空后还未加锁,此时进行了线程的切换,线程2得到了执行权,此时由于线程1为创建实例,所以线程2会创建一个实例出来。然后再切回线程1执行,由于之前线程1判断了httpClient为空,然后取得锁,此时仍进行了实例的创建。也就不满足单例模式了。所以第二次的再次判空时必要的。只有这样才能保证即使多线程也能创建唯一的实例。