通信网络编程2.0——JAVA

发布于:2025-06-13 ⋅ 阅读:(19) ⋅ 点赞:(0)

一、传统阻塞式 I/O 模型

实现简易多人聊天系统:服务端与客户端

服务端

public class ChatServer {
    int port = 6666;// 定义服务器端口号为 6666
    ServerSocket ss;// 定义一个 ServerSocket 对象用于监听客户端连接
    //List<Socket> clientSockets = new ArrayList<>();// 定义一个列表用于存储已连接的客户端 Socket 对象
    List<Socket> clientSockets = new CopyOnWriteArrayList<>();
    //迭代时会复制整个底层数组,因此在遍历过程中其他线程对集合的修改不会影响当前遍历,
    // 有效避免了 ConcurrentModificationException 异常。
}

定义了 ChatServer 类,指定服务器端口为 6666 ,创建 ServerSocket 对象用于监听客户端连接,采用 CopyOnWriteArrayList 存储已连接的客户端 Socket 对象。相较于普通列表,CopyOnWriteArrayList 在迭代时会复制整个底层数组,确保在遍历过程中其他线程对集合的修改不会影响当前遍历,有效避免并发修改异常。

public void initServer() {// 初始化服务器的方法
        try {

            ss = new ServerSocket(port);// 创建 ServerSocket 对象并绑定到指定端口
            System.out.println("服务器启动,等待客户端连接...");

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

 initServer 方法用于创建 ServerSocket 对象并绑定到指定端口,启动服务器端,等待客户端连接。

public void listenerConnection() {// 监听客户端连接的方法,返回连接的 Socket 对象
        new Thread(()->{
            while(true){

                try {

                    Socket socket = ss.accept();// 调用 accept() 方法等待客户端连接
                    //clientSockets.add(socket);

                    synchronized (clientSockets) {// 同步操作确保线程安全
                        clientSockets.add(socket);// 将连接的客户端 Socket 对象添加到列表中
                    }

                    System.out.println("客户端已连接:" + socket.getInetAddress().getHostAddress());// 输出客户端连接成功提示信息及客户端 IP 地址

                } catch (IOException e) {
                    throw new RuntimeException(e);
                }

            }
        }).start();

    }

listenerConnection 方法启动一个线程,通过 ServerSocketaccept() 方法等待客户端连接。当有客户端连接时,将其 Socket 对象添加到客户端列表中,并输出客户端 IP 地址。

public void readMsg(List<Socket> clientSockets, JTextArea msgShow) {// 读取客户端消息的方法

        //System.out.println("clientSockets size: " + clientSockets.size()); // 检查列表大小

        synchronized (clientSockets) {// 对客户端列表进行同步操作

            Thread tt = new Thread(() -> {// 创建一个线程用于读取并处理客户端消息
               //System.out.println("开始读取客户端发送的消息");


                while (true) {// 无限循环持续读取消息


                    InputStream is;// 定义输入流对象用于读取客户端消息
                    Socket socket = null;


                    try {
                        Thread.sleep(50);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }


                    for (Socket cSocket : clientSockets) {// 遍历每个客户端 Socket
                       //System.out.println("循环每个socket");
                           socket = cSocket;


                           if(socket == null){
                               continue;
                           }


                           try {

                               is = socket.getInputStream();// 获取客户端 Socket 对象的输入流
                           } catch (IOException e) {
                               throw new RuntimeException(e);
                           }



                        try {

                            int idLen = is.read();// 读取消息中发送方名称长度的字节


                            if(idLen == 0){
                                continue;
                            }


                            byte[] id = new byte[idLen];// 根据读取的长度创建字节数组存储发送方名称
                            is.read(id);// 读取发送方名称字节数组


                            int msgLen = is.read();// 读取消息内容长度的字节

                            if(msgLen == 0){
                                continue;
                            }

                            byte[] msg = new byte[msgLen];// 根据读取的长度创建字节数组存储消息内容
                            is.read(msg);// 读取消息内容字节数组


                            System.out.println(new String(id) + "发送的消息:" + new String(msg));// 将字节数组转换为字符串并输出消息内容
                            msgShow.append(new String(id) + "说:" + new String(msg) + "\n");


                            // 转发信息给所有其他客户端
                            for (Socket clientSocket : clientSockets) {// 遍历所有已连接的客户端 Socket 对象

                                if (clientSocket == socket) {// 如果是当前发送消息的客户端
                                    continue;
                                }

                                    OutputStream os = null;// 定义输出流对象用于向其他客户端发送消息
                                    os = clientSocket.getOutputStream();// 获取客户端 Socket 对象的输出流
                                    os.write(id.length);// 发送发送方名称长度
                                    os.write(id);// 发送发送方名称字节数组
                                    os.write(msg.length);// 发送消息内容长度
                                    os.write(msg);// 发送消息内容字节数组
                                    os.flush();// 刷新输出流确保数据发送完成

                            }
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }

                    }

                }

            });
            tt.start();

        }
    }

 readMsg 方法用于读取客户端发送的消息。创建一个线程,持续读取每个客户端的输入流。读取消息时,先读取发送方名称长度、名称字节数组,再读取消息内容长度、内容字节数组,将其转换为字符串并显示在消息区域。然后将消息转发给所有其他客户端。

public void start() {// 启动服务器的方法

        initServer();// 调用初始化服务器的方法

        //new Thread(()->{
        //startSend();// 启动服务端从控制台向所有客户端发送消息的线程
        //}).start();

        ChatUI ui = new ChatUI("服务端", clientSockets);
        ui.setVisible(true); // 确保 UI 可见
        listenerConnection();// 调用监听客户端连接的方法

        readMsg(clientSockets,ui.msgShow);// 调用读取消息的方法

    }

start 方法初始化服务器,创建服务端界面,启动监听客户端连接和读取消息的功能。

客户端

public class Client {
    Socket socket;// 定义 Socket 对象用于与服务器建立连接
    String ip;// 定义服务器 IP 地址
    int port;// 定义服务器端口号
    InputStream in;// 定义输入流对象用于读取服务器发送的消息
    OutputStream out;// 定义输出流对象用于向服务器发送消息

    public Client(String ip, int port) {// 构造方法,初始化客户端 IP 地址和端口号
        this.ip = ip;
        this.port = port;
    }
}

 定义了 Client 类,包含 Socket 对象用于连接服务器,以及服务器的 IP 地址和端口号。构造方法用于初始化客户端 IP 地址和端口号。

public void connectServer(String userName) {// 连接服务器的方法
        try {


            socket = new Socket(ip, port);// 创建 Socket 对象连接到指定 IP

            in = socket.getInputStream();// 获取 Socket 对象的输入流用于读取消息
            out = socket.getOutputStream();// 获取 Socket 对象的输出流用于发送消息


            try {
                out.write(userName.length());
                out.write(userName.getBytes());
                out.flush();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }


            System.out.println("连接服务器成功");


        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

connectServer 方法用于连接服务器。创建 Socket 对象连接到指定 IP 地址和端口号的服务器,获取输入流和输出流。然后向服务器发送用户名长度和用户名字节数组,完成连接。

public void readMsg(JTextArea msgShow) {// 读取服务器发送的消息的方法

        new Thread(() -> {// 创建一个线程用于读取并处理服务器消息
            try {
                System.out.println("开始读取消息");

                while (true) { // 无限循环持续读取消息


                    int senderNameLength = in.read();// 读取发送方名称长度的字节
                    byte[] senderNameBytes = new byte[senderNameLength];// 根据读取的长度创建字节数组存储发送方名称
                    in.read(senderNameBytes);// 读取发送方名称字节数组


                    int msgLength = in.read();// 读取消息内容长度的字节
                    byte[] msgBytes = new byte[msgLength];// 根据读取的长度创建字节数组存储消息内容
                    in.read(msgBytes);// 读取消息内容字节数组


                    System.out.println(new String(senderNameBytes) + "发送的消息:" + new String(msgBytes));// 将字节数组转换为字符串并输出消息内容
                    msgShow.append(new String(senderNameBytes) +"说:" + new String(msgBytes) + "\n");
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }).start();
    }

 readMsg 方法用于读取服务器发送的消息。创建一个线程,读取发送方名称长度、名称字节数组,消息内容长度、内容字节数组,将其转换为字符串并显示在消息区域。

public void startClient() {// 启动客户端的方法

        String userName = JOptionPane.showInputDialog("请输入用户名:");

        connectServer(userName);// 调用连接服务器的方法
        ChatUI ui = new ChatUI(userName, out);
        readMsg(ui.msgShow);// 调用读取消息的方法
        //startSend();// 调用发送消息的方法

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }


        new Thread() {
            public void run() {
                while (true) {

                    try {

                        out.write(0);
                        out.flush();
                        Thread.sleep(500);

                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                }
            }

        }.start();

    }

startClient 方法启动客户端。通过对话框获取用户名,连接服务器,创建客户端界面,启动读取消息的功能。同时创建一个线程,定期向服务器发送心跳包(0),保持连接。

图形界面

服务端界面

public ChatUI(String title, List<Socket> clientSockets) {// 服务器端构造方法
        super(title);// 设置窗口标题
        setSize(500, 500);// 设置窗口大小
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);// 设置关闭操作

        JScrollPane scrollPane = new JScrollPane(msgShow);// 创建滚动面板包括消息显示区域
        scrollPane.setPreferredSize(new Dimension(0, 350));
        add(scrollPane, BorderLayout.NORTH);// 添加到窗口北部

        // 创建消息输入面板及组件
        JPanel msgInput = new JPanel();
        JTextArea msg = new JTextArea();
        JScrollPane scrollPane1 = new JScrollPane(msg);
        scrollPane1.setPreferredSize(new Dimension(480, 80));
        msgInput.add(scrollPane1);
        JButton send = new JButton("发送");
        msgInput.add(send);
        msgInput.setPreferredSize(new Dimension(0, 120));
        add(msgInput, BorderLayout.SOUTH);// 添加到窗口南部

        setVisible(true);

        ChatListener cl = new ChatListener();// 创建事件监听器
        send.addActionListener(cl);// 为发送按钮添加监听器

        cl.showMsg = msgShow;// 传递消息显示组件
        cl.msgInput = msg;
        cl.userName = title;
        cl.clientSockets = clientSockets;

    }

 服务端界面包含显示消息的文本区域和消息输入面板。文本区域用于展示聊天记录,输入面板包含一个文本区域用于输入消息,一个发送按钮用于发送消息。为发送按钮添加事件监听器,当点击按钮时,触发发送消息的操作。

客户端界面

public ChatUI(String title, OutputStream out) {// 客户端构造方法
        super(title);
        setSize(500, 500);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        JScrollPane scrollPane = new JScrollPane(msgShow);
        scrollPane.setPreferredSize(new Dimension(0, 350));
        add(scrollPane, BorderLayout.NORTH);

        JPanel msgInput = new JPanel();
        JTextArea msg = new JTextArea();
        JScrollPane scrollPane1 = new JScrollPane(msg);
        scrollPane1.setPreferredSize(new Dimension(480, 80));
        msgInput.add(scrollPane1);
        JButton send = new JButton("发送");
        msgInput.add(send);
        msgInput.setPreferredSize(new Dimension(0, 120));
        add(msgInput, BorderLayout.SOUTH);

        setVisible(true);

        clientListener cl = new clientListener();
        send.addActionListener(cl);

        cl.showMsg = msgShow;
        cl.msgInput = msg;
        cl.userName = title;
        cl.out = out;

    }

客户端界面与服务端界面结构类似,用于展示聊天记录和输入消息。为发送按钮添加客户端事件监听器,当点击按钮时,将消息发送给服务器。

事件监听器

服务端事件监听器

public class ChatListener implements ActionListener {
    public List<Socket> clientSockets;// 客户端 Socket 列表
    JTextArea showMsg;// 消息显示区域
    JTextArea msgInput;// 消息输入区域
    String userName;// 用户名
    OutputStream out;// 输出流

    public void actionPerformed(ActionEvent e) {// 处理发送按钮点击事件
        String text = msgInput.getText();// 获取输入的消息文本

        showMsg.append(userName + ": " + text + "\n");// 在显示区域追加消息

        for (Socket cSocket : clientSockets) {// 遍历所有客户端
            Socket socket = cSocket;
            try {
                out = socket.getOutputStream();// 获取客户端输出流
                out.write(userName.getBytes().length);// 发送用户名长度
                out.write(userName.getBytes());// 发送用户名
                out.write(text.getBytes().length);// 发送消息内容长度
                out.write(text.getBytes());// 发送消息内容
                out.flush();// 刷新输出流
            } catch (IOException ex) {
                throw new RuntimeException(ex);
            }
        }

    }

}

 服务端事件监听器实现了 ActionListener 接口。当点击发送按钮时,获取输入的消息文本,将其添加到显示区域。然后遍历所有客户端 Socket,获取每个客户端的输出流,发送用户名长度、用户名、消息内容长度、消息内容给每个客户端。

客户端事件监听器

public class clientListener implements ActionListener {
    JTextArea showMsg;// 消息显示区域
    JTextArea msgInput;// 消息输入区域
    String userName;// 用户名
    OutputStream out;// 输出流

    public void actionPerformed(ActionEvent e) {// 处理发送按钮点击
        String text = msgInput.getText();// 获取输入消息

        showMsg.append(userName + ": " + text + "\n");// 显示消息

        try {
            out.write(userName.getBytes().length);// 发送用户名长度
            out.write(userName.getBytes());// 发送用户名
            out.write(text.getBytes().length);// 发送消息长度
            out.write(text.getBytes());// 发送消息内容
            out.flush();// 刷新输出流
            //msgInput.setText(""); // 清空输入框
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }


    }
}

客户端事件监听器实现了 ActionListener 接口。当点击发送按钮时,获取输入的消息文本,将其添加到显示区域。然后通过客户端的输出流向服务器发送用户名长度、用户名、消息内容长度、消息内容。

 

运行效果

二、NIO 模型

聊天服务器

public class NIOChatServer {

    private static final int PORT = 8080;
    private static final Charset charset = Charset.forName("UTF-8");
    private static Set<SocketChannel> clients = new HashSet<>();

    public static void main(String[] args) throws IOException {

        Selector selector = null;
        try {

            selector = Selector.open();
            ServerSocketChannel serverChannel = ServerSocketChannel.open();
            serverChannel.bind(new InetSocketAddress(PORT));
            serverChannel.configureBlocking(false);
            serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }


        System.out.println("Server started on port " + PORT);

        while (true){

            try {
                selector.select();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();

            while (keyIterator.hasNext()){

                SelectionKey key = keyIterator.next();
                keyIterator.remove();

                if (key.isAcceptable()){

                    handleAccept(key,selector);

                }else if (key.isReadable()){

                    handleRead(key);

                }
            }
        }
    }
}

在服务器端代码中,首先创建一个选择器(Selector), 它是 NIO 中用于监听多个通道事件的核心组件。然后打开一个服务器套接字通道(ServerSocketChannel),绑定到指定端口(8080),并将其设置为非阻塞模式。接着将通道注册到选择器上,监听连接接受事件(SelectionKey.OP_ACCEPT)。启动一个无限循环,调用选择器的 select() 方法,该方法会阻塞直到有通道事件发生,然后迭代处理每个事件。

private static void handleAccept(SelectionKey key, Selector selector) throws IOException{

        ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
        SocketChannel clientChannel = serverChannel.accept();
        clientChannel.configureBlocking(false);
        clientChannel.register(selector, SelectionKey.OP_READ);
        clients.add(clientChannel);
        System.out.println("New Client connected to " + clientChannel.getRemoteAddress());

    }

当检测到客户端连接事件时,服务器通道(ServerSocketChannel)调用 accept() 方法接受新连接,返回新的客户端套接字通道(SocketChannel)。将客户端通道设置为非阻塞模式,并注册到选择器上,监听读事件(SelectionKey.OP_READ),以便后续接收该客户端的消息。同时将客户端通道添加到 clients 集合中,用于后续广播消息。

private static void handleRead(SelectionKey key) throws IOException {

        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        int bytesRead = 0;
        try {

            bytesRead = clientChannel.read(buffer);
            if (bytesRead == -1){
                disconnectClient(clientChannel);
                return;
            }

            buffer.flip();
            String message = charset.decode(buffer).toString();
            System.out.println("Received: " + message);

            broadcastMessage(message,clientChannel);

        } catch (IOException e) {
            disconnectClient(clientChannel);
        }
    }

 当检测到读事件时,表示客户端有数据可读。创建一个缓冲区(ByteBuffer)用于存储从客户端读取的数据。调用客户端通道的 read() 方法将数据读取到缓冲区。如果返回值为 -1,表示客户端断开连接,调用 disconnectClient() 方法处理断开事件。否则,将缓冲区翻转(flip),解码缓冲区中的字节数据为字符串,并调用 broadcastMessage() 方法将消息广播给其他客户端。

private static void broadcastMessage(String message, SocketChannel sender) throws IOException {

        ByteBuffer buffer = charset.encode(message);
        for (SocketChannel client : clients) {
            if(client != sender && client.isConnected()){
                client.write(buffer);
                buffer.rewind();
            }
        }
    }

将消息字符串编码为字节缓冲区,然后遍历所有客户端通道。对于每个客户端通道(除了发送消息的客户端),如果通道处于连接状态,就将缓冲区中的数据写入通道,实现消息的广播。写完后调用 rewind() 方法重置缓冲区的位置,以便下次写操作从头开始。

private static void disconnectClient(SocketChannel client) throws IOException {

        clients.remove(client);
        System.out.println("Client disconnected from " + client.getRemoteAddress());
        client.close();
    }

 从 clients 集合中移除断开连接的客户端通道,关闭该通道,并打印断开连接的日志。

聊天客户端

public class BlockingChatClient {
    public static void main(String[] args) {
        Socket socket = null;
        try {
            socket = new Socket("localhost", 8080);
            System.out.println("Connected");

            // 创建输入输出流
            BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));

            // 启动一个线程来读取服务器发送的消息
            new Thread(() -> {
                String message;
                try {
                    while ((message = reader.readLine()) != null) {
                        System.out.println("[Server] " + message);
                    }
                } catch (IOException e) {
                    System.out.println("Disconnected from server");
                }
            }).start();

            // 从控制台读取用户输入并发送到服务器
            BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in));
            String input;
            while ((input = consoleReader.readLine()) != null) {
                writer.write(input);
                writer.newLine();
                writer.flush();

                if ("/exit".equalsIgnoreCase(input)) {
                    break;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (socket != null) {
                    socket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

客户端代码使用传统的阻塞式 I/O。启动时连接到服务器,建立 Socket 连接。然后获取输入流和输出流。启动一个线程读取服务器发送的消息并打印到控制台。在主线程中,从控制台读取用户输入,发送到服务器。当输入 "/exit" 时,退出客户端。最后确保关闭 Socket 连接。 

运行效果

三、两种模型对比

I/O模型

  • 资源消耗大 :每个客户端都需要一个独立线程,大量客户端连接会导致线程数量剧增,增加系统资源消耗和线程切换开销。

  • 扩展性差 :线程数量受限于系统资源,难以处理高并发场景。

NIO 模型

  • 高并发支持 :一个线程可管理多个客户端连接,显著降低资源消耗,提升系统并发能力。

  • 高效复用 :通过选择器复用线程,减少线程创建和销毁的开销。

对比方面 传统阻塞式 I/O NIO 模型
线程模型 一客户端一线程,资源占用大,线程切换频繁 一线程多客户端,资源占用小,线程切换少
并发能力 并发能力受限于线程数量 支持高并发,可处理大量客户端连接
阻塞处理 依赖线程隔离防止阻塞 通过非阻塞 I/O 和多路复用防止阻塞
适用场景 适合客户端数量较少,对响应时间要求不高的场景 适合高并发场景,如即时通讯、在线游戏等