本文主要的目的是分析 Okhttp
如何实现 HTTP Cookie
,而 Cookie
是在 BridgeInterceptor
中使用的,因此本文从 BridgeInterceptor
讲起。
BridgeInterceptor
BridgeInterceptor
是用来为请求报文设置首部信息,例如 Connection: Keep-Alive
,这些首部其中就包括 Cookie
首部。
BridgetInterceptor
是在 RealCall
的 getResponseWithInterceptorChain()
添加的
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
的情况进行处理。
- 设置
Content-Type
指明body
的MIME
的类型,例如text/plain
指明body
为纯文本类型。Content-Type
其实还可以指定字符集,例如Content-Type: text/html; charset=utf-8
。 - 设置
Content-Length
或Transfer-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)
响应码。
第三步是设置 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 6265 的 HTTP Cookie
,想要知道其中的细节,可以阅读下,并不复杂。
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
中指定了会话标识 SID
为 123
,指定了 Cookie
的域属性 domain
为 android.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
类的 name
和 value
成员变量组成的键值对,并用 ;
隔开。
在 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=0
,limit
为字符串的长度,先获取第一个分号的索引 cookiePairEnd
,然后根据 pos
和 cookiePairEnd
找出等号的索引 pairEqualsSign
,通过 pos
和 pairEqualsSign
解析出 name
, 通过 pairEqualsSign + 1
和 cookiePairEnd
解析出 value
。
2. 然后 pos=cookiePairEnd + 1
,按照上面的方式通过循环来解析各种属性。
实现 CookieJar
前面介绍过,HTTP Cookie
分为 会话Cookie
和 持久Cookie
,只要响应报文的 Set-Cookie
首部中,设置过 Expires
或 Max-Age
属性,这就说明是 持久Cookie
,这在前面所分析的 Cookie.parse()
方法中也得到验证。那么在实现 CookieJar
的时候,要分两种情况存储。
在获取 Cookie
的时候,我们要判断它是否过期,如果过期,要删除。 如果没过期,我们还要验证 Cookie
的 domain
和 path
是否匹配,这可以用 Cookie.matches(httpUrl url)
方法判断。
一旦我们明白以上两点,就可以开始写一个自己的 CookieJar
了,但是这个过程也是比较繁杂的,不过有一个现成的开源库 PersistentCookieJar,虽然它没有把 会话 Cookie
和 持久 Cookie
分开,但是从实现的角度看,已经很完善了。它的使用方式如下
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.cookieJar(new PersistentCookieJar(new SetCookieCache(), new SharedPrefsCookiePersistor(context));)
.build();