用 OIO 来进行 Socket 编程

警告
本文最后更新于 2023-12-20,文中内容可能已过时。

用 OIO 来进行 Socket 编程

我们回顾一下传统的 HTTP 服务器的原理:

  1. 创建一个ServerSocket,监听并绑定一个端口

  2. 一系列客户端来请求这个端口

  3. 服务器使用ServerSocket#accept方法,获得一个来自客户端的Socket连接对象

  4. 启动一个新线程处理连接

    1. Socket,得到字节流

    2. 解码协议,得到Http请求对象

    3. 处理Http请求,得到一个结果,封装成一个HttpResponse对象

    4. 编码协议,将结果序列化字节流

    5. 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 进行网络开发的主要问题是阻塞的地方太多了。使用ServerSocketSocket对象的每一个方法,基本上都是阻塞的

比如

  • 接收客户端的请求的ServerSocket#accept方法,是阻塞的,如果没有客户端与服务端建立连接,ServerSocket#accept方法就会卡在这里,无法返回

  • ServerSocketInputStreamSocketInputStream的阻塞,即接收缓冲区的阻塞,socket 的另一头没有给这一头推送消息,这一头的接收缓冲区里没数据,那么从这一头的接收缓冲区里读取数据的 API 就会阻塞

  • ServerSocketOutputStreamSocketOutputStream的阻塞,即发送缓冲区的阻塞,如果 socket 的另一头的接收缓冲区满了,没法收数据了,那么这一头的发送缓冲区的数据就不能发送过去,那么往这一头的发送缓冲区里推数据的操作也会阻塞,需要等到 socket 另一头的接收缓冲区被消费了,有空间接收新的数据了,这一头才能继续往那一头推数据

还有代码结构上的延迟

  • ServerSocket#accept方法返回 Socket 之后,如果在当前线程直接开始处理 Socket 的输入输出,则会延迟ServerSocket和其他的客户端建立连接,

    上面的代码的例子中通过新建线程处理Socket的 IO 操作,算是简单规避了这个问题

  • 在上面的例子中,不管是客户端还是服务端,对 socket 的接收数据和发送数据的处理都是在同一个线程里的,效果就是如果接收操作阻塞了,那么就会延迟发送操作,实际上应该分开,读和写都用一个单独的线程来处理。

解决方案

可以看到,要解决 OIO 网络编程过程中遇到的问题,需要创建大量的线程,因此,系统的资源利用率极低,后面我们可以通过 NIO 来解决这个问题,不过随着 Netty 框架的流行,以上问题均不在是问题。

关于 Netty 的设计,请看《Netty 的设计》

0%