记一个诡异响应码HTTP 411
开发过程碰到http 411错误,记录并分析其原因。
问题记录
首先看看411是什么。MDN给出的解释如下:
服务器拒绝在没有定义 Content-Length 头的情况下接受请求。在添加了表明请求消息体长度的有效 Content-Length 头之后,客户端可以再次提交该请求。
使用不带body的POST有点类似使用一个不带参数的方法,比如说int post(void)
。虽然可行,但不是好的做法,而且要注意POST请求不带body时一定要带Content-Length: 0
,不然某些代理会拒绝这个POST请求。
有如下一段代码:
1 | public static void main(String[] args) throws IOException { |
它的运行结果非常诡异:
- 在Genemotion上运行时会返回411,提示
Length Required
- 在PC返回200
- 在真机上返回200
如果注释掉httpURLConnection.setChunkedStreamingMode(128 * 1024)
这个调用后,在Genemotion, PC及真机上均返回200。
通常使用OkHttp或其他第三方库进行http访问,很少使用HttpURLConnection
进行http访问,因为前者更方便。所以不太了解HttpURLConnection.setChunkedStreamingMode()
方法的作用,另外还有一个类似的方法HttpURLConnection.setFixedLengthStreamingMode()
。
查了下这两个方法的作用,如下:
setFixedLengthStreamingMode()
- 用于事先知道content length的情况下启用http request body的流式处理而不使用内部的缓存机制。 如果应用尝试写入的数据大小超过指定的大小,或者在写入数据前关闭了OutputStream,方法会抛出异常。当启用流式处理时,不会自动处理authentication和redirection。需要authentication和redirection时会抛出HttpRetryException异常。setFixedLengthStreamingMode()
方法应当在URLConnection连上之前调用。setChunkedStreamingMode()
- 用于事先不知道content length的情况下http request body的流式处理而不使用内部的缓存机制。这种模式下,将使用chunked transfer encoding方式来发送请求体。注意,不是所有服务器都支持这一模式。当启用流式处理时,不会自动处理authentication和redirection。需要authentication和redirection时会抛出HttpRetryException异常。setChunkedStreamingMode()
方法应当在URLConnection连上之前调用。
这两个方法都是用于开启streaming模式,以提高性能,所以应该跟411 Length Required应该没有直接的关系。不太明白为什么调用setChunkedStreamingMode()
之后就会有问题。
可以在发生411错误时打印出响应。响应中的错误信息表示的确很可能是缺少Content-Length
请求头。
1 | Content-Length 1580 |
如何打印出请求呢?我的做法很简单,使用Node + Express搭一个web服务,用同一份代码访问这个服务,观察请求头。
1 | const express = require('express'); |
- 当调用了
setChunkedStreamingMode()
方法后(无论参数是否-1),web服务无法收到请求,错误响应跟上述错误类似 - 当没有调用
setChunkedStreamingMode()
方法,web服务正常收到请求,显示content-length: 0
1 | // Genemotion |
推测411大概是这样发生的:Genemotion虚拟上的HttpURLConnection实现有bug,导致调用setChunkedStreamingMode()
方法后没有body的POST请求中缺少Content-Length
请求头,所以这个请求被代理(squid/2.7.STABLE9)拦下来了,拦截原因正是411 Length Required
。
HttpURLConnection
为加深对HttpURLConnection的了解,这里将Android SDK中HttpURLConnection的注释文档翻译了一遍。
HttpURLConnection是用于支持HTTP特性的URLConnection,具体参考HTTP。
它的使用方式如下:
- 调用
URL.openConnection()
来获取一个新的HttpURLConnection
对象,并将其强制转型为HttpURLConnection - 准备请求。请求的最重要属性是其URI。请求头可能包括诸如credentials, preferred content types, session cookies之类的元数据
- 请求体(可选)。如果包括请求体,则必须调用
HttpURLConnection.setDoOutput(true)
。通过写入getOutputStream()
返回的输出流的方式来传输数据 - 读取响应。响应头通常包括请求体content type, content length, modified date, session cookies之类的元数据。响应体可以从
getInputStream()
返回的输入流中读取。如果没有响应体,getInputStream()
返回一个空的流 - 断开连接。一旦读取响应体完毕,需要调用
disconnect()
来关闭HttpURLConnection。断开连接可以释放相关资源
以访问http://www.android.com/网站为例:
1 | URL url = new URL("http://www.android.com/"); |
HTTPS
在以”https”开头的URL上调用URL.openConnection()
时会返回HttpsURLConnection,可以覆盖缺省的HostnameVerifier
和SSLSocketFactory
。从SSLContext
创建的SSLSocketFactory
可以提供自定义的X509TrustManager
用于验证证书链,而自定义的X509KeyManager
可以提供客户端证书。
资源处理
HttpURLConnection允许5次HTTP重定向。它会从原始服务器重定向到另一个,但不支持从HTTPS重定向到HTTP或从HTTP重定向到HTTP。
如果HTTP响应中有错误发生,getInputStream()
方法会抛出IOException。使用getErrorStream()
读取错误响应。而响应头可以使用正常的getHeaderFields()
获取。
发送内容
向web服务器上传数据时,需要调用setDoOutput(true)
进行配置。
为了达到最好的性能,在事先知道请求体长度时应当调用setFixedLengthStreamingMode()
,而事先无法知道请求体长度时调用setChunkedStreamingMode()
。否则HttpURLConnection会被发送数据前在内存中为整个请求体分配缓冲区,浪费内存甚至可能引起OOM,并且导致数据发送延迟。
看个上传数据的例子:
1 | HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); |
性能
HttpURLConnection返回的输入流和输出流都没有缓冲。多数调用者应当使用BufferedInputStream
或BufferedOutputStream
包装httpURLConnection返回的流。只做块读写的调用方可以忽略缓冲。
向服务器大量上传或下载数据时,使用流方式可以避免一次占用过多内存。除非你需要将body一次性放进内存,否则应该以流的方式进行处理(也就是说不要将整个body保存为byte数据或String)
为减少延迟,HttpURLConnection可能会为多次请求复用同一个底层的Socket。复用的结果是HTTP连接保持的时间比实际需要的时间要长一些。调用disconnect()
会将Socket放回连接池。
缺省情况下HttpURLConnection要求服务器端使用gzip压缩,它能自动为getInputStream()
调用方解压数据。这种情况下Content-Encoding和Content-Length两个响应头会被清除。在请求头中添加”Accept-Encoding: identity”来关闭gzip压缩。
1 | urlConnection.setRequestProperty("Accept-Encoding", "identity"); |
指定明确的”Accept-Encoding”请求头会关闭自动解压,不会修改原始的响应头。调用方必须自己根据响应头中的Content-Type头进行必要的解压。
getContentLength()
返回传输的字节数,不能作为已压缩的输入流getInputStream()
中可读取的字节数。相反的,应该一直读取输入流直到数据耗尽,即InputStream.read()
返回-1。
处理网络登录
一些WiFi网络会阻止用户访问,直到用户点击某个登录页面。通常是通过HTTP重定向来展示登录页。可以使用getURL()
来测试连接是否被重定向。当然,这种测试只有在收到响应头后才有效,你可以调用getInputStream()
或getHeaderFields()
来触发响应。下面的例子在检查响应是否有被重定向:
1 | HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); |
HTTP认证
HttpURLConnection支持HTTP basic authentication。使用Authenticator
来设置JVM全局的 authentication handler:
1 | Authenticator.setDefault(new Authenticator() { |
除非同时使用了HTTPS,不建议将其作为用户认证机制。特别要说明的是,用户名、密码、请求以及响应都是在网络上明文传输的。
Session cookie
为了在客户端和服务器端建立和维护一个长期的会话,HttpURLConnection自带一个可扩展的cookie manager。使用CookieHandler和CookieManager来管理JVM全局的cookie:
1 | CookieManager cookieManager = new CookieManager(); |
缺省情况下CookieManager只接受来自原始服务器的cookie rfc2616。另外两种策略是CookiePolicy.ACCEPT_ALL
和CookiePolicy.ACCEPT_NONE
。实现CookiePolicy
来自定义cookie策略。
缺省情况下CookieManager只将cookie保存在内存中。当退出JVM时会清空cookie。通过实现CookieStore来自定义如何存储cookie。
除了可以接收HTTP响应的cookie,还可以通过程序设置cookie。HTTP请求头中的cookie必须指定domain和path。
缺省情况下HttpCookie实例能用于支持RFC 2965的服务器。而很多web服务器只支持老的规范,RFC 2109。为了兼容大多数web服务器,需要将cookie版本设置为0。
举例来说,想访问法语版本的twitter,代码如下:
1 | HttpCookie cookie = new HttpCookie("lang", "fr"); |
HTTP Methods
缺省时HttpURLConnection使用GET
方法。如果调用setDoOutput(true)
方法,它将使用POST
方法。还支持其他几种method,包括:OPTIONS
, HEAD
, PUT
, DELETE
和TRACE
,可以通过setRequestMethod()
方法来进行设置。
代理
缺省时HttpURLConnection直接连接原始服务器。也可以通过HTTP
代理或SOCKS
代理连接原始服务器。使用代理的方式是这样:调用URL.openConnection(Proxy)
方法创建连接。
IPv6
HttpURLConnection支持IPv6。对于既有IPv4地址又有IPv6地址的服务器,它会尝试所有地址直到连接成功。
缓存
对于Android平台来说:Android 4.0开始添加了响应绊缓存。如何为app开启缓存可以参考android.net.http.HttpResponseCache
。
相关的类
- Authenticator
- CookieManager
- CookieHandler
- CookieStore