警告
本文最后更新于 2023-12-20,文中内容可能已过时。
用 OIO 来进行 Socket 编程
我们回顾一下传统的 HTTP 服务器的原理:
-
创建一个ServerSocket
,监听并绑定一个端口
-
一系列客户端来请求这个端口
-
服务器使用ServerSocket#accept
方法,获得一个来自客户端的Socket
连接对象
-
启动一个新线程处理连接
-
读Socket
,得到字节流
-
解码协议,得到Http
请求对象
-
处理Http
请求,得到一个结果,封装成一个HttpResponse
对象
-
编码协议,将结果序列化字节流
-
写Socket
,将字节流发给客户端继续循环步骤 3
我们写个代码简单实践一下。
简单实践
编写服务端的代码。
首先编写处理每一个建立了连接会话的处理线程,这个会话处理程序的逻辑很简单,在初始化线程的时候拿到Socket
对象,然后在线程执行的过程中,首先读取socket
接收缓冲区中的消息(此步骤是阻塞的),然后从控制台输入的消息,发送给 socket 的另一头(此步骤也可能会阻塞),然后关闭 socket,连接结束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
public class SocketThread extends Thread {
private Socket socket;
private CountDownLatch countDownLatch;
public SocketThread(Socket socket,CountDownLatch countDownLatch) {
this.socket = socket;
this.countDownLatch = countDownLatch;
}
/**
* 先读取客户端中的信息,再将自身的信息传输出去
*/
@Override
public void run() {
InputStreamReader isr = null;
BufferedReader bf = null;
PrintWriter pw = null;
InputStreamReader isrOfServer = null;
BufferedReader bfOfServer = null;
try {
// 1. 读取 socket 中的信息,也就是客户端发过来的信息
isr = new InputStreamReader(socket.getInputStream());
bf = new BufferedReader(isr);
String str = null;
// readLine() 方法实际上是从 socket 的接收缓冲区中读取信息,当客户端没有给服务端发消息的时候,socket 的接收缓冲区实际上是没有数据的,这个时候就会阻塞,只有当客户端端给服务端发了消息,readLine() 才会返回,否则就一直阻塞
if ((str = bf.readLine()) != null) {
System.out.println("客户端说:" + str);
}
// 2. 通过控制台输入信息,然后将信息传输给客户端
pw = new PrintWriter(socket.getOutputStream());
isrOfServer = new InputStreamReader(System.in);
bfOfServer = new BufferedReader(isrOfServer);
// readLine() 方法实际上是从 System.in 这个输入流中读取信息,system.in 是阻塞的,程序会等待用户在控制台输入字符,用户输入之后输入回车,readLine() 才会返回,否则就一直阻塞
String strOfServer = bfOfServer.readLine();
System.out.println("服务器说:" + strOfServer);
// write() 也是阻塞的,如果客户端的接收缓冲区满了也会阻塞,需要等到客户端的接收缓冲区有空间才能继续传输
pw.println(strOfServer);
pw.flush();
// 3. 关闭连接
// 你也可以持续地跟客户端发送消息,但是这里我们只是简单地发送一次消息,然后就关闭连接
socket.close();
countDownLatch.countDown();
} catch (IOException e) {
e.printStackTrace();
}
}
}
|
然后是服务端程序,服务端程序的逻辑也很简单,每建立与一个客户端的连接,就开启一个新的线程来处理,到达指定连接数量之后,关闭服务端。
注意等待客户端连接的时候,服务端的main
线程是阻塞的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
public class SimpleSever {
public static void main(String[] args) throws Exception {
// 指定一个端口号作为服务器通信端口号
// 创建 ServerSocket
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器开启!");
Socket socket = null;
int num = 1;
int maxConnections = 3;
CountDownLatch countDownLatch = new CountDownLatch(maxConnections);
while (num <= maxConnections) {
// ServerSocket.accept() 方法调用时会产生阻塞,线程在此停住,只有当客户端有 Socket 请求过来的时候才会往下走
// ServerSocket.accept() 返回的 Socket 中包含了请求源也就是客户端的地址和端口,这个 Socket 可以与 客户端通信,而 ServerSocket 是做不到的
// ServerSocket 所做的是创建服务器,等待客户端连接(serverSocket.accept())
socket = serverSocket.accept();
System.out.println("第" + (num++) + "次连接");
// 开启一个子线程去处理建立的连接,这样就可以实现多个客户端同时访问服务器
// 当然你也可以在当前线程处理 socket 接收地数据,但是这样就会延迟 serverSocket 与其他客户端建立连接,
new SocketThread(socket, countDownLatch).start();
}
countDownLatch.await();
// 关闭资源
serverSocket.close();
System.out.println("服务器关闭");
}
}
|
直接运行main
方法即可开启服务端
现在,我们来编写客户端,客户端的逻辑也很简单,因为每次通信完服务端都会关闭 socket,因此每次循环都是创建一个新的 socket,直到服务端关闭,跟服务端建立连接之后,首先从控制台输入的消息,发送给 socket 的另一头(此步骤也可能会阻塞),也就是服务端,然后读取socket
接收缓冲区中的消息(此步骤是阻塞的),然后,最后关闭 socket,连接结束。
直接运行main
方法即可开启客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
public class SimpleClient {
/**
* main 方法中直接使用 while true 循环然后在循环体内创建 Socket 连接,保证时刻和服务器保持连接,建立 Socket 之后先传输客户端的输入再读取服务器的输入
*
* @param args
*/
public static void main(String[] args) {
Socket socket = null;
PrintWriter pw = null;
InputStreamReader isr = null;
BufferedReader bf = null;
InputStreamReader isrOfClient = null;
BufferedReader bfOfClient = null;
while (true) {
try {
// 连接服务端,因为每次通信完服务端都会关闭 socket,因此每次循环都是创建一个新的 socket,直到服务端关闭
// "localhost", 8080 为要访问的服务器的域名和端口号,而客户端的端口号由操作系统随机分配
try {
socket = new Socket("localhost", 8080);
} catch (ConnectException e) {
System.out.println("服务端已关闭");
break;
}
// 1. 获取控制台输入,然后将信息传输给服务器
pw = new PrintWriter(socket.getOutputStream());
isr = new InputStreamReader(System.in);
bf = new BufferedReader(isr);
String str = null;
// readLine() 方法实际上是从 System.in 这个输入流中读取信息,system.in 是阻塞的,程序会等待用户在控制台输入字符,用户输入之后输入回车,readLine() 才会返回,否则就一直阻塞
str = bf.readLine();
if (str != null) {
// write() 也是阻塞的,如果服务端接收缓冲区满了也会阻塞,需要等到服务端接收缓冲区有空间才能继续传输
// 在当前测试代码中,服务端一次只消费一行数据,如果我们往服务端消费了 100000 行,那么推送到第 八九万行的时候,pw.println 就会卡住,因为服务端的接收缓冲区满了
// 这里为了正常演示,就不这么做了,设置 count 为 1
// int count = 100000;
int count = 1;
for (int i = 0; i < count; i++) {
pw.println(str + "---" + i);
// System.out.println(i);
}
System.out.println("客户端说:" + str);
}
pw.flush();
// 2. 获取服务端的传输过来地信息,然后打印出来
isrOfClient = new InputStreamReader(socket.getInputStream());
bfOfClient = new BufferedReader(isrOfClient);
String strOfClient = null;
// readLine() 方法实际上是从 socket 的接收缓冲区中读取信息,当服务端没有给客户端发消息的时候,socket 的接收缓冲区实际上是没有数据的,这个时候就会阻塞,只有当服务端给客户端发了消息,readLine() 才会返回,否则就一直阻塞
strOfClient = bfOfClient.readLine();
if (strOfClient != null) {
System.out.println("服务器:" + strOfClient);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
// 关闭资源
if (isrOfClient != null) {
isrOfClient.close();
}
if (pw != null) {
pw.close();
}
// 关闭 socket 意味着关闭相关资源。
if (socket != null) {
// socket.close();只是关闭当前 socket 然后再关闭 TCP 连接
// socket.shutdownInput(); socket.shutdownOutput(); 直接关闭 TCP 连接
socket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
|
这个实例代码的使用方式是,先开启服务端,然后再开启客户端,然后客户端在控制台输入,服务端会受到客户端的消息并打印在控制台,然后服务端再在控制台输入消息,客户端会受到,然后客户端再在控制台输入消息,如此循环 3 此之后,服务端关闭,客户端也跟着关闭。
服务端的控制台输出如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
服务器开启!
第1次连接
客户端说:111---0
222
服务器说:222
第2次连接
客户端说:333---0
444
服务器说:444
第3次连接
客户端说:555---0
666
服务器说:666
服务器关闭
|
客户端的控制台输出如下:
1
2
3
4
5
6
7
8
9
10
|
111
客户端说:111
服务器:222
333
客户端说:333
服务器:444
555
客户端说:555
服务器:666
服务端已关闭
|
使用 OIO 进行网络开发的问题
使用 IOI 进行网络开发的主要问题是阻塞的地方太多了。使用ServerSocket
和Socket
对象的每一个方法,基本上都是阻塞的
比如
-
接收客户端的请求的ServerSocket#accept
方法,是阻塞的,如果没有客户端与服务端建立连接,ServerSocket#accept
方法就会卡在这里,无法返回
-
ServerSocket
的InputStream
和Socket
的InputStream
的阻塞,即接收缓冲区的阻塞,socket 的另一头没有给这一头推送消息,这一头的接收缓冲区里没数据,那么从这一头的接收缓冲区里读取数据的 API 就会阻塞
-
ServerSocket
的OutputStream
和Socket
的OutputStream
的阻塞,即发送缓冲区的阻塞,如果 socket 的另一头的接收缓冲区满了,没法收数据了,那么这一头的发送缓冲区的数据就不能发送过去,那么往这一头的发送缓冲区里推数据的操作也会阻塞,需要等到 socket 另一头的接收缓冲区被消费了,有空间接收新的数据了,这一头才能继续往那一头推数据
还有代码结构上的延迟
-
ServerSocket#accept
方法返回 Socket
之后,如果在当前线程直接开始处理 Socket 的输入输出,则会延迟ServerSocket
和其他的客户端建立连接,
上面的代码的例子中通过新建线程处理Socket
的 IO 操作,算是简单规避了这个问题
-
在上面的例子中,不管是客户端还是服务端,对 socket 的接收数据和发送数据的处理都是在同一个线程里的,效果就是如果接收操作阻塞了,那么就会延迟发送操作,实际上应该分开,读和写都用一个单独的线程来处理。
解决方案
可以看到,要解决 OIO 网络编程过程中遇到的问题,需要创建大量的线程,因此,系统的资源利用率极低,后面我们可以通过 NIO 来解决这个问题,不过随着 Netty 框架的流行,以上问题均不在是问题。
关于 Netty 的设计,请看《Netty 的设计》