TCP 的连接与释放,及 HTTP 报文格式
2023-07-05, by alamide
Http 的连接与释放,报文格式,及一些常用的请求头。本次学习目标:
-
为什么 TCP 连接需要三次握手
-
为什么 TCP 释放连接需要四次挥手
-
HTTP 报文协议的格式
-
HTTP 一些常用的请求头
-
使用 Socket 请求来完成一次网络请求
-
复用 Socket,完成对同一个 HOST 多次请求
1.为什么 TCP 连接需要三次握手
看了很多资料都是在画图、画箭头、发响应码,真的是难懂,最终还是找到了比较认可且易懂的回答,也有了自己的理解。一直将三次握手带入到 TCP、具体的细节上是错误的,应该脱离 TCP 来思考这个问题。
思考这样一个问题:确定一个信道可通信最少需要几次通信(A <—-> B)?
第一次信息发送:A 端发送一条信息到 B 端,A 等待收到 B 的回复, A 不知道 B 是否能收到信息,所以需要等待回信确认
第二次信息发送:B 端收到信息,发送已收到给 A 端,等待 A 的回复,B 不知道 A 是否能收到信息,所以需要等待回信确认
第三次信息发送:A 端收到 B 的信息,确认可以通信,A 准备通信,并给 B 端发送确认信息;B 端收到,确认可以信道可用,B 准备通信
三次握手是确认信道是否双向可用最少通信次数,注意这里是可用而不是可靠,因为不论多少次握手都不能保证信道可靠。
所以为什么 TCP 连接需要三次握手?因为这不是 TCP 本身的需求,而是确定一条信道双向可用的要求。
2.为什么 TCP 释放连接需要四次挥手
释放连接需要双方确认,具体流程如下:
第一次:客户端数据发送完毕,给服务器发信息,表明客户端数据已发送完毕,需要等待服务端收到信息的确认信息
第二次:服务端收到客户端发送的数据已发送完毕的消息,回复客户端,已确认收到信息
第三次:服务端数据发送完毕,给客户端发送信息,表明服务端数据已发送完毕,需要等待客户端确认收到消息的回复
第四次:客户端收到服务端数据发送完毕的信息,向服务端发信息表明已收到信息,客户端释放连接;服务端收到信息,也释放连接。至此,两端连接释放完毕
其中第二和第三次信息不能合并,因为信息的传输是双向的,服务端收到客户端数据发送完毕的时候,服务端向客户端发送的数据,可能还没有传输完毕
3.HTTP 报文协议的格式
HTTP 是一种传输协议,既然是协议,那么肯定需要遵循一定的规则。HTTP 报文为请求报文和响应报文,都遵循一定的规则。
3.1 请求报文
一个简单请求报文如下
GET /cs/http-con-discon.html HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: keep-alive
Host: 127.0.0.1:4000
Pragma: no-cache
Referer: http://127.0.0.1:4000/
可以将请求报文分为三个部分:请求行、请求头、请求体。下面就逐步拆解出这三个部分:
3.1.1 请求行
请求行包含请求的方法、请求的 URI 和 HTTP 版本
GET /cs/http-con-discon.html HTTP/1.1
请求方法:GET 称为请求方法,除了 GET, 还有 POST、PUT、DELETE 等
请求 URI: /cs/http-con-discon.html 为请求 URI 表示请求资源的定位符
HTTP 版本:具体的 HTTP 协议版本
3.1.2 请求头
请求条件和属性的各类首部,常用的请求头有:
-
HOST 表明请求的目标主机地址
-
Accept 表明希望服务器返回的数据类型,如
application/xml
表明希望服务端返回 xml 格式的数据 -
Content-Type 表明请求体的数据类型,比如
application/json
表明请求体中携带的数据类型为 json -
Connection 表明希望维持长连接,即双方通信完毕后不立即释放连接
-
User-Agent 简称 UA,它是一个特殊字符串头,使得服务器能够识别客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等。在 UI 适配时很有用
-
Referer 表明访问来源信息
3.1.3 请求体
用来存放请求时携带的额外数据,下面写 Socket 请求时,再具体的解析
3.2 响应报文
一个简单的响应报文如下
HTTP/1.1 200 OK
Etag: 3043b4e81-24cb-64a4d58d
Content-Type: text/html; charset=utf-8
Content-Length: 9419
Last-Modified: Wed, 05 Jul 2023 02:29:33 GMT
Cache-Control: private, max-age=0, proxy-revalidate, no-store, no-cache, must-revalidate
Server: WEBrick/1.8.1 (Ruby/3.2.2/2023-03-30)
Date: Wed, 05 Jul 2023 02:29:51 GMT
Connection: Keep-Alive
与请求报文类似,可以将请求报文分为,状态行、响应头、响应体
3.2.1 状态行
包含表明响应结果的状态码,原因短语和HTTP版本
HTTP/1.1 200 OK
3.2.1 响应头
包含响应的各类信息,
Content-Type 表明响应体的数据类型,即表明客户端应该如何解析返回的数据
Content-Length 响应体的字节数
Connection 是否支持长连接
Transfer-Encoding 传输编码,如值为 chunked,表示报文的响应体分块编码,此时 Content-Length 的值不准确
3.2.3 响应体
具体响应的数据
4.Socket 完成网络通信
代码用到的库为 Okio、Benchmark
4.1 简单的 GET 请求
上文已经知晓 HTTP 请求体和响应体的格式了,首先来请求服务器,发送请求体
public class NetworkTest {
public static void main(String[] args) throws IOException {
urlConnection();
}
private static void urlConnection() throws IOException {
try (Socket socket = new Socket("localhost", 8080)) {
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
//写入请求行
bw.write("GET /user HTTP/1.1\r\n");
//写请求头
bw.write("Host: localhost:8080\r\n");
//表明报文结束
bw.write("\r\n");
bw.flush();
bw.close();
}
}
}
上面就可以像服务器发送一条 HTTP 请求了,没什么特别之处,就是按照约定的格式写数据就可以了
再来读取服务器返回的数据,读取服务器返回的数据,会有一点点需要注意的地方。因为读完服务器返回的信息后,服务器不一定会立即释放连接,所以 read()
会发生阻塞现象,我们要自己判断服务器是否数据传输完毕,有两种情况处理。
-
响应体的编码方式不为 chunked 时,可以直接依据响应头 Content-Length 来判断
-
为 chunked 时,要依次读取块内容,直到读到块长度为 0
这里为简单,只处理非 chunked,这里使用 Okio 来读取服务器返回的数据
public class NetworkTest {
public static void main(String[] args) throws IOException {
urlConnection();
}
private static void urlConnection() throws IOException {
try (Socket socket = new Socket("localhost", 8080)) {
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bw.write("GET /user HTTP/1.1\r\n");// \r\n 表示 CR + LF
bw.write("Host: localhost:8080\r\n");
bw.write("\r\n");
bw.flush();
final BufferedSource buffer = Okio.buffer(Okio.source(socket.getInputStream()));
//读取状态行
String statusLine = buffer.readUtf8Line();
System.out.println(statusLine);
Map<String, String> headers = new HashMap<>();
//读取请求头
String header;
while ((header = buffer.readUtf8Line()).length() != 0) {
System.out.println(header);
final String[] strings = header.split(": ");
headers.put(strings[0], strings[1]);
}
//读取请求体,需要指定读取长度,否则会一直阻塞
int contentLength = Integer.parseInt(headers.get("Content-Length"));
final String responseBody = buffer.readUtf8(contentLength);
System.out.println(responseBody);
buffer.close();
bw.close();
}
}
}
output:
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 8
Date: Wed, 05 Jul 2023 05:44:02 GMT
Get User
4.2 简单的 POST 请求
POST 请求携带简单参数,传递简单参数时,请求体的 Content-Type 要设置为 application/x-www-form-urlencoded,这样服务器就可以解析我们的参数了,
public class NetworkTest {
public static void main(String[] args) throws IOException {
postSimpleData();
}
private static void postSimpleData() throws IOException {
try (Socket socket = new Socket("localhost", 8080)) {
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bw.write("POST /user HTTP/1.1\r\n");// \r\n 表示 CR + LF
bw.write("Host: localhost:8080\r\n");
//写入请求体长度
final long size = Utf8.size("username=alamide");
bw.write("Content-Length: " + size + "\r\n");
//form 表单提交
bw.write("Content-Type: application/x-www-form-urlencoded\r\n");
bw.write("\r\n");
//写入参数
bw.write("username=alamide");
bw.flush();
//去读响应体,封装上面的方法
readResponseBody(socket.getInputStream());
bw.close();
}
}
}
4.3 POST MultiPart
向服务端发送用户昵称和头像信息,Content-Type 为 multipart/form-data。当类型为 MultiPart 时需要给每一个 Part 设置 Boundary,方便服务器拆分
public class NetworkTest {
public static void main(String[] args) throws IOException {
postMultiPartData();
}
private static void postMultiPartData() throws IOException {
final String boundary = ByteString.encodeUtf8(UUID.randomUUID().toString()).utf8();
try (Socket socket = new Socket("localhost", 8080)) {
//先将请求体中内容写入缓冲池,主要是为了计算请求体内容的大小
final Buffer sink = new Buffer();
//写参数
writeParam("username", "alamide", boundary, sink);
writeParam("age", "18", boundary, sink);
//写图片
writeImage("avatar", "1.jpeg", new File(file), boundary, sink);
//写结束标志
sink.writeUtf8("--" + boundary + "--\r\n");
//开始向服务端写请求报文
final BufferedSink buffer = Okio.buffer(Okio.sink(socket.getOutputStream()));
buffer.writeUtf8("POST /user HTTP/1.1\r\n");// \r\n 表示 CR + LF
buffer.writeUtf8("Host: localhost:8080\r\n");
buffer.writeUtf8("Content-Type: multipart/form-data; boundary=" + boundary + "\r\n");
//写请求体长度
buffer.writeUtf8("Content-Length: " + sink.size()+"\r\n");
//表示请求头数据已写完
buffer.writeUtf8("\r\n");
//写之前已经处理的请求体
buffer.write(sink.readByteArray());
buffer.flush();
//读响应体
readResponseBody(socket.getInputStream());
buffer.close();
}
}
private static void writeImage(String name, String filename, File file, String boundary, BufferedSink buffer) throws IOException {
buffer.writeUtf8("--" + boundary + "\r\n");
buffer.writeUtf8("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n");
buffer.writeUtf8("Content-Type: image/jpeg\r\n");
buffer.writeUtf8("\r\n");
buffer.writeAll(Okio.source(file));
buffer.writeUtf8("\r\n");
}
private static void writeParam(String name, String value, String boundary, BufferedSink buffer) throws IOException {
buffer.writeUtf8("--" + boundary + "\r\n");
buffer.writeUtf8("Content-Disposition: form-data; name=\"" + name + "\"\r\n");
buffer.writeUtf8("\r\n");
buffer.writeUtf8(value + "\r\n");
}
private static void readResponseBody(InputStream is) throws IOException {
System.out.println("read response");
final BufferedSource buffer = Okio.buffer(Okio.source(is));
String statusLine = buffer.readUtf8Line();
System.out.println(statusLine);
Map<String, String> headers = new HashMap<>();
String header;
while ((header = buffer.readUtf8Line()).length() != 0) {
System.out.println(header);
final String[] strings = header.split(": ");
headers.put(strings[0], strings[1]);
}
int contentLength = Integer.parseInt(headers.get("Content-Length"));
final String responseBody = buffer.readUtf8(contentLength);
System.out.println(responseBody);
buffer.close();
}
}
5.复用 Socket
前面有分析过 TCP 连接的三次握手和断开连接的四次挥手,可以看出 TCP 通信还是挺耗费资源的,如果处理的数据一次通信就发送完毕,那耗损比是非常高的,八次通信,只有一次用来传递数据,太浪费了。
可以考虑复用已建立的连接,在断开连接之前,两个主机之间的信道是否可用,只需要一次验证就可以了。下面来测试一下,复用 Socket 和未复用 Socket 的时间,
当前复用的前提是服务器支持长连接,即 Connection: keep-alive
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(value = 2, jvmArgs = {"-Xms4G", "-Xmx4G"})
public class NetworkTest {
private static final Integer REQUEST_TIMES = 50;
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(NetworkTest.class.getSimpleName())
.warmupIterations(5)
.measurementIterations(5)
.build();
new Runner(opt).run();
}
@Benchmark
public static void testDisposableSocket() {
for (int i = 0; i < REQUEST_TIMES; i++) {
try (Socket socket = new Socket("www.zhaosiyuan.com", 80)) {
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bw.write("GET / HTTP/1.1\r\n");// \r\n 表示 CR + LF
bw.write("Host: www.zhaosiyuan.com\r\n");
bw.write("Connection: keep-alive\r\n");
bw.write("\r\n");
bw.flush();
readResponseBody(socket.getInputStream());
bw.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@Benchmark
public static void testReuseSocket() {
try (Socket socket = new Socket("www.zhaosiyuan.com", 80)) {
for (int i = 0; i < REQUEST_TIMES; i++) {
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bw.write("GET / HTTP/1.1\r\n");// \r\n 表示 CR + LF
bw.write("Host: www.zhaosiyuan.com\r\n");
bw.write("Connection: keep-alive\r\n");
bw.write("\r\n");
bw.flush();
readResponseBody(socket.getInputStream());
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
请求资源 50 次,平均耗时为:
Benchmark Mode Cnt Score Error Units
NetworkTest.testDisposableSocket avgt 10 14367.734 ± 1343.908 ms/op
NetworkTest.testReuseSocket avgt 10 6382.347 ± 1967.409 ms/op
可以看到复用 Socket ,耗时还是比较少的,请求短时间访问同一资源越频繁,复用 Socket 越高效。
注意复用 Socket 时,不能主动关闭输入输出流,否则会导致 Socket 关闭
6.小总结
TCP 的三次握手与四次挥手,脱离 TCP 来理解会比较好理解,就是确认信道双向可用的最少通信次数;通信双方完全接收对方信息后关闭。
HTTP 协议就是一种协议,不要想的太复杂,就是一种规则、一种操作手册、一种说明书。按照指定的规则来通信就可以了。请求报文和响应报文格式都类似,请求行/状态行,头信息,请求体/响应体。其中请求体复杂一点,对与多类型数据需要用 boundary 来分隔各项。以及读数据时需要先读取 Content-Length,再读取指定长度数据,写数据时写 Content-Length。
由于 TCP 连接和断开连接需要多次确认,所以可以复用已建立的连接。节省资源。
Tags: http