Okhttp 之 HTTP Cookie 实现

本文主要的目的是分析 Okhttp 如何实现 HTTP Cookie,而 Cookie 是在 BridgeInterceptor 中使用的,因此本文从 BridgeInterceptor 讲起。

BridgeInterceptor

BridgeInterceptor 是用来为请求报文设置首部信息,例如 Connection: Keep-Alive,这些首部其中就包括 Cookie 首部。

BridgetInterceptor 是在 RealCallgetResponseWithInterceptorChain() 添加的

RealCall.java

    Response getResponseWithInterceptorChain() throws IOException {
        // ...
        interceptors.add(new BridgeInterceptor(client.cookieJar()));
        // ...
    }

创建 BridgeInterceptor 对象需要一个 CookieJar 对象,而 OkhttpClient 默认设置的 CookieJar 对象为 CookieJar.NO_COOKIES。现在来看下 CookieJar 接口的定义。

CookieJar

public interface CookieJar {
  CookieJar NO_COOKIES = new CookieJar() {
    @Override public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
    }

    @Override public List<Cookie> loadForRequest(HttpUrl url) {
      return Collections.emptyList();
    }
  };

  void saveFromResponse(HttpUrl url, List<Cookie> cookies);

  List<Cookie> loadForRequest(HttpUrl url);
}

CookieJar 接口定义了保存和加载 Cookie 集合的接口,它还定义了一个 CookiJar 的空实现 NO_COOKIES

BridgeInterceptor.intercept()

BridgeInterceptor 是用来处理请求报文的首部信息的,处理过程在 intercept() 方法中。

BridgeInterceptor.java

    public Response intercept(Chain chain) throws IOException {
        Request userRequest = chain.request();
        Request.Builder requestBuilder = userRequest.newBuilder();

        // 1. 如果请求有body,就设置Content-Type, Content-Length/Transfer-Encoding 请求头属性
        RequestBody body = userRequest.body();
        if (body != null) {
            MediaType contentType = body.contentType();
            if (contentType != null) {
                requestBuilder.header("Content-Type", contentType.toString());
            }

            long contentLength = body.contentLength();
            if (contentLength != -1) {
                requestBuilder.header("Content-Length", Long.toString(contentLength));
                requestBuilder.removeHeader("Transfer-Encoding");
            } else {
                requestBuilder.header("Transfer-Encoding", "chunked");
                requestBuilder.removeHeader("Content-Length");
            }
        }

        // 2. 确认并设置Host属性
        if (userRequest.header("Host") == null) {
            requestBuilder.header("Host", hostHeader(userRequest.url(), false));
        }

        // 3. 没有Connection请求头属性,就设置为Keep-Alive
        if (userRequest.header("Connection") == null) {
            requestBuilder.header("Connection", "Keep-Alive");
        }

        // 4. 没有Accept-Encoding和Range请求头属性,就设置为gzip
        // If we add an "Accept-Encoding: gzip" header field we're responsible for also decompressing
        // the transfer stream.
        boolean transparentGzip = false;
        if (userRequest.header("Accept-Encoding") == null && userRequest.header("Range") == null) {
            transparentGzip = true;
            requestBuilder.header("Accept-Encoding", "gzip");
        }

        // 5. 如果有cookie,就把内容设置到请求头的Cookie属性中
        List<Cookie> cookies = cookieJar.loadForRequest(userRequest.url());
        if (!cookies.isEmpty()) {
            requestBuilder.header("Cookie", cookieHeader(cookies));
        }

        // 6. 设置User-Agent请求头属性
        if (userRequest.header("User-Agent") == null) {
            requestBuilder.header("User-Agent", Version.userAgent());
        }

        // 7. 执行网络请求
        Response networkResponse = chain.proceed(requestBuilder.build());

        // 8. 使用cookieJar保存响应报文中的cookie信息
        HttpHeaders.receiveHeaders(cookieJar, userRequest.url(), networkResponse.headers());

        // 9. 重新构建响应
        Response.Builder responseBuilder = networkResponse.newBuilder()
                .request(userRequest);

        // 10. 处理响应报文的Content-Encoding响应头支持gzip的情况
        if (transparentGzip
                && "gzip".equalsIgnoreCase(networkResponse.header("Content-Encoding"))
                && HttpHeaders.hasBody(networkResponse)) {
            GzipSource responseBody = new GzipSource(networkResponse.body().source());
            Headers strippedHeaders = networkResponse.headers().newBuilder()
                    .removeAll("Content-Encoding")
                    .removeAll("Content-Length")
                    .build();
            responseBuilder.headers(strippedHeaders);
            String contentType = networkResponse.header("Content-Type");
            responseBuilder.body(new RealResponseBody(contentType, -1L, Okio.buffer(responseBody)));
        }

        return responseBuilder.build();
    }

