Spring的RestTemplate自动重定向,如何拿到重定向后的地址?

背景:最近项目中需要对接第三方接口下载一些文件,访问下载地址链接会重定向到真正的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的属性,其值即为重定向后的下载地址,至此,大功告成!

  • 8
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值