标签归档:http

HttpURLConnection在底层是否复用socket的简单验证方式

关于JDK自身的HttpURLConnection在底层是否复用socket的测试方式,可以快速用repl和lsof来检测:

// 本地启动一个 http server,它返回十几个字符
 ➜  curl "http://localhost:8080/sleep?time=1000"
{"code":"ok"}

// 在repl下连续请求这个url 若干次
scala> val is = new java.net.URL("http://localhost:8080/sleep?time=100").openConnection.getInputStream; for(i <- 1 to 15) is.read; is.close
is: java.io.InputStream = sun.net.www.protocol.http.HttpURLConnection$HttpInputStream@1ba9117e

scala> val is = new java.net.URL("http://localhost:8080/sleep?time=100").openConnection.getInputStream; for(i <- 1 to 15) is.read; is.close
is: java.io.InputStream = sun.net.www.protocol.http.HttpURLConnection$HttpInputStream@a82c5f1

与此同时在另一个终端用lsof查看socket,每秒刷新一次,可看到客户端socket是同一个

 ➜  /usr/sbin/lsof -Pan -iTCP -r 1 -p 43280
=======
=======
COMMAND   PID      USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    43280 hongjiang   47u  IPv6 0x43acdfd2ea5b0c01      0t0  TCP 127.0.0.1:57304->127.0.0.1:8080 (ESTABLISHED)
=======
COMMAND   PID      USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    43280 hongjiang   47u  IPv6 0x43acdfd2ea5b0c01      0t0  TCP 127.0.0.1:57304->127.0.0.1:8080 (ESTABLISHED)
=======
COMMAND   PID      USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    43280 hongjiang   47u  IPv6 0x43acdfd2ea5b0c01      0t0  TCP 127.0.0.1:57304->127.0.0.1:8080 (ESTABLISHED)
=======
COMMAND   PID      USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    43280 hongjiang   47u  IPv6 0x43acdfd2ea5b0c01      0t0  TCP 127.0.0.1:57304->127.0.0.1:8080 (ESTABLISHED)  

这个话题是由URLConnection在关闭的时候应该调用close还是disConnect所引起的,关于jdk里keep-alive相关的一些参数不展开了。

验证disconnect方法:

// 执行若干次
scala> val conn = new java.net.URL("http://localhost:8080/sleep?time=100").openConnection.asInstanceOf[java.net.HttpURLConnection]; val is=conn.getInputStream; for(i <- 1 to 15) is.read; conn.disconnect