第一步是对请求报文有body的情况进行处理。

  1. 设置 Content-Type 指明 bodyMIME 的类型,例如 text/plain 指明 body 为纯文本类型。Content-Type 其实还可以指定字符集,例如 Content-Type: text/html; charset=utf-8
  2. 设置 Content-LengthTransfer-Encoding 首部,这两个首部都是为了确定 body 的长度。
    • Content-Length 是指明 body 的长度。HTTP早期的版本采用关闭连接的办法来划定报文的结束。但是没有 Content-Length,接收方无法判断报文结束时,连接是正常的关闭还是由于发送方服务器崩溃而导致的连接关闭,所以 Content-Length可以检测报文截尾。而对于持久连接来说,Content-Length就显得尤其重要,它可以对多个报文进行正确分段。
    • 使用持久连接时,也可以没有 Content-Length 首部,可以采用分块编码(chunked encoding),也就是指定 Tranfser-Encoding 首部为 chunked。在这种情况下,数据可以分为一系列的块来发送,每块都有大小说明。也就是说服务器在生成首部的时候并不需要知道body的大小,仍然可以使用分块编码传输数据。

第二步是设置 Host 首部,HTTP/1.1 规定客户端在请求报文中必须要指定 Host 首部,如果不指定的话,任何基于 HTTP/1.1 的服务器会返回一个 400(Bad Request) 响应码。

扫描二维码关注公众号,回复: 8622591 查看本文章

第三步是设置 Connection 首部,HTTP/1.1 支持持久连接,因此如果未指定 Connection 首部,就直接设置为 Keep-Alive

Connection 首部也可以设置为 close,指定在下一条报文发送完毕后关闭连接。然而,Connection 首部并不像字面的意思那么简单,Connection 其实也可以指定不被转发的首部,当代理收到请求报文的时候,它会将 Connection 首部以及 Connection 列出的首部进行删除,然后再把请求转发出去。

第四步是设置 Accept-Encoding 首部,它用来告诉服务器,客户端可以接受的编码。 如果请求不包含 Accept-Encoding 首部,就把 Accept-Encoding 设置为 gzip,也就是说 Okhttp 默认支持 gzip 压缩和解压的。而对于 Range 首部,它是用来执行范围请求的,但是它与 Accept-Encoding: gzip 是不兼容的,所以也就出现了代码中那个 if 条件。

先跳过第五步,来看第六步,这里是设置 User-Agent,它是将发起请求的应用程序名称告诉服务器,例如,Okhttp 返回的就是一个版本 okhttp/3.10.0,而对于浏览器来说,就需要返回浏览器的版本和操作系统的名称等等一些数据。

本文的重点就是第五步和第八步,关于 HTTP Cookie 的部分。

HTTP Cookie

HTTP Cookie 是用来保存用户的一些信息的,例如登陆名。 HTTP Cookie 可以分为 会话 Cookie持久 Cookie会话 Cookie 在会话期间有效,一旦会话结束,例如退出浏览器,会话 Cookie 就会被删除。 而 持久 Cookie 会存储在硬盘上,就算会话断开,它仍然存在。

HTTP Cookie 最初是由 Netscape 公司定义的,也就是Cookie 版本0,而 RFC 2965 定义了一个扩展版本,也就是 Cookie 版本1,而 Okhttp 使用的是 RFC 6265HTTP Cookie,想要知道其中的细节,可以阅读下,并不复杂。

Cookie 一个简单的应用是跟踪会话(Session),当用户第一次访问一个网址的时候,例如访问 http://www.android.com 的时候,服务器会返回一个带有会话标识的响应报文

HTTP/1.1 200 OK
Set-Cookie: SID="123"; path="/"; domain="android.com"
Content-Type: text/html
Content-Length: 10

...body...

这个响应报文的 Set-Cookie 中指定了会话标识 SID123,指定了 Cookie 的域属性 domainandroid.com,指定了 Cookie 的路径属性为 /

当我们从当前页面再点击一个链接的时候,例如 http://www.android.com/hello.json,那么在请求报文中会添加 Cookie 首部

GET /hello.json HTTP/1.1
Host: www.android.com
Cookie: SID="123"

当服务器接收这个请求报文后,就会发现,这是同一个会话,那么就会把这个页面的所有数据与上个页面的所有数据都保存到同一个数据库中。

Okhttp Cookie

BridgeInterceptor.intercept() 的第五步中,使用 CookieJar 加载了与 request URL 相关的各种 Cookie,然后加入到 Cookie 首部中。其中 cookieHeader() 方法就是用来生成 Cookie 首部的值。

