HTTP 重定向原理

HTTP 协议中,Server 端可以通过 HTTP 状态码 + Location 响应头 的方式告知 Client, 当前访问的地址被移除,请访问新的资源 ;如下所示:

  • 301 状态码 告知 Client 当前资源被永久移除
  • Location 响应头 告知 Client 当前访问的资源被移到了什么地方

可以看到,当前请求还是有响应结果的,Client 可以选择 显示响应结果 ,或者 跳转到新的资源地址

$ curl -v http://h2.kail.xyz/
> GET / HTTP/1.1
> ...
>
< HTTP/1.1 301 Moved Permanently
< Location: https://h2.kail.xyz/
< ...
 
<html>
  <head><title>301 Moved Permanently</title></head>
  <body>
    <center><h1>301 Moved Permanently</h1></center>
    <hr><center>openresty/1.19.9.1</center>
  </body>
</html>
 

注意 1: 重定向是 针对 URL 资源的,不是针对域名

  • 比如:Server 端返回的 Location 值,完全可以是 Location: https://h2.kail.xyz/other-resource
  • 即 访问的是 http://h2.kail.xyz/,之后会跳转到 https://h2.kail.xyz/other-resource

注意 2:一般重定向后的资源会转成 GET 请求,也可以通过其他状态码控制使用原始请求方式,后面会说明

  • 假如原始请求是 POST,重定向后的资源会变成 GET 方式访问

HttpClient 中 Redirect 的默认行为

httpclient:4.5.6

我们一般这样创建一个默认的 HttpClient

final CloseableHttpClient httpClient = HttpClients.createDefault();
 

等同于

// 没有任何自定义参数
final CloseableHttpClient build = HttpClients.custom().build();
 

build() 时构造 CloseableHttpClient,关于重定向的部分如下:

public CloseableHttpClient build() {
  // ...
 
  // 判断是否禁用重定向,redirectHandlingDisabled 默认 false,即默认支持重定向
  if (!redirectHandlingDisabled) {
 
    // 重定向策略,如果用户没有自定义,使用默认重定向策略
    RedirectStrategy redirectStrategyCopy = this.redirectStrategy;
    if (redirectStrategyCopy == null) {
      redirectStrategyCopy = DefaultRedirectStrategy.INSTANCE;
    }
 
    // 在执行链中加入 RedirectExec 重定向执行器
    execChain = new RedirectExec(execChain, routePlannerCopy, redirectStrategyCopy);
  }
 
  // ...
}
 

RedirectStrategy 重定向策略

重试策略使用的默认实现是 DefaultRedirectStrategy,主要作用就是:

  1. 判断是否要重定向:响应头中包含 Location ,且 状态码是 301302303307 其中之一,HttpClient5 支持 308
  2. 获取重定向后的地址: 获取响应头中的 Location
  3. 转换请求类型 ,后面会说明(HttpClient5 转换逻辑放到 RedirectExec 中)

RedirectExec 重定向执行器

其核心逻辑是 根据是否重定向,进行循环请求 ,简化后的伪代码如下:

public CloseableHttpResponse execute(...){
  // 获取配置的最大重定向次数
  final int maxRedirects = config.getMaxRedirects() > 0 ? config.getMaxRedirects() : 50;
  // 循环重定向
  for (int redirectCount = 0;;redirectCount++) {
    // 执行请求逻辑,拿到 Response
    final CloseableHttpResponse response = requestExecutor.execute(...);
    // 是否开启重定向 && 当前请求资源被重定向了
    if (config.isRedirectsEnabled() && this.redirectStrategy.isRedirected(...)) {
      // 限制重定向次数
      if (redirectCount >= maxRedirects) {
        throw new RedirectException("Maximum redirects ("+ maxRedirects + ") exceeded");
      }
      // 获取重定向后的地址,构造新的请求,进入下次循环,重新发起请求
      final HttpRequest redirect = this.redirectStrategy.getRedirect(...);
    } else {
      // 如果重定向没有开启,直接返回
      return response;
    }
  }
}
 

禁用重定向

重定向默认是开启,如果您需要禁用 HttpClient 的重定向功能,从上面 HttpClients.custom().build()RedirectExec 的伪代码中可以看出,禁用重定向有两种方式:

  • 实例级别禁用 :禁止 RedirectExec 的构建,在整个请求逻辑中,没有 Redirect 相关逻辑的代码
  • 请求级别禁用: RedirectExec 仍在请求处理链中,但是不进行重定向,可以控制到指定的请求

实例级别禁用

CloseableHttpClient httpClient = HttpClients.custom()
  .disableRedirectHandling() // ❤ 该 HttpClient 实例不支持重定向
  .build();
 

请求级别禁用

RequestConfig requestConfig = RequestConfig.custom()
  .setRedirectsEnabled(false) // ❤ 请求配置
  .build();
CloseableHttpClient httpClient = HttpClients.custom()
  .setDefaultRequestConfig(requestConfig) // ❤ 默认行为
  .build();
HttpRequestBase request = new HttpGet("http://h2.kail.xyz");
request.setConfig(requestConfig); // ❤ 或 每次请求前进行设置
 

重定向状态码的语义

状态码协议规范作用

永久 vs 临时