这时没法用lsof观察了,它最小刷新单位是1秒,因为每次连接立即关闭导致没机会看到,得用tcpdump来观察

 ➜  sudo tcpdump -i lo0  -s 1024 -l -A  port 8080

 ^[[A04:59:57.066577 IP localhost.57355 > localhost.http-alt: Flags [S]
 ...
 -`=.-`=.GET /sleep?time=100 HTTP/1.1
User-Agent: Java/1.8.0_51
Host: localhost:8080
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive

...

05:00:05.407691 IP localhost.57356 > localhost.http-alt: Flags [P.], seq 1:168, ack 1, win 12759, options [nop,nop,TS val 761290281 ecr 761290281], length 167: HTTP: GET /sleep?time=100 HTTP/1.1
E...LF@.@.........................1........
-`^)-`^)GET /sleep?time=100 HTTP/1.1
User-Agent: Java/1.8.0_51
Host: localhost:8080
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive 

... 

05:00:07.045830 IP localhost.57357 > localhost.http-alt: Flags [P.], seq 1:168, ack 1, win 12759, options [nop,nop,TS val 761291915 ecr 761291915], length 167: HTTP: GET /sleep?time=100 HTTP/1.1
E.....@.@................l.;.\.,..1........
-`d.-`d.GET /sleep?time=100 HTTP/1.1
User-Agent: Java/1.8.0_51
Host: localhost:8080
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive

看到三次连接每次客户端socket端口都变了。

HTTP Header里的If-xxx条件参数

上一篇讨论实现静态资源访问的问题,除了可部分获取资源,还有条件参数,服务器端判断当前资源状况满足这些条件才处理请求。

这些条件有If-Match,If-Modified-Since,If-Range,If-Unmodified-Since,If-None-Match等。最常见的If-None-Match这个header,用来在获取资源时比较本地的etag,如果服务器端不一致才获取。

先获取一个资源的etag:

$ curl -I http://localhost:8080/chain.jpg

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Accept-Ranges: bytes
ETag: W/"4932-1448300760000"
Last-Modified: Tue, 24 Nov 2015 01:46:00 CST
Content-Type: image/jpeg
Content-Length: 4932
Date: Mon, 23 Nov 2015 18:12:49 GMT

然后在请求header里增加If-None-Match内容:

$ curl -v -H 'If-None-Match: W/"4932-1448300760000"'  http://localhost:8080/chain.jpg

* Hostname was NOT found in DNS cache
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /_files_/img/chain.jpg HTTP/1.1
> User-Agent: curl/7.37.1
> Host: localhost:8080
> Accept: */*
> If-None-Match: W/"4932-1448300760000"
>
< HTTP/1.1 304 Not Modified
* Server Apache-Coyote/1.1 is not blacklisted
< Server: Apache-Coyote/1.1
< ETag: W/"4932-1448300760000"
< Date: Mon, 23 Nov 2015 18:13:01 GMT
<
* Connection #0 to host localhost left intact

etag相等的话,条件不符合返回304。

HTTP Header里的Range和Content-Range参数

这个话题是从实现一个http资源的静态访问引发的。http协议从1.1开始支持获取文件的部分内容,这为并行下载以及断点续传提供了技术支持。它通过在Header里两个参数实现的,客户端发请求时对应的是Range,服务器端响应时对应的是Content-Range;通过tomcat看一下这两个参数。

在应用的根目录下放了一张图片”chain.jpg”,图片的大小是4932字节,用curl模拟分段请求,请求时把respons的header给dump到一个文件里:

$ curl -D "resp-header1.txt" -H 'Range: bytes=0-2000' \
    http://localhost:8080/chain.jpg > /tmp/test.jpg 

$ cat resp-header1.txt

HTTP/1.1 206 Partial Content # 返回状态码是206
Server: Apache-Coyote/1.1
Accept-Ranges: bytes
ETag: W/"4932-1447753566000"
Last-Modified: Tue, 17 Nov 2015 09:46:06 GMT
Content-Range: bytes 0-2000/4932
Content-Type: image/jpeg
Content-Length: 2001
Date: Tue, 17 Nov 2015 17:27:45 GMT 

这时在mac下用preview程序打开图片看到是部分的,把剩余部分数据也下载下来才行:

$ curl -H 'Range: bytes=2001-4932' \
    http://localhost:8080/chain.jpg >> /tmp/test.jpg

Range参数还支持多个区间,用逗号分隔,下面对另一个内容为”hello world”的文件”a.html”多区间请求,这时response的Content-Type不再是原文件mime类型,而用一种multipart/byteranges类型表示:

$ curl -D 'resp-header' -H 'Range: bytes=0-5,6-10' http://localhost:8080/a.html 
--CATALINA_MIME_BOUNDARY
Content-Type: text/html
Content-Range: bytes 0-5/12

hello
--CATALINA_MIME_BOUNDARY
Content-Type: text/html
Content-Range: bytes 6-10/12

world
--CATALINA_MIME_BOUNDARY--

$ cat resp-header

HTTP/1.1 206 Partial Content
Server: Apache-Coyote/1.1
Accept-Ranges: bytes
ETag: W/"12-1447780011000"
Last-Modified: Tue, 17 Nov 2015 17:06:51 GMT
Content-Type: multipart/byteranges; boundary=CATALINA_MIME_BOUNDARY
Content-Length: 208
Date: Tue, 17 Nov 2015 17:39:30 GMT

nginx与tomcat之间的keep-alive配置

今天碰到的一个情况,tomcat与前端nginx之间的存在大量的TIME_WAIT状态的连接,第一反应是这里可能没有配置keep-alive。问ops,回复说启用了;要来nginx的配置看了一下,发现upstream里设置了keepalive参数:

upstream  tomcat {
    server x.x.x.x:8080;
    ...
    keepalive 16;
}

不确定这个参数是不是http的keep-alive,在nginx的网站上找了一下

Syntax: keepalive connections;
Default:    —
Context:    upstream

The connections parameter sets the maximum number of idle keepalive connections to upstream servers that are preserved in the cache of each worker process.

它并不是与后端节点开启http-alive方式的意思,nginx作为反向代理后端并不局限http协议,这里的keepalive设置相当于每个worker连接池的最大空闲keepalive连接数,跟http里的keep-alive不是一回事。

在官方文档里明确要对后端节点使用http keep-alive 可以指定http版本为1.1:

location /http/ {
    proxy_pass http://http_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    ...
}

或者仍使用http 1.0协议,但显式的设置http header里Connection参数为Keep-Alive,这是因为keep-alive是http1.1的默认特性,在1.0里最初并未实现是后来从1.1里backport到1.0的,需要显式设定这个参数才启用:

location /http/ {
    proxy_pass http://http_backend;
    proxy_http_version 1.0;
    proxy_set_header Connection "Keep-Alive";
    ...
}

为了确认有效性,可以对tomcat的logger增加一句

org.apache.coyote.http11.level = FINE

这样可以在tomcat日志里看到每个请求的http header信息:

FINE: Received [GET / HTTP/1.0
Connection: Keep-Alive
Host: localhost:8080
User-Agent: curl/7.37.1
Accept: */*

确实在header里增加了。建议还是配置为http 1.1协议,支持chunked等特性。

ps, keepalive 这个词可能是指连接池或者tcp层面的参数,要看上下文。在http里是keep-alive,注意中间有个连字符。

servlet模型(3.1之前)对InputStream的处理是阻塞模式

同事聊起这个话题,当请求建立时是否tomcat就为每个连接分配线程了? 是否只要发起足够多的连接不必发送任何数据就可以DDoS了?对于DDoS这个话题不展开,这里仅仅说一下连接过来时,服务器端是否就一定分配了线程。这取决于tomcat配置的connector模式,只讨论一下bio和nio的情况。

以bio模式启动tomcat,然后建立3个连接(不发送任何数据):

$ nc localhost 8080 &
$ nc localhost 8080 &
$ nc localhost 8080 &

这时我们来看tomcat的bio执行线程是与连接数一对一的:

$ ./appctrl.sh jstack | grep http-bio-8080-exec
"http-bio-8080-exec-3" #24 daemon prio=5 os_prio=31 tid=0x00007fdd7a840800 nid=0x6607 runnable [0x000000011d78b000]
"http-bio-8080-exec-2" #23 daemon prio=5 os_prio=31 tid=0x00007fdd7a83f800 nid=0x6507 runnable [0x000000011d618000]
"http-bio-8080-exec-1" #22 daemon prio=5 os_prio=31 tid=0x00007fdd7a800000 nid=0x4107 runnable [0x000000011addd000]

而在nio模式下,请求建立时,并不会有执行线程,只有接收到数据发时,才会有线程分配:

$ nc localhost 8080
POST /test/post?name=foo HTTP/1.1
Host: localhost:8080
Context-Length: 10000 

上面在nc建立连接后,发送了一段http header(还未发送body,请求保持中),这时才会对这些请求数据分配线程阻塞执行:

$ ./appctrl.sh jstack | grep http-nio-8080-exec
"http-nio-8080-exec-3" #26 daemon prio=5 os_prio=31 tid=0x00007ffefb001800 nid=0x6007 waiting on condition [0x0000000123af7000]
"http-nio-8080-exec-2" #25 daemon prio=5 os_prio=31 tid=0x00007ffefa029000 nid=0x5d07 waiting on condition [0x000000012351c000]
"http-nio-8080-exec-1" #24 daemon prio=5 os_prio=31 tid=0x00007ffef900a000 nid=0x680b waiting on condition [0x000000012328f000] 

上面的测试是在tomcat7下测试的,nio在处理http request的时候是非阻塞的,但读取数据的时候是模拟阻塞的,因为servlet3.1之前对input的处理就是一种阻塞模式,参考以前的一张截图:

jdk的HttpURLConnection提供了日志开关

下午运行一个junit程序,时不时启动时就“卡住”,没有输出任何日志,通过jstack发现是spring初始化时调用了sun.net.www.protocol.http.HttpURLConnection访问网络时阻塞住了。按说依赖的spring的jar里都应该包含了这些dtd/xsd之类的文件,怎么仍会访问远程网络呢?

jdk自带的HttpURLConnection提供了日志(JUL)开关,可以查看有哪些URL请求,在运行时指定

-Djava.util.logging.config.file=/tmp/logging.properties

不指定的话,默认从 jre/lib/logging.properties 加载。在配置文件里:

# 增加一条针对HttpURLConnection的配置
sun.net.www.protocol.http.HttpURLConnection.level = ALL

# 并修改终端的输出级别
java.util.logging.ConsoleHandler.level = ALL

启动后看到如下日志:

FINEST: ProxySelector Request for http://www.springframework.org/dtd/spring-beans.dtd
Jul 25, 2015 4:49:45 PM sun.net.www.protocol.http.HttpURLConnection plainConnect0

FINEST: Proxy used: DIRECT
Jul 25, 2015 4:49:45 PM sun.net.www.protocol.http.HttpURLConnection writeRequests

FINE: sun.net.www.MessageHeader@531be3c55 pairs: 
{GET /dtd/spring-beans.dtd HTTP/1.1: null}
{User-Agent: Java/1.8.0_20}
{Host: www.springframework.org}
{Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2}
{Connection: keep-alive}
Jul 25, 2015 4:49:45 PM sun.net.www.protocol.http.HttpURLConnection getInputStream0

FINE: sun.net.www.MessageHeader@52af6cff16 pairs: {null: HTTP/1.1 200 OK}...

日志里看到确实是去访问了spring网站,根据url信息找到包含spring-beans.dtd内容的xml配置;这个工程是一个很老的工程,里的写法是:

<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">

要防止springframework.org或者GFW引发的不稳定,采用新的写法来避免:

<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:p="http://www.springframework.org/schema/p" 
    xmlns:c="http://www.springframework.org/schema/c"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans-4.1.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context-4.1.xsd ">

模拟tomcat bio模式下线程池利用率超过75%关闭keep-alive的情况

模拟一下在BIO模式下,当线程利用率超过75%时,将自动关闭keep-alive的场景。

通过curl命令来观察,默认情况下curl会开启keep-alive选项,不过注意curl复用socket的话是在同一进程内多次访问目标同一地址时才会复用,两次执行curl的话并不会复用,比如:

$ curl http://localhost:7001/main
$ curl http://localhost:7001/main

上面连续执行curl命令并不会复用socket,socket会随着进程的消失而关闭,下次新的进程会重新创建连接。可以通过tcpdump观察,上面两次连接是不同的socket:

$ sudo tcpdump -l -i lo0 port 7001

23:43:19.236948 IP6 localhost.62625 > localhost.afs3-callback
......
23:43:26.071504 IP6 localhost.62626 > localhost.afs3-callback
......

在同一个curl进程里多次访问同一地址的话,会复用socket,通过-v参数就可以观察到:

$ curl -v  http://localhost:7001/main  http://localhost:7001/main
* Hostname was NOT found in DNS cache
*   Trying ::1...
* Connected to localhost (::1) port 7001 (#0)
> GET /main HTTP/1.1
> User-Agent: curl/7.37.1
> Host: localhost:7001
> Accept: */*
>
< HTTP/1.1 200 OK
* Server Apache-Coyote/1.1 is not blacklisted
< Server: Apache-Coyote/1.1
< Transfer-Encoding: chunked
< Date: Mon, 18 Aug 2014 15:49:28 GMT
<
* Connection #0 to host localhost left intact
ok
* Found bundle for host localhost: 0x7fa7d8c08c50
* Re-using existing connection! (#0) with host localhost
* Connected to localhost (::1) port 7001 (#0)
> GET /main HTTP/1.1
> User-Agent: curl/7.37.1
> Host: localhost:7001
> Accept: */*
>
< HTTP/1.1 200 OK
* Server Apache-Coyote/1.1 is not blacklisted
< Server: Apache-Coyote/1.1
< Transfer-Encoding: chunked
< Date: Mon, 18 Aug 2014 15:49:28 GMT
<
* Connection #0 to host localhost left intact
ok

注意,在第二次请求开头有一句:Re-using existing connection! 表明复用了上次的socket,使用tcpdump也会看到确实是同一个socket端口连的tomcat。

默认情况下,线程池的最大线程数是200个,BIO模式下当线程利用率超过75%的时候,server会对新来的连接不再使用keep-alive。我们先模拟建立151个连接(默认开启keep-alive的):

for i in {1..151}; do 
    ( 
        {echo -ne "GET /main HTTP/1.1\nhost: localhost:7001\n\n"; sleep 20} 
        | telnet localhost 7001
    )&;  
done

上面的zsh脚本模拟了151个连接(脚本里for循环里使用后台子进程方式启动模拟任务,通过jobs命令也可查看到),每次新建立socket并在服务器端响应后保持连接20秒(这也是服务器端默认keep-alive的超时时间)。tomcat对这151个连接保持keep-alive,BIO模式下会有151个线程耗在上面,即使socket上请求已处理完,后续没有新的请求也不会让出线程,而一直阻塞在上面。这时刚好达到了 151/200 ≈ 0.75 的临界值,那么后续建立的socket将不能再享用keep-alive

现在在这个临界值上,再执行curl命令模拟新的连接:

$  curl -v  http://localhost:7001/main http://localhost:7001/main

* Hostname was NOT found in DNS cache
*   Trying ::1...
* Connected to localhost (::1) port 7001 (#0)
> GET /main HTTP/1.1
> User-Agent: curl/7.37.1
> Host: localhost:7001
> Accept: */*
>
< HTTP/1.1 200 OK
* Server Apache-Coyote/1.1 is not blacklisted
< Server: Apache-Coyote/1.1
< Transfer-Encoding: chunked
< Date: Mon, 18 Aug 2014 15:22:50 GMT
< Connection: close
<
* Closing connection 0
ok
* Hostname was found in DNS cache
*   Trying ::1...
* Connected to localhost (::1) port 7001 (#1)
> GET /main HTTP/1.1
> User-Agent: curl/7.37.1
> Host: localhost:7001
> Accept: */*
>
< HTTP/1.1 200 OK
* Server Apache-Coyote/1.1 is not blacklisted
< Server: Apache-Coyote/1.1
< Transfer-Encoding: chunked
< Date: Mon, 18 Aug 2014 15:22:50 GMT
< Connection: close
<
* Closing connection 1
ok

注意,这次连接有2次请求,但看不到Re-using existing connection! 关键字,每次请求结束,服务器都显式的关闭了连接,即在header里看到的:Connection: close字段。表明超过75%之后,新建立的连接都不会再使用keep-alive。

关于application/x-www-form-urlencoded编码

同事遇到在servlet端通过request对象getInputStream读取POST过来的数据,却读不到的问题,怀疑是tomcat的问题。查了一下Content-typeapplication/x-www-form-urlencoded,估计是被解析成了parameters,果然在他获取流之前,有过request.getParameter的操作。

熟悉servlet的话,这个问题应该算常识了。它其实跟容器无关,所有的servlet容器都是这样的行为。几年前在实现一个网关代理的时候就遇到过这个问题,当时使用的是jetty,发现POST过来的数据读不到,也是application/x-www-form-urlencoded编码,断点跟踪发现是在获取流之前有过request.getParameter,数据会被解析,并且后续数据流不可再被读取。

在servlet规范3.1.1节里,对POST数据何时会被当做parameters有描述:

1. The request is an HTTP or HTTPS request.
2. The HTTP method is POST.
3. The content type is application/x-www-form-urlencoded.
4. The servlet has made an initial call of any of the getParameter family of methods on the request object.

If the conditions are met, post form data will no longer be available for reading directly from the request object’s input stream.

规范里已经明确的声明当请求满足: 1) http/https, 2) POST, 3) Content-type 是application/x-www-form-urlencoded, 4) 调用过getParameter方法;则数据会被当做请求的paramaters,而不能再通过 request 的 inputstream 直接读取。