BridgeInterceptor.java

    /** Returns a 'Cookie' HTTP request header with all cookies, like {@code a=b; c=d}. */
    private String cookieHeader(List<Cookie> cookies) {
        StringBuilder cookieHeader = new StringBuilder();
        for (int i = 0, size = cookies.size(); i < size; i++) {
            if (i > 0) {
                cookieHeader.append("; ");
            }
            Cookie cookie = cookies.get(i);
            cookieHeader.append(cookie.name()).append('=').append(cookie.value());
        }
        return cookieHeader.toString();
    }

可以注意到,是用 Cookie 类的 namevalue成员变量组成的键值对,并用 ; 隔开。

BridgeInterceptor.intercept() 的第八步中,解析了响应头中的 Set-Cookie,并使用 cookieJar 保存响应报文中的cookie信息。

HttpHeaders.java

    public static void receiveHeaders(CookieJar cookieJar, HttpUrl url, Headers headers) {
        if (cookieJar == CookieJar.NO_COOKIES) return;
        // 解析请求头,并转化为 Cookie 集合
        List<Cookie> cookies = Cookie.parseAll(url, headers);
        if (cookies.isEmpty()) return;

        cookieJar.saveFromResponse(url, cookies);
    }

解析响应头中的 Set-Cookie 是用 Cookie 类的 parseAll() 方法

Cookie.java

    /** Returns all of the cookies from a set of HTTP response headers. */
    public static List<Cookie> parseAll(HttpUrl url, Headers headers) {
        // 1. 获取Set-Cookie首部的值
        List<String> cookieStrings = headers.values("Set-Cookie");
        List<Cookie> cookies = null;
        // 2. 生成 Cookie 对象集合
        for (int i = 0, size = cookieStrings.size(); i < size; i++) {
            Cookie cookie = Cookie.parse(url, cookieStrings.get(i));
            if (cookie == null) continue;
            if (cookies == null) cookies = new ArrayList<>();
            cookies.add(cookie);
        }
        // 3. 返回集合
        return cookies != null
            ? Collections.unmodifiableList(cookies)
            : Collections.<Cookie>emptyList();
    }

由于响应报文中可以包含多个 Set-Cookie 首部,因此Headers 类的 values() 方法会把响应头中的 所有Set-Cookie 解析成 List<String> 对象,这里就不深究如何解析的。然后,用 Cookie 类的 parse() 方法来为每一个 Set-Cookie 首部来生成 Cookie 对象。

