背景:最近项目中需要对接第三方接口下载一些文件,访问下载地址链接会重定向到真正的uri,由于某些原因,需要拿到重定向后的地址,即302跳转后的uri,但是spring的RestTemplate提供的get方法会默认自动重定向,返回的即为200,因此需要取消自动重定向或者在重定向过程中拿到真正的uri。
1.问题分析
如何知道spring会自动重定向呢?
先写一个简单的测试用例,例如百度app的下载地址,就是有会进行302跳转的url
private static final String BAIDU_APP_DOWNLOAD_URL = "https://downpack.baidu.com/litebaiduboxapp_AndroidPhone_1020164i.apk";
private static final RestTemplate REST_TEMPLATE = new RestTemplateBuilder().build();
@Test
public void getFile() throws IOException {
ResponseEntity<byte[]> responseEntity = REST_TEMPLATE.getForEntity(BAIDU_APP_DOWNLOAD_URL, byte[].class);
File file = new File("src/main/java/com/kiroscarlet/common/external/" + "temp.apk");
FileOutputStream fos = new FileOutputStream(file);
fos.write(Objects.requireNonNull(responseEntity.getBody()));
fos.flush();
fos.close();
}
测试getFile方法,会在当前项目的根目录下生成temp.apk,即为百度的安装包文件。
那么如何知道的确进行了302跳转呢,别着急,我们一步一步debug看一下:
首先进入RestTemplate内部,不管是封装的什么方法(getForObject、getForEntity、postForObject等等),最终都会调用doExecute方法,如下:
@Nullable
protected <T> T doExecute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
Assert.notNull(url, "URI is required");
Assert.notNull(method, "HttpMethod is required");
ClientHttpResponse response = null;
try {
ClientHttpRequest request = createRequest(url, method);
if (requestCallback != null) {
requestCallback.doWithRequest(request);
}
// 关键是这行代码,我们断点进去一步一步看
response = request.execute();
handleResponse(url, method, response);
return (responseExtractor != null ? responseExtractor.extractData(response) : null);
}
catch (IOException ex) {
String resource = url.toString();
String query = url.getRawQuery();
resource = (query != null ? resource.substring(0, resource.indexOf('?')) : resource);
throw new ResourceAccessException("I/O error on " + method.name() +
" request for \"" + resource + "\": " + ex.getMessage(), ex);
}
finally {
if (response != null) {
response.close();
}
}
使用idea在response = request.execute();
这行代码打上断点,执行一下发现这里的request是一个HttpComponentsClientHttpRequest
,这个类是怎么来的呢,往上面看,有一行代码
ClientHttpRequest request = createRequest(url, method);
点进去看一下,调用了HttpAccessor的相应方法,如下:
protected ClientHttpRequest createRequest(URI url, HttpMethod method) throws IOException {
ClientHttpRequest request = getRequestFactory().createRequest(url, method);
if (logger.isDebugEnabled()) {
logger.debug("HTTP " + method.name() + " " + url);
}
return request;
}
HttpAccessor是一个抽象方法,RestTemplate继承了它,因此这里的 getRequestFactory()实际上就是RestTemplate的requestFactory,一般主要使用两种requestFactory,分别是:SimpleClientHttpRequestFactory和HttpComponentsClientHttpRequestFactory,如果创建RestTemplate时没有使用工厂模式而是直接用
private static final RestTemplate REST_TEMPLATE = new RestTemplate();
那么得到的就是默认的SimpleClientHttpRequestFactory,使用J2SE提供的方式(既java.net包提供的方式)创建底层的Http请求连接,功能非常有限,可以设置下连接超时时间等属性。如果仅仅是需要禁止自动重定向话用这个其实也能实现。我们看下其实现代码:
protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
if (this.connectTimeout >= 0) {
connection.setConnectTimeout(this.connectTimeout);
}
if (this.readTimeout >= 0) {
connection.setReadTimeout(this.readTimeout);
}
connection.setDoInput(true);
if ("GET".equals(httpMethod)) {
// 在这里,如果是get方法的话就会把自动重定向设置为true
connection.setInstanceFollowRedirects(true);
}
else {
connection.setInstanceFollowRedirects(false);
}
if ("POST".equals(httpMethod) || "PUT".equals(httpMethod) ||
"PATCH".equals(httpMethod) || "DELETE".equals(httpMethod)) {
connection.setDoOutput(true);
}
else {
connection.setDoOutput(false);
}
connection.setRequestMethod(httpMethod);
}
2.解决方案
第一种解决方案
既然知道了spring会设置自动重定向,那么要禁止自动重定向就很简单了。我们写一个类来继承SimpleClientHttpRequestFactory,然后复写prepareConnection方法,把该属性设置为false即可,代码如下:
public class NoRedirectSimpleClientHttpRequestFactory extends SimpleClientHttpRequestFactory {
@Override
protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
super.prepareConnection(connection, httpMethod);
connection.setInstanceFollowRedirects(false);
}
}
那么该如何在创建的时候使用这个ClientHttpRequestFactory 呢,一种简单的方法直接在创建RestTemplate的时候传入参数即可
private static final RestTemplate REST_TEMPLATE = new RestTemplate(new NoRedirectSimpleClientHttpRequestFactory());
这样的话就返回的结果就是302了,可以在header中的"Location"拿到重定向的地址,再使用另外的RestTemplate来请求下载就可以了。但是spring既然提供了功能强大的RestTemplateBuilder,我们就要学会使用它,如下,可以顺便设置下超时时间之类的属性:
private static final RestTemplate REST_TEMPLATE = new RestTemplateBuilder()
.requestFactory(NoRedirectSimpleClientHttpRequestFactory.class)
.setConnectTimeout(Duration.ofMillis(3000))
.setConnectTimeout(Duration.ofMillis(5000))
.build();
另一种解决方案
使用上述方式也能勉强实现需求,但是很明显,需要调用两次http请求,非常耗费网络资源,如何在重定向的过程中拿到真实的uri,一次请求即可解决问题呢?因此,单单使用SimpleClientHttpRequestFactory已经不能满足我们的需求了,需要使用HttpComponentsClientHttpRequestFactory,该工厂底层使用HttpClient访问远程的Http服务,使用HttpClient可以配置连接池、重定向策略等信息。
在上面的代码中,如果我们直接使用new RestTemplateBuilder().build();
,那么得到的就是HttpComponentsClientHttpRequestFactory,这个类实现了什么功能呢,分析其源码:
/**
* Create a new instance of the {@code HttpComponentsClientHttpRequestFactory}
* with a default {@link HttpClient} based on system properties.
*/
public HttpComponentsClientHttpRequestFactory() {
this.httpClient = HttpClients.createSystem();
}
可以看到,spring提供了一个调用Apache的httpclient的入口,代码如下:
public static CloseableHttpClient createSystem() {
return HttpClientBuilder.create().useSystemProperties().build();
}
这里调用了HttpClientBuilder的build方法,可以看到build方法中进行了很多初始化操作,看起来比较复杂,那怎么找到我们需要的东西呢?
不要着急,我们回到RestTemplate的deExecute方法,在之前的断点出继续往里深入,可以看到经过AbstractClientHttpRequest
@Override
public final ClientHttpResponse execute() throws IOException {
assertNotExecuted();
// 进入这个方法
ClientHttpResponse result = executeInternal(this.headers);
this.executed = true;
return result;
}
执行到了HttpComponentsClientHttpRequest的executeInternal方法:
@Override
protected ClientHttpResponse executeInternal(HttpHeaders headers, byte[] bufferedOutput) throws IOException {
addHeaders(this.httpRequest, headers);
if (this.httpRequest instanceof HttpEntityEnclosingRequest) {
HttpEntityEnclosingRequest entityEnclosingRequest = (HttpEntityEnclosingRequest) this.httpRequest;
HttpEntity requestEntity = new ByteArrayEntity(bufferedOutput);
entityEnclosingRequest.setEntity(requestEntity);
}
// 在这里断点进入
HttpResponse httpResponse = this.httpClient.execute(this.httpRequest, this.httpContext);
return new HttpComponentsClientHttpResponse(httpResponse);
}
这里的httpClient是一个InternalHttpClient,都是通过HttpClientBuilder的build方法默认配置的,
@Override
protected CloseableHttpResponse doExecute(
final HttpHost target,
final HttpRequest request,
final HttpContext context) throws IOException, ClientProtocolException {
Args.notNull(request, "HTTP request");
HttpExecutionAware execAware = null;
if (request instanceof HttpExecutionAware) {
execAware = (HttpExecutionAware) request;
}
try {
// 省略一些代码,我们只看这个
return this.execChain.execute(route, wrapper, localcontext, execAware);
} catch (final HttpException httpException) {
throw new ClientProtocolException(httpException);
}
}
接下来就到了重点的部分,RedirectExec,从命名上也能看出这个类是和重定向有关的,详细分析一下:
@Override
public CloseableHttpResponse execute(
final HttpRoute route,
final HttpRequestWrapper request,
final HttpClientContext context,
final HttpExecutionAware execAware) throws IOException, HttpException {
// 省略一部分代码
// 从这里开始一步步执行看看
for (int redirectCount = 0;;) {
final CloseableHttpResponse response = requestExecutor.execute(
currentRoute, currentRequest, context, execAware);
try {
if (config.isRedirectsEnabled() &&
this.redirectStrategy.isRedirected(currentRequest.getOriginal(), response, context)) {
if (redirectCount >= maxRedirects) {
throw new RedirectException("Maximum redirects ("+ maxRedirects + ") exceeded");
}
redirectCount++;
final HttpRequest redirect = this.redirectStrategy.getRedirect(
currentRequest.getOriginal(), response, context);
if (!redirect.headerIterator().hasNext()) {
final HttpRequest original = request.getOriginal();
redirect.setHeaders(original.getAllHeaders());
}
// 省略部分代码
很明显可以看到,代码在执行完for循环里的第一句
final CloseableHttpResponse response = requestExecutor.execute(
currentRoute, currentRequest, context, execAware);
之后,这里的response的返回值为302,以及header中包含了名为Location的跳转之后的地址,之后是一个try…catch块,首先判断一下是否支持跳转以及调用redirectStrategy
的isRedirected方法判断,如果为真,并且重定向次数小于最大次数,那么就调用redirectStrategy.getRedirect
得到重定向后的请求,因此,关键就在于redirectStrategy
这个类里面的重定向策略,我们只需要在重定向的时候拿到真实地址就可以了。可以看到这里的redirectStrategy
是一个DefaultRedirectStrategy,很明显是代码进行的自动配置,我们只需要写一个类来继承它,加入我们的代码逻辑就可以了。
可以看到DefaultRedirectStrategy实现了接口RedirectStrategy,该接口只有两个方法:isRedirected和getRedirect,那么在那个方法里实现呢,通过一步步执行上面的代码可以发现,在重定向之后,如果判断isRedirected为假,就不会再调用getRedirect方法了,因此,我们的逻辑写在isRedirected中,具体代码如下:
public class GetUriRedirectStrategy extends DefaultRedirectStrategy {
@Override
public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException {
boolean redirected = super.isRedirected(request, response, context);
Header[] allHeaders = response.getAllHeaders();
for (Header header : allHeaders) {
if (StringUtils.equals(header.getName(), "Location")) {
context.setAttribute("uri", header.getValue());
}
}
Object uri = context.getAttribute("uri");
response.setHeader("uri", String.valueOf(uri));
return redirected;
}
}
在重定向之前,从header中拿出真实的地址Location,放到HttpContext中,在第二次调用判断时,再从HttpContext中取出,放到response的header中,这样就能在最终的返回结果中拿到了。(当然你也可以在getRedirect中放入,在isRedirected中拿出,只要能实现目的即可)
那么,剩下最后一个问题,如何把我们的重定向策略配置到整个调用过程中呢?
前面已经分析过,HttpComponentsClientHttpRequestFactory
在初始化时调用了HttpClients.createSystem()
方法,然后返回了HttpClientBuilder.create().useSystemProperties().build()
,而HttpClientBuilder有一个方法可以设置重定向策略,代码如下:
public final HttpClientBuilder setRedirectStrategy(final RedirectStrategy redirectStrategy) {
this.redirectStrategy = redirectStrategy;
return this;
}
那么,只需要在初始化时设置HttpClient为HttpClientBuilder.create().useSystemProperties().setRedirectStrategy(new GetUriRedirectStrategy()).build()
即可。
但是,想通过写一个类继承HttpComponentsClientHttpRequestFactory
是行不通的,因为HttpComponentsClientHttpRequestFactory
的HttpClient属性是私有的,我们无法修改。然而,该工厂给我们提供了一个自定义HttpClient的入口,如下:
public HttpComponentsClientHttpRequestFactory(HttpClient httpClient) {
this.httpClient = httpClient;
}
我们只需要在创建该工厂的时候传入HttpClient即可,具体代码如下:
private static final RestTemplate REST_TEMPLATE = new RestTemplateBuilder()
.requestFactory(new Supplier<ClientHttpRequestFactory>() {
@Override
public ClientHttpRequestFactory get() {
return new HttpComponentsClientHttpRequestFactory(
HttpClientBuilder.create().useSystemProperties().setRedirectStrategy(new GetUriRedirectStrategy()).build()
);
}
})
.build();
用lambda表达式简化一下如下:
private static final RestTemplate REST_TEMPLATE = new RestTemplateBuilder()
.requestFactory(() -> new HttpComponentsClientHttpRequestFactory(
HttpClientBuilder.create().useSystemProperties().setRedirectStrategy(new GetUriRedirectStrategy()).build()))
.build();
运行一下之前的代码,发现在返回值的header中多了一个uri的属性,其值即为重定向后的下载地址,至此,大功告成!