所以不论tomcat、jetty还是其他servlet容器都遵循这个方式。不过话说回来,为什么application/x-www-form-urlencoded编码的数据会被当做parameter来解析呢?

使用http上传数据可以用GET或POST,使用GET的话,只能通过uri的queryString形式,这会遇到长度的问题,各个浏览器或server可能对长度支持的不同,所以到要提交的数据如果太长并不适合使用GET提交。

采用POST的话,既可以在uri中带有queryString也可以将数据放在body中。body内容可以有多种编码形式,其中application/x-www-form-urlencoded编码其实是基于uri的percent-encoding编码的,所以采用application/x-www-form-urlencoded的POST数据和queryString只是形式不同,本质都是传递参数。

在tomcat的Request.parseParameters方法里,对于application/x-www-form-urlencoded是有做判断的,对这种编码会去解析body里的数据,填充到parameters里,所以后续想再通过流的方式读取body是读不到的(除非你没有触发过getParameter相关的方法)。

在HTML4之前,表单数据的编码方式只有application/x-www-form-urlencoded这一种(现在默认也是这种方式),因为早期的时候,web上提交过来的数据也是非常简单的,基本上以key-value形式为主,所以表单采用application/x-www-form-urlencoded这种编码形式也没什么问题。

在HTML4里又引入了multipart/form-data编码,对于这两种编码如何选择,请参考这里