永久: 原始资源被永久移除, 当你发起请求时,应该直接访问重定向后资源 ,客户端会缓存 301 状态,下次直接跳到新的资源地址,不会产生真实的请求

临时: 原始资源可能还在,不确定什么时候恢复, 当你发起请求时,应该先访问原始资源

测试:http://httpbin.org/status/301,第二次会走磁盘缓存

测试:http://httpbin.org/status/302,第二次仍然发起请求

301/302 vs 308/307 状态

  • 301/302308/307 对应的 永久 和 临时 语义是一样的
  • 308/307 状态码 不允许 Client 将原本为 非 GET 的请求重定向到 GET 请求上 ,即 会保留原始的请求方式
  • ❤ 而 301/302 会把 POST 请求 转为 GET 请求访问 Location 的值
  • 测试详见下方:「HttpClient 对 状态码 的处理方式」

303 状态

  • 可以理解为,原始请求的资源 和 重定向后的资源 都可以访问,重定向到的资源并不是你所请求的资源,而是对你所请求资源的一些描述
  • ❤ 与 302 类似,区别是 302 只会把 POST 转成 GET 方式访问 ,303 除了 GET 和 HEAD,其他都会被转成 GET

HttpClient 对 状态码 的处理方式

不区分永久和临时的语义

HttpClient 并 不区分永久和临时的语义 ,即 每次都会事先访问原始资源,再根据请求结果重定向,相当于每次访问会产生两次请求

这里配置 301 重定向,HTTP 重定向到 HTTPs

server {
  listen 80;
  server_name h2.kail.xyz;
  return 301 https://$server_name$request_uri;
}

测试代码

final CloseableHttpClient httpClient = HttpClients.createDefault();
HttpGet get = new HttpGet("http://h2.kail.xyz");
for (int i = 0; i < 2; i++) {
  try (CloseableHttpResponse response = httpClient.execute(get);) {
    EntityUtils.consume(response.getEntity());
  }
}
 

Nginx 日志,每次调用,两次请求

172.17.0.1 - - [26/Mar/2022:18:11:58 +0000] "GET / HTTP/1.1" 301 175 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
172.17.0.1 - - [26/Mar/2022:18:11:59 +0000] "GET / HTTP/1.1" 200 23 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
 
172.17.0.1 - - [26/Mar/2022:18:11:59 +0000] "GET / HTTP/1.1" 301 175 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
172.17.0.1 - - [26/Mar/2022:18:11:59 +0000] "GET / HTTP/1.1" 200 23 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
 

区分 301、308 状态

测试代码

HttpPost post = new HttpPost("http://h2.kail.xyz");                   
// 这里改成了 POST
try (CloseableHttpResponse response = httpClient.execute(post);) {
  EntityUtils.consume(response.getEntity());
}
 

301 状态时的 Nginx 日志,POST 重定向后变为了 GET

172.17.0.1 - - [26/Mar/2022:18:18:43 +0000] "POST / HTTP/1.1" 301 175 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
172.17.0.1 - - [26/Mar/2022:18:18:44 +0000] "GET / HTTP/1.1" 200 23 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
 

308 状态时的 Nginx 日志,重定向后 仍然是 POST (405 是因为 Nginx 不允许对静态资源发起 POST 请求)

172.17.0.1 - - [26/Mar/2022:18:21:52 +0000] "POST / HTTP/1.1" 308 177 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
172.17.0.1 - - [26/Mar/2022:18:21:52 +0000] "POST / HTTP/1.1" 405 163 "-" "Apache-HttpClient/5.1.3 (Java/1.8.0_271)"
 

HttpClient 对请求转换的伪代码

httpclient:5.1.3

支持重定向的状态码 @see DefaultRedirectStrategy

switch (statusCode) {
  case HttpStatus.SC_MOVED_PERMANENTLY:  // 301
  case HttpStatus.SC_MOVED_TEMPORARILY:  // 302
  case HttpStatus.SC_SEE_OTHER:          // 303
  case HttpStatus.SC_TEMPORARY_REDIRECT: // 307
  case HttpStatus.SC_PERMANENT_REDIRECT: // 308
    return true;
  default:
    return false;
}
 

状态码请求方式转换 @see RedirectExec

switch (statusCode) {
  case HttpStatus.SC_MOVED_PERMANENTLY: // 301
  case HttpStatus.SC_MOVED_TEMPORARILY: // 302
    // 只针对 POST 请求进行转换
    if (Method.POST.isSame(request.getMethod())) {
      // POST 转 GET
      redirectBuilder = BasicRequestBuilder.get();
    } else {
      // 其他类型不转
      redirectBuilder = BasicRequestBuilder.copy(scope.originalRequest);
    }
    break;
  case HttpStatus.SC_SEE_OTHER: // 303
    // 非 GET && 非 HEAD,即 GET 和 HEAD 不转,其他统一转成 GET
    if (!Method.GET.isSame(request.getMethod()) && !Method.HEAD.isSame(request.getMethod())) {
      redirectBuilder = BasicRequestBuilder.get();
    } else {
      // 其他类型不转
      redirectBuilder = BasicRequestBuilder.copy(scope.originalRequest);
    }
    break;
  default:
    // 307、308 不转换请求类型
    redirectBuilder = BasicRequestBuilder.copy(scope.originalRequest);
}