Cookie.java

  public static @Nullable Cookie parse(HttpUrl url, String setCookie) {
    return parse(System.currentTimeMillis(), url, setCookie);
  }

  static @Nullable Cookie parse(long currentTimeMillis, HttpUrl url, String setCookie) {
    int pos = 0;
    int limit = setCookie.length();

    // 1. 解析name=value
    int cookiePairEnd = delimiterOffset(setCookie, pos, limit, ';');

    int pairEqualsSign = delimiterOffset(setCookie, pos, cookiePairEnd, '=');
    if (pairEqualsSign == cookiePairEnd) return null;

    String cookieName = trimSubstring(setCookie, pos, pairEqualsSign);
    if (cookieName.isEmpty() || indexOfControlOrNonAscii(cookieName) != -1) return null;

    String cookieValue = trimSubstring(setCookie, pairEqualsSign + 1, cookiePairEnd);
    if (indexOfControlOrNonAscii(cookieValue) != -1) return null;

    long expiresAt = HttpDate.MAX_DATE;
    long deltaSeconds = -1L;
    String domain = null;
    String path = null;
    boolean secureOnly = false;
    boolean httpOnly = false;
    boolean hostOnly = true;
    boolean persistent = false;

    // 2. 循环解析属性,包括 expires, max-age, domain, path, secure, httponly
    pos = cookiePairEnd + 1;
    while (pos < limit) {
      int attributePairEnd = delimiterOffset(setCookie, pos, limit, ';');

      int attributeEqualsSign = delimiterOffset(setCookie, pos, attributePairEnd, '=');
      String attributeName = trimSubstring(setCookie, pos, attributeEqualsSign);
      String attributeValue = attributeEqualsSign < attributePairEnd
          ? trimSubstring(setCookie, attributeEqualsSign + 1, attributePairEnd)
          : "";

      if (attributeName.equalsIgnoreCase("expires")) {
        try {
          expiresAt = parseExpires(attributeValue, 0, attributeValue.length());
          persistent = true;
        } catch (IllegalArgumentException e) {
          // Ignore this attribute, it isn't recognizable as a date.
        }
      } else if (attributeName.equalsIgnoreCase("max-age")) {
        try {
          deltaSeconds = parseMaxAge(attributeValue);
          persistent = true;
        } catch (NumberFormatException e) {
          // Ignore this attribute, it isn't recognizable as a max age.
        }
      } else if (attributeName.equalsIgnoreCase("domain")) {
        try {
          domain = parseDomain(attributeValue);
          hostOnly = false;
        } catch (IllegalArgumentException e) {
          // Ignore this attribute, it isn't recognizable as a domain.
        }
      } else if (attributeName.equalsIgnoreCase("path")) {
        path = attributeValue;
      } else if (attributeName.equalsIgnoreCase("secure")) {
        secureOnly = true;
      } else if (attributeName.equalsIgnoreCase("httponly")) {
        httpOnly = true;
      }

      pos = attributePairEnd + 1;
    }

    // 3. 如果同时设置Max-Age和Expires属性,取 `Max-Age
    if (deltaSeconds == Long.MIN_VALUE) {
      expiresAt = Long.MIN_VALUE;
    } else if (deltaSeconds != -1L) {
      long deltaMilliseconds = deltaSeconds <= (Long.MAX_VALUE / 1000)
          ? deltaSeconds * 1000
          : Long.MAX_VALUE;
      expiresAt = currentTimeMillis + deltaMilliseconds;
      if (expiresAt < currentTimeMillis || expiresAt > HttpDate.MAX_DATE) {
        expiresAt = HttpDate.MAX_DATE; // Handle overflow & limit the date range.
      }
    }

    // 4. 如果domain属性为null,就取URL的Host首部值
    // If the domain is present, it must domain match. Otherwise we have a host-only cookie.
    String urlHost = url.host();
    if (domain == null) {
      domain = urlHost;
    } else if (!domainMatch(urlHost, domain)) {
      return null; // No domain match? This is either incompetence or malice!
    }

    // 5. 判断domain属性的合法性
    // If the domain is a suffix of the url host, it must not be a public suffix.
    if (urlHost.length() != domain.length()
        && PublicSuffixDatabase.get().getEffectiveTldPlusOne(domain) == null) {
      return null;
    }

    // 6. 判断 path属性的合法性
    // If the path is absent or didn't start with '/', use the default path. It's a string like
    // '/foo/bar' for a URL like 'http://example.com/foo/bar/baz'. It always starts with '/'.
    if (path == null || !path.startsWith("/")) {
      String encodedPath = url.encodedPath();
      int lastSlash = encodedPath.lastIndexOf('/');
      path = lastSlash != 0 ? encodedPath.substring(0, lastSlash) : "/";
    }

    // 7. 创建并返回 Cookie 对象
    return new Cookie(cookieName, cookieValue, expiresAt, domain, path, secureOnly, httpOnly,
        hostOnly, persistent);
  }

解析过程代码中已经说明,我们来举个例子说明下如何解析的吧。 假如现在响应报文中的 Set-Cookie 首部如下

Set-Cookie: SID="123"; Path="/"; Domain="android.com"; Max-Age="86400"; Secure; HttpOnly

那么用 Cookie.parse() 方法解析的就是 SID="123"; Path="/"; Domain="android.com"; Max-Age="86400"; Secure; HttpOnly 字符串。过程如下:
1. 首先 pos=0limit 为字符串的长度,先获取第一个分号的索引 cookiePairEnd,然后根据 poscookiePairEnd 找出等号的索引 pairEqualsSign,通过 pospairEqualsSign解析出 name , 通过 pairEqualsSign + 1cookiePairEnd 解析出 value
2. 然后 pos=cookiePairEnd + 1,按照上面的方式通过循环来解析各种属性。

实现 CookieJar

前面介绍过,HTTP Cookie 分为 会话Cookie持久Cookie,只要响应报文的 Set-Cookie 首部中,设置过 ExpiresMax-Age 属性,这就说明是 持久Cookie,这在前面所分析的 Cookie.parse() 方法中也得到验证。那么在实现 CookieJar 的时候,要分两种情况存储。

在获取 Cookie 的时候,我们要判断它是否过期,如果过期,要删除。 如果没过期,我们还要验证 Cookiedomainpath 是否匹配,这可以用 Cookie.matches(httpUrl url) 方法判断。

一旦我们明白以上两点,就可以开始写一个自己的 CookieJar 了,但是这个过程也是比较繁杂的,不过有一个现成的开源库 PersistentCookieJar,虽然它没有把 会话 Cookie持久 Cookie 分开,但是从实现的角度看,已经很完善了。它的使用方式如下

OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .cookieJar(new PersistentCookieJar(new SetCookieCache(), new SharedPrefsCookiePersistor(context));)
                .build();
发布了44 篇原创文章 · 获赞 30 · 访问量 400万+

猜你喜欢

转载自blog.csdn.net/zwlove5280/article/details/79964437